@crewhaus/hangar-ui 0.5.2 → 0.5.3

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/assets/js/api.js CHANGED
@@ -257,6 +257,9 @@ export const api = {
257
257
  addGroup: (name) => call(ROUTES.addGroup, {}, { name }),
258
258
  addScanRoot: (dir) => call(ROUTES.addScanRoot, {}, { dir }),
259
259
  setGroups: (id, groups) => call(ROUTES.setGroups, { id }, { groups }),
260
+ setGroupOrder: (id, group, order) => call(ROUTES.setGroupOrder, { id }, { group, order }),
261
+ groupProcPlan: (name, verb) => call(ROUTES.groupProcPlan, { name, verb }),
262
+ groupProc: (name, verb, body = {}) => call(ROUTES.groupProc, { name, verb }, body),
260
263
  setTags: (id, tags) => call(ROUTES.setTags, { id }, { tags }),
261
264
  setPin: (id, pinned) => call(ROUTES.setPin, { id }, { pinned }),
262
265
  setNotes: (id, notes) => call(ROUTES.setNotes, { id }, { notes }),
@@ -46,10 +46,21 @@ export const ROUTES = {
46
46
  scan: { method: "POST", path: "/api/scan", body: "Empty" }, // {}
47
47
  groups: { method: "GET", path: "/api/registry/groups" },
48
48
  addGroup: { method: "POST", path: "/api/registry/groups", body: "GroupCreate" }, // {name, color?}
49
+ // Group-ordered bulk lifecycle. PLAN first, then act: a fleet sweep is the
50
+ // one lifecycle action an operator should read before authorizing, and the
51
+ // plan is the same object the walk consumes.
52
+ groupProcPlan: { method: "GET", path: "/api/registry/groups/:name/proc/:verb" },
53
+ groupProc: {
54
+ method: "POST",
55
+ path: "/api/registry/groups/:name/proc/:verb",
56
+ body: "GroupProc", // {force?, acknowledge?, parallel?}
57
+ },
49
58
  addScanRoot: { method: "POST", path: "/api/registry/scan-roots", body: "ScanRootCreate" }, // {dir}
50
59
  removeHarness: { method: "DELETE", path: "/api/h/:id" },
51
60
  relocate: { method: "POST", path: "/api/h/:id/relocate", body: "Relocate" }, // {newDir}
52
61
  setGroups: { method: "PUT", path: "/api/h/:id/groups", body: "SetGroups" }, // {groups}
62
+ // One member's BOOT position inside a group; `order: null` clears it.
63
+ setGroupOrder: { method: "PUT", path: "/api/h/:id/groups", body: "SetGroupOrder" }, // {group, order}
53
64
  setTags: { method: "PUT", path: "/api/h/:id/tags", body: "SetTags" }, // {tags}
54
65
  setPin: { method: "PUT", path: "/api/h/:id/pin", body: "SetPin" }, // {pinned}
55
66
  setNotes: { method: "PUT", path: "/api/h/:id/notes", body: "SetNotes" }, // {notes}
@@ -19,7 +19,7 @@
19
19
  */
20
20
 
21
21
  import { api, streamRunEvents } from "../api.js";
22
- import { clear, collapsible, dot, el, emptyState, jsonPre, skeleton } from "../dom.js";
22
+ import { clear, collapsible, dot, el, emptyState, jsonPre, skeleton, toast } from "../dom.js";
23
23
  import { hrefHarness } from "../router.js";
24
24
  import { shapeAccent, shapeLabel } from "../shapes.js";
25
25
  import {
@@ -66,6 +66,13 @@ export async function renderRunsBoard(root) {
66
66
  return;
67
67
  }
68
68
 
69
+ // Groups first: a fleet is brought up as a fleet, in the order its members
70
+ // declare, and doing that a row at a time was N clicks in an order the
71
+ // operator had to remember.
72
+ const groupsCard = el("section", { class: "card" });
73
+ root.appendChild(groupsCard);
74
+ void renderGroupStrip(groupsCard, () => renderRunsBoard(root));
75
+
69
76
  const tbody = el("tbody");
70
77
  const table = el("div", { class: "table-scroll" }, [
71
78
  el("table", { class: "fleet" }, [
@@ -158,6 +165,119 @@ export async function renderRunsBoard(root) {
158
165
  }
159
166
  }
160
167
 
168
+ /**
169
+ * The group strip — group-ordered bulk lifecycle, PLAN FIRST.
170
+ *
171
+ * A fleet has dependency order (the secretary everyone's A2A door mounts
172
+ * boots first; the chief that supervises the rest last), and the board was
173
+ * per-row Start/Stop, so bringing one up was N clicks in an order the
174
+ * operator had to remember. Clicking a verb here does NOT act: it fetches
175
+ * the walk and renders it, in order, with every skip named — and only the
176
+ * second click runs it. A sweep that touches a whole fleet should be
177
+ * readable before it is authorized.
178
+ */
179
+ async function renderGroupStrip(card, reload) {
180
+ clear(card).appendChild(el("h3", { class: "card-title", text: "Groups" }));
181
+ let groups = [];
182
+ try {
183
+ groups = (await api.groups())?.groups ?? [];
184
+ } catch {
185
+ // The board is the point; a registry read that fails leaves the strip
186
+ // out rather than taking the page down.
187
+ card.remove();
188
+ return;
189
+ }
190
+ if (groups.length === 0) {
191
+ card.appendChild(
192
+ emptyState("No groups yet", "crewhaus harness group <name> --add <dir> --member-order <n>"),
193
+ );
194
+ return;
195
+ }
196
+ for (const group of groups) {
197
+ const body = el("div", { class: "muted", text: "pick a verb to see the planned order" });
198
+ card.appendChild(
199
+ el("div", { class: "group-row" }, [
200
+ el("div", { class: "row-head" }, [
201
+ el("strong", { text: group.name }),
202
+ el("span", { class: "muted", text: `order ${group.order}` }),
203
+ ...["start", "restart", "stop"].map((verb) =>
204
+ el("button", {
205
+ class: "btn",
206
+ text: verb,
207
+ onclick: () => void showGroupPlan(body, group.name, verb, reload),
208
+ }),
209
+ ),
210
+ ]),
211
+ body,
212
+ ]),
213
+ );
214
+ }
215
+ }
216
+
217
+ /** Render the walk a verb WOULD take, with a confirm that runs it. */
218
+ async function showGroupPlan(body, name, verb, reload) {
219
+ clear(body).appendChild(el("p", { class: "muted", text: "planning…" }));
220
+ let plan = null;
221
+ try {
222
+ plan = (await api.groupProcPlan(name, verb))?.plan ?? null;
223
+ } catch (err) {
224
+ clear(body).appendChild(el("p", { class: "error", text: String(err?.message ?? err) }));
225
+ return;
226
+ }
227
+ if (plan === null || plan.members.length === 0) {
228
+ clear(body).appendChild(el("p", { class: "muted", text: `group ${name} has no members` }));
229
+ return;
230
+ }
231
+ clear(body);
232
+ body.appendChild(
233
+ el("p", {
234
+ text: `${verb} would walk ${plan.actionable} member(s)${
235
+ verb === "stop" ? " in REVERSE boot order" : " in boot order"
236
+ }${plan.skipped > 0 ? `, skipping ${plan.skipped}` : ""}:`,
237
+ }),
238
+ );
239
+ body.appendChild(
240
+ el(
241
+ "ol",
242
+ { class: "plan-list" },
243
+ plan.members.map((m) =>
244
+ el("li", null, [
245
+ el("span", { text: `${m.specName} (${m.target})` }),
246
+ m.declaredOrder === null
247
+ ? el("span", { class: "muted", text: " no declared order" })
248
+ : null,
249
+ m.skip === null ? null : el("span", { class: "muted", text: ` — skipped: ${m.skip}` }),
250
+ ]),
251
+ ),
252
+ ),
253
+ );
254
+ const run = el("button", {
255
+ class: "btn btn-primary",
256
+ text: `run ${verb} on ${plan.actionable} member(s)`,
257
+ onclick: async () => {
258
+ run.disabled = true;
259
+ run.textContent = `${verb}ing…`;
260
+ try {
261
+ const result = (await api.groupProc(name, verb))?.result ?? null;
262
+ toast(
263
+ result === null
264
+ ? `${verb} ${name}: no result`
265
+ : `${verb} ${name}: ${result.ok} ok, ${result.failed} failed${
266
+ result.skipped > 0 ? `, ${result.skipped} skipped` : ""
267
+ }`,
268
+ result !== null && result.failed === 0 ? "ok" : "error",
269
+ );
270
+ reload();
271
+ } catch (err) {
272
+ toast(String(err?.message ?? err), "error");
273
+ run.disabled = false;
274
+ run.textContent = `run ${verb} on ${plan.actionable} member(s)`;
275
+ }
276
+ },
277
+ });
278
+ body.appendChild(run);
279
+ }
280
+
161
281
  function boardRow(feedRow, proc, nowMs, reload) {
162
282
  const name = feedRow.specName || dirTail(feedRow.dir, 1) || feedRow.id;
163
283
  // The fleet feed's `supervision` is the instant answer; `/proc` refines it.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crewhaus/hangar-ui",
3
- "version": "0.5.2",
3
+ "version": "0.5.3",
4
4
  "type": "module",
5
5
  "description": "The Hangar web console's static UI — zero-build, dependency-free browser ES modules embedded as text assets for the single-binary CLI to hand to the hangar server",
6
6
  "main": "dist/index.js",