@jr2/orchestrator 0.2.1 → 0.4.1
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/bin/server.ts +7 -4
- package/console/canvas.ts +27 -14
- package/console/components/app.ts +10 -1
- package/console/components/fleet.ts +39 -6
- package/console/components/machine-pane.ts +89 -2
- package/console/components/nav.ts +1 -1
- package/console/components/schema-form.ts +12 -4
- package/console/json-field.ts +23 -0
- package/console/main.ts +110 -39
- package/console/store.ts +221 -17
- package/console/style.css +90 -0
- package/package.json +4 -5
- package/src/actor.ts +507 -54
- package/src/agent.ts +2 -2
- package/src/ambient.ts +2 -0
- package/src/assembly-checks.ts +165 -0
- package/src/assembly-tar.ts +494 -0
- package/src/assembly.ts +721 -0
- package/src/chart.ts +2050 -0
- package/src/charts.ts +720 -0
- package/src/config.ts +233 -22
- package/src/content-hash.ts +105 -0
- package/src/custodian.lua +313 -0
- package/src/custodian.ts +951 -0
- package/src/customize.ts +216 -25
- package/src/effect-checks.ts +557 -0
- package/src/effect.ts +905 -0
- package/src/effector-wire.ts +525 -0
- package/src/fingerprint.ts +4 -3
- package/src/gate.ts +8 -0
- package/src/git-clone.ts +65 -0
- package/src/git-push.ts +336 -0
- package/src/harness-client.ts +247 -25
- package/src/held-secrets.ts +643 -0
- package/src/holds.ts +156 -0
- package/src/http-effect.ts +981 -0
- package/src/http.ts +514 -119
- package/src/images.ts +58 -31
- package/src/index.ts +49 -4
- package/src/instance.ts +445 -59
- package/src/kube-client.ts +333 -0
- package/src/machine-doc.ts +88 -6
- package/src/model-keys.ts +92 -0
- package/src/mount-digest.ts +53 -0
- package/src/names.ts +89 -7
- package/src/notices.ts +88 -0
- package/src/open-body.ts +1444 -0
- package/src/open-effect.ts +144 -0
- package/src/open.ts +4 -4
- package/src/parts.ts +530 -33
- package/src/party.ts +468 -0
- package/src/pick.ts +309 -0
- package/src/pool.ts +1 -1
- package/src/registration.ts +301 -17
- package/src/repo-fetch.ts +61 -86
- package/src/repos.ts +63 -74
- package/src/rest.ts +133 -0
- package/src/run-host.ts +1841 -105
- package/src/sandbox-effect.ts +891 -0
- package/src/sandbox-effector.ts +111 -0
- package/src/sandbox-kube.ts +1719 -0
- package/src/sandbox-watch.ts +260 -0
- package/src/secret-placement.ts +494 -0
- package/src/server.ts +285 -89
- package/src/setup.ts +589 -70
- package/src/size.ts +342 -0
- package/src/snapshot-store.ts +64 -13
- package/src/stub-harness.ts +5 -0
- package/src/sync-client.ts +54 -0
- package/src/timers.ts +178 -0
- package/src/tokens.ts +44 -12
- package/src/vocabulary.ts +66 -1
- package/src/wire.ts +156 -2
- package/src/workspace.ts +529 -194
- package/src/sandbox-kubectl.ts +0 -1144
package/bin/server.ts
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// The
|
|
3
|
-
// serve until SIGINT/SIGTERM.
|
|
4
|
-
//
|
|
5
|
-
//
|
|
2
|
+
// The Orchestrator Kit image's entrypoint (ADR-0019, ADR-0069): boot the orchestrator described
|
|
3
|
+
// by env + cwd and serve until SIGINT/SIGTERM. The cwd holds the state volume, and the Workflows
|
|
4
|
+
// come from the current Assembly on it; the folder itself is never loaded, so a process with no
|
|
5
|
+
// published Assembly serves none until `jr2 up` publishes one. A zero-build `.ts` shebang like
|
|
6
|
+
// the `jr2` bin; the e2e tier spawns this exact process as its per-scenario fixture and publishes
|
|
7
|
+
// through the same door (ADR-0010). Logic lives in `serverMain` — this file owns only what a
|
|
8
|
+
// process owns: env, cwd, stdout, signals, exit.
|
|
6
9
|
|
|
7
10
|
import { serverMain } from "../src/server.ts";
|
|
8
11
|
|
package/console/canvas.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// Working state only, never belief: the fold set, the viewport transform, and what is SHOWN live
|
|
9
9
|
// here — everything the page believes stays in store.ts and arrives as `updateCanvas` arguments.
|
|
10
10
|
|
|
11
|
-
import type { GateCard, ObservedRun } from "./store.ts";
|
|
11
|
+
import type { GateCard, ObservedRun, Seat } from "./store.ts";
|
|
12
12
|
|
|
13
13
|
// ---- The machine doc, as `GET /workflows/:name/machine` serves it -------------------------------
|
|
14
14
|
// Mirrors machine-doc.ts (the server's serializer) the way store.ts mirrors `RunObservation`: the
|
|
@@ -57,7 +57,8 @@ export type ChildMachineDoc = {
|
|
|
57
57
|
export type MachineEventDoc = {
|
|
58
58
|
name: string;
|
|
59
59
|
description?: string;
|
|
60
|
-
|
|
60
|
+
/** Absent: each writing's state decides who may send it (ADR-0015). */
|
|
61
|
+
audience?: "agent" | "external" | "any";
|
|
61
62
|
/** The def's input schema as JSON Schema. */
|
|
62
63
|
input: unknown;
|
|
63
64
|
};
|
|
@@ -70,6 +71,10 @@ export type MachineBodyDoc = {
|
|
|
70
71
|
/** The events THIS Machine declares, and only this one (ADR-0049): a nested Machine's ride its
|
|
71
72
|
* own body doc, because event names are scoped to the Machine that declared them. */
|
|
72
73
|
events: MachineEventDoc[];
|
|
74
|
+
/** Who may send what, per state (ADR-0015) — mirrors `MachineSeat`. */
|
|
75
|
+
seats: Seat[];
|
|
76
|
+
/** What the build found legal and suspect (ADR-0015). */
|
|
77
|
+
warnings: string[];
|
|
73
78
|
};
|
|
74
79
|
|
|
75
80
|
/** The serialized structure of a workflow's Machine — mirrors `MachineDoc`. */
|
|
@@ -77,6 +82,8 @@ export type MachineDoc = MachineBodyDoc & {
|
|
|
77
82
|
workflow: string;
|
|
78
83
|
/** States whose child list may be incomplete (enqueueActions closures) — surfaced as a notice. */
|
|
79
84
|
opaqueStates?: string[];
|
|
85
|
+
/** A run's doc only: each bound Chart it could not draw, and why (ADR-0070) — surfaced as a notice. */
|
|
86
|
+
chartsNotDrawn?: Array<{ path: string; src: string; reason: string }>;
|
|
80
87
|
};
|
|
81
88
|
|
|
82
89
|
/** One live child actor as the observation band reports it — `ObservedRun.children`, deepened
|
|
@@ -138,9 +145,11 @@ export function initCanvas(els: { canvas: HTMLElement; svg: SVGSVGElement }, can
|
|
|
138
145
|
svg = els.svg;
|
|
139
146
|
hooks = canvasHooks;
|
|
140
147
|
wireCanvasNavigation();
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
148
|
+
// The pane changes size with the window AND with the strips above the diagram (a wait, a held
|
|
149
|
+
// pick, the seats opening) — a fit follows either.
|
|
150
|
+
new ResizeObserver(() => {
|
|
151
|
+
if (fitting) fitWhole();
|
|
152
|
+
}).observe(svg);
|
|
144
153
|
}
|
|
145
154
|
|
|
146
155
|
const SVG_NS = "http://www.w3.org/2000/svg";
|
|
@@ -498,8 +507,8 @@ let layoutSize = { width: 0, height: 0 };
|
|
|
498
507
|
let scale = 1;
|
|
499
508
|
let panX = 0;
|
|
500
509
|
let panY = 0;
|
|
501
|
-
/** Fit the Machine
|
|
502
|
-
* reader takes the view into their own hands, after which it is theirs. */
|
|
510
|
+
/** Fit the whole Machine to the canvas, and keep fitting it as the diagram or the pane changes —
|
|
511
|
+
* until the reader takes the view into their own hands, after which it is theirs. */
|
|
503
512
|
let fitting = true;
|
|
504
513
|
/** The <g> the current render mounted — the transform target; each re-render swaps it in and
|
|
505
514
|
* re-applies the view, so a relayout never resets where the reader was looking. */
|
|
@@ -513,11 +522,15 @@ function applyView(): void {
|
|
|
513
522
|
hooks.onScale(Math.round(scale * 100)); // the nav readout follows every path
|
|
514
523
|
}
|
|
515
524
|
|
|
516
|
-
/** Scale so the whole width lands in the pane, centered.
|
|
517
|
-
* 1:1
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
525
|
+
/** Scale so the whole Machine — its width AND its height — lands in the pane, centered across.
|
|
526
|
+
* Never magnifies (a small Machine stays 1:1), and never below the floor: a Machine larger than the
|
|
527
|
+
* floor allows pans. Fitting the width alone left a tall Machine's lower states — a `workspace()`
|
|
528
|
+
* body under the wrapper's chain — below the pane, where no reader looks for them. */
|
|
529
|
+
function fitWhole(): void {
|
|
530
|
+
if (!layoutSize.width || svg.clientWidth <= 0 || svg.clientHeight <= 0) return applyView();
|
|
531
|
+
const across = (svg.clientWidth - FIT_PAD * 2) / layoutSize.width;
|
|
532
|
+
const down = (svg.clientHeight - FIT_PAD * 2) / layoutSize.height;
|
|
533
|
+
scale = Math.min(1, Math.max(0.2, Math.min(across, down)));
|
|
521
534
|
panX = Math.max(FIT_PAD, (svg.clientWidth - layoutSize.width * scale) / 2);
|
|
522
535
|
panY = FIT_PAD;
|
|
523
536
|
applyView();
|
|
@@ -547,7 +560,7 @@ export function zoomReset(): void {
|
|
|
547
560
|
}
|
|
548
561
|
export function zoomFit(): void {
|
|
549
562
|
fitting = true;
|
|
550
|
-
|
|
563
|
+
fitWhole();
|
|
551
564
|
}
|
|
552
565
|
|
|
553
566
|
// ---- Direct navigation: drag pans, wheel zooms --------------------------------------------------
|
|
@@ -638,7 +651,7 @@ async function renderMachine(doc: MachineBodyDoc, live: RunChild[]): Promise<voi
|
|
|
638
651
|
renderEdges(layout, rootG);
|
|
639
652
|
// The fresh <g> becomes the viewport, wearing the view the reader already had (or a fit).
|
|
640
653
|
viewportG = rootG;
|
|
641
|
-
fitting ?
|
|
654
|
+
fitting ? fitWhole() : applyView();
|
|
642
655
|
}
|
|
643
656
|
|
|
644
657
|
// ---- Live runs ----------------------------------------------------------------------------------
|
|
@@ -6,7 +6,14 @@
|
|
|
6
6
|
|
|
7
7
|
import { Fragment, h, type JSX } from "preact";
|
|
8
8
|
import { useState } from "preact/hooks";
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
selectedRun,
|
|
11
|
+
selectedRunGates,
|
|
12
|
+
selectedRunHeld,
|
|
13
|
+
selectedRunWaiting,
|
|
14
|
+
type Frame,
|
|
15
|
+
type Store,
|
|
16
|
+
} from "../store.ts";
|
|
10
17
|
import type { MachineDoc } from "../canvas.ts";
|
|
11
18
|
import { Nav } from "./nav.ts";
|
|
12
19
|
import { Fleet } from "./fleet.ts";
|
|
@@ -71,6 +78,8 @@ export function App(props: {
|
|
|
71
78
|
run: selectedRun(store),
|
|
72
79
|
selectedNodeId: store.selectedNodeId,
|
|
73
80
|
gates: selectedRunGates(store),
|
|
81
|
+
waiting: selectedRunWaiting(store),
|
|
82
|
+
held: selectedRunHeld(store),
|
|
74
83
|
api,
|
|
75
84
|
}),
|
|
76
85
|
h(Drawer, { store, chosenTab, onChooseTab: setChosenTab, api }),
|
|
@@ -2,14 +2,18 @@
|
|
|
2
2
|
// master–detail shell (ADR-0032). Every row derives from store selectors (`fleetRuns`), never a
|
|
3
3
|
// component-local cache; the vdom's keyed diff is what keeps a half-typed start form alive under
|
|
4
4
|
// the status frames that used to demand main.js's hand-rolled `memo()`.
|
|
5
|
+
//
|
|
6
|
+
// A named Chart is a Workflow (ADR-0070): it lists under its Chart wrapper, indented, with its origin
|
|
7
|
+
// (`chart file` or `chart`) and, when a publish no longer admits it, `unrunnable` with the reason.
|
|
5
8
|
|
|
6
9
|
import { h, type JSX } from "preact";
|
|
7
|
-
import { fleetRuns, type ObservedRun, type Store } from "../store.ts";
|
|
10
|
+
import { fleetEntries, fleetRuns, type FleetEntry, type ObservedRun, type Store } from "../store.ts";
|
|
8
11
|
import { SchemaForm } from "./schema-form.ts";
|
|
9
12
|
import type { AppApi } from "./app.ts";
|
|
10
13
|
|
|
11
14
|
export function Fleet(props: { store: Store; schemas: ReadonlyMap<string, unknown>; api: AppApi }): JSX.Element {
|
|
12
15
|
const { store, schemas, api } = props;
|
|
16
|
+
const entries = fleetEntries(store.listing);
|
|
13
17
|
return h(
|
|
14
18
|
"aside",
|
|
15
19
|
{ id: "fleet" },
|
|
@@ -17,18 +21,38 @@ export function Fleet(props: { store: Store; schemas: ReadonlyMap<string, unknow
|
|
|
17
21
|
h(
|
|
18
22
|
"ul",
|
|
19
23
|
{ id: "workflow-list" },
|
|
20
|
-
|
|
21
|
-
?
|
|
24
|
+
entries.length
|
|
25
|
+
? entries.map((entry) => workflowItem(entry, store, schemas, api))
|
|
22
26
|
: h("li", { class: "empty" }, "no workflows registered"),
|
|
23
27
|
),
|
|
24
28
|
);
|
|
25
29
|
}
|
|
26
30
|
|
|
27
|
-
|
|
31
|
+
/** How the rail names an origin (ADR-0070); a Machine that is no Chart wrapper shows none. */
|
|
32
|
+
function originTag(entry: FleetEntry): string | null {
|
|
33
|
+
if (entry.origin === "file") return "chart file";
|
|
34
|
+
if (entry.origin === "runtime") return "chart";
|
|
35
|
+
if (entry.wrapper) return entry.wrapper.oneShot ? "chart wrapper, one-shot" : "chart wrapper";
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function workflowItem(
|
|
40
|
+
entry: FleetEntry,
|
|
41
|
+
store: Store,
|
|
42
|
+
schemas: ReadonlyMap<string, unknown>,
|
|
43
|
+
api: AppApi,
|
|
44
|
+
): JSX.Element {
|
|
45
|
+
const name = entry.name;
|
|
28
46
|
const rows = fleetRuns(store, name);
|
|
47
|
+
const tag = originTag(entry);
|
|
29
48
|
return h(
|
|
30
49
|
"li",
|
|
31
|
-
{
|
|
50
|
+
{
|
|
51
|
+
key: name,
|
|
52
|
+
class: `${name === store.workflow ? "wf wf--selected" : "wf"}${entry.depth ? " wf--chart" : ""}`,
|
|
53
|
+
// A Chart indents under its wrapper; the rail's stylesheet has no rule for depth yet.
|
|
54
|
+
style: entry.depth ? { marginLeft: "14px" } : undefined,
|
|
55
|
+
},
|
|
32
56
|
h(
|
|
33
57
|
"div",
|
|
34
58
|
{ class: "wf-head", onClick: () => void api.selectWorkflow(name, { push: true }) },
|
|
@@ -45,8 +69,17 @@ function workflowItem(name: string, store: Store, schemas: ReadonlyMap<string, u
|
|
|
45
69
|
store.expanded.has(name) ? "▾" : "▸",
|
|
46
70
|
),
|
|
47
71
|
h("span", { class: "wf-name" }, name),
|
|
72
|
+
tag ? h("span", { class: "wf-count wf-origin" }, tag) : null,
|
|
73
|
+
entry.unrunnable ? h("span", { class: "wf-count wf-unrunnable", title: entry.unrunnable }, "unrunnable") : null,
|
|
74
|
+
entry.shadows
|
|
75
|
+
? h(
|
|
76
|
+
"span",
|
|
77
|
+
{ class: "wf-count wf-unrunnable", title: entry.shadows.unrunnable ?? "a Chart of this name is shadowed" },
|
|
78
|
+
entry.shadows.origin === "runtime" ? "shadows a chart" : "shadows a chart file",
|
|
79
|
+
)
|
|
80
|
+
: null,
|
|
48
81
|
h("span", { class: "wf-count" }, String(rows.filter((r) => !r.settled).length)),
|
|
49
|
-
store.token === "live"
|
|
82
|
+
store.token === "live" && !entry.unrunnable
|
|
50
83
|
? h(
|
|
51
84
|
"button",
|
|
52
85
|
{
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
import { h, type JSX } from "preact";
|
|
7
7
|
import { useEffect, useRef } from "preact/hooks";
|
|
8
|
-
import type
|
|
8
|
+
import { docSeats, type GateCard, type HeldLine, type ObservedRun, type WaitingLine } from "../store.ts";
|
|
9
9
|
import {
|
|
10
10
|
clearCanvas,
|
|
11
11
|
initCanvas,
|
|
@@ -21,9 +21,11 @@ export function MachinePane(props: {
|
|
|
21
21
|
run: ObservedRun | null;
|
|
22
22
|
selectedNodeId: string | null;
|
|
23
23
|
gates: GateCard[];
|
|
24
|
+
waiting: WaitingLine[];
|
|
25
|
+
held: HeldLine[];
|
|
24
26
|
api: AppApi;
|
|
25
27
|
}): JSX.Element {
|
|
26
|
-
const { view, run, selectedNodeId, gates, api } = props;
|
|
28
|
+
const { view, run, selectedNodeId, gates, waiting, held, api } = props;
|
|
27
29
|
const canvasRef = useRef<HTMLElement>(null);
|
|
28
30
|
const svgRef = useRef<SVGSVGElement>(null);
|
|
29
31
|
|
|
@@ -57,6 +59,9 @@ export function MachinePane(props: {
|
|
|
57
59
|
h("div", { id: "placeholder", hidden: !view.placeholder }, view.placeholder ?? ""),
|
|
58
60
|
h("div", { id: "error", hidden: !view.note, class: view.note?.notice ? "notice" : "" }, view.note?.text ?? ""),
|
|
59
61
|
rootTransitionStrip(view.doc),
|
|
62
|
+
seatsPanel(view.doc),
|
|
63
|
+
waitingStrip(waiting),
|
|
64
|
+
heldStrip(held),
|
|
60
65
|
h("svg", { id: "machine-svg", ref: svgRef }),
|
|
61
66
|
);
|
|
62
67
|
}
|
|
@@ -83,3 +88,85 @@ function rootTransitionStrip(doc: MachineDoc | null): JSX.Element {
|
|
|
83
88
|
}),
|
|
84
89
|
);
|
|
85
90
|
}
|
|
91
|
+
|
|
92
|
+
/** Who may send what, per state, and the load warnings (ADR-0015) — what `jr2 up` prints, for the
|
|
93
|
+
* Machine and every Machine it composes. Structure, so tokenless too. Folded by default: the
|
|
94
|
+
* diagram keeps the pane, and a warning shows in the summary so it is not missed folded. */
|
|
95
|
+
function seatsPanel(doc: MachineDoc | null): JSX.Element | null {
|
|
96
|
+
const { seats, warnings } = docSeats(doc);
|
|
97
|
+
if (!seats.length && !warnings.length) return null;
|
|
98
|
+
return h(
|
|
99
|
+
"details",
|
|
100
|
+
{ id: "seats" },
|
|
101
|
+
h(
|
|
102
|
+
"summary",
|
|
103
|
+
null,
|
|
104
|
+
"who may send what",
|
|
105
|
+
h("span", { class: "dim" }, ` · ${seats.length} seat${seats.length === 1 ? "" : "s"}`),
|
|
106
|
+
warnings.length
|
|
107
|
+
? h("span", { class: "seats-warned" }, ` · ${warnings.length} warning${warnings.length === 1 ? "" : "s"}`)
|
|
108
|
+
: null,
|
|
109
|
+
),
|
|
110
|
+
h(
|
|
111
|
+
"ul",
|
|
112
|
+
{ class: "seat-list" },
|
|
113
|
+
seats.map((seat) =>
|
|
114
|
+
h(
|
|
115
|
+
"li",
|
|
116
|
+
{ key: `${seat.state} ${seat.party} ${seat.agent ?? ""}` },
|
|
117
|
+
h("span", { class: "seat-state" }, seat.state),
|
|
118
|
+
h("span", { class: "dim" }, seat.party === "agent" ? ` agent:${seat.agent}` : " gate"),
|
|
119
|
+
h("span", { class: "seat-names" }, ` ${seat.names.length ? seat.names.join(", ") : "—"}`),
|
|
120
|
+
),
|
|
121
|
+
),
|
|
122
|
+
),
|
|
123
|
+
warnings.length
|
|
124
|
+
? h(
|
|
125
|
+
"ul",
|
|
126
|
+
{ class: "seat-warnings" },
|
|
127
|
+
warnings.map((w) => h("li", { key: w }, w)),
|
|
128
|
+
)
|
|
129
|
+
: null,
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Why the selected run's Workspaces in `placing` wait (ADR-0064), one line each, in the
|
|
134
|
+
* scheduler's or the quota's own words. The diagram lights `placing` for every viewer; this reason
|
|
135
|
+
* is guarded, so it is here only with a live token. */
|
|
136
|
+
function waitingStrip(waiting: WaitingLine[]): JSX.Element {
|
|
137
|
+
return h(
|
|
138
|
+
"ul",
|
|
139
|
+
{ id: "waiting" },
|
|
140
|
+
waiting.map((w) =>
|
|
141
|
+
h(
|
|
142
|
+
"li",
|
|
143
|
+
{ key: w.child },
|
|
144
|
+
h(
|
|
145
|
+
"span",
|
|
146
|
+
{ class: "waiting-head" },
|
|
147
|
+
`${w.child || "Workspace"} waits for ${w.on === "quota" ? "quota" : "a node"}`,
|
|
148
|
+
),
|
|
149
|
+
h("span", { class: "dim", title: w.since }, ` since ${new Date(w.since).toLocaleTimeString()}`),
|
|
150
|
+
h("div", { class: "waiting-message" }, w.message),
|
|
151
|
+
),
|
|
152
|
+
),
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** The selected run's outstanding picks (ADR-0024): the Agent, its pick, and since when it is held.
|
|
157
|
+
* A held Turn has no default time bound, so this is where a human sees one that waits too long.
|
|
158
|
+
* Guarded like the waiting strip: here only with a live token. */
|
|
159
|
+
function heldStrip(held: HeldLine[]): JSX.Element {
|
|
160
|
+
return h(
|
|
161
|
+
"ul",
|
|
162
|
+
{ id: "held" },
|
|
163
|
+
held.map((line) =>
|
|
164
|
+
h(
|
|
165
|
+
"li",
|
|
166
|
+
{ key: `${line.agent} ${line.pick}` },
|
|
167
|
+
h("span", { class: "held-head" }, `${line.agent || "Agent"}'s pick ${line.pick} runs`),
|
|
168
|
+
h("span", { class: "dim", title: line.since }, ` held since ${new Date(line.since).toLocaleTimeString()}`),
|
|
169
|
+
),
|
|
170
|
+
),
|
|
171
|
+
);
|
|
172
|
+
}
|
|
@@ -50,7 +50,7 @@ export function Nav(props: {
|
|
|
50
50
|
h("button", { id: "zoom-out", title: "zoom out", onClick: () => api.zoomOut() }, "−"),
|
|
51
51
|
h("button", { id: "zoom-reset", title: "reset zoom", onClick: () => api.zoomReset() }, `${zoomPct}%`),
|
|
52
52
|
h("button", { id: "zoom-in", title: "zoom in", onClick: () => api.zoomIn() }, "+"),
|
|
53
|
-
h("button", { id: "zoom-fit", title: "fit
|
|
53
|
+
h("button", { id: "zoom-fit", title: "fit the whole Machine", onClick: () => api.zoomFit() }, "fit"),
|
|
54
54
|
),
|
|
55
55
|
h(
|
|
56
56
|
"button",
|
|
@@ -1,15 +1,14 @@
|
|
|
1
1
|
// The shared form (start-run + gate delivery — ADR-0033): one component for both writes the
|
|
2
2
|
// Console makes. A flat object schema becomes typed inputs (string/number/boolean/enum, required
|
|
3
|
-
// marked
|
|
3
|
+
// marked; an object or array property is a JSON textarea, so a Chart wrapper's `chart` field —
|
|
4
|
+
// ADR-0070 — goes as an object, not as a string), no schema becomes a raw JSON textarea. `onSubmit` resolves to an error string to render
|
|
4
5
|
// inline (the server's 400 names the accepted shape — that text IS the form's error display) or null when the write
|
|
5
6
|
// landed. The inputs are UNCONTROLLED: what the reader is typing is theirs, not state, and the
|
|
6
7
|
// vdom's keyed diff keeps the elements — and so the half-typed text — alive across status frames.
|
|
7
8
|
|
|
8
9
|
import { h, type JSX } from "preact";
|
|
9
10
|
import { useState } from "preact/hooks";
|
|
10
|
-
|
|
11
|
-
/** One property of a flat object schema, as far as the form reads it. */
|
|
12
|
-
type SchemaProp = { type?: string; enum?: unknown[]; description?: string };
|
|
11
|
+
import { jsonField, type SchemaProp } from "../json-field.ts";
|
|
13
12
|
|
|
14
13
|
/** The schema's `properties`, or null when there is no schema to generate from (→ raw textarea). */
|
|
15
14
|
function propsOf(schema: unknown): Record<string, SchemaProp> | null {
|
|
@@ -89,12 +88,16 @@ function field(key: string, prop: SchemaProp, required: Set<unknown>): JSX.Eleme
|
|
|
89
88
|
);
|
|
90
89
|
} else if (prop.type === "number" || prop.type === "integer") {
|
|
91
90
|
input = h("input", { name: key, type: "number", step: "any" });
|
|
91
|
+
} else if (isJsonType(prop.type)) {
|
|
92
|
+
input = h("textarea", { name: key, rows: 4, placeholder: `JSON ${prop.type}` });
|
|
92
93
|
} else {
|
|
93
94
|
input = h("input", { name: key, type: "text" });
|
|
94
95
|
}
|
|
95
96
|
return h("label", { key, title: prop.description }, h("span", null, required.has(key) ? `${key} *` : key), input);
|
|
96
97
|
}
|
|
97
98
|
|
|
99
|
+
const isJsonType = (type: string | undefined): type is "object" | "array" => type === "object" || type === "array";
|
|
100
|
+
|
|
98
101
|
/** The submit body, read back off the DOM the fields live in. Typed fields: empty optional inputs
|
|
99
102
|
* are ABSENT, not "" — the server's schema is the authority on required-ness and says so in its
|
|
100
103
|
* 400. The raw textarea must parse to an object. */
|
|
@@ -116,6 +119,11 @@ function collect(form: HTMLFormElement, properties: Record<string, SchemaProp> |
|
|
|
116
119
|
const body: Record<string, unknown> = {};
|
|
117
120
|
for (const [key, prop] of Object.entries(properties)) {
|
|
118
121
|
const el = form.elements.namedItem(key);
|
|
122
|
+
if (el instanceof HTMLTextAreaElement) {
|
|
123
|
+
const value = jsonField(key, prop, el.value);
|
|
124
|
+
if (value !== undefined) body[key] = value;
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
119
127
|
if (!(el instanceof HTMLInputElement || el instanceof HTMLSelectElement)) continue;
|
|
120
128
|
if (el instanceof HTMLInputElement && el.type === "checkbox") {
|
|
121
129
|
body[key] = el.checked; // a required boolean: always an answer, by design
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// An object or array property's textarea, parsed (ADR-0033, ADR-0070): a Chart wrapper's one-shot
|
|
2
|
+
// door takes `chart` as an object, so the Console's start form sends JSON of the property's type,
|
|
3
|
+
// never a string. DOM-free, so the Node test project can read it without the browser libs.
|
|
4
|
+
|
|
5
|
+
/** One property of a flat object schema, as far as the form reads it. */
|
|
6
|
+
export type SchemaProp = { type?: string; enum?: unknown[]; description?: string };
|
|
7
|
+
|
|
8
|
+
/** An object or array property's textarea, parsed: absent when empty, an error that names the
|
|
9
|
+
* field when the text is not JSON of the property's type. */
|
|
10
|
+
export function jsonField(key: string, prop: SchemaProp, text: string): unknown {
|
|
11
|
+
const trimmed = text.trim();
|
|
12
|
+
if (!trimmed) return undefined;
|
|
13
|
+
let parsed: unknown;
|
|
14
|
+
try {
|
|
15
|
+
parsed = JSON.parse(trimmed);
|
|
16
|
+
} catch {
|
|
17
|
+
throw new Error(`${key}: not valid JSON`);
|
|
18
|
+
}
|
|
19
|
+
const isArray = Array.isArray(parsed);
|
|
20
|
+
const ok = prop.type === "array" ? isArray : parsed !== null && typeof parsed === "object" && !isArray;
|
|
21
|
+
if (!ok) throw new Error(`${key}: must be a JSON ${prop.type}`);
|
|
22
|
+
return parsed;
|
|
23
|
+
}
|