@astrale-os/adapter-cloudflare 0.1.9 → 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 (81) 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 +81 -62
  7. package/template/client/__tests__/app.test.tsx +143 -98
  8. package/template/client/__tests__/harness.ts +62 -12
  9. package/template/client/__tests__/kernel.test.ts +40 -51
  10. package/template/client/__tests__/seam.test.tsx +115 -0
  11. package/template/client/index.html +1 -1
  12. package/template/client/package.json +1 -0
  13. package/template/client/src/app.tsx +34 -83
  14. package/template/client/src/main.tsx +2 -2
  15. package/template/client/src/shell/client.ts +67 -0
  16. package/template/client/src/shell/index.ts +20 -0
  17. package/template/client/src/shell/invoke.ts +35 -0
  18. package/template/client/src/shell/transformers.ts +72 -0
  19. package/template/client/src/shell/use-async.ts +56 -0
  20. package/template/client/src/shell/use-capability.ts +59 -0
  21. package/template/client/src/shell/use-node.ts +61 -0
  22. package/template/client/src/shell/use-shell.ts +91 -0
  23. package/template/client/src/shell/view-router.tsx +97 -0
  24. package/template/client/src/status/components/StatusCard.tsx +50 -0
  25. package/template/client/src/status/components/index.ts +1 -0
  26. package/template/client/src/status/hooks/index.ts +3 -0
  27. package/template/client/src/status/hooks/useCheck.mutation.ts +16 -0
  28. package/template/client/src/status/hooks/useCheckable.query.ts +64 -0
  29. package/template/client/src/status/index.ts +7 -0
  30. package/template/client/src/status/status.api.ts +12 -0
  31. package/template/client/src/status/status.mappers.ts +19 -0
  32. package/template/client/src/status/status.types.ts +11 -0
  33. package/template/client/src/styles.css +182 -4
  34. package/template/client/src/ui/StatusBadge.tsx +31 -0
  35. package/template/client/src/ui/format.ts +24 -0
  36. package/template/client/src/ui/index.ts +13 -0
  37. package/template/client/src/ui/surface.tsx +56 -0
  38. package/template/client/src/ui/value.tsx +32 -0
  39. package/template/client/src/views/status.tsx +28 -0
  40. package/template/client/tsconfig.json +2 -1
  41. package/template/client/vite.config.ts +11 -13
  42. package/template/client/vitest.config.ts +11 -5
  43. package/template/core/monitor/health.ts +34 -0
  44. package/template/core/monitor/index.ts +9 -0
  45. package/template/core/monitor/keys.ts +41 -0
  46. package/template/core/monitor/node.ts +57 -0
  47. package/template/deps.ts +10 -9
  48. package/template/domain.ts +1 -1
  49. package/template/env.ts +2 -9
  50. package/template/integrations/prober/http.ts +32 -0
  51. package/template/integrations/prober/mock.ts +18 -0
  52. package/template/integrations/prober/port.ts +26 -0
  53. package/template/integrations/prober/registry.ts +65 -0
  54. package/template/package.json +1 -1
  55. package/template/pnpm-lock.yaml +2766 -0
  56. package/template/runtime/index.ts +63 -34
  57. package/template/runtime/monitor/check.ts +29 -0
  58. package/template/runtime/monitor/index.ts +9 -0
  59. package/template/runtime/monitor/seed.ts +95 -0
  60. package/template/runtime/monitor/watch.ts +31 -0
  61. package/template/runtime/shared.ts +21 -0
  62. package/template/runtime/status-page/add.ts +21 -0
  63. package/template/runtime/status-page/check.ts +50 -0
  64. package/template/runtime/status-page/create.ts +24 -0
  65. package/template/runtime/status-page/index.ts +8 -0
  66. package/template/schema/index.ts +11 -4
  67. package/template/schema/monitor.ts +94 -0
  68. package/template/views/index.ts +8 -2
  69. package/template/views/status-page.ts +16 -0
  70. package/template/client/src/lib/kernel.ts +0 -135
  71. package/template/client/src/lib/shell.ts +0 -197
  72. package/template/client/src/lib/use-node.ts +0 -66
  73. package/template/client/src/lib/use-shell.ts +0 -85
  74. package/template/core/keys.ts +0 -28
  75. package/template/core/note.ts +0 -148
  76. package/template/integrations/summary/heuristic.ts +0 -25
  77. package/template/integrations/summary/http.ts +0 -69
  78. package/template/integrations/summary/port.ts +0 -21
  79. package/template/integrations/summary/registry.ts +0 -52
  80. package/template/schema/note.ts +0 -67
  81. package/template/views/note.ts +0 -21
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Graph-response transformers at the kernel boundary — turn raw `::get` /
3
+ * `::listChildren` / `::getLinks` payloads into the shapes features consume.
4
+ * Domain-neutral: per-class node→record mappers live in each feature's
5
+ * `*.mappers.ts` and build on `qualifiedString` here.
6
+ *
7
+ * Props are stored under QUALIFIED keys
8
+ * (`monitors.astrale.ai:class.Monitor.property.url`). The client must not import
9
+ * server schema, so values resolve by KEY SUFFIX (`.property.<name>`),
10
+ * preferring domain-qualified keys over kernel ones
11
+ * (`kernel.astrale.ai:interface.Named.property.name` would otherwise shadow
12
+ * `Monitor.property.name` — both end in `.property.name`).
13
+ */
14
+ import type { KernelNode } from './client'
15
+
16
+ /** Resolve a prop by `.property.<name>` suffix; domain keys win over kernel ones. */
17
+ export function qualifiedProp(props: Record<string, unknown>, name: string): unknown {
18
+ const suffix = `.property.${name}`
19
+ let kernelFallback: unknown
20
+ for (const [key, value] of Object.entries(props)) {
21
+ if (!key.endsWith(suffix)) continue
22
+ if (key.startsWith('kernel.astrale.ai:')) {
23
+ if (kernelFallback === undefined) kernelFallback = value
24
+ } else {
25
+ return value
26
+ }
27
+ }
28
+ return kernelFallback
29
+ }
30
+
31
+ export function qualifiedString(props: Record<string, unknown>, name: string): string | undefined {
32
+ const v = qualifiedProp(props, name)
33
+ return typeof v === 'string' && v !== '' ? v : undefined
34
+ }
35
+
36
+ type RawLink = { class?: unknown; edgeClass?: unknown; target?: unknown; to?: unknown }
37
+
38
+ /**
39
+ * Targets of a `::getLinks` response whose edge class ends with `classTail`
40
+ * (e.g. `'monitor_on_host'`). Tolerates bare-array and `{ links }` envelopes,
41
+ * and both `target`/`to` + `class`/`edgeClass`.
42
+ */
43
+ export function linkTargets(raw: unknown, classTail: string): string[] {
44
+ const links: RawLink[] = Array.isArray(raw)
45
+ ? (raw as RawLink[])
46
+ : ((raw as { links?: RawLink[] } | null)?.links ?? [])
47
+ const out: string[] = []
48
+ for (const link of links) {
49
+ const cls =
50
+ typeof link.class === 'string'
51
+ ? link.class
52
+ : typeof link.edgeClass === 'string'
53
+ ? link.edgeClass
54
+ : ''
55
+ if (!cls.endsWith(classTail)) continue
56
+ const target =
57
+ typeof link.target === 'string'
58
+ ? link.target
59
+ : typeof link.to === 'string'
60
+ ? link.to
61
+ : undefined
62
+ if (target) out.push(target)
63
+ }
64
+ return out
65
+ }
66
+
67
+ /** A `::listChildren` response as a node array (bare array or enveloped). */
68
+ export function asNodeArray(raw: unknown): KernelNode[] {
69
+ if (Array.isArray(raw)) return raw as KernelNode[]
70
+ const obj = raw as { children?: KernelNode[]; nodes?: KernelNode[] } | null
71
+ return obj?.children ?? obj?.nodes ?? []
72
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Tiny async-resource hook — load on mount / when `deps` change, expose a
3
+ * `reload()` for refresh buttons and post-mutation refetches. Mirrors the
4
+ * cancellation discipline of `use-node.ts` (stale resolutions are dropped).
5
+ */
6
+ import { useCallback, useEffect, useState } from 'react'
7
+
8
+ import { errorMessage } from './client'
9
+
10
+ export type AsyncState<T> =
11
+ | { status: 'loading' }
12
+ | { status: 'ok'; data: T }
13
+ | { status: 'error'; message: string }
14
+
15
+ export type AsyncResource<T> = {
16
+ state: AsyncState<T>
17
+ /** Refetch. Keeps showing the previous data while the reload is in flight. */
18
+ reload: () => void
19
+ /** True while a reload is in flight on top of an `ok` state. */
20
+ reloading: boolean
21
+ }
22
+
23
+ export function useAsync<T>(fn: () => Promise<T>, deps: unknown[]): AsyncResource<T> {
24
+ const [state, setState] = useState<AsyncState<T>>({ status: 'loading' })
25
+ const [reloading, setReloading] = useState(false)
26
+ const [epoch, setEpoch] = useState(0)
27
+
28
+ useEffect(() => {
29
+ let cancelled = false
30
+ setState((prev) => {
31
+ if (prev.status === 'ok') {
32
+ setReloading(true)
33
+ return prev // keep stale data visible during a reload
34
+ }
35
+ return { status: 'loading' }
36
+ })
37
+ fn()
38
+ .then((data) => {
39
+ if (cancelled) return
40
+ setReloading(false)
41
+ setState({ status: 'ok', data })
42
+ })
43
+ .catch((err: unknown) => {
44
+ if (cancelled) return
45
+ setReloading(false)
46
+ setState({ status: 'error', message: errorMessage(err) })
47
+ })
48
+ return () => {
49
+ cancelled = true
50
+ }
51
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- deps are the caller's cache key
52
+ }, [...deps, epoch])
53
+
54
+ const reload = useCallback(() => setEpoch((e) => e + 1), [])
55
+ return { state, reload, reloading }
56
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * `useCapability` — the convention's WRITE primitive: a node method made
3
+ * interactive, wrapped in one uniform lifecycle (idle → running → done/failed).
4
+ * Every actuator (a form submit, a danger button) shares this machine instead
5
+ * of re-implementing busy/error flags by hand.
6
+ *
7
+ * const check = useCapability(() => invokeNode(session, nodeId, 'check', {}))
8
+ * …
9
+ * if (await check.run()) reload()
10
+ *
11
+ * (This is the single seam where permission-gating would later land — a
12
+ * `permitted` flag computed from the node's affordances — without touching any
13
+ * presentational component.)
14
+ */
15
+ import { useCallback, useState } from 'react'
16
+
17
+ import { errorMessage } from './client'
18
+
19
+ export type Phase = 'idle' | 'running' | 'done' | 'failed'
20
+
21
+ export interface Capability<P = void> {
22
+ /** Phase of the most recent invocation. */
23
+ phase: Phase
24
+ /** Failure message when `phase === 'failed'`, else null. */
25
+ error: string | null
26
+ /** Invoke; resolves true on success, false on failure (error then set). */
27
+ run: [P] extends [void] ? () => Promise<boolean> : (params: P) => Promise<boolean>
28
+ /** Return to idle, clearing any error. */
29
+ reset: () => void
30
+ }
31
+
32
+ export function useCapability<P = void>(perform: (params: P) => Promise<unknown>): Capability<P> {
33
+ const [phase, setPhase] = useState<Phase>('idle')
34
+ const [error, setError] = useState<string | null>(null)
35
+
36
+ const run = useCallback(
37
+ async (params: P): Promise<boolean> => {
38
+ setPhase('running')
39
+ setError(null)
40
+ try {
41
+ await perform(params)
42
+ setPhase('done')
43
+ return true
44
+ } catch (err) {
45
+ setPhase('failed')
46
+ setError(errorMessage(err))
47
+ return false
48
+ }
49
+ },
50
+ [perform],
51
+ )
52
+
53
+ const reset = useCallback(() => {
54
+ setPhase('idle')
55
+ setError(null)
56
+ }, [])
57
+
58
+ return { phase, error, run, reset } as Capability<P>
59
+ }
@@ -0,0 +1,61 @@
1
+ import type { KernelClient } from '@astrale-os/shell'
2
+
3
+ import { useCallback, useEffect, useState } from 'react'
4
+
5
+ import { errorMessage, type KernelNode } from './client'
6
+
7
+ export type NodeState =
8
+ | { status: 'idle' }
9
+ | { status: 'loading' }
10
+ | { status: 'ok'; node: KernelNode }
11
+ | { status: 'error'; message: string }
12
+
13
+ /**
14
+ * Fetch a node by id via `@<id>::get`. Re-fetches when the target node id
15
+ * changes (`setTarget` hot-swap); stays `idle` until a target node id exists.
16
+ * `session` is a stable reference for the view's lifetime, so listing it as a
17
+ * dep never re-fires on its own — and the shell's client reads the fresh token
18
+ * at call time, so a `ctrl:tokenRefresh` needs no re-fetch.
19
+ *
20
+ * Returns the node state plus a `reload()` that re-fetches the current node —
21
+ * the view calls it after an instance method (e.g. `::check`) mutates the node
22
+ * so the fresh props render. It bumps an internal `nonce` dep, re-firing the
23
+ * effect.
24
+ */
25
+ export function useNode(
26
+ session: KernelClient,
27
+ nodeId: string | undefined,
28
+ ): NodeState & { reload(): void } {
29
+ const [state, setState] = useState<NodeState>({ status: 'idle' })
30
+ const [nonce, setNonce] = useState(0)
31
+
32
+ const reload = useCallback(() => {
33
+ setNonce((n) => n + 1)
34
+ }, [])
35
+
36
+ useEffect(() => {
37
+ if (!nodeId) {
38
+ setState({ status: 'idle' })
39
+ return
40
+ }
41
+
42
+ let cancelled = false
43
+ setState({ status: 'loading' })
44
+ session
45
+ .call(`@${nodeId}::get`, {})
46
+ .then((result) => {
47
+ if (!cancelled) setState({ status: 'ok', node: result as KernelNode })
48
+ })
49
+ .catch((err: unknown) => {
50
+ if (!cancelled) {
51
+ setState({ status: 'error', message: errorMessage(err) })
52
+ }
53
+ })
54
+
55
+ return () => {
56
+ cancelled = true
57
+ }
58
+ }, [session, nodeId, nonce])
59
+
60
+ return { ...state, reload }
61
+ }
@@ -0,0 +1,91 @@
1
+ import {
2
+ createShell,
3
+ type IntentMessage,
4
+ type IntentRegistry,
5
+ type KernelClient,
6
+ type Shell,
7
+ } from '@astrale-os/shell'
8
+ import { useEffect, useMemo, useState } from 'react'
9
+
10
+ export type ShellStatus = 'loading' | 'ready' | 'standalone'
11
+
12
+ export type ShellState = {
13
+ status: ShellStatus
14
+ /** The kernel handle once `ready` — view hooks call through `session.call`. */
15
+ session: KernelClient | null
16
+ /** Current target node id — updates on `setTarget` hot-swaps. */
17
+ nodeId: string | undefined
18
+ /** Why we fell back to `standalone` (no parent / handshake timeout). */
19
+ reason: string | null
20
+ }
21
+
22
+ /**
23
+ * Boots the real Astrale shell in sandboxed (child) mode: runs the init
24
+ * handshake with the parent window to obtain the kernel session + target node,
25
+ * then tracks `setTarget` hot-swaps. The shell's `KernelClient` owns the
26
+ * delegation token (refreshed by the parent) and the kernel transport, so view
27
+ * hooks just call `session.call('@<id>::<method>')`.
28
+ *
29
+ * Three outcomes:
30
+ * - `loading` — handshake in flight
31
+ * - `ready` — parent completed the handshake; `session`/`nodeId` populated
32
+ * - `standalone` — no parent or it timed out (opened directly in a tab); the
33
+ * view renders a self-describing fallback
34
+ */
35
+ export function useShell(): ShellState {
36
+ const [status, setStatus] = useState<ShellStatus>('loading')
37
+ const [session, setSession] = useState<KernelClient | null>(null)
38
+ const [nodeId, setNodeId] = useState<string | undefined>(undefined)
39
+ const [reason, setReason] = useState<string | null>(null)
40
+
41
+ useEffect(() => {
42
+ // Not framed (opened directly in a tab): skip the handshake entirely rather
43
+ // than waiting out the init timeout — there is no parent to answer it.
44
+ if (!window.parent || window.parent === window) {
45
+ setReason('not running inside a parent frame')
46
+ setStatus('standalone')
47
+ return
48
+ }
49
+
50
+ let cancelled = false
51
+ let shell: Shell | null = null
52
+
53
+ void (async () => {
54
+ try {
55
+ const built = createShell({ mode: 'sandboxed', initTimeoutMs: 5000 })
56
+ await built.init()
57
+ if (cancelled) {
58
+ void built.dispose()
59
+ return
60
+ }
61
+ shell = built
62
+
63
+ // Hand views the shell's real `KernelClient` directly. It reads the live
64
+ // (parent-refreshed) token on each call and owns the transport, so view
65
+ // hooks just call `session.call('@<id>::<method>', params)`.
66
+ setSession(built.kernel)
67
+ setNodeId(built.targetNodeId)
68
+ setStatus('ready')
69
+
70
+ // Hot-swap: the parent pushes a new target via the typed `setTarget`
71
+ // intent; the iframe stays mounted, only `nodeId` updates.
72
+ built.parent?.on('intent', (msg: IntentMessage) => {
73
+ if (msg.envelope.name !== 'setTarget') return
74
+ const payload = msg.envelope.payload as IntentRegistry['setTarget']['payload']
75
+ if (typeof payload.nodeId === 'string') setNodeId(payload.nodeId)
76
+ })
77
+ } catch (err) {
78
+ if (cancelled) return
79
+ setReason(err instanceof Error ? err.message : String(err))
80
+ setStatus('standalone')
81
+ }
82
+ })()
83
+
84
+ return () => {
85
+ cancelled = true
86
+ if (shell) void shell.dispose()
87
+ }
88
+ }, [])
89
+
90
+ return useMemo(() => ({ status, session, nodeId, reason }), [status, session, nodeId, reason])
91
+ }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Tiny multi-view router for the domain's one SPA bundle.
3
+ *
4
+ * The domain declares several SPA Views, each mounted by the shell at its own
5
+ * path under `/ui/*` (e.g. `/ui/status-page`). The shell mounts ONE iframe per view,
6
+ * so inside the iframe `window.location.pathname` is that view's mount path. This
7
+ * bundle reads that path and renders the matching view — one build, many views.
8
+ *
9
+ * Two pieces:
10
+ * - `resolveView` — map `window.location.pathname` → a view component.
11
+ * - `ViewFrame` — DRYs the shell-handshake gating (`loading`/`standalone`/
12
+ * `ready`) every view repeats, so a view only writes its `ready` body.
13
+ *
14
+ * To add a view: write a `ViewComponent` (usually wrapping `ViewFrame`), add a
15
+ * `ROUTES` entry keyed by its mount path in `src/app.tsx`, and register a
16
+ * matching `defineView({ mount })` in the domain's `views/`.
17
+ */
18
+
19
+ import type { KernelClient } from '@astrale-os/shell'
20
+ import type { ReactNode } from 'react'
21
+
22
+ import type { ShellState } from './use-shell'
23
+
24
+ /** A view: given the shell state, render its tree. */
25
+ export type ViewComponent = (shell: ShellState) => ReactNode
26
+
27
+ /** Mount path → view component (key e.g. `/ui/status-page`). */
28
+ export type ViewRoutes = Record<string, ViewComponent>
29
+
30
+ /** Strip a single trailing slash (but keep a bare `/`). */
31
+ function normalizePath(pathname: string): string {
32
+ return pathname.length > 1 && pathname.endsWith('/') ? pathname.slice(0, -1) : pathname
33
+ }
34
+
35
+ /**
36
+ * Resolve the view for a mount path — exact match, tolerating a trailing slash.
37
+ * Returns `undefined` when no route is registered (caller renders a fallback).
38
+ */
39
+ export function resolveView(
40
+ routes: ViewRoutes,
41
+ pathname: string = window.location.pathname,
42
+ ): ViewComponent | undefined {
43
+ return routes[normalizePath(pathname)]
44
+ }
45
+
46
+ /**
47
+ * Shared shell-handshake gate. Renders the `.wrap`/`.card` shell and:
48
+ * - `loading` → title + subline + "Waiting for the shell handshake…"
49
+ * - `standalone` → title + subline + the "No parent shell" banner copy
50
+ * - `ready` → `children(session, nodeId)` (session is non-null here)
51
+ *
52
+ * Mirrors the markup/classes the original single-view app used, so styling is
53
+ * unchanged across views.
54
+ */
55
+ export function ViewFrame({
56
+ shell,
57
+ title,
58
+ subline,
59
+ children,
60
+ }: {
61
+ shell: ShellState
62
+ title: string
63
+ subline?: string
64
+ children: (session: KernelClient, nodeId: string | undefined) => ReactNode
65
+ }) {
66
+ const { status, session, nodeId, reason } = shell
67
+
68
+ return (
69
+ <div className="wrap">
70
+ <div className="card">
71
+ {status === 'loading' && (
72
+ <>
73
+ <h1 className="title">{title}</h1>
74
+ {subline && <p className="subline">{subline}</p>}
75
+ <p className="muted">Waiting for the shell handshake…</p>
76
+ </>
77
+ )}
78
+
79
+ {status === 'standalone' && (
80
+ <>
81
+ <h1 className="title">{title}</h1>
82
+ {subline && <p className="subline">{subline}</p>}
83
+ <div className="banner">
84
+ No parent shell ({reason}). This view is meant to be mounted by the Astrale GUI, which
85
+ hands it a target node and a kernel session. Showing a standalone preview.
86
+ </div>
87
+ <p className="body muted">
88
+ When mounted by the shell, this card renders the node you opened.
89
+ </p>
90
+ </>
91
+ )}
92
+
93
+ {status === 'ready' && session && children(session, nodeId)}
94
+ </div>
95
+ </div>
96
+ )
97
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * StatusCard — the status feature's one container. It owns the data/logic: loads
3
+ * the node (`useCheckable`), wires the `check` write (`useCheck`), and composes
4
+ * the presentation. Handles the query's loading/error/ok states; on `ok` it
5
+ * renders a `Panel` with the check-error banner (if any) + the StatusBadge.
6
+ * "Check now" runs the check then reloads the node so the fresh status renders.
7
+ *
8
+ * The generic surfaces come from the `@/ui` design system; the only domain-shaped
9
+ * primitive is the shared `StatusBadge`.
10
+ */
11
+ import type { KernelClient } from '@/shell'
12
+
13
+ import { ErrorBanner, Panel, Spinner, StatusBadge } from '@/ui'
14
+
15
+ import { useCheck, useCheckable } from '../hooks'
16
+
17
+ export function StatusCard({ session, nodeId }: { session: KernelClient; nodeId: string }) {
18
+ const checkable = useCheckable(session, nodeId)
19
+ const check = useCheck(session, nodeId)
20
+
21
+ async function checkNow() {
22
+ if (await check.run()) checkable.reload()
23
+ }
24
+
25
+ if (checkable.state === 'idle' || checkable.state === 'loading') {
26
+ return <Spinner label="Loading…" />
27
+ }
28
+ if (checkable.state === 'error') {
29
+ return <ErrorBanner>Failed to load: {checkable.message}</ErrorBanner>
30
+ }
31
+
32
+ const record = checkable.record!
33
+ const checking = check.phase === 'running'
34
+ const checkButton = (
35
+ <button type="button" className="check-btn" onClick={checkNow} disabled={checking}>
36
+ {checking ? 'Checking…' : 'Check now'}
37
+ </button>
38
+ )
39
+
40
+ return (
41
+ <Panel title={record.name} actions={checkButton}>
42
+ {check.phase === 'failed' && check.error && (
43
+ <ErrorBanner>Check failed: {check.error}</ErrorBanner>
44
+ )}
45
+ <div className="status-row">
46
+ <StatusBadge status={record.status} />
47
+ </div>
48
+ </Panel>
49
+ )
50
+ }
@@ -0,0 +1 @@
1
+ export { StatusCard } from './StatusCard'
@@ -0,0 +1,3 @@
1
+ export { useCheckable } from './useCheckable.query'
2
+ export type { CheckableQuery } from './useCheckable.query'
3
+ export { useCheck } from './useCheck.mutation'
@@ -0,0 +1,16 @@
1
+ /** Run a checkable node's `check` — the feature's one write capability. */
2
+ import { type Capability, type KernelClient, useCapability } from '@/shell'
3
+
4
+ import { check } from '../status.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
+ }
@@ -0,0 +1,64 @@
1
+ /** Load a checkable node → typed record, with a `reload()` for post-check refresh. */
2
+ import { useCallback, useRef, useState } from 'react'
3
+
4
+ import { type KernelClient, useNode } from '@/shell'
5
+
6
+ import type { CheckableRecord } from '../status.types'
7
+
8
+ import { checkableFromNode } from '../status.mappers'
9
+
10
+ export type CheckableQuery = {
11
+ /** Lifecycle of the underlying `@<id>::get`. */
12
+ state: 'idle' | 'loading' | 'error' | 'ok'
13
+ /** The projected record once `ok`. */
14
+ record?: CheckableRecord
15
+ /** Failure message once `error`. */
16
+ message?: string
17
+ /** Re-fetch the node — call after `check` mutates it server-side. */
18
+ reload(): void
19
+ /** True while a `reload()`-triggered re-fetch is in flight. */
20
+ reloading: boolean
21
+ }
22
+
23
+ /**
24
+ * Load the checkable node `nodeId` and project it to a `CheckableRecord`. Wraps
25
+ * `useNode` (which owns the `@<id>::get` + the hot-swap re-fetch) and re-exposes
26
+ * its `reload()` so the view can refresh after a `check`. `reloading` is true
27
+ * from the moment `reload()` is called until the re-fetch settles — distinct from
28
+ * the initial `loading`, so the view can show the existing record meanwhile.
29
+ */
30
+ export function useCheckable(session: KernelClient, nodeId: string): CheckableQuery {
31
+ const node = useNode(session, nodeId)
32
+ const [reloading, setReloading] = useState(false)
33
+ // Two-phase reload tracker. `reload()` arms it as `'requested'`; once we
34
+ // observe `useNode` enter `loading` it advances to `'loading'`; the next time
35
+ // it leaves `loading` the re-fetch has settled, so we clear `reloading`. The
36
+ // two phases avoid clearing on the synchronous render BEFORE the effect runs
37
+ // (when the status is still the pre-reload `ok`).
38
+ const phase = useRef<'idle' | 'requested' | 'loading'>('idle')
39
+
40
+ if (phase.current === 'requested' && node.status === 'loading') {
41
+ phase.current = 'loading'
42
+ } else if (phase.current === 'loading' && node.status !== 'loading') {
43
+ phase.current = 'idle'
44
+ if (reloading) setReloading(false)
45
+ }
46
+
47
+ const nodeReload = node.reload
48
+ const reload = useCallback(() => {
49
+ phase.current = 'requested'
50
+ setReloading(true)
51
+ nodeReload()
52
+ }, [nodeReload])
53
+
54
+ switch (node.status) {
55
+ case 'ok':
56
+ return { state: 'ok', record: checkableFromNode(node.node), reload, reloading }
57
+ case 'error':
58
+ return { state: 'error', message: node.message, reload, reloading }
59
+ case 'loading':
60
+ return { state: 'loading', reload, reloading }
61
+ default:
62
+ return { state: 'idle', reload, reloading }
63
+ }
64
+ }
@@ -0,0 +1,7 @@
1
+ /** The status feature — its api, types, mappers, hooks and components. One card,
2
+ * polymorphic over any `Checkable` node (Monitor or StatusPage). */
3
+ export * from './components'
4
+ export * from './hooks'
5
+ export { check } from './status.api'
6
+ export { checkableFromNode } from './status.mappers'
7
+ export type { CheckableRecord } from './status.types'
@@ -0,0 +1,12 @@
1
+ /** Raw kernel calls for the status feature — node instance methods. */
2
+ import { invokeNode, type KernelClient } from '@/shell'
3
+
4
+ /**
5
+ * Run a checkable node's `check` instance method (`@<id>::check`). The single
6
+ * write both classes share: a Monitor probes its target URL, a StatusPage rolls
7
+ * its watched monitors up — each updates its own `status` prop server-side; the
8
+ * caller reloads the node to render the fresh value.
9
+ */
10
+ export function check(session: KernelClient, nodeId: string): Promise<unknown> {
11
+ return invokeNode(session, nodeId, 'check', {})
12
+ }
@@ -0,0 +1,19 @@
1
+ /** Node → typed record transform for the status feature. */
2
+ import { type KernelNode, PROP, readProp, readPropBySuffix } from '@/shell'
3
+
4
+ import type { CheckableRecord } from './status.types'
5
+
6
+ const lastSegment = (path: string): string => path.split('/').filter(Boolean).pop() ?? path
7
+
8
+ /**
9
+ * Project a `Checkable` `KernelNode` into a `CheckableRecord`: the name from the
10
+ * kernel `Named.name` key, the domain-qualified `status` read by suffix (default
11
+ * `'unknown'`).
12
+ */
13
+ export function checkableFromNode(node: KernelNode): CheckableRecord {
14
+ const p = node.props ?? {}
15
+ return {
16
+ name: readProp(p, PROP.named.name) ?? lastSegment(node.path ?? '') ?? node.id,
17
+ status: readPropBySuffix(p, '.property.status') ?? 'unknown',
18
+ }
19
+ }
@@ -0,0 +1,11 @@
1
+ /** The status feature's client type — what a `Checkable` node projects to. */
2
+
3
+ /**
4
+ * Client-side projection of the `Checkable` node the view renders (a StatusPage).
5
+ * `status` stays a plain string — the page's rolled-up verdict
6
+ * (up/degraded/down/unknown), which `StatusBadge` renders.
7
+ */
8
+ export type CheckableRecord = {
9
+ name: string
10
+ status: string
11
+ }