@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.
- package/package.json +1 -1
- package/template/.agents/skills/astrale-cli/SKILL.md +1 -1
- package/template/.agents/skills/astrale-domain/SKILL.md +5 -5
- package/template/.env.example +5 -7
- package/template/README.md +2 -2
- package/template/client/README.md +81 -62
- package/template/client/__tests__/app.test.tsx +143 -98
- package/template/client/__tests__/harness.ts +62 -12
- package/template/client/__tests__/kernel.test.ts +40 -51
- package/template/client/__tests__/seam.test.tsx +115 -0
- package/template/client/index.html +1 -1
- package/template/client/package.json +1 -0
- package/template/client/src/app.tsx +34 -83
- package/template/client/src/main.tsx +2 -2
- package/template/client/src/shell/client.ts +67 -0
- package/template/client/src/shell/index.ts +20 -0
- package/template/client/src/shell/invoke.ts +35 -0
- package/template/client/src/shell/transformers.ts +72 -0
- package/template/client/src/shell/use-async.ts +56 -0
- package/template/client/src/shell/use-capability.ts +59 -0
- package/template/client/src/shell/use-node.ts +61 -0
- package/template/client/src/shell/use-shell.ts +91 -0
- package/template/client/src/shell/view-router.tsx +97 -0
- package/template/client/src/status/components/StatusCard.tsx +50 -0
- package/template/client/src/status/components/index.ts +1 -0
- package/template/client/src/status/hooks/index.ts +3 -0
- package/template/client/src/status/hooks/useCheck.mutation.ts +16 -0
- package/template/client/src/status/hooks/useCheckable.query.ts +64 -0
- package/template/client/src/status/index.ts +7 -0
- package/template/client/src/status/status.api.ts +12 -0
- package/template/client/src/status/status.mappers.ts +19 -0
- package/template/client/src/status/status.types.ts +11 -0
- package/template/client/src/styles.css +182 -4
- package/template/client/src/ui/StatusBadge.tsx +31 -0
- package/template/client/src/ui/format.ts +24 -0
- package/template/client/src/ui/index.ts +13 -0
- package/template/client/src/ui/surface.tsx +56 -0
- package/template/client/src/ui/value.tsx +32 -0
- package/template/client/src/views/status.tsx +28 -0
- package/template/client/tsconfig.json +2 -1
- package/template/client/vite.config.ts +11 -13
- package/template/client/vitest.config.ts +11 -5
- package/template/core/monitor/health.ts +34 -0
- package/template/core/monitor/index.ts +9 -0
- package/template/core/monitor/keys.ts +41 -0
- package/template/core/monitor/node.ts +57 -0
- package/template/deps.ts +10 -9
- package/template/domain.ts +1 -1
- package/template/env.ts +2 -9
- package/template/integrations/prober/http.ts +32 -0
- package/template/integrations/prober/mock.ts +18 -0
- package/template/integrations/prober/port.ts +26 -0
- package/template/integrations/prober/registry.ts +65 -0
- package/template/package.json +1 -1
- package/template/pnpm-lock.yaml +2766 -0
- package/template/runtime/index.ts +63 -34
- package/template/runtime/monitor/check.ts +29 -0
- package/template/runtime/monitor/index.ts +9 -0
- package/template/runtime/monitor/seed.ts +95 -0
- package/template/runtime/monitor/watch.ts +31 -0
- package/template/runtime/shared.ts +21 -0
- package/template/runtime/status-page/add.ts +21 -0
- package/template/runtime/status-page/check.ts +50 -0
- package/template/runtime/status-page/create.ts +24 -0
- package/template/runtime/status-page/index.ts +8 -0
- package/template/schema/index.ts +11 -4
- package/template/schema/monitor.ts +94 -0
- package/template/views/index.ts +8 -2
- package/template/views/status-page.ts +16 -0
- package/template/client/src/lib/kernel.ts +0 -135
- package/template/client/src/lib/shell.ts +0 -197
- package/template/client/src/lib/use-node.ts +0 -66
- package/template/client/src/lib/use-shell.ts +0 -85
- package/template/core/keys.ts +0 -28
- package/template/core/note.ts +0 -148
- package/template/integrations/summary/heuristic.ts +0 -25
- package/template/integrations/summary/http.ts +0 -69
- package/template/integrations/summary/port.ts +0 -21
- package/template/integrations/summary/registry.ts +0 -52
- package/template/schema/note.ts +0 -67
- 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,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
|
+
}
|