@astrale-os/adapter-cloudflare 0.3.0 → 0.3.2
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/LICENSE +202 -0
- package/package.json +4 -4
- package/template/.agents/skills/astrale-cli/SKILL.md +633 -0
- package/template/.domain-studio/comments.json +4 -0
- package/template/.env.example +3 -6
- package/template/CLAUDE.md +8 -6
- package/template/README.md +28 -19
- package/template/client/__tests__/app.test.tsx +39 -248
- package/template/client/__tests__/harness.ts +0 -47
- package/template/client/__tests__/shell.test.ts +46 -0
- package/template/client/package.json +1 -1
- package/template/client/src/app.tsx +6 -8
- package/template/client/src/shell/index.ts +1 -0
- package/template/client/src/shell/transformers.ts +36 -33
- package/template/client/src/shell/use-node.ts +21 -13
- package/template/client/src/ui/index.ts +4 -7
- package/template/core/README.md +23 -0
- package/template/deps.ts +10 -9
- package/template/domain.ts +11 -9
- package/template/env.ts +3 -5
- package/template/functions/index.ts +14 -25
- package/template/icons.ts +25 -0
- package/template/integrations/README.md +22 -0
- package/template/package.json +3 -3
- package/template/runtime/index.ts +25 -72
- package/template/schema/index.ts +10 -10
- package/template/tsconfig.json +1 -0
- package/template/views/index.ts +15 -12
- package/template/client/__tests__/kernel.test.ts +0 -77
- package/template/client/__tests__/seam.test.tsx +0 -115
- package/template/client/src/status/components/StatusCard.tsx +0 -160
- package/template/client/src/status/components/index.ts +0 -1
- package/template/client/src/status/hooks/index.ts +0 -3
- package/template/client/src/status/hooks/useCheck.mutation.ts +0 -16
- package/template/client/src/status/hooks/useCheckable.query.ts +0 -72
- package/template/client/src/status/index.ts +0 -7
- package/template/client/src/status/status.api.ts +0 -29
- package/template/client/src/status/status.mappers.ts +0 -102
- package/template/client/src/status/status.types.ts +0 -26
- package/template/client/src/ui/StatusBadge.tsx +0 -31
- package/template/client/src/views/status.tsx +0 -28
- package/template/core/monitor/health.ts +0 -34
- package/template/core/monitor/index.ts +0 -9
- package/template/core/monitor/keys.ts +0 -41
- package/template/core/monitor/node.ts +0 -63
- package/template/integrations/prober/http.ts +0 -32
- package/template/integrations/prober/mock.ts +0 -18
- package/template/integrations/prober/port.ts +0 -26
- package/template/integrations/prober/registry.ts +0 -65
- package/template/runtime/monitoring/index.ts +0 -8
- package/template/runtime/monitoring/monitor/check.ts +0 -29
- package/template/runtime/monitoring/monitor/index.ts +0 -10
- package/template/runtime/monitoring/monitor/seed.ts +0 -104
- package/template/runtime/monitoring/monitor/watch.ts +0 -31
- package/template/runtime/monitoring/page/add.ts +0 -21
- package/template/runtime/monitoring/page/check.ts +0 -50
- package/template/runtime/monitoring/page/create.ts +0 -24
- package/template/runtime/monitoring/page/index.ts +0 -9
- package/template/runtime/shared.ts +0 -21
- package/template/schema/monitor.ts +0 -92
- package/template/views/status-page.ts +0 -16
- package/template/views/welcome.ts +0 -35
package/template/README.md
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
# astrale-domain
|
|
2
2
|
|
|
3
|
-
|
|
4
3
|
A standalone [Astrale](https://astrale.ai) domain, deployed as an Astrale-managed
|
|
5
4
|
service (default) or as a worker on your own Cloudflare account.
|
|
6
5
|
|
|
6
|
+
This is a **blank skeleton** — every folder is wired up and ready, but the domain
|
|
7
|
+
ships with no classes, interfaces, views, or integrations. Fill in the terrain;
|
|
8
|
+
nothing here is a demo you have to delete.
|
|
9
|
+
|
|
7
10
|
## For AI agents
|
|
8
11
|
|
|
9
12
|
The complete domain-authoring guide is the `astrale-domain` skill (schema
|
|
@@ -30,30 +33,36 @@ astrale domain install <url> --direct # mount it on an instance (CLI)
|
|
|
30
33
|
pnpm prod # deploy prod → prints a URL
|
|
31
34
|
```
|
|
32
35
|
|
|
36
|
+
A freshly-scaffolded skeleton deploys and installs as-is (an empty domain with no
|
|
37
|
+
members) — then you add classes and handlers.
|
|
38
|
+
|
|
33
39
|
## What you write
|
|
34
40
|
|
|
35
41
|
```
|
|
36
|
-
schema/ classes + interfaces (the data model) + compiled accessors
|
|
37
|
-
|
|
42
|
+
schema/ classes + interfaces + edges (the data model) + compiled accessors
|
|
43
|
+
icons.ts class icons (raw SVG), attached via nodeClass({ icon })
|
|
44
|
+
core/<context>/ pure, transport-agnostic logic per bounded context
|
|
38
45
|
integrations/ external-API ports + adapters + a lazy registry (built into deps)
|
|
39
46
|
runtime/ the execute() handlers — the composition root (the methods map)
|
|
40
|
-
views/ iframe-mountable UIs
|
|
41
|
-
functions/ standalone Functions (e.g. webhooks)
|
|
47
|
+
views/ iframe-mountable UIs (inline HTML or a client/ SPA)
|
|
48
|
+
functions/ standalone Functions (e.g. webhooks, a postInstall seed)
|
|
42
49
|
client/ the SPA served under /ui
|
|
43
50
|
deps.ts env → typed Deps container (what handlers read as ctx.deps)
|
|
44
51
|
domain.ts wires it all together — the worker-safe definition
|
|
45
52
|
astrale.config.ts binds the domain to its deploy adapter (node-only)
|
|
46
53
|
```
|
|
47
54
|
|
|
48
|
-
`
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
`
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
55
|
+
`schema/`, `runtime/`, `views/`, and `functions/` start with empty registries;
|
|
56
|
+
`core/` and `integrations/` are empty folders with a short README each explaining
|
|
57
|
+
the pattern. `domain.ts` is the one place the domain contract is declared: it
|
|
58
|
+
imports the modules above and passes them to `defineDomain({ schema, methods,
|
|
59
|
+
deps, views, functions, … })` alongside the identity (`origin`, `requires`,
|
|
60
|
+
`postInstall`). Nothing is discovered by folder name — a renamed or mistyped
|
|
61
|
+
module is a compile error at that call, never a silently-missing route. Drop a
|
|
62
|
+
field (`deps`, `views`, `functions`) when the domain has none. `astrale.config.ts`
|
|
63
|
+
then binds that domain to a deploy adapter with `deploy(domain, cloudflare({ … }))`
|
|
64
|
+
and chooses, per env, which `client` assets serve mounted `/ui` views — keeping
|
|
65
|
+
the node-only adapter (wrangler, filesystem, frontend builds) out of the worker
|
|
57
66
|
bundle.
|
|
58
67
|
|
|
59
68
|
Everything else — the Worker entry, the wrangler config, the signing identity —
|
|
@@ -97,11 +106,11 @@ adapter-owned keys `name`, `main`, `assets`, `routes` are rejected (use
|
|
|
97
106
|
|
|
98
107
|
- **Edit a handler** (`core/` logic or `runtime/` wiring) → hot-reloads at the same URL. Nothing to reinstall.
|
|
99
108
|
- **Edit the schema** (`schema/`) → rebuilds the graph; reinstall with `astrale domain install <url> --direct`.
|
|
100
|
-
- **`postInstall`**
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
109
|
+
- **`postInstall`** — author a standalone `seed` function in `functions/`, reference
|
|
110
|
+
it from `domain.ts` (`postInstall: functions.seed`), and the kernel runs it once
|
|
111
|
+
after install, as the system identity, so the domain can lay down its initial
|
|
112
|
+
graph and set its own grants. Functions are first-class domain members,
|
|
113
|
+
addressable at `/:origin:function.seed`.
|
|
105
114
|
|
|
106
115
|
## Identity
|
|
107
116
|
|
|
@@ -1,275 +1,66 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { cleanup, render, renderHook, screen, waitFor } from '@testing-library/react'
|
|
2
2
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
|
3
3
|
|
|
4
4
|
import { App } from '@/app'
|
|
5
|
+
import { useShell } from '@/shell'
|
|
5
6
|
|
|
6
|
-
import {
|
|
7
|
-
|
|
8
|
-
const KERNEL_URL = 'https://k.example.test/api'
|
|
7
|
+
import { installFakeParent, type FakeParent } from './harness'
|
|
9
8
|
|
|
10
9
|
/**
|
|
11
|
-
* The router picks a view from `window.location.pathname
|
|
12
|
-
*
|
|
13
|
-
*
|
|
10
|
+
* The router picks a view from `window.location.pathname`. This skeleton has no
|
|
11
|
+
* views registered, so every path falls through to the fallback — that's the
|
|
12
|
+
* test below. The second block exercises the shell handshake plumbing (via the
|
|
13
|
+
* fake parent) so you know the seam works the moment you add a real view.
|
|
14
|
+
*
|
|
15
|
+
* `replaceState` rewrites the happy-dom location without a reload.
|
|
14
16
|
*/
|
|
15
17
|
function mountAt(path: string) {
|
|
16
18
|
window.history.replaceState({}, '', path)
|
|
17
19
|
}
|
|
18
20
|
|
|
19
21
|
beforeEach(() => {
|
|
20
|
-
mountAt('/ui/
|
|
21
|
-
})
|
|
22
|
-
|
|
23
|
-
afterEach(() => {
|
|
24
|
-
cleanup()
|
|
22
|
+
mountAt('/ui/anything')
|
|
25
23
|
})
|
|
26
24
|
|
|
27
|
-
|
|
28
|
-
* Let the handshake + any kernel fetches settle, flushing the resulting React
|
|
29
|
-
* state updates inside `act`. The handshake completes over a real MessagePort
|
|
30
|
-
* (async hops), so a microtask isn't enough — a short timer pump is.
|
|
31
|
-
*/
|
|
32
|
-
async function flush(ms = 20) {
|
|
33
|
-
await act(async () => {
|
|
34
|
-
await new Promise((r) => setTimeout(r, ms))
|
|
35
|
-
})
|
|
36
|
-
}
|
|
25
|
+
afterEach(cleanup)
|
|
37
26
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
delegationToken: 'tok-A',
|
|
44
|
-
tokenExpiresAt: Date.now() + 3_600_000,
|
|
45
|
-
targetNodeId,
|
|
27
|
+
describe('router fallback (no views registered)', () => {
|
|
28
|
+
it('renders the self-describing fallback for any path', () => {
|
|
29
|
+
render(<App />)
|
|
30
|
+
expect(screen.getByText('No view here')).toBeTruthy()
|
|
31
|
+
expect(screen.getByText(/No view registered for/)).toBeTruthy()
|
|
46
32
|
})
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/** A StatusPage node fixture — its rolled-up `status`, no `url`. */
|
|
50
|
-
const page = (id: string, status: string, name = 'Public status') =>
|
|
51
|
-
checkableNode({ id, name, status, className: 'StatusPage' })
|
|
52
|
-
|
|
53
|
-
const MONITOR_PATH = '/monitoring/monitors/astrale'
|
|
54
|
-
|
|
55
|
-
const watchLinks = (critical = true) => [
|
|
56
|
-
{
|
|
57
|
-
class: { raw: '/:monitors.astrale.ai:class.watches' },
|
|
58
|
-
target: { raw: MONITOR_PATH },
|
|
59
|
-
props: { 'monitors.astrale.ai:class.watches.property.critical': critical },
|
|
60
|
-
},
|
|
61
|
-
]
|
|
62
|
-
|
|
63
|
-
const monitor = (status: string, statusCode = 200) =>
|
|
64
|
-
checkableNode({
|
|
65
|
-
id: 'monitor-1',
|
|
66
|
-
path: MONITOR_PATH,
|
|
67
|
-
name: 'Astrale homepage',
|
|
68
|
-
status,
|
|
69
|
-
statusCode,
|
|
70
|
-
latencyMs: 84,
|
|
71
|
-
lastCheckedAt: '2026-06-14T11:55:00Z',
|
|
72
|
-
url: 'https://astrale.ai',
|
|
73
|
-
className: 'Monitor',
|
|
74
|
-
})
|
|
75
|
-
|
|
76
|
-
describe('status view (/ui/status-page)', () => {
|
|
77
|
-
it('loads the StatusPage node and renders its watched monitors', async () => {
|
|
78
|
-
const parent = handshake('page-1')
|
|
79
|
-
const kernel = installFakeKernel((body) => {
|
|
80
|
-
if (body.method === '@page-1::get') return ok(page('page-1', 'degraded'))
|
|
81
|
-
if (body.method === '@page-1::getLinks') return ok(watchLinks())
|
|
82
|
-
if (body.method === `${MONITOR_PATH}::get`) return ok(monitor('up'))
|
|
83
|
-
return { error: { code: 3002, message: `unexpected: ${body.method}` } }
|
|
84
|
-
})
|
|
85
|
-
|
|
86
|
-
try {
|
|
87
|
-
render(<App />)
|
|
88
|
-
await flush()
|
|
89
|
-
|
|
90
|
-
// Parent observed the child's handshakeAck.
|
|
91
|
-
await expect(parent.ack).resolves.toBeUndefined()
|
|
92
|
-
|
|
93
|
-
expect(screen.getByText('Public status')).toBeTruthy()
|
|
94
|
-
const badge = screen.getByText('DEGRADED')
|
|
95
|
-
expect(badge.className).toContain('status-degraded')
|
|
96
|
-
expect(screen.getByText('Astrale homepage')).toBeTruthy()
|
|
97
|
-
expect(screen.getByText('astrale.ai')).toBeTruthy()
|
|
98
|
-
expect(screen.getByText('Critical')).toBeTruthy()
|
|
99
|
-
expect(screen.getByText('84ms')).toBeTruthy()
|
|
100
|
-
|
|
101
|
-
expect(kernel.calls.map((c) => c.body.method)).toEqual([
|
|
102
|
-
'@page-1::get',
|
|
103
|
-
'@page-1::getLinks',
|
|
104
|
-
`${MONITOR_PATH}::get`,
|
|
105
|
-
])
|
|
106
|
-
} finally {
|
|
107
|
-
kernel.restore()
|
|
108
|
-
parent.restore()
|
|
109
|
-
}
|
|
110
|
-
})
|
|
111
|
-
|
|
112
|
-
it('clicks "Check now", issues @<id>::check, and reflects the refreshed status', async () => {
|
|
113
|
-
const parent = handshake('page-1')
|
|
114
|
-
// First ::get is down; ::check rolls up server-side; the post-check ::get is up.
|
|
115
|
-
let checked = false
|
|
116
|
-
const kernel = installFakeKernel((body) => {
|
|
117
|
-
if (body.method === '@page-1::check') {
|
|
118
|
-
checked = true
|
|
119
|
-
return ok(null)
|
|
120
|
-
}
|
|
121
|
-
if (body.method === '@page-1::getLinks') return ok(watchLinks())
|
|
122
|
-
if (body.method === `${MONITOR_PATH}::get`) return ok(monitor(checked ? 'up' : 'down'))
|
|
123
|
-
return ok(page('page-1', checked ? 'up' : 'down'))
|
|
124
|
-
})
|
|
125
|
-
|
|
126
|
-
try {
|
|
127
|
-
render(<App />)
|
|
128
|
-
await flush()
|
|
129
|
-
|
|
130
|
-
expect(screen.getAllByText('DOWN').length).toBeGreaterThan(0)
|
|
131
|
-
expect(kernel.calls).toHaveLength(3)
|
|
132
|
-
expect(kernel.calls[0]!.body.method).toBe('@page-1::get')
|
|
133
|
-
|
|
134
|
-
await act(async () => {
|
|
135
|
-
fireEvent.click(screen.getByText('Check now'))
|
|
136
|
-
})
|
|
137
|
-
await flush()
|
|
138
|
-
|
|
139
|
-
// A ::check call was issued, then the node was re-fetched.
|
|
140
|
-
expect(kernel.calls.map((c) => c.body.method)).toEqual([
|
|
141
|
-
'@page-1::get',
|
|
142
|
-
'@page-1::getLinks',
|
|
143
|
-
`${MONITOR_PATH}::get`,
|
|
144
|
-
'@page-1::check',
|
|
145
|
-
'@page-1::get',
|
|
146
|
-
'@page-1::getLinks',
|
|
147
|
-
`${MONITOR_PATH}::get`,
|
|
148
|
-
])
|
|
149
|
-
expect(screen.getAllByText('UP').length).toBeGreaterThan(0)
|
|
150
|
-
} finally {
|
|
151
|
-
kernel.restore()
|
|
152
|
-
parent.restore()
|
|
153
|
-
}
|
|
154
|
-
})
|
|
155
|
-
|
|
156
|
-
it('surfaces a check failure without losing the loaded record', async () => {
|
|
157
|
-
const parent = handshake('page-1')
|
|
158
|
-
const kernel = installFakeKernel((body) => {
|
|
159
|
-
if (body.method === '@page-1::check') {
|
|
160
|
-
return { error: { code: 2004, message: 'Permission denied' } }
|
|
161
|
-
}
|
|
162
|
-
if (body.method === '@page-1::getLinks') return ok(watchLinks())
|
|
163
|
-
if (body.method === `${MONITOR_PATH}::get`) return ok(monitor('up'))
|
|
164
|
-
return ok(page('page-1', 'up'))
|
|
165
|
-
})
|
|
166
|
-
|
|
167
|
-
try {
|
|
168
|
-
render(<App />)
|
|
169
|
-
await flush()
|
|
170
|
-
|
|
171
|
-
await act(async () => {
|
|
172
|
-
fireEvent.click(screen.getByText('Check now'))
|
|
173
|
-
})
|
|
174
|
-
await flush()
|
|
175
|
-
|
|
176
|
-
// The proper client maps the error code to a typed error, so the operator
|
|
177
|
-
// sees the clean server message (no `<code>:` prefix).
|
|
178
|
-
expect(screen.getByText(/Check failed:/)).toBeTruthy()
|
|
179
|
-
expect(screen.getByText(/Permission denied/)).toBeTruthy()
|
|
180
|
-
// The record is still shown.
|
|
181
|
-
expect(screen.getByText('Public status')).toBeTruthy()
|
|
182
|
-
} finally {
|
|
183
|
-
kernel.restore()
|
|
184
|
-
parent.restore()
|
|
185
|
-
}
|
|
186
|
-
})
|
|
187
|
-
|
|
188
|
-
it('renders the kernel error on the node-load error path', async () => {
|
|
189
|
-
const parent = handshake('missing-1')
|
|
190
|
-
const kernel = installFakeKernel(() => ({ error: { code: 3002, message: 'Path not found' } }))
|
|
33
|
+
})
|
|
191
34
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
await flush()
|
|
35
|
+
describe('shell handshake plumbing', () => {
|
|
36
|
+
let parent: FakeParent | undefined
|
|
195
37
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
kernel.restore()
|
|
200
|
-
parent.restore()
|
|
201
|
-
}
|
|
38
|
+
afterEach(() => {
|
|
39
|
+
parent?.restore()
|
|
40
|
+
parent = undefined
|
|
202
41
|
})
|
|
203
42
|
|
|
204
|
-
it('
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
43
|
+
it('reaches `ready` with a kernel session after the parent handshake', async () => {
|
|
44
|
+
parent = installFakeParent({
|
|
45
|
+
windowId: 'w-1',
|
|
46
|
+
kernelUrl: 'https://k.example.test/api',
|
|
47
|
+
functionId: 'fn-1',
|
|
48
|
+
delegationToken: 'tok-A',
|
|
49
|
+
tokenExpiresAt: Date.now() + 60_000,
|
|
50
|
+
targetNodeId: 'node-1',
|
|
211
51
|
})
|
|
212
52
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
// Parent pushes a new target node.
|
|
220
|
-
parent.setTarget('page-2')
|
|
221
|
-
await flush()
|
|
222
|
-
|
|
223
|
-
expect(screen.getByText('Internal status')).toBeTruthy()
|
|
224
|
-
expect(screen.getByText('DOWN')).toBeTruthy()
|
|
225
|
-
} finally {
|
|
226
|
-
kernel.restore()
|
|
227
|
-
parent.restore()
|
|
228
|
-
}
|
|
229
|
-
})
|
|
230
|
-
|
|
231
|
-
it('explains a missing target instead of a blank screen', async () => {
|
|
232
|
-
const parent = handshake(undefined)
|
|
233
|
-
const kernel = installFakeKernel(() => ok(null))
|
|
234
|
-
try {
|
|
235
|
-
render(<App />)
|
|
236
|
-
await flush()
|
|
237
|
-
|
|
238
|
-
expect(screen.getByText(/No target node/)).toBeTruthy()
|
|
239
|
-
expect(kernel.calls).toHaveLength(0)
|
|
240
|
-
} finally {
|
|
241
|
-
kernel.restore()
|
|
242
|
-
parent.restore()
|
|
243
|
-
}
|
|
53
|
+
const { result } = renderHook(() => useShell())
|
|
54
|
+
// waitFor polls inside `act`, so the handshake's async state updates land
|
|
55
|
+
// wrapped — the parent's `ack` promise need not be awaited separately.
|
|
56
|
+
await waitFor(() => expect(result.current.status).toBe('ready'))
|
|
57
|
+
expect(result.current.session).not.toBeNull()
|
|
58
|
+
expect(result.current.nodeId).toBe('node-1')
|
|
244
59
|
})
|
|
245
60
|
|
|
246
61
|
it('falls back to a standalone preview with no parent', async () => {
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
render(<App />)
|
|
251
|
-
await flush()
|
|
252
|
-
|
|
253
|
-
expect(screen.getByText(/No parent shell/)).toBeTruthy()
|
|
254
|
-
expect(kernel.calls).toHaveLength(0)
|
|
255
|
-
} finally {
|
|
256
|
-
kernel.restore()
|
|
257
|
-
}
|
|
258
|
-
})
|
|
259
|
-
})
|
|
260
|
-
|
|
261
|
-
describe('router fallback', () => {
|
|
262
|
-
it('renders "No view registered" for an unregistered path', async () => {
|
|
263
|
-
mountAt('/ui/nope')
|
|
264
|
-
const kernel = installFakeKernel(() => ok(null))
|
|
265
|
-
try {
|
|
266
|
-
render(<App />)
|
|
267
|
-
await flush()
|
|
268
|
-
|
|
269
|
-
expect(screen.getByText(/No view registered for \/ui\/nope/)).toBeTruthy()
|
|
270
|
-
expect(kernel.calls).toHaveLength(0)
|
|
271
|
-
} finally {
|
|
272
|
-
kernel.restore()
|
|
273
|
-
}
|
|
62
|
+
const { result } = renderHook(() => useShell())
|
|
63
|
+
await waitFor(() => expect(result.current.status).toBe('standalone'))
|
|
64
|
+
expect(result.current.session).toBeNull()
|
|
274
65
|
})
|
|
275
66
|
})
|
|
@@ -238,50 +238,3 @@ export function fakeKernelSession(kernelUrl = 'https://k.example.test/api'): Ker
|
|
|
238
238
|
url: kernelUrl,
|
|
239
239
|
}
|
|
240
240
|
}
|
|
241
|
-
|
|
242
|
-
/**
|
|
243
|
-
* Build a minimal kernel `Checkable` node — a StatusPage (or any class via
|
|
244
|
-
* `className`, default `StatusPage`). Carries a domain-qualified `status` prop
|
|
245
|
-
* (`<domain>:class.<className>.property.status`); the name uses the kernel
|
|
246
|
-
* `Named.name` key.
|
|
247
|
-
*/
|
|
248
|
-
export function checkableNode(opts: {
|
|
249
|
-
id: string
|
|
250
|
-
path?: string
|
|
251
|
-
name?: string
|
|
252
|
-
status?: string
|
|
253
|
-
url?: string
|
|
254
|
-
statusCode?: number
|
|
255
|
-
latencyMs?: number
|
|
256
|
-
lastCheckedAt?: string
|
|
257
|
-
className?: string
|
|
258
|
-
domain?: string
|
|
259
|
-
}): { id: string; path: string; class: { raw: string }; props: Record<string, unknown> } {
|
|
260
|
-
const domain = opts.domain ?? 'monitors.astrale.ai'
|
|
261
|
-
const className = opts.className ?? 'StatusPage'
|
|
262
|
-
const props: Record<string, unknown> = {}
|
|
263
|
-
if (opts.name !== undefined) {
|
|
264
|
-
props['kernel.astrale.ai:interface.Named.property.name'] = opts.name
|
|
265
|
-
}
|
|
266
|
-
if (opts.status !== undefined) {
|
|
267
|
-
props[`${domain}:class.${className}.property.status`] = opts.status
|
|
268
|
-
}
|
|
269
|
-
if (opts.url !== undefined) {
|
|
270
|
-
props[`${domain}:class.${className}.property.url`] = opts.url
|
|
271
|
-
}
|
|
272
|
-
if (opts.statusCode !== undefined) {
|
|
273
|
-
props[`${domain}:class.${className}.property.statusCode`] = opts.statusCode
|
|
274
|
-
}
|
|
275
|
-
if (opts.latencyMs !== undefined) {
|
|
276
|
-
props[`${domain}:class.${className}.property.latencyMs`] = opts.latencyMs
|
|
277
|
-
}
|
|
278
|
-
if (opts.lastCheckedAt !== undefined) {
|
|
279
|
-
props[`${domain}:class.${className}.property.lastCheckedAt`] = opts.lastCheckedAt
|
|
280
|
-
}
|
|
281
|
-
return {
|
|
282
|
-
id: opts.id,
|
|
283
|
-
path: opts.path ?? `/${opts.id}`,
|
|
284
|
-
class: { raw: `/:${domain}:class.${className}` },
|
|
285
|
-
props,
|
|
286
|
-
}
|
|
287
|
-
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The kernel-boundary plumbing every view builds on, tested with ZERO
|
|
3
|
+
* infrastructure — no shell, no kernel, no session. Props in, values out. These
|
|
4
|
+
* are the helpers `@/shell` exposes for reading qualified node props (which the
|
|
5
|
+
* kernel stores under domain-prefixed keys) plus the shared display formatters.
|
|
6
|
+
* Your feature mappers (`<feature>.mappers.ts`) build on these.
|
|
7
|
+
*/
|
|
8
|
+
import { describe, expect, it } from 'vitest'
|
|
9
|
+
|
|
10
|
+
import { qualifiedString, readProp, readPropBySuffix } from '@/shell'
|
|
11
|
+
import { relativeTime } from '@/ui'
|
|
12
|
+
|
|
13
|
+
describe('prop readers', () => {
|
|
14
|
+
it('readProp returns the string value at the exact key', () => {
|
|
15
|
+
expect(readProp({ a: 'x', b: 1 }, 'a')).toBe('x')
|
|
16
|
+
expect(readProp({ a: 'x' }, 'missing')).toBeUndefined()
|
|
17
|
+
expect(readProp({ a: 1 }, 'a')).toBeUndefined() // non-string
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('readPropBySuffix matches the first key ending with the suffix', () => {
|
|
21
|
+
const props = {
|
|
22
|
+
'acme.example.dev:class.Widget.property.label': 'Hello',
|
|
23
|
+
'kernel.astrale.ai:interface.Named.property.name': 'n',
|
|
24
|
+
}
|
|
25
|
+
expect(readPropBySuffix(props, '.property.label')).toBe('Hello')
|
|
26
|
+
expect(readPropBySuffix(props, '.property.status')).toBeUndefined()
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('qualifiedString prefers a domain key over the kernel one for the same suffix', () => {
|
|
30
|
+
const props = {
|
|
31
|
+
'kernel.astrale.ai:interface.Named.property.name': 'kernel name',
|
|
32
|
+
'acme.example.dev:class.Widget.property.name': 'domain name',
|
|
33
|
+
}
|
|
34
|
+
expect(qualifiedString(props, 'name')).toBe('domain name')
|
|
35
|
+
})
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
describe('relativeTime', () => {
|
|
39
|
+
const now = Date.parse('2026-06-14T12:00:00Z')
|
|
40
|
+
it('renders coarse buckets and a dash for missing input', () => {
|
|
41
|
+
expect(relativeTime(undefined, now)).toBe('—')
|
|
42
|
+
expect(relativeTime('2026-06-14T11:55:00Z', now)).toBe('5m ago')
|
|
43
|
+
expect(relativeTime('2026-06-14T10:00:00Z', now)).toBe('2h ago')
|
|
44
|
+
expect(relativeTime('not-a-date', now)).toBe('not-a-date')
|
|
45
|
+
})
|
|
46
|
+
})
|
|
@@ -4,19 +4,17 @@
|
|
|
4
4
|
* `window.location.pathname` IS that path. `App` reads it (via `resolveView`) and
|
|
5
5
|
* renders the matching view; an unregistered path shows a self-describing fallback.
|
|
6
6
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* 1. writing a `ViewComponent` (usually wrapping `ViewFrame` — see `
|
|
7
|
+
* `ROUTES` starts EMPTY — this is a blank skeleton, so every path falls through to
|
|
8
|
+
* `UnknownView`. Add a view by
|
|
9
|
+
* 1. writing a `ViewComponent` (usually wrapping `ViewFrame` — see `@/shell`),
|
|
10
10
|
* 2. adding a `ROUTES` entry keyed by its mount path,
|
|
11
|
-
* 3. registering a matching `defineView({ mount: '/ui/<path>' })` in
|
|
11
|
+
* 3. registering a matching `defineView({ mount: '/ui/<path>' })` in the domain's
|
|
12
|
+
* `views/`.
|
|
12
13
|
*/
|
|
13
14
|
|
|
14
15
|
import { resolveView, useShell, type ViewRoutes } from '@/shell'
|
|
15
|
-
import { StatusView } from '@/views/status'
|
|
16
16
|
|
|
17
|
-
const ROUTES: ViewRoutes = {
|
|
18
|
-
'/ui/status-page': StatusView,
|
|
19
|
-
}
|
|
17
|
+
const ROUTES: ViewRoutes = {}
|
|
20
18
|
|
|
21
19
|
export function App() {
|
|
22
20
|
const shell = useShell()
|
|
@@ -16,5 +16,6 @@ export type { AsyncResource, AsyncState } from './use-async'
|
|
|
16
16
|
export { useCapability } from './use-capability'
|
|
17
17
|
export type { Capability, Phase } from './use-capability'
|
|
18
18
|
export { asNodeArray, linkTargets, qualifiedProp, qualifiedString } from './transformers'
|
|
19
|
+
export type { GraphEdge } from './transformers'
|
|
19
20
|
export { resolveView, ViewFrame } from './view-router'
|
|
20
21
|
export type { ViewComponent, ViewRoutes } from './view-router'
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Graph-
|
|
3
|
-
*
|
|
4
|
-
* Domain-neutral: per-class node→record mappers
|
|
5
|
-
* `*.mappers.ts` and build on `qualifiedString` here.
|
|
2
|
+
* Graph-shape helpers at the kernel boundary — turn `shell.kernel.graph` results
|
|
3
|
+
* (`graph.node` / `graph.children` / `graph.links`, backed by `function.get`)
|
|
4
|
+
* into the shapes features consume. Domain-neutral: per-class node→record mappers
|
|
5
|
+
* live in each feature's `*.mappers.ts` and build on `qualifiedString` here.
|
|
6
|
+
*
|
|
7
|
+
* The graph sugar already returns typed records (`{ nodes }`, `{ edges }`) — no
|
|
8
|
+
* envelope-sniffing needed — so these are thin projections over those shapes.
|
|
6
9
|
*
|
|
7
10
|
* Props are stored under QUALIFIED keys
|
|
8
11
|
* (`monitors.astrale.ai:class.Monitor.property.url`). The client must not import
|
|
@@ -13,6 +16,15 @@
|
|
|
13
16
|
*/
|
|
14
17
|
import type { KernelNode } from './client'
|
|
15
18
|
|
|
19
|
+
/** One incident edge as `shell.kernel.graph.links` returns it (a `GraphEdgeWire`). */
|
|
20
|
+
export type GraphEdge = {
|
|
21
|
+
class: string
|
|
22
|
+
source: string
|
|
23
|
+
target: string
|
|
24
|
+
slug?: string | null
|
|
25
|
+
props?: Record<string, unknown>
|
|
26
|
+
}
|
|
27
|
+
|
|
16
28
|
/** Resolve a prop by `.property.<name>` suffix; domain keys win over kernel ones. */
|
|
17
29
|
export function qualifiedProp(props: Record<string, unknown>, name: string): unknown {
|
|
18
30
|
const suffix = `.property.${name}`
|
|
@@ -33,40 +45,31 @@ export function qualifiedString(props: Record<string, unknown>, name: string): s
|
|
|
33
45
|
return typeof v === 'string' && v !== '' ? v : undefined
|
|
34
46
|
}
|
|
35
47
|
|
|
36
|
-
type RawLink = { class?: unknown; edgeClass?: unknown; target?: unknown; to?: unknown }
|
|
37
|
-
|
|
38
48
|
/**
|
|
39
|
-
* Targets of
|
|
40
|
-
* (e.g. `'monitor_on_host'`).
|
|
41
|
-
*
|
|
49
|
+
* Targets of `graph.links` edges whose edge class ends with `classTail`
|
|
50
|
+
* (e.g. `'monitor_on_host'`). Accepts the `graph.links` result (`{ edges }`) or a
|
|
51
|
+
* bare `GraphEdge[]`.
|
|
42
52
|
*/
|
|
43
|
-
export function linkTargets(
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
53
|
+
export function linkTargets(
|
|
54
|
+
edges: GraphEdge[] | { edges: GraphEdge[] } | null | undefined,
|
|
55
|
+
classTail: string,
|
|
56
|
+
): string[] {
|
|
57
|
+
const list: GraphEdge[] = Array.isArray(edges) ? edges : (edges?.edges ?? [])
|
|
47
58
|
const out: string[] = []
|
|
48
|
-
for (const
|
|
49
|
-
|
|
50
|
-
|
|
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)
|
|
59
|
+
for (const edge of list) {
|
|
60
|
+
if (typeof edge.class !== 'string' || !edge.class.endsWith(classTail)) continue
|
|
61
|
+
if (typeof edge.target === 'string') out.push(edge.target)
|
|
63
62
|
}
|
|
64
63
|
return out
|
|
65
64
|
}
|
|
66
65
|
|
|
67
|
-
/**
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
66
|
+
/**
|
|
67
|
+
* The nodes of a `graph.children` / `graph.tree` result. Accepts the sugar result
|
|
68
|
+
* (`{ nodes }`) or a bare `KernelNode[]`.
|
|
69
|
+
*/
|
|
70
|
+
export function asNodeArray(
|
|
71
|
+
result: KernelNode[] | { nodes: KernelNode[] } | null | undefined,
|
|
72
|
+
): KernelNode[] {
|
|
73
|
+
if (Array.isArray(result)) return result
|
|
74
|
+
return result?.nodes ?? []
|
|
72
75
|
}
|