@voltro/cli 0.28.0 → 0.29.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +278 -0
- package/THIRD-PARTY-NOTICES.md +88 -2
- package/dist/{apiBuild-eUM32r1u.js → apiBuild-BESZbTjA.js} +3 -3
- package/dist/apiBuild-C8d74T6g.js +2 -0
- package/dist/bin.js +3 -3
- package/dist/checkCommand-BPAtyWs8.js +2420 -0
- package/dist/checkCommand-DBvZvCh1.js +2 -0
- package/dist/cliRuntime-Oh517vCV.js +96 -0
- package/dist/{commands-CSkrUI1h.js → commands-82BDEktg.js} +6803 -8191
- package/dist/dbCommand-CaIVTp-a.js +2 -0
- package/dist/{dbCommand-CpYgmSw4.js → dbCommand-bCBbk6tz.js} +2 -2
- package/dist/{dev-CEZwJhmb.js → dev-Cg4BKLmi.js} +1801 -1612
- package/dist/{dev-DlBWWnJQ.js → dev-DeEJj5T4.js} +1 -1
- package/dist/fileConventions-Cof68_BL.js +33 -0
- package/dist/{frameworkTableAssembly-BwHU9Euq.js → frameworkTableAssembly-4Db40V23.js} +2 -2
- package/dist/frameworkTableAssembly-BTNirAAk.js +2 -0
- package/dist/index.js +1 -1
- package/dist/{inspect-CUCCzw2I.js → inspect-BMvQpwqy.js} +211 -190
- package/dist/inspect-DHeaMZak.js +2 -0
- package/dist/{inspectMetrics-BU90mvJN.js → inspectMetrics-DHYygE4s.js} +999 -961
- package/dist/{manifestBuild-BnzAxp2O.js → manifestBuild-C4R1EdHn.js} +1 -1
- package/dist/manifestBuild-Ck_90gZy.js +2 -0
- package/dist/sdkgen-CW5NbYF0.js +582 -0
- package/dist/serveCommand-BXl8mhZD.js +1489 -0
- package/dist/serveEntry.js +2 -2
- package/dist/{start-BGXIf6zT.js → start-CsCIaW4-.js} +282 -269
- package/dist/startEntry.js +2 -2
- package/package.json +17 -17
- package/templates/AGENTS.md +2 -1
- package/templates/agent-docs/_index.md +2 -1
- package/templates/agent-docs/_manifest.json +10 -1
- package/templates/agent-docs/ai.md +135 -0
- package/templates/agent-docs/cli.md +124 -6
- package/templates/agent-docs/configuration.md +32 -0
- package/templates/agent-docs/data.md +282 -0
- package/templates/agent-docs/database/migrations.md +47 -0
- package/templates/agent-docs/database/misc.md +57 -0
- package/templates/agent-docs/database/schema.md +3 -1
- package/templates/agent-docs/internationalization.md +161 -1
- package/templates/agent-docs/local-first-mobile.md +414 -0
- package/templates/agent-docs/routing.md +93 -2
- package/templates/agent-docs/schema-driven-ui.md +12 -0
- package/templates/agent-docs/templates/apibackends.md +1 -1
- package/templates/agent-docs/whats-new.md +162 -181
- package/templates/agent-docs/workflows.md +11 -0
- package/templates/apps/api-ai/package.json +7 -7
- package/templates/apps/api-auth/package.json +8 -8
- package/templates/apps/api-backend/package.json +7 -7
- package/templates/apps/api-backend-deactivation/package.json +7 -7
- package/templates/apps/api-backend-mail/package.json +8 -8
- package/templates/apps/api-backend-mariadb/package.json +9 -9
- package/templates/apps/api-backend-sqlite/.env.example +19 -0
- package/templates/apps/api-backend-sqlite/README.md +38 -0
- package/templates/apps/api-backend-sqlite/app.config.ts +35 -0
- package/templates/apps/api-backend-sqlite/database/schema.ts +54 -0
- package/templates/apps/api-backend-sqlite/mutations/notes.create.mutation.server.ts +19 -0
- package/templates/apps/api-backend-sqlite/mutations/notes.create.mutation.ts +37 -0
- package/templates/apps/api-backend-sqlite/package.json +30 -0
- package/templates/apps/api-backend-sqlite/queries/notes.query.server.ts +14 -0
- package/templates/apps/api-backend-sqlite/queries/notes.query.ts +20 -0
- package/templates/apps/api-backend-sqlite/template.json +6 -0
- package/templates/apps/api-backend-sqlite/tests/notes.create.test.ts +50 -0
- package/templates/apps/api-backend-sqlite/tsconfig.json +5 -0
- package/templates/apps/api-backend-storage/package.json +8 -8
- package/templates/apps/api-cms/README.md +104 -0
- package/templates/apps/api-cms/actions/content.get.action.server.ts +27 -0
- package/templates/apps/api-cms/actions/content.get.action.ts +19 -0
- package/templates/apps/api-cms/actions/content.types.action.server.ts +26 -0
- package/templates/apps/api-cms/actions/content.types.action.ts +40 -0
- package/templates/apps/api-cms/actions/me.action.server.ts +18 -0
- package/templates/apps/api-cms/actions/me.action.ts +16 -0
- package/templates/apps/api-cms/app.config.ts +61 -0
- package/templates/apps/api-cms/content/blogPost.contentType.ts +39 -0
- package/templates/apps/api-cms/content/index.ts +18 -0
- package/templates/apps/api-cms/content/page.contentType.ts +24 -0
- package/templates/apps/api-cms/database/schema.ts +64 -0
- package/templates/apps/api-cms/mutations/content.publish.mutation.server.ts +19 -0
- package/templates/apps/api-cms/mutations/content.publish.mutation.ts +15 -0
- package/templates/apps/api-cms/mutations/content.saveDraft.mutation.server.ts +36 -0
- package/templates/apps/api-cms/mutations/content.saveDraft.mutation.ts +32 -0
- package/templates/apps/api-cms/mutations/content.unpublish.mutation.server.ts +19 -0
- package/templates/apps/api-cms/mutations/content.unpublish.mutation.ts +11 -0
- package/templates/apps/api-cms/package.json +32 -0
- package/templates/apps/api-cms/queries/content.list.query.server.ts +0 -0
- package/templates/apps/api-cms/queries/content.list.query.ts +27 -0
- package/templates/apps/api-cms/template.json +6 -0
- package/templates/apps/api-cms/tests/content.descriptors.test.ts +64 -0
- package/templates/apps/api-cms/tests/content.write.test.ts +85 -0
- package/templates/apps/api-cms/tsconfig.json +5 -0
- package/templates/apps/api-data-advanced/package.json +8 -8
- package/templates/apps/api-durable/package.json +8 -8
- package/templates/apps/api-feature-flags/package.json +9 -9
- package/templates/apps/api-governance/package.json +8 -8
- package/templates/apps/api-kv/package.json +8 -8
- package/templates/apps/api-moderation/package.json +8 -8
- package/templates/apps/api-observability/package.json +8 -8
- package/templates/apps/api-ratelimit/package.json +8 -8
- package/templates/apps/api-rbac/package.json +8 -8
- package/templates/apps/api-rest/package.json +7 -7
- package/templates/apps/api-saas/package.json +11 -11
- package/templates/apps/api-saas-starter/README.md +103 -0
- package/templates/apps/api-saas-starter/actions/me.action.server.ts +18 -0
- package/templates/apps/api-saas-starter/actions/me.action.ts +20 -0
- package/templates/apps/api-saas-starter/app.config.ts +87 -0
- package/templates/apps/api-saas-starter/database/schema.ts +57 -0
- package/templates/apps/api-saas-starter/mutations/invites.create.mutation.server.ts +26 -0
- package/templates/apps/api-saas-starter/mutations/invites.create.mutation.ts +18 -0
- package/templates/apps/api-saas-starter/mutations/projects.create.mutation.server.ts +29 -0
- package/templates/apps/api-saas-starter/mutations/projects.create.mutation.ts +18 -0
- package/templates/apps/api-saas-starter/package.json +32 -0
- package/templates/apps/api-saas-starter/queries/invites.list.query.server.ts +14 -0
- package/templates/apps/api-saas-starter/queries/invites.list.query.ts +17 -0
- package/templates/apps/api-saas-starter/queries/projects.list.query.server.ts +14 -0
- package/templates/apps/api-saas-starter/queries/projects.list.query.ts +18 -0
- package/templates/apps/api-saas-starter/template.json +6 -0
- package/templates/apps/api-saas-starter/tests/projects.create.test.ts +62 -0
- package/templates/apps/api-saas-starter/tests/session.test.ts +33 -0
- package/templates/apps/api-saas-starter/tsconfig.json +5 -0
- package/templates/apps/api-search/package.json +8 -8
- package/templates/apps/api-status/README.md +88 -0
- package/templates/apps/api-status/app.config.ts +36 -0
- package/templates/apps/api-status/authz.ts +33 -0
- package/templates/apps/api-status/database/schema.ts +70 -0
- package/templates/apps/api-status/mutations/components.create.mutation.server.ts +19 -0
- package/templates/apps/api-status/mutations/components.create.mutation.ts +16 -0
- package/templates/apps/api-status/mutations/incidents.create.mutation.server.ts +25 -0
- package/templates/apps/api-status/mutations/incidents.create.mutation.ts +22 -0
- package/templates/apps/api-status/mutations/incidents.resolve.mutation.server.ts +24 -0
- package/templates/apps/api-status/mutations/incidents.resolve.mutation.ts +19 -0
- package/templates/apps/api-status/mutations/incidents.update.mutation.server.ts +26 -0
- package/templates/apps/api-status/mutations/incidents.update.mutation.ts +21 -0
- package/templates/apps/api-status/package.json +30 -0
- package/templates/apps/api-status/queries/components.list.query.server.ts +14 -0
- package/templates/apps/api-status/queries/components.list.query.ts +13 -0
- package/templates/apps/api-status/queries/incidents.live.query.server.ts +14 -0
- package/templates/apps/api-status/queries/incidents.live.query.ts +20 -0
- package/templates/apps/api-status/queries/updates.list.query.server.ts +14 -0
- package/templates/apps/api-status/queries/updates.list.query.ts +17 -0
- package/templates/apps/api-status/template.json +6 -0
- package/templates/apps/api-status/tests/status.test.ts +70 -0
- package/templates/apps/api-status/tsconfig.json +5 -0
- package/templates/apps/api-versioning/package.json +8 -8
- package/templates/apps/api-webhooks/package.json +9 -9
- package/templates/apps/changelog/package.json +6 -6
- package/templates/apps/edge-functions/package.json +2 -2
- package/templates/apps/frontend-admin/package.json +8 -8
- package/templates/apps/frontend-app/package.json +8 -8
- package/templates/apps/frontend-auth/README.md +78 -0
- package/templates/apps/frontend-auth/app.config.ts +34 -0
- package/templates/apps/frontend-auth/package.json +32 -0
- package/templates/apps/frontend-auth/src/components/AuthShell.tsx +35 -0
- package/templates/apps/frontend-auth/src/components/PasswordStrength.tsx +33 -0
- package/templates/apps/frontend-auth/src/config.ts +11 -0
- package/templates/apps/frontend-auth/src/globals.css +105 -0
- package/templates/apps/frontend-auth/src/globals.d.ts +6 -0
- package/templates/apps/frontend-auth/src/lib/auth.ts +34 -0
- package/templates/apps/frontend-auth/src/lib/redirect.test.ts +24 -0
- package/templates/apps/frontend-auth/src/lib/redirect.ts +29 -0
- package/templates/apps/frontend-auth/src/locales/de.ts +66 -0
- package/templates/apps/frontend-auth/src/locales/en.ts +76 -0
- package/templates/apps/frontend-auth/src/locales/index.ts +14 -0
- package/templates/apps/frontend-auth/src/pages/forgot/page.tsx +51 -0
- package/templates/apps/frontend-auth/src/pages/layout.tsx +12 -0
- package/templates/apps/frontend-auth/src/pages/login/page.test.tsx +53 -0
- package/templates/apps/frontend-auth/src/pages/login/page.tsx +67 -0
- package/templates/apps/frontend-auth/src/pages/logout/page.tsx +28 -0
- package/templates/apps/frontend-auth/src/pages/magic/page.tsx +51 -0
- package/templates/apps/frontend-auth/src/pages/page.tsx +26 -0
- package/templates/apps/frontend-auth/src/pages/reset/page.test.tsx +51 -0
- package/templates/apps/frontend-auth/src/pages/reset/page.tsx +63 -0
- package/templates/apps/frontend-auth/src/pages/signup/page.tsx +60 -0
- package/templates/apps/frontend-auth/src/pages/verify/page.tsx +50 -0
- package/templates/apps/frontend-auth/template.json +6 -0
- package/templates/apps/frontend-auth/tsconfig.json +5 -0
- package/templates/apps/frontend-blank/package.json +7 -7
- package/templates/apps/frontend-cms/README.md +47 -0
- package/templates/apps/frontend-cms/app.config.ts +37 -0
- package/templates/apps/frontend-cms/package.json +33 -0
- package/templates/apps/frontend-cms/src/config.ts +8 -0
- package/templates/apps/frontend-cms/src/globals.css +105 -0
- package/templates/apps/frontend-cms/src/globals.d.ts +6 -0
- package/templates/apps/frontend-cms/src/lib/api.ts +58 -0
- package/templates/apps/frontend-cms/src/locales/de.ts +50 -0
- package/templates/apps/frontend-cms/src/locales/en.ts +55 -0
- package/templates/apps/frontend-cms/src/locales/index.ts +14 -0
- package/templates/apps/frontend-cms/src/pages/(app)/error.tsx +18 -0
- package/templates/apps/frontend-cms/src/pages/(app)/layout.test.tsx +31 -0
- package/templates/apps/frontend-cms/src/pages/(app)/layout.tsx +62 -0
- package/templates/apps/frontend-cms/src/pages/(app)/not-found.tsx +14 -0
- package/templates/apps/frontend-cms/src/pages/(app)/page.test.tsx +110 -0
- package/templates/apps/frontend-cms/src/pages/(app)/page.tsx +159 -0
- package/templates/apps/frontend-cms/src/pages/layout.tsx +12 -0
- package/templates/apps/frontend-cms/src/pages/login/page.test.tsx +58 -0
- package/templates/apps/frontend-cms/src/pages/login/page.tsx +93 -0
- package/templates/apps/frontend-cms/template.json +6 -0
- package/templates/apps/frontend-cms/tsconfig.json +5 -0
- package/templates/apps/frontend-contact/package.json +7 -7
- package/templates/apps/frontend-dashboard/package.json +7 -7
- package/templates/apps/frontend-docs/package.json +7 -7
- package/templates/apps/frontend-i18n/package.json +6 -6
- package/templates/apps/frontend-landing/package.json +7 -7
- package/templates/apps/frontend-portal/README.md +71 -0
- package/templates/apps/frontend-portal/app.config.ts +37 -0
- package/templates/apps/frontend-portal/package.json +32 -0
- package/templates/apps/frontend-portal/src/config.ts +8 -0
- package/templates/apps/frontend-portal/src/globals.css +93 -0
- package/templates/apps/frontend-portal/src/globals.d.ts +6 -0
- package/templates/apps/frontend-portal/src/lib/api.ts +62 -0
- package/templates/apps/frontend-portal/src/locales/de.ts +86 -0
- package/templates/apps/frontend-portal/src/locales/en.ts +93 -0
- package/templates/apps/frontend-portal/src/locales/index.ts +14 -0
- package/templates/apps/frontend-portal/src/pages/(portal)/api-keys/page.test.tsx +71 -0
- package/templates/apps/frontend-portal/src/pages/(portal)/api-keys/page.tsx +124 -0
- package/templates/apps/frontend-portal/src/pages/(portal)/billing/page.test.tsx +86 -0
- package/templates/apps/frontend-portal/src/pages/(portal)/billing/page.tsx +97 -0
- package/templates/apps/frontend-portal/src/pages/(portal)/error.tsx +19 -0
- package/templates/apps/frontend-portal/src/pages/(portal)/invoices/page.tsx +45 -0
- package/templates/apps/frontend-portal/src/pages/(portal)/layout.test.tsx +31 -0
- package/templates/apps/frontend-portal/src/pages/(portal)/layout.tsx +63 -0
- package/templates/apps/frontend-portal/src/pages/(portal)/not-found.tsx +15 -0
- package/templates/apps/frontend-portal/src/pages/(portal)/page.tsx +39 -0
- package/templates/apps/frontend-portal/src/pages/(portal)/profile/page.tsx +79 -0
- package/templates/apps/frontend-portal/src/pages/layout.tsx +12 -0
- package/templates/apps/frontend-portal/src/pages/login/page.test.tsx +58 -0
- package/templates/apps/frontend-portal/src/pages/login/page.tsx +96 -0
- package/templates/apps/frontend-portal/template.json +6 -0
- package/templates/apps/frontend-portal/tsconfig.json +5 -0
- package/templates/apps/frontend-saas/README.md +73 -0
- package/templates/apps/frontend-saas/app.config.ts +46 -0
- package/templates/apps/frontend-saas/package.json +32 -0
- package/templates/apps/frontend-saas/src/config.ts +8 -0
- package/templates/apps/frontend-saas/src/globals.css +85 -0
- package/templates/apps/frontend-saas/src/globals.d.ts +6 -0
- package/templates/apps/frontend-saas/src/lib/api.ts +47 -0
- package/templates/apps/frontend-saas/src/locales/de.ts +72 -0
- package/templates/apps/frontend-saas/src/locales/en.ts +81 -0
- package/templates/apps/frontend-saas/src/locales/index.ts +14 -0
- package/templates/apps/frontend-saas/src/pages/(marketing)/layout.tsx +30 -0
- package/templates/apps/frontend-saas/src/pages/(marketing)/login/page.test.tsx +58 -0
- package/templates/apps/frontend-saas/src/pages/(marketing)/login/page.tsx +96 -0
- package/templates/apps/frontend-saas/src/pages/(marketing)/page.tsx +27 -0
- package/templates/apps/frontend-saas/src/pages/dashboard/billing/page.tsx +72 -0
- package/templates/apps/frontend-saas/src/pages/dashboard/error.tsx +20 -0
- package/templates/apps/frontend-saas/src/pages/dashboard/layout.test.tsx +32 -0
- package/templates/apps/frontend-saas/src/pages/dashboard/layout.tsx +69 -0
- package/templates/apps/frontend-saas/src/pages/dashboard/not-found.tsx +17 -0
- package/templates/apps/frontend-saas/src/pages/dashboard/page.test.tsx +113 -0
- package/templates/apps/frontend-saas/src/pages/dashboard/page.tsx +76 -0
- package/templates/apps/frontend-saas/src/pages/dashboard/team/page.tsx +73 -0
- package/templates/apps/frontend-saas/src/pages/layout.tsx +12 -0
- package/templates/apps/frontend-saas/template.json +6 -0
- package/templates/apps/frontend-saas/tsconfig.json +5 -0
- package/templates/apps/frontend-spa/package.json +7 -7
- package/templates/apps/frontend-ssr/package.json +7 -7
- package/templates/apps/frontend-ssr-api/package.json +8 -8
- package/templates/apps/frontend-static-blog/package.json +6 -6
- package/templates/apps/frontend-status/README.md +51 -0
- package/templates/apps/frontend-status/app.config.ts +39 -0
- package/templates/apps/frontend-status/package.json +32 -0
- package/templates/apps/frontend-status/src/config.ts +7 -0
- package/templates/apps/frontend-status/src/globals.css +70 -0
- package/templates/apps/frontend-status/src/globals.d.ts +6 -0
- package/templates/apps/frontend-status/src/lib/status.ts +81 -0
- package/templates/apps/frontend-status/src/locales/de.ts +41 -0
- package/templates/apps/frontend-status/src/locales/en.ts +45 -0
- package/templates/apps/frontend-status/src/locales/index.ts +13 -0
- package/templates/apps/frontend-status/src/pages/layout.tsx +27 -0
- package/templates/apps/frontend-status/src/pages/page.test.tsx +123 -0
- package/templates/apps/frontend-status/src/pages/page.tsx +136 -0
- package/templates/apps/frontend-status/template.json +6 -0
- package/templates/apps/frontend-status/tsconfig.json +11 -0
- package/templates/baselines/compose/docker-compose.prod.yml +15 -0
- package/templates/baselines/compose-mariadb/docker-compose.prod.yml +15 -0
- package/dist/apiBuild-DgBS9ayv.js +0 -2
- package/dist/dbCommand-DvguqlzF.js +0 -2
- package/dist/fileConventions-3bffWssN.js +0 -30
- package/dist/frameworkTableAssembly-lrjZtk0G.js +0 -2
- package/dist/inspect-gt8bq-Tz.js +0 -2
- package/dist/manifestBuild-ifczArzr.js +0 -2
- package/dist/serveCommand-ZTn-dPFa.js +0 -1425
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# {{capProjectName}} {{capAppName}} — public status page
|
|
2
|
+
|
|
3
|
+
A public status page, and a clean **live-`useSubscription`** showcase. Consumes
|
|
4
|
+
the `api-status` backend. Bilingual (en/de), no auth.
|
|
5
|
+
|
|
6
|
+
Scaffold both halves together:
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
voltro init my-status --api=api-status --web=frontend-status
|
|
10
|
+
voltro dev
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## What it does
|
|
14
|
+
|
|
15
|
+
- **SSR first paint.** The index loader pulls the current incidents, components
|
|
16
|
+
and updates over the api's `/rpc` before the HTML is sent, so the page is
|
|
17
|
+
correct + crawlable on first load (no client round-trip).
|
|
18
|
+
- **Live upgrade.** The component then subscribes with `useSubscription('app',
|
|
19
|
+
'incidents.live' | 'components.list' | 'updates.list')`, so when an operator
|
|
20
|
+
declares or resolves an incident on the api, the banner, uptime grid and
|
|
21
|
+
timeline update on every open page within seconds — no polling.
|
|
22
|
+
- **Derived state.** The overall banner and the 90-day uptime grid are computed
|
|
23
|
+
from incident timestamps (`src/lib/status.ts`), not external monitoring — the
|
|
24
|
+
operator controls the narrative. That math is pure and unit-tested.
|
|
25
|
+
|
|
26
|
+
## No auth by design
|
|
27
|
+
|
|
28
|
+
The api leaves the read queries ungated, and this page carries no session gate —
|
|
29
|
+
a status page must stay up when the product (and its login) is down. Operators
|
|
30
|
+
post incidents against the api's operator-gated mutations from a separate
|
|
31
|
+
authenticated surface.
|
|
32
|
+
|
|
33
|
+
## Files
|
|
34
|
+
|
|
35
|
+
```
|
|
36
|
+
src/pages/
|
|
37
|
+
layout.tsx header (brand + language) + stylesheet
|
|
38
|
+
page.tsx the status page — SSR loader + 3 live subscriptions
|
|
39
|
+
page.test.tsx loader + render + the pure status math
|
|
40
|
+
src/lib/status.ts deriveOverall() + uptimeGrid() + uptimePercent() (pure, tested)
|
|
41
|
+
src/locales/{en,de}.ts bilingual catalogs
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Anti-patterns
|
|
45
|
+
|
|
46
|
+
- **Polling for updates.** The whole point is the live subscription — don't add
|
|
47
|
+
a `setInterval`; `useSubscription` pushes deltas.
|
|
48
|
+
- **Gating the reads.** These must work for anonymous visitors. Keep the api's
|
|
49
|
+
read queries ungated; the rbac guard belongs on the write mutations.
|
|
50
|
+
- **A secret in this app's env.** The browser bundle is public — secrets live on
|
|
51
|
+
the api.
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// Web app for the {{projectName}} project — a PUBLIC status page fed by the
|
|
2
|
+
// sibling `api-status` api.
|
|
3
|
+
//
|
|
4
|
+
// The index page is `renderMode: 'ssr'`: its loader runs server-side per request
|
|
5
|
+
// and pulls the current incidents + components over the api's POST /rpc, so the
|
|
6
|
+
// first paint + the document <title> reflect real state (crawlable, no client
|
|
7
|
+
// round-trip). The component then upgrades to live with `useSubscription`, so a
|
|
8
|
+
// new incident lands on every open page within seconds — the whole point of a
|
|
9
|
+
// status page.
|
|
10
|
+
//
|
|
11
|
+
// No auth: the reads are public (the api leaves them ungated), and the page must
|
|
12
|
+
// stay up even when the rest of the product — and its login — is down.
|
|
13
|
+
import { defineEnv, envVar } from '@voltro/env'
|
|
14
|
+
|
|
15
|
+
// Public, build-time env (baked into the bundle; read via `publicEnv`).
|
|
16
|
+
// Never a secret here — declare secrets on the api.
|
|
17
|
+
export const env = defineEnv({
|
|
18
|
+
VOLTRO_PUBLIC_APP_NAME: envVar.string({ access: 'public', default: '{{capProjectName}}' }),
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
export default {
|
|
22
|
+
type: 'web' as const,
|
|
23
|
+
name: '{{capProjectName}}{{capAppName}}',
|
|
24
|
+
port: {{port}},
|
|
25
|
+
theme: 'system' as const,
|
|
26
|
+
env,
|
|
27
|
+
// Bilingual by default (cookie-driven — no `/<locale>` prefix). The framework
|
|
28
|
+
// auto-wires <I18nProvider> from these codes + the `src/locales/<code>.ts`
|
|
29
|
+
// catalogs, so `<T>` / `useT()` / `useLocale()` work app-wide (SSR too). Add a
|
|
30
|
+
// code + a `src/locales/<code>.ts` to grow to more languages.
|
|
31
|
+
locales: ['en', 'de'] as const,
|
|
32
|
+
defaultLocale: 'en' as const,
|
|
33
|
+
// The api this page reads. The NAME ('app') is the lookup key the loader's
|
|
34
|
+
// `query` + the hooks use: `query('incidents.live', {})`,
|
|
35
|
+
// `useSubscription('app', 'incidents.live')`.
|
|
36
|
+
apis: {
|
|
37
|
+
app: { package: '@{{projectName}}/api' },
|
|
38
|
+
},
|
|
39
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@{{projectName}}/{{appName}}",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"dev": "voltro dev .",
|
|
8
|
+
"lint": "voltro doctor .",
|
|
9
|
+
"build": "voltro build .",
|
|
10
|
+
"start": "voltro start .",
|
|
11
|
+
"typecheck": "tsc --noEmit",
|
|
12
|
+
"test": "voltro test"
|
|
13
|
+
},
|
|
14
|
+
"dependencies": {
|
|
15
|
+
"@{{projectName}}/api": "0.29.0",
|
|
16
|
+
"@voltro/cli": "0.29.0",
|
|
17
|
+
"@voltro/client": "0.29.0",
|
|
18
|
+
"@voltro/env": "0.29.0",
|
|
19
|
+
"@voltro/i18n": "0.29.0",
|
|
20
|
+
"@voltro/ui-shadcn": "0.29.0",
|
|
21
|
+
"@voltro/web": "0.29.0",
|
|
22
|
+
"react": "^19.0.0",
|
|
23
|
+
"react-dom": "^19.0.0"
|
|
24
|
+
},
|
|
25
|
+
"devDependencies": {
|
|
26
|
+
"@voltro/devtools": "0.29.0",
|
|
27
|
+
"@types/react": "^19.0.0",
|
|
28
|
+
"@types/react-dom": "^19.0.0",
|
|
29
|
+
"typescript": "^6.0.3",
|
|
30
|
+
"vitest": "^4.1.10"
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// The brand name shown in the status-page header.
|
|
2
|
+
//
|
|
3
|
+
// A plain constant — not `publicEnv` — so the template typechecks BEFORE
|
|
4
|
+
// `voltro dev` generates the env types. `VOLTRO_PUBLIC_APP_NAME` is still
|
|
5
|
+
// declared in app.config.ts; to read it typed instead, after one `voltro dev`
|
|
6
|
+
// swap this for `publicEnv.VOLTRO_PUBLIC_APP_NAME` from '@voltro/env/public'.
|
|
7
|
+
export const APP_NAME = '{{capProjectName}}'
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/* Plain CSS — no Tailwind, so there's no @source scanner to configure. */
|
|
2
|
+
:root {
|
|
3
|
+
--bg: #ffffff;
|
|
4
|
+
--fg: #0a0a0a;
|
|
5
|
+
--muted: #6b7280;
|
|
6
|
+
--border: #e5e7eb;
|
|
7
|
+
--panel: #f9fafb;
|
|
8
|
+
--up: #16a34a;
|
|
9
|
+
--down: #dc2626;
|
|
10
|
+
--warn: #d97706;
|
|
11
|
+
}
|
|
12
|
+
:root.dark {
|
|
13
|
+
--bg: #0a0a0a;
|
|
14
|
+
--fg: #fafafa;
|
|
15
|
+
--muted: #9ca3af;
|
|
16
|
+
--border: #1f2937;
|
|
17
|
+
--panel: #111827;
|
|
18
|
+
--up: #22c55e;
|
|
19
|
+
--down: #f87171;
|
|
20
|
+
--warn: #f59e0b;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
* { box-sizing: border-box; }
|
|
24
|
+
body {
|
|
25
|
+
margin: 0;
|
|
26
|
+
background: var(--bg);
|
|
27
|
+
color: var(--fg);
|
|
28
|
+
font-family: ui-sans-serif, system-ui, -apple-system, sans-serif;
|
|
29
|
+
line-height: 1.55;
|
|
30
|
+
}
|
|
31
|
+
.muted { color: var(--muted); }
|
|
32
|
+
.spacer { flex: 1; }
|
|
33
|
+
.shell { max-width: 46rem; margin: 0 auto; padding: 1.5rem 1.25rem 4rem; }
|
|
34
|
+
.shell header { display: flex; align-items: center; gap: 1rem; margin-bottom: 1.5rem; }
|
|
35
|
+
.brand { font-weight: 700; }
|
|
36
|
+
h2 { font-size: 1rem; margin: 2rem 0 0.75rem; }
|
|
37
|
+
|
|
38
|
+
/* Overall banner */
|
|
39
|
+
.banner { display: flex; align-items: center; gap: 0.6rem; padding: 1rem 1.25rem; border-radius: 12px; border: 1px solid var(--border); }
|
|
40
|
+
.banner .dot { width: 0.7rem; height: 0.7rem; border-radius: 999px; background: var(--muted); }
|
|
41
|
+
.banner--operational { background: color-mix(in oklab, var(--up) 12%, transparent); border-color: var(--up); }
|
|
42
|
+
.banner--operational .dot { background: var(--up); }
|
|
43
|
+
.banner--degraded { background: color-mix(in oklab, var(--warn) 12%, transparent); border-color: var(--warn); }
|
|
44
|
+
.banner--degraded .dot { background: var(--warn); }
|
|
45
|
+
.banner--major_outage { background: color-mix(in oklab, var(--down) 12%, transparent); border-color: var(--down); }
|
|
46
|
+
.banner--major_outage .dot { background: var(--down); }
|
|
47
|
+
|
|
48
|
+
/* Uptime grid */
|
|
49
|
+
.uptime { display: flex; gap: 2px; flex-wrap: wrap; }
|
|
50
|
+
.uptime .day { width: 6px; height: 26px; border-radius: 2px; background: var(--up); }
|
|
51
|
+
.uptime .day.down { background: var(--down); }
|
|
52
|
+
|
|
53
|
+
/* Components */
|
|
54
|
+
.components, .incidents, .timeline { list-style: none; padding: 0; margin: 0; }
|
|
55
|
+
.components li { display: flex; justify-content: space-between; align-items: center; padding: 0.6rem 0; border-bottom: 1px solid var(--border); }
|
|
56
|
+
.pill { font-size: 0.78rem; border: 1px solid var(--border); border-radius: 999px; padding: 0.05rem 0.55rem; color: var(--muted); }
|
|
57
|
+
.pill--operational { color: var(--up); border-color: var(--up); }
|
|
58
|
+
.pill--degraded, .pill--partial_outage { color: var(--warn); border-color: var(--warn); }
|
|
59
|
+
.pill--major_outage { color: var(--down); border-color: var(--down); }
|
|
60
|
+
|
|
61
|
+
/* Incidents */
|
|
62
|
+
.incidents { display: flex; flex-direction: column; gap: 0.75rem; }
|
|
63
|
+
.incident { border: 1px solid var(--border); border-left-width: 3px; border-radius: 10px; padding: 0.9rem 1.1rem; background: var(--panel); }
|
|
64
|
+
.incident.impact--critical { border-left-color: var(--down); }
|
|
65
|
+
.incident.impact--major { border-left-color: var(--warn); }
|
|
66
|
+
.incident.impact--minor { border-left-color: var(--muted); }
|
|
67
|
+
.incident-head { display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; }
|
|
68
|
+
.timeline { margin-top: 0.6rem; display: flex; flex-direction: column; gap: 0.35rem; border-top: 1px dashed var(--border); padding-top: 0.6rem; }
|
|
69
|
+
.timeline li { display: flex; align-items: baseline; gap: 0.5rem; font-size: 0.9rem; }
|
|
70
|
+
.timeline .ts { color: var(--muted); font-variant-numeric: tabular-nums; white-space: nowrap; }
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// Ambient declarations.
|
|
2
|
+
//
|
|
3
|
+
// `declare module '*.css'`: TypeScript with `moduleResolution: Bundler`
|
|
4
|
+
// rejects a bare `import './globals.css'` unless the asset module is
|
|
5
|
+
// declared. Vite resolves it at build time; the declaration is type-only.
|
|
6
|
+
declare module '*.css'
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// Pure status math — no React, no api. Kept here so it's unit-testable on its
|
|
2
|
+
// own (see page.test.tsx) and reused by the page's banner + uptime grid.
|
|
3
|
+
|
|
4
|
+
export interface Incident {
|
|
5
|
+
readonly id: string
|
|
6
|
+
readonly title: string
|
|
7
|
+
readonly impact: string // 'minor' | 'major' | 'critical'
|
|
8
|
+
readonly status: string // 'investigating' | 'identified' | 'monitoring' | 'resolved'
|
|
9
|
+
readonly startedAt: string | Date
|
|
10
|
+
readonly resolvedAt: string | Date | null
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface Component {
|
|
14
|
+
readonly id: string
|
|
15
|
+
readonly name: string
|
|
16
|
+
readonly status: string // 'operational' | 'degraded' | 'partial_outage' | 'major_outage'
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface Update {
|
|
20
|
+
readonly id: string
|
|
21
|
+
readonly incidentId: string
|
|
22
|
+
readonly body: string
|
|
23
|
+
readonly status: string
|
|
24
|
+
readonly createdAt: string | Date
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type Overall = 'operational' | 'degraded' | 'major_outage'
|
|
28
|
+
|
|
29
|
+
const isOpen = (i: Incident): boolean => i.status !== 'resolved'
|
|
30
|
+
|
|
31
|
+
// The banner state, from current incidents + component statuses. A live critical
|
|
32
|
+
// incident (or a component in major_outage) is the worst; any other open
|
|
33
|
+
// incident or non-operational component is a partial degradation.
|
|
34
|
+
export const deriveOverall = (
|
|
35
|
+
incidents: ReadonlyArray<Incident>,
|
|
36
|
+
components: ReadonlyArray<Component>,
|
|
37
|
+
): Overall => {
|
|
38
|
+
const open = incidents.filter(isOpen)
|
|
39
|
+
const worstOpen = open.some((i) => i.impact === 'critical')
|
|
40
|
+
const worstComponent = components.some((c) => c.status === 'major_outage')
|
|
41
|
+
if (worstOpen || worstComponent) return 'major_outage'
|
|
42
|
+
if (open.length > 0 || components.some((c) => c.status !== 'operational')) return 'degraded'
|
|
43
|
+
return 'operational'
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const DAY_MS = 24 * 60 * 60 * 1000
|
|
47
|
+
const ms = (v: string | Date): number => (v instanceof Date ? v.getTime() : Date.parse(v))
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* A 90-day uptime grid (oldest → newest). A day counts as "down" when a
|
|
51
|
+
* major-or-critical incident overlapped it — derived from incident timestamps,
|
|
52
|
+
* NOT external monitoring, so operators control the narrative. `now` is a seam
|
|
53
|
+
* for deterministic tests.
|
|
54
|
+
*/
|
|
55
|
+
export const uptimeGrid = (
|
|
56
|
+
incidents: ReadonlyArray<Incident>,
|
|
57
|
+
now: number = Date.now(),
|
|
58
|
+
days = 90,
|
|
59
|
+
): ReadonlyArray<boolean> => {
|
|
60
|
+
const impactful = incidents.filter((i) => i.impact === 'major' || i.impact === 'critical')
|
|
61
|
+
const todayStart = Math.floor(now / DAY_MS) * DAY_MS
|
|
62
|
+
const grid: boolean[] = []
|
|
63
|
+
for (let d = days - 1; d >= 0; d--) {
|
|
64
|
+
const dayStart = todayStart - d * DAY_MS
|
|
65
|
+
const dayEnd = dayStart + DAY_MS
|
|
66
|
+
const down = impactful.some((i) => {
|
|
67
|
+
const start = ms(i.startedAt)
|
|
68
|
+
const end = i.resolvedAt == null ? now : ms(i.resolvedAt)
|
|
69
|
+
return start < dayEnd && end >= dayStart
|
|
70
|
+
})
|
|
71
|
+
grid.push(!down)
|
|
72
|
+
}
|
|
73
|
+
return grid
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Uptime percentage over the grid window, to one decimal.
|
|
77
|
+
export const uptimePercent = (grid: ReadonlyArray<boolean>): number => {
|
|
78
|
+
if (grid.length === 0) return 100
|
|
79
|
+
const up = grid.filter(Boolean).length
|
|
80
|
+
return Math.round((up / grid.length) * 1000) / 10
|
|
81
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// German catalog. Mirrors every key in `en.ts` — `defineLocale<typeof en>()`
|
|
2
|
+
// fails the build on a missing/misspelled key. Translate prose only; keep ICU
|
|
3
|
+
// placeholders (`{days}`, `{percent}`, `{when}`) identical to en.ts.
|
|
4
|
+
import { defineLocale } from '@voltro/i18n'
|
|
5
|
+
import en from './en'
|
|
6
|
+
|
|
7
|
+
export default defineLocale<typeof en>()({
|
|
8
|
+
'meta.title': 'Status',
|
|
9
|
+
'meta.description': 'Live-Servicestatus, Verfügbarkeit und Störungsverlauf.',
|
|
10
|
+
|
|
11
|
+
'lang.label': 'Sprache',
|
|
12
|
+
'header.status': 'Status',
|
|
13
|
+
|
|
14
|
+
'overall.operational': 'Alle Systeme betriebsbereit',
|
|
15
|
+
'overall.degraded': 'Einige Systeme beeinträchtigt',
|
|
16
|
+
'overall.major_outage': 'Schwerwiegende Störung',
|
|
17
|
+
|
|
18
|
+
'uptime.heading': 'Verfügbarkeit über {days} Tage',
|
|
19
|
+
'uptime.percent': '{percent}% Verfügbarkeit',
|
|
20
|
+
|
|
21
|
+
'components.heading': 'Komponenten',
|
|
22
|
+
'components.empty': 'Noch keine Komponenten konfiguriert.',
|
|
23
|
+
'comp.status.operational': 'Betriebsbereit',
|
|
24
|
+
'comp.status.degraded': 'Beeinträchtigt',
|
|
25
|
+
'comp.status.partial_outage': 'Teilausfall',
|
|
26
|
+
'comp.status.major_outage': 'Schwerer Ausfall',
|
|
27
|
+
|
|
28
|
+
'incidents.heading': 'Störungen',
|
|
29
|
+
'incidents.none': 'Keine Störungen gemeldet.',
|
|
30
|
+
'incident.started': 'Begonnen {when}',
|
|
31
|
+
'incident.ongoing': 'Andauernd',
|
|
32
|
+
'incident.resolved': 'Behoben {when}',
|
|
33
|
+
|
|
34
|
+
'impact.minor': 'Gering',
|
|
35
|
+
'impact.major': 'Erheblich',
|
|
36
|
+
'impact.critical': 'Kritisch',
|
|
37
|
+
'status.investigating': 'Wird untersucht',
|
|
38
|
+
'status.identified': 'Identifiziert',
|
|
39
|
+
'status.monitoring': 'Wird beobachtet',
|
|
40
|
+
'status.resolved': 'Behoben',
|
|
41
|
+
})
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Base catalog — source of truth. Every key MUST be mirrored in `de.ts`
|
|
2
|
+
// (`defineLocale<typeof en>()` enforces parity at build time). ICU `{token}`
|
|
3
|
+
// placeholders are substituted via `<T values={{ … }} />`.
|
|
4
|
+
import { defineCatalog } from '@voltro/i18n'
|
|
5
|
+
|
|
6
|
+
export default defineCatalog({
|
|
7
|
+
'meta.title': 'Status',
|
|
8
|
+
'meta.description': 'Live service status, uptime and incident history.',
|
|
9
|
+
|
|
10
|
+
'lang.label': 'Language',
|
|
11
|
+
'header.status': 'Status',
|
|
12
|
+
|
|
13
|
+
// Overall banner
|
|
14
|
+
'overall.operational': 'All systems operational',
|
|
15
|
+
'overall.degraded': 'Some systems degraded',
|
|
16
|
+
'overall.major_outage': 'Major outage',
|
|
17
|
+
|
|
18
|
+
// Uptime
|
|
19
|
+
'uptime.heading': '{days}-day uptime',
|
|
20
|
+
'uptime.percent': '{percent}% uptime',
|
|
21
|
+
|
|
22
|
+
// Components
|
|
23
|
+
'components.heading': 'Components',
|
|
24
|
+
'components.empty': 'No components configured yet.',
|
|
25
|
+
'comp.status.operational': 'Operational',
|
|
26
|
+
'comp.status.degraded': 'Degraded',
|
|
27
|
+
'comp.status.partial_outage': 'Partial outage',
|
|
28
|
+
'comp.status.major_outage': 'Major outage',
|
|
29
|
+
|
|
30
|
+
// Incidents
|
|
31
|
+
'incidents.heading': 'Incidents',
|
|
32
|
+
'incidents.none': 'No incidents reported.',
|
|
33
|
+
'incident.started': 'Started {when}',
|
|
34
|
+
'incident.ongoing': 'Ongoing',
|
|
35
|
+
'incident.resolved': 'Resolved {when}',
|
|
36
|
+
|
|
37
|
+
// Impact + lifecycle labels
|
|
38
|
+
'impact.minor': 'Minor',
|
|
39
|
+
'impact.major': 'Major',
|
|
40
|
+
'impact.critical': 'Critical',
|
|
41
|
+
'status.investigating': 'Investigating',
|
|
42
|
+
'status.identified': 'Identified',
|
|
43
|
+
'status.monitoring': 'Monitoring',
|
|
44
|
+
'status.resolved': 'Resolved',
|
|
45
|
+
} as const)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// Catalog lookup OUTSIDE React — for the page's `meta({ locale })` locale-aware
|
|
2
|
+
// <title>. Cookie-i18n (no URL prefix), so @voltro/web hands `meta` the active
|
|
3
|
+
// locale from the `voltro:lang` cookie. Inside React use `<T>` / `useT()`.
|
|
4
|
+
import en from './en'
|
|
5
|
+
import de from './de'
|
|
6
|
+
|
|
7
|
+
// Both catalogs share `en`'s keys (defineLocale enforces the mirror), so a
|
|
8
|
+
// known-key lookup returns `string` — what `PageMeta.title` requires under
|
|
9
|
+
// exactOptionalPropertyTypes.
|
|
10
|
+
export type Messages = Record<keyof typeof en, string>
|
|
11
|
+
|
|
12
|
+
export const getCatalog = (locale?: string): Messages =>
|
|
13
|
+
(locale === 'de' ? de : en) as Messages
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// Root layout — the status-page header (brand + language) and the stylesheet.
|
|
2
|
+
// Don't render <html>/<head>/<body> here — the framework shell owns them.
|
|
3
|
+
import type { ReactNode } from 'react'
|
|
4
|
+
import { T, useLocale, useT } from '@voltro/i18n'
|
|
5
|
+
import { LocaleSwitcher } from '@voltro/ui-shadcn'
|
|
6
|
+
import { APP_NAME } from '../config'
|
|
7
|
+
import '../globals.css'
|
|
8
|
+
|
|
9
|
+
const LOCALES = [
|
|
10
|
+
{ code: 'en', label: 'English' },
|
|
11
|
+
{ code: 'de', label: 'Deutsch' },
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
export default function Layout({ children }: { readonly children: ReactNode }): ReactNode {
|
|
15
|
+
const locale = useLocale()
|
|
16
|
+
const langLabel = useT('lang.label')
|
|
17
|
+
return (
|
|
18
|
+
<div className="shell">
|
|
19
|
+
<header>
|
|
20
|
+
<span className="brand">{APP_NAME} <span className="muted"><T id="header.status" /></span></span>
|
|
21
|
+
<span className="spacer" />
|
|
22
|
+
<LocaleSwitcher locales={LOCALES} current={locale} ariaLabel={langLabel} />
|
|
23
|
+
</header>
|
|
24
|
+
{children}
|
|
25
|
+
</div>
|
|
26
|
+
)
|
|
27
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
//
|
|
3
|
+
// The status page. We mock `@voltro/web` (the loader hook) + `@voltro/client`
|
|
4
|
+
// (the three subscriptions, keyed by tag) and assert: the loader drains the api,
|
|
5
|
+
// the SSR snapshot renders the banner/components/incidents, live subscription
|
|
6
|
+
// data overrides the snapshot, and the pure status math (banner + uptime) is
|
|
7
|
+
// correct on its own.
|
|
8
|
+
|
|
9
|
+
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
|
|
10
|
+
import { act, createElement, type ReactNode } from 'react'
|
|
11
|
+
import { createRoot, type Root } from 'react-dom/client'
|
|
12
|
+
import { I18nProvider } from '@voltro/i18n'
|
|
13
|
+
import enCatalog from '../locales/en'
|
|
14
|
+
import { deriveOverall, uptimeGrid, uptimePercent, type Incident } from '../lib/status'
|
|
15
|
+
|
|
16
|
+
const loaderData = vi.fn<() => unknown>()
|
|
17
|
+
const subs = new Map<string, unknown>()
|
|
18
|
+
vi.mock('@voltro/web', () => ({ useLoaderData: () => loaderData() }))
|
|
19
|
+
vi.mock('@voltro/client', () => ({ useSubscription: (_api: string, tag: string) => ({ data: subs.get(tag) }) }))
|
|
20
|
+
|
|
21
|
+
const { default: StatusPage, meta, loader, renderMode } = await import('./page')
|
|
22
|
+
|
|
23
|
+
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
|
|
24
|
+
|
|
25
|
+
let container: HTMLDivElement
|
|
26
|
+
let root: Root
|
|
27
|
+
|
|
28
|
+
const render = (node: ReactNode): void => {
|
|
29
|
+
container = document.createElement('div')
|
|
30
|
+
document.body.appendChild(container)
|
|
31
|
+
act(() => {
|
|
32
|
+
root = createRoot(container)
|
|
33
|
+
root.render(
|
|
34
|
+
createElement(I18nProvider, { locale: 'en', messages: enCatalog, defaultLocale: 'en', children: node }),
|
|
35
|
+
)
|
|
36
|
+
})
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const emptyData = { incidents: [], components: [], updates: [] }
|
|
40
|
+
|
|
41
|
+
beforeEach(() => {
|
|
42
|
+
loaderData.mockReset()
|
|
43
|
+
loaderData.mockReturnValue(emptyData)
|
|
44
|
+
subs.clear()
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
afterEach(() => {
|
|
48
|
+
if (root) act(() => root.unmount())
|
|
49
|
+
container?.remove()
|
|
50
|
+
document.body.innerHTML = ''
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
describe('status page — config + loader', () => {
|
|
54
|
+
test('is an SSR page titled Status', () => {
|
|
55
|
+
expect(renderMode).toBe('ssr')
|
|
56
|
+
expect(meta({ locale: 'en' }).title).toBe('Status')
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
test('the loader drains all three feeds from the api', async () => {
|
|
60
|
+
const query = vi.fn(async (tag: string) =>
|
|
61
|
+
tag === 'incidents.live' ? [{ id: 'inc_1' }]
|
|
62
|
+
: tag === 'components.list' ? [{ id: 'cmp_1' }]
|
|
63
|
+
: [{ id: 'iu_1' }])
|
|
64
|
+
const data = await loader({ query } as never)
|
|
65
|
+
expect(query).toHaveBeenCalledWith('incidents.live', {})
|
|
66
|
+
expect(data.incidents).toHaveLength(1)
|
|
67
|
+
expect(data.components).toHaveLength(1)
|
|
68
|
+
expect(data.updates).toHaveLength(1)
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
test('returns empty feeds on a client navigation (no server query fn)', async () => {
|
|
72
|
+
const data = await loader({ query: undefined } as never)
|
|
73
|
+
expect(data).toEqual(emptyData)
|
|
74
|
+
})
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
describe('status page — render', () => {
|
|
78
|
+
test('shows the all-operational banner with no incidents', () => {
|
|
79
|
+
render(createElement(StatusPage))
|
|
80
|
+
expect(container.querySelector('.banner--operational')).not.toBeNull()
|
|
81
|
+
expect(container.textContent).toContain('All systems operational')
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
test('renders a live incident over the (empty) SSR snapshot', () => {
|
|
85
|
+
subs.set('incidents.live', [
|
|
86
|
+
{ id: 'inc_9', title: 'API degraded', impact: 'major', status: 'investigating', startedAt: '2026-08-01T00:00:00Z', resolvedAt: null },
|
|
87
|
+
])
|
|
88
|
+
subs.set('updates.list', [
|
|
89
|
+
{ id: 'iu_1', incidentId: 'inc_9', body: 'Looking into it', status: 'investigating', createdAt: '2026-08-01T00:05:00Z' },
|
|
90
|
+
])
|
|
91
|
+
render(createElement(StatusPage))
|
|
92
|
+
expect(container.querySelector('.banner--degraded')).not.toBeNull()
|
|
93
|
+
expect(container.textContent).toContain('API degraded')
|
|
94
|
+
expect(container.querySelector('.timeline')).not.toBeNull()
|
|
95
|
+
expect(container.textContent).toContain('Looking into it')
|
|
96
|
+
})
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
describe('status math (pure)', () => {
|
|
100
|
+
const inc = (over: Partial<Incident>): Incident => ({
|
|
101
|
+
id: 'i', title: 't', impact: 'minor', status: 'investigating',
|
|
102
|
+
startedAt: '2026-08-01T00:00:00Z', resolvedAt: null, ...over,
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
test('deriveOverall escalates with impact and clears when resolved', () => {
|
|
106
|
+
expect(deriveOverall([], [])).toBe('operational')
|
|
107
|
+
expect(deriveOverall([inc({ impact: 'major' })], [])).toBe('degraded')
|
|
108
|
+
expect(deriveOverall([inc({ impact: 'critical' })], [])).toBe('major_outage')
|
|
109
|
+
expect(deriveOverall([inc({ impact: 'critical', status: 'resolved', resolvedAt: '2026-08-02T00:00:00Z' })], [])).toBe('operational')
|
|
110
|
+
expect(deriveOverall([], [{ id: 'c', name: 'API', status: 'major_outage' }])).toBe('major_outage')
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
test('uptimeGrid marks a day down only for a major/critical incident overlapping it', () => {
|
|
114
|
+
const now = Date.parse('2026-08-10T12:00:00Z')
|
|
115
|
+
const minorOnly = uptimeGrid([inc({ impact: 'minor', startedAt: '2026-08-09T00:00:00Z', resolvedAt: '2026-08-09T06:00:00Z' })], now, 90)
|
|
116
|
+
expect(minorOnly.every(Boolean)).toBe(true) // minor never dings uptime
|
|
117
|
+
expect(uptimePercent(minorOnly)).toBe(100)
|
|
118
|
+
|
|
119
|
+
const majorDay = uptimeGrid([inc({ impact: 'critical', startedAt: '2026-08-09T00:00:00Z', resolvedAt: '2026-08-09T06:00:00Z' })], now, 90)
|
|
120
|
+
expect(majorDay.filter((up) => !up)).toHaveLength(1) // exactly one down day
|
|
121
|
+
expect(uptimePercent(majorDay)).toBeLessThan(100)
|
|
122
|
+
})
|
|
123
|
+
})
|