@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.
Files changed (280) hide show
  1. package/CHANGELOG.md +278 -0
  2. package/THIRD-PARTY-NOTICES.md +88 -2
  3. package/dist/{apiBuild-eUM32r1u.js → apiBuild-BESZbTjA.js} +3 -3
  4. package/dist/apiBuild-C8d74T6g.js +2 -0
  5. package/dist/bin.js +3 -3
  6. package/dist/checkCommand-BPAtyWs8.js +2420 -0
  7. package/dist/checkCommand-DBvZvCh1.js +2 -0
  8. package/dist/cliRuntime-Oh517vCV.js +96 -0
  9. package/dist/{commands-CSkrUI1h.js → commands-82BDEktg.js} +6803 -8191
  10. package/dist/dbCommand-CaIVTp-a.js +2 -0
  11. package/dist/{dbCommand-CpYgmSw4.js → dbCommand-bCBbk6tz.js} +2 -2
  12. package/dist/{dev-CEZwJhmb.js → dev-Cg4BKLmi.js} +1801 -1612
  13. package/dist/{dev-DlBWWnJQ.js → dev-DeEJj5T4.js} +1 -1
  14. package/dist/fileConventions-Cof68_BL.js +33 -0
  15. package/dist/{frameworkTableAssembly-BwHU9Euq.js → frameworkTableAssembly-4Db40V23.js} +2 -2
  16. package/dist/frameworkTableAssembly-BTNirAAk.js +2 -0
  17. package/dist/index.js +1 -1
  18. package/dist/{inspect-CUCCzw2I.js → inspect-BMvQpwqy.js} +211 -190
  19. package/dist/inspect-DHeaMZak.js +2 -0
  20. package/dist/{inspectMetrics-BU90mvJN.js → inspectMetrics-DHYygE4s.js} +999 -961
  21. package/dist/{manifestBuild-BnzAxp2O.js → manifestBuild-C4R1EdHn.js} +1 -1
  22. package/dist/manifestBuild-Ck_90gZy.js +2 -0
  23. package/dist/sdkgen-CW5NbYF0.js +582 -0
  24. package/dist/serveCommand-BXl8mhZD.js +1489 -0
  25. package/dist/serveEntry.js +2 -2
  26. package/dist/{start-BGXIf6zT.js → start-CsCIaW4-.js} +282 -269
  27. package/dist/startEntry.js +2 -2
  28. package/package.json +17 -17
  29. package/templates/AGENTS.md +2 -1
  30. package/templates/agent-docs/_index.md +2 -1
  31. package/templates/agent-docs/_manifest.json +10 -1
  32. package/templates/agent-docs/ai.md +135 -0
  33. package/templates/agent-docs/cli.md +124 -6
  34. package/templates/agent-docs/configuration.md +32 -0
  35. package/templates/agent-docs/data.md +282 -0
  36. package/templates/agent-docs/database/migrations.md +47 -0
  37. package/templates/agent-docs/database/misc.md +57 -0
  38. package/templates/agent-docs/database/schema.md +3 -1
  39. package/templates/agent-docs/internationalization.md +161 -1
  40. package/templates/agent-docs/local-first-mobile.md +414 -0
  41. package/templates/agent-docs/routing.md +93 -2
  42. package/templates/agent-docs/schema-driven-ui.md +12 -0
  43. package/templates/agent-docs/templates/apibackends.md +1 -1
  44. package/templates/agent-docs/whats-new.md +162 -181
  45. package/templates/agent-docs/workflows.md +11 -0
  46. package/templates/apps/api-ai/package.json +7 -7
  47. package/templates/apps/api-auth/package.json +8 -8
  48. package/templates/apps/api-backend/package.json +7 -7
  49. package/templates/apps/api-backend-deactivation/package.json +7 -7
  50. package/templates/apps/api-backend-mail/package.json +8 -8
  51. package/templates/apps/api-backend-mariadb/package.json +9 -9
  52. package/templates/apps/api-backend-sqlite/.env.example +19 -0
  53. package/templates/apps/api-backend-sqlite/README.md +38 -0
  54. package/templates/apps/api-backend-sqlite/app.config.ts +35 -0
  55. package/templates/apps/api-backend-sqlite/database/schema.ts +54 -0
  56. package/templates/apps/api-backend-sqlite/mutations/notes.create.mutation.server.ts +19 -0
  57. package/templates/apps/api-backend-sqlite/mutations/notes.create.mutation.ts +37 -0
  58. package/templates/apps/api-backend-sqlite/package.json +30 -0
  59. package/templates/apps/api-backend-sqlite/queries/notes.query.server.ts +14 -0
  60. package/templates/apps/api-backend-sqlite/queries/notes.query.ts +20 -0
  61. package/templates/apps/api-backend-sqlite/template.json +6 -0
  62. package/templates/apps/api-backend-sqlite/tests/notes.create.test.ts +50 -0
  63. package/templates/apps/api-backend-sqlite/tsconfig.json +5 -0
  64. package/templates/apps/api-backend-storage/package.json +8 -8
  65. package/templates/apps/api-cms/README.md +104 -0
  66. package/templates/apps/api-cms/actions/content.get.action.server.ts +27 -0
  67. package/templates/apps/api-cms/actions/content.get.action.ts +19 -0
  68. package/templates/apps/api-cms/actions/content.types.action.server.ts +26 -0
  69. package/templates/apps/api-cms/actions/content.types.action.ts +40 -0
  70. package/templates/apps/api-cms/actions/me.action.server.ts +18 -0
  71. package/templates/apps/api-cms/actions/me.action.ts +16 -0
  72. package/templates/apps/api-cms/app.config.ts +61 -0
  73. package/templates/apps/api-cms/content/blogPost.contentType.ts +39 -0
  74. package/templates/apps/api-cms/content/index.ts +18 -0
  75. package/templates/apps/api-cms/content/page.contentType.ts +24 -0
  76. package/templates/apps/api-cms/database/schema.ts +64 -0
  77. package/templates/apps/api-cms/mutations/content.publish.mutation.server.ts +19 -0
  78. package/templates/apps/api-cms/mutations/content.publish.mutation.ts +15 -0
  79. package/templates/apps/api-cms/mutations/content.saveDraft.mutation.server.ts +36 -0
  80. package/templates/apps/api-cms/mutations/content.saveDraft.mutation.ts +32 -0
  81. package/templates/apps/api-cms/mutations/content.unpublish.mutation.server.ts +19 -0
  82. package/templates/apps/api-cms/mutations/content.unpublish.mutation.ts +11 -0
  83. package/templates/apps/api-cms/package.json +32 -0
  84. package/templates/apps/api-cms/queries/content.list.query.server.ts +0 -0
  85. package/templates/apps/api-cms/queries/content.list.query.ts +27 -0
  86. package/templates/apps/api-cms/template.json +6 -0
  87. package/templates/apps/api-cms/tests/content.descriptors.test.ts +64 -0
  88. package/templates/apps/api-cms/tests/content.write.test.ts +85 -0
  89. package/templates/apps/api-cms/tsconfig.json +5 -0
  90. package/templates/apps/api-data-advanced/package.json +8 -8
  91. package/templates/apps/api-durable/package.json +8 -8
  92. package/templates/apps/api-feature-flags/package.json +9 -9
  93. package/templates/apps/api-governance/package.json +8 -8
  94. package/templates/apps/api-kv/package.json +8 -8
  95. package/templates/apps/api-moderation/package.json +8 -8
  96. package/templates/apps/api-observability/package.json +8 -8
  97. package/templates/apps/api-ratelimit/package.json +8 -8
  98. package/templates/apps/api-rbac/package.json +8 -8
  99. package/templates/apps/api-rest/package.json +7 -7
  100. package/templates/apps/api-saas/package.json +11 -11
  101. package/templates/apps/api-saas-starter/README.md +103 -0
  102. package/templates/apps/api-saas-starter/actions/me.action.server.ts +18 -0
  103. package/templates/apps/api-saas-starter/actions/me.action.ts +20 -0
  104. package/templates/apps/api-saas-starter/app.config.ts +87 -0
  105. package/templates/apps/api-saas-starter/database/schema.ts +57 -0
  106. package/templates/apps/api-saas-starter/mutations/invites.create.mutation.server.ts +26 -0
  107. package/templates/apps/api-saas-starter/mutations/invites.create.mutation.ts +18 -0
  108. package/templates/apps/api-saas-starter/mutations/projects.create.mutation.server.ts +29 -0
  109. package/templates/apps/api-saas-starter/mutations/projects.create.mutation.ts +18 -0
  110. package/templates/apps/api-saas-starter/package.json +32 -0
  111. package/templates/apps/api-saas-starter/queries/invites.list.query.server.ts +14 -0
  112. package/templates/apps/api-saas-starter/queries/invites.list.query.ts +17 -0
  113. package/templates/apps/api-saas-starter/queries/projects.list.query.server.ts +14 -0
  114. package/templates/apps/api-saas-starter/queries/projects.list.query.ts +18 -0
  115. package/templates/apps/api-saas-starter/template.json +6 -0
  116. package/templates/apps/api-saas-starter/tests/projects.create.test.ts +62 -0
  117. package/templates/apps/api-saas-starter/tests/session.test.ts +33 -0
  118. package/templates/apps/api-saas-starter/tsconfig.json +5 -0
  119. package/templates/apps/api-search/package.json +8 -8
  120. package/templates/apps/api-status/README.md +88 -0
  121. package/templates/apps/api-status/app.config.ts +36 -0
  122. package/templates/apps/api-status/authz.ts +33 -0
  123. package/templates/apps/api-status/database/schema.ts +70 -0
  124. package/templates/apps/api-status/mutations/components.create.mutation.server.ts +19 -0
  125. package/templates/apps/api-status/mutations/components.create.mutation.ts +16 -0
  126. package/templates/apps/api-status/mutations/incidents.create.mutation.server.ts +25 -0
  127. package/templates/apps/api-status/mutations/incidents.create.mutation.ts +22 -0
  128. package/templates/apps/api-status/mutations/incidents.resolve.mutation.server.ts +24 -0
  129. package/templates/apps/api-status/mutations/incidents.resolve.mutation.ts +19 -0
  130. package/templates/apps/api-status/mutations/incidents.update.mutation.server.ts +26 -0
  131. package/templates/apps/api-status/mutations/incidents.update.mutation.ts +21 -0
  132. package/templates/apps/api-status/package.json +30 -0
  133. package/templates/apps/api-status/queries/components.list.query.server.ts +14 -0
  134. package/templates/apps/api-status/queries/components.list.query.ts +13 -0
  135. package/templates/apps/api-status/queries/incidents.live.query.server.ts +14 -0
  136. package/templates/apps/api-status/queries/incidents.live.query.ts +20 -0
  137. package/templates/apps/api-status/queries/updates.list.query.server.ts +14 -0
  138. package/templates/apps/api-status/queries/updates.list.query.ts +17 -0
  139. package/templates/apps/api-status/template.json +6 -0
  140. package/templates/apps/api-status/tests/status.test.ts +70 -0
  141. package/templates/apps/api-status/tsconfig.json +5 -0
  142. package/templates/apps/api-versioning/package.json +8 -8
  143. package/templates/apps/api-webhooks/package.json +9 -9
  144. package/templates/apps/changelog/package.json +6 -6
  145. package/templates/apps/edge-functions/package.json +2 -2
  146. package/templates/apps/frontend-admin/package.json +8 -8
  147. package/templates/apps/frontend-app/package.json +8 -8
  148. package/templates/apps/frontend-auth/README.md +78 -0
  149. package/templates/apps/frontend-auth/app.config.ts +34 -0
  150. package/templates/apps/frontend-auth/package.json +32 -0
  151. package/templates/apps/frontend-auth/src/components/AuthShell.tsx +35 -0
  152. package/templates/apps/frontend-auth/src/components/PasswordStrength.tsx +33 -0
  153. package/templates/apps/frontend-auth/src/config.ts +11 -0
  154. package/templates/apps/frontend-auth/src/globals.css +105 -0
  155. package/templates/apps/frontend-auth/src/globals.d.ts +6 -0
  156. package/templates/apps/frontend-auth/src/lib/auth.ts +34 -0
  157. package/templates/apps/frontend-auth/src/lib/redirect.test.ts +24 -0
  158. package/templates/apps/frontend-auth/src/lib/redirect.ts +29 -0
  159. package/templates/apps/frontend-auth/src/locales/de.ts +66 -0
  160. package/templates/apps/frontend-auth/src/locales/en.ts +76 -0
  161. package/templates/apps/frontend-auth/src/locales/index.ts +14 -0
  162. package/templates/apps/frontend-auth/src/pages/forgot/page.tsx +51 -0
  163. package/templates/apps/frontend-auth/src/pages/layout.tsx +12 -0
  164. package/templates/apps/frontend-auth/src/pages/login/page.test.tsx +53 -0
  165. package/templates/apps/frontend-auth/src/pages/login/page.tsx +67 -0
  166. package/templates/apps/frontend-auth/src/pages/logout/page.tsx +28 -0
  167. package/templates/apps/frontend-auth/src/pages/magic/page.tsx +51 -0
  168. package/templates/apps/frontend-auth/src/pages/page.tsx +26 -0
  169. package/templates/apps/frontend-auth/src/pages/reset/page.test.tsx +51 -0
  170. package/templates/apps/frontend-auth/src/pages/reset/page.tsx +63 -0
  171. package/templates/apps/frontend-auth/src/pages/signup/page.tsx +60 -0
  172. package/templates/apps/frontend-auth/src/pages/verify/page.tsx +50 -0
  173. package/templates/apps/frontend-auth/template.json +6 -0
  174. package/templates/apps/frontend-auth/tsconfig.json +5 -0
  175. package/templates/apps/frontend-blank/package.json +7 -7
  176. package/templates/apps/frontend-cms/README.md +47 -0
  177. package/templates/apps/frontend-cms/app.config.ts +37 -0
  178. package/templates/apps/frontend-cms/package.json +33 -0
  179. package/templates/apps/frontend-cms/src/config.ts +8 -0
  180. package/templates/apps/frontend-cms/src/globals.css +105 -0
  181. package/templates/apps/frontend-cms/src/globals.d.ts +6 -0
  182. package/templates/apps/frontend-cms/src/lib/api.ts +58 -0
  183. package/templates/apps/frontend-cms/src/locales/de.ts +50 -0
  184. package/templates/apps/frontend-cms/src/locales/en.ts +55 -0
  185. package/templates/apps/frontend-cms/src/locales/index.ts +14 -0
  186. package/templates/apps/frontend-cms/src/pages/(app)/error.tsx +18 -0
  187. package/templates/apps/frontend-cms/src/pages/(app)/layout.test.tsx +31 -0
  188. package/templates/apps/frontend-cms/src/pages/(app)/layout.tsx +62 -0
  189. package/templates/apps/frontend-cms/src/pages/(app)/not-found.tsx +14 -0
  190. package/templates/apps/frontend-cms/src/pages/(app)/page.test.tsx +110 -0
  191. package/templates/apps/frontend-cms/src/pages/(app)/page.tsx +159 -0
  192. package/templates/apps/frontend-cms/src/pages/layout.tsx +12 -0
  193. package/templates/apps/frontend-cms/src/pages/login/page.test.tsx +58 -0
  194. package/templates/apps/frontend-cms/src/pages/login/page.tsx +93 -0
  195. package/templates/apps/frontend-cms/template.json +6 -0
  196. package/templates/apps/frontend-cms/tsconfig.json +5 -0
  197. package/templates/apps/frontend-contact/package.json +7 -7
  198. package/templates/apps/frontend-dashboard/package.json +7 -7
  199. package/templates/apps/frontend-docs/package.json +7 -7
  200. package/templates/apps/frontend-i18n/package.json +6 -6
  201. package/templates/apps/frontend-landing/package.json +7 -7
  202. package/templates/apps/frontend-portal/README.md +71 -0
  203. package/templates/apps/frontend-portal/app.config.ts +37 -0
  204. package/templates/apps/frontend-portal/package.json +32 -0
  205. package/templates/apps/frontend-portal/src/config.ts +8 -0
  206. package/templates/apps/frontend-portal/src/globals.css +93 -0
  207. package/templates/apps/frontend-portal/src/globals.d.ts +6 -0
  208. package/templates/apps/frontend-portal/src/lib/api.ts +62 -0
  209. package/templates/apps/frontend-portal/src/locales/de.ts +86 -0
  210. package/templates/apps/frontend-portal/src/locales/en.ts +93 -0
  211. package/templates/apps/frontend-portal/src/locales/index.ts +14 -0
  212. package/templates/apps/frontend-portal/src/pages/(portal)/api-keys/page.test.tsx +71 -0
  213. package/templates/apps/frontend-portal/src/pages/(portal)/api-keys/page.tsx +124 -0
  214. package/templates/apps/frontend-portal/src/pages/(portal)/billing/page.test.tsx +86 -0
  215. package/templates/apps/frontend-portal/src/pages/(portal)/billing/page.tsx +97 -0
  216. package/templates/apps/frontend-portal/src/pages/(portal)/error.tsx +19 -0
  217. package/templates/apps/frontend-portal/src/pages/(portal)/invoices/page.tsx +45 -0
  218. package/templates/apps/frontend-portal/src/pages/(portal)/layout.test.tsx +31 -0
  219. package/templates/apps/frontend-portal/src/pages/(portal)/layout.tsx +63 -0
  220. package/templates/apps/frontend-portal/src/pages/(portal)/not-found.tsx +15 -0
  221. package/templates/apps/frontend-portal/src/pages/(portal)/page.tsx +39 -0
  222. package/templates/apps/frontend-portal/src/pages/(portal)/profile/page.tsx +79 -0
  223. package/templates/apps/frontend-portal/src/pages/layout.tsx +12 -0
  224. package/templates/apps/frontend-portal/src/pages/login/page.test.tsx +58 -0
  225. package/templates/apps/frontend-portal/src/pages/login/page.tsx +96 -0
  226. package/templates/apps/frontend-portal/template.json +6 -0
  227. package/templates/apps/frontend-portal/tsconfig.json +5 -0
  228. package/templates/apps/frontend-saas/README.md +73 -0
  229. package/templates/apps/frontend-saas/app.config.ts +46 -0
  230. package/templates/apps/frontend-saas/package.json +32 -0
  231. package/templates/apps/frontend-saas/src/config.ts +8 -0
  232. package/templates/apps/frontend-saas/src/globals.css +85 -0
  233. package/templates/apps/frontend-saas/src/globals.d.ts +6 -0
  234. package/templates/apps/frontend-saas/src/lib/api.ts +47 -0
  235. package/templates/apps/frontend-saas/src/locales/de.ts +72 -0
  236. package/templates/apps/frontend-saas/src/locales/en.ts +81 -0
  237. package/templates/apps/frontend-saas/src/locales/index.ts +14 -0
  238. package/templates/apps/frontend-saas/src/pages/(marketing)/layout.tsx +30 -0
  239. package/templates/apps/frontend-saas/src/pages/(marketing)/login/page.test.tsx +58 -0
  240. package/templates/apps/frontend-saas/src/pages/(marketing)/login/page.tsx +96 -0
  241. package/templates/apps/frontend-saas/src/pages/(marketing)/page.tsx +27 -0
  242. package/templates/apps/frontend-saas/src/pages/dashboard/billing/page.tsx +72 -0
  243. package/templates/apps/frontend-saas/src/pages/dashboard/error.tsx +20 -0
  244. package/templates/apps/frontend-saas/src/pages/dashboard/layout.test.tsx +32 -0
  245. package/templates/apps/frontend-saas/src/pages/dashboard/layout.tsx +69 -0
  246. package/templates/apps/frontend-saas/src/pages/dashboard/not-found.tsx +17 -0
  247. package/templates/apps/frontend-saas/src/pages/dashboard/page.test.tsx +113 -0
  248. package/templates/apps/frontend-saas/src/pages/dashboard/page.tsx +76 -0
  249. package/templates/apps/frontend-saas/src/pages/dashboard/team/page.tsx +73 -0
  250. package/templates/apps/frontend-saas/src/pages/layout.tsx +12 -0
  251. package/templates/apps/frontend-saas/template.json +6 -0
  252. package/templates/apps/frontend-saas/tsconfig.json +5 -0
  253. package/templates/apps/frontend-spa/package.json +7 -7
  254. package/templates/apps/frontend-ssr/package.json +7 -7
  255. package/templates/apps/frontend-ssr-api/package.json +8 -8
  256. package/templates/apps/frontend-static-blog/package.json +6 -6
  257. package/templates/apps/frontend-status/README.md +51 -0
  258. package/templates/apps/frontend-status/app.config.ts +39 -0
  259. package/templates/apps/frontend-status/package.json +32 -0
  260. package/templates/apps/frontend-status/src/config.ts +7 -0
  261. package/templates/apps/frontend-status/src/globals.css +70 -0
  262. package/templates/apps/frontend-status/src/globals.d.ts +6 -0
  263. package/templates/apps/frontend-status/src/lib/status.ts +81 -0
  264. package/templates/apps/frontend-status/src/locales/de.ts +41 -0
  265. package/templates/apps/frontend-status/src/locales/en.ts +45 -0
  266. package/templates/apps/frontend-status/src/locales/index.ts +13 -0
  267. package/templates/apps/frontend-status/src/pages/layout.tsx +27 -0
  268. package/templates/apps/frontend-status/src/pages/page.test.tsx +123 -0
  269. package/templates/apps/frontend-status/src/pages/page.tsx +136 -0
  270. package/templates/apps/frontend-status/template.json +6 -0
  271. package/templates/apps/frontend-status/tsconfig.json +11 -0
  272. package/templates/baselines/compose/docker-compose.prod.yml +15 -0
  273. package/templates/baselines/compose-mariadb/docker-compose.prod.yml +15 -0
  274. package/dist/apiBuild-DgBS9ayv.js +0 -2
  275. package/dist/dbCommand-DvguqlzF.js +0 -2
  276. package/dist/fileConventions-3bffWssN.js +0 -30
  277. package/dist/frameworkTableAssembly-lrjZtk0G.js +0 -2
  278. package/dist/inspect-gt8bq-Tz.js +0 -2
  279. package/dist/manifestBuild-ifczArzr.js +0 -2
  280. 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
+ })