@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,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,98 @@
|
|
|
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/monitor`). 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 { ReactNode } from 'react'
|
|
20
|
+
|
|
21
|
+
import type { KernelClient } from '@astrale-os/shell'
|
|
22
|
+
|
|
23
|
+
import type { ShellState } from './use-shell'
|
|
24
|
+
|
|
25
|
+
/** A view: given the shell state, render its tree. */
|
|
26
|
+
export type ViewComponent = (shell: ShellState) => ReactNode
|
|
27
|
+
|
|
28
|
+
/** Mount path → view component (key e.g. `/ui/monitor`). */
|
|
29
|
+
export type ViewRoutes = Record<string, ViewComponent>
|
|
30
|
+
|
|
31
|
+
/** Strip a single trailing slash (but keep a bare `/`). */
|
|
32
|
+
function normalizePath(pathname: string): string {
|
|
33
|
+
return pathname.length > 1 && pathname.endsWith('/') ? pathname.slice(0, -1) : pathname
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Resolve the view for a mount path — exact match, tolerating a trailing slash.
|
|
38
|
+
* Returns `undefined` when no route is registered (caller renders a fallback).
|
|
39
|
+
*/
|
|
40
|
+
export function resolveView(
|
|
41
|
+
routes: ViewRoutes,
|
|
42
|
+
pathname: string = window.location.pathname,
|
|
43
|
+
): ViewComponent | undefined {
|
|
44
|
+
return routes[normalizePath(pathname)]
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Shared shell-handshake gate. Renders the `.wrap`/`.card` shell and:
|
|
49
|
+
* - `loading` → title + subline + "Waiting for the shell handshake…"
|
|
50
|
+
* - `standalone` → title + subline + the "No parent shell" banner copy
|
|
51
|
+
* - `ready` → `children(session, nodeId)` (session is non-null here)
|
|
52
|
+
*
|
|
53
|
+
* Mirrors the markup/classes the original single-view app used, so styling is
|
|
54
|
+
* unchanged across views.
|
|
55
|
+
*/
|
|
56
|
+
export function ViewFrame({
|
|
57
|
+
shell,
|
|
58
|
+
title,
|
|
59
|
+
subline,
|
|
60
|
+
children,
|
|
61
|
+
}: {
|
|
62
|
+
shell: ShellState
|
|
63
|
+
title: string
|
|
64
|
+
subline?: string
|
|
65
|
+
children: (session: KernelClient, nodeId: string | undefined) => ReactNode
|
|
66
|
+
}) {
|
|
67
|
+
const { status, session, nodeId, reason } = shell
|
|
68
|
+
|
|
69
|
+
return (
|
|
70
|
+
<div className="wrap">
|
|
71
|
+
<div className="card">
|
|
72
|
+
{status === 'loading' && (
|
|
73
|
+
<>
|
|
74
|
+
<h1 className="title">{title}</h1>
|
|
75
|
+
{subline && <p className="subline">{subline}</p>}
|
|
76
|
+
<p className="muted">Waiting for the shell handshake…</p>
|
|
77
|
+
</>
|
|
78
|
+
)}
|
|
79
|
+
|
|
80
|
+
{status === 'standalone' && (
|
|
81
|
+
<>
|
|
82
|
+
<h1 className="title">{title}</h1>
|
|
83
|
+
{subline && <p className="subline">{subline}</p>}
|
|
84
|
+
<div className="banner">
|
|
85
|
+
No parent shell ({reason}). This view is meant to be mounted by the Astrale GUI, which
|
|
86
|
+
hands it a target node and a kernel session. Showing a standalone preview.
|
|
87
|
+
</div>
|
|
88
|
+
<p className="body muted">
|
|
89
|
+
When mounted by the shell, this card renders the node you opened.
|
|
90
|
+
</p>
|
|
91
|
+
</>
|
|
92
|
+
)}
|
|
93
|
+
|
|
94
|
+
{status === 'ready' && session && children(session, nodeId)}
|
|
95
|
+
</div>
|
|
96
|
+
</div>
|
|
97
|
+
)
|
|
98
|
+
}
|
|
@@ -3,11 +3,19 @@
|
|
|
3
3
|
--fg: #171717;
|
|
4
4
|
--muted: #737373;
|
|
5
5
|
--border: #e5e5e5;
|
|
6
|
+
--border-strong: #d4d4d8;
|
|
6
7
|
--card: #ffffff;
|
|
7
8
|
--accent: #2563eb;
|
|
9
|
+
--accent-soft: rgba(37, 99, 235, 0.1);
|
|
8
10
|
--warn-bg: #fffbeb;
|
|
9
11
|
--warn-border: #fde68a;
|
|
10
12
|
--warn-fg: #92400e;
|
|
13
|
+
--err: #dc2626;
|
|
14
|
+
--err-soft: rgba(220, 38, 38, 0.1);
|
|
15
|
+
--up: #16a34a;
|
|
16
|
+
--down: #dc2626;
|
|
17
|
+
--unknown: #737373;
|
|
18
|
+
--mono: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
11
19
|
}
|
|
12
20
|
|
|
13
21
|
* {
|
|
@@ -49,7 +57,7 @@ body {
|
|
|
49
57
|
}
|
|
50
58
|
|
|
51
59
|
.subline {
|
|
52
|
-
font-family:
|
|
60
|
+
font-family: var(--mono);
|
|
53
61
|
font-size: 0.72rem;
|
|
54
62
|
color: var(--muted);
|
|
55
63
|
margin: 0.25rem 0 1rem;
|
|
@@ -61,6 +69,8 @@ body {
|
|
|
61
69
|
margin: 0 0 1rem;
|
|
62
70
|
}
|
|
63
71
|
|
|
72
|
+
/* ─── Banners ───────────────────────────────────────────────────────────── */
|
|
73
|
+
|
|
64
74
|
.banner {
|
|
65
75
|
border: 1px solid var(--warn-border);
|
|
66
76
|
background: var(--warn-bg);
|
|
@@ -71,11 +81,57 @@ body {
|
|
|
71
81
|
margin-bottom: 1rem;
|
|
72
82
|
}
|
|
73
83
|
|
|
84
|
+
.banner-error {
|
|
85
|
+
border-color: var(--err);
|
|
86
|
+
background: var(--err-soft);
|
|
87
|
+
color: var(--err);
|
|
88
|
+
word-break: break-word;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/* ─── Panel ─────────────────────────────────────────────────────────────── */
|
|
92
|
+
|
|
93
|
+
.panel {
|
|
94
|
+
background: var(--card);
|
|
95
|
+
border: 1px solid var(--border);
|
|
96
|
+
border-radius: 0.7rem;
|
|
97
|
+
padding: 1.05rem 1.2rem;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
.panel-danger {
|
|
101
|
+
border-color: color-mix(in srgb, var(--err) 45%, var(--border));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
.panel-head {
|
|
105
|
+
display: flex;
|
|
106
|
+
align-items: center;
|
|
107
|
+
justify-content: space-between;
|
|
108
|
+
gap: 0.75rem;
|
|
109
|
+
margin-bottom: 0.85rem;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
.panel-title {
|
|
113
|
+
font-size: 1.1rem;
|
|
114
|
+
font-weight: 650;
|
|
115
|
+
margin: 0;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
.panel-danger .panel-title {
|
|
119
|
+
color: var(--err);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
.panel-actions {
|
|
123
|
+
display: inline-flex;
|
|
124
|
+
align-items: center;
|
|
125
|
+
gap: 0.5rem;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/* ─── Key/value ─────────────────────────────────────────────────────────── */
|
|
129
|
+
|
|
74
130
|
.kv {
|
|
75
131
|
border: 1px solid var(--border);
|
|
76
132
|
border-radius: 0.5rem;
|
|
77
133
|
overflow: hidden;
|
|
78
|
-
font-family:
|
|
134
|
+
font-family: var(--mono);
|
|
79
135
|
font-size: 0.72rem;
|
|
80
136
|
}
|
|
81
137
|
|
|
@@ -97,14 +153,131 @@ body {
|
|
|
97
153
|
|
|
98
154
|
.kv-val {
|
|
99
155
|
word-break: break-all;
|
|
156
|
+
min-width: 0;
|
|
100
157
|
}
|
|
101
158
|
|
|
102
159
|
.muted {
|
|
103
160
|
color: var(--muted);
|
|
104
161
|
}
|
|
105
162
|
|
|
106
|
-
.
|
|
163
|
+
.mono {
|
|
164
|
+
font-family: var(--mono);
|
|
107
165
|
font-size: 0.78rem;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
.link {
|
|
169
|
+
color: var(--accent);
|
|
170
|
+
text-decoration: none;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
.link:hover {
|
|
174
|
+
text-decoration: underline;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/* ─── Status row + badge ────────────────────────────────────────────────── */
|
|
178
|
+
|
|
179
|
+
.status-row {
|
|
180
|
+
display: flex;
|
|
181
|
+
align-items: center;
|
|
182
|
+
justify-content: space-between;
|
|
183
|
+
gap: 0.75rem;
|
|
184
|
+
margin: 0 0 1rem;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
.status-badge {
|
|
188
|
+
display: inline-block;
|
|
189
|
+
font-weight: 700;
|
|
190
|
+
font-size: 1rem;
|
|
191
|
+
letter-spacing: 0.04em;
|
|
192
|
+
padding: 0.3rem 0.85rem;
|
|
193
|
+
border-radius: 0.5rem;
|
|
194
|
+
color: #ffffff;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
.status-up {
|
|
198
|
+
background: var(--up);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
.status-down {
|
|
202
|
+
background: var(--down);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
.status-unknown {
|
|
206
|
+
background: var(--unknown);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/* ─── Buttons ───────────────────────────────────────────────────────────── */
|
|
210
|
+
|
|
211
|
+
.check-btn {
|
|
212
|
+
font: inherit;
|
|
108
213
|
font-weight: 600;
|
|
109
|
-
|
|
214
|
+
color: #ffffff;
|
|
215
|
+
background: var(--accent);
|
|
216
|
+
border: 0;
|
|
217
|
+
border-radius: 0.5rem;
|
|
218
|
+
padding: 0.4rem 0.85rem;
|
|
219
|
+
cursor: pointer;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
.check-btn:hover:not(:disabled) {
|
|
223
|
+
opacity: 0.9;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
.check-btn:disabled {
|
|
227
|
+
opacity: 0.55;
|
|
228
|
+
cursor: default;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/* ─── Empty state ───────────────────────────────────────────────────────── */
|
|
232
|
+
|
|
233
|
+
.empty {
|
|
234
|
+
border: 1px dashed var(--border-strong);
|
|
235
|
+
border-radius: 0.6rem;
|
|
236
|
+
padding: 1.4rem 1.2rem;
|
|
237
|
+
text-align: center;
|
|
238
|
+
display: flex;
|
|
239
|
+
flex-direction: column;
|
|
240
|
+
gap: 0.6rem;
|
|
241
|
+
align-items: center;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
.empty-text {
|
|
245
|
+
margin: 0;
|
|
246
|
+
color: var(--muted);
|
|
247
|
+
font-size: 0.85rem;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
.empty-hint {
|
|
251
|
+
font-family: var(--mono);
|
|
252
|
+
font-size: 0.72rem;
|
|
253
|
+
color: var(--muted);
|
|
254
|
+
background: var(--bg);
|
|
255
|
+
border: 1px solid var(--border);
|
|
256
|
+
border-radius: 0.4rem;
|
|
257
|
+
padding: 0.3rem 0.6rem;
|
|
258
|
+
word-break: break-all;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/* ─── Loading ───────────────────────────────────────────────────────────── */
|
|
262
|
+
|
|
263
|
+
.loading-line {
|
|
264
|
+
display: flex;
|
|
265
|
+
align-items: center;
|
|
266
|
+
gap: 0.5rem;
|
|
267
|
+
margin: 0;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
.spinner {
|
|
271
|
+
width: 0.85rem;
|
|
272
|
+
height: 0.85rem;
|
|
273
|
+
border-radius: 50%;
|
|
274
|
+
border: 2px solid var(--border-strong);
|
|
275
|
+
border-top-color: var(--accent);
|
|
276
|
+
animation: spin 0.8s linear infinite;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
@keyframes spin {
|
|
280
|
+
to {
|
|
281
|
+
transform: rotate(360deg);
|
|
282
|
+
}
|
|
110
283
|
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/** Shared display formatters — pure, presentation-side (used across views). */
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Coarse relative time for `lastCheckedAt`-style ISO stamps:
|
|
5
|
+
* `just now` / `5m ago` / `2h ago` / a `YYYY-MM-DD` date for anything older than
|
|
6
|
+
* 30 days. Returns `—` for a missing stamp and the raw string for an unparseable
|
|
7
|
+
* one.
|
|
8
|
+
*/
|
|
9
|
+
export function relativeTime(iso: string | undefined, now = Date.now()): string {
|
|
10
|
+
if (!iso) return '—'
|
|
11
|
+
const t = Date.parse(iso)
|
|
12
|
+
if (Number.isNaN(t)) return iso
|
|
13
|
+
const diff = now - t
|
|
14
|
+
if (diff < 0) return 'just now'
|
|
15
|
+
const s = Math.floor(diff / 1000)
|
|
16
|
+
if (s < 45) return 'just now'
|
|
17
|
+
const m = Math.floor(s / 60)
|
|
18
|
+
if (m < 60) return `${m}m ago`
|
|
19
|
+
const h = Math.floor(m / 60)
|
|
20
|
+
if (h < 48) return `${h}h ago`
|
|
21
|
+
const d = Math.floor(h / 24)
|
|
22
|
+
if (d < 30) return `${d}d ago`
|
|
23
|
+
return new Date(t).toISOString().slice(0, 10)
|
|
24
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The design system — generic, feature-AGNOSTIC presentational primitives +
|
|
3
|
+
* display formatters, no domain knowledge and no kernel session. Feature-specific
|
|
4
|
+
* UI (e.g. the Monitor's StatusBadge) lives in that feature's own `ui/`, not here.
|
|
5
|
+
* Import from the barrel: `import { Panel, KV, relativeTime } from '@/ui'`.
|
|
6
|
+
*/
|
|
7
|
+
export { EmptyState, ErrorBanner, Panel, Spinner } from './surface'
|
|
8
|
+
export { ExternalLink, KV, Mono } from './value'
|
|
9
|
+
export { relativeTime } from './format'
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/** Containers + feedback surfaces — pure presentational. */
|
|
2
|
+
import type { ReactNode } from 'react'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A titled card section. `actions` render in the header (e.g. a button); `tone:
|
|
6
|
+
* 'danger'` tints the border/title for destructive panels.
|
|
7
|
+
*/
|
|
8
|
+
export function Panel({
|
|
9
|
+
title,
|
|
10
|
+
actions,
|
|
11
|
+
tone,
|
|
12
|
+
children,
|
|
13
|
+
}: {
|
|
14
|
+
title: string
|
|
15
|
+
actions?: ReactNode
|
|
16
|
+
tone?: 'danger'
|
|
17
|
+
children: ReactNode
|
|
18
|
+
}) {
|
|
19
|
+
return (
|
|
20
|
+
<section className={tone === 'danger' ? 'panel panel-danger' : 'panel'}>
|
|
21
|
+
<header className="panel-head">
|
|
22
|
+
<h2 className="panel-title">{title}</h2>
|
|
23
|
+
{actions && <div className="panel-actions">{actions}</div>}
|
|
24
|
+
</header>
|
|
25
|
+
{children}
|
|
26
|
+
</section>
|
|
27
|
+
)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** A red alert banner for a failed call. */
|
|
31
|
+
export function ErrorBanner({ children }: { children: ReactNode }) {
|
|
32
|
+
return (
|
|
33
|
+
<div className="banner banner-error" role="alert">
|
|
34
|
+
{children}
|
|
35
|
+
</div>
|
|
36
|
+
)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Teaching empty state — what's missing and an optional command that fixes it. */
|
|
40
|
+
export function EmptyState({ children, hint }: { children: ReactNode; hint?: string }) {
|
|
41
|
+
return (
|
|
42
|
+
<div className="empty">
|
|
43
|
+
<p className="empty-text">{children}</p>
|
|
44
|
+
{hint && <code className="empty-hint">{hint}</code>}
|
|
45
|
+
</div>
|
|
46
|
+
)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** A spinner with a label, for in-flight loads. */
|
|
50
|
+
export function Spinner({ label }: { label: string }) {
|
|
51
|
+
return (
|
|
52
|
+
<p className="muted loading-line">
|
|
53
|
+
<span className="spinner" aria-hidden="true" /> {label}
|
|
54
|
+
</p>
|
|
55
|
+
)
|
|
56
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/** Inline value renderers — pure presentational. */
|
|
2
|
+
import type { ReactNode } from 'react'
|
|
3
|
+
|
|
4
|
+
/** One label/value row in a `.kv` grid. */
|
|
5
|
+
export function KV({ label, children }: { label: string; children: ReactNode }) {
|
|
6
|
+
return (
|
|
7
|
+
<div className="kv-row">
|
|
8
|
+
<div className="kv-key">{label}</div>
|
|
9
|
+
<div className="kv-val">{children}</div>
|
|
10
|
+
</div>
|
|
11
|
+
)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Monospace inline value with a soft em-dash fallback. */
|
|
15
|
+
export function Mono({ value, title }: { value: string | undefined; title?: string }) {
|
|
16
|
+
if (!value) return <span className="muted">—</span>
|
|
17
|
+
return (
|
|
18
|
+
<span className="mono" title={title ?? value}>
|
|
19
|
+
{value}
|
|
20
|
+
</span>
|
|
21
|
+
)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** An external link rendered without its scheme, opening in a new tab. */
|
|
25
|
+
export function ExternalLink({ url }: { url: string | undefined }) {
|
|
26
|
+
if (!url) return <span className="muted">—</span>
|
|
27
|
+
return (
|
|
28
|
+
<a className="mono link" href={url} target="_blank" rel="noreferrer noopener" title={url}>
|
|
29
|
+
{url.replace(/^https?:\/\//, '')}
|
|
30
|
+
</a>
|
|
31
|
+
)
|
|
32
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `ui-monitor` view (mount path `/ui/monitor`) — the Monitor detail panel.
|
|
3
|
+
*
|
|
4
|
+
* Mounted by the Astrale shell as a sandboxed iframe; `@/shell` (built on the
|
|
5
|
+
* real `@astrale-os/shell`) completes the handshake and hands over the kernel
|
|
6
|
+
* session + the target node id. `ViewFrame` gates the handshake (loading /
|
|
7
|
+
* standalone / ready); the `ready` body delegates to `MonitorCard`, the feature
|
|
8
|
+
* container that loads the node, renders its status/url/latency, and exposes a
|
|
9
|
+
* "Check now" probe.
|
|
10
|
+
*
|
|
11
|
+
* Pure composition — no data/transport logic lives here (that's `@/monitor` +
|
|
12
|
+
* `@/shell`).
|
|
13
|
+
*/
|
|
14
|
+
import { MonitorCard } from '@/monitor'
|
|
15
|
+
import { type ShellState, ViewFrame } from '@/shell'
|
|
16
|
+
|
|
17
|
+
export function MonitorView(shell: ShellState) {
|
|
18
|
+
return (
|
|
19
|
+
<ViewFrame shell={shell} title="Status monitor" subline="ui-monitor · Astrale view SPA">
|
|
20
|
+
{(session, nodeId) =>
|
|
21
|
+
nodeId ? (
|
|
22
|
+
// Keyed by node id so a target hot-swap remounts with fresh state.
|
|
23
|
+
<MonitorCard key={nodeId} session={session} nodeId={nodeId} />
|
|
24
|
+
) : (
|
|
25
|
+
<div className="banner">No target Monitor — open this view from a Monitor node.</div>
|
|
26
|
+
)
|
|
27
|
+
}
|
|
28
|
+
</ViewFrame>
|
|
29
|
+
)
|
|
30
|
+
}
|
|
@@ -1,30 +1,29 @@
|
|
|
1
|
+
import { fileURLToPath } from 'node:url'
|
|
2
|
+
|
|
1
3
|
import viteReact from '@vitejs/plugin-react'
|
|
2
4
|
import { defineConfig } from 'vite'
|
|
3
5
|
|
|
4
6
|
/**
|
|
5
|
-
* Client SPA for the domain's
|
|
6
|
-
*
|
|
7
|
+
* Client SPA for the domain's Views. Built into `../.dist/`, served by the
|
|
8
|
+
* generated worker via its `ASSETS` binding (`.astrale/`).
|
|
7
9
|
*
|
|
8
10
|
* `base: '/ui/'` + `outDir: '../.dist'`: Vite emits asset refs as
|
|
9
11
|
* `/ui/assets/<hash>.js`; the worker strips the `/ui` prefix before delegating
|
|
10
12
|
* to `ASSETS`, so files resolve from the directory root. `index.html` is the
|
|
11
|
-
* SPA fallback for `/ui/<anything
|
|
13
|
+
* SPA fallback for `/ui/<anything>` — `src/app.tsx` routes on the mount path.
|
|
12
14
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* pushed credential is picked up) and a minimal JSON kernel client
|
|
18
|
-
* (`src/lib/kernel.ts`, `@<id>::get`). Tests live in `__tests__/` and run on
|
|
19
|
-
* `vitest`/`happy-dom` (see `vitest.config.ts`) — vite never bundles them.
|
|
15
|
+
* Consumes `@astrale-os/shell` for the child handshake + the real kernel client:
|
|
16
|
+
* `src/shell/use-shell.ts` boots `createShell({ mode: 'sandboxed' })` and the
|
|
17
|
+
* feature hooks call kernel methods through `shell.kernel` (token refresh, codec
|
|
18
|
+
* negotiation, redirect following, delegation — all handled by the SDK).
|
|
20
19
|
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
* no writes. See `README.md`.
|
|
20
|
+
* `@` is aliased to `src/` (mirrors tsconfig `paths`) so feature code imports
|
|
21
|
+
* `@/shell`, `@/ui`, `@/monitor`.
|
|
24
22
|
*/
|
|
25
23
|
export default defineConfig({
|
|
26
24
|
base: '/ui/',
|
|
27
25
|
plugins: [viteReact()],
|
|
26
|
+
resolve: { alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) } },
|
|
28
27
|
build: {
|
|
29
28
|
outDir: '../.dist',
|
|
30
29
|
emptyOutDir: true,
|
|
@@ -1,18 +1,25 @@
|
|
|
1
|
+
import { fileURLToPath } from 'node:url'
|
|
2
|
+
|
|
1
3
|
import viteReact from '@vitejs/plugin-react'
|
|
2
4
|
import { defineConfig } from 'vitest/config'
|
|
3
5
|
|
|
4
6
|
/**
|
|
5
|
-
* Vitest config for the
|
|
6
|
-
* the
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* options.
|
|
7
|
+
* Vitest config for the client SPA. A DOM env (`happy-dom`) backs the React
|
|
8
|
+
* render + the shell handshake the tests exercise. Kept separate from
|
|
9
|
+
* `vite.config.ts` (which sets the `/ui/` base + `../.dist` build) so tests
|
|
10
|
+
* don't inherit the SPA build options.
|
|
10
11
|
*/
|
|
11
12
|
export default defineConfig({
|
|
12
13
|
plugins: [viteReact()],
|
|
14
|
+
resolve: { alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) } },
|
|
13
15
|
test: {
|
|
14
16
|
environment: 'happy-dom',
|
|
15
17
|
globals: true,
|
|
16
18
|
include: ['__tests__/**/*.test.{ts,tsx}', 'src/**/*.test.{ts,tsx}'],
|
|
19
|
+
// `@astrale-os/*` ship bundler-targeted ESM (extensionless relative imports).
|
|
20
|
+
// vitest externalizes node_modules and uses Node's stricter ESM resolver,
|
|
21
|
+
// which wants explicit `.js`; inlining the scope makes vitest transform them
|
|
22
|
+
// through vite — the same context the build uses. Standard for these deps.
|
|
23
|
+
server: { deps: { inline: [/@astrale-os\//] } },
|
|
17
24
|
},
|
|
18
25
|
})
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Health logic — PURE status rule, no I/O. Maps an observed probe result to the
|
|
3
|
+
* monitor's verdict. (Node identity/layout/seed data lives in `./node`; the
|
|
4
|
+
* kernel reads/writes live in `runtime/monitor/`; the network probe behind the
|
|
5
|
+
* `Prober` port in `integrations/prober/`.)
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** A monitor's health verdict. `unknown` is the pre-first-check state. */
|
|
9
|
+
export type HealthStatus = 'up' | 'down' | 'unknown'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Map an observed HTTP status code to a verdict. `0` means the host was
|
|
13
|
+
* unreachable (DNS/timeout/refused) — that's `down`. 2xx/3xx is `up`; anything
|
|
14
|
+
* else (4xx/5xx) is `down`. Pure.
|
|
15
|
+
*/
|
|
16
|
+
export function classify(statusCode: number): HealthStatus {
|
|
17
|
+
if (statusCode >= 200 && statusCode < 400) return 'up'
|
|
18
|
+
return 'down'
|
|
19
|
+
}
|