@crewhaus/hangar-ui 0.5.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/README.md +309 -0
- package/assets/hangar.css +1726 -0
- package/assets/index.html +21 -0
- package/assets/js/api.js +368 -0
- package/assets/js/app.js +734 -0
- package/assets/js/dom.js +228 -0
- package/assets/js/keys.js +159 -0
- package/assets/js/markdown.js +131 -0
- package/assets/js/notify.js +152 -0
- package/assets/js/omnibox.js +307 -0
- package/assets/js/router.js +280 -0
- package/assets/js/routes.js +793 -0
- package/assets/js/shapes.js +36 -0
- package/assets/js/supervision.js +1334 -0
- package/assets/js/util.js +426 -0
- package/assets/js/views/activity.js +140 -0
- package/assets/js/views/approvals.js +155 -0
- package/assets/js/views/channels.js +455 -0
- package/assets/js/views/control.js +253 -0
- package/assets/js/views/costs.js +103 -0
- package/assets/js/views/creds.js +610 -0
- package/assets/js/views/data.js +456 -0
- package/assets/js/views/deploy.js +106 -0
- package/assets/js/views/evals-lab.js +1029 -0
- package/assets/js/views/evals.js +342 -0
- package/assets/js/views/feedback.js +555 -0
- package/assets/js/views/health.js +176 -0
- package/assets/js/views/inbox.js +103 -0
- package/assets/js/views/inspect.js +751 -0
- package/assets/js/views/jobs.js +110 -0
- package/assets/js/views/library.js +605 -0
- package/assets/js/views/memory-fabric.js +2026 -0
- package/assets/js/views/memory.js +228 -0
- package/assets/js/views/onboarding.js +253 -0
- package/assets/js/views/overview.js +196 -0
- package/assets/js/views/panes.js +132 -0
- package/assets/js/views/proc.js +280 -0
- package/assets/js/views/review.js +164 -0
- package/assets/js/views/runs.js +452 -0
- package/assets/js/views/runtime.js +450 -0
- package/assets/js/views/schedulers.js +133 -0
- package/assets/js/views/security.js +925 -0
- package/assets/js/views/sessions.js +265 -0
- package/assets/js/views/settings.js +489 -0
- package/assets/js/views/spec-edit.js +1289 -0
- package/assets/js/views/spec.js +66 -0
- package/assets/js/views/thredz.js +1853 -0
- package/assets/js/views/token.js +49 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +145 -0
- package/package.json +36 -0
|
@@ -0,0 +1,1853 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thredz — the explorer: wiki, records and schemas, goals and tasks, views,
|
|
3
|
+
* dashboards and cards, listeners, webhooks, connectors, activity, traverse,
|
|
4
|
+
* and API-key administration. Plus the harness-less global explorer.
|
|
5
|
+
*
|
|
6
|
+
* EVERY REQUEST IS PROXIED. The browser never holds a Thredz key: it calls
|
|
7
|
+
* `/api/h/:id/thredz/*`, and the server reads the harness's key at request
|
|
8
|
+
* time. That is why this view has no key field and no "connect" button, and
|
|
9
|
+
* why the key-admin panel accepts a value but can never display one.
|
|
10
|
+
*
|
|
11
|
+
* Things the screen presents faithfully rather than smoothing over:
|
|
12
|
+
* - deletes are SOFT, with restore. There is no hard-delete affordance.
|
|
13
|
+
* - visibility is always set explicitly, defaulting to private.
|
|
14
|
+
* - a wiki write carries `expectedVersion` and gets the same
|
|
15
|
+
* re-read-retry flow as the local wiki.
|
|
16
|
+
* - every article arrives MASKED, so the editor writes back only what the
|
|
17
|
+
* operator typed: it never echoes a served value, and it goes read-only
|
|
18
|
+
* when the body carries the mask mark rather than persisting `***` over
|
|
19
|
+
* what it hid.
|
|
20
|
+
* - card-grammar validation messages are shown VERBATIM (KPI cards need
|
|
21
|
+
* both `display.aggregation` and `display.aggregationField`; record
|
|
22
|
+
* filters take a `tags` array, task/goal filters a singular `tag`).
|
|
23
|
+
* - free-plan quotas (three goals, listener limits) are facts, not errors.
|
|
24
|
+
* - the local store stays authoritative; a degraded mirror is a badge.
|
|
25
|
+
*
|
|
26
|
+
* EVERY PANEL RENDERS ITS OWN OUTAGE. The proxy answers 200 with
|
|
27
|
+
* `{ ok, note, upstream }` whatever the workspace said, so an unreachable
|
|
28
|
+
* workspace, a lapsed subscription and a plan cap each paint as a labelled
|
|
29
|
+
* state carrying the upstream status — never a blank panel, never a spinner
|
|
30
|
+
* that never ends, and never a zero that is really "we could not ask".
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import { api } from "../api.js";
|
|
34
|
+
import { asOf, clear, dot, el, emptyState, jsonPre, mdBlocks, skeleton, toast } from "../dom.js";
|
|
35
|
+
import { hrefHarness } from "../router.js";
|
|
36
|
+
import { clampText, fmtRelativeTime } from "../util.js";
|
|
37
|
+
|
|
38
|
+
/** The sub-tabs of the Thredz tab, in display order. */
|
|
39
|
+
const PANELS = [
|
|
40
|
+
["wiki", "Wiki"],
|
|
41
|
+
["records", "Records"],
|
|
42
|
+
["plan", "Goals & tasks"],
|
|
43
|
+
["boards", "Views & dashboards"],
|
|
44
|
+
["ops", "Automations"],
|
|
45
|
+
["graph", "Graph"],
|
|
46
|
+
["keys", "Keys"],
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
// shared rendering helpers
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
function card(title, children, titleExtra) {
|
|
54
|
+
return el("section", { class: "card" }, [
|
|
55
|
+
el("h3", { class: "card-title" }, [el("span", { text: title }), titleExtra ?? null]),
|
|
56
|
+
...(Array.isArray(children) ? children : [children]),
|
|
57
|
+
]);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function str(value, fallback = "") {
|
|
61
|
+
return typeof value === "string" && value !== "" ? value : fallback;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function list(payload, key) {
|
|
65
|
+
return payload && Array.isArray(payload[key]) ? payload[key] : [];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Row id, whatever the API called it. */
|
|
69
|
+
function idOf(row) {
|
|
70
|
+
if (!row || typeof row !== "object") return "";
|
|
71
|
+
return str(row._id, str(row.id, ""));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
// write safety (pure — unit-tested in hangar-ui/src/thredz-views.test.ts)
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
/** What the server's value-shape masker leaves behind. Text carrying it is
|
|
79
|
+
* not safe to write back — the mask would replace what it hid. */
|
|
80
|
+
export const MASK_MARK = "***";
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Whether a served article body may be written back at all.
|
|
84
|
+
*
|
|
85
|
+
* Every Thredz article is served through the server's `maskText`, so a body
|
|
86
|
+
* carrying the mask mark is a VIEW rather than the article: saving it would
|
|
87
|
+
* persist `***` over a real span — upstream, in a workspace other people
|
|
88
|
+
* read. The local wiki editor holds exactly this line (`memory-fabric.js`);
|
|
89
|
+
* the hosted one is not allowed to be looser.
|
|
90
|
+
*/
|
|
91
|
+
export function maskedEditGuard(bodyText) {
|
|
92
|
+
if (typeof bodyText !== "string" || !bodyText.includes(MASK_MARK)) {
|
|
93
|
+
return { readOnly: false, reason: null };
|
|
94
|
+
}
|
|
95
|
+
return {
|
|
96
|
+
readOnly: true,
|
|
97
|
+
reason:
|
|
98
|
+
"this body contains a masked credential-shaped span; saving from the console would persist the mask over what it hid — edit this article in the Thredz workspace",
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The exact body a wiki EDIT posts.
|
|
104
|
+
*
|
|
105
|
+
* A field this form does not edit is ABSENT rather than echoed. The server
|
|
106
|
+
* leaves an omitted field alone; an echoed one would be the MASKED value
|
|
107
|
+
* (the title is masked too), and a defaulted one would rename the article
|
|
108
|
+
* and force-publish a draft. `expectedVersion` rides along whenever the read
|
|
109
|
+
* gave us one — without it the server refuses the update outright, which is
|
|
110
|
+
* the same answer the local wiki gives.
|
|
111
|
+
*/
|
|
112
|
+
export function wikiEditBody(bodyText, version, visibilityChoice, editMessage) {
|
|
113
|
+
const message = typeof editMessage === "string" ? editMessage.trim() : "";
|
|
114
|
+
return {
|
|
115
|
+
body: bodyText,
|
|
116
|
+
...(message === "" ? {} : { editMessage: message }),
|
|
117
|
+
...(typeof version === "number" ? { expectedVersion: version } : {}),
|
|
118
|
+
...(visibilityChoice === "private" || visibilityChoice === "shared"
|
|
119
|
+
? { visibility: visibilityChoice }
|
|
120
|
+
: {}),
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** The one place a proxy answer becomes a traffic light + a sentence. Colour
|
|
125
|
+
* is never alone: every state carries its own words. */
|
|
126
|
+
function proxyState(payload) {
|
|
127
|
+
if (!payload) return { state: "unknown", label: "no answer from the manager" };
|
|
128
|
+
if (payload.unconfigured === true) return { state: "off", label: "no key in this harness" };
|
|
129
|
+
if (payload.ok === true) return { state: "ok", label: "workspace answered" };
|
|
130
|
+
const upstream = payload.upstream;
|
|
131
|
+
if (!upstream) return { state: "warn", label: str(payload.note, "refused") };
|
|
132
|
+
const cls = str(upstream.failureClass, "upstream-error");
|
|
133
|
+
const status = typeof upstream.status === "number" ? upstream.status : 0;
|
|
134
|
+
const where = status === 0 ? "no answer" : `HTTP ${status}`;
|
|
135
|
+
const bad = cls === "unreachable" || cls === "auth" || cls === "billing";
|
|
136
|
+
return { state: bad ? "bad" : "warn", label: `${cls} (${where})` };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* The banner a panel puts above its data when the workspace did not answer
|
|
141
|
+
* cleanly. The upstream MESSAGE is printed verbatim: on a card or schema
|
|
142
|
+
* refusal it names the valid keys, and paraphrasing it would turn a fixable
|
|
143
|
+
* mistake into a mystery.
|
|
144
|
+
*/
|
|
145
|
+
function upstreamBanner(payload) {
|
|
146
|
+
if (!payload || payload.ok === true) return null;
|
|
147
|
+
const { state, label } = proxyState(payload);
|
|
148
|
+
const upstream = payload.upstream ?? null;
|
|
149
|
+
const details = upstream && Array.isArray(upstream.details) ? upstream.details : [];
|
|
150
|
+
return el("div", { class: "error-card" }, [
|
|
151
|
+
el("div", { class: "refusal-head" }, [dot(state, label)]),
|
|
152
|
+
payload.note ? el("p", { class: "prose-pre", text: String(payload.note) }) : null,
|
|
153
|
+
details.length > 0
|
|
154
|
+
? el(
|
|
155
|
+
"ul",
|
|
156
|
+
{ class: "refusal-list" },
|
|
157
|
+
details.map((d) => el("li", { class: "refusal-item", text: String(d) })),
|
|
158
|
+
)
|
|
159
|
+
: null,
|
|
160
|
+
upstream?.remediation ? el("p", { class: "muted", text: String(upstream.remediation) }) : null,
|
|
161
|
+
]);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** "as of …" for a proxied figure — nothing here is live, it is a fetch. */
|
|
165
|
+
function fetchedChip(payload) {
|
|
166
|
+
return payload && typeof payload.fetchedAt === "string" ? asOf(payload.fetchedAt) : null;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function table(headers, rows) {
|
|
170
|
+
return el("div", { class: "table-scroll" }, [
|
|
171
|
+
el("table", { class: "fleet" }, [
|
|
172
|
+
el(
|
|
173
|
+
"thead",
|
|
174
|
+
null,
|
|
175
|
+
el(
|
|
176
|
+
"tr",
|
|
177
|
+
null,
|
|
178
|
+
headers.map((h) => el("th", { text: h })),
|
|
179
|
+
),
|
|
180
|
+
),
|
|
181
|
+
el("tbody", null, rows),
|
|
182
|
+
]),
|
|
183
|
+
]);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function chips(values) {
|
|
187
|
+
return (Array.isArray(values) ? values : []).map((v) =>
|
|
188
|
+
el("span", { class: "chip", text: String(v) }),
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function when(iso) {
|
|
193
|
+
return typeof iso === "string" && iso !== "" ? fmtRelativeTime(iso, Date.now()) : "—";
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function field(label, control) {
|
|
197
|
+
return el("label", { class: "field" }, [
|
|
198
|
+
el("span", { class: "mini-label", text: label }),
|
|
199
|
+
control,
|
|
200
|
+
]);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function input(attrs) {
|
|
204
|
+
return el("input", { class: "input", type: "text", ...attrs });
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function select(options, value) {
|
|
208
|
+
return el(
|
|
209
|
+
"select",
|
|
210
|
+
{ class: "input" },
|
|
211
|
+
options.map((opt) => el("option", { value: opt, text: opt, selected: opt === value || null })),
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Run a proxied call; a MANAGER-side failure toasts rather than blanking the
|
|
216
|
+
* screen, and the caller gets `null`. (A WORKSPACE refusal is not a failure
|
|
217
|
+
* here — it arrives as a 200 body with `ok:false`, which the panels render.) */
|
|
218
|
+
async function callProxy(fn) {
|
|
219
|
+
try {
|
|
220
|
+
const res = await fn();
|
|
221
|
+
return res?.body ? res.body : null;
|
|
222
|
+
} catch (err) {
|
|
223
|
+
toast(err instanceof Error ? err.message : String(err));
|
|
224
|
+
return null;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** A write: report what happened either way. `ok:false` carries the
|
|
229
|
+
* workspace's own words, so the toast quotes them. */
|
|
230
|
+
async function write(fn, okMessage) {
|
|
231
|
+
const body = await callProxy(fn);
|
|
232
|
+
if (body === null) return null;
|
|
233
|
+
if (body.ok === true) toast(okMessage, "info");
|
|
234
|
+
else toast(str(body.note, "the workspace refused that"));
|
|
235
|
+
return body;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// ---------------------------------------------------------------------------
|
|
239
|
+
// the per-harness tab
|
|
240
|
+
// ---------------------------------------------------------------------------
|
|
241
|
+
|
|
242
|
+
/** The per-harness Thredz tab. */
|
|
243
|
+
export async function renderThredz(root, ctx) {
|
|
244
|
+
clear(root).appendChild(skeleton(6));
|
|
245
|
+
const status = await callProxy(() => api.thredzStatus({ id: ctx.id }));
|
|
246
|
+
clear(root);
|
|
247
|
+
root.appendChild(statusCard(status, ctx));
|
|
248
|
+
if (!status || status.keyPresent !== true) {
|
|
249
|
+
root.appendChild(wiringCard(status));
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
const host = el("div", { class: "tab-section" });
|
|
253
|
+
const state = { panel: "wiki" };
|
|
254
|
+
const nav = el("nav", { class: "tabs", "aria-label": "thredz panels" });
|
|
255
|
+
const paint = () => {
|
|
256
|
+
clear(nav);
|
|
257
|
+
for (const [key, label] of PANELS) {
|
|
258
|
+
nav.appendChild(
|
|
259
|
+
el("button", {
|
|
260
|
+
class: `tab${state.panel === key ? " active" : ""}`,
|
|
261
|
+
type: "button",
|
|
262
|
+
"aria-current": state.panel === key ? "page" : null,
|
|
263
|
+
text: label,
|
|
264
|
+
onClick: () => {
|
|
265
|
+
state.panel = key;
|
|
266
|
+
paint();
|
|
267
|
+
},
|
|
268
|
+
}),
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
clear(host).appendChild(skeleton(4));
|
|
272
|
+
void renderPanel(host, ctx, state.panel);
|
|
273
|
+
};
|
|
274
|
+
root.appendChild(nav);
|
|
275
|
+
root.appendChild(host);
|
|
276
|
+
paint();
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function renderPanel(host, ctx, panel) {
|
|
280
|
+
if (panel === "wiki") return renderWikiPanel(host, ctx);
|
|
281
|
+
if (panel === "records") return renderRecordsPanel(host, ctx);
|
|
282
|
+
if (panel === "plan") return renderPlanPanel(host, ctx);
|
|
283
|
+
if (panel === "boards") return renderBoardsPanel(host, ctx);
|
|
284
|
+
if (panel === "ops") return renderOpsPanel(host, ctx);
|
|
285
|
+
if (panel === "graph") return renderGraphPanel(host, ctx);
|
|
286
|
+
return renderKeysPanel(host, ctx);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* The status card: which backend this harness's wiki actually uses, whether a
|
|
291
|
+
* key resolved and WHERE from (a provenance label — never a value), the tier
|
|
292
|
+
* that key carries, and the plan's listener quota.
|
|
293
|
+
*/
|
|
294
|
+
function statusCard(status, ctx) {
|
|
295
|
+
if (!status) {
|
|
296
|
+
return card("Thredz", emptyState("The manager did not answer for this harness", null));
|
|
297
|
+
}
|
|
298
|
+
const spec = status.spec && typeof status.spec === "object" ? status.spec : {};
|
|
299
|
+
const { state, label } = proxyState(status);
|
|
300
|
+
const backend = str(status.backend, "local");
|
|
301
|
+
const rows = [
|
|
302
|
+
["Wiki backend", backend === "thredz" ? "Thredz (hosted)" : "local files"],
|
|
303
|
+
["Key", status.keyPresent === true ? `resolved from ${str(status.keySource, "?")}` : "not set"],
|
|
304
|
+
["Variable", str(spec.envName, "—")],
|
|
305
|
+
["Workspace", str(status.workspace, "—")],
|
|
306
|
+
["New writes", `visibility: ${str(status.defaultVisibility, "private")}`],
|
|
307
|
+
["Permission tier", str(status.tier, "unknown")],
|
|
308
|
+
["Goal mirror", spec.goalsMirror === true ? "on (local goals stay authoritative)" : "off"],
|
|
309
|
+
["Messaging tools", spec.messaging === true ? "enabled" : "off"],
|
|
310
|
+
];
|
|
311
|
+
const quota =
|
|
312
|
+
status.listenerQuota && typeof status.listenerQuota === "object" ? status.listenerQuota : null;
|
|
313
|
+
if (quota) rows.push(["Listener quota", `${quota.used ?? "?"} of ${quota.limit ?? "?"} used`]);
|
|
314
|
+
const stats = status.wikiStats && typeof status.wikiStats === "object" ? status.wikiStats : null;
|
|
315
|
+
return card(
|
|
316
|
+
"Thredz",
|
|
317
|
+
[
|
|
318
|
+
el("div", { class: "safety-strip" }, [
|
|
319
|
+
dot(state, label),
|
|
320
|
+
el("span", { class: "chip", text: `backend: ${backend}` }),
|
|
321
|
+
status.localWikiDeclared === true
|
|
322
|
+
? el("span", { class: "chip", text: "local wiki store present — still authoritative" })
|
|
323
|
+
: null,
|
|
324
|
+
]),
|
|
325
|
+
upstreamBanner(status),
|
|
326
|
+
el(
|
|
327
|
+
"dl",
|
|
328
|
+
{ class: "kv" },
|
|
329
|
+
rows.flatMap(([k, v]) => [
|
|
330
|
+
el("dt", { class: "kv-k", text: k }),
|
|
331
|
+
el("dd", { text: String(v) }),
|
|
332
|
+
]),
|
|
333
|
+
),
|
|
334
|
+
stats
|
|
335
|
+
? el("details", { class: "fold" }, [
|
|
336
|
+
el("summary", { class: "fold-summary" }, [
|
|
337
|
+
el("span", { class: "muted", text: "workspace wiki stats" }),
|
|
338
|
+
]),
|
|
339
|
+
el("div", { class: "fold-body" }, [jsonPre(stats)]),
|
|
340
|
+
])
|
|
341
|
+
: null,
|
|
342
|
+
el("p", { class: "muted" }, [
|
|
343
|
+
"The key is read server-side per request and never reaches this browser. Local memory lives under ",
|
|
344
|
+
el("a", { class: "name-link", href: hrefHarness(ctx.id, "memory"), text: "Memory" }),
|
|
345
|
+
".",
|
|
346
|
+
]),
|
|
347
|
+
],
|
|
348
|
+
fetchedChip(status),
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** What to do when there is no key — an empty state that names the fix. */
|
|
353
|
+
function wiringCard(status) {
|
|
354
|
+
const spec = status?.spec && typeof status.spec === "object" ? status.spec : {};
|
|
355
|
+
const note = status ? str(status.note, "this harness has no Thredz workspace") : "";
|
|
356
|
+
const body = [el("p", { class: "prose-pre", text: note })];
|
|
357
|
+
if (spec.declared === true && spec.inlineKey !== true) {
|
|
358
|
+
body.push(
|
|
359
|
+
el("p", { class: "muted" }, [
|
|
360
|
+
"Set ",
|
|
361
|
+
el("code", { text: str(spec.envName, "THREDZ_API_KEY") }),
|
|
362
|
+
" in this harness's own .env — values go in, only presence comes back.",
|
|
363
|
+
]),
|
|
364
|
+
);
|
|
365
|
+
} else if (spec.declared !== true) {
|
|
366
|
+
body.push(
|
|
367
|
+
el("p", { class: "muted" }, [
|
|
368
|
+
"Add a ",
|
|
369
|
+
el("code", { text: "thredz:" }),
|
|
370
|
+
" block to the spec to point this harness's wiki at a hosted workspace. Until then the local store is the whole story.",
|
|
371
|
+
]),
|
|
372
|
+
);
|
|
373
|
+
}
|
|
374
|
+
return card("Not wired to a workspace", body);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// ---------------------------------------------------------------------------
|
|
378
|
+
// wiki
|
|
379
|
+
// ---------------------------------------------------------------------------
|
|
380
|
+
|
|
381
|
+
async function renderWikiPanel(host, ctx, state = { q: "", semantic: false, slug: null }) {
|
|
382
|
+
if (state.slug !== null) return renderArticle(host, ctx, state);
|
|
383
|
+
const query =
|
|
384
|
+
state.q === "" ? "" : `?q=${encodeURIComponent(state.q)}${state.semantic ? "&semantic=1" : ""}`;
|
|
385
|
+
const payload = await callProxy(() => api.thredzWiki({ id: ctx.id }, undefined, query));
|
|
386
|
+
clear(host);
|
|
387
|
+
const search = input({ value: state.q, placeholder: "search the workspace wiki…" });
|
|
388
|
+
const semantic = el("input", { type: "checkbox", checked: state.semantic || null });
|
|
389
|
+
const form = el("form", { class: "filter-bar" }, [
|
|
390
|
+
search,
|
|
391
|
+
el("label", { class: "check" }, [semantic, el("span", { text: "semantic" })]),
|
|
392
|
+
el("button", { class: "btn btn-primary", type: "submit", text: "Search" }),
|
|
393
|
+
]);
|
|
394
|
+
form.addEventListener("submit", (e) => {
|
|
395
|
+
e.preventDefault();
|
|
396
|
+
void renderWikiPanel(host, ctx, {
|
|
397
|
+
q: search.value.trim(),
|
|
398
|
+
semantic: semantic.checked,
|
|
399
|
+
slug: null,
|
|
400
|
+
});
|
|
401
|
+
});
|
|
402
|
+
const rows = list(payload, "articles").map((a) =>
|
|
403
|
+
el("tr", null, [
|
|
404
|
+
el(
|
|
405
|
+
"td",
|
|
406
|
+
null,
|
|
407
|
+
el("button", {
|
|
408
|
+
class: "btn btn-ghost name-link",
|
|
409
|
+
type: "button",
|
|
410
|
+
text: str(a.title, str(a.slug, "(untitled)")),
|
|
411
|
+
onClick: () => void renderWikiPanel(host, ctx, { ...state, slug: str(a.slug, idOf(a)) }),
|
|
412
|
+
}),
|
|
413
|
+
),
|
|
414
|
+
el("td", { class: "mono", text: str(a.slug, "—") }),
|
|
415
|
+
el("td", null, chips(a.tags)),
|
|
416
|
+
el("td", { class: "mono", text: String(a.version ?? "—") }),
|
|
417
|
+
el("td", { text: str(a.visibility, "—") }),
|
|
418
|
+
el("td", { text: when(a.updatedAt) }),
|
|
419
|
+
]),
|
|
420
|
+
);
|
|
421
|
+
host.appendChild(
|
|
422
|
+
card(
|
|
423
|
+
payload && payload.mode === "semantic" ? "Wiki — semantic search" : "Wiki",
|
|
424
|
+
[
|
|
425
|
+
form,
|
|
426
|
+
upstreamBanner(payload),
|
|
427
|
+
rows.length === 0
|
|
428
|
+
? emptyState(
|
|
429
|
+
payload && payload.ok === true
|
|
430
|
+
? str(payload.note, "No articles in this workspace")
|
|
431
|
+
: "Nothing to show — the workspace did not answer",
|
|
432
|
+
null,
|
|
433
|
+
)
|
|
434
|
+
: table(["Title", "Slug", "Tags", "Version", "Visibility", "Updated"], rows),
|
|
435
|
+
],
|
|
436
|
+
fetchedChip(payload),
|
|
437
|
+
),
|
|
438
|
+
);
|
|
439
|
+
host.appendChild(newArticleCard(ctx, (slug) => renderWikiPanel(host, ctx, { ...state, slug })));
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/** Create an article. The only place in this console that mints a hosted
|
|
443
|
+
* wiki page, and it always sends an explicit visibility — private unless
|
|
444
|
+
* the operator chooses otherwise, whatever the API would have defaulted to. */
|
|
445
|
+
function newArticleCard(ctx, open) {
|
|
446
|
+
const slug = input({ placeholder: "slug (how-to-deploy)" });
|
|
447
|
+
const title = input({ placeholder: "title" });
|
|
448
|
+
const body = el("textarea", { class: "input", rows: 6, spellcheck: "false" });
|
|
449
|
+
const visibility = select(["private", "shared"], "private");
|
|
450
|
+
const form = el("form", { class: "row-editor" }, [
|
|
451
|
+
field("Slug", slug),
|
|
452
|
+
field("Title", title),
|
|
453
|
+
field("Body", body),
|
|
454
|
+
field("Visibility", visibility),
|
|
455
|
+
el("div", { class: "editor-actions" }, [
|
|
456
|
+
el("button", { class: "btn btn-primary", type: "submit", text: "Create article" }),
|
|
457
|
+
]),
|
|
458
|
+
]);
|
|
459
|
+
form.addEventListener("submit", async (e) => {
|
|
460
|
+
e.preventDefault();
|
|
461
|
+
const name = slug.value.trim();
|
|
462
|
+
if (name === "") {
|
|
463
|
+
toast("a new article needs a slug");
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
const res = await write(
|
|
467
|
+
() =>
|
|
468
|
+
api.thredzWikiWrite(
|
|
469
|
+
{ id: ctx.id, slug: name },
|
|
470
|
+
{
|
|
471
|
+
body: body.value,
|
|
472
|
+
title: title.value.trim() === "" ? name : title.value.trim(),
|
|
473
|
+
visibility: visibility.value,
|
|
474
|
+
},
|
|
475
|
+
),
|
|
476
|
+
"Article created",
|
|
477
|
+
);
|
|
478
|
+
if (res && res.ok === true) void open(name);
|
|
479
|
+
});
|
|
480
|
+
return card("New article", [
|
|
481
|
+
el("p", {
|
|
482
|
+
class: "muted",
|
|
483
|
+
text: "New articles are private unless you say otherwise — the hosted API would default them to shared, and this console does not inherit that.",
|
|
484
|
+
}),
|
|
485
|
+
form,
|
|
486
|
+
]);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
async function renderArticle(host, ctx, state) {
|
|
490
|
+
clear(host).appendChild(skeleton(6));
|
|
491
|
+
const slug = state.slug;
|
|
492
|
+
const [payload, versions] = await Promise.all([
|
|
493
|
+
callProxy(() => api.thredzWikiArticle({ id: ctx.id, slug })),
|
|
494
|
+
callProxy(() => api.thredzWikiVersions({ id: ctx.id, slug })),
|
|
495
|
+
]);
|
|
496
|
+
clear(host);
|
|
497
|
+
const back = el("button", {
|
|
498
|
+
class: "btn btn-ghost",
|
|
499
|
+
type: "button",
|
|
500
|
+
text: "← all articles",
|
|
501
|
+
onClick: () => void renderWikiPanel(host, ctx, { ...state, slug: null }),
|
|
502
|
+
});
|
|
503
|
+
const article = payload?.article && typeof payload.article === "object" ? payload.article : null;
|
|
504
|
+
if (!article) {
|
|
505
|
+
host.appendChild(
|
|
506
|
+
card(slug, [back, upstreamBanner(payload), emptyState("No such article", null)]),
|
|
507
|
+
);
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
const version = typeof payload.version === "number" ? payload.version : null;
|
|
511
|
+
host.appendChild(
|
|
512
|
+
card(str(article.title, slug), [
|
|
513
|
+
el("div", { class: "safety-strip" }, [
|
|
514
|
+
back,
|
|
515
|
+
el("span", { class: "chip", text: `v${version ?? "?"}` }),
|
|
516
|
+
el("span", { class: "chip", text: `visibility: ${str(payload.visibility, "?")}` }),
|
|
517
|
+
payload.deleted === true
|
|
518
|
+
? dot("warn", "soft-deleted upstream — restorable, never destroyed")
|
|
519
|
+
: null,
|
|
520
|
+
fetchedChip(payload),
|
|
521
|
+
]),
|
|
522
|
+
upstreamBanner(payload),
|
|
523
|
+
mdBlocks(str(article.body, "")),
|
|
524
|
+
]),
|
|
525
|
+
);
|
|
526
|
+
host.appendChild(editorCard(ctx, slug, article, version, () => renderArticle(host, ctx, state)));
|
|
527
|
+
host.appendChild(
|
|
528
|
+
card("Backlinks & comments", [
|
|
529
|
+
el("div", { class: "twin" }, [
|
|
530
|
+
el("div", null, [
|
|
531
|
+
el("h4", { class: "sub-title", text: "Backlinks" }),
|
|
532
|
+
list(payload, "backlinks").length === 0
|
|
533
|
+
? emptyState("Nothing links here yet", null)
|
|
534
|
+
: el(
|
|
535
|
+
"ul",
|
|
536
|
+
{ class: "wiki-list" },
|
|
537
|
+
list(payload, "backlinks").map((b) =>
|
|
538
|
+
el("li", { text: str(b.slug, str(b.title, "—")) }),
|
|
539
|
+
),
|
|
540
|
+
),
|
|
541
|
+
]),
|
|
542
|
+
el("div", null, [
|
|
543
|
+
el("h4", { class: "sub-title", text: "Comments" }),
|
|
544
|
+
payload.commentsError
|
|
545
|
+
? el("p", { class: "muted", text: String(payload.commentsError) })
|
|
546
|
+
: null,
|
|
547
|
+
list(payload, "comments").length === 0
|
|
548
|
+
? emptyState("No comments", null)
|
|
549
|
+
: el(
|
|
550
|
+
"ul",
|
|
551
|
+
{ class: "fact-list" },
|
|
552
|
+
list(payload, "comments").map((c) =>
|
|
553
|
+
el("li", { class: "fact" }, [
|
|
554
|
+
el("div", { text: str(c.body, str(c.text, "")) }),
|
|
555
|
+
el("div", { class: "fact-meta", text: when(c.createdAt) }),
|
|
556
|
+
]),
|
|
557
|
+
),
|
|
558
|
+
),
|
|
559
|
+
]),
|
|
560
|
+
]),
|
|
561
|
+
]),
|
|
562
|
+
);
|
|
563
|
+
host.appendChild(versionsCard(ctx, slug, versions, () => renderArticle(host, ctx, state)));
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/**
|
|
567
|
+
* The editor. Every write carries `expectedVersion`; a stale answer is a
|
|
568
|
+
* FIRST-CLASS state — the panel says which version moved, offers a re-read,
|
|
569
|
+
* and keeps the operator's draft so nothing typed is lost.
|
|
570
|
+
*
|
|
571
|
+
* Two things this form deliberately does NOT do, both because the text it
|
|
572
|
+
* was seeded with is a masked VIEW of the article rather than the article:
|
|
573
|
+
* - it never echoes the title back (the served one may carry `***`, and an
|
|
574
|
+
* echoed default renames the article upstream);
|
|
575
|
+
* - it goes read-only when the served body carries the mask mark, the same
|
|
576
|
+
* guard the local wiki editor uses (`memory-fabric.js`).
|
|
577
|
+
*/
|
|
578
|
+
function editorCard(ctx, slug, article, version, reload) {
|
|
579
|
+
const body = el("textarea", { class: "input", rows: 12, spellcheck: "false" });
|
|
580
|
+
body.value = str(article.body, "");
|
|
581
|
+
const visibility = select(["(unchanged)", "private", "shared"], "(unchanged)");
|
|
582
|
+
const message = input({ placeholder: "edit message (optional)" });
|
|
583
|
+
const conflict = el("div", { class: "gated-why" });
|
|
584
|
+
const save = el("button", { class: "btn btn-primary", type: "submit", text: "Save" });
|
|
585
|
+
const guard = el("div", null, []);
|
|
586
|
+
const form = el("form", { class: "row-editor" }, [
|
|
587
|
+
field("Body", body),
|
|
588
|
+
field("Visibility", visibility),
|
|
589
|
+
field("Edit message", message),
|
|
590
|
+
guard,
|
|
591
|
+
conflict,
|
|
592
|
+
el("div", { class: "editor-actions" }, [save]),
|
|
593
|
+
]);
|
|
594
|
+
const masked = maskedEditGuard(body.value);
|
|
595
|
+
if (masked.readOnly) {
|
|
596
|
+
save.disabled = true;
|
|
597
|
+
body.readOnly = true;
|
|
598
|
+
guard.appendChild(
|
|
599
|
+
el("div", { class: "gated" }, [
|
|
600
|
+
dot("warn", "read-only here"),
|
|
601
|
+
el("span", { class: "muted gated-why", text: masked.reason }),
|
|
602
|
+
]),
|
|
603
|
+
);
|
|
604
|
+
}
|
|
605
|
+
form.addEventListener("submit", async (e) => {
|
|
606
|
+
e.preventDefault();
|
|
607
|
+
clear(conflict);
|
|
608
|
+
const stillMasked = maskedEditGuard(body.value);
|
|
609
|
+
if (stillMasked.readOnly) {
|
|
610
|
+
toast(stillMasked.reason);
|
|
611
|
+
return;
|
|
612
|
+
}
|
|
613
|
+
const edit = wikiEditBody(body.value, version, visibility.value, message.value);
|
|
614
|
+
const res = await write(() => api.thredzWikiWrite({ id: ctx.id, slug }, edit), "Article saved");
|
|
615
|
+
if (res && res.stale === true) {
|
|
616
|
+
conflict.appendChild(
|
|
617
|
+
dot("warn", str(res.note, `this article moved to v${res.currentVersion ?? "?"}`)),
|
|
618
|
+
);
|
|
619
|
+
conflict.appendChild(
|
|
620
|
+
el("p", {
|
|
621
|
+
class: "muted",
|
|
622
|
+
text: "Nothing was written. Re-read it, then re-apply your edit — the same flow the local wiki uses.",
|
|
623
|
+
}),
|
|
624
|
+
);
|
|
625
|
+
conflict.appendChild(
|
|
626
|
+
el("button", {
|
|
627
|
+
class: "btn",
|
|
628
|
+
type: "button",
|
|
629
|
+
text: "Re-read the current version",
|
|
630
|
+
onClick: () => void reload(),
|
|
631
|
+
}),
|
|
632
|
+
);
|
|
633
|
+
return;
|
|
634
|
+
}
|
|
635
|
+
if (res && res.ok === true) void reload();
|
|
636
|
+
});
|
|
637
|
+
return card("Edit", [
|
|
638
|
+
el("p", {
|
|
639
|
+
class: "muted",
|
|
640
|
+
text: "Writes carry the version you read; a create is private unless you say otherwise. The title is edited in the workspace, never echoed from here.",
|
|
641
|
+
}),
|
|
642
|
+
form,
|
|
643
|
+
]);
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
function versionsCard(ctx, slug, payload, reload) {
|
|
647
|
+
const rows = list(payload, "versions").map((v) => {
|
|
648
|
+
const number = typeof v.version === "number" ? v.version : null;
|
|
649
|
+
return el("tr", null, [
|
|
650
|
+
el("td", { class: "mono", text: String(number ?? "—") }),
|
|
651
|
+
el("td", { text: str(v.editMessage, "—") }),
|
|
652
|
+
el("td", { text: when(v.createdAt ?? v.updatedAt) }),
|
|
653
|
+
el(
|
|
654
|
+
"td",
|
|
655
|
+
{ class: "cell-actions" },
|
|
656
|
+
number === null
|
|
657
|
+
? null
|
|
658
|
+
: el("button", {
|
|
659
|
+
class: "btn btn-ghost",
|
|
660
|
+
type: "button",
|
|
661
|
+
text: "Roll back to this",
|
|
662
|
+
onClick: async () => {
|
|
663
|
+
const res = await write(
|
|
664
|
+
() =>
|
|
665
|
+
api.thredzWikiRollback(
|
|
666
|
+
{ id: ctx.id, slug },
|
|
667
|
+
{ version: number, confirm: true },
|
|
668
|
+
),
|
|
669
|
+
`Rolled back to v${number} (as a new version)`,
|
|
670
|
+
);
|
|
671
|
+
if (res && res.ok === true) void reload();
|
|
672
|
+
},
|
|
673
|
+
}),
|
|
674
|
+
),
|
|
675
|
+
]);
|
|
676
|
+
});
|
|
677
|
+
return card(
|
|
678
|
+
"Versions",
|
|
679
|
+
[
|
|
680
|
+
upstreamBanner(payload),
|
|
681
|
+
el("p", {
|
|
682
|
+
class: "muted",
|
|
683
|
+
text: "A rollback creates a NEW version on top; the versions in between are never removed.",
|
|
684
|
+
}),
|
|
685
|
+
rows.length === 0
|
|
686
|
+
? emptyState("No recorded versions yet", null)
|
|
687
|
+
: table(["Version", "Message", "When", ""], rows),
|
|
688
|
+
],
|
|
689
|
+
fetchedChip(payload),
|
|
690
|
+
);
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
// ---------------------------------------------------------------------------
|
|
694
|
+
// records
|
|
695
|
+
// ---------------------------------------------------------------------------
|
|
696
|
+
|
|
697
|
+
async function renderRecordsPanel(host, ctx, state = { type: "", status: "", tag: "" }) {
|
|
698
|
+
const params = new URLSearchParams();
|
|
699
|
+
for (const key of ["type", "status", "tag"]) {
|
|
700
|
+
if (state[key] !== "") params.set(key, state[key]);
|
|
701
|
+
}
|
|
702
|
+
const query = params.toString() === "" ? "" : `?${params.toString()}`;
|
|
703
|
+
const [payload, schemas] = await Promise.all([
|
|
704
|
+
callProxy(() => api.thredzRecords({ id: ctx.id }, undefined, query)),
|
|
705
|
+
callProxy(() => api.thredzSchemas({ id: ctx.id })),
|
|
706
|
+
]);
|
|
707
|
+
clear(host);
|
|
708
|
+
const fields = {
|
|
709
|
+
type: input({ value: state.type, placeholder: "type" }),
|
|
710
|
+
status: input({ value: state.status, placeholder: "status" }),
|
|
711
|
+
tag: input({ value: state.tag, placeholder: "tag" }),
|
|
712
|
+
};
|
|
713
|
+
const filters = el("form", { class: "filter-bar" }, [
|
|
714
|
+
fields.type,
|
|
715
|
+
fields.status,
|
|
716
|
+
fields.tag,
|
|
717
|
+
el("button", { class: "btn btn-primary", type: "submit", text: "Filter" }),
|
|
718
|
+
]);
|
|
719
|
+
filters.addEventListener("submit", (e) => {
|
|
720
|
+
e.preventDefault();
|
|
721
|
+
void renderRecordsPanel(host, ctx, {
|
|
722
|
+
type: fields.type.value.trim(),
|
|
723
|
+
status: fields.status.value.trim(),
|
|
724
|
+
tag: fields.tag.value.trim(),
|
|
725
|
+
});
|
|
726
|
+
});
|
|
727
|
+
const reload = () => renderRecordsPanel(host, ctx, state);
|
|
728
|
+
const rows = list(payload, "records").map((r) => {
|
|
729
|
+
const recordId = idOf(r);
|
|
730
|
+
const deleted = r.status === "deleted";
|
|
731
|
+
return el("tr", null, [
|
|
732
|
+
el("td", { text: str(r.title, recordId) }),
|
|
733
|
+
el("td", { class: "mono", text: str(r.type, "—") }),
|
|
734
|
+
el("td", null, [
|
|
735
|
+
el("span", { text: str(r.status, "—") }),
|
|
736
|
+
deleted ? dot("warn", "soft-deleted") : null,
|
|
737
|
+
]),
|
|
738
|
+
el("td", null, chips(r.tags)),
|
|
739
|
+
el("td", { text: when(r.updatedAt) }),
|
|
740
|
+
el(
|
|
741
|
+
"td",
|
|
742
|
+
{ class: "cell-actions" },
|
|
743
|
+
deleted
|
|
744
|
+
? el("button", {
|
|
745
|
+
class: "btn btn-ghost",
|
|
746
|
+
type: "button",
|
|
747
|
+
text: "Restore",
|
|
748
|
+
onClick: async (e) => {
|
|
749
|
+
// A restore is a one-shot: the second click has no stashed
|
|
750
|
+
// status to put back, so the button leaves the flight rather
|
|
751
|
+
// than firing again, and the toast quotes what the server
|
|
752
|
+
// actually did instead of asserting a restore.
|
|
753
|
+
const button = e.currentTarget;
|
|
754
|
+
if (button) button.disabled = true;
|
|
755
|
+
const res = await callProxy(() =>
|
|
756
|
+
api.thredzRecordRestore({ id: ctx.id, recordId }, {}),
|
|
757
|
+
);
|
|
758
|
+
if (res === null) {
|
|
759
|
+
if (button) button.disabled = false;
|
|
760
|
+
return;
|
|
761
|
+
}
|
|
762
|
+
if (res.restored === true) toast("Record restored", "info");
|
|
763
|
+
else toast(str(res.note, "the workspace refused that"));
|
|
764
|
+
if (res.ok === true) void reload();
|
|
765
|
+
else if (button) button.disabled = false;
|
|
766
|
+
},
|
|
767
|
+
})
|
|
768
|
+
: el("button", {
|
|
769
|
+
class: "btn btn-ghost",
|
|
770
|
+
type: "button",
|
|
771
|
+
text: "Soft-delete",
|
|
772
|
+
onClick: async () => {
|
|
773
|
+
const label = str(r.title, recordId);
|
|
774
|
+
if (!window.confirm(`Soft-delete "${label}"? It stays restorable.`)) return;
|
|
775
|
+
const res = await write(
|
|
776
|
+
() => api.thredzRecordDelete({ id: ctx.id, recordId }),
|
|
777
|
+
"Soft-deleted — restore is one click away",
|
|
778
|
+
);
|
|
779
|
+
if (res && res.ok === true) void reload();
|
|
780
|
+
},
|
|
781
|
+
}),
|
|
782
|
+
),
|
|
783
|
+
]);
|
|
784
|
+
});
|
|
785
|
+
host.appendChild(
|
|
786
|
+
card(
|
|
787
|
+
"Records",
|
|
788
|
+
[
|
|
789
|
+
filters,
|
|
790
|
+
upstreamBanner(payload),
|
|
791
|
+
payload?.tagsNote ? el("p", { class: "muted", text: String(payload.tagsNote) }) : null,
|
|
792
|
+
el("p", {
|
|
793
|
+
class: "muted",
|
|
794
|
+
text: "Delete is SOFT: the record is parked and its previous status stashed, so restore puts it back exactly.",
|
|
795
|
+
}),
|
|
796
|
+
rows.length === 0
|
|
797
|
+
? emptyState(
|
|
798
|
+
payload && payload.ok === true
|
|
799
|
+
? str(payload.note, "No records")
|
|
800
|
+
: "The workspace did not answer",
|
|
801
|
+
null,
|
|
802
|
+
)
|
|
803
|
+
: table(["Title", "Type", "Status", "Tags", "Updated", ""], rows),
|
|
804
|
+
],
|
|
805
|
+
fetchedChip(payload),
|
|
806
|
+
),
|
|
807
|
+
);
|
|
808
|
+
host.appendChild(recordCreateCard(ctx, schemas, reload));
|
|
809
|
+
host.appendChild(schemasCard(schemas));
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
function recordCreateCard(ctx, schemas, reload) {
|
|
813
|
+
const types = list(schemas, "schemas")
|
|
814
|
+
.map((s) => str(s.type, str(s.name, "")))
|
|
815
|
+
.filter((t) => t !== "");
|
|
816
|
+
const type = types.length > 0 ? select(types, types[0]) : input({ placeholder: "record type" });
|
|
817
|
+
const title = input({ placeholder: "title" });
|
|
818
|
+
const graphId = input({ placeholder: "graph id" });
|
|
819
|
+
const custom = el("textarea", { class: "input", rows: 4, spellcheck: "false" });
|
|
820
|
+
custom.value = "{}";
|
|
821
|
+
const form = el("form", { class: "row-editor" }, [
|
|
822
|
+
field("Type", type),
|
|
823
|
+
field("Title", title),
|
|
824
|
+
field("Graph", graphId),
|
|
825
|
+
field("Custom fields (JSON)", custom),
|
|
826
|
+
el("div", { class: "editor-actions" }, [
|
|
827
|
+
el("button", { class: "btn btn-primary", type: "submit", text: "Create record" }),
|
|
828
|
+
]),
|
|
829
|
+
]);
|
|
830
|
+
form.addEventListener("submit", async (e) => {
|
|
831
|
+
e.preventDefault();
|
|
832
|
+
let parsed = {};
|
|
833
|
+
try {
|
|
834
|
+
parsed = JSON.parse(custom.value === "" ? "{}" : custom.value);
|
|
835
|
+
} catch {
|
|
836
|
+
toast("custom fields must be JSON");
|
|
837
|
+
return;
|
|
838
|
+
}
|
|
839
|
+
const res = await write(
|
|
840
|
+
() =>
|
|
841
|
+
api.thredzRecordCreate(
|
|
842
|
+
{ id: ctx.id },
|
|
843
|
+
{ type: type.value, title: title.value, graphId: graphId.value, fields: parsed },
|
|
844
|
+
),
|
|
845
|
+
"Record created",
|
|
846
|
+
);
|
|
847
|
+
if (res && res.ok === true) void reload();
|
|
848
|
+
});
|
|
849
|
+
return card("New record", [
|
|
850
|
+
el("p", {
|
|
851
|
+
class: "muted",
|
|
852
|
+
text: "Schema validation happens in the workspace; its message is shown here word for word.",
|
|
853
|
+
}),
|
|
854
|
+
form,
|
|
855
|
+
]);
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
function schemasCard(payload) {
|
|
859
|
+
const schemas = list(payload, "schemas");
|
|
860
|
+
return card(
|
|
861
|
+
"Schemas",
|
|
862
|
+
[
|
|
863
|
+
upstreamBanner(payload),
|
|
864
|
+
schemas.length === 0
|
|
865
|
+
? emptyState(
|
|
866
|
+
payload && payload.ok === true
|
|
867
|
+
? "This workspace defines no record schemas"
|
|
868
|
+
: "Unavailable",
|
|
869
|
+
null,
|
|
870
|
+
)
|
|
871
|
+
: el(
|
|
872
|
+
"ul",
|
|
873
|
+
{ class: "fact-list" },
|
|
874
|
+
schemas.map((s) =>
|
|
875
|
+
el("li", { class: "fact" }, [
|
|
876
|
+
el("div", { text: str(s.type, str(s.name, "—")) }),
|
|
877
|
+
el(
|
|
878
|
+
"div",
|
|
879
|
+
{ class: "fact-meta" },
|
|
880
|
+
chips(Object.keys(s.fields ?? s.properties ?? {})),
|
|
881
|
+
),
|
|
882
|
+
]),
|
|
883
|
+
),
|
|
884
|
+
),
|
|
885
|
+
],
|
|
886
|
+
fetchedChip(payload),
|
|
887
|
+
);
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
// ---------------------------------------------------------------------------
|
|
891
|
+
// goals + tasks
|
|
892
|
+
// ---------------------------------------------------------------------------
|
|
893
|
+
|
|
894
|
+
async function renderPlanPanel(host, ctx) {
|
|
895
|
+
const [goals, tasks] = await Promise.all([
|
|
896
|
+
callProxy(() => api.thredzGoals({ id: ctx.id })),
|
|
897
|
+
callProxy(() => api.thredzTasks({ id: ctx.id })),
|
|
898
|
+
]);
|
|
899
|
+
clear(host);
|
|
900
|
+
const goalRows = list(goals, "goals").map((g) =>
|
|
901
|
+
el("tr", null, [
|
|
902
|
+
el("td", { text: str(g.title, idOf(g)) }),
|
|
903
|
+
el("td", { class: "mono", text: `${g.currentValue ?? "—"} / ${g.targetValue ?? "—"}` }),
|
|
904
|
+
el("td", { text: str(g.health, "—") }),
|
|
905
|
+
el("td", null, chips(g.tags)),
|
|
906
|
+
el("td", { text: when(g.deadline) }),
|
|
907
|
+
]),
|
|
908
|
+
);
|
|
909
|
+
host.appendChild(
|
|
910
|
+
card(
|
|
911
|
+
"Goals",
|
|
912
|
+
[
|
|
913
|
+
upstreamBanner(goals),
|
|
914
|
+
goals && goals.mirrored === true
|
|
915
|
+
? el("p", {
|
|
916
|
+
class: "muted",
|
|
917
|
+
text: "This harness mirrors its goals here; the local continuity store stays authoritative.",
|
|
918
|
+
})
|
|
919
|
+
: null,
|
|
920
|
+
el("p", {
|
|
921
|
+
class: "muted",
|
|
922
|
+
text: "Goal filters take a singular `tag` — the array form is card grammar.",
|
|
923
|
+
}),
|
|
924
|
+
goalRows.length === 0
|
|
925
|
+
? emptyState(
|
|
926
|
+
goals && goals.ok === true
|
|
927
|
+
? str(goals.note, "No goals")
|
|
928
|
+
: "The workspace did not answer",
|
|
929
|
+
null,
|
|
930
|
+
)
|
|
931
|
+
: table(["Goal", "Progress", "Health", "Tags", "Deadline"], goalRows),
|
|
932
|
+
],
|
|
933
|
+
fetchedChip(goals),
|
|
934
|
+
),
|
|
935
|
+
);
|
|
936
|
+
const reload = () => renderPlanPanel(host, ctx);
|
|
937
|
+
const taskRows = list(tasks, "tasks").map((t) => {
|
|
938
|
+
const taskId = idOf(t);
|
|
939
|
+
const gap = Array.isArray(t.tags) && t.tags.includes("knowledge-gap");
|
|
940
|
+
const done = t.status === "completed" || Boolean(t.completedAt);
|
|
941
|
+
return el("tr", null, [
|
|
942
|
+
el("td", null, [
|
|
943
|
+
el("span", { text: str(t.title, taskId) }),
|
|
944
|
+
gap ? el("span", { class: "chip chip-warn", text: "study queue" }) : null,
|
|
945
|
+
]),
|
|
946
|
+
el("td", { text: str(t.status, "—") }),
|
|
947
|
+
el("td", { text: str(t.priority, "—") }),
|
|
948
|
+
el("td", null, chips(t.tags)),
|
|
949
|
+
el("td", { text: when(t.deadline) }),
|
|
950
|
+
el(
|
|
951
|
+
"td",
|
|
952
|
+
{ class: "cell-actions" },
|
|
953
|
+
done
|
|
954
|
+
? el("span", { class: "muted", text: "done" })
|
|
955
|
+
: el("button", {
|
|
956
|
+
class: "btn btn-ghost",
|
|
957
|
+
type: "button",
|
|
958
|
+
text: "Complete",
|
|
959
|
+
onClick: async () => {
|
|
960
|
+
const res = await write(
|
|
961
|
+
() => api.thredzTaskUpdate({ id: ctx.id, taskId }, { status: "done" }),
|
|
962
|
+
"Task completed",
|
|
963
|
+
);
|
|
964
|
+
if (res && res.ok === true) void reload();
|
|
965
|
+
},
|
|
966
|
+
}),
|
|
967
|
+
),
|
|
968
|
+
]);
|
|
969
|
+
});
|
|
970
|
+
const studyQueue = tasks && typeof tasks.studyQueue === "number" ? tasks.studyQueue : 0;
|
|
971
|
+
host.appendChild(
|
|
972
|
+
card(
|
|
973
|
+
"Tasks",
|
|
974
|
+
[
|
|
975
|
+
upstreamBanner(tasks),
|
|
976
|
+
el("p", {
|
|
977
|
+
class: "muted",
|
|
978
|
+
text: `${studyQueue} tagged knowledge-gap — the learning loop's study queue.`,
|
|
979
|
+
}),
|
|
980
|
+
taskRows.length === 0
|
|
981
|
+
? emptyState(
|
|
982
|
+
tasks && tasks.ok === true
|
|
983
|
+
? str(tasks.note, "No tasks")
|
|
984
|
+
: "The workspace did not answer",
|
|
985
|
+
null,
|
|
986
|
+
)
|
|
987
|
+
: table(["Task", "Status", "Priority", "Tags", "Deadline", ""], taskRows),
|
|
988
|
+
],
|
|
989
|
+
fetchedChip(tasks),
|
|
990
|
+
),
|
|
991
|
+
);
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
// ---------------------------------------------------------------------------
|
|
995
|
+
// views + dashboards
|
|
996
|
+
// ---------------------------------------------------------------------------
|
|
997
|
+
|
|
998
|
+
async function renderBoardsPanel(host, ctx, state = { dashboardId: null }) {
|
|
999
|
+
if (state.dashboardId !== null) return renderDashboard(host, ctx, state);
|
|
1000
|
+
const [views, dashboards] = await Promise.all([
|
|
1001
|
+
callProxy(() => api.thredzViews({ id: ctx.id })),
|
|
1002
|
+
callProxy(() => api.thredzDashboards({ id: ctx.id })),
|
|
1003
|
+
]);
|
|
1004
|
+
clear(host);
|
|
1005
|
+
const results = el("div", { class: "tab-section" });
|
|
1006
|
+
const viewRows = list(views, "views").map((v) => {
|
|
1007
|
+
const viewId = idOf(v);
|
|
1008
|
+
return el("tr", null, [
|
|
1009
|
+
el("td", { text: str(v.name, viewId) }),
|
|
1010
|
+
el("td", { class: "mono", text: str(v.entityType, "—") }),
|
|
1011
|
+
el("td", { text: when(v.updatedAt) }),
|
|
1012
|
+
el(
|
|
1013
|
+
"td",
|
|
1014
|
+
{ class: "cell-actions" },
|
|
1015
|
+
el("button", {
|
|
1016
|
+
class: "btn btn-ghost",
|
|
1017
|
+
type: "button",
|
|
1018
|
+
text: "Run",
|
|
1019
|
+
onClick: async () => {
|
|
1020
|
+
clear(results).appendChild(skeleton(3));
|
|
1021
|
+
const body = await callProxy(() =>
|
|
1022
|
+
api.thredzViewExecute({ id: ctx.id, viewId }, { params: {} }),
|
|
1023
|
+
);
|
|
1024
|
+
clear(results).appendChild(viewResultsCard(body));
|
|
1025
|
+
},
|
|
1026
|
+
}),
|
|
1027
|
+
),
|
|
1028
|
+
]);
|
|
1029
|
+
});
|
|
1030
|
+
host.appendChild(
|
|
1031
|
+
card(
|
|
1032
|
+
"Saved views",
|
|
1033
|
+
[
|
|
1034
|
+
upstreamBanner(views),
|
|
1035
|
+
viewRows.length === 0
|
|
1036
|
+
? emptyState(
|
|
1037
|
+
views && views.ok === true
|
|
1038
|
+
? str(views.note, "No saved views")
|
|
1039
|
+
: "The workspace did not answer",
|
|
1040
|
+
null,
|
|
1041
|
+
)
|
|
1042
|
+
: table(["View", "Entity", "Updated", ""], viewRows),
|
|
1043
|
+
filterKeysFold(views),
|
|
1044
|
+
],
|
|
1045
|
+
fetchedChip(views),
|
|
1046
|
+
),
|
|
1047
|
+
);
|
|
1048
|
+
host.appendChild(results);
|
|
1049
|
+
const boardRows = list(dashboards, "dashboards").map((d) => {
|
|
1050
|
+
const dashboardId = idOf(d);
|
|
1051
|
+
return el("tr", null, [
|
|
1052
|
+
el(
|
|
1053
|
+
"td",
|
|
1054
|
+
null,
|
|
1055
|
+
el("button", {
|
|
1056
|
+
class: "btn btn-ghost name-link",
|
|
1057
|
+
type: "button",
|
|
1058
|
+
text: str(d.name, dashboardId),
|
|
1059
|
+
onClick: () => void renderBoardsPanel(host, ctx, { dashboardId }),
|
|
1060
|
+
}),
|
|
1061
|
+
),
|
|
1062
|
+
el("td", { class: "mono", text: String(list(d, "cards").length) }),
|
|
1063
|
+
el("td", { text: when(d.updatedAt) }),
|
|
1064
|
+
]);
|
|
1065
|
+
});
|
|
1066
|
+
host.appendChild(
|
|
1067
|
+
card(
|
|
1068
|
+
"Dashboards",
|
|
1069
|
+
[
|
|
1070
|
+
upstreamBanner(dashboards),
|
|
1071
|
+
boardRows.length === 0
|
|
1072
|
+
? emptyState(
|
|
1073
|
+
dashboards && dashboards.ok === true
|
|
1074
|
+
? str(dashboards.note, "No dashboards")
|
|
1075
|
+
: "The workspace did not answer",
|
|
1076
|
+
null,
|
|
1077
|
+
)
|
|
1078
|
+
: table(["Dashboard", "Cards", "Updated"], boardRows),
|
|
1079
|
+
grammarFold(dashboards),
|
|
1080
|
+
],
|
|
1081
|
+
fetchedChip(dashboards),
|
|
1082
|
+
),
|
|
1083
|
+
);
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
function viewResultsCard(payload) {
|
|
1087
|
+
if (!payload) return card("View results", emptyState("No answer", null));
|
|
1088
|
+
const rows = list(payload, "results");
|
|
1089
|
+
const columns = rows.length > 0 ? Object.keys(rows[0]).slice(0, 6) : [];
|
|
1090
|
+
return card(
|
|
1091
|
+
`Results — ${str(payload.viewName, str(payload.viewId, "view"))}`,
|
|
1092
|
+
[
|
|
1093
|
+
upstreamBanner(payload),
|
|
1094
|
+
rows.length === 0
|
|
1095
|
+
? emptyState(str(payload.note, "This view matched no rows"), null)
|
|
1096
|
+
: table(
|
|
1097
|
+
columns,
|
|
1098
|
+
rows.map((row) =>
|
|
1099
|
+
el(
|
|
1100
|
+
"tr",
|
|
1101
|
+
null,
|
|
1102
|
+
columns.map((c) => el("td", { text: clampText(String(row[c] ?? "—"), 60) })),
|
|
1103
|
+
),
|
|
1104
|
+
),
|
|
1105
|
+
),
|
|
1106
|
+
],
|
|
1107
|
+
fetchedChip(payload),
|
|
1108
|
+
);
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
/** The filter vocabulary, straight off the payload — records take a `tags`
|
|
1112
|
+
* ARRAY (AND), tasks and goals a singular `tag`. */
|
|
1113
|
+
function filterKeysFold(payload) {
|
|
1114
|
+
const keys =
|
|
1115
|
+
payload?.filterKeys && typeof payload.filterKeys === "object" ? payload.filterKeys : null;
|
|
1116
|
+
if (!keys) return null;
|
|
1117
|
+
return el("details", { class: "fold" }, [
|
|
1118
|
+
el("summary", { class: "fold-summary" }, [
|
|
1119
|
+
el("span", { class: "muted", text: "valid filter keys per entity type" }),
|
|
1120
|
+
]),
|
|
1121
|
+
el("div", { class: "fold-body" }, [
|
|
1122
|
+
table(
|
|
1123
|
+
["Entity", "Filter keys"],
|
|
1124
|
+
Object.entries(keys).map(([entity, names]) =>
|
|
1125
|
+
el("tr", null, [el("td", { class: "mono", text: entity }), el("td", null, chips(names))]),
|
|
1126
|
+
),
|
|
1127
|
+
),
|
|
1128
|
+
el("p", {
|
|
1129
|
+
class: "muted",
|
|
1130
|
+
text: "Record filters take `tags` as an ARRAY (all of them must match); task and goal filters take a singular `tag`.",
|
|
1131
|
+
}),
|
|
1132
|
+
]),
|
|
1133
|
+
]);
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
function grammarFold(payload) {
|
|
1137
|
+
const grammar =
|
|
1138
|
+
payload?.cardGrammar && typeof payload.cardGrammar === "object" ? payload.cardGrammar : null;
|
|
1139
|
+
if (!grammar) return null;
|
|
1140
|
+
return el("details", { class: "fold" }, [
|
|
1141
|
+
el("summary", { class: "fold-summary" }, [
|
|
1142
|
+
el("span", { class: "muted", text: "the card grammar" }),
|
|
1143
|
+
]),
|
|
1144
|
+
el("div", { class: "fold-body" }, [
|
|
1145
|
+
el("p", { class: "muted" }, ["Card types: ", ...chips(grammar.cardTypes)]),
|
|
1146
|
+
el("p", { class: "muted" }, ["Graph types: ", ...chips(grammar.graphTypes)]),
|
|
1147
|
+
el("p", { class: "muted" }, ["KPI aggregations: ", ...chips(grammar.aggregations)]),
|
|
1148
|
+
el("p", { class: "muted" }, ["A KPI card needs both ", ...chips(grammar.kpiRequires)]),
|
|
1149
|
+
filterKeysFold(payload),
|
|
1150
|
+
]),
|
|
1151
|
+
]);
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
async function renderDashboard(host, ctx, state) {
|
|
1155
|
+
clear(host).appendChild(skeleton(5));
|
|
1156
|
+
const dashboardId = state.dashboardId;
|
|
1157
|
+
const payload = await callProxy(() => api.thredzDashboard({ id: ctx.id, dashboardId }));
|
|
1158
|
+
clear(host);
|
|
1159
|
+
const back = el("button", {
|
|
1160
|
+
class: "btn btn-ghost",
|
|
1161
|
+
type: "button",
|
|
1162
|
+
text: "← all dashboards",
|
|
1163
|
+
onClick: () => void renderBoardsPanel(host, ctx, { dashboardId: null }),
|
|
1164
|
+
});
|
|
1165
|
+
const dashboard =
|
|
1166
|
+
payload?.dashboard && typeof payload.dashboard === "object" ? payload.dashboard : null;
|
|
1167
|
+
const cards = list(payload, "cards");
|
|
1168
|
+
host.appendChild(
|
|
1169
|
+
card(dashboard ? str(dashboard.name, dashboardId) : dashboardId, [
|
|
1170
|
+
el("div", { class: "safety-strip" }, [
|
|
1171
|
+
back,
|
|
1172
|
+
payload && payload.dataResolved === false
|
|
1173
|
+
? dot("warn", "card data unavailable — the cards are real, their numbers are not")
|
|
1174
|
+
: null,
|
|
1175
|
+
fetchedChip(payload),
|
|
1176
|
+
]),
|
|
1177
|
+
upstreamBanner(payload),
|
|
1178
|
+
payload?.dataError ? el("p", { class: "prose-pre", text: String(payload.dataError) }) : null,
|
|
1179
|
+
cards.length === 0
|
|
1180
|
+
? emptyState("This dashboard has no cards yet", null)
|
|
1181
|
+
: el(
|
|
1182
|
+
"div",
|
|
1183
|
+
{ class: "ov-grid" },
|
|
1184
|
+
cards.map((c) => cardTile(c)),
|
|
1185
|
+
),
|
|
1186
|
+
]),
|
|
1187
|
+
);
|
|
1188
|
+
host.appendChild(addCardCard(ctx, dashboardId, payload, () => renderDashboard(host, ctx, state)));
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
function cardTile(cardDef) {
|
|
1192
|
+
const display = cardDef.display && typeof cardDef.display === "object" ? cardDef.display : {};
|
|
1193
|
+
const source =
|
|
1194
|
+
cardDef.dataSource && typeof cardDef.dataSource === "object" ? cardDef.dataSource : {};
|
|
1195
|
+
const data = cardDef.data;
|
|
1196
|
+
const body = [];
|
|
1197
|
+
if (str(cardDef.type) === "kpi") {
|
|
1198
|
+
const value = data && typeof data === "object" && "value" in data ? data.value : null;
|
|
1199
|
+
body.push(
|
|
1200
|
+
el("div", { class: "big-stat" }, [
|
|
1201
|
+
el("span", { class: "stat-num", text: String(value ?? "—") }),
|
|
1202
|
+
]),
|
|
1203
|
+
);
|
|
1204
|
+
body.push(
|
|
1205
|
+
el("div", { class: "mini-row" }, [
|
|
1206
|
+
el("span", {
|
|
1207
|
+
class: "mini-label",
|
|
1208
|
+
text: `${str(display.aggregation, "?")}(${str(display.aggregationField, "?")})`,
|
|
1209
|
+
}),
|
|
1210
|
+
]),
|
|
1211
|
+
);
|
|
1212
|
+
} else if (Array.isArray(data) && data.length > 0) {
|
|
1213
|
+
const columns = Object.keys(data[0]).slice(0, 4);
|
|
1214
|
+
body.push(
|
|
1215
|
+
table(
|
|
1216
|
+
columns,
|
|
1217
|
+
data.slice(0, 8).map((row) =>
|
|
1218
|
+
el(
|
|
1219
|
+
"tr",
|
|
1220
|
+
null,
|
|
1221
|
+
columns.map((c) => el("td", { text: clampText(String(row[c] ?? "—"), 40) })),
|
|
1222
|
+
),
|
|
1223
|
+
),
|
|
1224
|
+
),
|
|
1225
|
+
);
|
|
1226
|
+
} else {
|
|
1227
|
+
body.push(el("p", { class: "muted", text: "no data resolved for this card" }));
|
|
1228
|
+
}
|
|
1229
|
+
const filterNames = Object.keys(source.filters ?? {}).join(", ");
|
|
1230
|
+
return el("section", { class: "card ov-card" }, [
|
|
1231
|
+
el("h3", { class: "card-title" }, [
|
|
1232
|
+
el("span", { text: str(cardDef.title, "card") }),
|
|
1233
|
+
el("span", { class: "chip", text: str(cardDef.type, "?") }),
|
|
1234
|
+
]),
|
|
1235
|
+
el("div", {
|
|
1236
|
+
class: "muted",
|
|
1237
|
+
text: `${str(source.entityType, "?")} · ${filterNames === "" ? "no filters" : filterNames}`,
|
|
1238
|
+
}),
|
|
1239
|
+
...body,
|
|
1240
|
+
]);
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
/**
|
|
1244
|
+
* The add-card form. It does NOT pre-validate the grammar: the workspace is
|
|
1245
|
+
* the authority, and its refusal names the valid keys for the entity type —
|
|
1246
|
+
* so the refusal is printed here verbatim, under the form, with the draft
|
|
1247
|
+
* intact.
|
|
1248
|
+
*/
|
|
1249
|
+
function addCardCard(ctx, dashboardId, payload, reload) {
|
|
1250
|
+
const grammar = payload?.cardGrammar ? payload.cardGrammar : {};
|
|
1251
|
+
const title = input({ placeholder: "card title" });
|
|
1252
|
+
const type = select(Array.isArray(grammar.cardTypes) ? grammar.cardTypes : ["table"], "table");
|
|
1253
|
+
const entity = select(
|
|
1254
|
+
payload?.filterKeys ? Object.keys(payload.filterKeys) : ["record"],
|
|
1255
|
+
"record",
|
|
1256
|
+
);
|
|
1257
|
+
const filters = el("textarea", { class: "input", rows: 3, spellcheck: "false" });
|
|
1258
|
+
filters.value = '{"tags": []}';
|
|
1259
|
+
const aggregation = select(
|
|
1260
|
+
["(none)", ...(Array.isArray(grammar.aggregations) ? grammar.aggregations : [])],
|
|
1261
|
+
"(none)",
|
|
1262
|
+
);
|
|
1263
|
+
const aggregationField = input({ placeholder: "aggregation field (KPI cards)" });
|
|
1264
|
+
const graphType = select(
|
|
1265
|
+
["(none)", ...(Array.isArray(grammar.graphTypes) ? grammar.graphTypes : [])],
|
|
1266
|
+
"(none)",
|
|
1267
|
+
);
|
|
1268
|
+
const refusal = el("div", { class: "gated-why" });
|
|
1269
|
+
const form = el("form", { class: "row-editor" }, [
|
|
1270
|
+
field("Title", title),
|
|
1271
|
+
field("Type", type),
|
|
1272
|
+
field("Entity", entity),
|
|
1273
|
+
field("Filters (JSON)", filters),
|
|
1274
|
+
field("KPI aggregation", aggregation),
|
|
1275
|
+
field("KPI aggregation field", aggregationField),
|
|
1276
|
+
field("Graph type", graphType),
|
|
1277
|
+
refusal,
|
|
1278
|
+
el("div", { class: "editor-actions" }, [
|
|
1279
|
+
el("button", { class: "btn btn-primary", type: "submit", text: "Add card" }),
|
|
1280
|
+
]),
|
|
1281
|
+
]);
|
|
1282
|
+
form.addEventListener("submit", async (e) => {
|
|
1283
|
+
e.preventDefault();
|
|
1284
|
+
clear(refusal);
|
|
1285
|
+
let parsedFilters = {};
|
|
1286
|
+
try {
|
|
1287
|
+
parsedFilters = JSON.parse(filters.value === "" ? "{}" : filters.value);
|
|
1288
|
+
} catch {
|
|
1289
|
+
toast("filters must be JSON");
|
|
1290
|
+
return;
|
|
1291
|
+
}
|
|
1292
|
+
const display = {};
|
|
1293
|
+
if (aggregation.value !== "(none)") display.aggregation = aggregation.value;
|
|
1294
|
+
if (aggregationField.value.trim() !== "") {
|
|
1295
|
+
display.aggregationField = aggregationField.value.trim();
|
|
1296
|
+
}
|
|
1297
|
+
if (graphType.value !== "(none)") display.graphType = graphType.value;
|
|
1298
|
+
const res = await write(
|
|
1299
|
+
() =>
|
|
1300
|
+
api.thredzCardCreate(
|
|
1301
|
+
{ id: ctx.id, dashboardId },
|
|
1302
|
+
{
|
|
1303
|
+
title: title.value,
|
|
1304
|
+
type: type.value,
|
|
1305
|
+
dataSource: { entityType: entity.value, filters: parsedFilters },
|
|
1306
|
+
display,
|
|
1307
|
+
},
|
|
1308
|
+
),
|
|
1309
|
+
"Card added",
|
|
1310
|
+
);
|
|
1311
|
+
if (res && res.ok === true) {
|
|
1312
|
+
void reload();
|
|
1313
|
+
return;
|
|
1314
|
+
}
|
|
1315
|
+
if (res) {
|
|
1316
|
+
// Verbatim — this string names the keys that WOULD have worked.
|
|
1317
|
+
refusal.appendChild(dot("warn", "the workspace rejected this card"));
|
|
1318
|
+
refusal.appendChild(el("p", { class: "prose-pre", text: str(res.note, "refused") }));
|
|
1319
|
+
}
|
|
1320
|
+
});
|
|
1321
|
+
return card("Add a card", [
|
|
1322
|
+
el("p", {
|
|
1323
|
+
class: "muted",
|
|
1324
|
+
text: "A KPI card needs both an aggregation and an aggregation field. Record filters take `tags` as an array; task and goal filters take a singular `tag`.",
|
|
1325
|
+
}),
|
|
1326
|
+
form,
|
|
1327
|
+
]);
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
// ---------------------------------------------------------------------------
|
|
1331
|
+
// automations
|
|
1332
|
+
// ---------------------------------------------------------------------------
|
|
1333
|
+
|
|
1334
|
+
async function renderOpsPanel(host, ctx) {
|
|
1335
|
+
const [listeners, webhooks, connectors, activity] = await Promise.all([
|
|
1336
|
+
callProxy(() => api.thredzListeners({ id: ctx.id })),
|
|
1337
|
+
callProxy(() => api.thredzWebhooks({ id: ctx.id })),
|
|
1338
|
+
callProxy(() => api.thredzConnectors({ id: ctx.id })),
|
|
1339
|
+
callProxy(() => api.thredzActivity({ id: ctx.id })),
|
|
1340
|
+
]);
|
|
1341
|
+
clear(host);
|
|
1342
|
+
const reload = () => renderOpsPanel(host, ctx);
|
|
1343
|
+
const quota = listeners?.quota && typeof listeners.quota === "object" ? listeners.quota : null;
|
|
1344
|
+
host.appendChild(
|
|
1345
|
+
card(
|
|
1346
|
+
"Listeners",
|
|
1347
|
+
[
|
|
1348
|
+
upstreamBanner(listeners),
|
|
1349
|
+
listeners && listeners.quotaLocked === true
|
|
1350
|
+
? dot("warn", "plan quota reached — a fact about the plan, not a failure")
|
|
1351
|
+
: null,
|
|
1352
|
+
quota
|
|
1353
|
+
? el("p", {
|
|
1354
|
+
class: "muted",
|
|
1355
|
+
text: `${quota.used ?? "?"} of ${quota.limit ?? "?"} listener slots used${quota.plan ? ` on the ${quota.plan} plan` : ""}.`,
|
|
1356
|
+
})
|
|
1357
|
+
: null,
|
|
1358
|
+
list(listeners, "listeners").length === 0
|
|
1359
|
+
? emptyState(
|
|
1360
|
+
listeners && listeners.ok === true
|
|
1361
|
+
? str(listeners.note, "No listeners")
|
|
1362
|
+
: "The workspace did not answer",
|
|
1363
|
+
null,
|
|
1364
|
+
)
|
|
1365
|
+
: table(
|
|
1366
|
+
["Listener", "Event", "Enabled", "Updated"],
|
|
1367
|
+
list(listeners, "listeners").map((l) =>
|
|
1368
|
+
el("tr", null, [
|
|
1369
|
+
el("td", { text: str(l.name, idOf(l)) }),
|
|
1370
|
+
el("td", { class: "mono", text: str(l.event, str(l.source, "—")) }),
|
|
1371
|
+
el("td", null, l.enabled === false ? dot("off", "off") : dot("ok", "on")),
|
|
1372
|
+
el("td", { text: when(l.updatedAt) }),
|
|
1373
|
+
]),
|
|
1374
|
+
),
|
|
1375
|
+
),
|
|
1376
|
+
listenerCreateForm(ctx, reload),
|
|
1377
|
+
],
|
|
1378
|
+
fetchedChip(listeners),
|
|
1379
|
+
),
|
|
1380
|
+
);
|
|
1381
|
+
host.appendChild(
|
|
1382
|
+
card(
|
|
1383
|
+
"Webhooks",
|
|
1384
|
+
[
|
|
1385
|
+
upstreamBanner(webhooks),
|
|
1386
|
+
webhooks && webhooks.failedDeliveries > 0
|
|
1387
|
+
? dot("warn", `${webhooks.failedDeliveries} recent deliveries did not land`)
|
|
1388
|
+
: null,
|
|
1389
|
+
webhooks?.deliveriesError
|
|
1390
|
+
? el("p", { class: "muted", text: String(webhooks.deliveriesError) })
|
|
1391
|
+
: null,
|
|
1392
|
+
list(webhooks, "webhooks").length === 0
|
|
1393
|
+
? emptyState(
|
|
1394
|
+
webhooks && webhooks.ok === true
|
|
1395
|
+
? str(webhooks.note, "No webhooks")
|
|
1396
|
+
: "The workspace did not answer",
|
|
1397
|
+
null,
|
|
1398
|
+
)
|
|
1399
|
+
: table(
|
|
1400
|
+
["URL", "Events", "Active"],
|
|
1401
|
+
list(webhooks, "webhooks").map((w) =>
|
|
1402
|
+
el("tr", null, [
|
|
1403
|
+
el("td", { class: "mono", text: clampText(str(w.url, "—"), 60) }),
|
|
1404
|
+
el("td", null, chips(w.events)),
|
|
1405
|
+
el("td", null, w.active === false ? dot("off", "off") : dot("ok", "on")),
|
|
1406
|
+
]),
|
|
1407
|
+
),
|
|
1408
|
+
),
|
|
1409
|
+
],
|
|
1410
|
+
fetchedChip(webhooks),
|
|
1411
|
+
),
|
|
1412
|
+
);
|
|
1413
|
+
host.appendChild(
|
|
1414
|
+
card(
|
|
1415
|
+
"Connectors",
|
|
1416
|
+
[
|
|
1417
|
+
upstreamBanner(connectors),
|
|
1418
|
+
list(connectors, "connectors").length === 0
|
|
1419
|
+
? emptyState(
|
|
1420
|
+
connectors && connectors.ok === true
|
|
1421
|
+
? str(connectors.note, "No connectors")
|
|
1422
|
+
: "The workspace did not answer",
|
|
1423
|
+
null,
|
|
1424
|
+
)
|
|
1425
|
+
: table(
|
|
1426
|
+
["Connector", "Type", "Source → destination"],
|
|
1427
|
+
list(connectors, "connectors").map((c) =>
|
|
1428
|
+
el("tr", null, [
|
|
1429
|
+
el("td", { text: str(c.title, idOf(c)) }),
|
|
1430
|
+
el("td", { class: "mono", text: str(c.type, "—") }),
|
|
1431
|
+
el("td", {
|
|
1432
|
+
class: "mono",
|
|
1433
|
+
text: `${str(c.sourceId, "?")} → ${str(c.destinationId, "?")}`,
|
|
1434
|
+
}),
|
|
1435
|
+
]),
|
|
1436
|
+
),
|
|
1437
|
+
),
|
|
1438
|
+
],
|
|
1439
|
+
fetchedChip(connectors),
|
|
1440
|
+
),
|
|
1441
|
+
);
|
|
1442
|
+
host.appendChild(
|
|
1443
|
+
card(
|
|
1444
|
+
"Workspace activity",
|
|
1445
|
+
[
|
|
1446
|
+
upstreamBanner(activity),
|
|
1447
|
+
el("p", {
|
|
1448
|
+
class: "muted",
|
|
1449
|
+
text: "This is the Thredz workspace's own feed — distinct from the manager's fleet activity digest.",
|
|
1450
|
+
}),
|
|
1451
|
+
list(activity, "items").length === 0
|
|
1452
|
+
? emptyState(
|
|
1453
|
+
activity && activity.ok === true
|
|
1454
|
+
? str(activity.note, "No activity")
|
|
1455
|
+
: "The workspace did not answer",
|
|
1456
|
+
null,
|
|
1457
|
+
)
|
|
1458
|
+
: el(
|
|
1459
|
+
"ul",
|
|
1460
|
+
{ class: "fact-list" },
|
|
1461
|
+
list(activity, "items").map((a) =>
|
|
1462
|
+
el("li", { class: "fact" }, [
|
|
1463
|
+
el("div", {
|
|
1464
|
+
text: `${str(a.action, "?")} ${str(a.entityType, "")} ${str(a.entityTitle, "")}`.trim(),
|
|
1465
|
+
}),
|
|
1466
|
+
el("div", { class: "fact-meta", text: when(a.createdAt) }),
|
|
1467
|
+
]),
|
|
1468
|
+
),
|
|
1469
|
+
),
|
|
1470
|
+
wikiAuditFold(activity),
|
|
1471
|
+
],
|
|
1472
|
+
fetchedChip(activity),
|
|
1473
|
+
),
|
|
1474
|
+
);
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
/** The wiki's own audit log — who edited, rolled back or re-graded an
|
|
1478
|
+
* article. A separate log upstream, so it gets its own fold rather than
|
|
1479
|
+
* being blended into the general feed. */
|
|
1480
|
+
function wikiAuditFold(activity) {
|
|
1481
|
+
const entries = list(activity, "wikiAudit");
|
|
1482
|
+
const error = activity?.wikiAuditError ?? null;
|
|
1483
|
+
if (entries.length === 0 && error === null) return null;
|
|
1484
|
+
return el("details", { class: "fold" }, [
|
|
1485
|
+
el("summary", { class: "fold-summary" }, [
|
|
1486
|
+
el("span", { class: "muted", text: "wiki audit log" }),
|
|
1487
|
+
]),
|
|
1488
|
+
el("div", { class: "fold-body" }, [
|
|
1489
|
+
error !== null ? el("p", { class: "muted", text: String(error) }) : null,
|
|
1490
|
+
entries.length === 0
|
|
1491
|
+
? emptyState("No wiki audit entries", null)
|
|
1492
|
+
: table(
|
|
1493
|
+
["Action", "Article", "By", "When"],
|
|
1494
|
+
entries.map((entry) =>
|
|
1495
|
+
el("tr", null, [
|
|
1496
|
+
el("td", { text: str(entry.action, "—") }),
|
|
1497
|
+
el("td", { class: "mono", text: str(entry.slug, str(entry.articleId, "—")) }),
|
|
1498
|
+
el("td", { class: "mono", text: str(entry.keyId, "—") }),
|
|
1499
|
+
el("td", { text: when(entry.createdAt) }),
|
|
1500
|
+
]),
|
|
1501
|
+
),
|
|
1502
|
+
),
|
|
1503
|
+
]),
|
|
1504
|
+
]);
|
|
1505
|
+
}
|
|
1506
|
+
|
|
1507
|
+
function listenerCreateForm(ctx, reload) {
|
|
1508
|
+
const event = input({ placeholder: "event (e.g. record.created)" });
|
|
1509
|
+
const name = input({ placeholder: "name" });
|
|
1510
|
+
const form = el("form", { class: "add-form" }, [
|
|
1511
|
+
event,
|
|
1512
|
+
name,
|
|
1513
|
+
el("button", { class: "btn", type: "submit", text: "Create listener" }),
|
|
1514
|
+
]);
|
|
1515
|
+
form.addEventListener("submit", async (e) => {
|
|
1516
|
+
e.preventDefault();
|
|
1517
|
+
const res = await write(
|
|
1518
|
+
() =>
|
|
1519
|
+
api.thredzListenerCreate(
|
|
1520
|
+
{ id: ctx.id },
|
|
1521
|
+
{ event: event.value.trim(), name: name.value.trim() },
|
|
1522
|
+
),
|
|
1523
|
+
"Listener created",
|
|
1524
|
+
);
|
|
1525
|
+
if (res && res.ok === true) void reload();
|
|
1526
|
+
});
|
|
1527
|
+
return el("div", null, [
|
|
1528
|
+
form,
|
|
1529
|
+
el("p", {
|
|
1530
|
+
class: "muted",
|
|
1531
|
+
text: "Creates are idempotency-keyed, so a replayed request cannot duplicate the automation.",
|
|
1532
|
+
}),
|
|
1533
|
+
]);
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1536
|
+
// ---------------------------------------------------------------------------
|
|
1537
|
+
// traverse
|
|
1538
|
+
// ---------------------------------------------------------------------------
|
|
1539
|
+
|
|
1540
|
+
async function renderGraphPanel(host, ctx) {
|
|
1541
|
+
clear(host);
|
|
1542
|
+
const results = el("div", { class: "tab-section" });
|
|
1543
|
+
const startId = input({ placeholder: "start id" });
|
|
1544
|
+
const startType = select(["record", "goal", "task", "connector", "graph"], "record");
|
|
1545
|
+
const direction = select(["both", "upstream", "downstream"], "both");
|
|
1546
|
+
const depth = input({ type: "number", value: "3", min: "1", max: "10" });
|
|
1547
|
+
const form = el("form", { class: "row-editor" }, [
|
|
1548
|
+
field("Start id", startId),
|
|
1549
|
+
field("Start type", startType),
|
|
1550
|
+
field("Direction", direction),
|
|
1551
|
+
field("Max depth", depth),
|
|
1552
|
+
el("div", { class: "editor-actions" }, [
|
|
1553
|
+
el("button", { class: "btn btn-primary", type: "submit", text: "Traverse" }),
|
|
1554
|
+
]),
|
|
1555
|
+
]);
|
|
1556
|
+
form.addEventListener("submit", async (e) => {
|
|
1557
|
+
e.preventDefault();
|
|
1558
|
+
clear(results).appendChild(skeleton(3));
|
|
1559
|
+
const body = await callProxy(() =>
|
|
1560
|
+
api.thredzTraverse(
|
|
1561
|
+
{ id: ctx.id },
|
|
1562
|
+
{
|
|
1563
|
+
startId: startId.value.trim(),
|
|
1564
|
+
startType: startType.value,
|
|
1565
|
+
direction: direction.value,
|
|
1566
|
+
maxDepth: Number.parseInt(depth.value, 10) || 3,
|
|
1567
|
+
},
|
|
1568
|
+
),
|
|
1569
|
+
);
|
|
1570
|
+
clear(results);
|
|
1571
|
+
if (!body) return;
|
|
1572
|
+
const nodes = list(body, "nodes");
|
|
1573
|
+
results.appendChild(
|
|
1574
|
+
card(
|
|
1575
|
+
"Traversal",
|
|
1576
|
+
[
|
|
1577
|
+
upstreamBanner(body),
|
|
1578
|
+
nodes.length === 0
|
|
1579
|
+
? emptyState(str(body.note, "Nothing connected in that direction"), null)
|
|
1580
|
+
: table(
|
|
1581
|
+
["Node", "Type", "Depth"],
|
|
1582
|
+
nodes.map((n) =>
|
|
1583
|
+
el("tr", null, [
|
|
1584
|
+
el("td", { text: str(n.title, idOf(n)) }),
|
|
1585
|
+
el("td", { class: "mono", text: str(n.type, str(n.entityType, "—")) }),
|
|
1586
|
+
el("td", { class: "mono", text: String(n.depth ?? "—") }),
|
|
1587
|
+
]),
|
|
1588
|
+
),
|
|
1589
|
+
),
|
|
1590
|
+
el("p", { class: "muted", text: `${list(body, "edges").length} edges` }),
|
|
1591
|
+
],
|
|
1592
|
+
fetchedChip(body),
|
|
1593
|
+
),
|
|
1594
|
+
);
|
|
1595
|
+
});
|
|
1596
|
+
host.appendChild(
|
|
1597
|
+
card("Graph traversal", [
|
|
1598
|
+
el("p", {
|
|
1599
|
+
class: "muted",
|
|
1600
|
+
text: "Walk the workspace graph from one node — records, goals, tasks and their connectors.",
|
|
1601
|
+
}),
|
|
1602
|
+
form,
|
|
1603
|
+
]),
|
|
1604
|
+
);
|
|
1605
|
+
host.appendChild(results);
|
|
1606
|
+
}
|
|
1607
|
+
|
|
1608
|
+
// ---------------------------------------------------------------------------
|
|
1609
|
+
// key administration
|
|
1610
|
+
// ---------------------------------------------------------------------------
|
|
1611
|
+
|
|
1612
|
+
async function renderKeysPanel(host, ctx) {
|
|
1613
|
+
const payload = await callProxy(() => api.thredzKeys({ id: ctx.id }));
|
|
1614
|
+
clear(host);
|
|
1615
|
+
const reload = () => renderKeysPanel(host, ctx);
|
|
1616
|
+
const keys = list(payload, "keys");
|
|
1617
|
+
const grants = list(payload, "grants");
|
|
1618
|
+
host.appendChild(
|
|
1619
|
+
card("API keys", [
|
|
1620
|
+
el("div", { class: "safety-strip" }, [
|
|
1621
|
+
dot(
|
|
1622
|
+
payload && payload.tier === "admin" ? "ok" : "off",
|
|
1623
|
+
`tier: ${payload ? str(payload.tier, "unknown") : "unknown"}`,
|
|
1624
|
+
),
|
|
1625
|
+
fetchedChip(payload),
|
|
1626
|
+
]),
|
|
1627
|
+
upstreamBanner(payload),
|
|
1628
|
+
el("p", {
|
|
1629
|
+
class: "muted",
|
|
1630
|
+
text: "Key VALUES never reach this screen — the manager strips them server-side. Only metadata and grants are shown.",
|
|
1631
|
+
}),
|
|
1632
|
+
keys.length === 0
|
|
1633
|
+
? emptyState(
|
|
1634
|
+
payload && payload.ok === true
|
|
1635
|
+
? str(payload.note, "No keys")
|
|
1636
|
+
: "Key administration needs an admin-tier key",
|
|
1637
|
+
null,
|
|
1638
|
+
)
|
|
1639
|
+
: table(
|
|
1640
|
+
["Key", "Owner", "Permissions", "Created", ""],
|
|
1641
|
+
keys.map((k) =>
|
|
1642
|
+
el("tr", null, [
|
|
1643
|
+
el("td", { class: "mono", text: str(k.id, "—") }),
|
|
1644
|
+
el("td", { text: str(k.owner, "—") }),
|
|
1645
|
+
el("td", null, [
|
|
1646
|
+
el("span", { text: str(k.permissions, "—") }),
|
|
1647
|
+
k.disabled === true ? dot("bad", "disabled") : null,
|
|
1648
|
+
]),
|
|
1649
|
+
el("td", { text: when(k.createdAt) }),
|
|
1650
|
+
el(
|
|
1651
|
+
"td",
|
|
1652
|
+
{ class: "cell-actions" },
|
|
1653
|
+
el("button", {
|
|
1654
|
+
class: "btn btn-ghost",
|
|
1655
|
+
type: "button",
|
|
1656
|
+
text: "Rotate…",
|
|
1657
|
+
onClick: () => renderRotate(host, ctx, k, reload),
|
|
1658
|
+
}),
|
|
1659
|
+
),
|
|
1660
|
+
]),
|
|
1661
|
+
),
|
|
1662
|
+
),
|
|
1663
|
+
grants.length === 0
|
|
1664
|
+
? null
|
|
1665
|
+
: el("details", { class: "fold" }, [
|
|
1666
|
+
el("summary", { class: "fold-summary" }, [
|
|
1667
|
+
el("span", { class: "muted", text: "wiki grants" }),
|
|
1668
|
+
]),
|
|
1669
|
+
el("div", { class: "fold-body" }, [
|
|
1670
|
+
table(
|
|
1671
|
+
["Key", "Permission"],
|
|
1672
|
+
grants.map((g) =>
|
|
1673
|
+
el("tr", null, [
|
|
1674
|
+
el("td", { class: "mono", text: str(g.keyId, "—") }),
|
|
1675
|
+
el("td", { text: str(g.permission, "—") }),
|
|
1676
|
+
]),
|
|
1677
|
+
),
|
|
1678
|
+
),
|
|
1679
|
+
]),
|
|
1680
|
+
]),
|
|
1681
|
+
]),
|
|
1682
|
+
);
|
|
1683
|
+
if (payload && payload.tier === "admin") host.appendChild(keyCreateCard(ctx, reload));
|
|
1684
|
+
}
|
|
1685
|
+
|
|
1686
|
+
function keyCreateCard(ctx, reload) {
|
|
1687
|
+
const label = input({ placeholder: "owner / label" });
|
|
1688
|
+
const permissions = select(["read-write", "read", "admin"], "read-write");
|
|
1689
|
+
const value = el("input", { class: "input", type: "password", placeholder: "the key's value" });
|
|
1690
|
+
const grant = select(["(none)", "read", "read-write"], "read-write");
|
|
1691
|
+
const form = el("form", { class: "row-editor" }, [
|
|
1692
|
+
field("Owner", label),
|
|
1693
|
+
field("Permissions", permissions),
|
|
1694
|
+
field("Value (write-only)", value),
|
|
1695
|
+
field("Wiki grant", grant),
|
|
1696
|
+
el("div", { class: "editor-actions" }, [
|
|
1697
|
+
el("button", { class: "btn btn-primary", type: "submit", text: "Register key" }),
|
|
1698
|
+
]),
|
|
1699
|
+
]);
|
|
1700
|
+
form.addEventListener("submit", async (e) => {
|
|
1701
|
+
e.preventDefault();
|
|
1702
|
+
const res = await write(
|
|
1703
|
+
() =>
|
|
1704
|
+
api.thredzKeyCreate(
|
|
1705
|
+
{ id: ctx.id },
|
|
1706
|
+
{
|
|
1707
|
+
label: label.value.trim(),
|
|
1708
|
+
permissions: permissions.value,
|
|
1709
|
+
value: value.value,
|
|
1710
|
+
...(grant.value === "(none)" ? {} : { wikiAccess: grant.value }),
|
|
1711
|
+
},
|
|
1712
|
+
),
|
|
1713
|
+
"Key registered — its value was forwarded once and stored nowhere",
|
|
1714
|
+
);
|
|
1715
|
+
value.value = "";
|
|
1716
|
+
if (res && res.ok === true) void reload();
|
|
1717
|
+
});
|
|
1718
|
+
return card("Register a key", [
|
|
1719
|
+
el("p", {
|
|
1720
|
+
class: "muted",
|
|
1721
|
+
text: "Mint the key in the Thredz account portal, then paste it here once. The manager forwards it and never stores, logs or returns it — which is also why it can never show you a key you have lost.",
|
|
1722
|
+
}),
|
|
1723
|
+
form,
|
|
1724
|
+
]);
|
|
1725
|
+
}
|
|
1726
|
+
|
|
1727
|
+
function renderRotate(host, ctx, key, reload) {
|
|
1728
|
+
const expected = str(key.owner, str(key.id, ""));
|
|
1729
|
+
const value = el("input", {
|
|
1730
|
+
class: "input",
|
|
1731
|
+
type: "password",
|
|
1732
|
+
placeholder: "the replacement value",
|
|
1733
|
+
});
|
|
1734
|
+
const confirmName = input({ placeholder: `type "${expected}" to confirm` });
|
|
1735
|
+
const form = el("form", { class: "row-editor" }, [
|
|
1736
|
+
field("Replacement value (write-only)", value),
|
|
1737
|
+
field("Confirm", confirmName),
|
|
1738
|
+
el("div", { class: "editor-actions" }, [
|
|
1739
|
+
el("button", { class: "btn btn-danger", type: "submit", text: "Rotate" }),
|
|
1740
|
+
el("button", {
|
|
1741
|
+
class: "btn btn-ghost",
|
|
1742
|
+
type: "button",
|
|
1743
|
+
text: "Cancel",
|
|
1744
|
+
onClick: () => void reload(),
|
|
1745
|
+
}),
|
|
1746
|
+
]),
|
|
1747
|
+
]);
|
|
1748
|
+
form.addEventListener("submit", async (e) => {
|
|
1749
|
+
e.preventDefault();
|
|
1750
|
+
const res = await write(
|
|
1751
|
+
() =>
|
|
1752
|
+
api.thredzKeyRotate(
|
|
1753
|
+
{ id: ctx.id, keyId: str(key.id, "") },
|
|
1754
|
+
{ value: value.value, confirmName: confirmName.value },
|
|
1755
|
+
),
|
|
1756
|
+
"Replacement key registered",
|
|
1757
|
+
);
|
|
1758
|
+
value.value = "";
|
|
1759
|
+
if (res && res.ok === true) void reload();
|
|
1760
|
+
});
|
|
1761
|
+
clear(host).appendChild(
|
|
1762
|
+
card(`Rotate ${expected}`, [
|
|
1763
|
+
el("p", {
|
|
1764
|
+
class: "muted",
|
|
1765
|
+
text: "Rotation registers a REPLACEMENT key with the same owner, permissions and wiki grant. It does not delete the old key — this console ships no hard-delete affordance, so retiring the old value stays a deliberate act in the Thredz account portal.",
|
|
1766
|
+
}),
|
|
1767
|
+
form,
|
|
1768
|
+
]),
|
|
1769
|
+
);
|
|
1770
|
+
}
|
|
1771
|
+
|
|
1772
|
+
// ---------------------------------------------------------------------------
|
|
1773
|
+
// the global explorer
|
|
1774
|
+
// ---------------------------------------------------------------------------
|
|
1775
|
+
|
|
1776
|
+
/** The harness-less global explorer (`#/thredz`). */
|
|
1777
|
+
export async function renderThredzGlobal(root) {
|
|
1778
|
+
clear(root).appendChild(skeleton(5));
|
|
1779
|
+
const payload = await callProxy(() => api.thredzGlobal());
|
|
1780
|
+
clear(root);
|
|
1781
|
+
if (!payload) {
|
|
1782
|
+
root.appendChild(card("Thredz explorer", emptyState("The manager did not answer", null)));
|
|
1783
|
+
return;
|
|
1784
|
+
}
|
|
1785
|
+
const { state, label } = proxyState(payload);
|
|
1786
|
+
const counts = payload.counts && typeof payload.counts === "object" ? payload.counts : null;
|
|
1787
|
+
const keyed = payload.keyPresent === true;
|
|
1788
|
+
root.appendChild(
|
|
1789
|
+
card(
|
|
1790
|
+
"Thredz explorer",
|
|
1791
|
+
[
|
|
1792
|
+
el("div", { class: "safety-strip" }, [
|
|
1793
|
+
dot(keyed ? state : "off", keyed ? label : "no manager key"),
|
|
1794
|
+
el("span", { class: "chip", text: str(payload.workspace, "—") }),
|
|
1795
|
+
]),
|
|
1796
|
+
upstreamBanner(payload),
|
|
1797
|
+
keyed
|
|
1798
|
+
? el(
|
|
1799
|
+
"dl",
|
|
1800
|
+
{ class: "kv" },
|
|
1801
|
+
[
|
|
1802
|
+
["Key source", str(payload.keySource, "—")],
|
|
1803
|
+
["Records", counts && counts.records !== null ? String(counts.records) : "—"],
|
|
1804
|
+
[
|
|
1805
|
+
"Dashboards",
|
|
1806
|
+
counts && counts.dashboards !== null ? String(counts.dashboards) : "—",
|
|
1807
|
+
],
|
|
1808
|
+
].flatMap(([k, v]) => [el("dt", { class: "kv-k", text: k }), el("dd", { text: v })]),
|
|
1809
|
+
)
|
|
1810
|
+
: el("p", { class: "prose-pre", text: str(payload.note, "") }),
|
|
1811
|
+
el("p", {
|
|
1812
|
+
class: "muted",
|
|
1813
|
+
text: "This explorer uses the manager's own CREWHAUS_THREDZ_KEY when one is set. It is read from the environment per request and never written to manager config.",
|
|
1814
|
+
}),
|
|
1815
|
+
],
|
|
1816
|
+
fetchedChip(payload),
|
|
1817
|
+
),
|
|
1818
|
+
);
|
|
1819
|
+
const harnesses = list(payload, "harnesses");
|
|
1820
|
+
root.appendChild(
|
|
1821
|
+
card("Harnesses wired to a workspace", [
|
|
1822
|
+
el("p", {
|
|
1823
|
+
class: "muted",
|
|
1824
|
+
text: `${payload.wired ?? 0} of ${harnesses.length} declare a \`thredz:\` block.`,
|
|
1825
|
+
}),
|
|
1826
|
+
harnesses.length === 0
|
|
1827
|
+
? emptyState("No harnesses registered yet", "crewhaus hangar add")
|
|
1828
|
+
: table(
|
|
1829
|
+
["Harness", "Backend", "Key variable", "New writes"],
|
|
1830
|
+
harnesses.map((h) =>
|
|
1831
|
+
el("tr", null, [
|
|
1832
|
+
el(
|
|
1833
|
+
"td",
|
|
1834
|
+
null,
|
|
1835
|
+
el("a", {
|
|
1836
|
+
class: "name-link",
|
|
1837
|
+
href: hrefHarness(str(h.id, ""), "thredz"),
|
|
1838
|
+
text: str(h.specName, str(h.id, "—")),
|
|
1839
|
+
}),
|
|
1840
|
+
),
|
|
1841
|
+
el(
|
|
1842
|
+
"td",
|
|
1843
|
+
null,
|
|
1844
|
+
h.declared === true ? dot("ok", "thredz") : dot("off", "local files"),
|
|
1845
|
+
),
|
|
1846
|
+
el("td", { class: "mono", text: str(h.envName, "—") }),
|
|
1847
|
+
el("td", { text: str(h.defaultVisibility, "private") }),
|
|
1848
|
+
]),
|
|
1849
|
+
),
|
|
1850
|
+
),
|
|
1851
|
+
]),
|
|
1852
|
+
);
|
|
1853
|
+
}
|