@astrale-os/adapter-cloudflare 0.1.9 → 0.1.10

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 (80) hide show
  1. package/package.json +1 -1
  2. package/template/.agents/skills/astrale-cli/SKILL.md +1 -1
  3. package/template/.agents/skills/astrale-domain/SKILL.md +5 -5
  4. package/template/.env.example +5 -7
  5. package/template/README.md +2 -2
  6. package/template/client/README.md +79 -62
  7. package/template/client/__tests__/app.test.tsx +188 -99
  8. package/template/client/__tests__/harness.ts +67 -12
  9. package/template/client/__tests__/kernel.test.ts +65 -50
  10. package/template/client/__tests__/seam.test.tsx +111 -0
  11. package/template/client/index.html +1 -1
  12. package/template/client/package.json +1 -0
  13. package/template/client/src/app.tsx +40 -83
  14. package/template/client/src/main.tsx +2 -2
  15. package/template/client/src/monitor/components/MonitorCard.tsx +50 -0
  16. package/template/client/src/monitor/components/index.ts +1 -0
  17. package/template/client/src/monitor/hooks/index.ts +3 -0
  18. package/template/client/src/monitor/hooks/useCheck.mutation.ts +16 -0
  19. package/template/client/src/monitor/hooks/useMonitor.query.ts +64 -0
  20. package/template/client/src/monitor/index.ts +6 -0
  21. package/template/client/src/monitor/monitor.api.ts +11 -0
  22. package/template/client/src/monitor/monitor.mappers.ts +38 -0
  23. package/template/client/src/monitor/monitor.types.ts +23 -0
  24. package/template/client/src/monitor/ui/MonitorDetails.UI.tsx +38 -0
  25. package/template/client/src/monitor/ui/StatusBadge.UI.tsx +14 -0
  26. package/template/client/src/monitor/ui/index.ts +8 -0
  27. package/template/client/src/shell/client.ts +67 -0
  28. package/template/client/src/shell/index.ts +20 -0
  29. package/template/client/src/shell/invoke.ts +35 -0
  30. package/template/client/src/shell/transformers.ts +72 -0
  31. package/template/client/src/shell/use-async.ts +56 -0
  32. package/template/client/src/shell/use-capability.ts +61 -0
  33. package/template/client/src/shell/use-node.ts +61 -0
  34. package/template/client/src/shell/use-shell.ts +91 -0
  35. package/template/client/src/shell/view-router.tsx +98 -0
  36. package/template/client/src/styles.css +177 -4
  37. package/template/client/src/ui/format.ts +24 -0
  38. package/template/client/src/ui/index.ts +9 -0
  39. package/template/client/src/ui/surface.tsx +56 -0
  40. package/template/client/src/ui/value.tsx +32 -0
  41. package/template/client/src/views/monitor.tsx +30 -0
  42. package/template/client/tsconfig.json +2 -1
  43. package/template/client/vite.config.ts +12 -13
  44. package/template/client/vitest.config.ts +12 -5
  45. package/template/core/monitor/health.ts +19 -0
  46. package/template/core/monitor/index.ts +9 -0
  47. package/template/core/monitor/keys.ts +29 -0
  48. package/template/core/monitor/node.ts +51 -0
  49. package/template/deps.ts +8 -8
  50. package/template/domain.ts +1 -1
  51. package/template/env.ts +2 -9
  52. package/template/integrations/prober/http.ts +43 -0
  53. package/template/integrations/prober/mock.ts +22 -0
  54. package/template/integrations/prober/port.ts +28 -0
  55. package/template/integrations/prober/registry.ts +66 -0
  56. package/template/package.json +1 -1
  57. package/template/pnpm-lock.yaml +2766 -0
  58. package/template/runtime/index.ts +36 -19
  59. package/template/runtime/monitor/check.ts +29 -0
  60. package/template/runtime/monitor/dependsOn.ts +16 -0
  61. package/template/runtime/monitor/index.ts +12 -0
  62. package/template/runtime/monitor/seed.ts +74 -0
  63. package/template/runtime/monitor/shared.ts +17 -0
  64. package/template/runtime/monitor/watch.ts +37 -0
  65. package/template/schema/index.ts +11 -4
  66. package/template/schema/monitor.ts +80 -0
  67. package/template/views/index.ts +9 -2
  68. package/template/views/monitor.ts +22 -0
  69. package/template/client/src/lib/kernel.ts +0 -135
  70. package/template/client/src/lib/shell.ts +0 -197
  71. package/template/client/src/lib/use-node.ts +0 -66
  72. package/template/client/src/lib/use-shell.ts +0 -85
  73. package/template/core/keys.ts +0 -28
  74. package/template/core/note.ts +0 -148
  75. package/template/integrations/summary/heuristic.ts +0 -25
  76. package/template/integrations/summary/http.ts +0 -69
  77. package/template/integrations/summary/port.ts +0 -21
  78. package/template/integrations/summary/registry.ts +0 -52
  79. package/template/schema/note.ts +0 -67
  80. package/template/views/note.ts +0 -21
