@astrale-os/adapter-cloudflare 0.2.0 → 0.3.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/dist/build.d.ts +2 -1
- package/dist/build.d.ts.map +1 -1
- package/dist/build.js +1 -1
- package/dist/build.js.map +1 -1
- package/dist/client.d.ts +14 -13
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +25 -18
- package/dist/client.js.map +1 -1
- package/dist/cloudflare.d.ts +4 -1
- package/dist/cloudflare.d.ts.map +1 -1
- package/dist/cloudflare.js +40 -18
- package/dist/cloudflare.js.map +1 -1
- package/dist/codegen/worker.d.ts +3 -3
- package/dist/codegen/worker.d.ts.map +1 -1
- package/dist/codegen/worker.js +19 -8
- package/dist/codegen/worker.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/params.d.ts +3 -0
- package/dist/params.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/build.ts +2 -1
- package/src/client.ts +43 -18
- package/src/cloudflare.ts +41 -14
- package/src/codegen/worker.ts +19 -8
- package/src/index.ts +2 -2
- package/src/params.ts +4 -0
- package/template/CLAUDE.md +24 -0
- package/template/README.md +24 -15
- package/template/astrale.config.ts +4 -4
- package/template/client/README.md +9 -9
- package/template/client/__tests__/app.test.tsx +58 -19
- package/template/client/__tests__/harness.ts +16 -0
- package/template/client/__tests__/kernel.test.ts +23 -3
- package/template/client/src/shell/use-async.ts +4 -1
- package/template/client/src/status/components/StatusCard.tsx +115 -5
- package/template/client/src/status/hooks/useCheckable.query.ts +48 -40
- package/template/client/src/status/index.ts +2 -2
- package/template/client/src/status/status.api.ts +18 -1
- package/template/client/src/status/status.mappers.ts +89 -6
- package/template/client/src/status/status.types.ts +17 -2
- package/template/client/src/styles.css +235 -14
- package/template/client/src/views/status.tsx +1 -1
- package/template/core/monitor/index.ts +2 -2
- package/template/core/monitor/node.ts +12 -6
- package/template/domain.ts +6 -4
- package/template/functions/index.ts +31 -7
- package/template/package.json +2 -2
- package/template/pnpm-lock.yaml +57 -43
- package/template/pnpm-workspace.yaml +2 -0
- package/template/runtime/index.ts +8 -17
- package/template/runtime/monitoring/index.ts +8 -0
- package/template/runtime/{monitor → monitoring/monitor}/check.ts +3 -3
- package/template/runtime/{monitor → monitoring/monitor}/index.ts +3 -2
- package/template/runtime/{monitor → monitoring/monitor}/seed.ts +19 -10
- package/template/runtime/{monitor → monitoring/monitor}/watch.ts +5 -5
- package/template/runtime/{status-page → monitoring/page}/add.ts +2 -2
- package/template/runtime/{status-page → monitoring/page}/check.ts +2 -2
- package/template/runtime/{status-page → monitoring/page}/create.ts +3 -3
- package/template/runtime/monitoring/page/index.ts +9 -0
- package/template/schema/monitor.ts +6 -8
- package/template/views/index.ts +1 -1
- package/template/.agents/skills/astrale-cli/SKILL.md +0 -458
- package/template/.agents/skills/astrale-domain/SKILL.md +0 -371
- package/template/runtime/status-page/index.ts +0 -8
package/src/cloudflare.ts
CHANGED
|
@@ -17,7 +17,7 @@ import { join } from 'node:path'
|
|
|
17
17
|
|
|
18
18
|
import type { CloudflareParams } from './params'
|
|
19
19
|
|
|
20
|
-
import { buildClient } from './client'
|
|
20
|
+
import { buildClient, resolveClientDir } from './client'
|
|
21
21
|
import { ensureIdentity } from './codegen/identity'
|
|
22
22
|
import { generateWorkerEntry } from './codegen/worker'
|
|
23
23
|
import { generateWranglerConfig } from './codegen/wrangler'
|
|
@@ -33,8 +33,12 @@ export function cloudflare(
|
|
|
33
33
|
envs,
|
|
34
34
|
|
|
35
35
|
async watch(params, ctx) {
|
|
36
|
-
const
|
|
37
|
-
|
|
36
|
+
const clientDir = resolveCloudflareClientDir(params, ctx)
|
|
37
|
+
const { configPath, port } = await prepare(params, ctx, 'dev', {
|
|
38
|
+
servesClient: Boolean(clientDir),
|
|
39
|
+
bundleAssets: Boolean(clientDir),
|
|
40
|
+
})
|
|
41
|
+
if (clientDir) await buildClient(clientDir, ctx.projectDir, logTo())
|
|
38
42
|
const handle = await runWranglerDev({
|
|
39
43
|
projectDir: ctx.projectDir,
|
|
40
44
|
configPath,
|
|
@@ -55,17 +59,25 @@ export function cloudflare(
|
|
|
55
59
|
// them itself, so a `astrale.config.ts` edit lands without a restart.
|
|
56
60
|
// Mirrors `watch`'s prep exactly (codegen + client build), minus the spawn.
|
|
57
61
|
async regenerate(params, ctx) {
|
|
58
|
-
|
|
59
|
-
|
|
62
|
+
const clientDir = resolveCloudflareClientDir(params, ctx)
|
|
63
|
+
await prepare(params, ctx, 'dev', {
|
|
64
|
+
servesClient: Boolean(clientDir),
|
|
65
|
+
bundleAssets: Boolean(clientDir),
|
|
66
|
+
})
|
|
67
|
+
if (clientDir) await buildClient(clientDir, ctx.projectDir, logTo())
|
|
60
68
|
},
|
|
61
69
|
|
|
62
70
|
async deploy(params, ctx) {
|
|
71
|
+
const clientDir = resolveCloudflareClientDir(params, ctx)
|
|
63
72
|
const {
|
|
64
73
|
configPath,
|
|
65
74
|
fallbackConfigPath,
|
|
66
75
|
workerName: name,
|
|
67
|
-
} = await prepare(params, ctx, 'deploy'
|
|
68
|
-
|
|
76
|
+
} = await prepare(params, ctx, 'deploy', {
|
|
77
|
+
servesClient: Boolean(clientDir),
|
|
78
|
+
bundleAssets: Boolean(clientDir),
|
|
79
|
+
})
|
|
80
|
+
if (clientDir) await buildClient(clientDir, ctx.projectDir, logTo())
|
|
69
81
|
const { url } = await runWranglerDeploy({
|
|
70
82
|
projectDir: ctx.projectDir,
|
|
71
83
|
configPath,
|
|
@@ -96,12 +108,29 @@ export function cloudflare(
|
|
|
96
108
|
})
|
|
97
109
|
}
|
|
98
110
|
|
|
111
|
+
function resolveCloudflareClientDir(
|
|
112
|
+
params: CloudflareParams,
|
|
113
|
+
ctx: WatchCtx | DeployCtx,
|
|
114
|
+
): string | undefined {
|
|
115
|
+
return resolveClientDir({
|
|
116
|
+
adapterName: 'cloudflare',
|
|
117
|
+
env: ctx.env,
|
|
118
|
+
projectDir: ctx.projectDir,
|
|
119
|
+
domain: ctx.domain,
|
|
120
|
+
...(params.client ? { client: params.client } : {}),
|
|
121
|
+
})
|
|
122
|
+
}
|
|
123
|
+
|
|
99
124
|
// ── codegen orchestration ──────────────────────────────────────────────────
|
|
100
125
|
|
|
101
126
|
export async function prepare(
|
|
102
127
|
params: CloudflareParams,
|
|
103
128
|
ctx: WatchCtx | DeployCtx,
|
|
104
129
|
mode: 'dev' | 'deploy',
|
|
130
|
+
clientAssets: { servesClient: boolean; bundleAssets: boolean } = {
|
|
131
|
+
servesClient: false,
|
|
132
|
+
bundleAssets: false,
|
|
133
|
+
},
|
|
105
134
|
): Promise<{ configPath: string; fallbackConfigPath?: string; port: number; workerName: string }> {
|
|
106
135
|
const astraleDir = join(ctx.projectDir, '.astrale')
|
|
107
136
|
await mkdir(astraleDir, { recursive: true })
|
|
@@ -112,13 +141,11 @@ export async function prepare(
|
|
|
112
141
|
// worker reach other instances' hosts internally, so a cross-instance JWKS
|
|
113
142
|
// fetch doesn't 522. Resolved once, fed to BOTH codegens below.
|
|
114
143
|
const router = resolveRouter(params.router)
|
|
115
|
-
// Two distinct client signals: the WORKER's `/ui` hook
|
|
116
|
-
//
|
|
117
|
-
//
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
const servesClient = ctx.domain.hasClient
|
|
121
|
-
const bundleAssets = Boolean(ctx.clientDir)
|
|
144
|
+
// Two distinct client signals: the WORKER's `/ui` hook and Wrangler's local
|
|
145
|
+
// assets binding. Direct Cloudflare deploys use both; managed deploys ship
|
|
146
|
+
// assets separately but still need the worker hook.
|
|
147
|
+
const servesClient = clientAssets.servesClient
|
|
148
|
+
const bundleAssets = clientAssets.bundleAssets
|
|
122
149
|
|
|
123
150
|
await writeFileAtomic(
|
|
124
151
|
join(astraleDir, 'worker.gen.ts'),
|
package/src/codegen/worker.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* The dev wires the domain ONCE in a worker-safe `domain.ts`
|
|
5
5
|
* (`export const domain = defineDomain({ schema, methods, deps, views,
|
|
6
|
-
* functions
|
|
6
|
+
* functions })`) and binds it to an adapter in `astrale.config.ts`
|
|
7
7
|
* (`deploy(domain, cloudflare({ … }))`). This generates the
|
|
8
8
|
* `export default { fetch }` plumbing the adapter owns by importing that single
|
|
9
9
|
* `domain` and SPREADING it into `domainWorkerEntry` — so the author's folder
|
|
@@ -13,8 +13,8 @@
|
|
|
13
13
|
*
|
|
14
14
|
* Everything the entry needs — schema/methods/deps/views/functions/requires/
|
|
15
15
|
* postInstall — rides on the spread; the only build-time signal left is whether
|
|
16
|
-
*
|
|
17
|
-
* and its import. Key properties:
|
|
16
|
+
* this adapter env serves a client SPA (`hasClient`), which gates the `/ui`
|
|
17
|
+
* asset hook and its import. Key properties:
|
|
18
18
|
*
|
|
19
19
|
* • Per-request `url` = the live serving origin (`scheme://host`). It is passed
|
|
20
20
|
* only to `createRemoteServer({ url })`; the SDK stamps every
|
|
@@ -47,7 +47,7 @@ export interface WorkerCodegenOptions {
|
|
|
47
47
|
|
|
48
48
|
export function generateWorkerEntry(opts: WorkerCodegenOptions): string {
|
|
49
49
|
// The asset-serving hook is a library helper (`assets`), not inlined string
|
|
50
|
-
// logic — imported only when
|
|
50
|
+
// logic — imported only when this adapter env serves client assets.
|
|
51
51
|
const serverImport = opts.hasClient
|
|
52
52
|
? `import { assets, domainWorkerEntry } from '@astrale-os/sdk/server'`
|
|
53
53
|
: `import { domainWorkerEntry } from '@astrale-os/sdk/server'`
|
|
@@ -64,7 +64,11 @@ export function generateWorkerEntry(opts: WorkerCodegenOptions): string {
|
|
|
64
64
|
// routeSubrequest), wired only when the adapter's `router` is set.
|
|
65
65
|
const routerEnvField = router
|
|
66
66
|
? `
|
|
67
|
-
${router.binding}?: { fetch(request: Request): Promise<Response> }
|
|
67
|
+
${router.binding}?: { fetch(request: Request): Promise<Response> }
|
|
68
|
+
// Sibling WFP services share one dispatch namespace; this binding reaches them
|
|
69
|
+
// IN-PROCESS (a same-zone public fetch 522s; the instance router doesn't know svc
|
|
70
|
+
// hosts). Injected by the \`services\` domain at deploy; absent → svc routing no-ops.
|
|
71
|
+
DISPATCHER?: { get(name: string): { fetch(request: Request): Promise<Response> } }`
|
|
68
72
|
: ''
|
|
69
73
|
const routerHelper = router
|
|
70
74
|
? `
|
|
@@ -79,9 +83,16 @@ function isInstanceHost(host) {
|
|
|
79
83
|
: ''
|
|
80
84
|
const routerHook = router
|
|
81
85
|
? `
|
|
82
|
-
// Divert
|
|
83
|
-
// Worker→Worker public fetch). No-op when
|
|
84
|
-
routeSubrequest: (url, env) =>
|
|
86
|
+
// Divert platform subrequests off the same-zone public edge (CF 522s a same-zone
|
|
87
|
+
// Worker→Worker public fetch). No-op when a binding is absent.
|
|
88
|
+
routeSubrequest: (url, env) => {
|
|
89
|
+
const host = url.hostname
|
|
90
|
+
// Sibling WFP service (<script>.svc.<domain>) → in-process via the dispatch namespace.
|
|
91
|
+
if (env.DISPATCHER && host.split('.')[1] === 'svc') return env.DISPATCHER.get(host.split('.')[0])
|
|
92
|
+
// Instance host → router service binding.
|
|
93
|
+
if (env.${router.binding} && isInstanceHost(host)) return env.${router.binding}
|
|
94
|
+
return null
|
|
95
|
+
},`
|
|
85
96
|
: ''
|
|
86
97
|
|
|
87
98
|
return `// AUTO-GENERATED by @astrale-os/adapter-cloudflare — do not edit.
|
package/src/index.ts
CHANGED
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
* import { cloudflare } from '@astrale-os/adapter-cloudflare'
|
|
6
6
|
*
|
|
7
7
|
* adapter: cloudflare({
|
|
8
|
-
* dev: { secrets: '.env.dev' },
|
|
9
|
-
* prod: { route: 'crm.acme.dev', secrets: '.env.prod' },
|
|
8
|
+
* dev: { client: { dir: 'client' }, secrets: '.env.dev' },
|
|
9
|
+
* prod: { client: { dir: 'client' }, route: 'crm.acme.dev', secrets: '.env.prod' },
|
|
10
10
|
* })
|
|
11
11
|
*
|
|
12
12
|
* `dev` runs `wrangler dev` locally; an env with no `route` ships to
|
package/src/params.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { ClientConfig } from './client'
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* `CloudflareParams` — the provider-typed config for the Cloudflare adapter.
|
|
3
5
|
*
|
|
@@ -11,6 +13,8 @@
|
|
|
11
13
|
* • remote (custom) — `deploy()` with `route`; URL = https://<route>
|
|
12
14
|
*/
|
|
13
15
|
export interface CloudflareParams {
|
|
16
|
+
/** Frontend assets to build and serve for this env's mounted `/ui` views. */
|
|
17
|
+
client?: ClientConfig
|
|
14
18
|
/**
|
|
15
19
|
* Custom route / hostname for a routed deploy, e.g. `'crm.acme.dev'`. Omit to
|
|
16
20
|
* deploy to `*.workers.dev`. Ignored by local `wrangler dev` (URL is
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# astrale-domain
|
|
2
|
+
|
|
3
|
+
A standalone Astrale domain. The authoring guide is not in this repo — it's the
|
|
4
|
+
`astrale-domain` agent skill. Load it before changing the schema or handlers.
|
|
5
|
+
|
|
6
|
+
## Agent skills
|
|
7
|
+
|
|
8
|
+
If the `astrale-domain` / `astrale-cli` skills aren't already available in your
|
|
9
|
+
harness, install them once (user-level):
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npx skills add astrale-os/cli -g # both skills · or run `astrale setup`
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
They install into your agent harness (not this project), so every Astrale
|
|
16
|
+
project picks them up. `astrale update` refreshes them alongside the CLI binary
|
|
17
|
+
and this project's `@astrale-os/*` dependencies.
|
|
18
|
+
|
|
19
|
+
## Orientation
|
|
20
|
+
|
|
21
|
+
See `README.md` for the project layout (`schema/`, `core/`, `integrations/`,
|
|
22
|
+
`runtime/`, `views/`, `functions/`, `client/`), the dev/deploy loop, and the
|
|
23
|
+
signing-identity note. `domain.ts` is the one place the domain contract is
|
|
24
|
+
declared.
|
package/template/README.md
CHANGED
|
@@ -6,11 +6,18 @@ service (default) or as a worker on your own Cloudflare account.
|
|
|
6
6
|
|
|
7
7
|
## For AI agents
|
|
8
8
|
|
|
9
|
-
The complete domain-authoring guide
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
9
|
+
The complete domain-authoring guide is the `astrale-domain` skill (schema
|
|
10
|
+
modeling, handlers, kernel calls, external-API patterns — DI, secrets,
|
|
11
|
+
idempotency — views, webhooks, deploys); CLI operations (auth, instances, calls)
|
|
12
|
+
are the `astrale-cli` skill. Install both once, user-level:
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
npx skills add astrale-os/cli -g # both skills · or run `astrale setup`
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Then load `astrale-domain` before making changes. (Skills are installed into your
|
|
19
|
+
agent harness, not committed to this project — `astrale update` keeps them, the
|
|
20
|
+
CLI, and your `@astrale-os/*` deps current.)
|
|
14
21
|
|
|
15
22
|
> **Requires [Bun](https://bun.sh)** — the `astrale-domain` CLI behind `pnpm dev`
|
|
16
23
|
> / `pnpm prod` imports `astrale.config.ts` and your ★ files directly, so it
|
|
@@ -38,15 +45,16 @@ domain.ts wires it all together — the worker-safe definition
|
|
|
38
45
|
astrale.config.ts binds the domain to its deploy adapter (node-only)
|
|
39
46
|
```
|
|
40
47
|
|
|
41
|
-
`domain.ts` is the one place
|
|
42
|
-
above and passes them to `defineDomain({ schema, methods, deps, views,
|
|
43
|
-
functions,
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
filesystem) out of the worker
|
|
48
|
+
`domain.ts` is the one place the domain contract is declared: it imports the
|
|
49
|
+
modules above and passes them to `defineDomain({ schema, methods, deps, views,
|
|
50
|
+
functions, … })` alongside the identity (`origin`, `requires`, `postInstall`).
|
|
51
|
+
Nothing is discovered by folder name — a renamed or mistyped module is a compile
|
|
52
|
+
error at that call, never a silently-missing route. Drop a field (`deps`,
|
|
53
|
+
`views`, `functions`) when the domain has none. `astrale.config.ts` then binds
|
|
54
|
+
that domain to a deploy adapter with `deploy(domain, cloudflare({ … }))` and
|
|
55
|
+
chooses, per env, which `client` assets serve mounted `/ui` views — keeping the
|
|
56
|
+
node-only adapter (wrangler, filesystem, frontend builds) out of the worker
|
|
57
|
+
bundle.
|
|
50
58
|
|
|
51
59
|
Everything else — the Worker entry, the wrangler config, the signing identity —
|
|
52
60
|
is generated under `.astrale/` (gitignored) by the adapter. You never edit it.
|
|
@@ -90,7 +98,8 @@ adapter-owned keys `name`, `main`, `assets`, `routes` are rejected (use
|
|
|
90
98
|
- **Edit a handler** (`core/` logic or `runtime/` wiring) → hot-reloads at the same URL. Nothing to reinstall.
|
|
91
99
|
- **Edit the schema** (`schema/`) → rebuilds the graph; reinstall with `astrale domain install <url> --direct`.
|
|
92
100
|
- **`postInstall`** (the static `Monitor.seed` method) runs once after install, as
|
|
93
|
-
the system identity, so the domain can seed
|
|
101
|
+
the system identity, so the domain can seed `/monitoring/monitors` and
|
|
102
|
+
`/monitoring/pages/status` and set its own grants. It
|
|
94
103
|
must be a class-hosted static addressed by a typed colon-path
|
|
95
104
|
(`/:origin:class.X:seed`) — the kernel refuses tree paths here.
|
|
96
105
|
|
|
@@ -18,8 +18,8 @@ import { deploy } from '@astrale-os/sdk'
|
|
|
18
18
|
//
|
|
19
19
|
// import { astrale } from '@astrale-os/adapter-astrale'
|
|
20
20
|
// export default deploy(domain, astrale({
|
|
21
|
-
// dev: { secrets: '.env.dev' }, // local wrangler dev
|
|
22
|
-
// prod: { instance: '<your-instance-slug>' }, //
|
|
21
|
+
// dev: { client: { dir: 'client' }, secrets: '.env.dev' }, // local wrangler dev
|
|
22
|
+
// prod: { client: { dir: 'client' }, instance: '<your-instance-slug>' }, // managed deploy
|
|
23
23
|
// }))
|
|
24
24
|
|
|
25
25
|
import { domain } from './domain'
|
|
@@ -28,9 +28,9 @@ export default deploy(
|
|
|
28
28
|
domain,
|
|
29
29
|
cloudflare({
|
|
30
30
|
// Local dev: `wrangler dev`. No route → URL is http://localhost:8787.
|
|
31
|
-
dev: { secrets: '.env.dev' },
|
|
31
|
+
dev: { client: { dir: 'client' }, secrets: '.env.dev' },
|
|
32
32
|
// Custom-domain prod. Drop `route` to ship to *.workers.dev instead.
|
|
33
|
-
prod: { route: 'astrale-domain.example.dev', secrets: '.env.prod' },
|
|
33
|
+
prod: { client: { dir: 'client' }, route: 'astrale-domain.example.dev', secrets: '.env.prod' },
|
|
34
34
|
// Author secrets ship via `secrets: '.env.prod'` on any env. Extra bindings
|
|
35
35
|
// (KV, R2, D1, queues, …) ride a `wrangler` block on any env (deep-merged).
|
|
36
36
|
}),
|
|
@@ -4,11 +4,11 @@ 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
6
|
that node from the kernel, and renders it. The bundled status view loads a
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
the
|
|
10
|
-
and served by the generated worker (`.astrale/`) under
|
|
11
|
-
binding.
|
|
7
|
+
StatusPage, follows its watched Monitor links, and renders the roll-up beside
|
|
8
|
+
the monitored targets, probe timestamps, latency, and HTTP status. "Check now"
|
|
9
|
+
calls the page's `check` instance method and reloads the page plus monitor rows.
|
|
10
|
+
Built into `../.dist/` and served by the generated worker (`.astrale/`) under
|
|
11
|
+
`/ui/*` via its `ASSETS` binding.
|
|
12
12
|
|
|
13
13
|
## Built on the real shell SDK
|
|
14
14
|
|
|
@@ -23,8 +23,8 @@ code imports `@/shell`, `@/ui`, `@/status`.
|
|
|
23
23
|
|
|
24
24
|
Feature-first: each feature owns its types/api/mappers/hooks/components. The
|
|
25
25
|
`shell/` and `ui/` folders are the generic, domain-neutral seams every feature
|
|
26
|
-
builds on. The template ships ONE feature, `status/`, over a
|
|
27
|
-
|
|
26
|
+
builds on. The template ships ONE feature, `status/`, over a StatusPage and its
|
|
27
|
+
watched Monitor nodes.
|
|
28
28
|
|
|
29
29
|
```
|
|
30
30
|
src/
|
|
@@ -47,8 +47,8 @@ src/
|
|
|
47
47
|
value.tsx # KV / Mono / ExternalLink
|
|
48
48
|
format.ts # relativeTime
|
|
49
49
|
index.ts # barrel → @/ui
|
|
50
|
-
status/ # THE status feature —
|
|
51
|
-
status.types.ts # CheckableRecord
|
|
50
|
+
status/ # THE status feature — StatusPage + watched Monitors
|
|
51
|
+
status.types.ts # StatusPanelRecord / CheckableRecord
|
|
52
52
|
status.api.ts # check(session, id) → @<id>::check
|
|
53
53
|
status.mappers.ts # checkableFromNode (node → record)
|
|
54
54
|
hooks/ # useCheckable.query / useCheck.mutation
|
|
@@ -50,11 +50,36 @@ function handshake(targetNodeId?: string) {
|
|
|
50
50
|
const page = (id: string, status: string, name = 'Public status') =>
|
|
51
51
|
checkableNode({ id, name, status, className: 'StatusPage' })
|
|
52
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
|
+
|
|
53
76
|
describe('status view (/ui/status-page)', () => {
|
|
54
|
-
it('loads the StatusPage node and renders its
|
|
77
|
+
it('loads the StatusPage node and renders its watched monitors', async () => {
|
|
55
78
|
const parent = handshake('page-1')
|
|
56
79
|
const kernel = installFakeKernel((body) => {
|
|
57
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'))
|
|
58
83
|
return { error: { code: 3002, message: `unexpected: ${body.method}` } }
|
|
59
84
|
})
|
|
60
85
|
|
|
@@ -68,12 +93,16 @@ describe('status view (/ui/status-page)', () => {
|
|
|
68
93
|
expect(screen.getByText('Public status')).toBeTruthy()
|
|
69
94
|
const badge = screen.getByText('DEGRADED')
|
|
70
95
|
expect(badge.className).toContain('status-degraded')
|
|
71
|
-
|
|
72
|
-
expect(screen.
|
|
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()
|
|
73
100
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
101
|
+
expect(kernel.calls.map((c) => c.body.method)).toEqual([
|
|
102
|
+
'@page-1::get',
|
|
103
|
+
'@page-1::getLinks',
|
|
104
|
+
`${MONITOR_PATH}::get`,
|
|
105
|
+
])
|
|
77
106
|
} finally {
|
|
78
107
|
kernel.restore()
|
|
79
108
|
parent.restore()
|
|
@@ -89,6 +118,8 @@ describe('status view (/ui/status-page)', () => {
|
|
|
89
118
|
checked = true
|
|
90
119
|
return ok(null)
|
|
91
120
|
}
|
|
121
|
+
if (body.method === '@page-1::getLinks') return ok(watchLinks())
|
|
122
|
+
if (body.method === `${MONITOR_PATH}::get`) return ok(monitor(checked ? 'up' : 'down'))
|
|
92
123
|
return ok(page('page-1', checked ? 'up' : 'down'))
|
|
93
124
|
})
|
|
94
125
|
|
|
@@ -96,8 +127,8 @@ describe('status view (/ui/status-page)', () => {
|
|
|
96
127
|
render(<App />)
|
|
97
128
|
await flush()
|
|
98
129
|
|
|
99
|
-
expect(screen.
|
|
100
|
-
expect(kernel.calls).toHaveLength(
|
|
130
|
+
expect(screen.getAllByText('DOWN').length).toBeGreaterThan(0)
|
|
131
|
+
expect(kernel.calls).toHaveLength(3)
|
|
101
132
|
expect(kernel.calls[0]!.body.method).toBe('@page-1::get')
|
|
102
133
|
|
|
103
134
|
await act(async () => {
|
|
@@ -108,10 +139,14 @@ describe('status view (/ui/status-page)', () => {
|
|
|
108
139
|
// A ::check call was issued, then the node was re-fetched.
|
|
109
140
|
expect(kernel.calls.map((c) => c.body.method)).toEqual([
|
|
110
141
|
'@page-1::get',
|
|
142
|
+
'@page-1::getLinks',
|
|
143
|
+
`${MONITOR_PATH}::get`,
|
|
111
144
|
'@page-1::check',
|
|
112
145
|
'@page-1::get',
|
|
146
|
+
'@page-1::getLinks',
|
|
147
|
+
`${MONITOR_PATH}::get`,
|
|
113
148
|
])
|
|
114
|
-
expect(screen.
|
|
149
|
+
expect(screen.getAllByText('UP').length).toBeGreaterThan(0)
|
|
115
150
|
} finally {
|
|
116
151
|
kernel.restore()
|
|
117
152
|
parent.restore()
|
|
@@ -120,11 +155,14 @@ describe('status view (/ui/status-page)', () => {
|
|
|
120
155
|
|
|
121
156
|
it('surfaces a check failure without losing the loaded record', async () => {
|
|
122
157
|
const parent = handshake('page-1')
|
|
123
|
-
const kernel = installFakeKernel((body) =>
|
|
124
|
-
body.method === '@page-1::check'
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
+
})
|
|
128
166
|
|
|
129
167
|
try {
|
|
130
168
|
render(<App />)
|
|
@@ -165,17 +203,18 @@ describe('status view (/ui/status-page)', () => {
|
|
|
165
203
|
|
|
166
204
|
it('re-fetches on a setTarget hot-swap', async () => {
|
|
167
205
|
const parent = handshake('page-1')
|
|
168
|
-
const kernel = installFakeKernel((body) =>
|
|
169
|
-
body.method
|
|
206
|
+
const kernel = installFakeKernel((body) => {
|
|
207
|
+
if (body.method.endsWith('::getLinks')) return ok([])
|
|
208
|
+
return body.method === '@page-2::get'
|
|
170
209
|
? ok(page('page-2', 'down', 'Internal status'))
|
|
171
|
-
: ok(page('page-1', 'up', 'Public status'))
|
|
172
|
-
)
|
|
210
|
+
: ok(page('page-1', 'up', 'Public status'))
|
|
211
|
+
})
|
|
173
212
|
|
|
174
213
|
try {
|
|
175
214
|
render(<App />)
|
|
176
215
|
await flush()
|
|
177
216
|
expect(screen.getByText('Public status')).toBeTruthy()
|
|
178
|
-
expect(screen.
|
|
217
|
+
expect(screen.getAllByText('UP').length).toBeGreaterThan(0)
|
|
179
218
|
|
|
180
219
|
// Parent pushes a new target node.
|
|
181
220
|
parent.setTarget('page-2')
|
|
@@ -250,6 +250,10 @@ export function checkableNode(opts: {
|
|
|
250
250
|
path?: string
|
|
251
251
|
name?: string
|
|
252
252
|
status?: string
|
|
253
|
+
url?: string
|
|
254
|
+
statusCode?: number
|
|
255
|
+
latencyMs?: number
|
|
256
|
+
lastCheckedAt?: string
|
|
253
257
|
className?: string
|
|
254
258
|
domain?: string
|
|
255
259
|
}): { id: string; path: string; class: { raw: string }; props: Record<string, unknown> } {
|
|
@@ -262,6 +266,18 @@ export function checkableNode(opts: {
|
|
|
262
266
|
if (opts.status !== undefined) {
|
|
263
267
|
props[`${domain}:class.${className}.property.status`] = opts.status
|
|
264
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
|
+
}
|
|
265
281
|
return {
|
|
266
282
|
id: opts.id,
|
|
267
283
|
path: opts.path ?? `/${opts.id}`,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest'
|
|
2
2
|
|
|
3
3
|
import { qualifiedString, readProp, readPropBySuffix } from '@/shell'
|
|
4
|
-
import { checkableFromNode } from '@/status'
|
|
4
|
+
import { checkableFromNode, watchedMonitorRefs } from '@/status'
|
|
5
5
|
import { relativeTime } from '@/ui'
|
|
6
6
|
|
|
7
7
|
import { checkableNode } from './harness'
|
|
@@ -36,16 +36,36 @@ describe('checkableFromNode mapper', () => {
|
|
|
36
36
|
const record = checkableFromNode(
|
|
37
37
|
checkableNode({ id: 'page-1', name: 'Public', status: 'degraded', className: 'StatusPage' }),
|
|
38
38
|
)
|
|
39
|
-
expect(record).
|
|
39
|
+
expect(record).toMatchObject({
|
|
40
|
+
id: 'page-1',
|
|
41
|
+
name: 'Public',
|
|
42
|
+
status: 'degraded',
|
|
43
|
+
className: 'StatusPage',
|
|
44
|
+
})
|
|
40
45
|
})
|
|
41
46
|
|
|
42
47
|
it('falls back to the path segment for the name and unknown for a missing status', () => {
|
|
43
|
-
const record = checkableFromNode(
|
|
48
|
+
const record = checkableFromNode(
|
|
49
|
+
checkableNode({ id: 'page-9', path: '/monitoring/pages/edge' }),
|
|
50
|
+
)
|
|
44
51
|
expect(record.name).toBe('edge')
|
|
45
52
|
expect(record.status).toBe('unknown')
|
|
46
53
|
})
|
|
47
54
|
})
|
|
48
55
|
|
|
56
|
+
describe('watchedMonitorRefs mapper', () => {
|
|
57
|
+
it('projects target refs and critical edge weights', () => {
|
|
58
|
+
const refs = watchedMonitorRefs([
|
|
59
|
+
{
|
|
60
|
+
class: { raw: '/:monitors.astrale.ai:class.watches' },
|
|
61
|
+
target: { raw: '/monitoring/monitors/astrale' },
|
|
62
|
+
props: { 'monitors.astrale.ai:class.watches.property.critical': true },
|
|
63
|
+
},
|
|
64
|
+
])
|
|
65
|
+
expect(refs).toEqual([{ target: '/monitoring/monitors/astrale', critical: true }])
|
|
66
|
+
})
|
|
67
|
+
})
|
|
68
|
+
|
|
49
69
|
describe('relativeTime', () => {
|
|
50
70
|
const now = Date.parse('2026-06-14T12:00:00Z')
|
|
51
71
|
it('renders coarse buckets and a dash for missing input', () => {
|
|
@@ -51,6 +51,9 @@ export function useAsync<T>(fn: () => Promise<T>, deps: unknown[]): AsyncResourc
|
|
|
51
51
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- deps are the caller's cache key
|
|
52
52
|
}, [...deps, epoch])
|
|
53
53
|
|
|
54
|
-
const reload = useCallback(() =>
|
|
54
|
+
const reload = useCallback(() => {
|
|
55
|
+
setReloading(true)
|
|
56
|
+
setEpoch((e) => e + 1)
|
|
57
|
+
}, [])
|
|
55
58
|
return { state, reload, reloading }
|
|
56
59
|
}
|