@astrale-os/adapter-cloudflare 0.2.1 → 0.3.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 (64) hide show
  1. package/dist/build.d.ts +2 -1
  2. package/dist/build.d.ts.map +1 -1
  3. package/dist/build.js +1 -1
  4. package/dist/build.js.map +1 -1
  5. package/dist/client.d.ts +14 -13
  6. package/dist/client.d.ts.map +1 -1
  7. package/dist/client.js +25 -18
  8. package/dist/client.js.map +1 -1
  9. package/dist/cloudflare.d.ts +4 -1
  10. package/dist/cloudflare.d.ts.map +1 -1
  11. package/dist/cloudflare.js +40 -18
  12. package/dist/cloudflare.js.map +1 -1
  13. package/dist/codegen/worker.d.ts +3 -3
  14. package/dist/codegen/worker.d.ts.map +1 -1
  15. package/dist/codegen/worker.js +19 -8
  16. package/dist/codegen/worker.js.map +1 -1
  17. package/dist/index.d.ts +2 -2
  18. package/dist/index.js +2 -2
  19. package/dist/params.d.ts +3 -0
  20. package/dist/params.d.ts.map +1 -1
  21. package/package.json +2 -2
  22. package/src/build.ts +2 -1
  23. package/src/client.ts +43 -18
  24. package/src/cloudflare.ts +41 -14
  25. package/src/codegen/worker.ts +19 -8
  26. package/src/index.ts +2 -2
  27. package/src/params.ts +4 -0
  28. package/template/CLAUDE.md +24 -0
  29. package/template/README.md +24 -15
  30. package/template/astrale.config.ts +4 -4
  31. package/template/client/README.md +9 -9
  32. package/template/client/__tests__/app.test.tsx +58 -19
  33. package/template/client/__tests__/harness.ts +16 -0
  34. package/template/client/__tests__/kernel.test.ts +23 -3
  35. package/template/client/src/shell/use-async.ts +4 -1
  36. package/template/client/src/status/components/StatusCard.tsx +115 -5
  37. package/template/client/src/status/hooks/useCheckable.query.ts +48 -40
  38. package/template/client/src/status/index.ts +2 -2
  39. package/template/client/src/status/status.api.ts +18 -1
  40. package/template/client/src/status/status.mappers.ts +89 -6
  41. package/template/client/src/status/status.types.ts +17 -2
  42. package/template/client/src/styles.css +235 -14
  43. package/template/client/src/views/status.tsx +1 -1
  44. package/template/core/monitor/index.ts +2 -2
  45. package/template/core/monitor/node.ts +12 -6
  46. package/template/domain.ts +6 -4
  47. package/template/functions/index.ts +31 -7
  48. package/template/package.json +2 -2
  49. package/template/pnpm-lock.yaml +2780 -0
  50. package/template/runtime/index.ts +8 -17
  51. package/template/runtime/monitoring/index.ts +8 -0
  52. package/template/runtime/{monitor → monitoring/monitor}/check.ts +3 -3
  53. package/template/runtime/{monitor → monitoring/monitor}/index.ts +3 -2
  54. package/template/runtime/{monitor → monitoring/monitor}/seed.ts +19 -10
  55. package/template/runtime/{monitor → monitoring/monitor}/watch.ts +5 -5
  56. package/template/runtime/{status-page → monitoring/page}/add.ts +2 -2
  57. package/template/runtime/{status-page → monitoring/page}/check.ts +2 -2
  58. package/template/runtime/{status-page → monitoring/page}/create.ts +3 -3
  59. package/template/runtime/monitoring/page/index.ts +9 -0
  60. package/template/schema/monitor.ts +6 -8
  61. package/template/views/index.ts +1 -1
  62. package/template/.agents/skills/astrale-cli/SKILL.md +0 -458
  63. package/template/.agents/skills/astrale-domain/SKILL.md +0 -372
  64. package/template/runtime/status-page/index.ts +0 -8
package/src/cloudflare.ts CHANGED
@@ -17,7 +17,7 @@ import { join } from 'node:path'
17
17
 
18
18
  import type { CloudflareParams } from './params'
19
19
 
20
- import { buildClient } from './client'
20
+ import { buildClient, resolveClientDir } from './client'
21
21
  import { ensureIdentity } from './codegen/identity'
22
22
  import { generateWorkerEntry } from './codegen/worker'
23
23
  import { generateWranglerConfig } from './codegen/wrangler'
