@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.
- 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 +79 -62
- package/template/client/__tests__/app.test.tsx +188 -99
- package/template/client/__tests__/harness.ts +67 -12
- package/template/client/__tests__/kernel.test.ts +65 -50
- package/template/client/__tests__/seam.test.tsx +111 -0
- package/template/client/index.html +1 -1
- package/template/client/package.json +1 -0
- package/template/client/src/app.tsx +40 -83
- package/template/client/src/main.tsx +2 -2
- package/template/client/src/monitor/components/MonitorCard.tsx +50 -0
- package/template/client/src/monitor/components/index.ts +1 -0
- package/template/client/src/monitor/hooks/index.ts +3 -0
- package/template/client/src/monitor/hooks/useCheck.mutation.ts +16 -0
- package/template/client/src/monitor/hooks/useMonitor.query.ts +64 -0
- package/template/client/src/monitor/index.ts +6 -0
- package/template/client/src/monitor/monitor.api.ts +11 -0
- package/template/client/src/monitor/monitor.mappers.ts +38 -0
- package/template/client/src/monitor/monitor.types.ts +23 -0
- package/template/client/src/monitor/ui/MonitorDetails.UI.tsx +38 -0
- package/template/client/src/monitor/ui/StatusBadge.UI.tsx +14 -0
- package/template/client/src/monitor/ui/index.ts +8 -0
- 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 +61 -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 +98 -0
- package/template/client/src/styles.css +177 -4
- package/template/client/src/ui/format.ts +24 -0
- package/template/client/src/ui/index.ts +9 -0
- package/template/client/src/ui/surface.tsx +56 -0
- package/template/client/src/ui/value.tsx +32 -0
- package/template/client/src/views/monitor.tsx +30 -0
- package/template/client/tsconfig.json +2 -1
- package/template/client/vite.config.ts +12 -13
- package/template/client/vitest.config.ts +12 -5
- package/template/core/monitor/health.ts +19 -0
- package/template/core/monitor/index.ts +9 -0
- package/template/core/monitor/keys.ts +29 -0
- package/template/core/monitor/node.ts +51 -0
- package/template/deps.ts +8 -8
- package/template/domain.ts +1 -1
- package/template/env.ts +2 -9
- package/template/integrations/prober/http.ts +43 -0
- package/template/integrations/prober/mock.ts +22 -0
- package/template/integrations/prober/port.ts +28 -0
- package/template/integrations/prober/registry.ts +66 -0
- package/template/package.json +1 -1
- package/template/pnpm-lock.yaml +2766 -0
- package/template/runtime/index.ts +36 -19
- package/template/runtime/monitor/check.ts +29 -0
- package/template/runtime/monitor/dependsOn.ts +16 -0
- package/template/runtime/monitor/index.ts +12 -0
- package/template/runtime/monitor/seed.ts +74 -0
- package/template/runtime/monitor/shared.ts +17 -0
- package/template/runtime/monitor/watch.ts +37 -0
- package/template/schema/index.ts +11 -4
- package/template/schema/monitor.ts +80 -0
- package/template/views/index.ts +9 -2
- package/template/views/monitor.ts +22 -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,64 @@
|
|
|
1
|
+
/** Load a Monitor 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 { MonitorRecord } from '../monitor.types'
|
|
7
|
+
|
|
8
|
+
import { monitorFromNode } from '../monitor.mappers'
|
|
9
|
+
|
|
10
|
+
export type MonitorQuery = {
|
|
11
|
+
/** Lifecycle of the underlying `@<id>::get`. */
|
|
12
|
+
state: 'idle' | 'loading' | 'error' | 'ok'
|
|
13
|
+
/** The projected record once `ok`. */
|
|
14
|
+
record?: MonitorRecord
|
|
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 Monitor node `nodeId` and project it to a `MonitorRecord`. 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 useMonitor(session: KernelClient, nodeId: string): MonitorQuery {
|
|
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: monitorFromNode(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,6 @@
|
|
|
1
|
+
/** The Monitor feature — its api, types, mappers, hooks and components. */
|
|
2
|
+
export * from './components'
|
|
3
|
+
export * from './hooks'
|
|
4
|
+
export { check } from './monitor.api'
|
|
5
|
+
export { monitorFromNode, normalizeStatus } from './monitor.mappers'
|
|
6
|
+
export type { MonitorRecord } from './monitor.types'
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** Raw kernel calls for the Monitor feature — node instance methods. */
|
|
2
|
+
import { invokeNode, type KernelClient } from '@/shell'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Run the Monitor's `check` instance method (`@<id>::check`). Probes the target
|
|
6
|
+
* URL server-side and updates the node's `status`/`statusCode`/`latencyMs`/
|
|
7
|
+
* `lastCheckedAt` props; the caller reloads the node to render the fresh values.
|
|
8
|
+
*/
|
|
9
|
+
export function check(session: KernelClient, nodeId: string): Promise<unknown> {
|
|
10
|
+
return invokeNode(session, nodeId, 'check', {})
|
|
11
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/** Node → typed record transforms for the Monitor feature. */
|
|
2
|
+
import { type KernelNode, PROP, readProp, readPropBySuffix } from '@/shell'
|
|
3
|
+
|
|
4
|
+
import type { MonitorRecord, MonitorStatus } from './monitor.types'
|
|
5
|
+
|
|
6
|
+
const lastSegment = (path: string): string => path.split('/').filter(Boolean).pop() ?? path
|
|
7
|
+
|
|
8
|
+
/** Coerce a string prop to a finite number, or `undefined`. */
|
|
9
|
+
function readNumberBySuffix(props: Record<string, unknown>, suffix: string): number | undefined {
|
|
10
|
+
const raw = readPropBySuffix(props, suffix)
|
|
11
|
+
if (raw === undefined) return undefined
|
|
12
|
+
const n = Number(raw)
|
|
13
|
+
return Number.isFinite(n) ? n : undefined
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Normalize the node's `status` prop to the three known states. */
|
|
17
|
+
export function normalizeStatus(raw: string | undefined): MonitorStatus {
|
|
18
|
+
return raw === 'up' || raw === 'down' ? raw : 'unknown'
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Project a Monitor `KernelNode` into a `MonitorRecord`. The name comes from the
|
|
23
|
+
* kernel `Named.name` key; the domain props (`url`/`status`/`statusCode`/
|
|
24
|
+
* `latencyMs`/`lastCheckedAt`) are domain-qualified, so read them by suffix.
|
|
25
|
+
*/
|
|
26
|
+
export function monitorFromNode(node: KernelNode): MonitorRecord {
|
|
27
|
+
const p = node.props ?? {}
|
|
28
|
+
return {
|
|
29
|
+
id: node.id,
|
|
30
|
+
path: node.path ?? '',
|
|
31
|
+
name: readProp(p, PROP.named.name) ?? lastSegment(node.path ?? '') ?? node.id,
|
|
32
|
+
url: readPropBySuffix(p, '.property.url') ?? '',
|
|
33
|
+
status: normalizeStatus(readPropBySuffix(p, '.property.status')),
|
|
34
|
+
statusCode: readNumberBySuffix(p, '.property.statusCode'),
|
|
35
|
+
latencyMs: readNumberBySuffix(p, '.property.latencyMs'),
|
|
36
|
+
lastCheckedAt: readPropBySuffix(p, '.property.lastCheckedAt'),
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/** The Monitor feature's client types. */
|
|
2
|
+
|
|
3
|
+
/** The three known states of a Monitor's normalized status. */
|
|
4
|
+
export type MonitorStatus = 'up' | 'down' | 'unknown'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Client-side projection of a Monitor node. Domain props are read off the
|
|
8
|
+
* kernel node by suffix (see `monitor.mappers.ts`); numeric props are coerced.
|
|
9
|
+
*/
|
|
10
|
+
export type MonitorRecord = {
|
|
11
|
+
id: string
|
|
12
|
+
path: string
|
|
13
|
+
name: string
|
|
14
|
+
url: string
|
|
15
|
+
/** up | down | unknown — normalized from the node's `status` prop. */
|
|
16
|
+
status: MonitorStatus
|
|
17
|
+
/** HTTP status code from the last check, if any. */
|
|
18
|
+
statusCode?: number
|
|
19
|
+
/** Round-trip latency of the last check, in milliseconds. */
|
|
20
|
+
latencyMs?: number
|
|
21
|
+
/** ISO timestamp of the last check, if any. */
|
|
22
|
+
lastCheckedAt?: string
|
|
23
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MonitorDetails — the Monitor record's PRESENTATION, extracted pure. Given a
|
|
3
|
+
* projected `MonitorRecord`, it lays out the StatusBadge + the url/latency/
|
|
4
|
+
* statusCode/last-checked KV rows using the feature-agnostic `@/ui` primitives.
|
|
5
|
+
*
|
|
6
|
+
* Pure: no hooks, no kernel, no data loading — props in, DOM out. The container
|
|
7
|
+
* (`MonitorCard`) owns loading the record and wiring the "Check now" write.
|
|
8
|
+
*/
|
|
9
|
+
import { ExternalLink, KV, Mono, relativeTime } from '@/ui'
|
|
10
|
+
|
|
11
|
+
import type { MonitorRecord } from '../monitor.types'
|
|
12
|
+
|
|
13
|
+
import { StatusBadge } from './StatusBadge.UI'
|
|
14
|
+
|
|
15
|
+
export function MonitorDetails({ record }: { record: MonitorRecord }) {
|
|
16
|
+
return (
|
|
17
|
+
<>
|
|
18
|
+
<div className="status-row">
|
|
19
|
+
<StatusBadge status={record.status} />
|
|
20
|
+
</div>
|
|
21
|
+
|
|
22
|
+
<div className="kv">
|
|
23
|
+
<KV label="url">
|
|
24
|
+
<ExternalLink url={record.url || undefined} />
|
|
25
|
+
</KV>
|
|
26
|
+
<KV label="latency">
|
|
27
|
+
<Mono value={record.latencyMs !== undefined ? `${record.latencyMs}ms` : undefined} />
|
|
28
|
+
</KV>
|
|
29
|
+
<KV label="status code">
|
|
30
|
+
<Mono value={record.statusCode !== undefined ? String(record.statusCode) : undefined} />
|
|
31
|
+
</KV>
|
|
32
|
+
<KV label="last checked">
|
|
33
|
+
<Mono value={relativeTime(record.lastCheckedAt)} title={record.lastCheckedAt} />
|
|
34
|
+
</KV>
|
|
35
|
+
</div>
|
|
36
|
+
</>
|
|
37
|
+
)
|
|
38
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** Monitor status chip — pure presentational; styling lives in `styles.css`. */
|
|
2
|
+
|
|
3
|
+
import type { MonitorStatus } from '../monitor.types'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Color-coded status pill: up = green, down = red, unknown = muted. The label is
|
|
7
|
+
* the uppercased status (`UP` / `DOWN` / `UNKNOWN`). Monitor-specific — it knows
|
|
8
|
+
* the feature's up/down/unknown vocabulary — so it lives in `monitor/ui`, not the
|
|
9
|
+
* feature-agnostic `@/ui` design system.
|
|
10
|
+
*/
|
|
11
|
+
export function StatusBadge({ status }: { status: MonitorStatus }) {
|
|
12
|
+
const label = status === 'up' ? 'UP' : status === 'down' ? 'DOWN' : 'UNKNOWN'
|
|
13
|
+
return <span className={`status-badge status-${status}`}>{label}</span>
|
|
14
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Monitor feature's OWN presentation — monitor-specific, feature-aware UI
|
|
3
|
+
* (it knows the up/down/unknown status vocabulary and the record shape). Pure
|
|
4
|
+
* components, built on the feature-agnostic `@/ui` primitives. Import within the
|
|
5
|
+
* feature: `import { StatusBadge, MonitorDetails } from '../ui'`.
|
|
6
|
+
*/
|
|
7
|
+
export { StatusBadge } from './StatusBadge.UI'
|
|
8
|
+
export { MonitorDetails } from './MonitorDetails.UI'
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Graph/prop helpers for the nodes the kernel returns — reading fully-qualified
|
|
3
|
+
* props and short class names off a `KernelNode`. Pure shaping: the kernel
|
|
4
|
+
* TRANSPORT now lives in `@astrale-os/shell` (`shell.kernel` → `session.call`),
|
|
5
|
+
* so this file no longer speaks the wire.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Prop key constants — the graph stores props with fully-qualified keys
|
|
10
|
+
* (`<domain>:<member>.property.<name>`). The kernel `Named.name` key is fixed
|
|
11
|
+
* and known; domain props (`url`, `status`) are qualified by the (build-time
|
|
12
|
+
* unknown) domain origin — read those by suffix with `readPropBySuffix`.
|
|
13
|
+
*/
|
|
14
|
+
export const PROP = {
|
|
15
|
+
named: {
|
|
16
|
+
name: 'kernel.astrale.ai:interface.Named.property.name',
|
|
17
|
+
},
|
|
18
|
+
} as const
|
|
19
|
+
|
|
20
|
+
export type KernelNode = {
|
|
21
|
+
id: string
|
|
22
|
+
path: string
|
|
23
|
+
class: string | { raw?: string }
|
|
24
|
+
props: Record<string, unknown>
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function readProp(props: Record<string, unknown>, key: string): string | undefined {
|
|
28
|
+
const v = props[key]
|
|
29
|
+
return typeof v === 'string' ? v : undefined
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Read a string prop by key suffix — for domain-qualified props whose full key
|
|
34
|
+
* embeds the (build-time-unknown) domain origin. e.g.
|
|
35
|
+
* `readPropBySuffix(props, '.property.url')`.
|
|
36
|
+
*/
|
|
37
|
+
export function readPropBySuffix(
|
|
38
|
+
props: Record<string, unknown>,
|
|
39
|
+
suffix: string,
|
|
40
|
+
): string | undefined {
|
|
41
|
+
for (const [k, v] of Object.entries(props)) {
|
|
42
|
+
if (k.endsWith(suffix) && typeof v === 'string') return v
|
|
43
|
+
}
|
|
44
|
+
return undefined
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Short class name from a `class.raw` path `/:<domain>:class.<Name>` → `<Name>`. */
|
|
48
|
+
export function classShortName(node: KernelNode): string {
|
|
49
|
+
const raw = (typeof node.class === 'string' ? node.class : node.class?.raw) ?? ''
|
|
50
|
+
const last = raw.split(':').pop() ?? ''
|
|
51
|
+
const dot = last.indexOf('.')
|
|
52
|
+
return dot >= 0 ? last.slice(dot + 1) : last
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Operator-facing message for a failed kernel call. Errors from the shell's
|
|
57
|
+
* kernel client carry a numeric `code` (e.g. `NotFoundError` → `code 3002`) with
|
|
58
|
+
* the raw server text in `message`; surface them as `<code>: <message>` so the
|
|
59
|
+
* code stays visible. Plain errors fall back to their message.
|
|
60
|
+
*/
|
|
61
|
+
export function errorMessage(err: unknown): string {
|
|
62
|
+
if (err instanceof Error) {
|
|
63
|
+
const code = (err as { code?: unknown }).code
|
|
64
|
+
return typeof code === 'number' ? `${code}: ${err.message}` : err.message
|
|
65
|
+
}
|
|
66
|
+
return String(err)
|
|
67
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The kernel boundary — everything for talking to the kernel and the GUI
|
|
3
|
+
* shell, plus the common read/write primitives features build their
|
|
4
|
+
* `.query`/`.mutation` hooks on. Import from the barrel: `@/shell`.
|
|
5
|
+
*/
|
|
6
|
+
export { classShortName, errorMessage, PROP, readProp, readPropBySuffix } from './client'
|
|
7
|
+
export type { KernelNode } from './client'
|
|
8
|
+
export { callMethod, invokeNode, nodeAddr } from './invoke'
|
|
9
|
+
export type { KernelClient } from '@astrale-os/shell'
|
|
10
|
+
export { useNode } from './use-node'
|
|
11
|
+
export type { NodeState } from './use-node'
|
|
12
|
+
export { useShell } from './use-shell'
|
|
13
|
+
export type { ShellState, ShellStatus } from './use-shell'
|
|
14
|
+
export { useAsync } from './use-async'
|
|
15
|
+
export type { AsyncResource, AsyncState } from './use-async'
|
|
16
|
+
export { useCapability } from './use-capability'
|
|
17
|
+
export type { Capability, Phase } from './use-capability'
|
|
18
|
+
export { asNodeArray, linkTargets, qualifiedProp, qualifiedString } from './transformers'
|
|
19
|
+
export { resolveView, ViewFrame } from './view-router'
|
|
20
|
+
export type { ViewComponent, ViewRoutes } from './view-router'
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Kernel invocation primitives — the single place a view reaches the kernel,
|
|
3
|
+
* over the shell's proper `KernelClient` (`session.call`, wired by the sandboxed
|
|
4
|
+
* handshake: fresh delegation token per call, redirects followed). `callMethod`
|
|
5
|
+
* is the generic method-ref call; `invokeNode` is instance dispatch by node id
|
|
6
|
+
* (`@<id>::<method>` — the shell hands views node IDs, not paths); `nodeAddr`
|
|
7
|
+
* picks the stable address for a node.
|
|
8
|
+
*/
|
|
9
|
+
import type { KernelClient } from '@astrale-os/shell'
|
|
10
|
+
|
|
11
|
+
import type { KernelNode } from './client'
|
|
12
|
+
|
|
13
|
+
/** One kernel call through the live session (proper client: fresh token, redirects). */
|
|
14
|
+
export function callMethod(
|
|
15
|
+
session: KernelClient,
|
|
16
|
+
method: string,
|
|
17
|
+
params: Record<string, unknown> = {},
|
|
18
|
+
): Promise<unknown> {
|
|
19
|
+
return session.call(method, params)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Instance-method dispatch by node id. */
|
|
23
|
+
export function invokeNode(
|
|
24
|
+
session: KernelClient,
|
|
25
|
+
nodeId: string,
|
|
26
|
+
method: string,
|
|
27
|
+
params: Record<string, unknown> = {},
|
|
28
|
+
): Promise<unknown> {
|
|
29
|
+
return callMethod(session, `@${nodeId}::${method}`, params)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Stable instance address: tree path when known, else `@id`. */
|
|
33
|
+
export function nodeAddr(node: KernelNode): string {
|
|
34
|
+
return node.path && node.path.startsWith('/') ? node.path : `@${node.id}`
|
|
35
|
+
}
|
|
@@ -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,61 @@
|
|
|
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>(
|
|
33
|
+
perform: (params: P) => Promise<unknown>,
|
|
34
|
+
): Capability<P> {
|
|
35
|
+
const [phase, setPhase] = useState<Phase>('idle')
|
|
36
|
+
const [error, setError] = useState<string | null>(null)
|
|
37
|
+
|
|
38
|
+
const run = useCallback(
|
|
39
|
+
async (params: P): Promise<boolean> => {
|
|
40
|
+
setPhase('running')
|
|
41
|
+
setError(null)
|
|
42
|
+
try {
|
|
43
|
+
await perform(params)
|
|
44
|
+
setPhase('done')
|
|
45
|
+
return true
|
|
46
|
+
} catch (err) {
|
|
47
|
+
setPhase('failed')
|
|
48
|
+
setError(errorMessage(err))
|
|
49
|
+
return false
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
[perform],
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
const reset = useCallback(() => {
|
|
56
|
+
setPhase('idle')
|
|
57
|
+
setError(null)
|
|
58
|
+
}, [])
|
|
59
|
+
|
|
60
|
+
return { phase, error, run, reset } as Capability<P>
|
|
61
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { useCallback, useEffect, useState } from 'react'
|
|
2
|
+
|
|
3
|
+
import type { KernelClient } from '@astrale-os/shell'
|
|
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
|
+
}
|