diff --git a/.github/workflows/status.yml b/.github/workflows/status.yml new file mode 100644 index 00000000000..a22fc67fc82 --- /dev/null +++ b/.github/workflows/status.yml @@ -0,0 +1,31 @@ +name: Status app + +on: + pull_request: + paths: + - "apps/status/**" + - "bun.lock" + - ".github/workflows/status.yml" + push: + branches: [main] + paths: + - "apps/status/**" + - "bun.lock" + - ".github/workflows/status.yml" + +permissions: + contents: read + +jobs: + verify: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + with: + bun-version: "1.4.0" + - run: bun install --frozen-lockfile + - run: bun run --cwd apps/status test + - run: bun run --cwd apps/status build + env: + NEXT_TELEMETRY_DISABLED: "1" diff --git a/apps/status/.env.example b/apps/status/.env.example new file mode 100644 index 00000000000..dd6d639d522 --- /dev/null +++ b/apps/status/.env.example @@ -0,0 +1,9 @@ +UPSTASH_REDIS_REST_URL= +UPSTASH_REDIS_REST_TOKEN= +CRON_SECRET= +STATUS_WEBSITE_URL=https://cap.so +STATUS_API_URL=https://cap.so/api/auth/providers +STATUS_SHARING_URL= +STATUS_STORAGE_URL= +STATUS_MEDIA_URL= +STATUS_RENDER_URL= diff --git a/apps/status/.gitignore b/apps/status/.gitignore new file mode 100644 index 00000000000..81f74ff7895 --- /dev/null +++ b/apps/status/.gitignore @@ -0,0 +1,2 @@ +.next/ +next-env.d.ts diff --git a/apps/status/README.md b/apps/status/README.md new file mode 100644 index 00000000000..b67159b3556 --- /dev/null +++ b/apps/status/README.md @@ -0,0 +1,60 @@ +# Cap Status + +An independent Next.js app for `status.cap.so`. It reuses the current landing page’s Instrument Sans, Source Serif 4, DM Mono, cloud artwork, and colour palette. It has no dependency on the main web app, its database, or its deployment. + +## Deploy on Vercel + +1. Import this repository into a **separate Vercel project**, with root directory **`apps/status`**, framework **Next.js**, and build command **`bun run build`**. Keep access to the repository root enabled so Vercel uses the existing Bun workspace and lockfile. Use Bun 1.4.0. Do not attach the main site’s environment variables. +2. Attach a dedicated Upstash Redis database through the Vercel Marketplace. Set `UPSTASH_REDIS_REST_URL` and `UPSTASH_REDIS_REST_TOKEN` on the production environment. Keep preview data in a separate database, or leave preview storage unconfigured. +3. Set a random `CRON_SECRET` of at least 32 bytes on production. Vercel sends it as the cron’s Bearer token. Do not prefix any of these variables with `NEXT_PUBLIC_`. +4. Configure the production service URLs below. Deploy on a plan supporting **five-minute cron jobs** (Vercel Pro or higher), then add `status.cap.so` to this project and point its DNS at Vercel. The page starts with honest “no data” states until checks run. +5. Verify the first `/api/cron/check` invocation succeeds in the Vercel logs and `/api/status` contains current observations for every configured service. Set an **external dead-man monitor** to alert when `/api/status` has an old `checkedAt` (over 15 minutes), returns non-200, or cannot be reached. This detects a failed scheduler, Redis, or the status deployment itself. +6. Before public launch, configure a Vercel WAF rate limit for `/api/status` (GET and HEAD). Start with 300 requests per minute per IP in log mode, check expected traffic, then enforce with a 429 response. The public API rejects query parameters before accessing Redis, preventing cache-busting URLs from consuming storage requests. The WAF rule protects function usage as well and is configured on the separate status project, not the main site. + +No production deployment, DNS changes, external monitors, or storage resources are created by this PR. + +## Service coverage + +All endpoints must be direct HTTPS URLs returning 2xx; redirects are intentionally rejected so a sign-in redirect or protection page cannot count as a healthy service. Use dedicated, public, read-only canaries; never use a customer’s private recording or a signed URL that expires. + +| Environment variable | Probe | Coverage | +| --- | --- | --- | +| `STATUS_WEBSITE_URL` | HEAD; defaults to `https://cap.so` | Public website reachability | +| `STATUS_API_URL` | GET; defaults to `https://cap.so/api/auth/providers` | Authentication API reachability and a real provider payload; does not simulate sign-in or prove database health | +| `STATUS_SHARING_URL` | HEAD; required | A permanent public test recording’s share page returning 200 | +| `STATUS_STORAGE_URL` | HEAD; required | A small, permanent canary object on the production storage/CDN path; does not test uploads | +| `STATUS_MEDIA_URL` | GET; required | Media server `/health`, including `status === "ok"` rather than HTTP status alone | +| `STATUS_RENDER_URL` | GET; required | Render coordinator `/health`, requiring `ok`, active workers, and render slots | + +Missing or invalid configuration is shown as unknown, never operational. Add other services to `lib/services.ts` and their environment variables to `.env.example`. Each service gets its own history and automatic incidents. The `/health` URLs are existing endpoints; this app adds no instrumentation, middleware, migrations, or code to Cap’s production services. + +These are availability checks, not end-to-end recording/upload/playback tests. A cache-served canary confirms the serving path, not every downstream dependency. Keep functional synthetic tests and provider alerting alongside this page. + +## Monitoring and persistence + +- Vercel cron runs every five minutes **only on production**. `/api/cron/check` rejects unauthenticated calls. Preview deployments cannot run the collector. +- Six lightweight probes run concurrently, each with an eight-second deadline. Healthy responses over three seconds are degraded. An unhealthy probe is retried once immediately. Normally 72 requests per hour reach Cap; retries can increase that to 144 per hour, independent of page traffic. HEAD probes do not download recordings; GET probes use only small health JSON. +- A 55-second Redis lease prevents overlapping collectors. The function deadline is 50 seconds. Saving observations and releasing the lease is one atomic Lua operation conditional on lease ownership. A crashed collector’s lease expires. Duplicate invocations in the same five-minute UTC interval do not double-count. +- A first confirmed unhealthy sample shows degraded/unknown status. Two consecutive unhealthy samples open an incident, typically 5–10 minutes after a failure begins; two consecutive successful samples close it. After a monitoring gap, consecutive counters reset. An unobserved gap never resolves an incident. +- Redis stores 90 UTC calendar days of daily observed success/degradation/failure counts and all ongoing incidents and up to 500 recently resolved incidents. Empty days remain empty. Uptime is successful observed checks divided by all observed checks; it is not a promise about unobserved time. Hover, focus, or tap a day to see the sample count. +- Visitors read only `/api/status`, cached at the CDN for 30 seconds. The visible page refreshes once a minute and stops polling in hidden tabs. It independently marks observations older than 15 minutes unknown. Storage failures show monitoring unavailable; they never fabricate healthy data. No service URLs, health payloads, or credentials appear in the public snapshot. + +The status page and its scheduler share Vercel. The independent dead-man monitor is needed to notify operators of a Vercel-wide outage. This version records automatic incidents; notifications and manually written incident updates are outside its scope. + +## Local checks + +From the repository root, install with `bun install --frozen-lockfile`. Then: + +```sh +bun run --cwd apps/status test +bun run --cwd apps/status typecheck +bun run --cwd apps/status build +``` + +Copy `.env.example` to `.env.local` in this app for local configuration. Use a separate development Redis database. `bun run --cwd apps/status start` serves the production build on port 3003; `bun run --cwd apps/status dev` uses the same port. The status app is excluded from the root development commands, including the Windows wrapper, so it runs only when explicitly started and cannot take the desktop harness's port 3002. Respect the repository’s existing-server rules when starting a local server. + +## Screenshots + +[Desktop preview](screenshots/desktop.png) · [Mobile preview](screenshots/mobile.png) + +These screenshots show the production build with sample monitoring responses, not live service history. The browser checks also cover missing storage, stale observations, an active outage, incident display, service expansion, and keyboard navigation through daily uptime. Automated WCAG 2.1 A/AA checks pass for the normal and outage views. diff --git a/apps/status/app/api/cron/check/route.ts b/apps/status/app/api/cron/check/route.ts new file mode 100644 index 00000000000..88ab8e32ef2 --- /dev/null +++ b/apps/status/app/api/cron/check/route.ts @@ -0,0 +1,79 @@ +import { randomUUID, timingSafeEqual } from "node:crypto"; +import { probe } from "../../../../lib/probe"; +import { SERVICES, serviceUrl } from "../../../../lib/services"; +import { applyResult, CHECK_INTERVAL_MS } from "../../../../lib/state"; +import { + COMMIT_SCRIPT, + getRedis, + LOCK_KEY, + RELEASE_SCRIPT, + readState, + STATE_KEY, +} from "../../../../lib/store"; + +export const maxDuration = 50; +export const dynamic = "force-dynamic"; + +export async function GET(request: Request) { + const secret = process.env.CRON_SECRET; + const supplied = Buffer.from(request.headers.get("authorization") ?? ""); + const expected = Buffer.from(`Bearer ${secret}`); + if ( + !secret || + supplied.length !== expected.length || + !timingSafeEqual(supplied, expected) + ) { + return new Response("Unauthorized", { status: 401 }); + } + if (process.env.VERCEL_ENV && process.env.VERCEL_ENV !== "production") { + return Response.json({ skipped: "Preview deployment" }); + } + try { + const redis = getRedis(); + const owner = randomUUID(); + if (!(await redis.set(LOCK_KEY, owner, { nx: true, ex: 55 }))) + return Response.json({ skipped: "Check already running" }); + try { + const state = await readState(redis); + const now = Date.now(); + if ( + state.checkedAt && + Math.floor(Date.parse(state.checkedAt) / CHECK_INTERVAL_MS) >= + Math.floor(now / CHECK_INTERVAL_MS) + ) { + return Response.json({ skipped: "Check interval already sampled" }); + } + const results = await Promise.all( + SERVICES.map(async (service) => { + try { + const url = serviceUrl(service); + return { + id: service.id, + result: url ? await probe(service, url) : null, + }; + } catch { + console.error(`Invalid monitor configuration: ${service.id}`); + return { id: service.id, result: null }; + } + }), + ); + for (const { id, result } of results) applyResult(state, id, result, now); + state.checkedAt = new Date(now).toISOString(); + const committed = await redis.eval( + COMMIT_SCRIPT, + [LOCK_KEY, STATE_KEY], + [owner, JSON.stringify(state)], + ); + if (committed !== 1) throw new Error("Monitor lease expired"); + return Response.json({ + checkedAt: state.checkedAt, + checked: results.filter((entry) => entry.result).length, + }); + } finally { + await redis.eval(RELEASE_SCRIPT, [LOCK_KEY], [owner]); + } + } catch { + console.error("Status check could not be saved"); + return Response.json({ error: "Monitoring unavailable" }, { status: 503 }); + } +} diff --git a/apps/status/app/api/status/route.ts b/apps/status/app/api/status/route.ts new file mode 100644 index 00000000000..2de30338e3f --- /dev/null +++ b/apps/status/app/api/status/route.ts @@ -0,0 +1,27 @@ +import { emptyState, publicSnapshot } from "../../../lib/state"; +import { getRedis, readState } from "../../../lib/store"; + +export const dynamic = "force-dynamic"; + +export async function GET(request: Request) { + if (new URL(request.url).search) { + return Response.json( + { error: "Query parameters are not supported" }, + { status: 400, headers: { "Cache-Control": "no-store" } }, + ); + } + try { + return Response.json(publicSnapshot(await readState(getRedis())), { + headers: { + "Cache-Control": + "public, max-age=0, s-maxage=30, stale-while-revalidate=30", + }, + }); + } catch { + console.error("Status snapshot unavailable"); + return Response.json(publicSnapshot(emptyState()), { + status: 503, + headers: { "Cache-Control": "no-store" }, + }); + } +} diff --git a/apps/status/app/globals.css b/apps/status/app/globals.css new file mode 100644 index 00000000000..30180a59af1 --- /dev/null +++ b/apps/status/app/globals.css @@ -0,0 +1,965 @@ +:root { + color-scheme: light; + --ink: #111111; + --muted: #68737f; + --line: #e1e7ee; + --green: #428d72; + --amber: #b77d24; + --red: #c35858; +} + +* { + box-sizing: border-box; +} +html { + scroll-behavior: smooth; + scroll-padding-top: 28px; +} +body { + margin: 0; + background: #ffffff; + color: var(--ink); + font-family: var(--font-sans), sans-serif; + -webkit-font-smoothing: antialiased; +} +button, +input { + font: inherit; +} +button, +a { + -webkit-tap-highlight-color: transparent; +} +button { + color: inherit; +} +a { + color: inherit; + text-decoration: none; +} +button { + cursor: pointer; +} +button:focus-visible, +a:focus-visible, +summary:focus-visible { + outline: 2px solid #4081c8; + outline-offset: 5px; +} +h1, +h2, +h3, +p { + margin: 0; +} +.skip-link { + position: fixed; + top: -60px; + left: 20px; + z-index: 20; + padding: 12px; + background: white; +} +.skip-link:focus { + top: 12px; +} +.site-header { + height: 86px; + max-width: 1320px; + margin: auto; + padding: 0 48px; + display: flex; + align-items: center; + justify-content: space-between; +} +.brand { + display: inline-flex; + align-items: center; + gap: 10px; + font-size: 24px; + font-weight: 600; + letter-spacing: -1px; +} +.brand img { + width: 34px; + height: 34px; +} +.brand-divider { + height: 21px; + width: 1px; + background: #d8dfe5; + margin: 0 7px 0 12px; + transform: rotate(16deg); +} +.brand-context { + font-size: 16px; + font-weight: 400; + letter-spacing: -0.2px; + color: #67727c; +} +.back-link { + display: flex; + align-items: center; + gap: 8px; + font-size: 14px; + color: #4b5865; +} +.back-link:hover, +footer a:hover { + color: #3777bd; +} +.hero { + position: relative; + margin: 0 16px; + min-height: 357px; + overflow: hidden; + border-radius: 24px; + text-align: center; + background: #edf1f6; +} +.sky { + position: absolute; + inset: 0; + background: + radial-gradient(36% 60% at 72% 0%, #ffe0c0a6, transparent 75%), + linear-gradient(180deg, #c8ddf6 0%, #d9e7f8 35%, #edf1f6 100%); +} +.sky::after { + content: ""; + position: absolute; + inset: 0; + opacity: 0.18; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='180' height='180'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.8' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Cpath fill='%23888' filter='url(%23n)' opacity='.3' d='M0 0h180v180H0z'/%3E%3C/svg%3E"); +} +.cloud { + height: auto; + position: absolute; + pointer-events: none; + object-fit: contain; + opacity: 0.8; +} +.cloud-left { + width: 430px; + left: -130px; + top: 88px; + transform: rotate(-5deg); +} +.cloud-right { + width: 510px; + right: -120px; + top: 118px; +} +.hero-content { + position: relative; + padding: 80px 22px 66px; +} +h1 { + font-size: clamp(40px, 4.8vw, 68px); + font-weight: 400; + letter-spacing: -3.1px; + line-height: 1.07; +} +.hero-description { + margin-top: 17px; + font-family: var(--font-serif), Georgia, serif; + font-weight: 300; + font-size: 18px; + color: #4e5d6c; + letter-spacing: -0.3px; +} +.hero-link { + margin-top: 26px; + display: inline-flex; + gap: 12px; + font-size: 12px; + color: #64788e; +} +.hero-link span { + transition: transform 160ms; +} +.hero-link:hover span { + transform: translateY(3px); +} +.content { + position: relative; + max-width: 940px; + padding: 0 30px; + margin: -29px auto 0; +} +.summary { + border: 1px solid #dbe9e2; + border-radius: 16px; + padding: 25px 28px; + display: flex; + justify-content: space-between; + align-items: center; + gap: 15px; + background: linear-gradient(110deg, #f2faf5, #fcfefd); + box-shadow: + 0 5px 20px -14px #4b6d7140, + 0 1px 0 #ffffff inset; +} +.summary-main { + display: flex; + align-items: center; + gap: 18px; +} +.summary-icon { + width: 44px; + height: 44px; + flex-shrink: 0; + border-radius: 50%; + display: grid; + place-items: center; + background: #e1f1e8; + color: #388160; + border: 1px solid #cae3d6; + box-shadow: inset 0 1px 2px #ffffff; +} +.summary h2 { + font-size: 20px; + font-weight: 500; + letter-spacing: -0.6px; +} +.summary p { + color: #61746a; + font-size: 13px; + margin-top: 5px; +} +.summary.unknown { + background: linear-gradient(110deg, #f4f7fb, #fdfefe); + border-color: #dce4ec; +} +.summary.unknown .summary-icon { + color: #526d88; + background: #e8eff7; + border-color: #d6e1ed; +} +.summary.unknown p { + color: #64768b; +} +.summary.degraded { + background: #fffaf0; + border-color: #eee0c6; +} +.summary.degraded .summary-icon { + color: #9c6b27; + background: #f9edcf; + border-color: #efdfb9; +} +.summary.degraded p { + color: #78633e; +} +.summary.outage { + background: #fff7f6; + border-color: #f0d9d5; +} +.summary.outage .summary-icon { + color: #b55951; + background: #fae6e2; + border-color: #efd4cd; +} +.summary.outage p { + color: #79564f; +} +.live-label { + display: inline-flex; + gap: 6px; + align-items: center; + font-family: var(--font-mono), monospace; + font-size: 10px; + color: #466b58; + white-space: nowrap; +} +.dot { + width: 6px; + height: 6px; + border-radius: 50%; + display: inline-block; + flex-shrink: 0; + background: currentColor; +} +.live-dot { + color: #528e71; + box-shadow: 0 0 0 3px #528e7110; +} +.section-heading { + display: flex; + justify-content: space-between; + align-items: center; + gap: 14px; + padding: 41px 0 19px; +} +.section-heading h2 { + font-size: 22px; + letter-spacing: -0.65px; + font-weight: 500; +} +.section-heading p { + margin-top: 7px; + color: var(--muted); + font-size: 13px; +} +.update-label { + display: flex; + align-items: center; + gap: 6px; + font-size: 11px; + color: #66717c; +} +.refresh-icon { + font-size: 16px; +} +.services-card { + border: 1px solid var(--line); + border-radius: 16px; + background: #ffffff; + box-shadow: 0 4px 16px -13px #25395130; +} +.service { + padding: 21px 25px 19px; +} +.service + .service { + border-top: 1px solid #edf0f3; +} +.service-heading { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 15px; + gap: 12px; +} +.service-name { + border: 0; + padding: 0; + background: none; + display: flex; + align-items: center; + gap: 11px; + text-align: left; + font-size: 14px; + font-weight: 500; +} +.service-icon { + width: 31px; + height: 31px; + display: grid; + place-items: center; + background: #f3f6fa; + border: 1px solid #e8edf4; + border-radius: 8px; + color: #6988ab; +} +.icon-api, +.icon-render { + background: #f5f3fa; + border-color: #eeebf5; + color: #9282b6; +} +.icon-sharing { + background: #f0f6f3; + border-color: #e5eee8; + color: #719c83; +} +.icon-media { + background: #faf5ef; + border-color: #f1eae1; + color: #b19674; +} +.chevron { + font-size: 15px; + color: #9aa6b1; + margin-left: 1px; + transition: transform 160ms; +} +.chevron.expanded { + transform: rotate(180deg); +} +.status-label { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 11px; + white-space: nowrap; +} +.status-label.operational { + color: #3e8167; +} +.status-label.degraded { + color: #926515; +} +.status-label.outage { + color: #ad4848; +} +.status-label.unknown { + color: #66717c; +} +.history { + position: relative; +} +.bars { + margin: 0; + padding: 0; + border: 0; + display: flex; + gap: 3px; + height: 26px; +} +.bar { + flex: 1; + min-width: 0; + border: 0; + padding: 0; + border-radius: 2px; + transition: + filter 120ms, + transform 120ms; +} +.bar:hover, +.bar:focus-visible { + filter: brightness(0.88); + transform: scaleY(1.12); + outline-offset: 2px; +} +.bar.operational, +.key-swatch.operational { + background: #88b7a3; +} +.bar.degraded, +.key-swatch.degraded { + background: #dfb867; +} +.bar.outage, +.key-swatch.outage { + background: #d68a83; +} +.bar.unknown, +.key-swatch.unknown { + background: repeating-linear-gradient( + 135deg, + #e5eaf0 0, + #e5eaf0 3px, + #f1f4f7 3px, + #f1f4f7 5px + ); +} +.history-tooltip { + position: absolute; + z-index: 5; + bottom: 36px; + left: 50%; + transform: translateX(-50%); + display: flex; + flex-direction: column; + gap: 6px; + border: 1px solid #dce3ea; + box-shadow: 0 7px 24px #203b5d16; + background: white; + border-radius: 9px; + padding: 10px 14px; + font-size: 11px; + white-space: nowrap; + pointer-events: none; +} +.history-tooltip span { + color: var(--muted); +} +.history-caption { + display: flex; + align-items: center; + justify-content: space-between; + margin-top: 9px; + font-family: var(--font-mono), monospace; + font-size: 9px; + color: #66717c; +} +.uptime { + display: flex; + gap: 5px; + align-items: center; +} +.uptime strong { + font-weight: 400; + color: #697681; +} +.caption-divider { + margin: 0 5px; + color: #bdc7cf; +} +.service-details { + font-size: 12px; + color: #66717c; + padding: 17px 0 2px; + line-height: 1.7; +} +.service-details > div { + display: flex; + gap: 24px; + margin-top: 8px; + flex-wrap: wrap; +} +.service-details strong { + font-weight: 400; + color: #495a6b; + margin-left: 7px; +} +.detail-note { + margin-top: 8px; + color: #9b742f; +} +.history-key { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + padding: 15px 1px 0; + font-size: 10px; + color: #66717c; +} +.history-key > div { + display: flex; + gap: 13px; +} +.history-key > div > span { + display: flex; + align-items: center; + gap: 5px; +} +.key-swatch { + display: inline-block; + width: 7px; + height: 7px; + border-radius: 2px; +} +.incidents .section-heading { + padding-top: 44px; +} +.period-label { + font-size: 11px; + color: #66717c; +} +.empty-incidents { + display: flex; + gap: 16px; + align-items: center; + border: 1px solid var(--line); + border-radius: 14px; + padding: 28px 25px; + background: #fafcfd; +} +.empty-icon { + display: grid; + place-items: center; + width: 36px; + height: 36px; + flex-shrink: 0; + border: 1px solid #e2e9ee; + border-radius: 50%; + background: #f1f5f7; + color: #819d91; +} +.empty-incidents h3 { + font-weight: 400; + font-size: 14px; + letter-spacing: -0.15px; +} +.empty-incidents p { + margin-top: 6px; + font-size: 11px; + color: #66717c; +} +.empty-flower { + font-size: 36px; + color: #73879b; + margin-left: auto; + font-weight: 300; +} +.incident-list { + border: 1px solid var(--line); + border-radius: 14px; + overflow: hidden; +} +.incident { + display: flex; + gap: 28px; + padding: 26px; + font-size: 13px; +} +.incident + .incident { + border-top: 1px solid var(--line); +} +.incident-date { + flex-shrink: 0; + width: 58px; + color: #66717c; + font-family: var(--font-mono), monospace; + font-size: 11px; + padding-top: 3px; +} +.incident-tag { + font-size: 10px; + padding: 3px 7px; + border-radius: 4px; +} +.incident-tag.operational { + background: #e9f3ed; + color: #477b60; +} +.incident-tag.outage { + background: #fae9e6; + color: #97483f; +} +.incident-tag.degraded { + background: #faf0d9; + color: #886020; +} +.incident h3 { + font-size: 15px; + font-weight: 500; + margin-top: 10px; +} +.incident p { + font-size: 12px; + line-height: 1.6; + margin-top: 7px; + color: #66717c; +} +.incident .incident-time { + font-family: var(--font-mono), monospace; + font-size: 10px; + margin-top: 12px; +} +.more-incidents { + border: 1px solid var(--line); + border-radius: 7px; + padding: 9px 13px; + background: white; + display: flex; + align-items: center; + gap: 10px; + font-size: 12px; + margin: 18px auto 0; +} +.about-monitoring { + margin: 27px 0 35px; + border-bottom: 1px solid var(--line); + padding-bottom: 25px; + color: #66717c; + font-size: 12px; +} +.about-monitoring summary { + list-style: none; + cursor: pointer; + display: flex; + justify-content: space-between; + align-items: center; +} +.about-monitoring summary::-webkit-details-marker { + display: none; +} +.about-monitoring summary span { + font-size: 17px; + color: #9ca6b0; +} +.about-monitoring p { + padding: 15px 35px 0 0; + line-height: 1.9; +} +footer { + padding-bottom: 34px; + font-size: 10px; + color: #66717c; + display: flex; + justify-content: space-between; + align-items: center; + gap: 15px; +} +footer > div { + display: flex; + align-items: center; + gap: 7px; +} +.footer-dot { + width: 5px; + height: 5px; + border-radius: 50%; + background: #9aabb9; +} +footer nav { + display: flex; + gap: 22px; +} +footer a { + display: flex; + align-items: center; + gap: 4px; +} +footer svg { + width: 12px; + height: 12px; +} +.loading-services { + padding: 100px 20px; + text-align: center; + color: var(--muted); + font-size: 14px; +} + +@media (min-width: 1500px) { + .hero { + margin-left: auto; + margin-right: auto; + max-width: 1468px; + } +} + +@media (max-width: 640px) { + .site-header { + height: 72px; + padding: 0 23px; + } + .brand { + font-size: 22px; + gap: 7px; + } + .brand img { + width: 31px; + height: 31px; + } + .brand-divider { + margin-left: 9px; + margin-right: 4px; + height: 17px; + } + .brand-context { + font-size: 14px; + } + .back-link { + font-size: 12px; + gap: 4px; + } + .hero { + margin: 0 9px; + border-radius: 19px; + min-height: 305px; + } + .hero-content { + padding: 68px 16px 66px; + } + h1 { + letter-spacing: -1.8px; + font-size: clamp(32px, 8.5vw, 48px); + } + .hero-description { + font-size: 15px; + line-height: 1.5; + max-width: 240px; + margin: 16px auto 0; + } + .hero-link { + font-size: 11px; + margin-top: 22px; + } + .cloud-left { + width: 260px; + left: -175px; + top: 107px; + opacity: 0.7; + } + .cloud-right { + width: 310px; + right: -205px; + top: 120px; + opacity: 0.7; + } + .content { + padding: 0 21px; + margin-top: -24px; + } + .summary { + padding: 19px 17px; + gap: 10px; + border-radius: 13px; + } + .summary-main { + gap: 11px; + align-items: flex-start; + } + .summary-icon { + width: 32px; + height: 32px; + margin-top: 2px; + } + .summary-icon svg { + width: 18px; + height: 18px; + } + .summary h2 { + font-size: 16px; + letter-spacing: -0.4px; + } + .summary p { + font-size: 11px; + line-height: 1.6; + max-width: 220px; + } + .live-label { + font-size: 0; + gap: 0; + align-self: flex-start; + padding-top: 13px; + } + .live-label .dot { + width: 5px; + height: 5px; + } + .section-heading { + padding-top: 32px; + padding-bottom: 17px; + align-items: flex-start; + } + .section-heading h2 { + font-size: 20px; + } + .section-heading p { + font-size: 11px; + margin-top: 6px; + } + .update-label { + font-size: 9px; + padding-top: 5px; + } + .refresh-icon { + display: none; + } + .service { + padding: 18px 16px 16px; + } + .service-heading { + margin-bottom: 13px; + gap: 6px; + } + .service-name { + font-size: 12px; + gap: 7px; + } + .service-icon { + width: 27px; + height: 27px; + border-radius: 7px; + } + .service-icon svg { + width: 16px; + height: 16px; + } + .chevron { + display: none; + } + .status-label { + font-size: 9px; + gap: 4px; + } + .status-label .dot { + width: 4px; + height: 4px; + } + .bars { + margin: 0; + padding: 0; + border: 0; + gap: 1.5px; + height: 24px; + } + .bar { + border-radius: 1px; + } + .history-caption { + font-size: 8px; + margin-top: 8px; + } + .caption-divider { + margin: 0 3px; + } + .history-key { + flex-direction: column; + align-items: flex-start; + gap: 10px; + font-size: 9px; + } + .history-key > div { + gap: 14px; + } + .incidents .section-heading { + padding-top: 34px; + } + .period-label { + font-size: 10px; + padding-top: 7px; + white-space: nowrap; + } + .empty-incidents { + padding: 20px 16px; + gap: 10px; + } + .empty-incidents h3 { + font-size: 12px; + line-height: 1.6; + max-width: 240px; + } + .empty-incidents p { + font-size: 10px; + line-height: 1.7; + } + .empty-icon { + width: 30px; + height: 30px; + } + .empty-icon svg { + width: 18px; + height: 18px; + } + .empty-flower { + display: none; + } + .incident { + gap: 15px; + padding: 20px 16px; + } + .incident-date { + width: 45px; + font-size: 10px; + } + .incident h3 { + font-size: 14px; + } + .incident p { + font-size: 11px; + } + .about-monitoring { + margin-top: 23px; + padding-bottom: 22px; + margin-bottom: 25px; + font-size: 11px; + } + footer { + align-items: flex-start; + flex-direction: column; + font-size: 9px; + gap: 16px; + padding-bottom: 30px; + } + footer nav { + gap: 24px; + } +} + +@media (prefers-reduced-motion: reduce) { + html { + scroll-behavior: auto; + } + .bar, + .chevron, + .hero-link span { + transition: none; + } +} diff --git a/apps/status/app/layout.tsx b/apps/status/app/layout.tsx new file mode 100644 index 00000000000..90855e538c9 --- /dev/null +++ b/apps/status/app/layout.tsx @@ -0,0 +1,41 @@ +import type { Metadata } from "next"; +import { DM_Mono, Instrument_Sans, Source_Serif_4 } from "next/font/google"; +import type { ReactNode } from "react"; +import "./globals.css"; + +const sans = Instrument_Sans({ + subsets: ["latin"], + weight: ["400", "500", "600"], + variable: "--font-sans", + display: "swap", +}); +const serif = Source_Serif_4({ + subsets: ["latin"], + weight: ["300", "400"], + variable: "--font-serif", + display: "swap", +}); +const mono = DM_Mono({ + subsets: ["latin"], + weight: "400", + variable: "--font-mono", + display: "swap", +}); + +export const metadata: Metadata = { + metadataBase: new URL("https://status.cap.so"), + title: "Cap Status — A little peace of mind.", + description: "Live service health, uptime, and incident history for Cap.", + icons: { icon: "/cap-icon.svg" }, +}; + +export default function RootLayout({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} diff --git a/apps/status/app/page.tsx b/apps/status/app/page.tsx new file mode 100644 index 00000000000..51fca27a4a3 --- /dev/null +++ b/apps/status/app/page.tsx @@ -0,0 +1,5 @@ +import { StatusPage } from "../components/status-page"; + +export default function Page() { + return ; +} diff --git a/apps/status/components/icons.tsx b/apps/status/components/icons.tsx new file mode 100644 index 00000000000..ef4f012e262 --- /dev/null +++ b/apps/status/components/icons.tsx @@ -0,0 +1,68 @@ +export function Arrow({ diagonal = false }: { diagonal?: boolean }) { + return ( + + ); +} + +export function Check() { + return ( + + ); +} + +export function ServiceIcon({ id }: { id: string }) { + const paths: Record = { + website: + "M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0ZM3 12h18M12 3c3 3 3 15 0 18-3-3-3-15 0-18Z", + api: "m8 7-5 5 5 5m8-10 5 5-5 5m-3-12-2 14", + sharing: "M15 9h3a4 4 0 0 1 0 8h-4m-5-2H6a4 4 0 0 1 0-8h4m-2 5h8", + storage: + "M12 3c5 0 9 2 9 4s-4 4-9 4-9-2-9-4 4-4 9-4ZM3 7v10c0 2 4 4 9 4s9-2 9-4V7M3 12c0 2 4 4 9 4s9-2 9-4", + media: "M5 4h14v16H5zM5 8h14M5 16h14M9 4v4m6-4v4M9 16v4m6-4v4", + render: + "M7 18H6a4 4 0 0 1-1-8 7 7 0 0 1 13-2 5 5 0 0 1 0 10h-1m-8-4 3-3 3 3m-3-3v10", + }; + return ( + + ); +} diff --git a/apps/status/components/service-row.tsx b/apps/status/components/service-row.tsx new file mode 100644 index 00000000000..bf2b44c55ac --- /dev/null +++ b/apps/status/components/service-row.tsx @@ -0,0 +1,172 @@ +"use client"; + +import { useId, useState } from "react"; +import { availability, dayStatus } from "../lib/state"; +import type { Day, Health, PublicService } from "../lib/types"; +import { ServiceIcon } from "./icons"; + +export const LABELS: Record = { + operational: "Operational", + degraded: "Degraded", + outage: "Outage", + unknown: "No recent data", +}; + +export function formatUptime(days: Day[]): string { + const value = availability(days); + return value === null ? "—" : `${value === 100 ? "100" : value.toFixed(2)}%`; +} + +export function formatDate(date: string): string { + return new Date(date).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + timeZone: "UTC", + }); +} + +function dayDescription(day: Day) { + const checks = day.operational + day.degraded + day.outage; + return checks + ? `${formatUptime([day])} observed uptime · ${checks.toLocaleString()} checks` + : "No monitoring data"; +} + +export function ServiceRow({ service }: { service: PublicService }) { + const [expanded, setExpanded] = useState(false); + const [selected, setSelected] = useState(service.days.length - 1); + const [tooltip, setTooltip] = useState(false); + const detailsId = useId(); + const activeDay = service.days[selected]; + return ( +
+
+ + + + {LABELS[service.status]} + +
+
+
setTooltip(false)} + aria-label={`${service.name}: daily uptime history. Use arrow keys to explore.`} + > + {service.days.map((day, index) => ( +
+ {tooltip && activeDay && ( + + {formatDate(activeDay.date)} UTC + {dayDescription(activeDay)} + + )} +
+
+ 90 days ago + + {formatUptime(service.days)} uptime + ·Today + +
+ +
+ ); +} diff --git a/apps/status/components/status-page.tsx b/apps/status/components/status-page.tsx new file mode 100644 index 00000000000..5b193e3dab7 --- /dev/null +++ b/apps/status/components/status-page.tsx @@ -0,0 +1,386 @@ +"use client"; + +import Image from "next/image"; +import { useEffect, useId, useState } from "react"; +import { emptyState, isStale, publicSnapshot } from "../lib/state"; +import type { Health, Snapshot } from "../lib/types"; +import { Arrow, Check } from "./icons"; +import { formatDate, ServiceRow } from "./service-row"; + +const MESSAGES: Record = { + operational: { + title: "All systems operational", + description: "Everything is running smoothly. Happy recording.", + }, + degraded: { + title: "Some services are degraded", + description: "You may experience slower responses on the services below.", + }, + outage: { + title: "Some services are unavailable", + description: + "We’re detecting an interruption. See affected services below.", + }, + unknown: { + title: "Waiting for a clear picture", + description: "Some services don’t have recent monitoring data yet.", + }, +}; + +function overallStatus(statuses: Health[]): Health { + return statuses.includes("outage") + ? "outage" + : statuses.includes("degraded") + ? "degraded" + : statuses.includes("unknown") + ? "unknown" + : "operational"; +} + +export function StatusPage() { + const titleId = useId(); + const servicesId = useId(); + const incidentsId = useId(); + const [snapshot, setSnapshot] = useState(null); + const [unavailable, setUnavailable] = useState(false); + const [loading, setLoading] = useState(true); + const [now, setNow] = useState(null); + const [showAll, setShowAll] = useState(false); + + useEffect(() => { + let active = true; + let inFlight = false; + let controller: AbortController | null = null; + async function refresh() { + if (document.hidden) return; + setNow(Date.now()); + if (inFlight) return; + inFlight = true; + controller = new AbortController(); + const timeout = setTimeout(() => controller?.abort(), 10_000); + try { + const response = await fetch("/api/status", { + signal: controller.signal, + }); + if (!response.ok) throw new Error("Status unavailable"); + const data: Snapshot = await response.json(); + if (active) { + setSnapshot(data); + setUnavailable(false); + } + } catch { + if (active) setUnavailable(true); + } finally { + clearTimeout(timeout); + inFlight = false; + if (active) { + setLoading(false); + setNow(Date.now()); + } + } + } + setNow(Date.now()); + void refresh(); + const poll = setInterval(() => { + if (document.hidden) return; + setNow(Date.now()); + void refresh(); + }, 60_000); + const clock = setInterval(() => { + if (!document.hidden) setNow(Date.now()); + }, 15_000); + document.addEventListener("visibilitychange", refresh); + return () => { + active = false; + controller?.abort(); + clearInterval(poll); + clearInterval(clock); + document.removeEventListener("visibilitychange", refresh); + }; + }, []); + + const data = snapshot ?? (now ? publicSnapshot(emptyState(), now) : null); + const services = + data?.services.map((service) => ({ + ...service, + status: + unavailable || isStale(service.checkedAt, now ?? 0) + ? ("unknown" as const) + : service.status, + })) ?? []; + const overall = overallStatus( + services.length ? services.map((service) => service.status) : ["unknown"], + ); + const message = MESSAGES[overall]; + const incidents = data?.incidents ?? []; + const visibleIncidents = showAll ? incidents : incidents.slice(0, 5); + const fresh = Boolean( + snapshot?.checkedAt && + !isStale(snapshot.checkedAt, now ?? 0) && + !unavailable, + ); + const secondsAgo = + snapshot?.checkedAt && now + ? Math.max(0, Math.floor((now - Date.parse(snapshot.checkedAt)) / 1_000)) + : null; + + return ( + <> + + Skip to service status + +
+ + + Cap + + Status + + + Back to Cap + +
+
+
+ +
+

A little peace of mind.

+

+ A live look at the services behind your recordings. +

+ + Here’s how things are running + +
+
+
+
+
+ + {overall === "operational" ? ( + + ) : ( + + )} + +
+

+ {loading + ? "Checking system status" + : unavailable + ? "Status updates are unavailable" + : message.title} +

+

+ {loading + ? "Getting the latest service health." + : unavailable + ? "We can’t reach the monitor right now. Service health is unknown." + : message.description} +

+
+
+
+ + {fresh ? "Live" : loading ? "Connecting" : "Awaiting data"} +
+
+
+
+

Service status

+

Small checks. A clearer picture.

+
+
+ + {secondsAgo !== null && fresh + ? `Updated ${secondsAgo < 60 ? "just now" : `${Math.floor(secondsAgo / 60)}m ago`}` + : "Checks every 5 minutes"} +
+
+
+ {services.map((service) => ( + + ))} + {!data && ( +
Loading service health…
+ )} +
+
+ Daily uptime · last 90 days · UTC +
+ + + Operational + + + + Degraded + + + + Outage + + + + No data + +
+
+
+
+
+

Incident history

+

A little context, if something goes off track.

+
+ Past 90 days +
+ {visibleIncidents.length ? ( +
+ {visibleIncidents.map((incident) => { + const service = services.find( + (entry) => entry.id === incident.serviceId, + ); + return ( +
+
+ {formatDate(incident.startedAt)} +
+
+ + {incident.resolvedAt ? "Resolved" : "Detected"} + +

+ {service?.name ?? "Service"}{" "} + {incident.severity === "outage" + ? "interruption" + : "degraded performance"} +

+

+ Automated checks detected{" "} + {incident.severity === "outage" + ? "an availability issue" + : "reduced service health"} + . + {incident.resolvedAt + ? " Service recovered after two successful checks." + : " Recovery has not yet been confirmed."} +

+

+ {new Date(incident.startedAt).toLocaleTimeString( + "en-GB", + { + hour: "2-digit", + minute: "2-digit", + timeZone: "UTC", + }, + )}{" "} + UTC + {incident.resolvedAt + ? ` · Recovered ${formatDate(incident.resolvedAt)} at ${new Date(incident.resolvedAt).toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit", timeZone: "UTC" })} UTC` + : " · Ongoing"} +

+
+
+ ); + })} +
+ ) : ( +
+ + + +
+

+ {snapshot?.checkedAt + ? "Nothing to report. Just the way we like it." + : "Your incident history starts here."} +

+

+ {snapshot?.checkedAt + ? "No incidents recorded in the monitoring history available." + : "Incidents will appear here once monitoring begins."} +

+
+ +
+ )} + {incidents.length > 5 && ( + + )} +
+
+ + How we keep an eye on things + +

+ Lightweight checks run every five minutes, independently of this + page. We retry an unhealthy response before recording it, confirm + incidents across two checks, and confirm recovery twice. Grey bars + mean no data; gaps are never counted as uptime. Percentages + describe successful observed checks, not unmonitored time. Select + a service or a day to explore its history. All times are UTC. +

+
+ +
+
+ + ); +} diff --git a/apps/status/lib/probe.ts b/apps/status/lib/probe.ts new file mode 100644 index 00000000000..4408e90a71c --- /dev/null +++ b/apps/status/lib/probe.ts @@ -0,0 +1,79 @@ +import type { ProbeResult, ServiceDefinition } from "./types"; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +export function validateHealth( + service: ServiceDefinition, + body: unknown, +): boolean { + if (!isRecord(body)) return false; + if (service.validation === "media") return body.status === "ok"; + if (service.validation === "render") + return ( + body.ok === true && + typeof body.workers === "number" && + body.workers > 0 && + typeof body.slots === "number" && + body.slots > 0 + ); + if (service.validation === "auth") + return Object.values(body).some( + (provider) => + isRecord(provider) && + typeof provider.id === "string" && + typeof provider.type === "string", + ); + return true; +} + +async function attempt( + service: ServiceDefinition, + url: string, + fetcher: typeof fetch, +): Promise { + const start = performance.now(); + try { + const response = await fetcher(url, { + method: service.method, + redirect: "manual", + cache: "no-store", + signal: AbortSignal.timeout(8_000), + headers: { + "User-Agent": "Cap-Status/1.0", + Accept: service.validation ? "application/json" : "*/*", + }, + }); + let status: ProbeResult["status"] = response.ok ? "operational" : "outage"; + if ( + response.ok && + service.validation && + !validateHealth(service, await response.json()) + ) + status = "degraded"; + if (!response.bodyUsed) await response.body?.cancel(); + const latencyMs = Math.round(performance.now() - start); + return { + status: + status === "operational" && latencyMs > 3_000 ? "degraded" : status, + latencyMs, + }; + } catch { + return { + status: "outage", + latencyMs: Math.round(performance.now() - start), + }; + } +} + +export async function probe( + service: ServiceDefinition, + url: string, + fetcher = fetch, +): Promise { + const first = await attempt(service, url, fetcher); + return first.status === "operational" + ? first + : attempt(service, url, fetcher); +} diff --git a/apps/status/lib/services.ts b/apps/status/lib/services.ts new file mode 100644 index 00000000000..60e423289b7 --- /dev/null +++ b/apps/status/lib/services.ts @@ -0,0 +1,69 @@ +import type { ServiceDefinition } from "./types"; + +export const SERVICES: ServiceDefinition[] = [ + { + id: "website", + name: "Website", + description: "The Cap website and downloads.", + env: "STATUS_WEBSITE_URL", + defaultUrl: "https://cap.so", + method: "HEAD", + }, + { + id: "api", + name: "API & authentication", + description: "Availability of Cap’s authentication API.", + env: "STATUS_API_URL", + defaultUrl: "https://cap.so/api/auth/providers", + method: "GET", + validation: "auth", + }, + { + id: "sharing", + name: "Video sharing", + description: "Opening and viewing shared recordings.", + env: "STATUS_SHARING_URL", + method: "HEAD", + }, + { + id: "storage", + name: "Storage & delivery", + description: "Access to recordings on Cap’s storage and CDN.", + env: "STATUS_STORAGE_URL", + method: "HEAD", + }, + { + id: "media", + name: "Media processing", + description: "The service that processes and converts your recordings.", + env: "STATUS_MEDIA_URL", + method: "GET", + validation: "media", + }, + { + id: "render", + name: "Cloud rendering", + description: "The render coordinator and its available workers.", + env: "STATUS_RENDER_URL", + method: "GET", + validation: "render", + }, +]; + +export function serviceUrl(service: ServiceDefinition): string | undefined { + const value = process.env[service.env]?.trim() || service.defaultUrl; + if (!value) return undefined; + const url = new URL(value); + if (url.protocol !== "https:" || url.username || url.password) { + throw new Error(`Invalid HTTPS endpoint for ${service.id}`); + } + return url.toString(); +} + +export function isConfigured(service: ServiceDefinition): boolean { + try { + return Boolean(serviceUrl(service)); + } catch { + return false; + } +} diff --git a/apps/status/lib/state.ts b/apps/status/lib/state.ts new file mode 100644 index 00000000000..810c8dc8780 --- /dev/null +++ b/apps/status/lib/state.ts @@ -0,0 +1,190 @@ +import { isConfigured, SERVICES } from "./services"; +import type { + Day, + Health, + MonitorState, + ProbeResult, + ServiceState, + Snapshot, +} from "./types"; + +export const CHECK_INTERVAL_MS = 5 * 60_000; +export const STALE_AFTER_MS = 3 * CHECK_INTERVAL_MS; +export const HISTORY_DAYS = 90; +const DAY_MS = 86_400_000; + +export function emptyState(): MonitorState { + return { version: 1, checkedAt: null, services: {}, incidents: [] }; +} + +export function emptyService(): ServiceState { + return { + status: "unknown", + checkedAt: null, + latencyMs: null, + failures: 0, + successes: 0, + issueStartedAt: null, + incidentId: null, + days: [], + }; +} + +export function dates(now: number): string[] { + return Array.from({ length: HISTORY_DAYS }, (_, index) => + new Date(now - (HISTORY_DAYS - 1 - index) * DAY_MS) + .toISOString() + .slice(0, 10), + ); +} + +export function isStale(checkedAt: string | null, now: number): boolean { + return !checkedAt || now - Date.parse(checkedAt) > STALE_AFTER_MS; +} + +export function dayStatus(day: Day): Health { + if (day.outage) return "outage"; + if (day.degraded) return "degraded"; + return day.operational ? "operational" : "unknown"; +} + +export function availability(days: Day[]): number | null { + const observed = days.reduce( + (sum, day) => sum + day.operational + day.degraded + day.outage, + 0, + ); + return observed + ? (days.reduce((sum, day) => sum + day.operational, 0) / observed) * 100 + : null; +} + +export function applyResult( + state: MonitorState, + id: string, + result: ProbeResult | null, + now: number, +): void { + const previous = state.services[id] ?? emptyService(); + const time = new Date(now).toISOString(); + const cutoff = dates(now)[0]; + previous.days = previous.days.filter((day) => day.date >= cutoff); + if (!result) { + previous.status = "unknown"; + previous.checkedAt = null; + previous.latencyMs = null; + previous.failures = 0; + previous.successes = 0; + previous.issueStartedAt = null; + state.services[id] = previous; + return; + } + if ( + previous.checkedAt && + Math.floor(Date.parse(previous.checkedAt) / CHECK_INTERVAL_MS) >= + Math.floor(now / CHECK_INTERVAL_MS) + ) + return; + if (isStale(previous.checkedAt, now)) { + previous.failures = 0; + previous.successes = 0; + previous.issueStartedAt = null; + previous.status = "unknown"; + } + const day = previous.days.find( + (entry) => entry.date === time.slice(0, 10), + ) ?? { date: time.slice(0, 10), operational: 0, degraded: 0, outage: 0 }; + if (!previous.days.includes(day)) previous.days.push(day); + day[result.status]++; + previous.checkedAt = time; + previous.latencyMs = result.latencyMs; + if (result.status === "operational") { + previous.successes++; + previous.failures = 0; + previous.issueStartedAt = null; + if (!previous.incidentId || previous.successes >= 2) { + previous.status = "operational"; + const incident = state.incidents.find( + (entry) => entry.id === previous.incidentId, + ); + if (incident) incident.resolvedAt = time; + previous.incidentId = null; + } + } else { + previous.successes = 0; + previous.failures++; + previous.issueStartedAt ??= time; + if (previous.failures >= 2) { + previous.status = result.status; + if (!previous.incidentId) { + previous.incidentId = `${id}-${now}`; + state.incidents.unshift({ + id: previous.incidentId, + serviceId: id, + severity: result.status, + startedAt: previous.issueStartedAt, + resolvedAt: null, + }); + } else { + const incident = state.incidents.find( + (entry) => entry.id === previous.incidentId, + ); + if (incident && result.status === "outage") + incident.severity = "outage"; + } + } else if (previous.status === "operational") { + previous.status = "degraded"; + } + } + state.services[id] = previous; + state.incidents = state.incidents + .filter((entry) => !entry.resolvedAt) + .concat( + state.incidents + .filter( + (entry) => + entry.resolvedAt && + Date.parse(entry.resolvedAt) >= now - HISTORY_DAYS * DAY_MS, + ) + .slice(0, 500), + ) + .sort((a, b) => Date.parse(b.startedAt) - Date.parse(a.startedAt)); +} + +export function publicSnapshot( + state: MonitorState, + now = Date.now(), +): Snapshot { + return { + checkedAt: state.checkedAt, + services: SERVICES.map((service) => { + const saved = state.services[service.id] ?? emptyService(); + const configured = isConfigured(service); + return { + id: service.id, + name: service.name, + description: service.description, + configured, + status: + !configured || isStale(saved.checkedAt, now) + ? "unknown" + : saved.status, + checkedAt: saved.checkedAt, + latencyMs: saved.latencyMs, + days: dates(now).map( + (date) => + saved.days.find((day) => day.date === date) ?? { + date, + operational: 0, + degraded: 0, + outage: 0, + }, + ), + }; + }), + incidents: state.incidents.filter( + (entry) => + !entry.resolvedAt || + Date.parse(entry.resolvedAt) >= now - HISTORY_DAYS * DAY_MS, + ), + }; +} diff --git a/apps/status/lib/store.ts b/apps/status/lib/store.ts new file mode 100644 index 00000000000..e363cfc9ce2 --- /dev/null +++ b/apps/status/lib/store.ts @@ -0,0 +1,37 @@ +import { Redis } from "@upstash/redis"; +import { emptyState } from "./state"; +import type { MonitorState } from "./types"; + +export const STATE_KEY = "cap:status:v1:state"; +export const LOCK_KEY = "cap:status:v1:lock"; + +export function getRedis(): Redis { + const url = process.env.UPSTASH_REDIS_REST_URL; + const token = process.env.UPSTASH_REDIS_REST_TOKEN; + if (!url || !token) throw new Error("Status storage is not configured"); + return new Redis({ + url, + token, + retry: { retries: 1 }, + signal: () => AbortSignal.timeout(5_000), + }); +} + +export async function readState(redis: Redis): Promise { + const state = await redis.get(STATE_KEY); + if (state && state.version !== 1) + throw new Error("Unsupported status data version"); + return state ?? emptyState(); +} + +export const COMMIT_SCRIPT = ` +if redis.call("GET", KEYS[1]) ~= ARGV[1] then return 0 end +redis.call("SET", KEYS[2], ARGV[2]) +redis.call("DEL", KEYS[1]) +return 1 +`; + +export const RELEASE_SCRIPT = ` +if redis.call("GET", KEYS[1]) == ARGV[1] then return redis.call("DEL", KEYS[1]) end +return 0 +`; diff --git a/apps/status/lib/types.ts b/apps/status/lib/types.ts new file mode 100644 index 00000000000..e71d65b62da --- /dev/null +++ b/apps/status/lib/types.ts @@ -0,0 +1,66 @@ +export type Health = "operational" | "degraded" | "outage" | "unknown"; + +export type Day = { + date: string; + operational: number; + degraded: number; + outage: number; +}; + +export type Incident = { + id: string; + serviceId: string; + severity: "degraded" | "outage"; + startedAt: string; + resolvedAt: string | null; +}; + +export type ServiceState = { + status: Health; + checkedAt: string | null; + latencyMs: number | null; + failures: number; + successes: number; + issueStartedAt: string | null; + incidentId: string | null; + days: Day[]; +}; + +export type MonitorState = { + version: 1; + checkedAt: string | null; + services: Record; + incidents: Incident[]; +}; + +export type ServiceDefinition = { + id: string; + name: string; + description: string; + env: string; + defaultUrl?: string; + method: "GET" | "HEAD"; + validation?: "auth" | "media" | "render"; +}; + +export type PublicService = Pick< + ServiceDefinition, + "id" | "name" | "description" +> & { + status: Health; + checkedAt: string | null; + latencyMs: number | null; + configured: boolean; + days: Day[]; +}; + +export type Snapshot = { + checkedAt: string | null; + services: PublicService[]; + incidents: Incident[]; +}; + +export type ProbeResult = { + status: Exclude; + latencyMs: number; +}; diff --git a/apps/status/next.config.ts b/apps/status/next.config.ts new file mode 100644 index 00000000000..7eca8d8ebe7 --- /dev/null +++ b/apps/status/next.config.ts @@ -0,0 +1,19 @@ +import type { NextConfig } from "next"; + +const config: NextConfig = { + poweredByHeader: false, + async headers() { + return [ + { + source: "/:path*", + headers: [ + { key: "X-Content-Type-Options", value: "nosniff" }, + { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" }, + { key: "X-Frame-Options", value: "DENY" }, + ], + }, + ]; + }, +}; + +export default config; diff --git a/apps/status/package.json b/apps/status/package.json new file mode 100644 index 00000000000..443c40e40a2 --- /dev/null +++ b/apps/status/package.json @@ -0,0 +1,24 @@ +{ + "name": "@cap/status", + "version": "1.0.0", + "private": true, + "scripts": { + "dev": "next dev --port 3003", + "build": "next build", + "start": "next start --port 3003", + "typecheck": "next typegen && tsc --noEmit", + "test": "bun test tests" + }, + "dependencies": { + "@upstash/redis": "1.39.0", + "next": "16.3.0", + "react": "19.2.4", + "react-dom": "19.2.4" + }, + "devDependencies": { + "@types/node": "^20.11.5", + "@types/react": "19.2.14", + "@types/react-dom": "19.2.3", + "typescript": "^5.8.3" + } +} diff --git a/apps/status/public/cap-icon.svg b/apps/status/public/cap-icon.svg new file mode 100644 index 00000000000..7071770d13d --- /dev/null +++ b/apps/status/public/cap-icon.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/apps/status/public/cloud-left.webp b/apps/status/public/cloud-left.webp new file mode 100644 index 00000000000..a6698735822 Binary files /dev/null and b/apps/status/public/cloud-left.webp differ diff --git a/apps/status/public/cloud-right.webp b/apps/status/public/cloud-right.webp new file mode 100644 index 00000000000..443d307ccc3 Binary files /dev/null and b/apps/status/public/cloud-right.webp differ diff --git a/apps/status/screenshots/desktop.png b/apps/status/screenshots/desktop.png new file mode 100644 index 00000000000..03ac57cd98f Binary files /dev/null and b/apps/status/screenshots/desktop.png differ diff --git a/apps/status/screenshots/mobile.png b/apps/status/screenshots/mobile.png new file mode 100644 index 00000000000..9e54765fef9 Binary files /dev/null and b/apps/status/screenshots/mobile.png differ diff --git a/apps/status/tests/probe.test.ts b/apps/status/tests/probe.test.ts new file mode 100644 index 00000000000..ec2498b8e15 --- /dev/null +++ b/apps/status/tests/probe.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, test } from "bun:test"; +import { probe, validateHealth } from "../lib/probe"; +import { SERVICES, serviceUrl } from "../lib/services"; + +const website = SERVICES[0]; +const auth = SERVICES[1]; +const media = SERVICES[4]; +const render = SERVICES[5]; + +describe("lightweight probes", () => { + test("uses bounded HEAD requests without redirects or a response download", async () => { + let calls = 0; + const fetcher = (async (_url, options) => { + calls++; + expect(options?.method).toBe("HEAD"); + expect(options?.redirect).toBe("manual"); + expect(options?.cache).toBe("no-store"); + expect(options?.signal).toBeInstanceOf(AbortSignal); + return new Response(null, { status: 200 }); + }) as typeof fetch; + expect((await probe(website, "https://example.com", fetcher)).status).toBe( + "operational", + ); + expect(calls).toBe(1); + }); + + test("a transient failure is retried exactly once", async () => { + let calls = 0; + const fetcher = (async () => + new Response(null, { + status: ++calls === 1 ? 503 : 200, + })) as typeof fetch; + expect((await probe(website, "https://example.com", fetcher)).status).toBe( + "operational", + ); + expect(calls).toBe(2); + }); + + test("network errors, redirects, and invalid JSON never report healthy", async () => { + for (const fetcher of [ + async () => { + throw new Error("unreachable"); + }, + async () => new Response(null, { status: 302 }), + async () => new Response("not JSON"), + ]) + expect( + (await probe(auth, "https://example.com", fetcher as typeof fetch)) + .status, + ).toBe("outage"); + }); + + test("validates health payloads even when HTTP returns 200", async () => { + expect(validateHealth(media, { status: "degraded" })).toBe(false); + expect(validateHealth(render, { ok: true, workers: 0, slots: 0 })).toBe( + false, + ); + expect(validateHealth(render, { ok: true, workers: 2, slots: 4 })).toBe( + true, + ); + expect(validateHealth(auth, {})).toBe(false); + const fetcher = (async () => + Response.json({ status: "ok" })) as typeof fetch; + expect((await probe(media, "https://example.com", fetcher)).status).toBe( + "operational", + ); + expect((await probe(render, "https://example.com", fetcher)).status).toBe( + "degraded", + ); + }); + + test("never permits credentials or non-HTTPS monitor URLs", () => { + const definition = { + ...website, + env: "STATUS_TEST_URL", + defaultUrl: "http://example.com", + }; + expect(() => serviceUrl(definition)).toThrow(); + expect(() => + serviceUrl({ + ...definition, + defaultUrl: "https://user:password@example.com", + }), + ).toThrow(); + }); +}); diff --git a/apps/status/tests/routes.test.ts b/apps/status/tests/routes.test.ts new file mode 100644 index 00000000000..d1c77ca9fa3 --- /dev/null +++ b/apps/status/tests/routes.test.ts @@ -0,0 +1,225 @@ +import { afterAll, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import { GET as collect } from "../app/api/cron/check/route"; +import { GET as readStatus } from "../app/api/status/route"; +import { SERVICES } from "../lib/services"; +import { CHECK_INTERVAL_MS, emptyState } from "../lib/state"; +import { LOCK_KEY, STATE_KEY } from "../lib/store"; +import deployment from "../vercel.json"; + +const values = new Map(); +const realFetch = globalThis.fetch; +let storageCommands = 0; +const variableNames = [ + "UPSTASH_REDIS_REST_URL", + "UPSTASH_REDIS_REST_TOKEN", + "CRON_SECRET", + "VERCEL_ENV", + ...SERVICES.map((service) => service.env), +]; +const previousEnvironment = Object.fromEntries( + variableNames.map((key) => [key, process.env[key]]), +); +let probeCalls = 0; +let failStorage = false; +let stealLease = false; + +function command(args: string[]) { + storageCommands++; + const [name, key, value] = args; + if (name.toUpperCase() === "SET") { + if (args.some((arg) => arg.toUpperCase() === "NX") && values.has(key)) + return null; + values.set(key, value); + return "OK"; + } + if (name.toUpperCase() === "GET") return values.get(key) ?? null; + if (name.toUpperCase() === "EVAL") { + const count = Number(args[2]); + const keys = args.slice(3, 3 + count); + const parameters = args.slice(3 + count); + if (values.get(keys[0]) !== parameters[0]) return 0; + if (count === 2) values.set(keys[1], parameters[1]); + values.delete(keys[0]); + return 1; + } + throw new Error(`Unexpected Redis command: ${name}`); +} + +const server = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + async fetch(request) { + if (failStorage) + return Response.json({ error: "unavailable" }, { status: 503 }); + const args = await request.json(); + return Response.json( + Array.isArray(args[0]) + ? args.map((entry: string[]) => ({ result: command(entry) })) + : { result: command(args) }, + ); + }, +}); + +beforeEach(() => { + values.clear(); + storageCommands = 0; + probeCalls = 0; + failStorage = false; + stealLease = false; + process.env.UPSTASH_REDIS_REST_URL = server.url.toString(); + process.env.UPSTASH_REDIS_REST_TOKEN = "test-token"; + process.env.CRON_SECRET = "test-secret"; + process.env.VERCEL_ENV = "production"; + for (const service of SERVICES) + process.env[service.env] = `https://status-fixture.example/${service.id}`; + globalThis.fetch = (async (input, options) => { + const url = new URL(String(input)); + if (url.hostname !== "status-fixture.example") + return realFetch(input, options); + probeCalls++; + await new Promise((resolve) => setTimeout(resolve, 5)); + if (stealLease) values.set(LOCK_KEY, "another-owner"); + if (url.pathname === "/api") + return Response.json({ google: { id: "google", type: "oauth" } }); + if (url.pathname === "/media") return Response.json({ status: "ok" }); + if (url.pathname === "/render") + return Response.json({ ok: true, workers: 2, slots: 4 }); + return new Response(null, { status: 200 }); + }) as typeof fetch; +}); + +afterAll(() => { + globalThis.fetch = realFetch; + server.stop(true); + for (const key of variableNames) { + if (previousEnvironment[key] === undefined) delete process.env[key]; + else process.env[key] = previousEnvironment[key]; + } +}); + +const authorizedRequest = () => + new Request("https://status.example/api/cron/check", { + headers: { authorization: "Bearer test-secret" }, + }); + +describe("scheduler → storage → public API", () => { + test("rejects unauthorized or missing-secret requests before doing work", async () => { + expect( + (await collect(new Request("https://status.example/api/cron/check"))) + .status, + ).toBe(401); + delete process.env.CRON_SECRET; + expect((await collect(authorizedRequest())).status).toBe(401); + expect(probeCalls).toBe(0); + expect(values.size).toBe(0); + }); + + test("rejects cache-busting queries before accessing storage", async () => { + for (const query of [ + "?x=one", + "?x=two", + "?cache=false×tamp=123", + "?%78=encoded", + ]) { + const response = await readStatus( + new Request(`https://status.example/api/status${query}`), + ); + expect(response.status).toBe(400); + expect(response.headers.get("cache-control")).toBe("no-store"); + } + expect(storageCommands).toBe(0); + expect(probeCalls).toBe(0); + }); + + test("previews never probe production services", async () => { + process.env.VERCEL_ENV = "preview"; + expect(await (await collect(authorizedRequest())).json()).toEqual({ + skipped: "Preview deployment", + }); + expect(probeCalls).toBe(0); + }); + + test("persists real observations and serves them without running new probes", async () => { + expect((await collect(authorizedRequest())).status).toBe(200); + expect(probeCalls).toBe(6); + expect(values.has(STATE_KEY)).toBe(true); + expect(values.has(LOCK_KEY)).toBe(false); + const response = await readStatus( + new Request("https://status.example/api/status"), + ); + const snapshot = await response.json(); + expect( + snapshot.services.every( + (service: { status: string }) => service.status === "operational", + ), + ).toBe(true); + expect(JSON.stringify(snapshot)).not.toContain("status-fixture.example"); + expect(JSON.stringify(snapshot)).not.toContain("test-token"); + expect(response.headers.get("cache-control")).toContain("s-maxage=30"); + expect(probeCalls).toBe(6); + expect(await (await collect(authorizedRequest())).json()).toEqual({ + skipped: "Check interval already sampled", + }); + expect(probeCalls).toBe(6); + }); + + test("limits collection to the configured five-minute interval", async () => { + expect(deployment.crons[0].schedule).toBe("*/5 * * * *"); + const start = + Math.floor(Date.now() / CHECK_INTERVAL_MS) * CHECK_INTERVAL_MS; + values.set( + STATE_KEY, + JSON.stringify({ + ...emptyState(), + checkedAt: new Date(start).toISOString(), + }), + ); + const clock = spyOn(Date, "now").mockReturnValue(start + 2 * 60_000); + try { + expect(await (await collect(authorizedRequest())).json()).toEqual({ + skipped: "Check interval already sampled", + }); + expect(probeCalls).toBe(0); + clock.mockReturnValue(start + CHECK_INTERVAL_MS); + expect((await collect(authorizedRequest())).status).toBe(200); + expect(probeCalls).toBe(6); + } finally { + clock.mockRestore(); + } + }); + + test("overlapping invocations perform a single collection", async () => { + const responses = await Promise.all([ + collect(authorizedRequest()), + collect(authorizedRequest()), + ]); + expect(probeCalls).toBe(6); + expect( + (await Promise.all(responses.map((response) => response.json()))).some( + (body) => body.skipped === "Check already running", + ), + ).toBe(true); + }); + + test("a lost lease cannot commit or remove a newer owner's lock", async () => { + stealLease = true; + expect((await collect(authorizedRequest())).status).toBe(503); + expect(values.has(STATE_KEY)).toBe(false); + expect(values.get(LOCK_KEY)).toBe("another-owner"); + }); + + test("storage outages return unknown data without cached success", async () => { + failStorage = true; + const response = await readStatus( + new Request("https://status.example/api/status"), + ); + expect(response.status).toBe(503); + expect(response.headers.get("cache-control")).toBe("no-store"); + expect( + (await response.json()).services.every( + (service: { status: string }) => service.status === "unknown", + ), + ).toBe(true); + expect(probeCalls).toBe(0); + }); +}); diff --git a/apps/status/tests/state.test.ts b/apps/status/tests/state.test.ts new file mode 100644 index 00000000000..17f980c3da1 --- /dev/null +++ b/apps/status/tests/state.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, test } from "bun:test"; +import { + applyResult, + availability, + CHECK_INTERVAL_MS, + dates, + emptyState, + isStale, + publicSnapshot, + STALE_AFTER_MS, +} from "../lib/state"; + +const start = Date.UTC(2026, 9, 13, 12); +const good = { status: "operational" as const, latencyMs: 120 }; +const bad = { status: "outage" as const, latencyMs: 8_000 }; + +describe("monitor state", () => { + test("does not invent history or healthy services before the first check", () => { + const snapshot = publicSnapshot(emptyState(), start); + expect(snapshot.services).toHaveLength(6); + expect( + snapshot.services.every((service) => service.status === "unknown"), + ).toBe(true); + expect(snapshot.services[0].days).toHaveLength(90); + expect(availability(snapshot.services[0].days)).toBeNull(); + }); + + test("confirms an incident and recovery over two consecutive samples", () => { + const state = emptyState(); + applyResult(state, "website", good, start); + applyResult(state, "website", bad, start + CHECK_INTERVAL_MS); + expect(state.services.website.status).toBe("degraded"); + expect(state.incidents).toHaveLength(0); + applyResult(state, "website", bad, start + 2 * CHECK_INTERVAL_MS); + expect(state.services.website.status).toBe("outage"); + expect(state.incidents).toHaveLength(1); + expect(state.incidents[0].startedAt).toBe( + new Date(start + CHECK_INTERVAL_MS).toISOString(), + ); + applyResult(state, "website", good, start + 3 * CHECK_INTERVAL_MS); + expect(state.services.website.status).toBe("outage"); + expect(state.incidents[0].resolvedAt).toBeNull(); + applyResult(state, "website", good, start + 4 * CHECK_INTERVAL_MS); + expect(state.services.website.status).toBe("operational"); + expect(state.incidents[0].resolvedAt).toBe( + new Date(start + 4 * CHECK_INTERVAL_MS).toISOString(), + ); + expect(state.incidents).toHaveLength(1); + }); + + test("ignores duplicate and out-of-order samples", () => { + const state = emptyState(); + applyResult(state, "website", good, start); + applyResult(state, "website", bad, start + CHECK_INTERVAL_MS - 1); + applyResult(state, "website", bad, start - CHECK_INTERVAL_MS); + expect(state.services.website.days[0].operational).toBe(1); + expect(state.services.website.days[0].outage).toBe(0); + expect(state.services.website.status).toBe("operational"); + }); + + test("does not combine failures separated by a monitoring gap", () => { + const state = emptyState(); + applyResult(state, "website", bad, start); + applyResult(state, "website", bad, start + STALE_AFTER_MS + 1); + expect(state.incidents).toHaveLength(0); + expect(state.services.website.status).toBe("unknown"); + }); + + test("marks old observations unknown without erasing the incident or history", () => { + const state = emptyState(); + applyResult(state, "website", bad, start); + applyResult(state, "website", bad, start + CHECK_INTERVAL_MS); + const snapshot = publicSnapshot( + state, + start + CHECK_INTERVAL_MS + STALE_AFTER_MS + 1, + ); + expect(snapshot.services[0].status).toBe("unknown"); + expect(snapshot.incidents[0].resolvedAt).toBeNull(); + expect(availability(snapshot.services[0].days)).toBe(0); + }); + + test("retains exactly 90 calendar days across UTC boundaries", () => { + const state = emptyState(); + applyResult(state, "website", good, start - 90 * 86_400_000); + applyResult(state, "website", good, start - 89 * 86_400_000); + applyResult(state, "website", good, start); + expect(state.services.website.days).toHaveLength(2); + expect(dates(start)).toHaveLength(90); + expect(dates(start).at(-1)).toBe("2026-10-13"); + expect( + isStale(new Date(start).toISOString(), start + STALE_AFTER_MS + 1), + ).toBe(true); + }); + + test("unconfigured services stay unknown and do not manufacture an outage", () => { + const state = emptyState(); + applyResult(state, "media", null, start); + expect(state.services.media.status).toBe("unknown"); + expect(state.services.media.days).toHaveLength(0); + expect(state.incidents).toHaveLength(0); + }); + + test("reports observed availability without filling gaps with successes", () => { + expect( + availability([ + { date: "2026-10-13", operational: 9, degraded: 0, outage: 1 }, + ]), + ).toBe(90); + expect( + availability([ + { date: "2026-10-13", operational: 0, degraded: 0, outage: 0 }, + ]), + ).toBeNull(); + }); + + test("expired resolved incidents disappear even when the collector stops", () => { + const state = emptyState(); + state.incidents = [ + { + id: "old", + serviceId: "website", + severity: "outage", + startedAt: new Date(start - 92 * 86_400_000).toISOString(), + resolvedAt: new Date(start - 91 * 86_400_000).toISOString(), + }, + { + id: "ongoing", + serviceId: "website", + severity: "outage", + startedAt: new Date(start - 92 * 86_400_000).toISOString(), + resolvedAt: null, + }, + ]; + expect( + publicSnapshot(state, start).incidents.map((entry) => entry.id), + ).toEqual(["ongoing"]); + }); +}); diff --git a/apps/status/tsconfig.json b/apps/status/tsconfig.json new file mode 100644 index 00000000000..a681bfbaf4f --- /dev/null +++ b/apps/status/tsconfig.json @@ -0,0 +1,30 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["dom", "dom.iterable", "esnext"], + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "react-jsx", + "incremental": true, + "skipLibCheck": true, + "plugins": [ + { + "name": "next" + } + ], + "allowJs": true + }, + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts", + ".next/dev/types/**/*.ts" + ], + "exclude": ["node_modules", "tests"] +} diff --git a/apps/status/vercel.json b/apps/status/vercel.json new file mode 100644 index 00000000000..ec0f9ec2525 --- /dev/null +++ b/apps/status/vercel.json @@ -0,0 +1,6 @@ +{ + "$schema": "https://openapi.vercel.sh/vercel.json", + "framework": "nextjs", + "buildCommand": "bun run build", + "crons": [{ "path": "/api/cron/check", "schedule": "*/5 * * * *" }] +} diff --git a/bun.lock b/bun.lock index bf5178d7525..714da3725b6 100644 --- a/bun.lock +++ b/bun.lock @@ -228,6 +228,22 @@ "@types/bun": "1.4.0", }, }, + "apps/status": { + "name": "@cap/status", + "version": "1.0.0", + "dependencies": { + "@upstash/redis": "1.39.0", + "next": "16.3.0", + "react": "19.2.4", + "react-dom": "19.2.4", + }, + "devDependencies": { + "@types/node": "^20.11.5", + "@types/react": "19.2.14", + "@types/react-dom": "19.2.3", + "typescript": "^5.8.3", + }, + }, "apps/web": { "name": "@cap/web", "version": "0.3.1", @@ -996,6 +1012,8 @@ "@cap/sdk-recorder": ["@cap/sdk-recorder@workspace:packages/sdk-recorder"], + "@cap/status": ["@cap/status@workspace:apps/status"], + "@cap/ui": ["@cap/ui@workspace:packages/ui"], "@cap/ui-solid": ["@cap/ui-solid@workspace:packages/ui-solid"], @@ -2706,6 +2724,8 @@ "@unrs/resolver-binding-win32-x64-msvc": ["@unrs/resolver-binding-win32-x64-msvc@1.12.2", "", { "os": "win32", "cpu": "x64" }, "sha512-nAB74NfSNKknqQ1RrYj6uz8FcXEomu/MATJZxh/x+BArzN2U3JbOYC0APYzUIGhVY3m5hRxA8VPNdPBoG8txlA=="], + "@upstash/redis": ["@upstash/redis@1.39.0", "", { "dependencies": { "uncrypto": "^0.1.3" } }, "sha512-VwkpYTdxSfjSulQS5h16NHtg/1UEheBfvez2AeGKinAujOoGXhXCcohQRzsQ/d2g/XzbjzTVGhG8Ds8uOI0Mxg=="], + "@vercel/cli-auth": ["@vercel/cli-auth@0.0.1", "", { "dependencies": { "async-listen": "3.0.0", "open": "8.4.0", "xdg-app-paths": "5.1.0", "zod": "4.1.11" } }, "sha512-CnqiuMlZ4pjs2LCPYiR6aLKPPd3Xb8SBI1Y7eotXKgpx6qgrGNY+E7EIyUt5ErGHJGIrCZyGG5WEo4bHtVmz2Q=="], "@vercel/cli-config": ["@vercel/cli-config@0.2.0", "", { "dependencies": { "xdg-app-paths": "5.5.1", "zod": "4.1.11" } }, "sha512-fJRRRB7734BDuXZ89yBEaA2ncYhH7bWX30mk04W80J6VAfQc+4iB8lyzAdaGpFV3/vNlkt9VZt+/uoQoWX6UsQ=="], @@ -5944,6 +5964,10 @@ "@cap/env/@types/node": ["@types/node@22.20.0", "", { "dependencies": { "undici-types": "6.21.0" } }, "sha512-QWlFW2wf3nTjC13/DqRnBpR4ZO36VJH/JVBkA/vcnmbTBNQIlnObqyqZE1tUR7+Ni23Lda8R1BxMfbXRpCUx5g=="], + "@cap/status/react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="], + + "@cap/status/react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="], + "@cap/ui/@tauri-apps/cli": ["@tauri-apps/cli@1.6.3", "", { "dependencies": { "semver": "7.8.5" }, "optionalDependencies": { "@tauri-apps/cli-darwin-arm64": "1.6.3", "@tauri-apps/cli-darwin-x64": "1.6.3", "@tauri-apps/cli-linux-arm-gnueabihf": "1.6.3", "@tauri-apps/cli-linux-arm64-gnu": "1.6.3", "@tauri-apps/cli-linux-arm64-musl": "1.6.3", "@tauri-apps/cli-linux-x64-gnu": "1.6.3", "@tauri-apps/cli-linux-x64-musl": "1.6.3", "@tauri-apps/cli-win32-arm64-msvc": "1.6.3", "@tauri-apps/cli-win32-ia32-msvc": "1.6.3", "@tauri-apps/cli-win32-x64-msvc": "1.6.3" }, "bin": { "tauri": "tauri.js" } }, "sha512-q46umd6QLRKDd4Gg6WyZBGa2fWvk0pbeUA5vFomm4uOs1/17LIciHv2iQ4UD+2Yv5H7AO8YiE1t50V0POiEGEw=="], "@cap/ui/lucide-react": ["lucide-react@0.294.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0" } }, "sha512-V7o0/VECSGbLHn3/1O67FUgBwWB+hmzshrgDVRJQhMh8uj5D3HBuIvhuAmQTtlupILSplwIZg5FTc4tTKMA2SA=="], diff --git a/package.json b/package.json index bc7c64c9ae0..63c46e9ab0c 100644 --- a/package.json +++ b/package.json @@ -15,10 +15,10 @@ "db:generate": "dotenv -e .env -- bun run --filter @cap/database db:generate", "db:push": "dotenv -e .env -- bun run --filter @cap/database db:push", "db:studio": "dotenv -e .env -- bun run --filter @cap/database db:studio", - "dev": "bun run docker:up && trap 'bun run docker:stop' EXIT && dotenv -e .env -- turbo run dev --filter=!@cap/chrome-extension --env-mode=loose --ui tui", + "dev": "bun run docker:up && trap 'bun run docker:stop' EXIT && dotenv -e .env -- turbo run dev --filter=!@cap/chrome-extension --filter=!@cap/status --env-mode=loose --ui tui", "dev:desktop": "bun run --cwd apps/desktop dev", "dev:extension": "bun run docker:up && trap 'bun run docker:stop' EXIT && dotenv -e .env -- turbo run dev --filter=@cap/web --filter=@cap/chrome-extension --env-mode=loose --ui tui", - "dev:manual": "bun run docker:up && trap 'bun run docker:stop' EXIT && dotenv -e .env -- turbo run dev --filter=!@cap/chrome-extension --no-cache --concurrency 1", + "dev:manual": "bun run docker:up && trap 'bun run docker:stop' EXIT && dotenv -e .env -- turbo run dev --filter=!@cap/chrome-extension --filter=!@cap/status --no-cache --concurrency 1", "dev:mobile": "bun run docker:up && trap 'bun run docker:stop' EXIT && CAP_MOBILE_DISABLE_ASSOCIATED_DOMAINS=1 EXPO_PUBLIC_CAP_WEB_URL=${EXPO_PUBLIC_CAP_WEB_URL:-http://localhost:3000} dotenv -e .env -- turbo run dev --filter=@cap/web --filter=@cap/mobile --env-mode=loose --ui tui", "dev:mobile:physical": "bun run docker:up && trap 'bun run docker:stop' EXIT && CAP_MOBILE_DEVICE=physical CAP_MOBILE_DISABLE_ASSOCIATED_DOMAINS=1 dotenv -e .env -- turbo run dev --filter=@cap/web --filter=@cap/mobile --env-mode=loose --ui tui", "dev:web": "bun run dev --filter=!@cap/desktop --filter=!@cap/mobile", diff --git a/scripts/dev-windows.cmd b/scripts/dev-windows.cmd index 91bb1319126..53b8c849241 100644 --- a/scripts/dev-windows.cmd +++ b/scripts/dev-windows.cmd @@ -1,2 +1,2 @@ @echo off -start /b cmd /c "bun run docker:up > nul" && timeout /t 5 /nobreak > nul && dotenv -e .env -- turbo run dev --filter=!@cap/chrome-extension --filter=!@cap/mobile --env-mode=loose --ui tui %* +start /b cmd /c "bun run docker:up > nul" && timeout /t 5 /nobreak > nul && dotenv -e .env -- turbo run dev --filter=!@cap/chrome-extension --filter=!@cap/status --filter=!@cap/mobile --env-mode=loose --ui tui %*