@astrale-os/adapter-cloudflare 0.1.10 → 0.2.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 (62) hide show
  1. package/package.json +1 -1
  2. package/template/.agents/skills/astrale-cli/SKILL.md +1 -1
  3. package/template/client/README.md +25 -23
  4. package/template/client/__tests__/app.test.tsx +44 -88
  5. package/template/client/__tests__/harness.ts +11 -16
  6. package/template/client/__tests__/kernel.test.ts +9 -35
  7. package/template/client/__tests__/seam.test.tsx +22 -18
  8. package/template/client/src/app.tsx +11 -17
  9. package/template/client/src/shell/use-capability.ts +1 -3
  10. package/template/client/src/shell/use-node.ts +2 -2
  11. package/template/client/src/shell/view-router.tsx +3 -4
  12. package/template/client/src/status/components/StatusCard.tsx +50 -0
  13. package/template/client/src/status/components/index.ts +1 -0
  14. package/template/client/src/status/hooks/index.ts +3 -0
  15. package/template/client/src/{monitor → status}/hooks/useCheck.mutation.ts +2 -2
  16. package/template/client/src/{monitor/hooks/useMonitor.query.ts → status/hooks/useCheckable.query.ts} +8 -8
  17. package/template/client/src/status/index.ts +7 -0
  18. package/template/client/src/status/status.api.ts +12 -0
  19. package/template/client/src/status/status.mappers.ts +19 -0
  20. package/template/client/src/status/status.types.ts +11 -0
  21. package/template/client/src/styles.css +5 -0
  22. package/template/client/src/ui/StatusBadge.tsx +31 -0
  23. package/template/client/src/ui/index.ts +6 -2
  24. package/template/client/src/views/status.tsx +28 -0
  25. package/template/client/vite.config.ts +2 -3
  26. package/template/client/vitest.config.ts +1 -2
  27. package/template/core/monitor/health.ts +19 -4
  28. package/template/core/monitor/keys.ts +14 -2
  29. package/template/core/monitor/node.ts +27 -21
  30. package/template/deps.ts +2 -1
  31. package/template/integrations/prober/http.ts +4 -15
  32. package/template/integrations/prober/mock.ts +1 -5
  33. package/template/integrations/prober/port.ts +0 -2
  34. package/template/integrations/prober/registry.ts +6 -7
  35. package/template/package.json +1 -1
  36. package/template/runtime/index.ts +51 -39
  37. package/template/runtime/monitor/check.ts +9 -9
  38. package/template/runtime/monitor/index.ts +4 -7
  39. package/template/runtime/monitor/seed.ts +67 -46
  40. package/template/runtime/monitor/watch.ts +6 -12
  41. package/template/runtime/{monitor/shared.ts → shared.ts} +7 -3
  42. package/template/runtime/status-page/add.ts +21 -0
  43. package/template/runtime/status-page/check.ts +50 -0
  44. package/template/runtime/status-page/create.ts +24 -0
  45. package/template/runtime/status-page/index.ts +8 -0
  46. package/template/schema/index.ts +5 -5
  47. package/template/schema/monitor.ts +62 -48
  48. package/template/views/index.ts +4 -5
  49. package/template/views/status-page.ts +16 -0
  50. package/template/client/src/monitor/components/MonitorCard.tsx +0 -50
  51. package/template/client/src/monitor/components/index.ts +0 -1
  52. package/template/client/src/monitor/hooks/index.ts +0 -3
  53. package/template/client/src/monitor/index.ts +0 -6
  54. package/template/client/src/monitor/monitor.api.ts +0 -11
  55. package/template/client/src/monitor/monitor.mappers.ts +0 -38
  56. package/template/client/src/monitor/monitor.types.ts +0 -23
  57. package/template/client/src/monitor/ui/MonitorDetails.UI.tsx +0 -38
  58. package/template/client/src/monitor/ui/StatusBadge.UI.tsx +0 -14
  59. package/template/client/src/monitor/ui/index.ts +0 -8
  60. package/template/client/src/views/monitor.tsx +0 -30
  61. package/template/runtime/monitor/dependsOn.ts +0 -16
  62. package/template/views/monitor.ts +0 -22
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astrale-os/adapter-cloudflare",
3
- "version": "0.1.10",
3
+ "version": "0.2.0",
4
4
  "description": "Deploy an Astrale domain as a standalone Cloudflare Worker",
