diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json new file mode 100644 index 00000000..81860b75 --- /dev/null +++ b/.claude-plugin/marketplace.json @@ -0,0 +1,17 @@ +{ + "name": "memwal-plugins", + "owner": { + "name": "Mysten Labs" + }, + "metadata": { + "description": "Official MemWal plugins for Claude Code and Codex." + }, + "plugins": [ + { + "name": "memwal", + "source": "./packages/mcp/plugin", + "description": "Automatic Walrus Memory — proactive recall and durable-fact saving via the MemWal MCP + lifecycle hooks.", + "version": "0.0.5" + } + ] +} diff --git a/.codex-plugin/marketplace.json b/.codex-plugin/marketplace.json new file mode 100644 index 00000000..040e7a6f --- /dev/null +++ b/.codex-plugin/marketplace.json @@ -0,0 +1,19 @@ +{ + "name": "memwal-plugins", + "interface": { + "displayName": "MemWal Plugins" + }, + "plugins": [ + { + "name": "memwal", + "source": { + "source": "local", + "path": "./packages/mcp/plugin" + }, + "policy": { + "installation": "AVAILABLE" + }, + "category": "Productivity" + } + ] +} diff --git a/.cursor-plugin/marketplace.json b/.cursor-plugin/marketplace.json new file mode 100644 index 00000000..155c9aaa --- /dev/null +++ b/.cursor-plugin/marketplace.json @@ -0,0 +1,17 @@ +{ + "name": "memwal-plugins", + "owner": { + "name": "Mysten Labs" + }, + "metadata": { + "description": "Official MemWal plugins for Cursor" + }, + "plugins": [ + { + "name": "memwal", + "source": "./packages/mcp/plugin", + "description": "Automatic Walrus Memory — proactive recall and durable-fact saving via the MemWal MCP + lifecycle hooks.", + "version": "0.0.5" + } + ] +} diff --git a/.gitignore b/.gitignore index 0a7c0a84..d252afbc 100644 --- a/.gitignore +++ b/.gitignore @@ -119,6 +119,8 @@ railway-*.md # Per-developer MCP client config (user paths) .mcp.json +# ...but the marketplace plugin must ship its own MCP config +!packages/mcp/plugin/.mcp.json # Personal/internal planning notes — never include in repo plans/ @@ -132,3 +134,6 @@ plans/ # reviewable life in past PRs. services/server/review/ claudedocs/ + +# Temp File +temp/ diff --git a/apps/app/public/skills/setup b/apps/app/public/skills/setup index 9ea9d19d..31a36f45 100644 --- a/apps/app/public/skills/setup +++ b/apps/app/public/skills/setup @@ -22,17 +22,24 @@ user explicitly asks for developer integration. ## Setup Rules -1. First decide whether you have local shell and filesystem access. -2. If you can edit local user config files, do the setup yourself after showing +1. Identify the AI client first. ChatGPT (or another client that cannot run a + local MCP command but supports custom connector headers) uses the Remote + MCP section below. Every other client (Claude Desktop, Claude Code, Cursor, + Codex, other local apps) uses the Local MCP Server section. +2. Decide whether you have local shell and filesystem access. +3. If you can edit local user config files, do the setup yourself after showing the config you will add. -3. If you cannot edit local files, give the user exact commands/config to paste - and wait for their result. -4. Preserve existing MCP servers. Merge the `memwal` server; do not replace the +4. If you cannot edit local files yourself, prefer giving the user ONE terminal + command to paste (for example a `claude mcp add ...` or login command) over + raw JSON/TOML to hand-edit. Fall back to a config block only when no command + exists for that client; then state the exact file path and merge the + `memwal` entry, never replace the whole file. +5. Preserve existing MCP servers. Merge the `memwal` server; do not replace the whole config. -5. Never print or expose values from `~/.memwal/credentials.json`. -6. Tell the user to fully quit and reopen the AI client after config changes. -7. Stop at the first real blocker and report the exact failure. -8. Keep the final status short. Do not repeat full config blocks or terminal +6. Never print or expose values from `~/.memwal/credentials.json`. +7. Tell the user to fully quit and reopen the AI client after config changes. +8. Stop at the first real blocker and report the exact failure. +9. Keep the final status short. Do not repeat full config blocks or terminal commands after setup succeeds unless the user must copy/paste them manually. ## Requirements @@ -45,6 +52,8 @@ node -v Walrus Memory MCP requires Node.js 20 or newer. If Node is missing or older than 20, ask the user to install Node.js 20+ from https://nodejs.org/ and stop. +The Remote MCP path needs Node.js only once, for the login step on the user's +computer. The user signs in through the browser with Google/zkLogin or a Sui-compatible wallet. The sign-in flow writes persistent credentials to: @@ -117,6 +126,9 @@ Edit `~/.cursor/mcp.json` and merge: ### Claude Desktop +Use the config file below. Claude Desktop's Settings > Connectors UI only +supports OAuth remote servers and does not work for Walrus Memory. + Edit: - macOS: `~/Library/Application Support/Claude/claude_desktop_config.json` @@ -157,7 +169,17 @@ cwd = "/Users/YOUR_USERNAME" ### Claude Code -Run: +Preferred: install the Walrus Memory plugin. It bundles the same MCP server +plus lifecycle hooks that make memory automatic. Run inside Claude Code: + +```text +/plugin marketplace add MystenLabs/MemWal +/plugin install memwal@memwal-plugins +``` + +Then restart Claude Code. + +MCP-only alternative: ```bash claude mcp add --scope user memwal -- npx -y @mysten-incubation/memwal-mcp @@ -185,7 +207,11 @@ seconds while `npx` fetches the package. ## Verify Tools -Ask the AI client: +Fastest check: ask the client to call `memwal_health`. A healthy reply proves +the server is reachable. If health succeeds but memory tools return 401, +credentials are missing; run the login. + +Then ask the AI client: ```text What MCP tools do you have available? @@ -194,9 +220,11 @@ What MCP tools do you have available? Expected Walrus Memory tools: - `memwal_remember` +- `memwal_remember_bulk` - `memwal_recall` - `memwal_analyze` - `memwal_restore` +- `memwal_health` - `memwal_login` - `memwal_logout` @@ -224,11 +252,23 @@ Use memwal_recall to search for: "setup verification succeeded" If the tools support a `namespace` argument, use a setup-only namespace such as `setup-verification`. -## ChatGPT and Remote MCP +## Remote MCP (ChatGPT and other header-capable clients) + +Some clients connect to a remote MCP server URL instead of running a local +command. Use this path only when the client supports custom request headers. +ChatGPT (developer mode) supports this today. -ChatGPT custom MCP apps use remote MCP servers, not a local `npx` stdio config. +Prerequisite: credentials must already exist. Have the user run the login once +on their computer (see Recommended Login). The Remote MCP path needs Node.js +only for that one step. -Use remote MCP only when the client supports custom headers: +For ChatGPT: + +1. Settings > Connectors > enable Developer mode. +2. Add a connector with URL `https://relayer.memory.walrus.xyz/api/mcp`. +3. Add the two headers shown in the template below. + +For config-file clients that support remote servers with headers, merge: ```json { @@ -244,11 +284,21 @@ Use remote MCP only when the client supports custom headers: } ``` -The values come from `~/.memwal/credentials.json` after login. Treat the bearer -token like an API key. Do not save it in a repo file or print it in chat. +To fill the placeholders, have the USER open the credentials file themselves: -If the client cannot attach required headers, explain the blocker and suggest a -local MCP client such as Cursor, Claude Desktop, Claude Code, or Codex. +```bash +cat ~/.memwal/credentials.json +``` + +`delegatePrivateKey` is the Bearer token; `accountId` is the +`x-memwal-account-id` value. The user pastes them directly into the connector +settings, never into the chat. You (the agent) must not read or print this +file. Treat the bearer token like an API key and never save it in a repo file. + +If the client cannot attach custom headers, remote MCP is not available for it +today. Tell the user which clients are supported (ChatGPT developer mode, +Claude Desktop, Claude Code, Cursor, Codex) and offer to set one of those up +instead. Do not re-run this skill for the same client. ## Troubleshooting @@ -259,6 +309,7 @@ local MCP client such as Cursor, Claude Desktop, Claude Code, or Codex. | No Walrus Memory tools after restart | Check the MCP config path and fully restart the client. | | Only `memwal_login` works | Credentials are missing. Run `memwal_login` or `npx -y @mysten-incubation/memwal-mcp login --prod`. | | Memory tools return 401 | The delegate key may be stale or revoked. Run `npx -y @mysten-incubation/memwal-mcp login --prod` again. | +| `memwal_recall` returns nothing although memories were saved before | Run `memwal_restore ` to rebuild the search index from Walrus, then retry the recall. | | Sign out | Run `npx -y @mysten-incubation/memwal-mcp --logout`. This removes local credentials but does not revoke the delegate key. | ## Final Report @@ -277,12 +328,19 @@ window is not enough. Login: succeeded. Config: . After reopening, ask: "What MCP tools do you have available?" + +Memory is automatic from here: the assistant saves durable facts and recalls +them as you chat. There is no sync schedule to configure. Try: +- "What do you remember about me?" +- "Remember that I prefer short answers." ``` Rules for the final response: - Put the restart instruction before verification details. -- Keep it under 8 lines if there is no blocker. +- Keep it under 14 lines if there is no blocker. +- Offer at most 3 starter prompts. +- Never state storage sizes or explorer links that the tools did not return. - Do not include the full TOML/JSON config again after it has already been applied. - Do not include long verification prompts before the restart. After restart, diff --git a/apps/status/.dockerignore b/apps/status/.dockerignore new file mode 100644 index 00000000..f16739ae --- /dev/null +++ b/apps/status/.dockerignore @@ -0,0 +1,4 @@ +node_modules +dist +.env.local +.DS_Store diff --git a/apps/status/.env.example b/apps/status/.env.example new file mode 100644 index 00000000..940de84d --- /dev/null +++ b/apps/status/.env.example @@ -0,0 +1,24 @@ +# Walrus Memory Status service + +# Runtime targets probed by the Node service. Browser clients call same-origin +# /api/status, so this does not require relayer CORS changes. +STATUS_RELAYER_PRODUCTION_URL=https://relayer.memory.walrus.xyz +STATUS_RELAYER_STAGING_URL=https://relayer-staging.memory.walrus.xyz +# Backward-compatible fallback used only if STATUS_RELAYER_PRODUCTION_URL is unset. +# STATUS_RELAYER_URL=https://relayer.memory.walrus.xyz +STATUS_HEALTH_PATH=/health +STATUS_REQUEST_TIMEOUT_MS=8000 +STATUS_POLL_INTERVAL_MS=60000 +STATUS_HISTORY_DAYS=90 + +# Optional Postgres history storage. Without this, the service still serves +# live checks but cannot retain historical uptime. +DATABASE_URL=postgres://memwal_status:memwal_status_secret@127.0.0.1:5433/memwal_status + +# Optional links rendered on the page. +STATUS_SUI_STATUS_URL=https://status.sui.io/ +STATUS_WALRUS_URL=https://www.walrus.xyz/ + +# Optional admin API key for incident management endpoints. +# If set, POST / PATCH / DELETE /api/incidents* require X-Admin-API-Key header. +STATUS_ADMIN_API_KEY= diff --git a/apps/status/.gitignore b/apps/status/.gitignore new file mode 100644 index 00000000..d97f3f9c --- /dev/null +++ b/apps/status/.gitignore @@ -0,0 +1,3 @@ +dist +node_modules +.env.local diff --git a/apps/status/Dockerfile b/apps/status/Dockerfile new file mode 100644 index 00000000..b6c04299 --- /dev/null +++ b/apps/status/Dockerfile @@ -0,0 +1,39 @@ +# ============================================================ +# Walrus Memory Status — standalone status service +# Build context: repo root (Railway Root Directory = /) +# ============================================================ + +FROM node:22-alpine AS builder + +RUN corepack enable && corepack prepare pnpm@9.12.3 --activate + +WORKDIR /app + +COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./ +COPY apps/status/package.json ./apps/status/ + +RUN pnpm install --frozen-lockfile + +COPY apps/status/ ./apps/status/ + +RUN pnpm --filter @memwal/status build + +FROM node:22-alpine AS runtime + +WORKDIR /app/apps/status + +COPY --from=builder /app/node_modules /app/node_modules +COPY --from=builder /app/apps/status/node_modules ./node_modules +COPY --from=builder /app/apps/status/dist ./dist +COPY --from=builder /app/apps/status/package.json ./package.json +COPY --from=builder /app/apps/status/server.mjs ./server.mjs + +ENV NODE_ENV=production +ENV PORT=3000 +ENV STATUS_RELAYER_PRODUCTION_URL=https://relayer.memory.walrus.xyz +ENV STATUS_RELAYER_STAGING_URL=https://relayer-staging.memory.walrus.xyz +ENV STATUS_POLL_INTERVAL_MS=60000 +ENV STATUS_HISTORY_DAYS=90 +EXPOSE 3000 + +CMD ["node", "server.mjs"] diff --git a/apps/status/HANDOFF.md b/apps/status/HANDOFF.md new file mode 100644 index 00000000..9ab0cea2 --- /dev/null +++ b/apps/status/HANDOFF.md @@ -0,0 +1,126 @@ +# WALM-99 — Status Page — Handoff + +## Linear issue +- **ID / Title:** WALM-99 — "[Monitoring] - status page" +- **Status:** In Progress (started 2026-06-08), No priority +- **Assignee:** Max Mai · **Reporter:** Henry Nguyen +- **Project / Team:** Walrus Memory +- **Reference design:** https://status.sui.io/ (Sui's Statuspage — the page should look/behave like this) +- **Linear git branch field:** `maxmai/walm-99-montoring-status-page` +- **Actual working branch:** `codex/walm-99-status-page` (NOT yet merged to `dev`) + +## Branch state +7 commits ahead of `origin/dev`, all additive, all under `apps/status/` (+`package.json`, `pnpm-lock.yaml`): +``` +d440f03 Match Statuspage incident typography +4d91f25 Match Statuspage routes and feeds +d3268d6 Add status history tabs +3418c58 Merge origin/dev into branch +44648f3 Add status history storage +a67314b Polish status page layout +86c528d Add standalone status service +``` +~2,600 lines added. Validated: `pnpm --filter @memwal/status build` passes. + +--- + +## What EXISTS today (done) + +A **standalone status service** at `apps/status/` — its own app, separate from the rest of the monorepo. + +### Backend — `server.mjs` (Node HTTP, no framework, ~800 lines) +- Serves the built Vite SPA from `dist/` with correct cache headers + SPA fallback + path-traversal guards. +- **Server-side health probe** of the relayer (`probeRelayer`): GETs `STATUS_RELAYER_URL` + `/health` with an 8s timeout. Maps result → `operational` (HTTP ok + body `status:"ok"`), `degraded` (ok but body not `ok`), or `outage` (non-ok / timeout / error). Done server-side so the relayer needs no CORS changes. +- **Background polling** (`startPolling`): when a DB is configured, polls every `STATUS_POLL_INTERVAL_MS` (default 60s) and stores each check. +- **Postgres history storage** (`postgres` lib): auto-creates `status_checks` table + index on boot. Reads back daily-bucketed uptime over `STATUS_HISTORY_DAYS` (default 90). Computes uptime % (operational+degraded counted as "up"). +- **Graceful degradation:** if no `DATABASE_URL` or Postgres is down, falls back to live checks so the page + Railway healthcheck still work. DB errors are surfaced, not fatal. +- **Endpoints:** + - `GET /health` — service liveness + DB state (for Railway healthcheck) + - `GET /api/status` — full snapshot: relayer status, 90-day history buckets, uptime %, DB state, plus hardcoded `dependencies` (Sui network, Walrus storage) marked `monitoring` + - `GET /history.atom` and `GET /history.rss` — Atom/RSS feeds derived from outage/degraded buckets +- Graceful SIGINT/SIGTERM shutdown; closes DB pool. + +### `status_checks` table schema (automated checks only) +``` +id, checked_at, target, status('operational'|'degraded'|'outage'), +http_status, latency_ms, error, payload(jsonb), created_at +``` + +### Frontend — `src/App.tsx` (React 19 + Vite, ~730 lines) + `src/index.css` (~770 lines) +- Statuspage-style UI, client-routed (history.pushState) across 3 routes: + - `/` **current** — summary banner ("All Systems Operational"), per-component rows with 90-day uptime bars, "Past Incidents" (last 10 days) + - `/history` **Incidents** tab — past incidents (last 30 days) + - `/uptime` **Uptime** tab — 3-month uptime calendar with component selector + month pager +- Auto-refreshes `/api/status` every 60s. +- "Subscribe to Updates" dropdown linking the Atom/RSS feeds. +- **Component rows are partly derived/synthetic:** "Walrus Memory Relayer" uses real probe history; "SDK Compatibility Metadata", "Memory API Pipeline" are derived from relayer status; "Sui Network" & "Walrus Storage" are static `monitoring` placeholders (no real probing). +- Typography tuned to match Sui reference (28px heading / 20px date / 16px body; mobile no-overflow). + +### Ops / packaging +- `Dockerfile`, `.dockerignore`, `docker-compose.yml` (local Postgres on port 5433) +- `railway.json` — Dockerfile builder, watches `apps/status/**`, 1 replica, restart-on-failure +- `README.md`, `.env.example`, icon asset, full TS/Vite config + +### Key env vars +`STATUS_RELAYER_URL` (default `https://relayer.memory.walrus.xyz`), `STATUS_HEALTH_PATH` (`/health`), `STATUS_REQUEST_TIMEOUT_MS` (8000), `STATUS_POLL_INTERVAL_MS` (60000), `STATUS_HISTORY_DAYS` (90), `DATABASE_URL` / `STATUS_DATABASE_URL` (optional), `STATUS_SUI_STATUS_URL`, `STATUS_WALRUS_URL`, `STATUS_PUBLIC_URL`. + +--- + +## What was COMPLETED in this session + +### 1. Manual incident management ✅ +Implemented full backend + frontend incident management: + +**Database:** +- `incidents` table: `id, identifier, title, status, severity, component, message, started_at, resolved_at, created_at, updated_at` +- `incident_updates` table: `id, incident_id, status, message, created_at` +- Auto-created on boot alongside existing `status_checks` table + +**Admin API (requires `STATUS_ADMIN_API_KEY` header):** +- `GET /api/incidents` — list all incidents +- `POST /api/incidents` — create incident +- `GET /api/incidents/:id` — get incident with updates +- `PATCH /api/incidents/:id` — update incident (resolve, edit fields) +- `POST /api/incidents/:id/updates` — post lifecycle update +- `DELETE /api/incidents/:id` — delete incident + +**Frontend:** +- `/admin` route with full incident admin panel +- Create incident form (title, status, severity, component, message) +- List existing incidents with updates +- Inline update + resolve buttons +- Delete confirmation +- API key stored in `sessionStorage` +- Footer links updated to include Admin, Current Status, Incident History, Uptime + +**Status payload + feeds:** +- `/api/status` now returns `incidents: { active, recent }` +- `IncidentHistory` renders real incidents when available, falls back to synthesized bucket text when empty +- Atom/RSS feeds include real incident entries + +### 2. Real external dependency monitoring +Still static `monitoring` placeholders (out of scope for this session). + +### 3. Deploy — still pending +- Railway service + Postgres provisioning not yet done. + +### 4. Merge — still pending +- Open PR `codex/walm-99-status-page` → `dev`. + +--- + +## Env changes +Added `STATUS_ADMIN_API_KEY` to `.env.example`: +``` +STATUS_ADMIN_API_KEY=your-secret-key +``` + +## Tested +- `pnpm --filter @memwal/status build` passes +- Local server with Postgres: create, update, resolve, delete incidents all working +- `/api/status` correctly surfaces active + recent incidents +- Atom feed includes incident entries + +## Remaining for full completion +1. Deploy to Railway with Postgres + `STATUS_ADMIN_API_KEY` +2. PR and merge to `dev` diff --git a/apps/status/README.md b/apps/status/README.md new file mode 100644 index 00000000..51356381 --- /dev/null +++ b/apps/status/README.md @@ -0,0 +1,47 @@ +# Walrus Memory Status + +Standalone status service for Walrus Memory. + +The browser app calls this service at `/api/status`. The Node server then probes +`STATUS_RELAYER_URL/health` server-side, so the relayer does not need to allow +the status page origin through CORS. + +## Local development + +Start the local Postgres history database: + +```sh +docker compose -f apps/status/docker-compose.yml up -d postgres +``` + +Then run the status service with `DATABASE_URL` set: + +```sh +export DATABASE_URL=postgres://memwal_status:memwal_status_secret@127.0.0.1:5433/memwal_status +pnpm --filter @memwal/status dev +``` + +This builds the client and runs the same Node server used in production. For +client-only Vite work without the probe API, use: + +```sh +pnpm --filter @memwal/status dev:client +``` + +## Runtime env + +- `STATUS_RELAYER_PRODUCTION_URL`: production relayer base URL, default `https://relayer.memory.walrus.xyz` +- `STATUS_RELAYER_STAGING_URL`: staging relayer base URL, default `https://relayer-staging.memory.walrus.xyz` +- `STATUS_RELAYER_URL`: backward-compatible fallback for production if `STATUS_RELAYER_PRODUCTION_URL` is unset +- `STATUS_HEALTH_PATH`: health path, default `/health` +- `STATUS_REQUEST_TIMEOUT_MS`: probe timeout, default `8000` +- `DATABASE_URL`: optional Postgres URL used to store uptime checks +- `STATUS_POLL_INTERVAL_MS`: background probe interval, default `60000` +- `STATUS_HISTORY_DAYS`: number of daily uptime buckets to return, default `90` + +When `DATABASE_URL` is set, the service creates the `status_checks` table on +startup, polls both relayer health endpoints in the background, and stores each +check. `/api/status` returns the latest checks plus daily uptime buckets for each +component. +status page. If Postgres is unavailable, the service falls back to live checks so +Railway health checks can still pass. diff --git a/apps/status/docker-compose.yml b/apps/status/docker-compose.yml new file mode 100644 index 00000000..982e7086 --- /dev/null +++ b/apps/status/docker-compose.yml @@ -0,0 +1,31 @@ +name: memwal-status + +services: + postgres: + image: postgres:17-alpine + container_name: memwal-status-postgres + restart: unless-stopped + environment: + POSTGRES_DB: memwal_status + POSTGRES_USER: memwal_status + POSTGRES_PASSWORD: memwal_status_secret + ports: + - "127.0.0.1:5433:5432" + volumes: + - memwal_status_pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U memwal_status -d memwal_status"] + interval: 5s + timeout: 5s + retries: 5 + deploy: + resources: + limits: + memory: 512m + cpus: "1.0" + reservations: + memory: 128m + cpus: "0.25" + +volumes: + memwal_status_pgdata: diff --git a/apps/status/index.html b/apps/status/index.html new file mode 100644 index 00000000..a1c57bbc --- /dev/null +++ b/apps/status/index.html @@ -0,0 +1,17 @@ + + + + + + + + Walrus Memory Status + + +
+ + + diff --git a/apps/status/package.json b/apps/status/package.json new file mode 100644 index 00000000..1af0c19a --- /dev/null +++ b/apps/status/package.json @@ -0,0 +1,28 @@ +{ + "name": "@memwal/status", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "pnpm run build && node server.mjs", + "dev:client": "vite --host 0.0.0.0", + "build": "tsc -b && vite build", + "preview": "node server.mjs", + "start": "node server.mjs", + "serve:dist": "node server.mjs" + }, + "dependencies": { + "lucide-react": "^0.577.0", + "postgres": "^3.4.8", + "react": "^19.2.0", + "react-dom": "^19.2.0" + }, + "devDependencies": { + "@types/node": "^24.10.1", + "@types/react": "^19.2.7", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^5.1.1", + "typescript": "~5.9.3", + "vite": "^7.3.1" + } +} diff --git a/apps/status/public/memwal-icon.svg b/apps/status/public/memwal-icon.svg new file mode 100644 index 00000000..5c826639 --- /dev/null +++ b/apps/status/public/memwal-icon.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/apps/status/railway.json b/apps/status/railway.json new file mode 100644 index 00000000..2fb86964 --- /dev/null +++ b/apps/status/railway.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://railway.com/railway.schema.json", + "build": { + "builder": "DOCKERFILE", + "dockerfilePath": "apps/status/Dockerfile", + "watchPatterns": [ + "apps/status/**" + ] + }, + "deploy": { + "runtime": "V2", + "numReplicas": 1, + "restartPolicyType": "ON_FAILURE", + "restartPolicyMaxRetries": 10 + } +} diff --git a/apps/status/server.mjs b/apps/status/server.mjs new file mode 100644 index 00000000..bbc85e64 --- /dev/null +++ b/apps/status/server.mjs @@ -0,0 +1,1475 @@ +import { createReadStream } from 'node:fs' +import { stat } from 'node:fs/promises' +import { createServer } from 'node:http' +import { timingSafeEqual } from 'node:crypto' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import postgres from 'postgres' + +const appDir = path.dirname(fileURLToPath(import.meta.url)) +const distDir = path.resolve(appDir, 'dist') +const distPrefix = `${distDir}${path.sep}` +const port = Number(process.env.PORT || 3000) +const host = process.env.HOST || '0.0.0.0' + +const DEFAULT_RELAYER_PRODUCTION_URL = 'https://relayer.memory.walrus.xyz' +const DEFAULT_RELAYER_STAGING_URL = 'https://relayer-staging.memory.walrus.xyz' +const DEFAULT_HEALTH_PATH = '/health' +const DEFAULT_TIMEOUT_MS = 8000 +const DEFAULT_POLL_INTERVAL_MS = 60_000 +const DEFAULT_HISTORY_DAYS = 90 +const HISTORY_TARGET = 'relayer' + +function getRelayerComponents() { + return [ + { + id: 'relayer-production', + name: 'Walrus Memory Relayer production (mainnet)', + url: ( + process.env.STATUS_RELAYER_PRODUCTION_URL || + process.env.STATUS_RELAYER_URL || + DEFAULT_RELAYER_PRODUCTION_URL + ).trim(), + }, + { + id: 'relayer-staging', + name: 'Walrus Memory Relayer staging (testnet)', + url: (process.env.STATUS_RELAYER_STAGING_URL || DEFAULT_RELAYER_STAGING_URL).trim(), + }, + ] +} + +let sql = null +let databaseReady = false +let databaseError = null +let databaseInitPromise = null +let pollInFlight = null +let pollTimer = null + +const contentTypes = new Map([ + ['', 'text/plain; charset=utf-8'], + ['.css', 'text/css; charset=utf-8'], + ['.gif', 'image/gif'], + ['.html', 'text/html; charset=utf-8'], + ['.ico', 'image/x-icon'], + ['.jpeg', 'image/jpeg'], + ['.jpg', 'image/jpeg'], + ['.js', 'application/javascript; charset=utf-8'], + ['.json', 'application/json; charset=utf-8'], + ['.map', 'application/json; charset=utf-8'], + ['.png', 'image/png'], + ['.svg', 'image/svg+xml; charset=utf-8'], + ['.webmanifest', 'application/manifest+json; charset=utf-8'], + ['.webp', 'image/webp'], +]) + +const fileLikeRequestPattern = /\/[^/?#]+\.[^/?#]+$/ + +function readNumberEnv(name, fallback, minimum, maximum) { + const value = Number(process.env[name] || fallback) + if (!Number.isFinite(value) || value < minimum || value > maximum) return fallback + return Math.round(value) +} + +function getTimeoutMs() { + return readNumberEnv('STATUS_REQUEST_TIMEOUT_MS', DEFAULT_TIMEOUT_MS, 1000, 30000) +} + +function getPollIntervalMs() { + return readNumberEnv('STATUS_POLL_INTERVAL_MS', DEFAULT_POLL_INTERVAL_MS, 10000, 3600000) +} + +function getHistoryDays() { + return readNumberEnv('STATUS_HISTORY_DAYS', DEFAULT_HISTORY_DAYS, 1, 365) +} + +function databaseUrl() { + return (process.env.DATABASE_URL || process.env.STATUS_DATABASE_URL || '').trim() +} + +function adminApiKey() { + return (process.env.STATUS_ADMIN_API_KEY || '').trim() +} + +const ALLOWED_INCIDENT_STATUSES = ['investigating', 'identified', 'monitoring', 'resolved'] +const ALLOWED_SEVERITIES = ['minor', 'major', 'critical'] + +function isValidStatus(value) { + return ALLOWED_INCIDENT_STATUSES.includes(value) +} + +function isValidSeverity(value) { + return ALLOWED_SEVERITIES.includes(value) +} + +function isValidDate(value) { + if (!value) return false + const d = new Date(value) + return !Number.isNaN(d.getTime()) +} + +function isAdminAuthValid(req) { + const key = adminApiKey() + if (!key) return false + const header = req.headers['x-admin-api-key'] || '' + if (header.length !== key.length) return false + try { + return timingSafeEqual(Buffer.from(header), Buffer.from(key)) + } catch { + return false + } +} + +function isDatabaseConfigured() { + return Boolean(databaseUrl()) +} + +function relayerBaseUrl() { + return ( + process.env.STATUS_RELAYER_URL || + process.env.MEMWAL_SERVER_URL || + process.env.VITE_MEMWAL_SERVER_URL || + DEFAULT_RELAYER_PRODUCTION_URL + ).trim() +} + +function resolveHealthUrl(rawBase) { + const base = (rawBase || relayerBaseUrl()).trim() + const healthPath = (process.env.STATUS_HEALTH_PATH || DEFAULT_HEALTH_PATH).trim() || DEFAULT_HEALTH_PATH + const url = new URL(base) + + if (!process.env.STATUS_HEALTH_PATH && /\/health\/?$/.test(url.pathname)) { + url.search = '' + return url.toString() + } + + const basePath = url.pathname.replace(/\/+$/, '') + const nextPath = `${basePath}/${healthPath.replace(/^\/+/, '')}` + url.pathname = nextPath.replace(/\/{2,}/g, '/') + url.search = '' + return url.toString() +} + +function noStoreHeaders(contentType) { + return { + 'Cache-Control': 'no-store', + 'Content-Type': contentType, + 'X-Content-Type-Options': 'nosniff', + } +} + +function sendJson(res, statusCode, body) { + if (statusCode === 204) { + res.writeHead(204) + res.end() + return + } + const payload = JSON.stringify(body) + res.writeHead(statusCode, { + ...noStoreHeaders('application/json; charset=utf-8'), + 'Content-Length': Buffer.byteLength(payload), + }) + res.end(payload) +} + +function sendText(res, statusCode, body, cacheControl = 'no-store') { + res.writeHead(statusCode, { + 'Cache-Control': cacheControl, + 'Content-Type': 'text/plain; charset=utf-8', + 'X-Content-Type-Options': 'nosniff', + }) + res.end(body) +} + +function sendXml(res, statusCode, body, contentType) { + res.writeHead(statusCode, { + ...noStoreHeaders(`${contentType}; charset=utf-8`), + 'Content-Length': Buffer.byteLength(body), + }) + res.end(body) +} + +function cacheControlFor(pathname, servingIndex) { + if (servingIndex) return 'no-store' + if (pathname.startsWith('/assets/')) return 'public, max-age=31536000, immutable' + return 'public, max-age=300, must-revalidate' +} + +function resolveDistPath(pathname) { + const relativePath = pathname.replace(/^\/+/, '') + const resolved = path.resolve(distDir, relativePath) + + if (resolved !== distDir && !resolved.startsWith(distPrefix)) { + return null + } + + return resolved +} + +function isRecord(value) { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function truncateError(message) { + if (!message) return 'Health check failed' + return message.length > 500 ? `${message.slice(0, 500)}...` : message +} + +function escapeXml(value) { + return String(value) + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", ''') +} + +function publicBaseUrl(req) { + const configured = (process.env.STATUS_PUBLIC_URL || '').trim().replace(/\/+$/, '') + if (configured) return configured + + const hostHeader = req.headers['x-forwarded-host'] || req.headers.host || `localhost:${port}` + const firstHost = String(hostHeader).split(',')[0].trim() + const fallbackProto = /^(localhost|127\.0\.0\.1|\[::1\])(?::\d+)?$/.test(firstHost) ? 'http' : 'https' + const proto = req.headers['x-forwarded-proto'] || fallbackProto + const firstProto = String(proto).split(',')[0].trim() + return `${firstProto}://${firstHost}` +} + +function getSql() { + if (!sql) { + sql = postgres(databaseUrl(), { + max: 2, + idle_timeout: 20, + connect_timeout: Math.ceil(getTimeoutMs() / 1000), + }) + } + + return sql +} + +async function initializeDatabase() { + if (!isDatabaseConfigured()) { + databaseReady = false + databaseError = null + return false + } + + try { + const db = getSql() + await db` + CREATE TABLE IF NOT EXISTS status_checks ( + id BIGSERIAL PRIMARY KEY, + checked_at TIMESTAMPTZ NOT NULL, + target TEXT NOT NULL, + status TEXT NOT NULL CHECK (status IN ('operational', 'degraded', 'outage')), + http_status INTEGER, + latency_ms INTEGER, + error TEXT, + payload JSONB, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() + ) + ` + await db` + CREATE INDEX IF NOT EXISTS status_checks_target_checked_at_idx + ON status_checks (target, checked_at DESC) + ` + await db` + CREATE TABLE IF NOT EXISTS incidents ( + id BIGSERIAL PRIMARY KEY, + identifier TEXT UNIQUE, + title TEXT NOT NULL, + status TEXT NOT NULL CHECK (status IN ('investigating', 'identified', 'monitoring', 'resolved')), + severity TEXT NOT NULL CHECK (severity IN ('minor', 'major', 'critical')), + component TEXT, + message TEXT NOT NULL, + started_at TIMESTAMPTZ NOT NULL, + resolved_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() + ) + ` + await db` + CREATE TABLE IF NOT EXISTS incident_updates ( + id BIGSERIAL PRIMARY KEY, + incident_id BIGINT NOT NULL REFERENCES incidents(id) ON DELETE CASCADE, + status TEXT NOT NULL CHECK (status IN ('investigating', 'identified', 'monitoring', 'resolved')), + message TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() + ) + ` + await db` + CREATE INDEX IF NOT EXISTS incident_updates_incident_id_idx + ON incident_updates (incident_id, created_at DESC) + ` + await db` + CREATE INDEX IF NOT EXISTS incidents_status_created_at_idx + ON incidents (status, created_at DESC) + ` + databaseReady = true + databaseError = null + return true + } catch (error) { + databaseReady = false + databaseError = truncateError(error?.message || String(error)) + console.error('[status-service] database initialization failed', error) + return false + } +} + +async function ensureDatabaseReady() { + if (!isDatabaseConfigured()) return false + if (databaseReady) return true + if (!databaseInitPromise) { + databaseInitPromise = initializeDatabase().finally(() => { + databaseInitPromise = null + }) + } + return databaseInitPromise +} + +async function probeRelayer(name, rawBase, target) { + const checkedAt = new Date().toISOString() + const url = resolveHealthUrl(rawBase) + const startedAt = performance.now() + const controller = new AbortController() + const timeoutId = setTimeout(() => controller.abort(), getTimeoutMs()) + + try { + const response = await fetch(url, { + method: 'GET', + headers: { accept: 'application/json' }, + signal: controller.signal, + }) + const latencyMs = Math.max(1, Math.round(performance.now() - startedAt)) + const text = await response.text() + let health = null + + if (text) { + try { + const parsed = JSON.parse(text) + health = isRecord(parsed) ? parsed : null + } catch { + health = null + } + } + + const reportedOk = isRecord(health) && health.status === 'ok' + const status = response.ok ? (reportedOk ? 'operational' : 'degraded') : 'outage' + + return { + id: target, + name, + status, + url, + httpStatus: response.status, + latencyMs, + checkedAt, + health, + error: response.ok ? null : truncateError(text || response.statusText), + } + } catch (error) { + const latencyMs = Math.max(1, Math.round(performance.now() - startedAt)) + const message = error?.name === 'AbortError' + ? `Timed out after ${getTimeoutMs()} ms` + : error?.message || String(error) + + return { + id: target, + name, + status: 'outage', + url, + httpStatus: null, + latencyMs, + checkedAt, + health: null, + error: truncateError(message), + } + } finally { + clearTimeout(timeoutId) + } +} + +async function insertCheck(relayer) { + if (!(await ensureDatabaseReady())) return false + + try { + const db = getSql() + await db` + INSERT INTO status_checks ( + checked_at, + target, + status, + http_status, + latency_ms, + error, + payload + ) + VALUES ( + ${new Date(relayer.checkedAt)}, + ${relayer.id}, + ${relayer.status}, + ${relayer.httpStatus}, + ${relayer.latencyMs}, + ${relayer.error}, + ${JSON.stringify(relayer.health ?? null)}::jsonb + ) + ` + databaseReady = true + databaseError = null + return true + } catch (error) { + databaseReady = false + databaseError = truncateError(error?.message || String(error)) + console.error('[status-service] failed to insert status check', error) + return false + } +} + +async function probeAndStore() { + const components = getRelayerComponents() + const results = await Promise.all( + components.map((c) => probeRelayer(c.name, c.url, c.id)) + ) + for (const relayer of results) { + await insertCheck(relayer) + } + return results +} + +async function runPollCycle() { + if (!pollInFlight) { + pollInFlight = probeAndStore().finally(() => { + pollInFlight = null + }) + } + + return pollInFlight +} + +function rowToRelayer(row) { + const component = getRelayerComponents().find((c) => c.id === row.target) + return { + id: row.target || HISTORY_TARGET, + name: component?.name || 'Walrus Memory Relayer', + status: row.status, + url: component ? resolveHealthUrl(component.url) : resolveHealthUrl(), + httpStatus: row.http_status, + latencyMs: row.latency_ms, + checkedAt: new Date(row.checked_at).toISOString(), + health: normalizePayload(row.payload), + error: row.error, + } +} + +async function readLatestCheck(target) { + if (!(await ensureDatabaseReady())) return null + + const rows = await getSql()` + SELECT target, checked_at, status, http_status, latency_ms, error, payload + FROM status_checks + WHERE target = ${target} + ORDER BY checked_at DESC + LIMIT 1 + ` + + return rows[0] ? rowToRelayer(rows[0]) : null +} + +async function listIncidents(limit = 50, offset = 0) { + if (!(await ensureDatabaseReady())) return [] + + const db = getSql() + const rows = await db` + SELECT id, identifier, title, status, severity, component, message, started_at, resolved_at, created_at, updated_at + FROM incidents + ORDER BY created_at DESC + LIMIT ${limit} OFFSET ${offset} + ` + + if (!rows.length) return [] + + const ids = rows.map((r) => Number(r.id)) + const updateRows = await db` + SELECT id, incident_id, status, message, created_at + FROM incident_updates + WHERE incident_id IN ${db(ids)} + ORDER BY created_at DESC + ` + + const updatesByIncident = new Map() + for (const u of updateRows) { + const incidentId = Number(u.incident_id) + if (!updatesByIncident.has(incidentId)) { + updatesByIncident.set(incidentId, []) + } + updatesByIncident.get(incidentId).push({ + id: Number(u.id), + status: u.status, + message: u.message, + createdAt: new Date(u.created_at).toISOString(), + }) + } + + return rows.map((row) => ({ + id: Number(row.id), + identifier: row.identifier, + title: row.title, + status: row.status, + severity: row.severity, + component: row.component, + message: row.message, + startedAt: new Date(row.started_at).toISOString(), + resolvedAt: row.resolved_at ? new Date(row.resolved_at).toISOString() : null, + createdAt: new Date(row.created_at).toISOString(), + updatedAt: new Date(row.updated_at).toISOString(), + updates: updatesByIncident.get(Number(row.id)) || [], + })) +} + +async function getIncidentWithUpdates(id) { + if (!(await ensureDatabaseReady())) return null + + const incidentRows = await getSql()` + SELECT id, identifier, title, status, severity, component, message, started_at, resolved_at, created_at, updated_at + FROM incidents + WHERE id = ${id} + LIMIT 1 + ` + + if (!incidentRows[0]) return null + + const row = incidentRows[0] + const updates = await getSql()` + SELECT id, status, message, created_at + FROM incident_updates + WHERE incident_id = ${row.id} + ORDER BY created_at DESC + ` + + return { + id: Number(row.id), + identifier: row.identifier, + title: row.title, + status: row.status, + severity: row.severity, + component: row.component, + message: row.message, + startedAt: new Date(row.started_at).toISOString(), + resolvedAt: row.resolved_at ? new Date(row.resolved_at).toISOString() : null, + createdAt: new Date(row.created_at).toISOString(), + updatedAt: new Date(row.updated_at).toISOString(), + updates: updates.map((u) => ({ + id: Number(u.id), + status: u.status, + message: u.message, + createdAt: new Date(u.created_at).toISOString(), + })), + } +} + +async function createIncident(payload) { + if (!(await ensureDatabaseReady())) return null + + const db = getSql() + const id = await db.begin(async (trx) => { + const rows = await trx` + INSERT INTO incidents (title, status, severity, component, message, started_at) + VALUES ( + ${payload.title}, + ${payload.status}, + ${payload.severity}, + ${payload.component || null}, + ${payload.message}, + ${new Date(payload.startedAt || Date.now())} + ) + RETURNING id + ` + + const id = rows[0]?.id + if (!id) return null + + const identifier = `WM-INC-${id}` + await trx`UPDATE incidents SET identifier = ${identifier} WHERE id = ${id}` + + if (payload.updates?.length) { + for (const update of payload.updates) { + await trx` + INSERT INTO incident_updates (incident_id, status, message, created_at) + VALUES (${id}, ${update.status}, ${update.message}, ${new Date(update.createdAt || Date.now())}) + ` + } + } else { + await trx` + INSERT INTO incident_updates (incident_id, status, message) + VALUES (${id}, ${payload.status}, ${payload.message}) + ` + } + + return id + }) + + if (!id) return null + return getIncidentWithUpdates(id) +} + +async function updateIncident(id, payload) { + if (!(await ensureDatabaseReady())) return null + + const existing = await getIncidentWithUpdates(id) + if (!existing) return null + + const db = getSql() + const title = payload.title !== undefined ? payload.title : existing.title + const status = payload.status !== undefined ? payload.status : existing.status + const severity = payload.severity !== undefined ? payload.severity : existing.severity + const component = payload.component !== undefined ? payload.component : existing.component + const message = payload.message !== undefined ? payload.message : existing.message + const startedAt = payload.startedAt !== undefined ? new Date(payload.startedAt) : new Date(existing.startedAt) + let resolvedAt + if (payload.resolvedAt !== undefined) { + resolvedAt = payload.resolvedAt ? new Date(payload.resolvedAt) : null + } else if (status === 'resolved' && existing.status !== 'resolved') { + resolvedAt = new Date() + } else { + resolvedAt = existing.resolvedAt ? new Date(existing.resolvedAt) : null + } + + await db` + UPDATE incidents + SET + title = ${title}, + status = ${status}, + severity = ${severity}, + component = ${component}, + message = ${message}, + started_at = ${startedAt}, + resolved_at = ${resolvedAt}, + updated_at = now() + WHERE id = ${id} + ` + + // If status changed to resolved and no update row was explicitly added, record it + if (payload.status === 'resolved' && existing.status !== 'resolved') { + const updateMessage = payload.message && payload.message !== existing.message + ? payload.message + : 'Incident resolved.' + await db` + INSERT INTO incident_updates (incident_id, status, message) + VALUES (${id}, 'resolved', ${updateMessage}) + ` + } + + return getIncidentWithUpdates(id) +} + +async function addIncidentUpdate(incidentId, payload) { + if (!(await ensureDatabaseReady())) return null + + const db = getSql() + + // Fetch current status to detect resolved transition + const existingRows = await db` + SELECT status FROM incidents WHERE id = ${incidentId} + ` + const existingStatus = existingRows[0]?.status + const transitioningToResolved = payload.status === 'resolved' && existingStatus !== 'resolved' + + await db` + INSERT INTO incident_updates (incident_id, status, message, created_at) + VALUES ( + ${incidentId}, + ${payload.status}, + ${payload.message}, + ${new Date(payload.createdAt || Date.now())} + ) + ` + + if (payload.status) { + if (transitioningToResolved) { + await db` + UPDATE incidents + SET status = ${payload.status}, resolved_at = now(), updated_at = now() + WHERE id = ${incidentId} + ` + } else { + await db` + UPDATE incidents + SET status = ${payload.status}, updated_at = now() + WHERE id = ${incidentId} + ` + } + } + + return getIncidentWithUpdates(incidentId) +} + +async function deleteIncident(id) { + if (!(await ensureDatabaseReady())) return false + const result = await getSql()`DELETE FROM incidents WHERE id = ${id}` + return result.count > 0 +} + +async function readActiveAndRecentIncidents(days = 30) { + if (!(await ensureDatabaseReady())) return { active: [], recent: [] } + + const rows = await getSql()` + SELECT id, identifier, title, status, severity, component, message, started_at, resolved_at, created_at, updated_at + FROM incidents + WHERE status != 'resolved' + OR resolved_at >= now() - (${days}::int * interval '1 day') + OR created_at >= now() - (${days}::int * interval '1 day') + ORDER BY created_at DESC + LIMIT 100 + ` + + const incidents = rows.map((row) => ({ + id: Number(row.id), + identifier: row.identifier, + title: row.title, + status: row.status, + severity: row.severity, + component: row.component, + message: row.message, + startedAt: new Date(row.started_at).toISOString(), + resolvedAt: row.resolved_at ? new Date(row.resolved_at).toISOString() : null, + createdAt: new Date(row.created_at).toISOString(), + updatedAt: new Date(row.updated_at).toISOString(), + updates: [], + })) + + const ids = incidents.map((i) => i.id) + if (ids.length > 0) { + const updateRows = await getSql()` + SELECT id, incident_id, status, message, created_at + FROM incident_updates + WHERE incident_id IN (${ids}) + ORDER BY created_at ASC + ` + const updatesById = new Map() + for (const row of updateRows) { + const id = Number(row.incident_id) + if (!updatesById.has(id)) updatesById.set(id, []) + updatesById.get(id).push({ + id: Number(row.id), + status: row.status, + message: row.message, + createdAt: new Date(row.created_at).toISOString(), + }) + } + for (const incident of incidents) { + incident.updates = updatesById.get(incident.id) ?? [] + } + } + + return { + active: incidents.filter((i) => i.status !== 'resolved'), + recent: incidents.filter((i) => i.status === 'resolved'), + } +} + +function utcDateKey(date) { + return date.toISOString().slice(0, 10) +} + +function addUtcDays(date, days) { + const next = new Date(date) + next.setUTCDate(next.getUTCDate() + days) + return next +} + +function startOfUtcDay(date) { + return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())) +} + +function bucketStatus(bucket) { + if (!bucket.total) return 'unknown' + + const outagePct = bucket.outage / bucket.total + const degradedPct = bucket.degraded / bucket.total + + // Statuspage-style thresholds (60s polling ≈ 1,440 checks/day) + // > 5% outage (~72+ min) → red (significant downtime) + if (outagePct > 0.05) return 'outage' + + // Minor blips: some outage or notable degraded → yellow + if (outagePct > 0 || degradedPct > 0.05) return 'degraded' + + return 'operational' +} + +function normalizePayload(payload) { + if (isRecord(payload)) return payload + if (typeof payload !== 'string' || !payload) return null + + try { + const parsed = JSON.parse(payload) + return isRecord(parsed) ? parsed : null + } catch { + return null + } +} + +function makeEmptyHistory(enabled, reason = null, target = HISTORY_TARGET) { + const days = getHistoryDays() + const today = startOfUtcDay(new Date()) + const firstDay = addUtcDays(today, -(days - 1)) + const buckets = Array.from({ length: days }, (_, index) => ({ + date: utcDateKey(addUtcDays(firstDay, index)), + status: 'unknown', + total: 0, + ok: 0, + degraded: 0, + outage: 0, + })) + + return { + enabled, + source: enabled ? 'postgres' : 'live', + days, + target, + totalChecks: 0, + uptimePct: null, + unavailableReason: reason, + buckets, + } +} + +async function readHistory(target) { + const days = getHistoryDays() + if (!(await ensureDatabaseReady())) { + return makeEmptyHistory(false, databaseError, target) + } + + try { + const rows = await getSql()` + SELECT + (date_trunc('day', checked_at AT TIME ZONE 'UTC'))::date AS day, + count(*)::int AS total, + count(*) FILTER (WHERE status = 'operational')::int AS ok, + count(*) FILTER (WHERE status = 'degraded')::int AS degraded, + count(*) FILTER (WHERE status = 'outage')::int AS outage + FROM status_checks + WHERE target = ${target} + AND checked_at >= now() - (${days}::int * interval '1 day') + GROUP BY 1 + ORDER BY 1 + ` + + const byDay = new Map(rows.map((row) => { + const day = row.day instanceof Date ? utcDateKey(row.day) : String(row.day).slice(0, 10) + return [day, { + total: Number(row.total || 0), + ok: Number(row.ok || 0), + degraded: Number(row.degraded || 0), + outage: Number(row.outage || 0), + }] + })) + + const today = startOfUtcDay(new Date()) + const firstDay = addUtcDays(today, -(days - 1)) + let totalChecks = 0 + let upChecks = 0 + + const buckets = Array.from({ length: days }, (_, index) => { + const date = utcDateKey(addUtcDays(firstDay, index)) + const row = byDay.get(date) ?? { + total: 0, + ok: 0, + degraded: 0, + outage: 0, + } + totalChecks += row.total + upChecks += row.ok + row.degraded + + return { + date, + status: bucketStatus(row), + total: row.total, + ok: row.ok, + degraded: row.degraded, + outage: row.outage, + } + }) + + return { + enabled: true, + source: 'postgres', + days, + target, + totalChecks, + uptimePct: totalChecks > 0 ? Number(((upChecks / totalChecks) * 100).toFixed(2)) : null, + unavailableReason: null, + buckets, + } + } catch (error) { + databaseReady = false + databaseError = truncateError(error?.message || String(error)) + console.error('[status-service] failed to read status history', error) + return makeEmptyHistory(false, databaseError, target) + } +} + + + +async function getRelayerSnapshot() { + const components = getRelayerComponents() + + if (!isDatabaseConfigured()) { + return Promise.all( + components.map((c) => probeRelayer(c.name, c.url, c.id)) + ) + } + + const staleAfterMs = getPollIntervalMs() * 1.5 + let needsPoll = false + + const latestChecks = await Promise.all( + components.map(async (c) => { + const latest = await readLatestCheck(c.id) + const latestAgeMs = latest ? Date.now() - Date.parse(latest.checkedAt) : Number.POSITIVE_INFINITY + if (!latest || latestAgeMs > staleAfterMs) { + needsPoll = true + } + return { component: c, latest } + }) + ) + + if (needsPoll) { + await runPollCycle() + for (const item of latestChecks) { + item.latest = await readLatestCheck(item.component.id) + } + } + + return latestChecks.map((item) => item.latest) +} + +async function statusPayload() { + const components = await getRelayerSnapshot() + const histories = {} + for (const component of components) { + histories[component.id] = await readHistory(component.id) + } + const incidents = await readActiveAndRecentIncidents() + + const serviceStatus = components.some((c) => c.status === 'outage') + ? 'outage' + : components.some((c) => c.status === 'degraded') + ? 'degraded' + : 'operational' + + const historyEnabled = Object.values(histories).some((h) => h.enabled) + + return { + generatedAt: new Date().toISOString(), + service: { + name: 'Walrus Memory Status', + status: serviceStatus, + runtime: 'node', + historyEnabled, + }, + components, + histories, + incidents, + database: { + configured: isDatabaseConfigured(), + ready: databaseReady, + error: databaseError, + }, + dependencies: [ + { + name: 'Sui network', + status: 'monitoring', + url: process.env.STATUS_SUI_STATUS_URL || 'https://status.sui.io/', + }, + { + name: 'Walrus storage', + status: 'monitoring', + url: process.env.STATUS_WALRUS_URL || 'https://www.walrus.xyz/', + }, + ], + } +} + +function feedEntries(snapshot, baseUrl) { + const entries = [] + const components = snapshot.components ?? [] + const production = components.find((c) => c.id === 'relayer-production') || components[0] + const worstComponent = components.find((c) => c.status === 'outage') || components.find((c) => c.status === 'degraded') || production + + const activeIncidents = snapshot.incidents?.active ?? [] + const recentIncidents = snapshot.incidents?.recent ?? [] + const allIncidents = [...activeIncidents, ...recentIncidents] + + if (allIncidents.length > 0) { + for (const incident of allIncidents.slice(0, 10)) { + entries.push({ + id: `${baseUrl}/history#${incident.identifier}`, + title: `${incident.identifier}: ${incident.title} (${incident.status})`, + updated: incident.updatedAt || incident.createdAt, + summary: incident.message, + link: `${baseUrl}/history`, + }) + } + } else if (snapshot.service?.status === 'operational') { + entries.push({ + id: `${baseUrl}/`, + title: 'All Systems Operational', + updated: production?.checkedAt || snapshot.generatedAt, + summary: 'Walrus Memory Relayers are operational. No incidents reported.', + link: `${baseUrl}/`, + }) + } else { + entries.push({ + id: `${baseUrl}/`, + title: `Walrus Memory Relayer ${worstComponent?.status || 'unavailable'}`, + updated: worstComponent?.checkedAt || snapshot.generatedAt, + summary: worstComponent?.error || `Relayer status is ${worstComponent?.status || 'unknown'}.`, + link: `${baseUrl}/`, + }) + } + + const productionHistory = snapshot.histories?.['relayer-production'] + for (const bucket of productionHistory?.buckets ?? []) { + if (bucket.status !== 'outage' && bucket.status !== 'degraded') continue + entries.push({ + id: `${baseUrl}/history#${bucket.date}`, + title: bucket.status === 'outage' + ? `Major outage on ${bucket.date}` + : `Degraded performance on ${bucket.date}`, + updated: `${bucket.date}T00:00:00.000Z`, + summary: bucket.status === 'outage' + ? `${bucket.outage} outage check${bucket.outage === 1 ? '' : 's'} recorded.` + : `${bucket.degraded} degraded check${bucket.degraded === 1 ? '' : 's'} recorded.`, + link: `${baseUrl}/history`, + }) + } + + return entries.slice(0, 20) +} + +function atomFeed(snapshot, baseUrl) { + const updated = snapshot.generatedAt + const entries = feedEntries(snapshot, baseUrl).map((entry) => ` + + ${escapeXml(entry.id)} + ${escapeXml(entry.title)} + ${escapeXml(entry.updated)} + + ${escapeXml(entry.summary)} + `).join('') + + return ` + + ${escapeXml(baseUrl)}/history.atom + Walrus Memory Status History + ${escapeXml(updated)} + + + ${entries} +` +} + +function rssFeed(snapshot, baseUrl) { + const entries = feedEntries(snapshot, baseUrl).map((entry) => ` + + ${escapeXml(entry.id)} + ${escapeXml(entry.title)} + ${escapeXml(entry.link)} + ${escapeXml(new Date(entry.updated).toUTCString())} + ${escapeXml(entry.summary)} + `).join('') + + return ` + + + Walrus Memory Status History + ${escapeXml(baseUrl)}/ + Real-time and historical status updates for Walrus Memory. + ${escapeXml(new Date(snapshot.generatedAt).toUTCString())} + ${entries} + +` +} + +async function serveFile(req, res, filePath, pathname, servingIndex = false) { + const fileStat = await stat(filePath) + if (!fileStat.isFile()) return false + + const ext = path.extname(filePath).toLowerCase() + const contentType = contentTypes.get(ext) || 'application/octet-stream' + + res.writeHead(200, { + 'Accept-Ranges': 'bytes', + 'Cache-Control': cacheControlFor(pathname, servingIndex), + 'Content-Length': fileStat.size, + 'Content-Type': contentType, + 'X-Content-Type-Options': 'nosniff', + }) + + if (req.method === 'HEAD') { + res.end() + return true + } + + createReadStream(filePath) + .on('error', () => { + if (!res.headersSent) { + sendText(res, 500, 'Internal server error') + } else { + res.destroy() + } + }) + .pipe(res) + + return true +} + +function startPolling() { + if (!isDatabaseConfigured()) return + + const pollIntervalMs = getPollIntervalMs() + void runPollCycle() + pollTimer = setInterval(() => { + void runPollCycle() + }, pollIntervalMs) + pollTimer.unref?.() + console.log(`[status-service] polling ${getRelayerComponents().length} relayers every ${pollIntervalMs} ms`) +} + +function readJsonBody(req, maxBytes = 256_000) { + return new Promise((resolve, reject) => { + const chunks = [] + let received = 0 + + req.on('data', (chunk) => { + received += chunk.length + if (received > maxBytes) { + req.destroy() + reject(new Error('Request body too large')) + return + } + chunks.push(chunk) + }) + + req.on('end', () => { + const raw = Buffer.concat(chunks).toString('utf-8') + if (!raw) return resolve({}) + try { + resolve(JSON.parse(raw)) + } catch { + reject(new Error('Invalid JSON')) + } + }) + + req.on('error', reject) + }) +} + +async function shutdown(signal) { + console.log(`[status-service] received ${signal}, shutting down`) + if (pollTimer) clearInterval(pollTimer) + server.close(async () => { + if (sql) await sql.end({ timeout: 5 }) + process.exit(0) + }) +} + +const server = createServer(async (req, res) => { + const method = req.method || 'GET' + let pathname + try { + pathname = decodeURIComponent(new URL(req.url || '/', 'http://localhost').pathname) + } catch { + sendText(res, 400, 'Bad request') + return + } + + if (pathname.includes('\0')) { + sendText(res, 400, 'Bad request') + return + } + + const isApiRoute = pathname.startsWith('/api/') + const allowedMethods = isApiRoute ? ['GET', 'HEAD', 'POST', 'PATCH', 'DELETE'] : ['GET', 'HEAD'] + + if (!allowedMethods.includes(method)) { + res.writeHead(405, { + Allow: allowedMethods.join(', '), + ...noStoreHeaders('text/plain; charset=utf-8'), + }) + res.end('Method not allowed') + return + } + + if (pathname === '/health') { + sendJson(res, 200, { + status: 'ok', + service: 'walrus-memory-status', + generatedAt: new Date().toISOString(), + database: { + configured: isDatabaseConfigured(), + ready: databaseReady, + error: databaseError, + }, + }) + return + } + + if (pathname === '/api/status') { + if (method !== 'GET' && method !== 'HEAD') { + sendJson(res, 405, { error: 'Method not allowed' }) + return + } + try { + sendJson(res, 200, await statusPayload()) + } catch (error) { + console.error('[status-service] failed to build status payload', error) + sendJson(res, 500, { + generatedAt: new Date().toISOString(), + service: { name: 'Walrus Memory Status', status: 'degraded' }, + error: 'Status service failed to build payload', + }) + } + return + } + + if (pathname === '/api/incidents') { + if (method === 'GET' || method === 'HEAD') { + try { + const incidents = await listIncidents() + sendJson(res, 200, { incidents }) + } catch (error) { + console.error('[status-service] failed to list incidents', error) + sendJson(res, 500, { error: 'Failed to list incidents' }) + } + return + } + + if (method === 'POST') { + if (!isAdminAuthValid(req)) { + sendJson(res, 401, { error: 'Unauthorized' }) + return + } + try { + const body = await readJsonBody(req) + if (!body.title || !body.status || !body.severity || !body.message) { + sendJson(res, 400, { error: 'Missing required fields: title, status, severity, message' }) + return + } + if (!isValidStatus(body.status)) { + sendJson(res, 400, { error: `Invalid status. Allowed: ${ALLOWED_INCIDENT_STATUSES.join(', ')}` }) + return + } + if (!isValidSeverity(body.severity)) { + sendJson(res, 400, { error: `Invalid severity. Allowed: ${ALLOWED_SEVERITIES.join(', ')}` }) + return + } + if (body.startedAt && !isValidDate(body.startedAt)) { + sendJson(res, 400, { error: 'Invalid startedAt date' }) + return + } + const incident = await createIncident(body) + if (!incident) { + sendJson(res, 500, { error: 'Failed to create incident' }) + return + } + sendJson(res, 201, incident) + } catch (error) { + console.error('[status-service] failed to create incident', error) + sendJson(res, 500, { error: 'Failed to create incident' }) + } + return + } + + sendJson(res, 405, { error: 'Method not allowed' }) + return + } + + const incidentDetailMatch = pathname.match(/^\/api\/incidents\/(\d+)$/) + if (incidentDetailMatch) { + const incidentId = Number(incidentDetailMatch[1]) + + if (method === 'GET' || method === 'HEAD') { + try { + const incident = await getIncidentWithUpdates(incidentId) + if (!incident) { + sendJson(res, 404, { error: 'Incident not found' }) + return + } + sendJson(res, 200, incident) + } catch (error) { + console.error('[status-service] failed to get incident', error) + sendJson(res, 500, { error: 'Failed to get incident' }) + } + return + } + + if (method === 'PATCH') { + if (!isAdminAuthValid(req)) { + sendJson(res, 401, { error: 'Unauthorized' }) + return + } + try { + const body = await readJsonBody(req) + if (body.status !== undefined && !isValidStatus(body.status)) { + sendJson(res, 400, { error: `Invalid status. Allowed: ${ALLOWED_INCIDENT_STATUSES.join(', ')}` }) + return + } + if (body.severity !== undefined && !isValidSeverity(body.severity)) { + sendJson(res, 400, { error: `Invalid severity. Allowed: ${ALLOWED_SEVERITIES.join(', ')}` }) + return + } + if (body.startedAt !== undefined && !isValidDate(body.startedAt)) { + sendJson(res, 400, { error: 'Invalid startedAt date' }) + return + } + if (body.resolvedAt !== undefined && body.resolvedAt && !isValidDate(body.resolvedAt)) { + sendJson(res, 400, { error: 'Invalid resolvedAt date' }) + return + } + const incident = await updateIncident(incidentId, body) + if (!incident) { + sendJson(res, 404, { error: 'Incident not found' }) + return + } + sendJson(res, 200, incident) + } catch (error) { + console.error('[status-service] failed to update incident', error) + sendJson(res, 500, { error: 'Failed to update incident' }) + } + return + } + + if (method === 'DELETE') { + if (!isAdminAuthValid(req)) { + sendJson(res, 401, { error: 'Unauthorized' }) + return + } + try { + const deleted = await deleteIncident(incidentId) + if (!deleted) { + sendJson(res, 404, { error: 'Incident not found' }) + return + } + sendJson(res, 204, null) + } catch (error) { + console.error('[status-service] failed to delete incident', error) + sendJson(res, 500, { error: 'Failed to delete incident' }) + } + return + } + + sendJson(res, 405, { error: 'Method not allowed' }) + return + } + + const incidentUpdatesMatch = pathname.match(/^\/api\/incidents\/(\d+)\/updates$/) + if (incidentUpdatesMatch) { + const incidentId = Number(incidentUpdatesMatch[1]) + + if (method === 'POST') { + if (!isAdminAuthValid(req)) { + sendJson(res, 401, { error: 'Unauthorized' }) + return + } + try { + const body = await readJsonBody(req) + if (!body.status || !body.message) { + sendJson(res, 400, { error: 'Missing required fields: status, message' }) + return + } + if (!isValidStatus(body.status)) { + sendJson(res, 400, { error: `Invalid status. Allowed: ${ALLOWED_INCIDENT_STATUSES.join(', ')}` }) + return + } + if (body.createdAt && !isValidDate(body.createdAt)) { + sendJson(res, 400, { error: 'Invalid createdAt date' }) + return + } + const incident = await addIncidentUpdate(incidentId, body) + if (!incident) { + sendJson(res, 404, { error: 'Incident not found' }) + return + } + sendJson(res, 201, incident) + } catch (error) { + console.error('[status-service] failed to add incident update', error) + sendJson(res, 500, { error: 'Failed to add incident update' }) + } + return + } + + sendJson(res, 405, { error: 'Method not allowed' }) + return + } + + if (pathname === '/history.atom' || pathname === '/history.rss') { + if (method !== 'GET' && method !== 'HEAD') { + sendJson(res, 405, { error: 'Method not allowed' }) + return + } + try { + const snapshot = await statusPayload() + const baseUrl = publicBaseUrl(req) + if (pathname === '/history.atom') { + sendXml(res, 200, atomFeed(snapshot, baseUrl), 'application/atom+xml') + } else { + sendXml(res, 200, rssFeed(snapshot, baseUrl), 'application/rss+xml') + } + } catch (error) { + console.error('[status-service] failed to build history feed', error) + sendText(res, 500, 'Status feed failed to build') + } + return + } + + const staticPath = resolveDistPath(pathname) + if (!staticPath) { + sendText(res, 403, 'Forbidden') + return + } + + try { + if (await serveFile(req, res, staticPath, pathname)) return + } catch (error) { + if (error?.code !== 'ENOENT') { + console.error('[status-service] failed to serve file', { pathname, error }) + sendText(res, 500, 'Internal server error') + return + } + } + + if (fileLikeRequestPattern.test(pathname)) { + sendText(res, 404, 'Not found') + return + } + + try { + if (!(await serveFile(req, res, path.join(distDir, 'index.html'), pathname, true))) { + sendText(res, 500, 'Internal server error') + } + } catch (error) { + console.error('[status-service] failed to serve SPA fallback', { pathname, error }) + sendText(res, 500, 'Internal server error') + } +}) + +process.once('SIGINT', () => { + void shutdown('SIGINT') +}) +process.once('SIGTERM', () => { + void shutdown('SIGTERM') +}) + +await initializeDatabase() +startPolling() + +server.listen(port, host, () => { + console.log(`[status-service] serving ${distDir} on http://${host}:${port}`) + for (const component of getRelayerComponents()) { + console.log(`[status-service] relayer health target [${component.id}]: ${resolveHealthUrl(component.url)}`) + } + console.log(`[status-service] history storage: ${isDatabaseConfigured() ? 'postgres' : 'disabled'}`) +}) diff --git a/apps/status/src/App.tsx b/apps/status/src/App.tsx new file mode 100644 index 00000000..1f993c29 --- /dev/null +++ b/apps/status/src/App.tsx @@ -0,0 +1,1148 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' +import { AlertTriangle, ChevronLeft, ChevronRight, Rss, X } from 'lucide-react' + +type StatusKind = 'operational' | 'degraded' | 'outage' | 'monitoring' | 'unknown' +type LoadState = 'loading' | 'ready' | 'error' +type PageRoute = 'current' | 'history' | 'uptime' | 'admin' + +interface HealthPayload { + status?: string + version?: string + relayerVersion?: string + apiVersion?: string + mode?: string + minSupportedSdk?: { + typescript?: string + python?: string + mcp?: string + } +} + +interface HistoryBucket { + date: string + status: StatusKind + total: number + ok: number + degraded: number + outage: number +} + +interface StatusHistory { + enabled: boolean + source: string + days: number + target: string + totalChecks: number + uptimePct: number | null + unavailableReason: string | null + buckets: HistoryBucket[] +} + +interface IncidentUpdate { + id: number + status: string + message: string + createdAt: string +} + +interface Incident { + id: number + identifier: string + title: string + status: string + severity: string + component: string | null + message: string + startedAt: string + resolvedAt: string | null + createdAt: string + updatedAt: string + updates?: IncidentUpdate[] +} + +interface IncidentList { + active: Incident[] + recent: Incident[] +} + +interface StatusComponent { + id: string + name: string + status: StatusKind + url: string + httpStatus: number | null + latencyMs: number | null + checkedAt: string + health: HealthPayload | null + error: string | null +} + +interface StatusSnapshot { + generatedAt: string + service: { + name: string + status: StatusKind + runtime?: string + historyEnabled?: boolean + } + components: StatusComponent[] + histories: Record + incidents?: IncidentList + database?: { + configured: boolean + ready: boolean + error: string | null + } +} + +interface ComponentRow { + name: string + status: StatusKind + uptimeLabel: string + history: HistoryBucket[] +} + +interface IncidentDay { + date: string + label: string + status: 'none' | 'degraded' | 'outage' + messages: string[] +} + +interface CalendarDay { + date: string + status: StatusKind +} + +interface CalendarMonth { + key: string + label: string + uptimeLabel: string + days: Array +} + +const REFRESH_INTERVAL_MS = 60_000 +const BAR_COUNT = 90 + +const statusLabel: Record = { + operational: 'Operational', + degraded: 'Degraded Performance', + outage: 'Major Outage', + monitoring: 'Monitoring', + unknown: 'No Data', +} + +function pathToRoute(pathname: string): PageRoute { + if (pathname.startsWith('/admin')) return 'admin' + if (pathname.startsWith('/uptime')) return 'uptime' + if (pathname.startsWith('/history')) return 'history' + return 'current' +} + +function formatIncidentDate(value: Date) { + return new Intl.DateTimeFormat(undefined, { + month: 'short', + day: 'numeric', + year: 'numeric', + }).format(value) +} + +function formatMonthYear(value: Date) { + return new Intl.DateTimeFormat(undefined, { + month: 'long', + year: 'numeric', + }).format(value) +} + +function getOverallStatus(snapshot: StatusSnapshot | null, loadState: LoadState): StatusKind { + if (loadState === 'error') return 'outage' + const components = snapshot?.components ?? [] + if (components.length === 0) return 'monitoring' + if (components.some((c) => c.status === 'outage')) return 'outage' + if (components.some((c) => c.status === 'degraded')) return 'degraded' + if (components.every((c) => c.status === 'operational')) return 'operational' + return 'monitoring' +} + +function getStatusTitle(status: StatusKind) { + if (status === 'operational') return 'All Systems Operational' + if (status === 'degraded') return 'Degraded Performance' + if (status === 'outage') return 'Service Disruption' + return 'Checking System Status' +} + +function toDateKey(date: Date) { + return date.toISOString().slice(0, 10) +} + +function parseDateKey(value: string) { + const [year, month, day] = value.split('-').map(Number) + return new Date(Date.UTC(year, month - 1, day)) +} + +function startOfUtcDay(date: Date) { + return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())) +} + +function addUtcDays(date: Date, days: number) { + const next = new Date(date) + next.setUTCDate(next.getUTCDate() + days) + return next +} + +function startOfUtcMonth(date: Date) { + return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), 1)) +} + +function addUtcMonths(date: Date, months: number) { + return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + months, 1)) +} + +function daysInUtcMonth(date: Date) { + return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + 1, 0)).getUTCDate() +} + +function emptyHistoryBuckets(status: StatusKind = 'unknown') { + const today = startOfUtcDay(new Date()) + const firstDay = addUtcDays(today, -(BAR_COUNT - 1)) + + return Array.from({ length: BAR_COUNT }, (_, index) => ({ + date: toDateKey(addUtcDays(firstDay, index)), + status: index === BAR_COUNT - 1 ? status : 'unknown', + total: 0, + ok: 0, + degraded: 0, + outage: 0, + })) +} + +function normalizeBuckets(history: StatusHistory | null | undefined, latestStatus: StatusKind) { + if (!history?.buckets?.length) return emptyHistoryBuckets(latestStatus) + + const buckets = history.buckets.slice(-BAR_COUNT) + if (buckets.length < BAR_COUNT) { + return [ + ...emptyHistoryBuckets().slice(0, BAR_COUNT - buckets.length), + ...buckets, + ] + } + + return buckets +} + +function formatUptime(history: StatusHistory | null | undefined) { + if (!history?.enabled || !history.totalChecks || history.uptimePct === null) return 'history unavailable' + const decimals = history.uptimePct === 100 ? 1 : 2 + return `${history.uptimePct.toFixed(decimals)} % uptime` +} + +function buildRows(snapshot: StatusSnapshot | null, loadState: LoadState): ComponentRow[] { + const components = snapshot?.components ?? [] + if (components.length === 0 && loadState === 'error') { + return [ + { + name: 'Walrus Memory Relayer production (mainnet)', + status: 'outage', + uptimeLabel: 'history unavailable', + history: normalizeBuckets(null, 'outage'), + }, + ] + } + + return components.map((component) => { + const history = snapshot?.histories?.[component.id] + return { + name: component.name, + status: component.status, + uptimeLabel: formatUptime(history), + history: normalizeBuckets(history, component.status), + } + }) +} + +function buildIncidentDays(history: StatusHistory | null | undefined, incidents: IncidentList | null | undefined, count = 10): IncidentDay[] { + const allIncidents = [ + ...(incidents?.active ?? []), + ...(incidents?.recent ?? []), + ] + + const incidentsByDate = new Map() + for (const incident of allIncidents) { + const date = incident.startedAt.slice(0, 10) + const list = incidentsByDate.get(date) ?? [] + list.push(incident) + incidentsByDate.set(date, list) + } + + const bucketByDate = new Map() + if (history?.buckets) { + for (const bucket of history.buckets) { + bucketByDate.set(bucket.date, bucket) + } + } + + const today = startOfUtcDay(new Date()) + const days: IncidentDay[] = [] + + for (let i = 0; i < count; i++) { + const date = addUtcDays(today, -i) + const dateKey = toDateKey(date) + const dayIncidents = incidentsByDate.get(dateKey) + const bucket = bucketByDate.get(dateKey) + const isToday = i === 0 + + if (dayIncidents && dayIncidents.length > 0) { + const messages: string[] = [] + for (const inc of dayIncidents) { + const statusText = inc.status === 'resolved' ? '' : ` — ${inc.status}` + messages.push(`${inc.identifier}: ${inc.title}${statusText}`) + if (inc.updates && inc.updates.length > 0) { + for (const u of inc.updates) { + const time = new Date(u.createdAt).toLocaleTimeString('en-US', { + hour: 'numeric', + minute: '2-digit', + }) + messages.push(` ${time} — ${u.status} — ${u.message}`) + } + } + } + const allResolved = dayIncidents.every((inc) => inc.status === 'resolved') + const severity = dayIncidents.some((inc) => inc.severity === 'critical' || inc.severity === 'major') + ? 'outage' + : 'degraded' + days.push({ + date: dateKey, + label: formatIncidentDate(date), + status: allResolved ? 'none' : severity, + messages, + }) + } else if (bucket) { + const outagePct = bucket.total ? bucket.outage / bucket.total : 0 + const degradedPct = bucket.total ? bucket.degraded / bucket.total : 0 + let status: 'outage' | 'degraded' | 'none' + if (outagePct > 0.05) { + status = 'outage' + } else if (outagePct > 0 || degradedPct > 0.05) { + status = 'degraded' + } else { + status = 'none' + } + const msg = status === 'outage' + ? 'Relayer health checks reported a major outage.' + : status === 'degraded' + ? 'Relayer health checks reported degraded performance.' + : isToday + ? 'No incidents reported today.' + : 'No incidents reported.' + days.push({ + date: dateKey, + label: formatIncidentDate(date), + status, + messages: [msg], + }) + } else { + days.push({ + date: dateKey, + label: formatIncidentDate(date), + status: 'none', + messages: [isToday ? 'No incidents reported today.' : 'No incidents reported.'], + }) + } + } + + return days +} + +function buildCalendarMonths(history: HistoryBucket[], pageOffset: number): CalendarMonth[] { + const datedBuckets = history.filter((bucket) => bucket.date) + const latestDate = datedBuckets.at(-1)?.date ? parseDateKey(datedBuckets.at(-1)!.date) : new Date() + const endMonth = addUtcMonths(startOfUtcMonth(latestDate), -pageOffset) + const startMonth = addUtcMonths(endMonth, -2) + const bucketByDate = new Map(datedBuckets.map((bucket) => [bucket.date, bucket])) + + return Array.from({ length: 3 }, (_, monthIndex) => { + const monthStart = addUtcMonths(startMonth, monthIndex) + const dayCount = daysInUtcMonth(monthStart) + const leadingDays = monthStart.getUTCDay() + const monthBuckets: HistoryBucket[] = [] + const days: Array = Array.from({ length: leadingDays }, () => null) + + for (let day = 1; day <= dayCount; day += 1) { + const date = new Date(Date.UTC(monthStart.getUTCFullYear(), monthStart.getUTCMonth(), day)) + const dateKey = toDateKey(date) + const bucket = bucketByDate.get(dateKey) + if (bucket) monthBuckets.push(bucket) + days.push({ + date: dateKey, + status: bucket?.status ?? 'unknown', + }) + } + + const totalChecks = monthBuckets.reduce((sum, bucket) => sum + bucket.total, 0) + const upChecks = monthBuckets.reduce((sum, bucket) => sum + bucket.ok + bucket.degraded, 0) + const uptime = totalChecks > 0 ? (upChecks / totalChecks) * 100 : null + + return { + key: toDateKey(monthStart), + label: formatMonthYear(monthStart), + uptimeLabel: uptime === null ? 'No data' : `${uptime === 100 ? uptime.toFixed(0) : uptime.toFixed(2)}%`, + days, + } + }) +} + +function calendarRangeLabel(months: CalendarMonth[]) { + if (!months.length) return '' + return `${months[0].label} to ${months[months.length - 1].label}` +} + +function StatusPill({ status }: { status: StatusKind }) { + return {statusLabel[status]} +} + +function Header({ + subscribeOpen, + onSubscribeToggle, + onSubscribeClose, + onNavigate, +}: { + subscribeOpen: boolean + onSubscribeToggle: () => void + onSubscribeClose: () => void + onNavigate: (href: string) => void +}) { + return ( +
+ { + event.preventDefault() + onNavigate('/') + }} + > + + Walrus Memory + + +
+ + + {subscribeOpen && ( +
+
+ + + +
+
+ Get the Atom Feed or{' '} + RSS Feed. +
+
+ )} +
+
+ ) +} + +function ComponentStatusRow({ row }: { row: ComponentRow }) { + return ( +
+
+

{row.name}

+ +
+ +
+ {row.history.map((bucket, index) => ( +
+ +
+ {BAR_COUNT} days ago + {row.uptimeLabel} + Today +
+
+ ) +} + +function IncidentHistory({ + history, + incidents, + count, +}: { + history: StatusHistory | null | undefined + incidents: IncidentList | null | undefined + count: number +}) { + const incidentDays = buildIncidentDays(history, incidents, count) + + return ( +
+

Past Incidents

+
+ {incidentDays.map((day) => ( +
+

{day.label}

+ {day.messages.map((msg, idx) => ( +

+ {msg} +

+ ))} +
+ ))} +
+
+ ) +} + +function HistoryTabs({ + route, + onNavigate, +}: { + route: PageRoute + onNavigate: (href: string) => void +}) { + return ( + + ) +} + +function UptimeCalendar({ + rows, + selectedName, + calendarPage, + onSelectComponent, + onCalendarPageChange, +}: { + rows: ComponentRow[] + selectedName: string + calendarPage: number + onSelectComponent: (name: string) => void + onCalendarPageChange: (page: number) => void +}) { + const selectedRow = rows.find((row) => row.name === selectedName) ?? rows[1] ?? rows[0] + const months = buildCalendarMonths(selectedRow?.history ?? emptyHistoryBuckets(), calendarPage) + + return ( +
+
+ + +
+ + {calendarRangeLabel(months)} + +
+
+ +
+ {months.map((month) => ( +
+
+

{month.label}

+ {month.uptimeLabel} +
+
+ {month.days.map((day, index) => day ? ( +
+
+ ))} +
+
+ ) +} + +function StatusFooter({ + route, + onNavigate, +}: { + route: PageRoute + onNavigate: (href: string) => void +}) { + const links = [] + if (route !== 'current') links.push({ href: '/', label: 'Current Status' }) + if (route !== 'history') links.push({ href: '/history', label: 'Incident History' }) + if (route !== 'uptime') links.push({ href: '/uptime', label: 'Uptime' }) + + return ( + + ) +} + +async function loadSnapshot(signal: AbortSignal) { + const response = await fetch('/api/status', { + method: 'GET', + cache: 'no-store', + headers: { accept: 'application/json' }, + signal, + }) + + if (!response.ok) { + throw new Error(`Status API returned HTTP ${response.status}`) + } + + return response.json() as Promise +} + +async function loadIncidents(): Promise { + const response = await fetch('/api/incidents', { + method: 'GET', + cache: 'no-store', + headers: { accept: 'application/json' }, + }) + if (!response.ok) throw new Error(`Incidents API returned HTTP ${response.status}`) + const data = await response.json() + return data.incidents ?? [] +} + +async function createIncidentApi(payload: Record, apiKey: string): Promise { + const response = await fetch('/api/incidents', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Admin-API-Key': apiKey, + }, + body: JSON.stringify(payload), + }) + if (!response.ok) { + const err = await response.json().catch(() => ({ error: 'Unknown error' })) + throw new Error(err.error || `HTTP ${response.status}`) + } + return response.json() +} + +async function addIncidentUpdateApi(incidentId: number, payload: Record, apiKey: string): Promise { + const response = await fetch(`/api/incidents/${incidentId}/updates`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Admin-API-Key': apiKey, + }, + body: JSON.stringify(payload), + }) + if (!response.ok) { + const err = await response.json().catch(() => ({ error: 'Unknown error' })) + throw new Error(err.error || `HTTP ${response.status}`) + } + return response.json() +} + +async function resolveIncidentApi(incidentId: number, apiKey: string): Promise { + const response = await fetch(`/api/incidents/${incidentId}`, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + 'X-Admin-API-Key': apiKey, + }, + body: JSON.stringify({ status: 'resolved', resolvedAt: new Date().toISOString() }), + }) + if (!response.ok) { + const err = await response.json().catch(() => ({ error: 'Unknown error' })) + throw new Error(err.error || `HTTP ${response.status}`) + } + return response.json() +} + +async function deleteIncidentApi(incidentId: number, apiKey: string): Promise { + const response = await fetch(`/api/incidents/${incidentId}`, { + method: 'DELETE', + headers: { + 'X-Admin-API-Key': apiKey, + }, + }) + if (!response.ok) { + const err = await response.json().catch(() => ({ error: 'Unknown error' })) + throw new Error(err.error || `HTTP ${response.status}`) + } +} + +function AdminPanel({ + apiKey, + onApiKeyChange, + onMutate, + componentNames, +}: { + apiKey: string + onApiKeyChange: (key: string) => void + onMutate?: () => void + componentNames: string[] +}) { + const [incidents, setIncidents] = useState([]) + const [loadState, setLoadState] = useState('loading') + const [error, setError] = useState(null) + const [formError, setFormError] = useState(null) + const [formSuccess, setFormSuccess] = useState(null) + + const refresh = useCallback(async () => { + setLoadState('loading') + try { + const data = await loadIncidents() + setIncidents(data) + setLoadState('ready') + setError(null) + } catch (e) { + setLoadState('error') + setError(e instanceof Error ? e.message : String(e)) + } + }, []) + + useEffect(() => { + void refresh() + }, [refresh]) + + const handleCreate = async (event: React.FormEvent) => { + event.preventDefault() + setFormError(null) + setFormSuccess(null) + const form = event.currentTarget + const formData = new FormData(form) + const payload = { + title: String(formData.get('title') || ''), + status: String(formData.get('status') || 'investigating'), + severity: String(formData.get('severity') || 'minor'), + component: String(formData.get('component') || ''), + message: String(formData.get('message') || ''), + startedAt: new Date().toISOString(), + } + if (!payload.title || !payload.message) { + setFormError('Title and message are required.') + return + } + try { + await createIncidentApi(payload, apiKey) + setFormSuccess('Incident created.') + form.reset() + void refresh() + onMutate?.() + } catch (e) { + setFormError(e instanceof Error ? e.message : String(e)) + } + } + + const handleUpdate = async (incidentId: number, event: React.FormEvent) => { + event.preventDefault() + setFormError(null) + setFormSuccess(null) + const form = event.currentTarget + const formData = new FormData(form) + const payload = { + status: String(formData.get('status') || ''), + message: String(formData.get('message') || ''), + } + if (!payload.message) { + setFormError('Message is required.') + return + } + try { + await addIncidentUpdateApi(incidentId, payload, apiKey) + setFormSuccess('Update posted.') + form.reset() + void refresh() + onMutate?.() + } catch (e) { + setFormError(e instanceof Error ? e.message : String(e)) + } + } + + const handleResolve = async (incidentId: number) => { + setFormError(null) + setFormSuccess(null) + try { + await resolveIncidentApi(incidentId, apiKey) + setFormSuccess('Incident resolved.') + void refresh() + onMutate?.() + } catch (e) { + setFormError(e instanceof Error ? e.message : String(e)) + } + } + + const handleDelete = async (incidentId: number) => { + if (!window.confirm('Delete this incident permanently?')) return + setFormError(null) + setFormSuccess(null) + try { + await deleteIncidentApi(incidentId, apiKey) + setFormSuccess('Incident deleted.') + void refresh() + onMutate?.() + } catch (e) { + setFormError(e instanceof Error ? e.message : String(e)) + } + } + + return ( +
+

Incident Administration

+ +
+ +
+ + {(formError || formSuccess) && ( +
+ {formError || formSuccess} +
+ )} + +
+

Create Incident

+
+ + + + + +