@astrale-os/adapter-cloudflare 0.1.10 → 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/client/README.md +25 -23
- package/template/client/__tests__/app.test.tsx +44 -88
- package/template/client/__tests__/harness.ts +11 -16
- package/template/client/__tests__/kernel.test.ts +9 -35
- package/template/client/__tests__/seam.test.tsx +22 -18
- package/template/client/src/app.tsx +11 -17
- package/template/client/src/shell/use-capability.ts +1 -3
- package/template/client/src/shell/use-node.ts +2 -2
- package/template/client/src/shell/view-router.tsx +3 -4
- 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/{monitor → status}/hooks/useCheck.mutation.ts +2 -2
- package/template/client/src/{monitor/hooks/useMonitor.query.ts → status/hooks/useCheckable.query.ts} +8 -8
- 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 +5 -0
- package/template/client/src/ui/StatusBadge.tsx +31 -0
- package/template/client/src/ui/index.ts +6 -2
- package/template/client/src/views/status.tsx +28 -0
- package/template/client/vite.config.ts +2 -3
- package/template/client/vitest.config.ts +1 -2
- package/template/core/monitor/health.ts +19 -4
- package/template/core/monitor/keys.ts +14 -2
- package/template/core/monitor/node.ts +27 -21
- package/template/deps.ts +2 -1
- package/template/integrations/prober/http.ts +4 -15
- package/template/integrations/prober/mock.ts +1 -5
- package/template/integrations/prober/port.ts +0 -2
- package/template/integrations/prober/registry.ts +6 -7
- package/template/package.json +1 -1
- package/template/runtime/index.ts +51 -39
- package/template/runtime/monitor/check.ts +9 -9
- package/template/runtime/monitor/index.ts +4 -7
- package/template/runtime/monitor/seed.ts +67 -46
- package/template/runtime/monitor/watch.ts +6 -12
- package/template/runtime/{monitor/shared.ts → shared.ts} +7 -3
- 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 +5 -5
- package/template/schema/monitor.ts +62 -48
- package/template/views/index.ts +4 -5
- package/template/views/status-page.ts +16 -0
- package/template/client/src/monitor/components/MonitorCard.tsx +0 -50
- package/template/client/src/monitor/components/index.ts +0 -1
- package/template/client/src/monitor/hooks/index.ts +0 -3
- package/template/client/src/monitor/index.ts +0 -6
- package/template/client/src/monitor/monitor.api.ts +0 -11
- package/template/client/src/monitor/monitor.mappers.ts +0 -38
- package/template/client/src/monitor/monitor.types.ts +0 -23
- package/template/client/src/monitor/ui/MonitorDetails.UI.tsx +0 -38
- package/template/client/src/monitor/ui/StatusBadge.UI.tsx +0 -14
- package/template/client/src/monitor/ui/index.ts +0 -8
- package/template/client/src/views/monitor.tsx +0 -30
- package/template/runtime/monitor/dependsOn.ts +0 -16
- package/template/views/monitor.ts +0 -22
package/package.json
CHANGED
|
@@ -91,7 +91,7 @@ Examples:
|
|
|
91
91
|
|
|
92
92
|
```bash
|
|
93
93
|
astrale call /:blog.acme.com:class.Author:list
|
|
94
|
-
astrale call /:blog.acme.com:
|
|
94
|
+
astrale call /:blog.acme.com:class.Monitor:watch url=https://astrale.ai
|
|
95
95
|
astrale call /blog.acme.com/alice::deactivate
|
|
96
96
|
astrale call @f00d...::deactivate
|
|
97
97
|
```
|
|
@@ -3,9 +3,10 @@
|
|
|
3
3
|
A small React + Vite SPA that renders the domain's Views. It is loaded inside an
|
|
4
4
|
iframe mounted by the Astrale shell, runs the shell handshake (via the real
|
|
5
5
|
`@astrale-os/shell`) to learn its target node id and a kernel session, fetches
|
|
6
|
-
that node from the kernel, and renders it. The bundled
|
|
7
|
-
Monitor
|
|
8
|
-
|
|
6
|
+
that node from the kernel, and renders it. The bundled status view loads a
|
|
7
|
+
`Checkable` node (a Monitor or a StatusPage — both carry a `status` prop and a
|
|
8
|
+
`::check` method) and renders its status, with a "Check now" button that calls
|
|
9
|
+
the node's `check` instance method and reloads the node. Built into `../.dist/`
|
|
9
10
|
and served by the generated worker (`.astrale/`) under `/ui/*` via its `ASSETS`
|
|
10
11
|
binding.
|
|
11
12
|
|
|
@@ -16,18 +17,19 @@ client: `src/shell/use-shell.ts` boots `createShell({ mode: 'sandboxed' })`, and
|
|
|
16
17
|
feature hooks call kernel methods through `shell.kernel` (token refresh, codec
|
|
17
18
|
negotiation, redirect following, and delegation are all handled by the SDK — no
|
|
18
19
|
inline wire code). `@` is aliased to `src/` (mirrors `tsconfig` paths), so feature
|
|
19
|
-
code imports `@/shell`, `@/ui`, `@/
|
|
20
|
+
code imports `@/shell`, `@/ui`, `@/status`.
|
|
20
21
|
|
|
21
22
|
## Layout
|
|
22
23
|
|
|
23
24
|
Feature-first: each feature owns its types/api/mappers/hooks/components. The
|
|
24
25
|
`shell/` and `ui/` folders are the generic, domain-neutral seams every feature
|
|
25
|
-
builds on.
|
|
26
|
+
builds on. The template ships ONE feature, `status/`, over a `Checkable` node —
|
|
27
|
+
one card loads the node and exposes its status + a "Check now" action.
|
|
26
28
|
|
|
27
29
|
```
|
|
28
30
|
src/
|
|
29
31
|
main.tsx # entry → createRoot(App)
|
|
30
|
-
app.tsx # path router: ROUTES { '/ui/
|
|
32
|
+
app.tsx # path router: ROUTES { '/ui/status-page': StatusView }
|
|
31
33
|
styles.css # self-contained styles (no Tailwind), design tokens
|
|
32
34
|
shell/ # GENERIC kernel/shell adapter on @astrale-os/shell
|
|
33
35
|
client.ts # prop readers (PROP, readProp, readPropBySuffix) + errors
|
|
@@ -41,23 +43,23 @@ src/
|
|
|
41
43
|
index.ts # barrel → @/shell
|
|
42
44
|
ui/ # PURE presentation — no kernel, no hooks
|
|
43
45
|
surface.tsx # Panel / ErrorBanner / EmptyState / Spinner
|
|
44
|
-
|
|
46
|
+
StatusBadge.tsx # StatusBadge (up | degraded | down | unknown)
|
|
45
47
|
value.tsx # KV / Mono / ExternalLink
|
|
46
48
|
format.ts # relativeTime
|
|
47
49
|
index.ts # barrel → @/ui
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
hooks/ #
|
|
53
|
-
components/ #
|
|
54
|
-
index.ts # barrel → @/
|
|
50
|
+
status/ # THE status feature — polymorphic over Checkable nodes
|
|
51
|
+
status.types.ts # CheckableRecord
|
|
52
|
+
status.api.ts # check(session, id) → @<id>::check
|
|
53
|
+
status.mappers.ts # checkableFromNode (node → record)
|
|
54
|
+
hooks/ # useCheckable.query / useCheck.mutation
|
|
55
|
+
components/ # StatusCard (container: hooks + @/ui)
|
|
56
|
+
index.ts # barrel → @/status
|
|
55
57
|
views/
|
|
56
|
-
|
|
58
|
+
status.tsx # StatusView = <ViewFrame>…<StatusCard/></ViewFrame>
|
|
57
59
|
__tests__/
|
|
58
60
|
harness.ts # fake shell parent (real protocol) + fake kernel (fetch stub)
|
|
59
61
|
app.test.tsx # handshake → render → check now → setTarget → fallback
|
|
60
|
-
seam.test.tsx # pure @/ui + @/
|
|
62
|
+
seam.test.tsx # pure @/ui + @/status hooks in isolation (no handshake)
|
|
61
63
|
kernel.test.ts # pure helpers: prop readers, mapper, relativeTime
|
|
62
64
|
```
|
|
63
65
|
|
|
@@ -81,14 +83,14 @@ the generated worker forwards `/ui/*` to vite.
|
|
|
81
83
|
and the `targetNodeId`). `useShell()` boots `@astrale-os/shell`, surfaces the
|
|
82
84
|
kernel session, and tracks `setTarget` hot-swaps.
|
|
83
85
|
3. A feature loads its node through the session: `useNode(session, nodeId)` calls
|
|
84
|
-
`@<id>::get` over `shell.kernel`, and a mapper (`
|
|
85
|
-
to a typed record. Domain props are read by key SUFFIX (`.property.
|
|
86
|
-
because the domain origin is unknown at build time; the name uses the kernel
|
|
86
|
+
`@<id>::get` over `shell.kernel`, and a mapper (`checkableFromNode`) projects
|
|
87
|
+
it to a typed record. Domain props are read by key SUFFIX (`.property.status`,
|
|
88
|
+
…) because the domain origin is unknown at build time; the name uses the kernel
|
|
87
89
|
`Named.name` key.
|
|
88
90
|
4. Writes go through `useCapability`: "Check now" runs `@<id>::check`, then calls
|
|
89
|
-
`
|
|
90
|
-
|
|
91
|
-
|
|
91
|
+
`useCheckable`'s `reload()` so the fresh status re-renders. The shell's kernel
|
|
92
|
+
client owns the credential and refreshes it before expiry — features never
|
|
93
|
+
touch the token.
|
|
92
94
|
|
|
93
95
|
## Adding a view
|
|
94
96
|
|
|
@@ -97,6 +99,6 @@ the generated worker forwards `/ui/*` to vite.
|
|
|
97
99
|
3. Register a matching `defineView({ mount: '/ui/<path>' })` in the domain's
|
|
98
100
|
`views/` so the shell mounts an iframe there.
|
|
99
101
|
|
|
100
|
-
For a NEW feature, mirror `src/
|
|
102
|
+
For a NEW feature, mirror `src/status/`: a `*.types.ts`, `*.api.ts`,
|
|
101
103
|
`*.mappers.ts`, a `hooks/` folder of `.query`/`.mutation` hooks, and a
|
|
102
104
|
`components/` folder of containers that compose `@/ui`.
|
|
@@ -2,21 +2,22 @@ import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
|
|
|
2
2
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
|
3
3
|
|
|
4
4
|
import { App } from '@/app'
|
|
5
|
-
|
|
5
|
+
|
|
6
|
+
import { checkableNode, installFakeKernel, installFakeParent, ok } from './harness'
|
|
6
7
|
|
|
7
8
|
const KERNEL_URL = 'https://k.example.test/api'
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* The router picks a view from `window.location.pathname`, so every test mounts
|
|
11
|
-
* at
|
|
12
|
-
*
|
|
12
|
+
* at the StatusPage view's path. `replaceState` rewrites the happy-dom location
|
|
13
|
+
* without a reload; the fallback test overrides it.
|
|
13
14
|
*/
|
|
14
15
|
function mountAt(path: string) {
|
|
15
16
|
window.history.replaceState({}, '', path)
|
|
16
17
|
}
|
|
17
18
|
|
|
18
19
|
beforeEach(() => {
|
|
19
|
-
mountAt('/ui/
|
|
20
|
+
mountAt('/ui/status-page')
|
|
20
21
|
})
|
|
21
22
|
|
|
22
23
|
afterEach(() => {
|
|
@@ -38,30 +39,22 @@ function handshake(targetNodeId?: string) {
|
|
|
38
39
|
return installFakeParent({
|
|
39
40
|
windowId: 'win-1',
|
|
40
41
|
kernelUrl: KERNEL_URL,
|
|
41
|
-
functionId: 'ui-
|
|
42
|
+
functionId: 'ui-status-page',
|
|
42
43
|
delegationToken: 'tok-A',
|
|
43
44
|
tokenExpiresAt: Date.now() + 3_600_000,
|
|
44
45
|
targetNodeId,
|
|
45
46
|
})
|
|
46
47
|
}
|
|
47
48
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
+
describe('status view (/ui/status-page)', () => {
|
|
54
|
+
it('loads the StatusPage node and renders its name + rolled-up status', async () => {
|
|
55
|
+
const parent = handshake('page-1')
|
|
51
56
|
const kernel = installFakeKernel((body) => {
|
|
52
|
-
if (body.method === '@
|
|
53
|
-
return ok(
|
|
54
|
-
monitorNode({
|
|
55
|
-
id: 'mon-1',
|
|
56
|
-
name: 'API health',
|
|
57
|
-
url: 'https://api.example.test/health',
|
|
58
|
-
status: 'up',
|
|
59
|
-
statusCode: '200',
|
|
60
|
-
latencyMs: '42',
|
|
61
|
-
lastCheckedAt: '2026-06-14T10:00:00Z',
|
|
62
|
-
}),
|
|
63
|
-
)
|
|
64
|
-
}
|
|
57
|
+
if (body.method === '@page-1::get') return ok(page('page-1', 'degraded'))
|
|
65
58
|
return { error: { code: 3002, message: `unexpected: ${body.method}` } }
|
|
66
59
|
})
|
|
67
60
|
|
|
@@ -72,36 +65,15 @@ describe('monitor view (/ui/monitor)', () => {
|
|
|
72
65
|
// Parent observed the child's handshakeAck.
|
|
73
66
|
await expect(parent.ack).resolves.toBeUndefined()
|
|
74
67
|
|
|
75
|
-
expect(screen.getByText('
|
|
76
|
-
|
|
77
|
-
expect(
|
|
78
|
-
|
|
79
|
-
expect(
|
|
80
|
-
expect(screen.getByText('42ms')).toBeTruthy()
|
|
81
|
-
expect(screen.getByText('200')).toBeTruthy()
|
|
68
|
+
expect(screen.getByText('Public status')).toBeTruthy()
|
|
69
|
+
const badge = screen.getByText('DEGRADED')
|
|
70
|
+
expect(badge.className).toContain('status-degraded')
|
|
71
|
+
// A StatusPage carries no `url`, so there's no url row.
|
|
72
|
+
expect(screen.queryByText('url')).toBeNull()
|
|
82
73
|
|
|
83
74
|
// Exactly one kernel call, for the node load.
|
|
84
75
|
expect(kernel.calls).toHaveLength(1)
|
|
85
|
-
expect(kernel.calls[0]!.body.method).toBe('@
|
|
86
|
-
} finally {
|
|
87
|
-
kernel.restore()
|
|
88
|
-
parent.restore()
|
|
89
|
-
}
|
|
90
|
-
})
|
|
91
|
-
|
|
92
|
-
it('renders a DOWN badge when the monitor status is down', async () => {
|
|
93
|
-
const parent = handshake('mon-d')
|
|
94
|
-
const kernel = installFakeKernel(() =>
|
|
95
|
-
ok(monitorNode({ id: 'mon-d', name: 'Down site', url: 'https://x.test', status: 'down' })),
|
|
96
|
-
)
|
|
97
|
-
|
|
98
|
-
try {
|
|
99
|
-
render(<App />)
|
|
100
|
-
await flush()
|
|
101
|
-
|
|
102
|
-
const badge = screen.getByText('DOWN')
|
|
103
|
-
expect(badge).toBeTruthy()
|
|
104
|
-
expect(badge.className).toContain('status-down')
|
|
76
|
+
expect(kernel.calls[0]!.body.method).toBe('@page-1::get')
|
|
105
77
|
} finally {
|
|
106
78
|
kernel.restore()
|
|
107
79
|
parent.restore()
|
|
@@ -109,36 +81,25 @@ describe('monitor view (/ui/monitor)', () => {
|
|
|
109
81
|
})
|
|
110
82
|
|
|
111
83
|
it('clicks "Check now", issues @<id>::check, and reflects the refreshed status', async () => {
|
|
112
|
-
const parent = handshake('
|
|
113
|
-
// First ::get is down; ::check
|
|
84
|
+
const parent = handshake('page-1')
|
|
85
|
+
// First ::get is down; ::check rolls up server-side; the post-check ::get is up.
|
|
114
86
|
let checked = false
|
|
115
87
|
const kernel = installFakeKernel((body) => {
|
|
116
|
-
if (body.method === '@
|
|
88
|
+
if (body.method === '@page-1::check') {
|
|
117
89
|
checked = true
|
|
118
90
|
return ok(null)
|
|
119
91
|
}
|
|
120
|
-
|
|
121
|
-
return ok(
|
|
122
|
-
monitorNode({
|
|
123
|
-
id: 'mon-1',
|
|
124
|
-
name: 'API health',
|
|
125
|
-
url: 'https://api.example.test/health',
|
|
126
|
-
status: checked ? 'up' : 'down',
|
|
127
|
-
statusCode: checked ? '200' : '503',
|
|
128
|
-
}),
|
|
129
|
-
)
|
|
92
|
+
return ok(page('page-1', checked ? 'up' : 'down'))
|
|
130
93
|
})
|
|
131
94
|
|
|
132
95
|
try {
|
|
133
96
|
render(<App />)
|
|
134
97
|
await flush()
|
|
135
98
|
|
|
136
|
-
// Initial load: DOWN.
|
|
137
99
|
expect(screen.getByText('DOWN')).toBeTruthy()
|
|
138
100
|
expect(kernel.calls).toHaveLength(1)
|
|
139
|
-
expect(kernel.calls[0]!.body.method).toBe('@
|
|
101
|
+
expect(kernel.calls[0]!.body.method).toBe('@page-1::get')
|
|
140
102
|
|
|
141
|
-
// Click "Check now".
|
|
142
103
|
await act(async () => {
|
|
143
104
|
fireEvent.click(screen.getByText('Check now'))
|
|
144
105
|
})
|
|
@@ -146,14 +107,11 @@ describe('monitor view (/ui/monitor)', () => {
|
|
|
146
107
|
|
|
147
108
|
// A ::check call was issued, then the node was re-fetched.
|
|
148
109
|
expect(kernel.calls.map((c) => c.body.method)).toEqual([
|
|
149
|
-
'@
|
|
150
|
-
'@
|
|
151
|
-
'@
|
|
110
|
+
'@page-1::get',
|
|
111
|
+
'@page-1::check',
|
|
112
|
+
'@page-1::get',
|
|
152
113
|
])
|
|
153
|
-
|
|
154
|
-
// The refreshed status renders.
|
|
155
114
|
expect(screen.getByText('UP')).toBeTruthy()
|
|
156
|
-
expect(screen.getByText('200')).toBeTruthy()
|
|
157
115
|
} finally {
|
|
158
116
|
kernel.restore()
|
|
159
117
|
parent.restore()
|
|
@@ -161,13 +119,12 @@ describe('monitor view (/ui/monitor)', () => {
|
|
|
161
119
|
})
|
|
162
120
|
|
|
163
121
|
it('surfaces a check failure without losing the loaded record', async () => {
|
|
164
|
-
const parent = handshake('
|
|
165
|
-
const kernel = installFakeKernel((body) =>
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
})
|
|
122
|
+
const parent = handshake('page-1')
|
|
123
|
+
const kernel = installFakeKernel((body) =>
|
|
124
|
+
body.method === '@page-1::check'
|
|
125
|
+
? { error: { code: 2004, message: 'Permission denied' } }
|
|
126
|
+
: ok(page('page-1', 'up')),
|
|
127
|
+
)
|
|
171
128
|
|
|
172
129
|
try {
|
|
173
130
|
render(<App />)
|
|
@@ -183,7 +140,7 @@ describe('monitor view (/ui/monitor)', () => {
|
|
|
183
140
|
expect(screen.getByText(/Check failed:/)).toBeTruthy()
|
|
184
141
|
expect(screen.getByText(/Permission denied/)).toBeTruthy()
|
|
185
142
|
// The record is still shown.
|
|
186
|
-
expect(screen.getByText('
|
|
143
|
+
expect(screen.getByText('Public status')).toBeTruthy()
|
|
187
144
|
} finally {
|
|
188
145
|
kernel.restore()
|
|
189
146
|
parent.restore()
|
|
@@ -198,8 +155,7 @@ describe('monitor view (/ui/monitor)', () => {
|
|
|
198
155
|
render(<App />)
|
|
199
156
|
await flush()
|
|
200
157
|
|
|
201
|
-
expect(screen.getByText(/Failed to load
|
|
202
|
-
// The proper client maps NOT_FOUND (3002) to a typed error → clean message.
|
|
158
|
+
expect(screen.getByText(/Failed to load/)).toBeTruthy()
|
|
203
159
|
expect(screen.getByText(/Path not found/)).toBeTruthy()
|
|
204
160
|
} finally {
|
|
205
161
|
kernel.restore()
|
|
@@ -208,24 +164,24 @@ describe('monitor view (/ui/monitor)', () => {
|
|
|
208
164
|
})
|
|
209
165
|
|
|
210
166
|
it('re-fetches on a setTarget hot-swap', async () => {
|
|
211
|
-
const parent = handshake('
|
|
167
|
+
const parent = handshake('page-1')
|
|
212
168
|
const kernel = installFakeKernel((body) =>
|
|
213
|
-
body.method === '@
|
|
214
|
-
? ok(
|
|
215
|
-
: ok(
|
|
169
|
+
body.method === '@page-2::get'
|
|
170
|
+
? ok(page('page-2', 'down', 'Internal status'))
|
|
171
|
+
: ok(page('page-1', 'up', 'Public status')),
|
|
216
172
|
)
|
|
217
173
|
|
|
218
174
|
try {
|
|
219
175
|
render(<App />)
|
|
220
176
|
await flush()
|
|
221
|
-
expect(screen.getByText('
|
|
177
|
+
expect(screen.getByText('Public status')).toBeTruthy()
|
|
222
178
|
expect(screen.getByText('UP')).toBeTruthy()
|
|
223
179
|
|
|
224
180
|
// Parent pushes a new target node.
|
|
225
|
-
parent.setTarget('
|
|
181
|
+
parent.setTarget('page-2')
|
|
226
182
|
await flush()
|
|
227
183
|
|
|
228
|
-
expect(screen.getByText('
|
|
184
|
+
expect(screen.getByText('Internal status')).toBeTruthy()
|
|
229
185
|
expect(screen.getByText('DOWN')).toBeTruthy()
|
|
230
186
|
} finally {
|
|
231
187
|
kernel.restore()
|
|
@@ -240,7 +196,7 @@ describe('monitor view (/ui/monitor)', () => {
|
|
|
240
196
|
render(<App />)
|
|
241
197
|
await flush()
|
|
242
198
|
|
|
243
|
-
expect(screen.getByText(/No target
|
|
199
|
+
expect(screen.getByText(/No target node/)).toBeTruthy()
|
|
244
200
|
expect(kernel.calls).toHaveLength(0)
|
|
245
201
|
} finally {
|
|
246
202
|
kernel.restore()
|
|
@@ -240,37 +240,32 @@ export function fakeKernelSession(kernelUrl = 'https://k.example.test/api'): Ker
|
|
|
240
240
|
}
|
|
241
241
|
|
|
242
242
|
/**
|
|
243
|
-
* Build a minimal kernel
|
|
244
|
-
*
|
|
245
|
-
*
|
|
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
246
|
* `Named.name` key.
|
|
247
247
|
*/
|
|
248
|
-
export function
|
|
248
|
+
export function checkableNode(opts: {
|
|
249
249
|
id: string
|
|
250
250
|
path?: string
|
|
251
251
|
name?: string
|
|
252
|
-
url?: string
|
|
253
252
|
status?: string
|
|
254
|
-
|
|
255
|
-
latencyMs?: string
|
|
256
|
-
lastCheckedAt?: string
|
|
253
|
+
className?: string
|
|
257
254
|
domain?: string
|
|
258
255
|
}): { id: string; path: string; class: { raw: string }; props: Record<string, unknown> } {
|
|
259
256
|
const domain = opts.domain ?? 'monitors.astrale.ai'
|
|
260
|
-
const
|
|
257
|
+
const className = opts.className ?? 'StatusPage'
|
|
261
258
|
const props: Record<string, unknown> = {}
|
|
262
259
|
if (opts.name !== undefined) {
|
|
263
260
|
props['kernel.astrale.ai:interface.Named.property.name'] = opts.name
|
|
264
261
|
}
|
|
265
|
-
if (opts.
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
if (opts.latencyMs !== undefined) props[`${base}.latencyMs`] = opts.latencyMs
|
|
269
|
-
if (opts.lastCheckedAt !== undefined) props[`${base}.lastCheckedAt`] = opts.lastCheckedAt
|
|
262
|
+
if (opts.status !== undefined) {
|
|
263
|
+
props[`${domain}:class.${className}.property.status`] = opts.status
|
|
264
|
+
}
|
|
270
265
|
return {
|
|
271
266
|
id: opts.id,
|
|
272
|
-
path: opts.path ??
|
|
273
|
-
class: { raw: `/:${domain}:class
|
|
267
|
+
path: opts.path ?? `/${opts.id}`,
|
|
268
|
+
class: { raw: `/:${domain}:class.${className}` },
|
|
274
269
|
props,
|
|
275
270
|
}
|
|
276
271
|
}
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest'
|
|
2
2
|
|
|
3
3
|
import { qualifiedString, readProp, readPropBySuffix } from '@/shell'
|
|
4
|
-
import {
|
|
4
|
+
import { checkableFromNode } from '@/status'
|
|
5
5
|
import { relativeTime } from '@/ui'
|
|
6
|
-
|
|
6
|
+
|
|
7
|
+
import { checkableNode } from './harness'
|
|
7
8
|
|
|
8
9
|
describe('prop readers', () => {
|
|
9
10
|
it('readProp returns the string value at the exact key', () => {
|
|
@@ -30,45 +31,18 @@ describe('prop readers', () => {
|
|
|
30
31
|
})
|
|
31
32
|
})
|
|
32
33
|
|
|
33
|
-
describe('
|
|
34
|
-
it('projects name
|
|
35
|
-
const record =
|
|
36
|
-
|
|
37
|
-
id: 'mon-1',
|
|
38
|
-
name: 'API health',
|
|
39
|
-
url: 'https://api.test/health',
|
|
40
|
-
status: 'up',
|
|
41
|
-
statusCode: '200',
|
|
42
|
-
latencyMs: '42',
|
|
43
|
-
lastCheckedAt: '2026-06-14T10:00:00Z',
|
|
44
|
-
}),
|
|
34
|
+
describe('checkableFromNode mapper', () => {
|
|
35
|
+
it('projects the name and rolled-up status of a StatusPage node', () => {
|
|
36
|
+
const record = checkableFromNode(
|
|
37
|
+
checkableNode({ id: 'page-1', name: 'Public', status: 'degraded', className: 'StatusPage' }),
|
|
45
38
|
)
|
|
46
|
-
expect(record).
|
|
47
|
-
id: 'mon-1',
|
|
48
|
-
name: 'API health',
|
|
49
|
-
url: 'https://api.test/health',
|
|
50
|
-
status: 'up',
|
|
51
|
-
statusCode: 200,
|
|
52
|
-
latencyMs: 42,
|
|
53
|
-
lastCheckedAt: '2026-06-14T10:00:00Z',
|
|
54
|
-
})
|
|
39
|
+
expect(record).toEqual({ name: 'Public', status: 'degraded' })
|
|
55
40
|
})
|
|
56
41
|
|
|
57
42
|
it('falls back to the path segment for the name and unknown for a missing status', () => {
|
|
58
|
-
const record =
|
|
43
|
+
const record = checkableFromNode(checkableNode({ id: 'page-9', path: '/status-pages/edge' }))
|
|
59
44
|
expect(record.name).toBe('edge')
|
|
60
45
|
expect(record.status).toBe('unknown')
|
|
61
|
-
expect(record.url).toBe('')
|
|
62
|
-
expect(record.statusCode).toBeUndefined()
|
|
63
|
-
})
|
|
64
|
-
})
|
|
65
|
-
|
|
66
|
-
describe('normalizeStatus', () => {
|
|
67
|
-
it('passes through up/down and maps everything else to unknown', () => {
|
|
68
|
-
expect(normalizeStatus('up')).toBe('up')
|
|
69
|
-
expect(normalizeStatus('down')).toBe('down')
|
|
70
|
-
expect(normalizeStatus('weird')).toBe('unknown')
|
|
71
|
-
expect(normalizeStatus(undefined)).toBe('unknown')
|
|
72
46
|
})
|
|
73
47
|
})
|
|
74
48
|
|
|
@@ -1,20 +1,21 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* What the presentation/logic seam unlocks:
|
|
3
3
|
*
|
|
4
|
-
* 1. PRESENTATION components (`@/ui`
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* 2. FEATURE hooks (`@/
|
|
4
|
+
* 1. PRESENTATION components (`@/ui`) test with ZERO infrastructure — no fake
|
|
5
|
+
* shell, no fake kernel, no session. Just props in, DOM out. (Contrast
|
|
6
|
+
* app.test.tsx, which stands up a handshake + kernel stub to reach the same
|
|
7
|
+
* markup.)
|
|
8
|
+
* 2. FEATURE hooks (`@/status`) test against a bare `KernelClient` (the fake
|
|
9
9
|
* kernel fetch stub) with no handshake — the write lifecycle and the
|
|
10
10
|
* reload signal are verified once, in isolation.
|
|
11
11
|
*/
|
|
12
12
|
import { act, cleanup, render, renderHook, screen, waitFor } from '@testing-library/react'
|
|
13
13
|
import { afterEach, describe, expect, it } from 'vitest'
|
|
14
14
|
|
|
15
|
-
import { useCheck,
|
|
16
|
-
import { StatusBadge } from '@/
|
|
17
|
-
|
|
15
|
+
import { useCheck, useCheckable } from '@/status'
|
|
16
|
+
import { StatusBadge } from '@/ui'
|
|
17
|
+
|
|
18
|
+
import { checkableNode, fakeKernelSession, installFakeKernel, ok } from './harness'
|
|
18
19
|
|
|
19
20
|
afterEach(cleanup)
|
|
20
21
|
|
|
@@ -23,24 +24,25 @@ describe('presentation is pure (no kernel, no session, no shell)', () => {
|
|
|
23
24
|
const { rerender } = render(<StatusBadge status="up" />)
|
|
24
25
|
expect(screen.getByText('UP').className).toContain('status-up')
|
|
25
26
|
|
|
27
|
+
rerender(<StatusBadge status="degraded" />)
|
|
28
|
+
expect(screen.getByText('DEGRADED').className).toContain('status-degraded')
|
|
29
|
+
|
|
26
30
|
rerender(<StatusBadge status="down" />)
|
|
27
31
|
expect(screen.getByText('DOWN').className).toContain('status-down')
|
|
28
32
|
|
|
29
|
-
rerender(<StatusBadge status="
|
|
33
|
+
rerender(<StatusBadge status="weird" />)
|
|
30
34
|
expect(screen.getByText('UNKNOWN').className).toContain('status-unknown')
|
|
31
35
|
})
|
|
32
36
|
})
|
|
33
37
|
|
|
34
|
-
describe('
|
|
38
|
+
describe('useCheckable — query + reload signal', () => {
|
|
35
39
|
it('loads the record, then exposes reloading across a reload()', async () => {
|
|
36
40
|
const session = fakeKernelSession()
|
|
37
41
|
let status = 'down'
|
|
38
|
-
const kernel = installFakeKernel(() =>
|
|
39
|
-
ok(monitorNode({ id: 'mon-1', name: 'API', url: 'https://x.test', status })),
|
|
40
|
-
)
|
|
42
|
+
const kernel = installFakeKernel(() => ok(checkableNode({ id: 'page-1', name: 'API', status })))
|
|
41
43
|
|
|
42
44
|
try {
|
|
43
|
-
const { result } = renderHook(() =>
|
|
45
|
+
const { result } = renderHook(() => useCheckable(session, 'page-1'))
|
|
44
46
|
await waitFor(() => expect(result.current.state).toBe('ok'))
|
|
45
47
|
expect(result.current.record?.status).toBe('down')
|
|
46
48
|
expect(result.current.reloading).toBe(false)
|
|
@@ -63,7 +65,7 @@ describe('useMonitor — query + reload signal', () => {
|
|
|
63
65
|
const session = fakeKernelSession()
|
|
64
66
|
const kernel = installFakeKernel(() => ({ error: { code: 3002, message: 'Path not found' } }))
|
|
65
67
|
try {
|
|
66
|
-
const { result } = renderHook(() =>
|
|
68
|
+
const { result } = renderHook(() => useCheckable(session, 'missing'))
|
|
67
69
|
await waitFor(() => expect(result.current.state).toBe('error'))
|
|
68
70
|
expect(result.current.message).toMatch(/Path not found/)
|
|
69
71
|
} finally {
|
|
@@ -77,7 +79,7 @@ describe('useCheck — the shared write-lifecycle', () => {
|
|
|
77
79
|
const session = fakeKernelSession()
|
|
78
80
|
const kernel = installFakeKernel(() => ok(null))
|
|
79
81
|
try {
|
|
80
|
-
const { result } = renderHook(() => useCheck(session, '
|
|
82
|
+
const { result } = renderHook(() => useCheck(session, 'page-1'))
|
|
81
83
|
expect(result.current.phase).toBe('idle')
|
|
82
84
|
|
|
83
85
|
let returned: boolean | undefined
|
|
@@ -94,9 +96,11 @@ describe('useCheck — the shared write-lifecycle', () => {
|
|
|
94
96
|
|
|
95
97
|
it('captures a failure: phase failed, error set, resolves false', async () => {
|
|
96
98
|
const session = fakeKernelSession()
|
|
97
|
-
const kernel = installFakeKernel(() => ({
|
|
99
|
+
const kernel = installFakeKernel(() => ({
|
|
100
|
+
error: { code: 2004, message: 'Permission denied' },
|
|
101
|
+
}))
|
|
98
102
|
try {
|
|
99
|
-
const { result } = renderHook(() => useCheck(session, '
|
|
103
|
+
const { result } = renderHook(() => useCheck(session, 'page-1'))
|
|
100
104
|
let returned: boolean | undefined
|
|
101
105
|
await act(async () => {
|
|
102
106
|
returned = await result.current.run()
|
|
@@ -1,27 +1,21 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Path-based view router for the domain's SPA bundle. The shell mounts each Astrale
|
|
3
|
+
* View in its own iframe at its own mount path, so inside the iframe
|
|
4
|
+
* `window.location.pathname` IS that path. `App` reads it (via `resolveView`) and
|
|
5
|
+
* renders the matching view; an unregistered path shows a self-describing fallback.
|
|
3
6
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* iframe `window.location.pathname` is that path. `App` reads it (via
|
|
10
|
-
* `resolveView`) and renders the matching view; an unregistered path shows a
|
|
11
|
-
* self-describing fallback.
|
|
12
|
-
*
|
|
13
|
-
* To add a view:
|
|
14
|
-
* 1. write a `ViewComponent` (usually wrapping `ViewFrame` — see `views/`),
|
|
15
|
-
* 2. add a `ROUTES` entry below keyed by its mount path,
|
|
16
|
-
* 3. register a matching `defineView({ mount: '/ui/<path>' })` in the domain's
|
|
17
|
-
* `views/` so the shell mounts an iframe there.
|
|
7
|
+
* Today there's one view — the StatusPage panel at `/ui/status-page`. The router
|
|
8
|
+
* (not a direct render) is the extension seam: add a view by
|
|
9
|
+
* 1. writing a `ViewComponent` (usually wrapping `ViewFrame` — see `views/`),
|
|
10
|
+
* 2. adding a `ROUTES` entry keyed by its mount path,
|
|
11
|
+
* 3. registering a matching `defineView({ mount: '/ui/<path>' })` in `views/`.
|
|
18
12
|
*/
|
|
19
13
|
|
|
20
14
|
import { resolveView, useShell, type ViewRoutes } from '@/shell'
|
|
21
|
-
import {
|
|
15
|
+
import { StatusView } from '@/views/status'
|
|
22
16
|
|
|
23
17
|
const ROUTES: ViewRoutes = {
|
|
24
|
-
'/ui/
|
|
18
|
+
'/ui/status-page': StatusView,
|
|
25
19
|
}
|
|
26
20
|
|
|
27
21
|
export function App() {
|
|
@@ -29,9 +29,7 @@ export interface Capability<P = void> {
|
|
|
29
29
|
reset: () => void
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
-
export function useCapability<P = void>(
|
|
33
|
-
perform: (params: P) => Promise<unknown>,
|
|
34
|
-
): Capability<P> {
|
|
32
|
+
export function useCapability<P = void>(perform: (params: P) => Promise<unknown>): Capability<P> {
|
|
35
33
|
const [phase, setPhase] = useState<Phase>('idle')
|
|
36
34
|
const [error, setError] = useState<string | null>(null)
|
|
37
35
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { useCallback, useEffect, useState } from 'react'
|
|
2
|
-
|
|
3
1
|
import type { KernelClient } from '@astrale-os/shell'
|
|
4
2
|
|
|
3
|
+
import { useCallback, useEffect, useState } from 'react'
|
|
4
|
+
|
|
5
5
|
import { errorMessage, type KernelNode } from './client'
|
|
6
6
|
|
|
7
7
|
export type NodeState =
|