@@ -1,7 +1,11 @@
1
1
  /**
2
- * Test harness — a FAKE shell parent + a FAKE kernel, so the client's inline
3
- * handshake (`src/lib/shell.ts`) and JSON kernel client (`src/lib/kernel.ts`)
4
- * can be exercised with no real GUI/kernel.
2
+ * Test harness — a FAKE shell parent + a FAKE kernel, so the client's shell
3
+ * handshake (now `@astrale-os/shell`, `createShell({ mode: 'sandboxed' })`) and
4
+ * its kernel calls can be exercised with no real GUI/kernel.
5
+ *
6
+ * `fakeKernelSession()` is the unit-test counterpart: a `KernelClient` whose
7
+ * `call` hits the fake kernel directly (no handshake), for hooks tested in
8
+ * isolation.
5
9
  *
6
10
  * The fake parent mirrors `runParentHandshake`
7
11
  * (`shell/packages/shell/src/application/windowing/handshake.ts`):
@@ -17,6 +21,8 @@
17
21
  * the child believes it is framed.
18
22
  */
19
23
 
24
+ import type { KernelClient } from '@/shell'
25
+
20
26
  const INIT_REQUEST_TYPE = 'astrale-shell/init-request'
21
27
  const INIT_RESPONSE_TYPE = 'astrale-shell/init-response'
22
28
 
@@ -196,26 +202,75 @@ export function ok(result: unknown): { result: unknown } {
196
202
  return { result }
197
203
  }
198
204
 