5
5
  "keywords": [
6
6
  "adapter",
@@ -91,7 +91,7 @@ Examples:
91
91
 
92
92
  ```bash
93
93
  astrale call /:blog.acme.com:class.Author:list
94
- astrale call /:blog.acme.com:interface.MonitorOps:watch url=https://astrale.ai
94
+ astrale call /:blog.acme.com:class.Monitor:watch url=https://astrale.ai
95
95
  astrale call /blog.acme.com/alice::deactivate
96
96
  astrale call @f00d...::deactivate
97
97
  ```
@@ -3,9 +3,10 @@
3
3
  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
- that node from the kernel, and renders it. The bundled `ui-monitor` view loads a
7
- Monitor and renders its status/url/latency, with a "Check now" button that calls
8
- the Monitor's `check` instance method and reloads the node. Built into `../.dist/`
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/`
9
10
  and served by the generated worker (`.astrale/`) under `/ui/*` via its `ASSETS`
10
11
  binding.
11
12
 
@@ -16,18 +17,19 @@ client: `src/shell/use-shell.ts` boots `createShell({ mode: 'sandboxed' })`, and
16
17
  feature hooks call kernel methods through `shell.kernel` (token refresh, codec
17
18
  negotiation, redirect following, and delegation are all handled by the SDK — no
18
19
  inline wire code). `@` is aliased to `src/` (mirrors `tsconfig` paths), so feature
19
- code imports `@/shell`, `@/ui`, `@/monitor`.
20
+ code imports `@/shell`, `@/ui`, `@/status`.
20
21
 
21
22
  ## Layout
22
23
 
23
24
  Feature-first: each feature owns its types/api/mappers/hooks/components. The
24
25
  `shell/` and `ui/` folders are the generic, domain-neutral seams every feature
25
- builds on.
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
28
 
27
29
  ```
28
30
  src/
29
31
  main.tsx # entry → createRoot(App)
30
- app.tsx # path router: ROUTES { '/ui/monitor': MonitorView }
32
+ app.tsx # path router: ROUTES { '/ui/status-page': StatusView }
31
33
  styles.css # self-contained styles (no Tailwind), design tokens
32
34
  shell/ # GENERIC kernel/shell adapter on @astrale-os/shell
33
35
  client.ts # prop readers (PROP, readProp, readPropBySuffix) + errors
@@ -41,23 +43,23 @@ src/
41
43
  index.ts # barrel → @/shell
42
44
  ui/ # PURE presentation — no kernel, no hooks
43
45
  surface.tsx # Panel / ErrorBanner / EmptyState / Spinner
44
- badge.tsx # StatusBadge (up | down | unknown)
46
+ StatusBadge.tsx # StatusBadge (up | degraded | down | unknown)
45
47
  value.tsx # KV / Mono / ExternalLink
46
48
  format.ts # relativeTime
47
49
  index.ts # barrel → @/ui
48
- monitor/ # THE Monitor feature (template example)
49
- monitor.types.ts # MonitorRecord
50
- monitor.api.ts # check(session, id) → @<id>::check
51
- monitor.mappers.ts # monitorFromNode (node → record)
52
- hooks/ # useMonitor.query / useCheck.mutation
53
- components/ # MonitorCard (container: hooks + @/ui)
54
- index.ts # barrel → @/monitor
50
+ status/ # THE status feature polymorphic over Checkable nodes
51
+ status.types.ts # CheckableRecord
52
+ status.api.ts # check(session, id) → @<id>::check
53
+ status.mappers.ts # checkableFromNode (node → record)
54
+ hooks/ # useCheckable.query / useCheck.mutation
55
+ components/ # StatusCard (container: hooks + @/ui)
56
+ index.ts # barrel → @/status
55
57
  views/
56
- monitor.tsx # MonitorView = <ViewFrame>…<MonitorCard/></ViewFrame>
58
+ status.tsx # StatusView = <ViewFrame>…<StatusCard/></ViewFrame>
57
59
  __tests__/
58
60
  harness.ts # fake shell parent (real protocol) + fake kernel (fetch stub)
59
61
  app.test.tsx # handshake → render → check now → setTarget → fallback
60
- seam.test.tsx # pure @/ui + @/monitor hooks in isolation (no handshake)
62
+ seam.test.tsx # pure @/ui + @/status hooks in isolation (no handshake)
61
63
  kernel.test.ts # pure helpers: prop readers, mapper, relativeTime
62
64
  ```
63
65
 
@@ -81,14 +83,14 @@ the generated worker forwards `/ui/*` to vite.
81
83
  and the `targetNodeId`). `useShell()` boots `@astrale-os/shell`, surfaces the
82
84
  kernel session, and tracks `setTarget` hot-swaps.
83
85
  3. A feature loads its node through the session: `useNode(session, nodeId)` calls
84
- `@<id>::get` over `shell.kernel`, and a mapper (`monitorFromNode`) projects it
85
- to a typed record. Domain props are read by key SUFFIX (`.property.url`, …)
86
- because the domain origin is unknown at build time; the name uses the kernel
86
+ `@<id>::get` over `shell.kernel`, and a mapper (`checkableFromNode`) projects
87
+ it to a typed record. Domain props are read by key SUFFIX (`.property.status`,
88
+ …) because the domain origin is unknown at build time; the name uses the kernel
87
89
  `Named.name` key.
88
90
  4. Writes go through `useCapability`: "Check now" runs `@<id>::check`, then calls
89
- `useMonitor`'s `reload()` so the fresh status/latency re-render. The shell's
90
- kernel client owns the credential and refreshes it before expiry — features
91
- never touch the token.
91
+ `useCheckable`'s `reload()` so the fresh status re-renders. The shell's kernel
92
+ client owns the credential and refreshes it before expiry — features never
93
+ touch the token.
92
94
 
93
95
  ## Adding a view
94
96
 
@@ -97,6 +99,6 @@ the generated worker forwards `/ui/*` to vite.
97
99
  3. Register a matching `defineView({ mount: '/ui/<path>' })` in the domain's
98
100
  `views/` so the shell mounts an iframe there.
99
101
 
100
- For a NEW feature, mirror `src/monitor/`: a `*.types.ts`, `*.api.ts`,
102
+ For a NEW feature, mirror `src/status/`: a `*.types.ts`, `*.api.ts`,
101
103
  `*.mappers.ts`, a `hooks/` folder of `.query`/`.mutation` hooks, and a
102
104
  `components/` folder of containers that compose `@/ui`.
@@ -2,21 +2,22 @@ import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
2
2
  import { afterEach, beforeEach, describe, expect, it } from 'vitest'
3
3
 
4
4
  import { App } from '@/app'
5
- import { installFakeKernel, installFakeParent, monitorNode, ok } from './harness'
5
+
6
+ import { checkableNode, installFakeKernel, installFakeParent, ok } from './harness'
6
7
 
7
8
  const KERNEL_URL = 'https://k.example.test/api'
8
9
 
9
10
  /**
10
11
  * The router picks a view from `window.location.pathname`, so every test mounts
11
- * at a known path. `replaceState` rewrites the happy-dom location without a
12
- * reload. Default to the Monitor detail path; the fallback test overrides.
12
+ * at the StatusPage view's path. `replaceState` rewrites the happy-dom location
13
+ * without a reload; the fallback test overrides it.
13
14
  */
14
15
  function mountAt(path: string) {
15
16
  window.history.replaceState({}, '', path)
16
17
  }
17
18
 
18
19
  beforeEach(() => {
19
- mountAt('/ui/monitor')
20
+ mountAt('/ui/status-page')
20
21
  })
21
22
 
22
23
  afterEach(() => {
@@ -38,30 +39,22 @@ function handshake(targetNodeId?: string) {
38
39
  return installFakeParent({
39
40
  windowId: 'win-1',
40
41
  kernelUrl: KERNEL_URL,
41
- functionId: 'ui-monitor',
42
+ functionId: 'ui-status-page',
42
43
  delegationToken: 'tok-A',
43
44
  tokenExpiresAt: Date.now() + 3_600_000,
44
45
  targetNodeId,
45
46
  })
46
47
  }
47
48
 
48
- describe('monitor view (/ui/monitor)', () => {
49
- it('loads the target node and renders name, url, status and latency', async () => {
50
- const parent = handshake('mon-1')
49
+ /** A StatusPage node fixture — its rolled-up `status`, no `url`. */
50
+ const page = (id: string, status: string, name = 'Public status') =>
51
+ checkableNode({ id, name, status, className: 'StatusPage' })
52
+
53
+ describe('status view (/ui/status-page)', () => {
54
+ it('loads the StatusPage node and renders its name + rolled-up status', async () => {
55
+ const parent = handshake('page-1')
51
56
  const kernel = installFakeKernel((body) => {
52
- if (body.method === '@mon-1::get') {
53
- return ok(
54
- monitorNode({
55
- id: 'mon-1',
56
- name: 'API health',
57
- url: 'https://api.example.test/health',
58
- status: 'up',
59
- statusCode: '200',
60
- latencyMs: '42',
61
- lastCheckedAt: '2026-06-14T10:00:00Z',
62
- }),
63
- )
64
- }
57
+ if (body.method === '@page-1::get') return ok(page('page-1', 'degraded'))
65
58
  return { error: { code: 3002, message: `unexpected: ${body.method}` } }
66
59
  })
67
60
 
@@ -72,36 +65,15 @@ describe('monitor view (/ui/monitor)', () => {
72
65
  // Parent observed the child's handshakeAck.
73
66
  await expect(parent.ack).resolves.toBeUndefined()
74
67
 
75
- expect(screen.getByText('API health')).toBeTruthy()
76
- // ExternalLink strips the scheme.
77
- expect(screen.getByText('api.example.test/health')).toBeTruthy()
78
- const badge = screen.getByText('UP')
79
- expect(badge.className).toContain('status-up')
80
- expect(screen.getByText('42ms')).toBeTruthy()
81
- expect(screen.getByText('200')).toBeTruthy()
68
+ expect(screen.getByText('Public status')).toBeTruthy()
69
+ const badge = screen.getByText('DEGRADED')
70
+ expect(badge.className).toContain('status-degraded')
71
+ // A StatusPage carries no `url`, so there's no url row.
72
+ expect(screen.queryByText('url')).toBeNull()
82
73
 
83
74
  // Exactly one kernel call, for the node load.
84
75
  expect(kernel.calls).toHaveLength(1)
85
- expect(kernel.calls[0]!.body.method).toBe('@mon-1::get')
86
- } finally {
87
- kernel.restore()
88
- parent.restore()
89
- }
90
- })
91
-
92
- it('renders a DOWN badge when the monitor status is down', async () => {
93
- const parent = handshake('mon-d')
94
- const kernel = installFakeKernel(() =>
95
- ok(monitorNode({ id: 'mon-d', name: 'Down site', url: 'https://x.test', status: 'down' })),
96
- )
97
-
98
- try {
99
- render(<App />)
100
- await flush()
101
-
102
- const badge = screen.getByText('DOWN')
103
- expect(badge).toBeTruthy()
104
- expect(badge.className).toContain('status-down')
76
+ expect(kernel.calls[0]!.body.method).toBe('@page-1::get')
105
77
  } finally {
106
78
  kernel.restore()
107
79
  parent.restore()
@@ -109,36 +81,25 @@ describe('monitor view (/ui/monitor)', () => {
109
81
  })
110
82
 
111
83
  it('clicks "Check now", issues @<id>::check, and reflects the refreshed status', async () => {
112
- const parent = handshake('mon-1')
113
- // First ::get is down; ::check mutates server-side; the post-check ::get is up.
84
+ const parent = handshake('page-1')
85
+ // First ::get is down; ::check rolls up server-side; the post-check ::get is up.
114
86
  let checked = false
115
87
  const kernel = installFakeKernel((body) => {
116
- if (body.method === '@mon-1::check') {
88
+ if (body.method === '@page-1::check') {
117
89
  checked = true
118
90
  return ok(null)
119
91
  }
120
- // @mon-1::get reflect the pre/post-check status.
121
- return ok(
122
- monitorNode({
123
- id: 'mon-1',
124
- name: 'API health',
125
- url: 'https://api.example.test/health',
126
- status: checked ? 'up' : 'down',
127
- statusCode: checked ? '200' : '503',
128
- }),
129
- )
92
+ return ok(page('page-1', checked ? 'up' : 'down'))
130
93
  })
131
94
 
132
95
  try {
133
96
  render(<App />)
134
97
  await flush()
135
98
 
136
- // Initial load: DOWN.
137
99
  expect(screen.getByText('DOWN')).toBeTruthy()
138
100
  expect(kernel.calls).toHaveLength(1)
139
- expect(kernel.calls[0]!.body.method).toBe('@mon-1::get')
101
+ expect(kernel.calls[0]!.body.method).toBe('@page-1::get')
140
102
 
141
- // Click "Check now".
142
103
  await act(async () => {
143
104
  fireEvent.click(screen.getByText('Check now'))
144
105
  })
@@ -146,14 +107,11 @@ describe('monitor view (/ui/monitor)', () => {
146
107
 
147
108
  // A ::check call was issued, then the node was re-fetched.
148
109
  expect(kernel.calls.map((c) => c.body.method)).toEqual([
149
- '@mon-1::get',
150
- '@mon-1::check',
151
- '@mon-1::get',
110
+ '@page-1::get',
111
+ '@page-1::check',
112
+ '@page-1::get',
152
113
  ])
153
-
154
- // The refreshed status renders.
155
114
  expect(screen.getByText('UP')).toBeTruthy()
156
- expect(screen.getByText('200')).toBeTruthy()
157
115
  } finally {
158
116
  kernel.restore()
159
117
  parent.restore()
@@ -161,13 +119,12 @@ describe('monitor view (/ui/monitor)', () => {
161
119
  })
162
120
 
163
121
  it('surfaces a check failure without losing the loaded record', async () => {
164
- const parent = handshake('mon-1')
165
- const kernel = installFakeKernel((body) => {
166
- if (body.method === '@mon-1::check') {
167
- return { error: { code: 2004, message: 'Permission denied' } }
168
- }
169
- return ok(monitorNode({ id: 'mon-1', name: 'API health', url: 'https://x.test', status: 'up' }))
170
- })
122
+ 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
+ )
171
128
 
172
129
  try {
173
130
  render(<App />)
@@ -183,7 +140,7 @@ describe('monitor view (/ui/monitor)', () => {
183
140
  expect(screen.getByText(/Check failed:/)).toBeTruthy()
184
141
  expect(screen.getByText(/Permission denied/)).toBeTruthy()
185
142
  // The record is still shown.
186
- expect(screen.getByText('API health')).toBeTruthy()
143
+ expect(screen.getByText('Public status')).toBeTruthy()
187
144
  } finally {
188
145
  kernel.restore()
189
146
  parent.restore()
@@ -198,8 +155,7 @@ describe('monitor view (/ui/monitor)', () => {
198
155
  render(<App />)
199
156
  await flush()
200
157
 
201
- expect(screen.getByText(/Failed to load the Monitor/)).toBeTruthy()
202
- // The proper client maps NOT_FOUND (3002) to a typed error → clean message.
158
+ expect(screen.getByText(/Failed to load/)).toBeTruthy()
203
159
  expect(screen.getByText(/Path not found/)).toBeTruthy()
204
160
  } finally {
205
161
  kernel.restore()
@@ -208,24 +164,24 @@ describe('monitor view (/ui/monitor)', () => {
208
164
  })
209
165
 
210
166
  it('re-fetches on a setTarget hot-swap', async () => {
211
- const parent = handshake('mon-1')
167
+ const parent = handshake('page-1')
212
168
  const kernel = installFakeKernel((body) =>
213
- body.method === '@mon-2::get'
214
- ? ok(monitorNode({ id: 'mon-2', name: 'Second monitor', url: 'https://b.test', status: 'down' }))
215
- : ok(monitorNode({ id: 'mon-1', name: 'First monitor', url: 'https://a.test', status: 'up' })),
169
+ body.method === '@page-2::get'
170
+ ? ok(page('page-2', 'down', 'Internal status'))
171
+ : ok(page('page-1', 'up', 'Public status')),
216
172
  )
217
173
 
218
174
  try {
219
175
  render(<App />)
220
176
  await flush()
221
- expect(screen.getByText('First monitor')).toBeTruthy()
177
+ expect(screen.getByText('Public status')).toBeTruthy()
222
178
  expect(screen.getByText('UP')).toBeTruthy()
223
179
 
224
180
  // Parent pushes a new target node.
225
- parent.setTarget('mon-2')
181
+ parent.setTarget('page-2')
226
182
  await flush()
227
183
 
228
- expect(screen.getByText('Second monitor')).toBeTruthy()
184
+ expect(screen.getByText('Internal status')).toBeTruthy()
229
185
  expect(screen.getByText('DOWN')).toBeTruthy()
230
186
  } finally {
231
187
  kernel.restore()
@@ -240,7 +196,7 @@ describe('monitor view (/ui/monitor)', () => {
240
196
  render(<App />)
241
197
  await flush()
242
198
 
243
- expect(screen.getByText(/No target Monitor/)).toBeTruthy()
199
+ expect(screen.getByText(/No target node/)).toBeTruthy()
244
200
  expect(kernel.calls).toHaveLength(0)
245
201
  } finally {
246
202
  kernel.restore()
@@ -240,37 +240,32 @@ export function fakeKernelSession(kernelUrl = 'https://k.example.test/api'): Ker
240
240
  }
241
241
 
242
242
  /**
243
- * Build a minimal kernel Monitor node whose props carry url/status/statusCode/
244
- * latencyMs/lastCheckedAt, with the same domain-qualified key format the kernel
245
- * uses (`<domain>:class.Monitor.property.<name>`). The name uses the kernel
243
+ * Build a minimal kernel `Checkable` node a StatusPage (or any class via
244
+ * `className`, default `StatusPage`). Carries a domain-qualified `status` prop
245
+ * (`<domain>:class.<className>.property.status`); the name uses the kernel
246
246
  * `Named.name` key.
247
247
  */
248
- export function monitorNode(opts: {
248
+ export function checkableNode(opts: {
249
249
  id: string
250
250
  path?: string
251
251
  name?: string
252
- url?: string
253
252
  status?: string
254
- statusCode?: string
255
- latencyMs?: string
256
- lastCheckedAt?: string
253
+ className?: string
257
254
  domain?: string
258
255
  }): { id: string; path: string; class: { raw: string }; props: Record<string, unknown> } {
259
256
  const domain = opts.domain ?? 'monitors.astrale.ai'
260
- const base = `${domain}:class.Monitor.property`
257
+ const className = opts.className ?? 'StatusPage'
261
258
  const props: Record<string, unknown> = {}
262
259
  if (opts.name !== undefined) {
263
260
  props['kernel.astrale.ai:interface.Named.property.name'] = opts.name
264
261
  }
265
- if (opts.url !== undefined) props[`${base}.url`] = opts.url
266
- if (opts.status !== undefined) props[`${base}.status`] = opts.status
267
- if (opts.statusCode !== undefined) props[`${base}.statusCode`] = opts.statusCode
268
- if (opts.latencyMs !== undefined) props[`${base}.latencyMs`] = opts.latencyMs
269
- if (opts.lastCheckedAt !== undefined) props[`${base}.lastCheckedAt`] = opts.lastCheckedAt
262
+ if (opts.status !== undefined) {
263
+ props[`${domain}:class.${className}.property.status`] = opts.status
264
+ }
270
265
  return {
271
266
  id: opts.id,
272
- path: opts.path ?? `/monitors/${opts.id}`,
273
- class: { raw: `/:${domain}:class.Monitor` },
267
+ path: opts.path ?? `/${opts.id}`,
268
+ class: { raw: `/:${domain}:class.${className}` },
274
269
  props,
275
270
  }
276
271
  }
@@ -1,9 +1,10 @@
1
1
  import { describe, expect, it } from 'vitest'
2
2
 
3
3
  import { qualifiedString, readProp, readPropBySuffix } from '@/shell'
4
- import { monitorFromNode, normalizeStatus } from '@/monitor'
4
+ import { checkableFromNode } from '@/status'
5
5
  import { relativeTime } from '@/ui'
6
- import { monitorNode } from './harness'
6
+
7
+ import { checkableNode } from './harness'
7
8
 
8
9
  describe('prop readers', () => {
9
10
  it('readProp returns the string value at the exact key', () => {
@@ -30,45 +31,18 @@ describe('prop readers', () => {
30
31
  })
31
32
  })
32
33
 
33
- describe('monitorFromNode mapper', () => {
34
- it('projects name, url, normalized status and coerced numbers', () => {
35
- const record = monitorFromNode(
36
- monitorNode({
37
- id: 'mon-1',
38
- name: 'API health',
39
- url: 'https://api.test/health',
40
- status: 'up',
41
- statusCode: '200',
42
- latencyMs: '42',
43
- lastCheckedAt: '2026-06-14T10:00:00Z',
44
- }),
34
+ describe('checkableFromNode mapper', () => {
35
+ it('projects the name and rolled-up status of a StatusPage node', () => {
36
+ const record = checkableFromNode(
37
+ checkableNode({ id: 'page-1', name: 'Public', status: 'degraded', className: 'StatusPage' }),
45
38
  )
46
- expect(record).toMatchObject({
47
- id: 'mon-1',
48
- name: 'API health',
49
- url: 'https://api.test/health',
50
- status: 'up',
51
- statusCode: 200,
52
- latencyMs: 42,
53
- lastCheckedAt: '2026-06-14T10:00:00Z',
54
- })
39
+ expect(record).toEqual({ name: 'Public', status: 'degraded' })
55
40
  })
56
41
 
57
42
  it('falls back to the path segment for the name and unknown for a missing status', () => {
58
- const record = monitorFromNode(monitorNode({ id: 'mon-9', path: '/monitors/edge' }))
43
+ const record = checkableFromNode(checkableNode({ id: 'page-9', path: '/status-pages/edge' }))
59
44
  expect(record.name).toBe('edge')
60
45
  expect(record.status).toBe('unknown')
61
- expect(record.url).toBe('')
62
- expect(record.statusCode).toBeUndefined()
63
- })
64
- })
65
-
66
- describe('normalizeStatus', () => {
67
- it('passes through up/down and maps everything else to unknown', () => {
68
- expect(normalizeStatus('up')).toBe('up')
69
- expect(normalizeStatus('down')).toBe('down')
70
- expect(normalizeStatus('weird')).toBe('unknown')
71
- expect(normalizeStatus(undefined)).toBe('unknown')
72
46
  })
73
47
  })
74
48
 
@@ -1,20 +1,21 @@
1
1
  /**
2
2
  * What the presentation/logic seam unlocks:
3
3
  *
4
- * 1. PRESENTATION components (`@/ui` and a feature's own `monitor/ui`) test with
5
- * ZERO infrastructure — no fake shell, no fake kernel, no session. Just props
6
- * in, DOM out. (Contrast app.test.tsx, which stands up a handshake + kernel
7
- * stub to reach the same markup.)
8
- * 2. FEATURE hooks (`@/monitor`) test against a bare `KernelClient` (the fake
4
+ * 1. PRESENTATION components (`@/ui`) test with ZERO infrastructure no fake
5
+ * shell, no fake kernel, no session. Just props in, DOM out. (Contrast
6
+ * app.test.tsx, which stands up a handshake + kernel stub to reach the same
7
+ * markup.)
8
+ * 2. FEATURE hooks (`@/status`) test against a bare `KernelClient` (the fake
9
9
  * kernel fetch stub) with no handshake — the write lifecycle and the
10
10
  * reload signal are verified once, in isolation.
11
11
  */
12
12
  import { act, cleanup, render, renderHook, screen, waitFor } from '@testing-library/react'
13
13
  import { afterEach, describe, expect, it } from 'vitest'
14
14
 
15
- import { useCheck, useMonitor } from '@/monitor'
16
- import { StatusBadge } from '@/monitor/ui'
17
- import { fakeKernelSession, installFakeKernel, monitorNode, ok } from './harness'
15
+ import { useCheck, useCheckable } from '@/status'
16
+ import { StatusBadge } from '@/ui'
17
+
18
+ import { checkableNode, fakeKernelSession, installFakeKernel, ok } from './harness'
18
19
 
19
20
  afterEach(cleanup)
20
21
 
@@ -23,24 +24,25 @@ describe('presentation is pure (no kernel, no session, no shell)', () => {
23
24
  const { rerender } = render(<StatusBadge status="up" />)
24
25
  expect(screen.getByText('UP').className).toContain('status-up')
25
26
 
27
+ rerender(<StatusBadge status="degraded" />)
28
+ expect(screen.getByText('DEGRADED').className).toContain('status-degraded')
29
+
26
30
  rerender(<StatusBadge status="down" />)
27
31
  expect(screen.getByText('DOWN').className).toContain('status-down')
28
32
 
29
- rerender(<StatusBadge status="unknown" />)
33
+ rerender(<StatusBadge status="weird" />)
30
34
  expect(screen.getByText('UNKNOWN').className).toContain('status-unknown')
31
35
  })
32
36
  })
33
37
 
34
- describe('useMonitor — query + reload signal', () => {
38
+ describe('useCheckable — query + reload signal', () => {
35
39
  it('loads the record, then exposes reloading across a reload()', async () => {
36
40
  const session = fakeKernelSession()
37
41
  let status = 'down'
38
- const kernel = installFakeKernel(() =>
39
- ok(monitorNode({ id: 'mon-1', name: 'API', url: 'https://x.test', status })),
40
- )
42
+ const kernel = installFakeKernel(() => ok(checkableNode({ id: 'page-1', name: 'API', status })))
41
43
 
42
44
  try {
43
- const { result } = renderHook(() => useMonitor(session, 'mon-1'))
45
+ const { result } = renderHook(() => useCheckable(session, 'page-1'))
44
46
  await waitFor(() => expect(result.current.state).toBe('ok'))
45
47
  expect(result.current.record?.status).toBe('down')
46
48
  expect(result.current.reloading).toBe(false)
@@ -63,7 +65,7 @@ describe('useMonitor — query + reload signal', () => {
63
65
  const session = fakeKernelSession()
64
66
  const kernel = installFakeKernel(() => ({ error: { code: 3002, message: 'Path not found' } }))
65
67
  try {
66
- const { result } = renderHook(() => useMonitor(session, 'missing'))
68
+ const { result } = renderHook(() => useCheckable(session, 'missing'))
67
69
  await waitFor(() => expect(result.current.state).toBe('error'))
68
70
  expect(result.current.message).toMatch(/Path not found/)
69
71
  } finally {
@@ -77,7 +79,7 @@ describe('useCheck — the shared write-lifecycle', () => {
77
79
  const session = fakeKernelSession()
78
80
  const kernel = installFakeKernel(() => ok(null))
79
81
  try {
80
- const { result } = renderHook(() => useCheck(session, 'mon-1'))
82
+ const { result } = renderHook(() => useCheck(session, 'page-1'))
81
83
  expect(result.current.phase).toBe('idle')
82
84
 
83
85
  let returned: boolean | undefined
@@ -94,9 +96,11 @@ describe('useCheck — the shared write-lifecycle', () => {
94
96
 
95
97
  it('captures a failure: phase failed, error set, resolves false', async () => {
96
98
  const session = fakeKernelSession()
97
- const kernel = installFakeKernel(() => ({ error: { code: 2004, message: 'Permission denied' } }))
99
+ const kernel = installFakeKernel(() => ({
100
+ error: { code: 2004, message: 'Permission denied' },
101
+ }))
98
102
  try {
99
- const { result } = renderHook(() => useCheck(session, 'mon-1'))
103
+ const { result } = renderHook(() => useCheck(session, 'page-1'))
100
104
  let returned: boolean | undefined
101
105
  await act(async () => {
102
106
  returned = await result.current.run()
@@ -1,27 +1,21 @@
1
1
  /**
2
- * Multi-view router for the domain's one SPA bundle.
2
+ * Path-based view router for the domain's SPA bundle. The shell mounts each Astrale
3
+ * View in its own iframe at its own mount path, so inside the iframe
4
+ * `window.location.pathname` IS that path. `App` reads it (via `resolveView`) and
5
+ * renders the matching view; an unregistered path shows a self-describing fallback.
3
6
  *
4
- * The domain declares its SPA Views, each served from THIS one bundle under
5
- * `/ui/*` and mounted by the shell as its own iframe at its own mount path:
6
- * - `/ui/monitor` the Monitor detail panel (`views/monitor.tsx`)
7
- *
8
- * Because the shell mounts one iframe per view at its mount path, inside the
9
- * iframe `window.location.pathname` is that path. `App` reads it (via
10
- * `resolveView`) and renders the matching view; an unregistered path shows a
11
- * self-describing fallback.
12
- *
13
- * To add a view:
14
- * 1. write a `ViewComponent` (usually wrapping `ViewFrame` — see `views/`),
15
- * 2. add a `ROUTES` entry below keyed by its mount path,
16
- * 3. register a matching `defineView({ mount: '/ui/<path>' })` in the domain's
17
- * `views/` so the shell mounts an iframe there.
7
+ * Today there's one view the StatusPage panel at `/ui/status-page`. The router
8
+ * (not a direct render) is the extension seam: add a view by
9
+ * 1. writing a `ViewComponent` (usually wrapping `ViewFrame` see `views/`),
10
+ * 2. adding a `ROUTES` entry keyed by its mount path,
11
+ * 3. registering a matching `defineView({ mount: '/ui/<path>' })` in `views/`.
18
12
  */
19
13
 
20
14
  import { resolveView, useShell, type ViewRoutes } from '@/shell'
21
- import { MonitorView } from '@/views/monitor'
15
+ import { StatusView } from '@/views/status'
22
16
 
23
17
  const ROUTES: ViewRoutes = {
24
- '/ui/monitor': MonitorView,
18
+ '/ui/status-page': StatusView,
25
19
  }
26
20
 
27
21
  export function App() {
@@ -29,9 +29,7 @@ export interface Capability<P = void> {
29
29
  reset: () => void
30
30
  }
31
31
 
32
- export function useCapability<P = void>(
33
- perform: (params: P) => Promise<unknown>,
34
- ): Capability<P> {
32
+ export function useCapability<P = void>(perform: (params: P) => Promise<unknown>): Capability<P> {
35
33
  const [phase, setPhase] = useState<Phase>('idle')
36
34
  const [error, setError] = useState<string | null>(null)
37
35
 
@@ -1,7 +1,7 @@
1
- import { useCallback, useEffect, useState } from 'react'
2
-
3
1
  import type { KernelClient } from '@astrale-os/shell'
4
2
 
3
+ import { useCallback, useEffect, useState } from 'react'
4
+
5
5
  import { errorMessage, type KernelNode } from './client'
6
6
 
7
7
  export type NodeState =