@@ -33,8 +33,12 @@ export function cloudflare(
33
33
  envs,
34
34
 
35
35
  async watch(params, ctx) {
36
- const { configPath, port } = await prepare(params, ctx, 'dev')
37
- if (ctx.clientDir) await buildClient(ctx.clientDir, ctx.projectDir, logTo())
36
+ const clientDir = resolveCloudflareClientDir(params, ctx)
37
+ const { configPath, port } = await prepare(params, ctx, 'dev', {
38
+ servesClient: Boolean(clientDir),
39
+ bundleAssets: Boolean(clientDir),
40
+ })
41
+ if (clientDir) await buildClient(clientDir, ctx.projectDir, logTo())
38
42
  const handle = await runWranglerDev({
39
43
  projectDir: ctx.projectDir,
40
44
  configPath,
@@ -55,17 +59,25 @@ export function cloudflare(
55
59
  // them itself, so a `astrale.config.ts` edit lands without a restart.
56
60
  // Mirrors `watch`'s prep exactly (codegen + client build), minus the spawn.
57
61
  async regenerate(params, ctx) {
58
- await prepare(params, ctx, 'dev')
59
- if (ctx.clientDir) await buildClient(ctx.clientDir, ctx.projectDir, logTo())
62
+ const clientDir = resolveCloudflareClientDir(params, ctx)
63
+ await prepare(params, ctx, 'dev', {
64
+ servesClient: Boolean(clientDir),
65
+ bundleAssets: Boolean(clientDir),
66
+ })
67
+ if (clientDir) await buildClient(clientDir, ctx.projectDir, logTo())
60
68
  },
61
69
 
62
70
  async deploy(params, ctx) {
71
+ const clientDir = resolveCloudflareClientDir(params, ctx)
63
72
  const {
64
73
  configPath,
65
74
  fallbackConfigPath,
66
75
  workerName: name,
67
- } = await prepare(params, ctx, 'deploy')
68
- if (ctx.clientDir) await buildClient(ctx.clientDir, ctx.projectDir, logTo())
76
+ } = await prepare(params, ctx, 'deploy', {
77
+ servesClient: Boolean(clientDir),
78
+ bundleAssets: Boolean(clientDir),
79
+ })
80
+ if (clientDir) await buildClient(clientDir, ctx.projectDir, logTo())
69
81
  const { url } = await runWranglerDeploy({
70
82
  projectDir: ctx.projectDir,
71
83
  configPath,
@@ -96,12 +108,29 @@ export function cloudflare(
96
108
  })
97
109
  }
98
110
 
111
+ function resolveCloudflareClientDir(
112
+ params: CloudflareParams,
113
+ ctx: WatchCtx | DeployCtx,
114
+ ): string | undefined {
115
+ return resolveClientDir({
116
+ adapterName: 'cloudflare',
117
+ env: ctx.env,
118
+ projectDir: ctx.projectDir,
119
+ domain: ctx.domain,
120
+ ...(params.client ? { client: params.client } : {}),
121
+ })
122
+ }
123
+
99
124
  // ── codegen orchestration ──────────────────────────────────────────────────
100
125
 
101
126
  export async function prepare(
102
127
  params: CloudflareParams,
103
128
  ctx: WatchCtx | DeployCtx,
104
129
  mode: 'dev' | 'deploy',
130
+ clientAssets: { servesClient: boolean; bundleAssets: boolean } = {
131
+ servesClient: false,
132
+ bundleAssets: false,
133
+ },
105
134
  ): Promise<{ configPath: string; fallbackConfigPath?: string; port: number; workerName: string }> {
106
135
  const astraleDir = join(ctx.projectDir, '.astrale')
107
136
  await mkdir(astraleDir, { recursive: true })
@@ -112,13 +141,11 @@ export async function prepare(
112
141
  // worker reach other instances' hosts internally, so a cross-instance JWKS
113
142
  // fetch doesn't 522. Resolved once, fed to BOTH codegens below.
114
143
  const router = resolveRouter(params.router)
115
- // Two distinct client signals: the WORKER's `/ui` hook is present whenever the
116
- // domain declares a client (`ctx.domain.hasClient`) including managed
117
- // deploys, which ship the built assets separately and pass no `clientDir`; the
118
- // wrangler `assets.directory` binding is wired only when there's a local dir to
119
- // point at (`ctx.clientDir` — direct Cloudflare deploys + local dev).
120
- const servesClient = ctx.domain.hasClient
121
- const bundleAssets = Boolean(ctx.clientDir)
144
+ // Two distinct client signals: the WORKER's `/ui` hook and Wrangler's local
145
+ // assets binding. Direct Cloudflare deploys use both; managed deploys ship
146
+ // assets separately but still need the worker hook.
147
+ const servesClient = clientAssets.servesClient
148
+ const bundleAssets = clientAssets.bundleAssets
122
149
 
123
150
  await writeFileAtomic(
124
151
  join(astraleDir, 'worker.gen.ts'),
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * The dev wires the domain ONCE in a worker-safe `domain.ts`
5
5
  * (`export const domain = defineDomain({ schema, methods, deps, views,
6
- * functions, client })`) and binds it to an adapter in `astrale.config.ts`
6
+ * functions })`) and binds it to an adapter in `astrale.config.ts`
7
7
  * (`deploy(domain, cloudflare({ … }))`). This generates the
8
8
  * `export default { fetch }` plumbing the adapter owns by importing that single
9
9
  * `domain` and SPREADING it into `domainWorkerEntry` — so the author's folder
@@ -13,8 +13,8 @@
13
13
  *
14
14
  * Everything the entry needs — schema/methods/deps/views/functions/requires/
15
15
  * postInstall — rides on the spread; the only build-time signal left is whether
16
- * the domain serves a client SPA (`hasClient`), which gates the `/ui` asset hook
17
- * and its import. Key properties:
16
+ * this adapter env serves a client SPA (`hasClient`), which gates the `/ui`
17
+ * asset hook and its import. Key properties:
18
18
  *
19
19
  * • Per-request `url` = the live serving origin (`scheme://host`). It is passed
20
20
  * only to `createRemoteServer({ url })`; the SDK stamps every
@@ -47,7 +47,7 @@ export interface WorkerCodegenOptions {
47
47
 
48
48
  export function generateWorkerEntry(opts: WorkerCodegenOptions): string {
49
49
  // The asset-serving hook is a library helper (`assets`), not inlined string
50
- // logic — imported only when the domain declares a `client` binding.
50
+ // logic — imported only when this adapter env serves client assets.
51
51
  const serverImport = opts.hasClient
52
52
  ? `import { assets, domainWorkerEntry } from '@astrale-os/sdk/server'`
53
53
  : `import { domainWorkerEntry } from '@astrale-os/sdk/server'`
@@ -64,7 +64,11 @@ export function generateWorkerEntry(opts: WorkerCodegenOptions): string {
64
64
  // routeSubrequest), wired only when the adapter's `router` is set.
65
65
  const routerEnvField = router
66
66
  ? `
67
- ${router.binding}?: { fetch(request: Request): Promise<Response> }`
67
+ ${router.binding}?: { fetch(request: Request): Promise<Response> }
68
+ // Sibling WFP services share one dispatch namespace; this binding reaches them
69
+ // IN-PROCESS (a same-zone public fetch 522s; the instance router doesn't know svc
70
+ // hosts). Injected by the \`services\` domain at deploy; absent → svc routing no-ops.
71
+ DISPATCHER?: { get(name: string): { fetch(request: Request): Promise<Response> } }`
68
72
  : ''
69
73
  const routerHelper = router
70
74
  ? `
@@ -79,9 +83,16 @@ function isInstanceHost(host) {
79
83
  : ''
80
84
  const routerHook = router
81
85
  ? `
82
- // Divert instance-host subrequests through ${router.binding} (CF 522s a same-zone
83
- // Worker→Worker public fetch). No-op when the binding is absent.
84
- routeSubrequest: (url, env) => (env.${router.binding} && isInstanceHost(url.hostname) ? env.${router.binding} : null),`
86
+ // Divert platform subrequests off the same-zone public edge (CF 522s a same-zone
87
+ // Worker→Worker public fetch). No-op when a binding is absent.
88
+ routeSubrequest: (url, env) => {
89
+ const host = url.hostname
90
+ // Sibling WFP service (<script>.svc.<domain>) → in-process via the dispatch namespace.
91
+ if (env.DISPATCHER && host.split('.')[1] === 'svc') return env.DISPATCHER.get(host.split('.')[0])
92
+ // Instance host → router service binding.
93
+ if (env.${router.binding} && isInstanceHost(host)) return env.${router.binding}
94
+ return null
95
+ },`
85
96
  : ''
86
97
 
87
98
  return `// AUTO-GENERATED by @astrale-os/adapter-cloudflare — do not edit.
package/src/index.ts CHANGED
@@ -5,8 +5,8 @@
5
5
  * import { cloudflare } from '@astrale-os/adapter-cloudflare'
6
6
  *
7
7
  * adapter: cloudflare({
8
- * dev: { secrets: '.env.dev' },
9
- * prod: { route: 'crm.acme.dev', secrets: '.env.prod' },
8
+ * dev: { client: { dir: 'client' }, secrets: '.env.dev' },
9
+ * prod: { client: { dir: 'client' }, route: 'crm.acme.dev', secrets: '.env.prod' },
10
10
  * })
11
11
  *
12
12
  * `dev` runs `wrangler dev` locally; an env with no `route` ships to
package/src/params.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import type { ClientConfig } from './client'
2
+
1
3
  /**
2
4
  * `CloudflareParams` — the provider-typed config for the Cloudflare adapter.
3
5
  *
@@ -11,6 +13,8 @@
11
13
  * • remote (custom) — `deploy()` with `route`; URL = https://<route>
12
14
  */
13
15
  export interface CloudflareParams {
16
+ /** Frontend assets to build and serve for this env's mounted `/ui` views. */
17
+ client?: ClientConfig
14
18
  /**
15
19
  * Custom route / hostname for a routed deploy, e.g. `'crm.acme.dev'`. Omit to
16
20
  * deploy to `*.workers.dev`. Ignored by local `wrangler dev` (URL is
@@ -0,0 +1,24 @@
1
+ # astrale-domain
2
+
3
+ A standalone Astrale domain. The authoring guide is not in this repo — it's the
4
+ `astrale-domain` agent skill. Load it before changing the schema or handlers.
5
+
6
+ ## Agent skills
7
+
8
+ If the `astrale-domain` / `astrale-cli` skills aren't already available in your
9
+ harness, install them once (user-level):
10
+
11
+ ```bash
12
+ npx skills add astrale-os/cli -g # both skills · or run `astrale setup`
13
+ ```
14
+
15
+ They install into your agent harness (not this project), so every Astrale
16
+ project picks them up. `astrale update` refreshes them alongside the CLI binary
17
+ and this project's `@astrale-os/*` dependencies.
18
+
19
+ ## Orientation
20
+
21
+ See `README.md` for the project layout (`schema/`, `core/`, `integrations/`,
22
+ `runtime/`, `views/`, `functions/`, `client/`), the dev/deploy loop, and the
23
+ signing-identity note. `domain.ts` is the one place the domain contract is
24
+ declared.
@@ -6,11 +6,18 @@ service (default) or as a worker on your own Cloudflare account.
6
6
 
7
7
  ## For AI agents
8
8
 
9
- The complete domain-authoring guide ships with this project at
10
- `.agents/skills/astrale-domain/SKILL.md`schema modeling, handlers, kernel
11
- calls, external-API patterns (DI, secrets, idempotency), views, webhooks,
12
- deploys. Load it before making changes. For CLI operations (auth, instances,
13
- calls), use the `astrale-cli` skill.
9
+ The complete domain-authoring guide is the `astrale-domain` skill (schema
10
+ modeling, handlers, kernel calls, external-API patterns DI, secrets,
11
+ idempotency views, webhooks, deploys); CLI operations (auth, instances, calls)
12
+ are the `astrale-cli` skill. Install both once, user-level:
13
+
14
+ ```bash
15
+ npx skills add astrale-os/cli -g # both skills · or run `astrale setup`
16
+ ```
17
+
18
+ Then load `astrale-domain` before making changes. (Skills are installed into your
19
+ agent harness, not committed to this project — `astrale update` keeps them, the
20
+ CLI, and your `@astrale-os/*` deps current.)
14
21
 
15
22
  > **Requires [Bun](https://bun.sh)** — the `astrale-domain` CLI behind `pnpm dev`
16
23
  > / `pnpm prod` imports `astrale.config.ts` and your ★ files directly, so it
@@ -38,15 +45,16 @@ domain.ts wires it all together — the worker-safe definition
38
45
  astrale.config.ts binds the domain to its deploy adapter (node-only)
39
46
  ```
40
47
 
41
- `domain.ts` is the one place everything is declared: it imports the modules
42
- above and passes them to `defineDomain({ schema, methods, deps, views,
43
- functions, client, … })` alongside the identity (`origin`, `requires`,
44
- `postInstall`). Nothing is discovered by folder name — a renamed or mistyped
45
- module is a compile error at that call, never a silently-missing route. Drop a
46
- field (`deps`, `views`, `functions`, `client`) when the domain has none.
47
- `astrale.config.ts` then binds that domain to a deploy adapter with
48
- `deploy(domain, cloudflare({ }))` — keeping the node-only adapter (wrangler,
49
- filesystem) out of the worker bundle.
48
+ `domain.ts` is the one place the domain contract is declared: it imports the
49
+ modules above and passes them to `defineDomain({ schema, methods, deps, views,
50
+ functions, … })` alongside the identity (`origin`, `requires`, `postInstall`).
51
+ Nothing is discovered by folder name — a renamed or mistyped module is a compile
52
+ error at that call, never a silently-missing route. Drop a field (`deps`,
53
+ `views`, `functions`) when the domain has none. `astrale.config.ts` then binds
54
+ that domain to a deploy adapter with `deploy(domain, cloudflare({ … }))` and
55
+ chooses, per env, which `client` assets serve mounted `/ui` views — keeping the
56
+ node-only adapter (wrangler, filesystem, frontend builds) out of the worker
57
+ bundle.
50
58
 
51
59
  Everything else — the Worker entry, the wrangler config, the signing identity —
52
60
  is generated under `.astrale/` (gitignored) by the adapter. You never edit it.
@@ -90,7 +98,8 @@ adapter-owned keys `name`, `main`, `assets`, `routes` are rejected (use
90
98
  - **Edit a handler** (`core/` logic or `runtime/` wiring) → hot-reloads at the same URL. Nothing to reinstall.
91
99
  - **Edit the schema** (`schema/`) → rebuilds the graph; reinstall with `astrale domain install <url> --direct`.
92
100
  - **`postInstall`** (the static `Monitor.seed` method) runs once after install, as
93
- the system identity, so the domain can seed itself and set its own grants. It
101
+ the system identity, so the domain can seed `/monitoring/monitors` and
102
+ `/monitoring/pages/status` and set its own grants. It
94
103
  must be a class-hosted static addressed by a typed colon-path
95
104
  (`/:origin:class.X:seed`) — the kernel refuses tree paths here.
96
105
 
@@ -18,8 +18,8 @@ import { deploy } from '@astrale-os/sdk'
18
18
  //
19
19
  // import { astrale } from '@astrale-os/adapter-astrale'
20
20
  // export default deploy(domain, astrale({
21
- // dev: { secrets: '.env.dev' }, // local wrangler dev, unchanged
22
- // prod: { instance: '<your-instance-slug>' }, // pnpm prod → managed deploy
21
+ // dev: { client: { dir: 'client' }, secrets: '.env.dev' }, // local wrangler dev
22
+ // prod: { client: { dir: 'client' }, instance: '<your-instance-slug>' }, // managed deploy
23
23
  // }))
24
24
 
25
25
  import { domain } from './domain'
@@ -28,9 +28,9 @@ export default deploy(
28
28
  domain,
29
29
  cloudflare({
30
30
  // Local dev: `wrangler dev`. No route → URL is http://localhost:8787.
31
- dev: { secrets: '.env.dev' },
31
+ dev: { client: { dir: 'client' }, secrets: '.env.dev' },
32
32
  // Custom-domain prod. Drop `route` to ship to *.workers.dev instead.
33
- prod: { route: 'astrale-domain.example.dev', secrets: '.env.prod' },
33
+ prod: { client: { dir: 'client' }, route: 'astrale-domain.example.dev', secrets: '.env.prod' },
34
34
  // Author secrets ship via `secrets: '.env.prod'` on any env. Extra bindings
35
35
  // (KV, R2, D1, queues, …) ride a `wrangler` block on any env (deep-merged).
36
36
  }),
@@ -4,11 +4,11 @@ A small React + Vite SPA that renders the domain's Views. It is loaded inside an
4
4
  iframe mounted by the Astrale shell, runs the shell handshake (via the real
5
5
  `@astrale-os/shell`) to learn its target node id and a kernel session, fetches
6
6
  that node from the kernel, and renders it. The bundled status view loads a
7
- `Checkable` node (a Monitor or a StatusPage both carry a `status` prop and a
8
- `::check` method) and renders its status, with a "Check now" button that calls
9
- the node's `check` instance method and reloads the node. Built into `../.dist/`
10
- and served by the generated worker (`.astrale/`) under `/ui/*` via its `ASSETS`
11
- binding.
7
+ StatusPage, follows its watched Monitor links, and renders the roll-up beside
8
+ the monitored targets, probe timestamps, latency, and HTTP status. "Check now"
9
+ calls the page's `check` instance method and reloads the page plus monitor rows.
10
+ Built into `../.dist/` and served by the generated worker (`.astrale/`) under
11
+ `/ui/*` via its `ASSETS` binding.
12
12
 
13
13
  ## Built on the real shell SDK
14
14
 
@@ -23,8 +23,8 @@ code imports `@/shell`, `@/ui`, `@/status`.
23
23
 
24
24
  Feature-first: each feature owns its types/api/mappers/hooks/components. The
25
25
  `shell/` and `ui/` folders are the generic, domain-neutral seams every feature
26
- builds on. The template ships ONE feature, `status/`, over a `Checkable` node
27
- one card loads the node and exposes its status + a "Check now" action.
26
+ builds on. The template ships ONE feature, `status/`, over a StatusPage and its
27
+ watched Monitor nodes.
28
28
 
29
29
  ```
30
30
  src/
@@ -47,8 +47,8 @@ src/
47
47
  value.tsx # KV / Mono / ExternalLink
48
48
  format.ts # relativeTime
49
49
  index.ts # barrel → @/ui
50
- status/ # THE status feature — polymorphic over Checkable nodes
51
- status.types.ts # CheckableRecord
50
+ status/ # THE status feature — StatusPage + watched Monitors
51
+ status.types.ts # StatusPanelRecord / CheckableRecord
52
52
  status.api.ts # check(session, id) → @<id>::check
53
53
  status.mappers.ts # checkableFromNode (node → record)
54
54
  hooks/ # useCheckable.query / useCheck.mutation
@@ -50,11 +50,36 @@ function handshake(targetNodeId?: string) {
50
50
  const page = (id: string, status: string, name = 'Public status') =>
51
51
  checkableNode({ id, name, status, className: 'StatusPage' })
52
52
 
53
+ const MONITOR_PATH = '/monitoring/monitors/astrale'
54
+
55
+ const watchLinks = (critical = true) => [
56
+ {
57
+ class: { raw: '/:monitors.astrale.ai:class.watches' },
58
+ target: { raw: MONITOR_PATH },
59
+ props: { 'monitors.astrale.ai:class.watches.property.critical': critical },
60
+ },
61
+ ]
62
+
63
+ const monitor = (status: string, statusCode = 200) =>
64
+ checkableNode({
65
+ id: 'monitor-1',
66
+ path: MONITOR_PATH,
67
+ name: 'Astrale homepage',
68
+ status,
69
+ statusCode,
70
+ latencyMs: 84,
71
+ lastCheckedAt: '2026-06-14T11:55:00Z',
72
+ url: 'https://astrale.ai',
73
+ className: 'Monitor',
74
+ })
75
+
53
76
  describe('status view (/ui/status-page)', () => {
54
- it('loads the StatusPage node and renders its name + rolled-up status', async () => {
77
+ it('loads the StatusPage node and renders its watched monitors', async () => {
55
78
  const parent = handshake('page-1')
56
79
  const kernel = installFakeKernel((body) => {
57
80
  if (body.method === '@page-1::get') return ok(page('page-1', 'degraded'))
81
+ if (body.method === '@page-1::getLinks') return ok(watchLinks())
82
+ if (body.method === `${MONITOR_PATH}::get`) return ok(monitor('up'))
58
83
  return { error: { code: 3002, message: `unexpected: ${body.method}` } }
59
84
  })
60
85
 
@@ -68,12 +93,16 @@ describe('status view (/ui/status-page)', () => {
68
93
  expect(screen.getByText('Public status')).toBeTruthy()
69
94
  const badge = screen.getByText('DEGRADED')
70
95
  expect(badge.className).toContain('status-degraded')
71
- // A StatusPage carries no `url`, so there's no url row.
72
- expect(screen.queryByText('url')).toBeNull()
96
+ expect(screen.getByText('Astrale homepage')).toBeTruthy()
97
+ expect(screen.getByText('astrale.ai')).toBeTruthy()
98
+ expect(screen.getByText('Critical')).toBeTruthy()
99
+ expect(screen.getByText('84ms')).toBeTruthy()
73
100
 
74
- // Exactly one kernel call, for the node load.
75
- expect(kernel.calls).toHaveLength(1)
76
- expect(kernel.calls[0]!.body.method).toBe('@page-1::get')
101
+ expect(kernel.calls.map((c) => c.body.method)).toEqual([
102
+ '@page-1::get',
103
+ '@page-1::getLinks',
104
+ `${MONITOR_PATH}::get`,
105
+ ])
77
106
  } finally {
78
107
  kernel.restore()
79
108
  parent.restore()
@@ -89,6 +118,8 @@ describe('status view (/ui/status-page)', () => {
89
118
  checked = true
90
119
  return ok(null)
91
120
  }
121
+ if (body.method === '@page-1::getLinks') return ok(watchLinks())
122
+ if (body.method === `${MONITOR_PATH}::get`) return ok(monitor(checked ? 'up' : 'down'))
92
123
  return ok(page('page-1', checked ? 'up' : 'down'))
93
124
  })
94
125
 
@@ -96,8 +127,8 @@ describe('status view (/ui/status-page)', () => {
96
127
  render(<App />)
97
128
  await flush()
98
129
 
99
- expect(screen.getByText('DOWN')).toBeTruthy()
100
- expect(kernel.calls).toHaveLength(1)
130
+ expect(screen.getAllByText('DOWN').length).toBeGreaterThan(0)
131
+ expect(kernel.calls).toHaveLength(3)
101
132
  expect(kernel.calls[0]!.body.method).toBe('@page-1::get')
102
133
 
103
134
  await act(async () => {
@@ -108,10 +139,14 @@ describe('status view (/ui/status-page)', () => {
108
139
  // A ::check call was issued, then the node was re-fetched.
109
140
  expect(kernel.calls.map((c) => c.body.method)).toEqual([
110
141
  '@page-1::get',
142
+ '@page-1::getLinks',
143
+ `${MONITOR_PATH}::get`,
111
144
  '@page-1::check',
112
145
  '@page-1::get',
146
+ '@page-1::getLinks',
147
+ `${MONITOR_PATH}::get`,
113
148
  ])
114
- expect(screen.getByText('UP')).toBeTruthy()
149
+ expect(screen.getAllByText('UP').length).toBeGreaterThan(0)
115
150
  } finally {
116
151
  kernel.restore()
117
152
  parent.restore()
@@ -120,11 +155,14 @@ describe('status view (/ui/status-page)', () => {
120
155
 
121
156
  it('surfaces a check failure without losing the loaded record', async () => {
122
157
  const parent = handshake('page-1')
123
- const kernel = installFakeKernel((body) =>
124
- body.method === '@page-1::check'
125
- ? { error: { code: 2004, message: 'Permission denied' } }
126
- : ok(page('page-1', 'up')),
127
- )
158
+ const kernel = installFakeKernel((body) => {
159
+ if (body.method === '@page-1::check') {
160
+ return { error: { code: 2004, message: 'Permission denied' } }
161
+ }
162
+ if (body.method === '@page-1::getLinks') return ok(watchLinks())
163
+ if (body.method === `${MONITOR_PATH}::get`) return ok(monitor('up'))
164
+ return ok(page('page-1', 'up'))
165
+ })
128
166
 
129
167
  try {
130
168
  render(<App />)
@@ -165,17 +203,18 @@ describe('status view (/ui/status-page)', () => {
165
203
 
166
204
  it('re-fetches on a setTarget hot-swap', async () => {
167
205
  const parent = handshake('page-1')
168
- const kernel = installFakeKernel((body) =>
169
- body.method === '@page-2::get'
206
+ const kernel = installFakeKernel((body) => {
207
+ if (body.method.endsWith('::getLinks')) return ok([])
208
+ return body.method === '@page-2::get'
170
209
  ? ok(page('page-2', 'down', 'Internal status'))
171
- : ok(page('page-1', 'up', 'Public status')),
172
- )
210
+ : ok(page('page-1', 'up', 'Public status'))
211
+ })
173
212
 
174
213
  try {
175
214
  render(<App />)
176
215
  await flush()
177
216
  expect(screen.getByText('Public status')).toBeTruthy()
178
- expect(screen.getByText('UP')).toBeTruthy()
217
+ expect(screen.getAllByText('UP').length).toBeGreaterThan(0)
179
218
 
180
219
  // Parent pushes a new target node.
181
220
  parent.setTarget('page-2')
@@ -250,6 +250,10 @@ export function checkableNode(opts: {
250
250
  path?: string
251
251
  name?: string
252
252
  status?: string
253
+ url?: string
254
+ statusCode?: number
255
+ latencyMs?: number
256
+ lastCheckedAt?: string
253
257
  className?: string
254
258
  domain?: string
255
259
  }): { id: string; path: string; class: { raw: string }; props: Record<string, unknown> } {
@@ -262,6 +266,18 @@ export function checkableNode(opts: {
262
266
  if (opts.status !== undefined) {
263
267
  props[`${domain}:class.${className}.property.status`] = opts.status
264
268
  }
269
+ if (opts.url !== undefined) {
270
+ props[`${domain}:class.${className}.property.url`] = opts.url
271
+ }
272
+ if (opts.statusCode !== undefined) {
273
+ props[`${domain}:class.${className}.property.statusCode`] = opts.statusCode
274
+ }
275
+ if (opts.latencyMs !== undefined) {
276
+ props[`${domain}:class.${className}.property.latencyMs`] = opts.latencyMs
277
+ }
278
+ if (opts.lastCheckedAt !== undefined) {
279
+ props[`${domain}:class.${className}.property.lastCheckedAt`] = opts.lastCheckedAt
280
+ }
265
281
  return {
266
282
  id: opts.id,
267
283
  path: opts.path ?? `/${opts.id}`,
@@ -1,7 +1,7 @@
1
1
  import { describe, expect, it } from 'vitest'
2
2
 
3
3
  import { qualifiedString, readProp, readPropBySuffix } from '@/shell'
4
- import { checkableFromNode } from '@/status'
4
+ import { checkableFromNode, watchedMonitorRefs } from '@/status'
5
5
  import { relativeTime } from '@/ui'
6
6
 
7
7
  import { checkableNode } from './harness'
@@ -36,16 +36,36 @@ describe('checkableFromNode mapper', () => {
36
36
  const record = checkableFromNode(
37
37
  checkableNode({ id: 'page-1', name: 'Public', status: 'degraded', className: 'StatusPage' }),
38
38
  )
39
- expect(record).toEqual({ name: 'Public', status: 'degraded' })
39
+ expect(record).toMatchObject({
40
+ id: 'page-1',
41
+ name: 'Public',
42
+ status: 'degraded',
43
+ className: 'StatusPage',
44
+ })
40
45
  })
41
46
 
42
47
  it('falls back to the path segment for the name and unknown for a missing status', () => {
43
- const record = checkableFromNode(checkableNode({ id: 'page-9', path: '/status-pages/edge' }))
48
+ const record = checkableFromNode(
49
+ checkableNode({ id: 'page-9', path: '/monitoring/pages/edge' }),
50
+ )
44
51
  expect(record.name).toBe('edge')
45
52
  expect(record.status).toBe('unknown')
46
53
  })
47
54
  })
48
55
 
56
+ describe('watchedMonitorRefs mapper', () => {
57
+ it('projects target refs and critical edge weights', () => {
58
+ const refs = watchedMonitorRefs([
59
+ {
60
+ class: { raw: '/:monitors.astrale.ai:class.watches' },
61
+ target: { raw: '/monitoring/monitors/astrale' },
62
+ props: { 'monitors.astrale.ai:class.watches.property.critical': true },
63
+ },
64
+ ])
65
+ expect(refs).toEqual([{ target: '/monitoring/monitors/astrale', critical: true }])
66
+ })
67
+ })
68
+
49
69
  describe('relativeTime', () => {
50
70
  const now = Date.parse('2026-06-14T12:00:00Z')
51
71
  it('renders coarse buckets and a dash for missing input', () => {
@@ -51,6 +51,9 @@ export function useAsync<T>(fn: () => Promise<T>, deps: unknown[]): AsyncResourc
51
51
  // eslint-disable-next-line react-hooks/exhaustive-deps -- deps are the caller's cache key
52
52
  }, [...deps, epoch])
53
53
 
54
- const reload = useCallback(() => setEpoch((e) => e + 1), [])
54
+ const reload = useCallback(() => {
55
+ setReloading(true)
56
+ setEpoch((e) => e + 1)
57
+ }, [])
55
58
  return { state, reload, reloading }
56
59
  }