199
- /** Build a minimal kernel Note node whose props carry title/body. */
200
- export function noteNode(opts: {
205
+ /**
206
+ * A `KernelClient` for unit tests that exercise feature hooks WITHOUT a
207
+ * handshake. Its `call` POSTs the kernel JSON envelope (`{ method, params, id }`)
208
+ * so the `installFakeKernel` fetch stub records it and `body.method`/
209
+ * `body.params` assertions hold — same wire the real `shell.kernel` speaks to a
210
+ * JSON kernel. The credential surface (`mintDelegation`/`authToken`/`url`) is
211
+ * stubbed minimally — the isolated hooks under test only ever touch `call`.
212
+ */
213
+ export function fakeKernelSession(kernelUrl = 'https://k.example.test/api'): KernelClient {
214
+ let idSeq = 0
215
+ return {
216
+ async call(method: string, params: unknown = {}): Promise<unknown> {
217
+ idSeq += 1
218
+ const res = await fetch(kernelUrl, {
219
+ method: 'POST',
220
+ headers: {
221
+ 'content-type': 'application/vnd.astrale.kernel+json',
222
+ accept: 'application/vnd.astrale.kernel+json',
223
+ authorization: 'tok-A',
224
+ },
225
+ body: JSON.stringify({ method, params, id: `c${idSeq}` }),
226
+ })
227
+ const obj = (await res.json()) as Record<string, unknown>
228
+ if (obj.error && typeof obj.error === 'object') {
229
+ const err = obj.error as { code?: unknown; message?: unknown }
230
+ const code = typeof err.code === 'number' ? err.code : 5000
231
+ const message = typeof err.message === 'string' ? err.message : 'Unknown error'
232
+ throw new Error(`${code}: ${message}`)
233
+ }
234
+ return obj.result
235
+ },
236
+ mintDelegation: async () => '<fake>',
237
+ authToken: () => '<fake>',
238
+ url: kernelUrl,
239
+ }
240
+ }
241
+
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
246
+ * `Named.name` key.
247
+ */
248
+ export function monitorNode(opts: {
201
249
  id: string
202
250
  path?: string
203
251
  name?: string
204
- title?: string
205
- body?: string
252
+ url?: string
253
+ status?: string
254
+ statusCode?: string
255
+ latencyMs?: string
256
+ lastCheckedAt?: string
206
257
  domain?: string
207
258
  }): { id: string; path: string; class: { raw: string }; props: Record<string, unknown> } {
208
- const domain = opts.domain ?? 'notes.astrale.ai'
259
+ const domain = opts.domain ?? 'monitors.astrale.ai'
260
+ const base = `${domain}:class.Monitor.property`
209
261
  const props: Record<string, unknown> = {}
210
262
  if (opts.name !== undefined) {
211
263
  props['kernel.astrale.ai:interface.Named.property.name'] = opts.name
212
264
  }
213
- if (opts.title !== undefined) props[`${domain}:class.Note.property.title`] = opts.title
214
- if (opts.body !== undefined) props[`${domain}:class.Note.property.body`] = opts.body
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
215
270
  return {
216
271
  id: opts.id,
217
- path: opts.path ?? `/notes/${opts.id}`,
218
- class: { raw: `/:${domain}:class.Note` },
272
+ path: opts.path ?? `/monitors/${opts.id}`,
273
+ class: { raw: `/:${domain}:class.Monitor` },
219
274
  props,
220
275
  }
221
276
  }
@@ -1,68 +1,83 @@
1
- import { afterEach, describe, expect, it } from 'vitest'
1
+ import { describe, expect, it } from 'vitest'
2
2
 
3
- import { kernelCall, readProp, readPropBySuffix } from '../src/lib/kernel'
4
- import { installFakeKernel } from './harness'
3
+ import { qualifiedString, readProp, readPropBySuffix } from '@/shell'
4
+ import { monitorFromNode, normalizeStatus } from '@/monitor'
5
+ import { relativeTime } from '@/ui'
6
+ import { monitorNode } from './harness'
5
7
 
6
- const TYPE = 'application/vnd.astrale.kernel+json'
7
-
8
- describe('kernelCall inline JSON envelope client', () => {
9
- let restore: (() => void) | undefined
10
- afterEach(() => {
11
- restore?.()
12
- restore = undefined
8
+ describe('prop readers', () => {
9
+ it('readProp returns the string value at the exact key', () => {
10
+ expect(readProp({ a: 'x', b: 1 }, 'a')).toBe('x')
11
+ expect(readProp({ a: 'x' }, 'missing')).toBeUndefined()
12
+ expect(readProp({ a: 1 }, 'a')).toBeUndefined() // non-string
13
13
  })
14
14
 
15
- it('POSTs the envelope with a trailing slash, bare token, and kernel content type', async () => {
16
- const k = installFakeKernel(() => ({ result: { ok: true } }))
17
- restore = k.restore
18
- const result = await kernelCall('https://k.test/api', 'tok-X', '@n1::get', {})
19
- expect(result).toEqual({ ok: true })
20
-
21
- const req = k.calls[0]!
22
- expect(req.method).toBe('POST')
23
- expect(req.url).toBe('https://k.test/api/') // slash added
24
- expect(req.headers['authorization']).toBe('tok-X')
25
- expect(req.headers['content-type']).toBe(TYPE)
26
- expect(req.headers['accept']).toBe(TYPE)
27
- expect(req.body).toMatchObject({ method: '@n1::get', params: {} })
28
- expect(req.body.id).toBeTruthy()
15
+ it('readPropBySuffix matches the first key ending with the suffix', () => {
16
+ const props = {
17
+ 'monitors.astrale.ai:class.Monitor.property.url': 'https://x.test',
18
+ 'kernel.astrale.ai:interface.Named.property.name': 'n',
19
+ }
20
+ expect(readPropBySuffix(props, '.property.url')).toBe('https://x.test')
21
+ expect(readPropBySuffix(props, '.property.status')).toBeUndefined()
29
22
  })
30
23
 
31
- it('does not double the trailing slash when the URL already ends in one', async () => {
32
- const k = installFakeKernel(() => ({ result: 1 }))
33
- restore = k.restore
34
- await kernelCall('https://k.test/api/', 'tok', '@n::get')
35
- expect(k.calls[0]!.url).toBe('https://k.test/api/')
24
+ it('qualifiedString prefers a domain key over the kernel one for the same suffix', () => {
25
+ const props = {
26
+ 'kernel.astrale.ai:interface.Named.property.name': 'kernel name',
27
+ 'monitors.astrale.ai:class.Monitor.property.name': 'domain name',
28
+ }
29
+ expect(qualifiedString(props, 'name')).toBe('domain name')
36
30
  })
31
+ })
37
32
 
38
- it('throws "<code>: <message>" on a kernel error envelope', async () => {
39
- const k = installFakeKernel(() => ({ error: { code: 2004, message: 'Permission denied' } }))
40
- restore = k.restore
41
- await expect(kernelCall('https://k.test/api', 't', '@n::get')).rejects.toThrow(
42
- '2004: Permission denied',
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
+ }),
43
45
  )
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
+ })
44
55
  })
45
56
 
46
- it('throws on an unexpected redirect envelope', async () => {
47
- const k = installFakeKernel(() => ({ redirect: { url: 'https://other.test/api' } }))
48
- restore = k.restore
49
- await expect(kernelCall('https://k.test/api', 't', '@n::get')).rejects.toThrow(/redirect/)
57
+ 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' }))
59
+ expect(record.name).toBe('edge')
60
+ expect(record.status).toBe('unknown')
61
+ expect(record.url).toBe('')
62
+ expect(record.statusCode).toBeUndefined()
50
63
  })
51
64
  })
52
65
 
53
- describe('prop readers', () => {
54
- it('readProp returns the string value at the exact key', () => {
55
- expect(readProp({ a: 'x', b: 1 }, 'a')).toBe('x')
56
- expect(readProp({ a: 'x' }, 'missing')).toBeUndefined()
57
- expect(readProp({ a: 1 }, 'a')).toBeUndefined() // non-string
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')
58
72
  })
73
+ })
59
74
 
60
- it('readPropBySuffix matches the first key ending with the suffix', () => {
61
- const props = {
62
- 'notes.astrale.ai:class.Note.property.body': 'the body',
63
- 'kernel.astrale.ai:interface.Named.property.name': 'n',
64
- }
65
- expect(readPropBySuffix(props, '.property.body')).toBe('the body')
66
- expect(readPropBySuffix(props, '.property.title')).toBeUndefined()
75
+ describe('relativeTime', () => {
76
+ const now = Date.parse('2026-06-14T12:00:00Z')
77
+ it('renders coarse buckets and a dash for missing input', () => {
78
+ expect(relativeTime(undefined, now)).toBe('')
79
+ expect(relativeTime('2026-06-14T11:55:00Z', now)).toBe('5m ago')
80
+ expect(relativeTime('2026-06-14T10:00:00Z', now)).toBe('2h ago')
81
+ expect(relativeTime('not-a-date', now)).toBe('not-a-date')
67
82
  })
68
83
  })
@@ -0,0 +1,111 @@
1
+ /**
2
+ * What the presentation/logic seam unlocks:
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
9
+ * kernel fetch stub) with no handshake — the write lifecycle and the
10
+ * reload signal are verified once, in isolation.
11
+ */
12
+ import { act, cleanup, render, renderHook, screen, waitFor } from '@testing-library/react'
13
+ import { afterEach, describe, expect, it } from 'vitest'
14
+
15
+ import { useCheck, useMonitor } from '@/monitor'
16
+ import { StatusBadge } from '@/monitor/ui'
17
+ import { fakeKernelSession, installFakeKernel, monitorNode, ok } from './harness'
18
+
19
+ afterEach(cleanup)
20
+
21
+ describe('presentation is pure (no kernel, no session, no shell)', () => {
22
+ it('StatusBadge renders the label and tone class for each status', () => {
23
+ const { rerender } = render(<StatusBadge status="up" />)
24
+ expect(screen.getByText('UP').className).toContain('status-up')
25
+
26
+ rerender(<StatusBadge status="down" />)
27
+ expect(screen.getByText('DOWN').className).toContain('status-down')
28
+
29
+ rerender(<StatusBadge status="unknown" />)
30
+ expect(screen.getByText('UNKNOWN').className).toContain('status-unknown')
31
+ })
32
+ })
33
+
34
+ describe('useMonitor — query + reload signal', () => {
35
+ it('loads the record, then exposes reloading across a reload()', async () => {
36
+ const session = fakeKernelSession()
37
+ let status = 'down'
38
+ const kernel = installFakeKernel(() =>
39
+ ok(monitorNode({ id: 'mon-1', name: 'API', url: 'https://x.test', status })),
40
+ )
41
+
42
+ try {
43
+ const { result } = renderHook(() => useMonitor(session, 'mon-1'))
44
+ await waitFor(() => expect(result.current.state).toBe('ok'))
45
+ expect(result.current.record?.status).toBe('down')
46
+ expect(result.current.reloading).toBe(false)
47
+
48
+ // Server-side status flips; a reload picks it up and flags `reloading`.
49
+ status = 'up'
50
+ act(() => {
51
+ result.current.reload()
52
+ })
53
+ expect(result.current.reloading).toBe(true)
54
+
55
+ await waitFor(() => expect(result.current.reloading).toBe(false))
56
+ expect(result.current.record?.status).toBe('up')
57
+ } finally {
58
+ kernel.restore()
59
+ }
60
+ })
61
+
62
+ it('surfaces a node-load error', async () => {
63
+ const session = fakeKernelSession()
64
+ const kernel = installFakeKernel(() => ({ error: { code: 3002, message: 'Path not found' } }))
65
+ try {
66
+ const { result } = renderHook(() => useMonitor(session, 'missing'))
67
+ await waitFor(() => expect(result.current.state).toBe('error'))
68
+ expect(result.current.message).toMatch(/Path not found/)
69
+ } finally {
70
+ kernel.restore()
71
+ }
72
+ })
73
+ })
74
+
75
+ describe('useCheck — the shared write-lifecycle', () => {
76
+ it('idle → running → done, then resolves true', async () => {
77
+ const session = fakeKernelSession()
78
+ const kernel = installFakeKernel(() => ok(null))
79
+ try {
80
+ const { result } = renderHook(() => useCheck(session, 'mon-1'))
81
+ expect(result.current.phase).toBe('idle')
82
+
83
+ let returned: boolean | undefined
84
+ await act(async () => {
85
+ returned = await result.current.run()
86
+ })
87
+ expect(returned).toBe(true)
88
+ expect(result.current.phase).toBe('done')
89
+ expect(result.current.error).toBeNull()
90
+ } finally {
91
+ kernel.restore()
92
+ }
93
+ })
94
+
95
+ it('captures a failure: phase failed, error set, resolves false', async () => {
96
+ const session = fakeKernelSession()
97
+ const kernel = installFakeKernel(() => ({ error: { code: 2004, message: 'Permission denied' } }))
98
+ try {
99
+ const { result } = renderHook(() => useCheck(session, 'mon-1'))
100
+ let returned: boolean | undefined
101
+ await act(async () => {
102
+ returned = await result.current.run()
103
+ })
104
+ expect(returned).toBe(false)
105
+ expect(result.current.phase).toBe('failed')
106
+ expect(result.current.error).toMatch(/Permission denied/)
107
+ } finally {
108
+ kernel.restore()
109
+ }
110
+ })
111
+ })
@@ -3,7 +3,7 @@
3
3
  <head>
4
4
  <meta charset="utf-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1" />
6
- <title>astrale-domain · ui-note</title>
6
+ <title>astrale-domain · view</title>
7
7
  </head>
8
8
  <body>
9
9
  <div id="root"></div>
@@ -11,6 +11,7 @@
11
11
  "test": "vitest run"
12
12
  },
13
13
  "dependencies": {
14
+ "@astrale-os/shell": ">=0.1.0 <1.0.0",
14
15
  "react": "^19.2.0",
15
16
  "react-dom": "^19.2.0"
16
17
  },
@@ -1,94 +1,51 @@
1
- import type { NodeSession } from './lib/use-node'
2
-
3
- import { classShortName, type KernelNode, PROP, readProp, readPropBySuffix } from './lib/kernel'
4
- import { useNode } from './lib/use-node'
5
- import { useShell } from './lib/use-shell'
6
-
7
1
  /**
8
- * The `ui-note` view. Mounted by the Astrale shell as a sandboxed iframe; the
9
- * parent completes the handshake (see `lib/shell.ts`) and pushes the target
10
- * node id this view renders. The view then loads that Note from the kernel via
11
- * a minimal inline JSON client (`lib/kernel.ts`, `@<id>::get`) and renders its
12
- * title/body no `@astrale-os/*` deps.
2
+ * Multi-view router for the domain's one SPA bundle.
3
+ *
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.
13
18
  */
14
- export function App() {
15
- const { status, session, nodeId, reason } = useShell()
16
-
17
- return (
18
- <div className="wrap">
19
- <div className="card">
20
- {status === 'loading' && (
21
- <>
22
- <h1 className="title">Note</h1>
23
- <p className="subline">ui-note · Astrale view SPA</p>
24
- <p className="muted">Waiting for the shell handshake…</p>
25
- </>
26
- )}
27
19
 
28
- {status === 'standalone' && (
29
- <>
30
- <h1 className="title">Note</h1>
31
- <p className="subline">ui-note · Astrale view SPA</p>
32
- <div className="banner">
33
- No parent shell ({reason}). This view is meant to be mounted by the Astrale GUI, which
34
- hands it a target node and a kernel token. Showing a standalone preview.
35
- </div>
36
- <p className="body muted">
37
- When mounted by the shell, this card renders the Note you opened.
38
- </p>
39
- </>
40
- )}
20
+ import { resolveView, useShell, type ViewRoutes } from '@/shell'
21
+ import { MonitorView } from '@/views/monitor'
41
22
 
42
- {status === 'ready' && session && <NoteCard session={session} nodeId={nodeId} />}
43
- </div>
44
- </div>
45
- )
23
+ const ROUTES: ViewRoutes = {
24
+ '/ui/monitor': MonitorView,
46
25
  }
47
26
 
48
- /**
49
- * Renders one Note, loaded from the kernel via the live session. Split out so
50
- * `useNode` is called unconditionally (the parent only mounts this once the
51
- * handshake is `ready`, so `session` is non-null here).
52
- */
53
- function NoteCard({ session, nodeId }: { session: NodeSession; nodeId: string | undefined }) {
54
- const state = useNode(session, nodeId)
55
-
56
- const title =
57
- state.status === 'ok' ? (readProp(state.node.props, PROP.named.name) ?? 'Note') : 'Note'
58
-
59
- return (
60
- <>
61
- <h1 className="title">{title}</h1>
62
- <p className="subline">
63
- {state.status === 'ok'
64
- ? `${classShortName(state.node)} · ${state.node.path}`
65
- : 'ui-note · Astrale view SPA'}
66
- </p>
67
-
68
- {state.status === 'idle' && (
69
- <p className="body muted">Handshake complete, but no target node / token yet.</p>
70
- )}
71
-
72
- {state.status === 'loading' && <p className="muted">Loading the Note…</p>}
27
+ export function App() {
28
+ const shell = useShell()
29
+ const View = resolveView(ROUTES)
73
30
 
74
- {state.status === 'error' && (
75
- <div className="banner">Failed to load the Note: {state.message}</div>
76
- )}
31
+ if (!View) return <UnknownView />
77
32
 
78
- {state.status === 'ok' && <NoteBody node={state.node} />}
79
- </>
80
- )
33
+ return <>{View(shell)}</>
81
34
  }
82
35
 
83
- /**
84
- * The Note's `body` prop. Its key is domain-qualified
85
- * (`<domain>:class.Note.property.body`), so read it by suffix rather than a
86
- * build-time-unknown full key.
87
- */
88
- function NoteBody({ node }: { node: KernelNode }) {
89
- const body = readPropBySuffix(node.props, '.property.body')
90
- if (!body) {
91
- return <p className="body muted">This Note has no body.</p>
92
- }
93
- return <p className="body">{body}</p>
36
+ /** Fallback when the iframe's mount path has no registered view. */
37
+ function UnknownView() {
38
+ return (
39
+ <div className="wrap">
40
+ <div className="card">
41
+ <h1 className="title">No view here</h1>
42
+ <p className="subline">Astrale view SPA</p>
43
+ <div className="banner">
44
+ No view registered for {window.location.pathname}. Add a ROUTES entry in{' '}
45
+ <code>src/app.tsx</code> (and a matching <code>defineView({'{ mount }'})</code> in the
46
+ domain's <code>views/</code>).
47
+ </div>
48
+ </div>
49
+ </div>
50
+ )
94
51
  }
@@ -1,9 +1,9 @@
1
1
  import { createRoot } from 'react-dom/client'
2
2
 
3
3
  import './styles.css'
4
- import { App } from './app'
4
+ import { App } from '@/app'
5
5
 
6
6
  const root = document.getElementById('root')
7
- if (!root) throw new Error('ui-note client: #root missing from index.html')
7
+ if (!root) throw new Error('astrale view client: #root missing from index.html')
8
8
 
9
9
  createRoot(root).render(<App />)
@@ -0,0 +1,50 @@
1
+ /**
2
+ * MonitorCard — the Monitor feature's one container. It owns the data/logic:
3
+ * loads the node (`useMonitor`), wires the `check` write (`useCheck`), and
4
+ * composes the presentation. Handles the query's loading/error/ok states; on
5
+ * `ok` it renders a `Panel` whose body is the check-error banner (if any) plus
6
+ * the pure `MonitorDetails` view, with a "Check now" button that runs the probe
7
+ * then reloads the node so the fresh values render.
8
+ *
9
+ * Thin by design: the record's layout lives in `monitor/ui` (`MonitorDetails`);
10
+ * the generic surfaces come from the `@/ui` design system.
11
+ */
12
+ import type { KernelClient } from '@/shell'
13
+
14
+ import { ErrorBanner, Panel, Spinner } from '@/ui'
15
+
16
+ import { useCheck, useMonitor } from '../hooks'
17
+ import { MonitorDetails } from '../ui'
18
+
19
+ export function MonitorCard({ session, nodeId }: { session: KernelClient; nodeId: string }) {
20
+ const monitor = useMonitor(session, nodeId)
21
+ const probe = useCheck(session, nodeId)
22
+
23
+ async function checkNow() {
24
+ if (await probe.run()) monitor.reload()
25
+ }
26
+
27
+ if (monitor.state === 'idle' || monitor.state === 'loading') {
28
+ return <Spinner label="Loading the Monitor…" />
29
+ }
30
+ if (monitor.state === 'error') {
31
+ return <ErrorBanner>Failed to load the Monitor: {monitor.message}</ErrorBanner>
32
+ }
33
+
34
+ const record = monitor.record!
35
+ const checking = probe.phase === 'running'
36
+ const checkButton = (
37
+ <button type="button" className="check-btn" onClick={checkNow} disabled={checking}>
38
+ {checking ? 'Checking…' : 'Check now'}
39
+ </button>
40
+ )
41
+
42
+ return (
43
+ <Panel title={record.name} actions={checkButton}>
44
+ {probe.phase === 'failed' && probe.error && (
45
+ <ErrorBanner>Check failed: {probe.error}</ErrorBanner>
46
+ )}
47
+ <MonitorDetails record={record} />
48
+ </Panel>
49
+ )
50
+ }
@@ -0,0 +1 @@
1
+ export { MonitorCard } from './MonitorCard'
@@ -0,0 +1,3 @@
1
+ export { useMonitor } from './useMonitor.query'
2
+ export type { MonitorQuery } from './useMonitor.query'
3
+ export { useCheck } from './useCheck.mutation'
@@ -0,0 +1,16 @@
1
+ /** Run a Monitor's `check` probe — the feature's one write capability. */
2
+ import { type Capability, type KernelClient, useCapability } from '@/shell'
3
+
4
+ import { check } from '../monitor.api'
5
+
6
+ /**
7
+ * Wrap `@<id>::check` in the shared `useCapability` lifecycle (idle → running →
8
+ * done/failed). The view runs it and, on success, reloads the node so the fresh
9
+ * status renders:
10
+ *
11
+ * const probe = useCheck(session, nodeId)
12
+ * if (await probe.run()) reload()
13
+ */
14
+ export function useCheck(session: KernelClient, nodeId: string): Capability {
15
+ return useCapability(() => check(session, nodeId))
16
+ }