@volter/world-console 2.0.13 → 2.0.15
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 +16 -2
- package/client/brand/icons.ts +2 -0
- package/client/console.css +390 -0
- package/client/console.tsx +1498 -956
- package/client/kit/kit.css +4 -0
- package/client/kit/kit.tsx +2 -2
- package/client/main.tsx +8 -0
- package/dist/client/brand/icons.ts +2 -0
- package/dist/client/console.bundle.js +13 -224
- package/dist/client/console.css +390 -0
- package/dist/client/console.tsx +1498 -956
- package/dist/client/kit/kit.css +4 -0
- package/dist/client/kit/kit.tsx +2 -2
- package/dist/client/main.bundle.js +250 -0
- package/dist/client/main.tsx +8 -0
- package/dist/src/console.js +1 -1
- package/package.json +8 -5
- package/src/console.ts +1 -1
package/client/console.tsx
CHANGED
|
@@ -4,14 +4,13 @@
|
|
|
4
4
|
// answer, and offers nothing `volter world` or `curl` could not do. Locators are roles and text.
|
|
5
5
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
6
6
|
import { Background, BackgroundVariant, Controls, MiniMap, NodeToolbar, Panel, Position, ReactFlow, ReactFlowProvider, useNodesState, useReactFlow, type Node, type NodeProps } from '@xyflow/react';
|
|
7
|
-
import { applyTheme,
|
|
8
|
-
import { createRoot } from 'react-dom/client';
|
|
7
|
+
import { applyTheme, storedTheme, type Theme, At, Brand, ConfirmHost, MenuDismiss, confirmAct, CopyField, counted, Dialog, DrawerButton, Empty, failure as doorWords, listed, Mark, Menu, Pending, Skeleton, Toaster, toast, useDoor as useKitDoor, vendorName, when, ago, type Load } from './kit/kit.tsx';
|
|
9
8
|
import { agentPrompt } from '../../../packages/cli/src/agent-prompt.ts';
|
|
9
|
+
import { ICONS } from './brand/icons.ts';
|
|
10
10
|
|
|
11
|
-
type Twin = { twin: string; protocol?: { major?: number; standing?: string } | null; root?: { url?: string } | null; credential?: { placedAt?: string; fingerprint?: string } | null; refresh?: { at?: string; observedAt?: string; failed?: { at?: string; reason?: string; count?: number } } | null; position?: number; lastReceipt?: { status?: string; externalId?: string; reason?: string } | null; mirror?: boolean };
|
|
11
|
+
type Twin = { twin: string; protocol?: { major?: number; standing?: string } | null; root?: { url?: string; deploy?: string } | null; credential?: { placedAt?: string; fingerprint?: string; sealedBy?: 'vault' | 'key'; boundTo?: string } | null; refresh?: { at?: string; observedAt?: string; failed?: { at?: string; reason?: string; count?: number } } | null; position?: number; lastReceipt?: { status?: string; externalId?: string; reason?: string } | null; mirror?: boolean };
|
|
12
12
|
type Row = { org: string; world: string; name: string; base: string; token: string; readToken: string; twins: Array<{ vendor: string; protocol?: { major?: number } | null; root?: unknown }> };
|
|
13
|
-
type Entry = { id: string; service: string; op: string; operation?: string; subject: { type: string; id: string }; occurredAt: string; actor?: { kind: string; id?: string }; provenance?: string; batch?: string; receipt?: { status?: string; externalId?: string; reason?: string }; landsId?: string; fields?: Record<string, unknown>; traceparent?: string };
|
|
14
|
-
type Changeset = { name: string; actions?: unknown[]; receipts?: unknown; verification?: unknown; approvals?: unknown[] };
|
|
13
|
+
type Entry = { id: string; service: string; op: string; operation?: string; subject: { type: string; id: string }; occurredAt: string; actor?: { kind: string; id?: string }; provenance?: string; batch?: string; receipt?: { status?: string; at?: string; externalId?: string; reason?: string }; landsId?: string; /** a landed copy under the vendor's id: the local subject id it stands for */ aliasOf?: string; fields?: Record<string, unknown>; traceparent?: string; input?: unknown; /** the credential that called, as the World names it: `key:<name>`, `key`, `person:<who>`, `person`, `token`, `token:read` */ caller?: string };
|
|
15
14
|
type Mode = { kind: 'admin'; worlds: Row[] } | { kind: 'world'; worlds: Array<{ name: string; base: string; twins: Row['twins'] }> };
|
|
16
15
|
|
|
17
16
|
const TOKEN_KEY = 'volter.console.token';
|
|
@@ -65,13 +64,60 @@ const held = (): string => {
|
|
|
65
64
|
try { return sessionStorage.getItem(TOKEN_KEY) ?? ''; } catch { return ''; }
|
|
66
65
|
};
|
|
67
66
|
|
|
67
|
+
/** A page's own act a refusal points at: a button on the page that has it, or the page that owns the fix. */
|
|
68
|
+
type Act = 'verify' | 'approve' | 'vault' | 'remote' | 'settings';
|
|
69
|
+
/** A door's refusal in the page's words: the door speaks to its CLI and its API (`run …`, `PUT /-/…`), a page never
|
|
70
|
+
* tells a person to run a command, so each refusal a page can answer becomes that answer (an act here, or the page
|
|
71
|
+
* that has it), and what is left loses its command hints. */
|
|
72
|
+
function plainly(words: string): { text: string; acts: Act[] } {
|
|
73
|
+
const w = words.trim();
|
|
74
|
+
if (/not ready/i.test(w) && /(never verified|not verified|no approvals)/i.test(w)) {
|
|
75
|
+
const parts: string[] = []; const acts: Act[] = [];
|
|
76
|
+
if (/(never verified|not verified)/i.test(w)) { parts.push('it is not verified yet'); acts.push('verify'); }
|
|
77
|
+
if (/no approvals/i.test(w)) { parts.push('no one has approved it'); acts.push('approve'); }
|
|
78
|
+
return { text: `Not ready to deploy: ${listed(parts)}.`, acts };
|
|
79
|
+
}
|
|
80
|
+
const sealed = /no credential (?:is )?sealed for ([a-z0-9_-]+)/i.exec(w);
|
|
81
|
+
if (sealed) return { text: `No credential is sealed for ${vendorName(sealed[1]!)}: seal one in Settings › Vault.`, acts: ['vault'] };
|
|
82
|
+
if (/has no origin/i.test(w)) return { text: 'This World has no remote: name one in Settings › Remote.', acts: ['remote'] };
|
|
83
|
+
const rootless = /the ([a-z0-9_-]+) twin has no root/i.exec(w) ?? /:\s*([a-z0-9_, -]+?) (?:has|have) no root/i.exec(w);
|
|
84
|
+
if (rootless) { const vs = rootless[1]!.split(/,\s*/).filter(Boolean).map(vendorName); return { text: `${listed(vs)} ${vs.length === 1 ? 'is' : 'are'} not connected to ${vs.length === 1 ? 'its' : 'their'} real service: Settings › Real services.`, acts: ['settings'] }; }
|
|
85
|
+
if (/opening a seal is/i.test(w)) return { text: 'A key cannot seal a credential: open the World as yourself.', acts: [] };
|
|
86
|
+
if (/a key manages no keys/i.test(w)) return { text: 'A key cannot manage keys: open the World as yourself.', acts: [] };
|
|
87
|
+
if (/^PUT origin:/i.test(w)) return { text: "That is not a World's address: https://<host>/<org>/<world>", acts: [] };
|
|
88
|
+
if (/^clock: \{/i.test(w)) return { text: 'Pick a date and time.', acts: [] };
|
|
89
|
+
if (/^advance: \{/i.test(w)) return { text: 'Advance by a number and a unit: 90m, 12h, 3d.', acts: [] };
|
|
90
|
+
if (/^branches: \{/i.test(w)) return { text: 'That branch could not be made: pick now, or an instant before it.', acts: [] };
|
|
91
|
+
if (/^approve: \{/i.test(w)) return { text: 'Name who approves.', acts: [] };
|
|
92
|
+
if (/a browser deploys only what it names/i.test(w)) return { text: 'A deploy is confirmed by its name: use Deploy again.', acts: [] };
|
|
93
|
+
if (/^no such door/i.test(w)) return { text: 'This World does not answer that here.', acts: [] };
|
|
94
|
+
const text = w
|
|
95
|
+
.replace(/\s*[—–-]+\s*(?:run\s+)?`[^`]*`/g, '') // "— run `volter …`", "— `printf … | volter …`"
|
|
96
|
+
.replace(/\s*[—–-]+\s*(?:PUT|POST|GET|DELETE)\s+\/-\/\S+(?:\s*\{[^}]*\})?/g, '') // "— PUT /-/<w>/origin { url, token }"
|
|
97
|
+
.replace(/\s*\(\s*`[^`]*`\s*\)/g, '') // "(`volter twin <vendor> root <url>`)"
|
|
98
|
+
.replace(/`(?:volter|printf|curl|npm|bun)[^`]*`/g, '')
|
|
99
|
+
.replace(/:\s*\{[^}]*\}/g, '') // a body's shape, `name: { … }`
|
|
100
|
+
.replace(/\(\s*\)/g, '')
|
|
101
|
+
.replace(/\s*;\s*(?=[;.]|$)/g, '').replace(/\s+([,.;:])/g, '$1').trim();
|
|
102
|
+
return { text: text || words, acts: [] };
|
|
103
|
+
}
|
|
104
|
+
/** The door's refusal, in the page's words. */
|
|
105
|
+
const failure = async (r: Response): Promise<string> => plainly(await doorWords(r)).text;
|
|
106
|
+
/** Where an act a refusal points at lives, when it is a page. */
|
|
107
|
+
const ACT_PAGE: Partial<Record<Act, [string, string]>> = { vault: ['vault', 'Open Vault'], remote: ['remote', 'Open Remote'], settings: ['settings/services', 'Open Real services'] };
|
|
108
|
+
function ActLinks({ name, acts }: { name: string; acts: Act[] }): React.JSX.Element | null {
|
|
109
|
+
const pages = acts.map((a) => ACT_PAGE[a]).filter((x): x is [string, string] => Boolean(x));
|
|
110
|
+
if (pages.length === 0) return null;
|
|
111
|
+
return <>{pages.map(([page, label]) => <React.Fragment key={page}> <a href={`${BASE}/${name}/~/${page}`} onClick={(e) => { e.preventDefault(); go([...name.split('/'), '~', ...page.split('/')]); }}>{label}</a></React.Fragment>)}</>;
|
|
112
|
+
}
|
|
113
|
+
|
|
68
114
|
async function door(token: string, path: string, init: RequestInit = {}): Promise<Response> {
|
|
69
115
|
// SESSION: no token header; the World's session cookie (same-origin) is the credential
|
|
70
116
|
return nativeFetch(`${ORIGIN}${path}`, { ...init, headers: { ...(init.headers ?? {}), ...(token === SESSION ? {} : { 'x-volter-token': token }), ...(init.body && !(init.headers as Record<string, string> | undefined)?.['content-type'] ? { 'content-type': 'application/json' } : {}) } });
|
|
71
117
|
}
|
|
72
118
|
|
|
73
119
|
/** One door read with this token (the kit's useDoor). */
|
|
74
|
-
function useDoor<T>(token: string, path: string | null, version = 0): Load<T> {
|
|
120
|
+
function useDoor<T>(token: string, path: string | null, version = 0): Load<T> { const l = useKitDoor<T>((p) => door(token, p), path, version, token); return l.state === 'error' ? { ...l, error: plainly(l.error).text } : l; }
|
|
75
121
|
|
|
76
122
|
/** A datetime-local input's value for an instant, in the viewer's zone. */
|
|
77
123
|
const localInput = (iso: string): string => { const d = new Date(iso); const p = (n: number): string => String(n).padStart(2, '0'); return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}T${p(d.getHours())}:${p(d.getMinutes())}`; };
|
|
@@ -81,7 +127,6 @@ const nextMinute = (iso: string): string => new Date(Math.ceil(Date.parse(iso) /
|
|
|
81
127
|
|
|
82
128
|
/** An entry's standing: observed from the vendor, a receipt, default data, or not yet pushed. */
|
|
83
129
|
const standing = (e: Entry): string => (e.operation === 'observed' ? `observed${e.provenance === 'placeholder' ? ' (default data)' : ' (vendor)'}` : e.receipt ? `${e.receipt.status ?? ''} ${e.receipt.externalId ?? ''} ${e.receipt.reason ?? ''}`.trim() : e.provenance === 'placeholder' ? 'default data' : 'unpushed');
|
|
84
|
-
const actorOf = (e: Entry): string => (e.actor ? `${e.actor.kind}${e.actor.id ? ` ${e.actor.id}` : ''}` : '—');
|
|
85
130
|
/** The trace id of a W3C traceparent (`00-<32 hex>-<16 hex>-<2 hex>`), or null. */
|
|
86
131
|
const traceOf = (tp?: string): { trace: string; span: string } | null => { const m = /^[0-9a-f]{2}-([0-9a-f]{32})-([0-9a-f]{16})-[0-9a-f]{2}$/.exec(tp ?? ''); return m ? { trace: m[1]!, span: m[2]! } : null; };
|
|
87
132
|
|
|
@@ -161,13 +206,18 @@ function moveTo(origin: string, token: string, name: string, path?: string): voi
|
|
|
161
206
|
}
|
|
162
207
|
|
|
163
208
|
/** The path under the console's base: [] worlds; [org, world] a world; [org, world, twin] a twin; [org, world, '~', tab, arg?] a tab of the world. */
|
|
164
|
-
|
|
209
|
+
// where the path is kept: the address bar, or (a page whose root says `data-route="memory"`, as a story's frame is) this
|
|
210
|
+
// page's memory, so a page that is not served at its own addresses still moves between its pages
|
|
211
|
+
let memoryPath: string | null = ROOT.dataset.route === 'memory' ? '' : null;
|
|
212
|
+
/** Move a memory-routed page to `path` (under the base), as following a link there would. */
|
|
213
|
+
export function openAt(path: string): void { memoryPath = path; window.dispatchEvent(new PopStateEvent('popstate')); }
|
|
214
|
+
function route(): string[] { return (memoryPath ?? window.location.pathname.slice(BASE.length)).split('/').filter(Boolean).map((p) => { try { return decodeURIComponent(p); } catch { return ''; } }); }
|
|
165
215
|
/** An org's or a World's name: letters, digits and single hyphens (world-origins.ts), never a separator or a dot. */
|
|
166
216
|
const WORLD_PART = /^[a-z0-9](?:[a-z0-9]|-(?!-))*$/i;
|
|
167
217
|
/** The World a path names, `<org>/<world>`, or '' when its segments are not names: nothing decoded from a path (an
|
|
168
218
|
* encoded `/` or `..`) ever reaches a door's path. */
|
|
169
219
|
const worldOf = (o?: string, w?: string): string => (o && w && WORLD_PART.test(o) && WORLD_PART.test(w) ? `${o}/${w}` : '');
|
|
170
|
-
function go(parts: string[]): void { window.history.pushState(null, '', `${BASE}
|
|
220
|
+
function go(parts: string[]): void { const path = `/${parts.map(encodeURIComponent).join('/')}`; if (memoryPath !== null) memoryPath = path; else window.history.pushState(null, '', `${BASE}${path}`); window.dispatchEvent(new PopStateEvent('popstate')); }
|
|
171
221
|
/** A page of a branch this tab holds: at the branch's own origin when it has one (this origin serves its World only), carrying its read token. */
|
|
172
222
|
function goBranch(branch: string, parts: string[]): void {
|
|
173
223
|
const there = worldOriginOf(snapshots()[branch]?.origin);
|
|
@@ -202,6 +252,20 @@ async function localSession(): Promise<{ token: string } | 'moved' | null> {
|
|
|
202
252
|
return null;
|
|
203
253
|
}
|
|
204
254
|
|
|
255
|
+
/** Appearance: the system's, light or dark, kept in this browser. */
|
|
256
|
+
function ThemeChoice(): React.JSX.Element {
|
|
257
|
+
const [theme, setTheme] = useState<Theme>(storedTheme);
|
|
258
|
+
const pick = (t: Theme) => (): void => { try { if (t === 'system') localStorage.removeItem('volter.theme'); else localStorage.setItem('volter.theme', t); } catch { /* a private window keeps it for this page */ } applyTheme(t); setTheme(t); };
|
|
259
|
+
return (
|
|
260
|
+
<>
|
|
261
|
+
<p className="menu-label">Appearance</p>
|
|
262
|
+
<div className="theme-choice" role="radiogroup" aria-label="Appearance">
|
|
263
|
+
{(['system', 'light', 'dark'] as const).map((t) => <button key={t} type="button" role="radio" aria-checked={theme === t} onClick={pick(t)}>{t === 'system' ? 'System' : t === 'light' ? 'Light' : 'Dark'}</button>)}
|
|
264
|
+
</div>
|
|
265
|
+
</>
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
|
|
205
269
|
function App(): React.JSX.Element {
|
|
206
270
|
const [token, setToken] = useState<string>(held());
|
|
207
271
|
// a World this machine serves (localSession): no sign-in, no sign-out; `probed`: that was asked, so a sign-in form
|
|
@@ -269,7 +333,7 @@ function App(): React.JSX.Element {
|
|
|
269
333
|
const ping = await nativeFetch(`${ORIGIN}/-/ping`); // not a platform answer: perhaps this origin is a bare host
|
|
270
334
|
if (!ping.ok) { setError(`${ORIGIN} does not answer as a host`); setMode(null); return; }
|
|
271
335
|
worlds = ((await ping.json()) as { worlds?: string[] }).worlds ?? [];
|
|
272
|
-
if (worlds.length === 0) { setError(`${ORIGIN} does not list its
|
|
336
|
+
if (worlds.length === 0) { setError(`${ORIGIN} does not list its Worlds: open a World at its own address, ${ORIGIN}${BASE}/<org>/<world>`); setMode(null); return; }
|
|
273
337
|
}
|
|
274
338
|
const opened: Array<{ name: string; base: string; twins: Row['twins'] }> = [];
|
|
275
339
|
for (const name of worlds) { const r = await door(t, `/-/${name}/twins`); if (r.ok) { const b = (await r.json()) as { twins?: Array<{ twin: string; protocol?: { major?: number } | null; root?: unknown }> }; opened.push({ name, base: `${WORLDS}/${name}`, twins: (b.twins ?? []).map((x) => ({ vendor: x.twin, protocol: x.protocol ?? null, root: x.root ?? null })) }); } }
|
|
@@ -282,16 +346,20 @@ function App(): React.JSX.Element {
|
|
|
282
346
|
const [who, setWho] = useState<string>('');
|
|
283
347
|
|
|
284
348
|
if (!token || (!mode && !error)) {
|
|
349
|
+
// as GitHub Desktop asks to sign in: one window, what opening does, the one field and the one act
|
|
285
350
|
return (
|
|
286
|
-
<main>
|
|
287
|
-
<
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
<
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
351
|
+
<main className="app gate">
|
|
352
|
+
<div className="gate-window">
|
|
353
|
+
<Brand href={`${BASE}/`} />
|
|
354
|
+
{(token && !mode && !error) || (!token && !probed) ? <p className="mode">Opening…</p> : (
|
|
355
|
+
<form onSubmit={(e) => { e.preventDefault(); keep(draft.trim()); setToken(draft.trim()); }} className="stack">
|
|
356
|
+
<h2>Open a World</h2>
|
|
357
|
+
<p className="mode">A World's token opens that World. A host's admin token opens every World it serves.</p>
|
|
358
|
+
<label><span>Token</span><input name="token" type="password" value={draft} onChange={(e) => setDraft(e.target.value)} autoFocus autoComplete="off" /></label>
|
|
359
|
+
<button type="submit" className="primary">Open</button>
|
|
360
|
+
</form>
|
|
361
|
+
)}
|
|
362
|
+
</div>
|
|
295
363
|
</main>
|
|
296
364
|
);
|
|
297
365
|
}
|
|
@@ -311,21 +379,32 @@ function App(): React.JSX.Element {
|
|
|
311
379
|
if (mode) for (const w of mode.worlds) navigator.sendBeacon(`${w.base.replace(/\/[^/]+\/[^/]+$/, '')}/-/${w.name}/session/end`); keep(''); setToken(''); setDraft(''); setMode(null); go([]);
|
|
312
380
|
};
|
|
313
381
|
if (error || !mode) {
|
|
314
|
-
return <main><Brand href={`${BASE}/`} /><p role="alert">{error ?? 'nothing answered'}</p>{portal() ? <a className="button" href={`${portal()}/`}>Back to {new URL(portal()).host}</a> : <button onClick={signOut}>{local ? 'Try again' : 'Use another token'}</button>}</main>;
|
|
382
|
+
return <main className="app gate"><div className="gate-window"><Brand href={`${BASE}/`} /><h2>Could not open it</h2><p role="alert">{error ?? 'nothing answered'}</p>{portal() ? <a className="button primary" href={`${portal()}/`}>Back to {new URL(portal()).host}</a> : <button type="button" className="primary" onClick={signOut}>{local ? 'Try again' : 'Use another token'}</button>}</div></main>;
|
|
315
383
|
}
|
|
316
384
|
// `/<org>/<world>/<twin>` is a twin; `/<org>/<world>/~/<tab>[/<arg>]` is a tab of the World — `~`
|
|
317
385
|
// is never a vendor name, so a tab can never shadow a twin
|
|
318
|
-
const [org, world, third, tabPart, argPart] = parts;
|
|
386
|
+
const [org, world, third, tabPart, argPart, argMore, argLast] = parts;
|
|
319
387
|
const tabbed = third === '~';
|
|
320
388
|
// a vendor's name and a tab's argument (a vendor, a trace id) are names too: nothing else reaches a door or a frame
|
|
321
389
|
const PART = /^[A-Za-z0-9_-]+$/;
|
|
390
|
+
const RECORD_ID = /^[^\u0000-\u001f\u007f]{1,200}$/;
|
|
322
391
|
const twin = tabbed || !third || !PART.test(third) ? undefined : third;
|
|
323
|
-
|
|
392
|
+
// a tab's argument is one name, or a World's full name `<org>/<world>` (a host's branch, on Branches)
|
|
393
|
+
const tabArg = argPart && PART.test(argPart) ? (argMore !== undefined && tabPart !== 'changes' && tabPart !== 'screens' ? (worldOf(argPart, argMore) || undefined) : argPart) : undefined;
|
|
394
|
+
// Changes' tab and a changeset's page under it: `~/changes/changesets/<name>`
|
|
395
|
+
const tabSub = tabPart === 'changes' && argPart === 'changesets' && argMore && PART.test(argMore) ? argMore
|
|
396
|
+
// a record on a vendor's screens: `~/screens/<vendor>/<type>/<id>`
|
|
397
|
+
// (the type is the vendor's, dotted where it is namespaced — `treasury.inbound_transfer`; the id is the vendor's own — a
|
|
398
|
+
// Slack message's is its timestamp; both only ever reach the screens' `?show=`, encoded)
|
|
399
|
+
: tabPart === 'screens' && argMore && argLast && /^[A-Za-z0-9_.-]+$/.test(argMore) && RECORD_ID.test(argLast) ? `${argMore}:${argLast}` : undefined;
|
|
324
400
|
// only a World's name opens a World page (and so reaches its doors); any other path is the list of Worlds
|
|
325
401
|
const name = worldOf(org, world);
|
|
326
402
|
const snapshot = name ? snapshots()[name] ?? null : null;
|
|
327
403
|
/** The token a World is opened with: a snapshot this tab made opens with its read token. */
|
|
328
404
|
const tokenOf = (n: string): string => snapshotToken(n) || worldToken(n);
|
|
405
|
+
// with a snapshot in view the World's own pages read the World it is a snapshot of: with the parent's credential when this
|
|
406
|
+
// page holds one (the admin's row for it, or a World token whose doors answered for it), else none
|
|
407
|
+
const parentToken = snapshot ? (mode.kind === 'admin' ? mode.worlds.find((w) => w.name === snapshot.parent)?.token ?? null : mode.worlds.some((w) => w.name === snapshot.parent) ? token : null) : null;
|
|
329
408
|
const returnToLive = async (s: Snapshot): Promise<void> => {
|
|
330
409
|
// the parent's pages (and its token) live at its own origin: that page removes the branch
|
|
331
410
|
const back = worldOriginOf(s.parentOrigin);
|
|
@@ -336,8 +415,9 @@ function App(): React.JSX.Element {
|
|
|
336
415
|
go(s.parent.split('/'));
|
|
337
416
|
};
|
|
338
417
|
return (
|
|
339
|
-
<main className={name ? 'app' :
|
|
340
|
-
|
|
418
|
+
<main className={name ? 'app workspace' : 'app home'}>
|
|
419
|
+
{/* a World's workspace has its own toolbar (Workspace): no header over it */}
|
|
420
|
+
{name ? null : <header>
|
|
341
421
|
{name ? <DrawerButton /> : null}
|
|
342
422
|
<Brand href={`${home() && home() !== ORIGIN ? home() : ''}${BASE}/`} onClick={(e) => { if (home() && home() !== ORIGIN) return; e.preventDefault(); go([]); }} />
|
|
343
423
|
<nav aria-label="breadcrumb" className="crumbs">
|
|
@@ -347,11 +427,12 @@ function App(): React.JSX.Element {
|
|
|
347
427
|
</nav>
|
|
348
428
|
{/* the way back is the org's Worlds on the platform that signed the pass: the issuer is verified, the org is this World's own */}
|
|
349
429
|
{portal() ? <a href={`${portal()}/${name ? encodeURIComponent(name.split('/')[0]!) : ''}`} title={`Back to the platform at ${new URL(portal()).host}`} className="back-link">← <span className="back-text">{name ? `${name.split('/')[0]}'s Worlds` : new URL(portal()).host}</span></a> : null}
|
|
350
|
-
|
|
351
|
-
{
|
|
352
|
-
|
|
430
|
+
{/* a World's page says who is here in its sidebar's account block; the host's list, which has no sidebar, here */}
|
|
431
|
+
{name ? null : <span className="mode">{local ? 'on this machine' : who ? `signed in as ${who}` : mode.kind === 'admin' ? 'host: admin' : 'opened with a link or key'}</span>}
|
|
432
|
+
{name || local ? null : <button onClick={signOut}>Sign out</button>}
|
|
433
|
+
</header>}
|
|
353
434
|
{name
|
|
354
|
-
? <WorldView key={name} name={name} token={tokenOf(name)} twin={twin} tab={tabbed ? tabPart ?? 'overview' : 'overview'} arg={tabbed ? tabArg : undefined} admin={snapshot ? null : mode.kind === 'admin' ? token : null} snapshot={snapshot} onReturn={returnToLive} opens={(n) => !!snapshots()[n] || (mode.kind === 'admin' && mode.worlds.some((w) => w.name === n))} onRemoved={() => { void load(token); go([]); }} onWho={setWho} />
|
|
435
|
+
? <WorldView key={name} name={name} token={tokenOf(name)} twin={twin} tab={tabbed ? tabPart ?? 'overview' : 'overview'} arg={tabbed ? tabArg : undefined} {...(tabbed && tabSub ? { sub: tabSub } : {})} admin={snapshot ? null : mode.kind === 'admin' ? token : null} snapshot={snapshot} parentToken={parentToken} who={{ local, person: who || null, admin: mode.kind === 'admin' }} onSignOut={signOut} onReturn={returnToLive} opens={(n) => !!snapshots()[n] || (mode.kind === 'admin' && mode.worlds.some((w) => w.name === n))} onRemoved={() => { void load(token); go([]); }} onWho={setWho} worlds={mode.worlds.map((w) => w.name)} />
|
|
355
436
|
: <Worlds mode={mode} admin={mode.kind === 'admin' ? token : null} reload={() => load(token)} />}
|
|
356
437
|
</main>
|
|
357
438
|
);
|
|
@@ -366,79 +447,91 @@ function Worlds({ mode, admin, reload }: { mode: Mode; admin: string | null; rel
|
|
|
366
447
|
if (!(await confirmAct({ title: `Rotate the tokens of ${name}?`, body: 'Every app, script and person holding the current tokens loses access at once. The new ones are shown once.', action: 'Rotate tokens', danger: true }))) return;
|
|
367
448
|
const r = await door(admin!, `/-/worlds/${name}/rotate`, { method: 'POST' });
|
|
368
449
|
const b = (await r.json().catch(() => ({}))) as { token?: string; readToken?: string; error?: string };
|
|
369
|
-
setNote(r.ok ? `rotated ${name}: the new tokens are shown once — token ${b.token} · read token ${b.readToken}` : b.error
|
|
450
|
+
setNote(r.ok ? `rotated ${name}: the new tokens are shown once — token ${b.token} · read token ${b.readToken}` : b.error ? plainly(b.error).text : `answered ${r.status}`);
|
|
370
451
|
await reload();
|
|
371
452
|
};
|
|
372
453
|
const removeWorld = async (name: string): Promise<void> => {
|
|
373
454
|
const r = await door(admin!, `/-/worlds/${name}`, { method: 'DELETE' });
|
|
374
455
|
const b = (await r.json().catch(() => ({}))) as { error?: string };
|
|
375
|
-
setNote(r.ok ? `removed ${name}` : b.error
|
|
456
|
+
setNote(r.ok ? `removed ${name}` : b.error ? plainly(b.error).text : `answered ${r.status}`);
|
|
376
457
|
await reload();
|
|
377
458
|
};
|
|
378
|
-
const [provisioning, setProvisioning] = useState(false);
|
|
459
|
+
const [provisioning, setProvisioning] = useState(false); const [making, setMaking] = useState(false); const [filter, setFilter] = useState('');
|
|
379
460
|
const provision = async (e: React.FormEvent): Promise<void> => {
|
|
380
461
|
e.preventDefault(); setNote(null); setProvisioning(true);
|
|
381
462
|
try {
|
|
382
463
|
const r = await door(admin!, '/-/worlds', { method: 'POST', body: JSON.stringify({ org, world, vendors: vendors.split(',').map((v) => v.trim()).filter(Boolean) }) });
|
|
383
464
|
const body = (await r.json()) as { error?: string; name?: string };
|
|
384
|
-
if (!r.ok) { setNote(body.error
|
|
385
|
-
setNote(`provisioned ${body.name}`); setWorld(''); setVendors(''); await reload(); // the org stays: the next world usually goes to the same org
|
|
465
|
+
if (!r.ok) { setNote(body.error ? plainly(body.error).text : `the host answered ${r.status}`); return; }
|
|
466
|
+
setNote(`provisioned ${body.name}`); setWorld(''); setVendors(''); setMaking(false); await reload(); // the org stays: the next world usually goes to the same org
|
|
386
467
|
} finally { setProvisioning(false); }
|
|
387
468
|
};
|
|
469
|
+
const f = filter.trim().toLowerCase();
|
|
470
|
+
const rows = mode.worlds.filter((w) => !f || w.name.toLowerCase().includes(f) || w.twins.some((t) => t.vendor.includes(f)));
|
|
471
|
+
const orgs = [...new Set(rows.map((w) => w.name.split('/')[0]!))];
|
|
388
472
|
return (
|
|
389
|
-
<section aria-labelledby="worlds">
|
|
390
|
-
<
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
<
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
{
|
|
403
|
-
<
|
|
404
|
-
<
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
473
|
+
<section className="home-window" aria-labelledby="worlds">
|
|
474
|
+
<div className="home-head">
|
|
475
|
+
<h2 id="worlds">Worlds</h2>
|
|
476
|
+
<span className="mode">{counted(mode.worlds.length, 'World')} {admin ? 'this host serves' : 'this key opens'}</span>
|
|
477
|
+
{admin ? <button type="button" className="primary" onClick={() => { setNote(null); setMaking(true); }}><Icon name="plus" size={16} />New World</button> : null}
|
|
478
|
+
</div>
|
|
479
|
+
<div className="home-find"><input type="search" placeholder="Filter by World or vendor" aria-label="Filter Worlds" value={filter} onChange={(e) => setFilter(e.target.value)} /></div>
|
|
480
|
+
{note && !making ? <p role="status" className="home-note">{note}</p> : null}
|
|
481
|
+
{orgs.map((org) => (
|
|
482
|
+
<React.Fragment key={org}>
|
|
483
|
+
<p className="menu-label home-org">{org}</p>
|
|
484
|
+
<ul className="home-list">{rows.filter((w) => w.name.split('/')[0] === org).map((w) => (
|
|
485
|
+
<li key={w.name}>
|
|
486
|
+
<a href={`${BASE}/${w.name}`} onClick={(e) => { e.preventDefault(); go(w.name.split('/')); }}>
|
|
487
|
+
<Icon name="globe" size={20} />
|
|
488
|
+
<span className="ws-row-text"><b>{w.name.split('/')[1]}</b><small><code>{w.base}</code></small></span>
|
|
489
|
+
<span className="home-vendors">{w.twins.map((t) => <span key={t.vendor} title={vendorName(t.vendor)}><Mark vendor={t.vendor} /></span>)}</span>
|
|
490
|
+
</a>
|
|
491
|
+
{/* a host's admin manages a World here, on the host's origin, where the admin token lives: a World's own pages never hold it */}
|
|
492
|
+
{admin ? (
|
|
493
|
+
<details className="menu home-more">
|
|
494
|
+
<summary aria-label={`More for ${w.name}`}>⋯</summary>
|
|
495
|
+
<div className="popover menu-list">
|
|
496
|
+
<button type="button" onClick={(e) => { (e.currentTarget as HTMLElement).closest('details')?.removeAttribute('open'); void rotateWorld(w.name); }}>Rotate tokens…</button>
|
|
497
|
+
<button type="button" className="danger-item" onClick={(e) => { (e.currentTarget as HTMLElement).closest('details')?.removeAttribute('open'); void confirmAct({ title: `Remove ${w.name}?`, body: 'The host stops serving it and its whole history goes with it. This cannot be undone.', action: 'Remove World', danger: true, typed: w.name }).then((ok) => { if (ok) void removeWorld(w.name); }); }}>Remove World…</button>
|
|
498
|
+
</div>
|
|
499
|
+
</details>
|
|
500
|
+
) : null}
|
|
501
|
+
</li>
|
|
502
|
+
))}</ul>
|
|
503
|
+
</React.Fragment>
|
|
504
|
+
))}
|
|
505
|
+
{rows.length === 0 ? <p className="mode home-note">{f ? `No World matches ${filter}.` : 'No Worlds here yet.'}</p> : null}
|
|
506
|
+
<Dialog open={making} onClose={() => { if (!provisioning) setMaking(false); }} title="New World" footer={<><button type="button" onClick={() => setMaking(false)} disabled={provisioning}>Cancel</button><button type="submit" form="provision" className="primary" disabled={provisioning}>{provisioning ? 'Making… (ten seconds or so)' : 'Make World'}</button></>}>
|
|
507
|
+
<form id="provision" onSubmit={provision} className="stack">
|
|
508
|
+
<label><span>Org</span><input name="org" value={org} onChange={(e) => setOrg(e.target.value)} required /></label>
|
|
509
|
+
<label><span>World</span><input name="world" value={world} onChange={(e) => setWorld(e.target.value)} required /></label>
|
|
510
|
+
<label><span>Vendors</span><input name="vendors" value={vendors} onChange={(e) => setVendors(e.target.value)} placeholder="stripe, slack" required /><span className="mode">A twin of each, with its default data.</span></label>
|
|
417
511
|
{note ? <p role="status">{note}</p> : null}
|
|
418
512
|
</form>
|
|
419
|
-
|
|
513
|
+
</Dialog>
|
|
420
514
|
</section>
|
|
421
515
|
);
|
|
422
516
|
}
|
|
423
517
|
|
|
424
518
|
type Clock = { at: string; frozen: boolean };
|
|
425
519
|
type WorldMap = { world: string; twins: Array<{ twin: string; position: number; mirror: boolean; root: { url: string } | null; resources: Array<{ type: string; count: number }> }> };
|
|
426
|
-
type Placed = { twin: string; position: number; entry: Entry };
|
|
520
|
+
type Placed = { twin: string; position: number; entry: Entry; /** the credential that called, as the World names it from its request journal (the timeline door): `key:<name>`, `person:<who>`, `person`, `token` */ caller?: string };
|
|
427
521
|
type Timeline = { entries: Placed[]; next: string | null };
|
|
428
522
|
type Diff = { base: { id: string; at: string | null }; changes: Array<{ twin: string; entry: Entry }> };
|
|
429
|
-
type Branch = { name: string; at?: { instant?: string }; createdAt: string; expiresAt: string | null };
|
|
523
|
+
type Branch = { name: string; from?: string; at?: { instant?: string } | null; createdAt: string; expiresAt: string | null };
|
|
430
524
|
/** The branches door: the host's snapshots of this World, and the World's own named branches (`volter world branch`),
|
|
431
525
|
* one of which, `checkedOut`, is the one this serve holds. */
|
|
432
526
|
type BranchesDoor = { branches: Branch[]; checkedOut?: string; named?: Array<{ name: string; running: boolean }>; canServe?: boolean };
|
|
433
527
|
/** What the World's session answered: its scope; `unknown` when the host has no session door (a write then says). */
|
|
434
528
|
type Scope = 'pending' | 'read' | 'write' | 'unknown';
|
|
435
529
|
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
const TABS = [
|
|
440
|
-
type Tab = (typeof TABS)[number]
|
|
441
|
-
const TAB_ALIASES: Record<string, Tab> = { dashboard: 'overview', mirrors: 'vendors', screens: 'canvas', timeline: 'activity', monitoring: 'activity', trace: 'activity', operations: 'settings' };
|
|
530
|
+
/** The addresses under a World's `~/` (docs/contributing/console.md): its places (changes and its changesets, events and a
|
|
531
|
+
* trace, a vendor's records, its screens and the canvas of them all) and its windows (branches, settings and the two
|
|
532
|
+
* settings tabs addressed on their own, remote and vault). Anything else opens Changes. */
|
|
533
|
+
const TABS = ['changes', 'events', 'trace', 'vendors', 'screens', 'canvas', 'branches', 'settings', 'remote', 'vault'] as const;
|
|
534
|
+
type Tab = (typeof TABS)[number] | 'overview';
|
|
442
535
|
/** A vendor's own screens, at the twin's place in the World: the vendor's site (served-world.ts, the wire's page fallback). */
|
|
443
536
|
const mirrorUrl = (name: string, vendor: string): string => `${WORLDS}/${name}/${vendor}/`;
|
|
444
537
|
/** What an entry did, in words: the thing's kind and the vendor's verb, the thing's own label where it has one. */
|
|
@@ -461,12 +554,8 @@ function said(e: Entry): { what: string; label: string | null } {
|
|
|
461
554
|
}
|
|
462
555
|
/** A secret's id is never shown whole (a token, a password, a key): its last four characters. */
|
|
463
556
|
const shown = (e: Entry): string => (/token|password|secret|key|credential/i.test(e.subject.type) && e.subject.id.length > 6 ? `••••${e.subject.id.slice(-4)}` : e.subject.id);
|
|
464
|
-
/** Who did it: an app or agent by its id; the vendor itself for what it does on its own (an event it records). */
|
|
465
|
-
const byWhom = (e: Entry, vendor: string): string => (!e.actor ? '' : e.actor.kind === 'system' ? `by ${vendorName(vendor)}` : e.actor.id ? `by ${e.actor.id}` : e.actor.kind === 'agent' || e.actor.kind === 'app' ? 'by your app' : `by ${e.actor.kind}`);
|
|
466
|
-
/** An entry's standing worth saying: where it came from, or what its push did. Not yet pushed is the ordinary case. */
|
|
467
|
-
const notable = (e: Entry): string | null => (e.operation === 'observed' || e.receipt || e.provenance === 'placeholder' ? standing(e) : null);
|
|
468
557
|
|
|
469
|
-
function WorldView({ name, token, twin, tab, arg, admin, snapshot, onReturn, opens, onRemoved, onWho }: { name: string; token: string; twin?: string; tab: string; arg?: string; admin: string | null; snapshot: Snapshot | null; onReturn: (s: Snapshot) => Promise<void>; opens: (name: string) => boolean; onRemoved: () => void; onWho?: (who: string) => void }): React.JSX.Element {
|
|
558
|
+
function WorldView({ name, token, twin, tab, arg, sub, admin, snapshot, parentToken, who, onSignOut, onReturn, opens, onRemoved, onWho, worlds = [] }: { /** the Worlds this page opens, for the World menu's switcher */ worlds?: string[]; /** who is at this page (the account block) */ who: { local: boolean; person: string | null; admin: boolean }; onSignOut: () => void; name: string; token: string; twin?: string; tab: string; arg?: string; /** with a snapshot in view: the credential this page holds for the World it is a snapshot of, or null */ parentToken?: string | null; /** a page under a tab's argument: a changeset under Changes › Changesets */ sub?: string; admin: string | null; snapshot: Snapshot | null; onReturn: (s: Snapshot) => Promise<void>; opens: (name: string) => boolean; onRemoved: () => void; onWho?: (who: string) => void }): React.JSX.Element {
|
|
470
559
|
// the session: its scope says which controls this token may use, and its cookie is what a mirror reads with
|
|
471
560
|
const [scope, setScope] = useState<Scope>('pending');
|
|
472
561
|
// why the session was not opened (the wall says it, with a way to try again), and that try
|
|
@@ -488,7 +577,7 @@ function WorldView({ name, token, twin, tab, arg, admin, snapshot, onReturn, ope
|
|
|
488
577
|
if (r.status === 409 && own && own !== ORIGIN) { moveTo(own, token, name); return; }
|
|
489
578
|
if (!live) return;
|
|
490
579
|
setScope(!r.ok ? 'unknown' : b.scope === 'read' ? 'read' : b.scope === 'write' ? 'write' : 'unknown');
|
|
491
|
-
if (!r.ok) setSessionError(b.error
|
|
580
|
+
if (!r.ok) setSessionError(b.error ? plainly(b.error).text : `answered ${r.status}`);
|
|
492
581
|
}).catch((e: unknown) => { if (live) { setScope('unknown'); setSessionError(`the World did not answer: ${e instanceof Error ? e.message : String(e)}`); } });
|
|
493
582
|
return () => { live = false; };
|
|
494
583
|
}, [name, token, attempt]);
|
|
@@ -498,7 +587,9 @@ function WorldView({ name, token, twin, tab, arg, admin, snapshot, onReturn, ope
|
|
|
498
587
|
// hold; the server refuses such writes anyway, and a read-only viewer should not be offered them
|
|
499
588
|
const canWrite = scope === 'write';
|
|
500
589
|
/** A write the door refused as out of scope: this token reads only, whatever the session said. */
|
|
501
|
-
|
|
590
|
+
// only the World saying this credential reads (`the read token opens only reads`, write access needed) makes the page
|
|
591
|
+
// read only: a 403 for another reason (a key opening a seal) is said where it happened and changes no scope
|
|
592
|
+
const refused = (r: Response): void => { if (r.status !== 403) return; void r.clone().json().then((b: { error?: string }) => { if (/read token|opens only reads|write access|reads only/i.test(b?.error ?? '')) setScope('read'); }, () => null); };
|
|
502
593
|
|
|
503
594
|
const [tick, setTick] = useState(0);
|
|
504
595
|
useEffect(() => { const id = setInterval(() => { if (!document.hidden) setTick((t) => t + 1); }, 5000); return () => clearInterval(id); }, []);
|
|
@@ -510,271 +601,674 @@ function WorldView({ name, token, twin, tab, arg, admin, snapshot, onReturn, ope
|
|
|
510
601
|
// branches are the host's: a host that does not answer the door has no "as of", and the console shows none
|
|
511
602
|
const hasBranches = !(branches.state === 'error' && (branches.status === 404 || branches.status === 405));
|
|
512
603
|
|
|
513
|
-
|
|
514
|
-
const current: Tab = TABS
|
|
604
|
+
|
|
605
|
+
const current: Tab = (TABS as readonly string[]).includes(tab) ? (tab as Tab) : 'overview';
|
|
515
606
|
const vendors = twins.state === 'ok' ? twins.data.twins.map((t) => t.twin) : [];
|
|
516
|
-
const held = (v: string): number => (map.state === 'ok' ? (map.data.twins.find((x) => x.twin === v)?.resources ?? []).reduce((n, r) => n + r.count, 0) : 0);
|
|
517
607
|
const chosen = current === 'vendors' && arg ? arg : undefined;
|
|
518
608
|
const retry = (): void => { setScope('pending'); setAttempt((a) => a + 1); };
|
|
519
609
|
// ALL SCREENS is a canvas app's own page: the whole window, the World's pages a click away (Figma's, tldraw's)
|
|
520
|
-
if (current === 'canvas' || (current === 'vendors' && !chosen)) {
|
|
521
|
-
return <Canvas name={name} twins={twins} session={session} retry={retry} snapshot={snapshot} back={() => go(name.split('/'))} />;
|
|
610
|
+
if (current === 'canvas' || (current === 'vendors' && !chosen) || (tab === 'screens' && !arg)) {
|
|
611
|
+
return <Canvas name={name} token={token} twins={twins} session={session} retry={retry} snapshot={snapshot} back={() => go(name.split('/'))} />;
|
|
522
612
|
}
|
|
523
|
-
|
|
524
|
-
const
|
|
525
|
-
const
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
}
|
|
613
|
+
// EVERY World opens as its workspace: on this machine, on a host, and a snapshot of one (read only, a way back to live)
|
|
614
|
+
const tabOf = (t?: string): SettingsTab => (SETTINGS_TABS.find(([k]) => k === t)?.[0] ?? 'account');
|
|
615
|
+
const win: WorkspaceWindow = tab === 'settings' ? { kind: 'settings', tab: tabOf(arg) } : tab === 'remote' ? { kind: 'settings', tab: 'remote' } : tab === 'vault' ? { kind: 'settings', tab: 'vault' } : tab === 'branches' ? { kind: 'branches', ...(arg ? { open: arg } : {}) } : null;
|
|
616
|
+
// a vendor's own address (`/<org>/<world>/<vendor>`) is its place: what it stores and its log
|
|
617
|
+
const view: WorkspaceView = twin ? 'vendor' : tab === 'screens' && arg ? 'screen' : current === 'vendors' && chosen ? 'vendor' : tab === 'trace' ? 'trace' : tab === 'events' ? 'events' : tab === 'changes' && arg === 'changesets' ? 'history' : 'changes';
|
|
618
|
+
return <Workspace name={name} token={token} view={view} win={win} who={who} snapshot={snapshot} onReturn={onReturn} onSignOut={onSignOut} worlds={worlds} session={session} retry={retry} {...(view === 'vendor' ? { arg: twin ?? chosen } : view === 'events' || view === 'trace' || view === 'screen' ? (arg ? { arg } : {}) : {})} {...(sub ? { sub } : {})} twins={twins} map={map} branches={hasBranches ? branches : null} clock={clock} canWrite={canWrite && !snapshot} refused={refused} signsAs={signsAs} opens={opens} tick={tick} admin={admin} onRemoved={onRemoved} onClock={() => setClockVersion((v) => v + 1)} scope={snapshot ? 'read' : scope} />;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/** A brand icon by its Lucide name (scripts/brand-tokens.ts writes the set), in the text's colour. */
|
|
622
|
+
function Icon({ name, size = 20 }: { name: string; size?: number }): React.JSX.Element {
|
|
623
|
+
return <svg className="icon" viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth={size <= 16 ? 1.75 : 1.5} strokeLinecap="square" strokeLinejoin="miter" aria-hidden="true">{(ICONS[name] ?? []).map(([tag, attrs], i) => React.createElement(tag, { key: i, ...attrs }))}</svg>;
|
|
624
|
+
}
|
|
625
|
+
/** What a record's writes did to it, as a working tree marks a file: made here, removed, or changed. */
|
|
626
|
+
function changeKind(writes: Entry[]): { icon: string; word: string; tone: string } {
|
|
627
|
+
const verb = (e: Entry): string => { const op = (e.operation ?? e.op).toLowerCase(); return op.includes('.') ? op.slice(op.lastIndexOf('.') + 1) : op; };
|
|
628
|
+
if (writes.some((e) => /^(delete|remove|archive|revoke)/.test(verb(e)) || e.fields?.deleted === true)) return { icon: 'square-minus', word: 'removed', tone: 'removed' };
|
|
629
|
+
if (/^(create|add|insert|issue|send|post|upload|invite|record|seed)/.test(verb(writes[0]!))) return { icon: 'square-plus', word: 'made', tone: 'added' };
|
|
630
|
+
return { icon: 'square-dot', word: 'changed', tone: 'changed' };
|
|
631
|
+
}
|
|
632
|
+
type WorkspaceView = 'changes' | 'history' | 'events' | 'trace' | 'vendor' | 'screen';
|
|
633
|
+
type ChangedRecord = { key: string; twin: string; writes: Entry[] };
|
|
634
|
+
/** The workspace's windows over its view: Settings (GitHub Desktop's, its tabs beside its pane) and Branches. */
|
|
635
|
+
type WorkspaceWindow = { kind: 'settings'; tab: SettingsTab } | { kind: 'branches'; open?: string } | null;
|
|
636
|
+
const SETTINGS_TABS = [['account', 'Account', 'user'], ['changesets', 'Changesets', 'git-commit-horizontal'], ['remote', 'Remote', 'cloud'], ['access', 'Access', 'key-round'], ['services', 'Real services', 'plug'], ['vault', 'Vault', 'vault'], ['checks', 'Checks', 'list-checks'], ['clock', 'Clock', 'clock'], ['appearance', 'Appearance', 'palette']] as const;
|
|
637
|
+
type SettingsTab = (typeof SETTINGS_TABS)[number][0];
|
|
638
|
+
const shortBranch = (world: string, b: string): string => { const last = b.split('/').pop() ?? b; const base = world.split('/').pop() ?? ''; return last.startsWith(`${base}-`) ? last.slice(base.length + 1) : last; };
|
|
639
|
+
const kindOf = (t: string): string => t.replace(/^_+/, '').replace(/[_-]+/g, ' ');
|
|
640
|
+
|
|
641
|
+
/** One changed record as a diff reads: its fields as they stood before this branch's changes (folded from its history
|
|
642
|
+
* ahead of them) and as they stand now, only those that moved; the writes that moved them listed above. */
|
|
643
|
+
function RecordDiff({ record, history, screens }: { record: ChangedRecord; history: Entry[] | null; /** the World, when this vendor has screens: the record's change is seen there */ screens?: string }): React.JSX.Element {
|
|
644
|
+
const head = record.writes[record.writes.length - 1]!; const s = said(head); const k = changeKind(record.writes);
|
|
645
|
+
const mine = new Set(record.writes.map((e) => e.id));
|
|
646
|
+
const first = record.writes[0]!.occurredAt;
|
|
647
|
+
const before: Record<string, unknown> = {};
|
|
648
|
+
// a landed copy (a push's receipt of one of these writes, under the vendor's id) is the same write, never what it held before
|
|
649
|
+
if (history) for (const e of history) if (!mine.has(e.id) && !e.landsId && !e.aliasOf && e.subject.type === head.subject.type && e.subject.id === head.subject.id && e.occurredAt <= first) Object.assign(before, e.fields ?? {});
|
|
650
|
+
const after: Record<string, unknown> = { ...before }; for (const e of record.writes) Object.assign(after, e.fields ?? {});
|
|
651
|
+
const made = k.tone === 'added' && Object.keys(before).length === 0;
|
|
652
|
+
const rows = [...new Set([...Object.keys(before), ...Object.keys(after)])].filter((f) => !same(before[f], after[f]));
|
|
529
653
|
return (
|
|
530
|
-
<div className="
|
|
531
|
-
<
|
|
532
|
-
<
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
{hasBranches ? item('branches') : null}
|
|
537
|
-
{item('settings')}
|
|
538
|
-
<BranchPicker name={name} snapshot={snapshot} branches={hasBranches ? branches : null} opens={opens} />
|
|
539
|
-
<p className="side-group">Branch</p>
|
|
540
|
-
{item('branch')}
|
|
541
|
-
{item('changes')}
|
|
542
|
-
{item('changesets')}
|
|
543
|
-
{item('activity')}
|
|
544
|
-
<p className="side-group">Vendors</p>
|
|
545
|
-
<a href={`${BASE}/${name}/~/canvas`} className="all-screens" onClick={open(['~', 'canvas'])}><span className="mark" aria-hidden="true">▦</span><span>All screens</span></a>
|
|
546
|
-
<ul className="side-vendors">
|
|
547
|
-
{vendors.map((v) => (
|
|
548
|
-
<li key={v}><a href={`${BASE}/${name}/~/vendors/${v}`} aria-current={chosen === v ? 'page' : undefined} className={chosen === v ? 'on' : undefined} onClick={open(['~', 'vendors', v])}><Mark vendor={v} /><span>{vendorName(v)}</span><span className="count">{held(v) || ''}</span></a></li>
|
|
549
|
-
))}
|
|
550
|
-
</ul>
|
|
551
|
-
</nav>
|
|
552
|
-
<div className="side-foot"><ThemeChoice /></div>
|
|
553
|
-
</aside>
|
|
554
|
-
<div className="content">
|
|
555
|
-
{snapshot ? (
|
|
556
|
-
<div className="snapshot" role="region" aria-label="Snapshot">
|
|
557
|
-
<span>You are looking at <strong>{snapshot.parent}</strong> as it was at <At iso={snapshot.at} /> · a read-only copy (read-only snapshot) · {snapshot.expiresAt ? (Date.parse(snapshot.expiresAt) <= Date.now() ? <>gone since <At iso={snapshot.expiresAt} /></> : <>goes away <At iso={snapshot.expiresAt} /></>) : 'kept until you go back'}</span>
|
|
558
|
-
<button type="button" onClick={() => void onReturn(snapshot)}>Return to live</button>
|
|
559
|
-
</div>
|
|
560
|
-
) : null}
|
|
561
|
-
<div className="content-head">
|
|
562
|
-
<h2 id="world">{chosen ? <span className="vendor-cell"><Mark vendor={chosen} />{title}</span> : title}{scope === 'read' ? <> <span className="badge read-only" title="This token reads the World; it changes nothing">Read only</span></> : null}</h2>
|
|
563
|
-
<div className="head-actions">
|
|
564
|
-
<ClockBar name={name} token={token} clock={clock} canWrite={canWrite && !snapshot} onChanged={() => setClockVersion((v) => v + 1)} refused={refused} />
|
|
565
|
-
{!snapshot && canWrite && hasBranches && branches.state !== 'loading' ? <AsOf name={name} token={token} clock={clock} refused={refused} /> : null}
|
|
566
|
-
</div>
|
|
567
|
-
</div>
|
|
568
|
-
<div role="region" aria-labelledby="world" className="panel">
|
|
569
|
-
{current === 'overview' ? <WorldOverview name={name} token={token} map={map} vendors={vendors} twins={twins} session={session} branches={hasBranches ? branches : null} canWrite={canWrite && !snapshot} tick={tick} {...(snapshot ? { snapshotOf: snapshot.parent } : {})} /> : null}
|
|
570
|
-
{current === 'branch' ? <BranchOverview name={name} token={token} map={map} vendors={vendors} tick={tick} /> : null}
|
|
571
|
-
{current === 'vendors' ? <Vendors name={name} token={token} twins={twins} session={session} retry={retry} selected={chosen} /> : null}
|
|
572
|
-
{current === 'activity' && tab === 'trace' ? <Trace name={name} token={token} id={arg ?? ''} /> : null}
|
|
573
|
-
{current === 'activity' && tab !== 'trace' ? <TimelineTab name={name} token={token} vendors={vendors} /> : null}
|
|
574
|
-
{current === 'changes' ? <Changes name={name} token={token} /> : null}
|
|
575
|
-
{current === 'changesets' ? <Changesets name={name} token={token} signsAs={signsAs} canWrite={canWrite && !snapshot} rooted={map.state === 'ok' ? map.data.twins.filter((t) => t.root).map((t) => t.twin) : []} {...(arg ? { open: arg } : {})} refused={refused} /> : null}
|
|
576
|
-
{current === 'branches' ? <Overview name={name} token={token} canWrite={canWrite} branches={hasBranches ? branches : null} opens={opens} onBranchesChanged={() => setClockVersion((v) => v + 1)} refused={refused} /> : null}
|
|
577
|
-
{current === 'settings' ? <Operations name={name} token={token} admin={admin} canWrite={canWrite} refused={refused} onRemoved={onRemoved} /> : null}
|
|
578
|
-
</div>
|
|
654
|
+
<div className="ws-diff" aria-label={`${s.label ?? shown(head)}: its changes`}>
|
|
655
|
+
<div className="ws-diff-head">
|
|
656
|
+
<span className={`ws-kind ${k.tone}`} title={k.word}><Icon name={k.icon} size={16} /></span>
|
|
657
|
+
<Mark vendor={record.twin} />
|
|
658
|
+
<div className="cell-label"><b>{s.label ?? shown(head)}</b><small>{vendorName(record.twin)} · {head.subject.type.replace(/^_+/, '').replace(/[_-]+/g, ' ')} <code>{shown(head)}</code> · {k.word} here</small></div>
|
|
659
|
+
{screens && k.tone !== 'removed' ? <a className="ws-on-screens" href={`${BASE}/${screens}/~/screens/${record.twin}/${head.subject.type}/${encodeURIComponent(head.subject.id)}`} onClick={(e) => { e.preventDefault(); go([...screens.split('/'), '~', 'screens', record.twin, head.subject.type, head.subject.id]); }}><Icon name="monitor" size={16} />On {vendorName(record.twin)}'s screens</a> : null}
|
|
579
660
|
</div>
|
|
661
|
+
<ol className="ws-writes">{record.writes.map((e) => <li key={e.id}><b>{said(e).what}</b> <span className="mode"><At iso={e.occurredAt} /> · {whoDid(e)}</span></li>)}</ol>
|
|
662
|
+
{history === null ? <p className="mode pad">Reading its history…</p> : rows.length === 0 ? <p className="mode pad">No field holds a new value.</p> : (
|
|
663
|
+
<table className="ws-fields" aria-label="Fields before and after">
|
|
664
|
+
<thead><tr><th>Field</th>{made ? null : <th>Before</th>}<th>{k.tone === 'removed' ? 'After' : 'Now'}</th></tr></thead>
|
|
665
|
+
<tbody>{rows.map((f) => (
|
|
666
|
+
<tr key={f}>
|
|
667
|
+
<th scope="row"><code>{f}</code></th>
|
|
668
|
+
{made ? null : <td className={f in before ? 'was' : 'none'}>{f in before ? <Value v={before[f]} /> : <span className="mode">—</span>}</td>}
|
|
669
|
+
<td className={f in after ? 'now' : 'none'}>{f in after ? <Value v={after[f]} /> : <span className="mode">—</span>}</td>
|
|
670
|
+
</tr>
|
|
671
|
+
))}</tbody>
|
|
672
|
+
</table>
|
|
673
|
+
)}
|
|
580
674
|
</div>
|
|
581
675
|
);
|
|
582
676
|
}
|
|
583
677
|
|
|
584
|
-
/**
|
|
585
|
-
*
|
|
586
|
-
function
|
|
587
|
-
const
|
|
588
|
-
|
|
678
|
+
/** The changed records, as GitKraken lists changed files: Path (one flat list, each row marked with its vendor) or Tree
|
|
679
|
+
* (the records under their vendor), chosen above the list and kept in this browser. */
|
|
680
|
+
function RecordList({ records, selected, onPick, label }: { records: ChangedRecord[]; selected?: string; onPick: (key: string) => void; label: string }): React.JSX.Element {
|
|
681
|
+
const [tree, setTree] = useState<boolean>(() => { try { return localStorage.getItem('volter.console.records') === 'tree'; } catch { return false; } });
|
|
682
|
+
const pick = (t: boolean): void => { setTree(t); try { localStorage.setItem('volter.console.records', t ? 'tree' : 'path'); } catch { /* a private window */ } };
|
|
683
|
+
const row = (r: ChangedRecord, nested: boolean): React.JSX.Element => {
|
|
684
|
+
const h = r.writes[r.writes.length - 1]!; const s = said(h); const k = changeKind(r.writes);
|
|
685
|
+
return <li key={r.key}><button type="button" className={[selected === r.key ? 'on' : '', nested ? 'nested' : ''].join(' ').trim() || undefined} aria-pressed={selected === r.key} onClick={() => onPick(r.key)}>{nested ? null : <Mark vendor={r.twin} />}<span className="ws-row-text"><b>{s.label ?? shown(h)}</b><small>{kindOf(h.subject.type)} · {s.what.split(' ').slice(-1)[0]}{r.writes.length > 1 ? ` · ${counted(r.writes.length, 'write')}` : ''}</small></span><span className={`ws-kind ${k.tone}`} title={k.word} aria-label={k.word}><Icon name={k.icon} size={16} /></span></button></li>;
|
|
686
|
+
};
|
|
687
|
+
const vendors = [...new Set(records.map((r) => r.twin))];
|
|
688
|
+
return (
|
|
689
|
+
<>
|
|
690
|
+
<div className="ws-view-switch" role="group" aria-label="List the records by">
|
|
691
|
+
<button type="button" aria-pressed={!tree} className={tree ? undefined : 'on'} onClick={() => pick(false)}>Path</button>
|
|
692
|
+
<button type="button" aria-pressed={tree} className={tree ? 'on' : undefined} onClick={() => pick(true)}>Tree</button>
|
|
693
|
+
</div>
|
|
694
|
+
<ul className="ws-list" aria-label={label}>{tree
|
|
695
|
+
? vendors.map((v) => {
|
|
696
|
+
const mine = records.filter((r) => r.twin === v);
|
|
697
|
+
return <li key={v} className="ws-group"><p className="ws-group-head"><Mark vendor={v} /><b>{vendorName(v)}</b><span className="mode">{mine.length}</span></p><ul>{mine.map((r) => row(r, true))}</ul></li>;
|
|
698
|
+
})
|
|
699
|
+
: records.map((r) => row(r, false))}</ul>
|
|
700
|
+
</>
|
|
701
|
+
);
|
|
589
702
|
}
|
|
590
703
|
|
|
591
|
-
/**
|
|
592
|
-
|
|
704
|
+
/** The branch picker, as GitHub Desktop's Current Branch opens: a filter and New branch, this World's branches with the
|
|
705
|
+
* one served here ticked, then the branches made of it (live, or as of an instant) and when each goes away. */
|
|
706
|
+
function BranchMenu({ name, token, branches, opens, refused, onChanged }: { name: string; token: string; branches: Load<BranchesDoor> | null; opens: (name: string) => boolean; refused: (r: Response) => void; onChanged: () => void }): React.JSX.Element {
|
|
707
|
+
const [filter, setFilter] = useState(''); const [making, setMaking] = useState(false);
|
|
708
|
+
const served = (branches?.state === 'ok' && branches.data.checkedOut) || name.split('/')[1]!;
|
|
709
|
+
const named = branches?.state === 'ok' && branches.data.named?.length ? branches.data.named : [{ name: served, running: true }];
|
|
710
|
+
const copies = branches?.state === 'ok' ? branches.data.branches : [];
|
|
711
|
+
const f = filter.trim().toLowerCase(); const hit = (n: string): boolean => !f || n.toLowerCase().includes(f);
|
|
712
|
+
const close = (e: React.MouseEvent): void => { (e.currentTarget as HTMLElement).closest('details')?.removeAttribute('open'); };
|
|
593
713
|
return (
|
|
594
|
-
<details className="menu
|
|
595
|
-
<summary
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
<
|
|
599
|
-
|
|
600
|
-
<
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
)}
|
|
714
|
+
<details className="menu ws-pick">
|
|
715
|
+
<summary title="The branch served here"><Icon name="git-branch" size={16} /><span className="ws-pick-label"><small>Branch</small><b>{served}</b></span><Icon name="chevron-down" size={16} /></summary>
|
|
716
|
+
<div className="popover ws-branch-menu">
|
|
717
|
+
<div className="ws-branch-find"><input type="search" placeholder="Filter" aria-label="Filter branches" value={filter} onChange={(e) => setFilter(e.target.value)} /><button type="button" onClick={(e) => { close(e); setMaking(true); }} disabled={!branches}>New branch</button></div>
|
|
718
|
+
<p className="menu-label">This World</p>
|
|
719
|
+
<ul className="ws-branch-list">{named.filter((b) => hit(b.name)).map((b) => (
|
|
720
|
+
<li key={b.name} className={b.name === served ? 'current' : undefined}><span className="tick">{b.name === served ? <Icon name="check" size={16} /> : <Icon name="git-branch" size={16} />}</span><span className="name">{b.name}</span><span className="mode">{b.name === served ? 'served here' : b.running ? 'running' : 'stopped'}</span></li>
|
|
721
|
+
))}</ul>
|
|
722
|
+
<p className="menu-label">Branches of it</p>
|
|
723
|
+
{copies.length === 0 ? <p className="mode ws-branch-none">None yet. New branch makes one, from now or as the World was.</p> : (
|
|
724
|
+
<ul className="ws-branch-list">{copies.filter((b) => hit(b.name)).map((b) => {
|
|
725
|
+
const row = <><span className="tick"><Icon name="git-branch" size={16} /></span><span className="name">{shortBranch(name, b.name)}</span><span className="mode">{b.at?.instant ? <>as of <At iso={b.at.instant} time /></> : 'live'}</span></>;
|
|
726
|
+
const goes = b.expiresAt ? `Goes away ${new Date(b.expiresAt).toLocaleString()}` : 'Kept';
|
|
727
|
+
return <li key={b.name}>{opens(b.name) ? <a href={`${BASE}/${b.name}`} title={goes} onClick={(e) => { e.preventDefault(); close(e); goBranch(b.name, b.name.split('/')); }}>{row}</a> : <span title={`${goes}. Its tokens went to whoever made it; it opens from their page.`}>{row}</span>}</li>;
|
|
728
|
+
})}</ul>
|
|
729
|
+
)}
|
|
730
|
+
<div className="ws-branch-foot"><a href={`${BASE}/${name}/~/branches`} onClick={(e) => { e.preventDefault(); close(e); go([...name.split('/'), '~', 'branches']); }}>Manage branches…</a></div>
|
|
731
|
+
</div>
|
|
732
|
+
<NewBranch name={name} token={token} open={making} onClose={() => setMaking(false)} onMade={onChanged} refused={refused} />
|
|
613
733
|
</details>
|
|
614
734
|
);
|
|
615
735
|
}
|
|
616
736
|
|
|
617
|
-
/**
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
737
|
+
/** Changes | History, as GitHub Desktop heads its left pane: the two tabs of one place, each with its count. */
|
|
738
|
+
function ChangesTabs({ name, view, changes, changesets }: { name: string; view: 'changes' | 'history'; changes: number; changesets: number }): React.JSX.Element {
|
|
739
|
+
const to = (parts: string[]) => (e: React.MouseEvent): void => { e.preventDefault(); go([...name.split('/'), '~', ...parts]); };
|
|
740
|
+
return (
|
|
741
|
+
<nav className="tabs ws-pane-tabs" aria-label="Changes and history">
|
|
742
|
+
<a href={`${BASE}/${name}/~/changes`} className={view === 'changes' ? 'on' : undefined} aria-current={view === 'changes' ? 'page' : undefined} onClick={to(['changes'])}>Changes{changes ? <span className="count">{changes}</span> : null}</a>
|
|
743
|
+
<a href={`${BASE}/${name}/~/changes/changesets`} className={view === 'history' ? 'on' : undefined} aria-current={view === 'history' ? 'page' : undefined} onClick={to(['changes', 'changesets'])}>History{changesets ? <span className="count">{changesets}</span> : null}</a>
|
|
744
|
+
</nav>
|
|
745
|
+
);
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
/** HISTORY, as GitHub Desktop's History tab is: the changesets down the left (what it says, when), newest first; the
|
|
749
|
+
* selected one beside them, headed by its message and what it changed (GitHub Desktop's "6 changed files +148 −107"),
|
|
750
|
+
* then its records beside the selected one's fields before and after. Review (where it went, the checks, the approvals,
|
|
751
|
+
* and Verify, Approve and Deploy) is one button in that head, a window over it: GitHub Desktop has no review, so this
|
|
752
|
+
* one is ours. */
|
|
753
|
+
function HistoryView({ name, token, sets, sub, signsAs, canWrite, rooted, refused, onChanged, changes, mirrored, required }: { name: string; token: string; sets: Load<{ changesets: ChangesetView[] }>; sub?: string; /** records not in a changeset, the Changes tab's count */ changes: number; /** the vendors with screens */ mirrored: string[]; /** approvals this World requires before a changeset deploys */ required: number; signsAs: string | null; canWrite: boolean; rooted: string[]; refused: (r: Response) => void; onChanged: () => void }): React.JSX.Element {
|
|
754
|
+
const [picked, setPicked] = useState<string | null>(null); const [reviewing, setReviewing] = useState(false);
|
|
755
|
+
const list = sets.state === 'ok' ? [...sets.data.changesets].sort((a, b) => b.createdAt.localeCompare(a.createdAt)) : [];
|
|
756
|
+
const c = list.find((x) => x.name === sub) ?? list[0];
|
|
757
|
+
const records = useMemo((): ChangedRecord[] => {
|
|
758
|
+
const by = new Map<string, ChangedRecord>();
|
|
759
|
+
for (const x of c ? changesetChanges(c) : []) { const key = `${x.twin}:${x.entry.subject.type}:${x.entry.subject.id}`; const r = by.get(key) ?? { key, twin: x.twin, writes: [] }; r.writes.push(x.entry); by.set(key, r); }
|
|
760
|
+
return [...by.values()];
|
|
761
|
+
}, [c]);
|
|
762
|
+
const record = records.find((r) => r.key === picked) ?? records[0];
|
|
763
|
+
const log = useDoor<{ entries: Entry[] }>(token, record ? `/-/${name}/log/${record.twin}` : null);
|
|
764
|
+
const tally = { added: 0, removed: 0, changed: 0 } as Record<string, number>; for (const r of records) tally[changeKind(r.writes).tone] = (tally[changeKind(r.writes).tone] ?? 0) + 1;
|
|
765
|
+
const sent = c ? sentOf(c) : null; const review = c ? reviewOf(c, required) : null;
|
|
623
766
|
return (
|
|
624
|
-
<
|
|
625
|
-
<
|
|
626
|
-
|
|
627
|
-
<
|
|
628
|
-
<ul className="
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
? <
|
|
634
|
-
|
|
635
|
-
)
|
|
636
|
-
</ul>
|
|
637
|
-
|
|
767
|
+
<div className="ws-two">
|
|
768
|
+
<div className="ws-files">
|
|
769
|
+
<ChangesTabs name={name} view="history" changes={changes} changesets={list.length} />
|
|
770
|
+
<Pending load={sets} what="the changesets" lines={4} />
|
|
771
|
+
<ul className="ws-list ws-commits" aria-label="Changesets">{list.map((x) => {
|
|
772
|
+
const on = c?.name === x.name; const notSent = sentOf(x).label === 'Not sent';
|
|
773
|
+
return (
|
|
774
|
+
<li key={x.name}><a href={`${BASE}/${name}/~/changes/changesets/${x.name}`} className={on ? 'on' : undefined} aria-current={on ? 'true' : undefined} onClick={(e) => { e.preventDefault(); setPicked(null); go([...name.split('/'), '~', 'changes', 'changesets', x.name]); }}>
|
|
775
|
+
<span className="ws-row-text"><b>{titleOf(x)}</b><small>{x.owner ?? (x.cutBy ? callerName(x.cutBy) : 'unrecorded')} • <At iso={x.createdAt} /></small></span>
|
|
776
|
+
{notSent ? <span className="ws-unpushed" title="Not pushed to the remote yet"><Icon name="arrow-up-from-line" size={16} /></span> : null}
|
|
777
|
+
</a></li>
|
|
778
|
+
);
|
|
779
|
+
})}</ul>
|
|
780
|
+
</div>
|
|
781
|
+
<div className="ws-pane-right">
|
|
782
|
+
{sets.state === 'ok' && !c ? <Empty title="No changesets yet"><p>Cut one from Changes: the cut box is at its foot.</p></Empty> : null}
|
|
783
|
+
{c && sent && review ? (
|
|
638
784
|
<>
|
|
639
|
-
<
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
<
|
|
643
|
-
|
|
644
|
-
|
|
785
|
+
<div className="ws-commit-head">
|
|
786
|
+
<div className="ws-commit-title">
|
|
787
|
+
<h3>{titleOf(c)}</h3>
|
|
788
|
+
<p className="ws-commit-stat"><Icon name="file-diff" size={16} /> {counted(records.length, 'changed record')}{tally.added ? <span className="plus"> +{tally.added}</span> : null}{tally.removed ? <span className="minus"> −{tally.removed}</span> : null}{tally.changed ? <span className="dot"> •{tally.changed}</span> : null}</p>
|
|
789
|
+
<p className="mode ws-commit-meta"><code>{c.name}</code> · <At iso={c.createdAt} /></p>
|
|
790
|
+
<p className="ws-commit-who">{[['Owner', c.owner ?? (c.cutBy && /^(token|token:read)$|^key:(?!.* for:)/.test(c.cutBy) ? 'no person: a service’s' : 'unrecorded')], ['Cut by', c.cutBy ? (c.pushedBy && c.applied === null && c.cutBy === 'machine' ? 'the machine it was cut on' : callerName(c.cutBy)) : 'unrecorded'], ...(c.pushedBy ? [['Pushed by', callerName(c.pushedBy)]] : [])].map(([k, v]) => <span key={k}><span className="mode">{k}</span> {v}</span>)}</p>
|
|
791
|
+
{/* a copy that arrived by a push carries no push outcome of its own; the pushing World's copy does */}
|
|
792
|
+
{c.pushedBy ? <p className="mode ws-commit-meta">{c.applied === null ? "Owner and cut by are as the World it was cut in recorded them; pushed by is this World's own record." : 'Owner and cut by are this World’s record; pushed by is as the remote recorded it.'}</p> : null}
|
|
793
|
+
</div>
|
|
794
|
+
<button type="button" onClick={() => setReviewing(true)} title="Where it went, the checks, the approvals; Verify, Approve and Deploy"><span className={`badge cs-${sent.tone}`}>{sent.label}</span><span className={`badge cs-${review.tone}`}>{review.label}</span>Review…</button>
|
|
795
|
+
</div>
|
|
796
|
+
<div className="ws-two inner">
|
|
797
|
+
<div className="ws-files"><RecordList records={records} {...(record ? { selected: record.key } : {})} onPick={setPicked} label="Records it changed" /></div>
|
|
798
|
+
<div className="ws-detail">{record ? <RecordDiff record={record} history={log.state === 'ok' ? log.data.entries : log.state === 'error' ? [] : null} {...(mirrored.includes(record.twin) ? { screens: name } : {})} /> : <p className="mode pad">It changes nothing.</p>}</div>
|
|
799
|
+
</div>
|
|
800
|
+
<Dialog open={reviewing} onClose={() => setReviewing(false)} title={`Review: ${titleOf(c)}`} wide>
|
|
801
|
+
<ChangesetPage key={`h:${c.name}`} name={name} token={token} signsAs={signsAs} c={c} canWrite={canWrite} rooted={rooted} refused={refused} onChanged={onChanged} part="head" required={required} />
|
|
802
|
+
<ChangesetPage key={`r:${c.name}`} name={name} token={token} signsAs={signsAs} c={c} canWrite={canWrite} rooted={rooted} refused={refused} onChanged={onChanged} part="review" required={required} />
|
|
803
|
+
</Dialog>
|
|
645
804
|
</>
|
|
646
805
|
) : null}
|
|
647
|
-
{branches?.state === 'ok' && others.length === 0 && !snapshot && (branches.data.named?.length ?? 0) <= 1 ? <p className="mode">No branches yet. “View an earlier moment…” opens one.</p> : null}
|
|
648
806
|
</div>
|
|
649
|
-
</
|
|
807
|
+
</div>
|
|
650
808
|
);
|
|
651
809
|
}
|
|
652
810
|
|
|
653
|
-
/**
|
|
654
|
-
*
|
|
655
|
-
function
|
|
656
|
-
const
|
|
657
|
-
const
|
|
658
|
-
const
|
|
659
|
-
|
|
660
|
-
const
|
|
661
|
-
const
|
|
662
|
-
const
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
return { vendor: v, counts };
|
|
666
|
-
});
|
|
667
|
-
const totals = new Array<number>(hours).fill(0).map((_, i) => series.reduce((n, s) => n + s.counts[i]!, 0));
|
|
668
|
-
const peak = Math.max(1, ...totals);
|
|
669
|
-
const W = 720; const H = 160; const bw = W / hours;
|
|
670
|
-
const hues = ['var(--volter-data-base)', '#7c5cff', '#e3572b', '#1f9d6b', '#d9a400', '#2b7de3', '#c2417e', '#6b7280', '#0ea5a4'];
|
|
811
|
+
/** EVENTS, as Proxyman lists traffic: the vendors as tabs over a dense table of every write, newest first (when, vendor,
|
|
812
|
+
* what, the record, who, its receipt); the selected one under it, the request it came from beside what it wrote. */
|
|
813
|
+
function EventsView({ name, token, vendors, vendor, tick }: { name: string; token: string; vendors: string[]; vendor?: string; tick: number }): React.JSX.Element {
|
|
814
|
+
const [limit, setLimit] = useState(PAGE * 4); const [op, setOp] = useState(''); const [picked, setPicked] = useState<string | null>(null);
|
|
815
|
+
const load = useDoor<Timeline>(token, `/-/${name}/timeline?limit=${limit}${vendor ? `&twin=${encodeURIComponent(vendor)}` : ''}`, Math.floor(tick / 2));
|
|
816
|
+
const all = load.state === 'ok' ? foldLanded(load.data.entries) : [];
|
|
817
|
+
const ops = [...new Set(all.map((p) => said(p.entry).what))].sort();
|
|
818
|
+
const rows = all.filter((p) => !op || said(p.entry).what === op);
|
|
819
|
+
const sel = rows.find((p) => keyOf(p) === picked) ?? rows[0];
|
|
820
|
+
const to = (v?: string) => (e: React.MouseEvent): void => { e.preventDefault(); setPicked(null); go([...name.split('/'), '~', 'events', ...(v ? [v] : [])]); };
|
|
821
|
+
const body = sel?.entry.input && typeof sel.entry.input === 'object' ? (sel.entry.input as { body?: unknown }).body : undefined;
|
|
822
|
+
const t = sel ? traceOf(sel.entry.traceparent) : null; const settled = sel ? settledOf(sel) : null; const usage = sel ? usageOf(sel.entry) : [];
|
|
671
823
|
return (
|
|
672
|
-
|
|
673
|
-
<div className="
|
|
674
|
-
<
|
|
675
|
-
<
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
824
|
+
<div className="ws-events">
|
|
825
|
+
<div className="ws-filterbar">
|
|
826
|
+
<nav className="ws-seg" aria-label="Vendor">
|
|
827
|
+
<a href={`${BASE}/${name}/~/events`} className={!vendor ? 'on' : undefined} aria-current={!vendor ? 'page' : undefined} onClick={to()}>All</a>
|
|
828
|
+
{vendors.map((v) => <a key={v} href={`${BASE}/${name}/~/events/${v}`} className={vendor === v ? 'on' : undefined} aria-current={vendor === v ? 'page' : undefined} onClick={to(v)}><Mark vendor={v} />{vendorName(v)}</a>)}
|
|
829
|
+
</nav>
|
|
830
|
+
<select aria-label="Operation" value={op} onChange={(e) => { setOp(e.target.value); setPicked(null); }}><option value="">Every operation</option>{ops.map((o) => <option key={o} value={o}>{o}</option>)}</select>
|
|
831
|
+
</div>
|
|
832
|
+
<div className="ws-grid-wrap">
|
|
833
|
+
<Pending load={load} what="the events" lines={6} />
|
|
834
|
+
<table className="ws-grid" aria-label="Events">
|
|
835
|
+
<thead><tr><th>Time</th><th>Vendor</th><th>What</th><th>Record</th><th>By</th><th>Receipt</th></tr></thead>
|
|
836
|
+
<tbody>{rows.map((p) => {
|
|
837
|
+
const s = said(p.entry); const st = settledOf(p); const on = sel && keyOf(p) === keyOf(sel);
|
|
679
838
|
return (
|
|
680
|
-
<tr key={
|
|
681
|
-
<td
|
|
682
|
-
<td className="
|
|
683
|
-
<td
|
|
839
|
+
<tr key={keyOf(p)} className={on ? 'on' : undefined} aria-selected={on} onClick={() => setPicked(keyOf(p))}>
|
|
840
|
+
<td className="mono"><At iso={p.entry.occurredAt} time /></td>
|
|
841
|
+
<td><span className="vendor-cell"><Mark vendor={p.twin} />{vendorName(p.twin)}</span></td>
|
|
842
|
+
<td><b>{s.what}</b></td>
|
|
843
|
+
<td className="ws-cell-clip">{s.label ? `“${s.label}”` : <code>{shown(p.entry)}</code>}</td>
|
|
844
|
+
<td className="mode">{whoDid(p.entry, p.caller)}</td>
|
|
845
|
+
<td>{st ? <span className={st.bad ? 'badge cs-bad' : st.status === 'deployed' ? 'badge cs-done' : 'badge'}>{st.status}</span> : <span className="mode">{standing(p.entry)}</span>}</td>
|
|
684
846
|
</tr>
|
|
685
847
|
);
|
|
686
848
|
})}</tbody>
|
|
687
849
|
</table>
|
|
688
850
|
</div>
|
|
689
|
-
<div className="
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
851
|
+
<div className="ws-event-detail">
|
|
852
|
+
{sel ? (
|
|
853
|
+
<>
|
|
854
|
+
<div className="ws-event-line"><span className="badge">{opOf(sel.entry)}</span>{settled ? <span className={settled.bad ? 'badge cs-bad' : 'badge cs-good'}>{settled.status}</span> : null}<code>{sel.entry.subject.type}:{shown(sel.entry)}</code><span className="mode"><At iso={sel.entry.occurredAt} /> · {whoDid(sel.entry, sel.caller)}</span>{t ? <a href={`${BASE}/${name}/~/trace/${t.trace}`} onClick={(e) => { e.preventDefault(); go([...name.split('/'), '~', 'trace', t.trace]); }}>Its trace →</a> : null}</div>
|
|
855
|
+
<div className="ws-event-panes">
|
|
856
|
+
<div role="region" aria-label="The request"><h4>Request</h4>{body !== undefined ? <RequestBody body={body} /> : <p className="mode">No request body: the World or the vendor wrote it.</p>}{usage.length ? <><h4>Usage</h4><dl className="fields">{usage.map(([k, v]) => <React.Fragment key={k}><dt>{k}</dt><dd>{v}</dd></React.Fragment>)}</dl></> : null}</div>
|
|
857
|
+
<div role="region" aria-label="What it wrote"><h4>Written</h4><Fields fields={sel.entry.fields} /><h4>{sel.copies.length ? 'Receipts' : 'Receipt'}</h4><p className="mode">{sel.copies.length ? sel.copies.map((c) => c.entry.receipt?.status ?? 'landed copy').join(' · ') : receiptOf(sel.entry)}</p></div>
|
|
858
|
+
</div>
|
|
859
|
+
</>
|
|
860
|
+
) : <p className="mode pad">Nothing written here yet.</p>}
|
|
861
|
+
</div>
|
|
862
|
+
<div className="ws-foot mode"><span>{sel ? `${rows.indexOf(sel) + 1} of ${rows.length}` : rows.length} events{vendor ? ` at ${vendorName(vendor)}` : ''}</span>{load.state === 'ok' && load.data.next ? <button type="button" onClick={() => setLimit((l) => l + PAGE * 4)}>Load older</button> : null}</div>
|
|
863
|
+
</div>
|
|
864
|
+
);
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
/** A TRACE, as Jaeger opens one: what it is and how long it took over every write it made, each a row (the vendor and
|
|
868
|
+
* what it did) with its moment on the trace's own timeline; the selected one opens under its row. */
|
|
869
|
+
function TraceView({ name, token, id }: { name: string; token: string; id: string }): React.JSX.Element {
|
|
870
|
+
const valid = /^[0-9a-f]{32}$/.test(id);
|
|
871
|
+
const load = useDoor<Timeline>(token, valid ? `/-/${name}/timeline?trace=${id}&limit=500` : null);
|
|
872
|
+
const steps = load.state === 'ok' ? [...load.data.entries].sort((a, b) => -newestFirst(a, b)) : [];
|
|
873
|
+
const [open, setOpen] = useState<string | null>(null);
|
|
874
|
+
const t0 = steps[0] ? Date.parse(steps[0].entry.occurredAt) : 0;
|
|
875
|
+
const span = steps.length ? Math.max(1, Date.parse(steps[steps.length - 1]!.entry.occurredAt) - t0) : 1;
|
|
876
|
+
const lanes = [...new Set(steps.map((s) => s.twin))];
|
|
877
|
+
return (
|
|
878
|
+
<div className="ws-sheet ws-trace">
|
|
879
|
+
<p className="crumbs-line"><a href={`${BASE}/${name}/~/events`} onClick={(e) => { e.preventDefault(); go([...name.split('/'), '~', 'events']); }}><Icon name="arrow-left" size={16} /> Events</a></p>
|
|
880
|
+
<h2>{steps[0] ? `${said(steps[0].entry).what} …` : 'Trace'} <code className="mode">{id.slice(0, 12)}</code></h2>
|
|
881
|
+
{!valid ? <p role="alert">{id ? `${id} is not a trace id` : 'No trace named'}: a W3C trace id is 32 hex characters.</p> : <Pending load={load} what="the trace" />}
|
|
882
|
+
{steps.length ? <p className="ws-trace-meta">Started <b><At iso={steps[0]!.entry.occurredAt} /></b> · Took <b>{span} ms</b> · Vendors <b>{lanes.map(vendorName).join(', ')}</b> · Writes <b>{steps.length}</b></p> : null}
|
|
883
|
+
{load.state === 'ok' && steps.length === 0 ? <Empty title="Nothing in this World carries this trace"><p>An app instrumented with OpenTelemetry sends a <code>traceparent</code> with each call, and every write that call makes keeps it, so one click reads across every vendor.</p></Empty> : null}
|
|
884
|
+
{steps.length ? (
|
|
885
|
+
<div className="ws-spans" role="table" aria-label="The trace's writes">
|
|
886
|
+
<div className="ws-span head" role="row"><span role="columnheader">Vendor and what</span><span role="columnheader" className="ws-ticks"><span>0 ms</span><span>{Math.round(span / 2)} ms</span><span>{span} ms</span></span></div>
|
|
887
|
+
{steps.map((x) => {
|
|
888
|
+
const ms = Date.parse(x.entry.occurredAt) - t0; const k = keyOf(x); const s = said(x.entry);
|
|
889
|
+
return (
|
|
890
|
+
<React.Fragment key={k}>
|
|
891
|
+
<div className={open === k ? 'ws-span on' : 'ws-span'} role="row" onClick={() => setOpen(open === k ? null : k)}>
|
|
892
|
+
<span role="cell" className="ws-span-name"><Mark vendor={x.twin} /><b>{vendorName(x.twin)}</b> <span className="mode">{s.what}{s.label ? ` “${s.label}”` : ''}</span></span>
|
|
893
|
+
<span role="cell" className="ws-span-track"><span className="ws-span-dot" style={{ left: `${(ms / span) * 100}%` }} /><span className="ws-span-at mode" style={{ left: `${(ms / span) * 100}%` }}>+{ms} ms</span></span>
|
|
894
|
+
</div>
|
|
895
|
+
{open === k ? <div className="ws-span-detail"><p className="mode"><code>{opOf(x.entry)}</code> on <code>{x.entry.subject.type}:{shown(x.entry)}</code> · {whoDid(x.entry, x.caller)} · {standing(x.entry)}</p><Fields fields={x.entry.fields} /></div> : null}
|
|
896
|
+
</React.Fragment>
|
|
897
|
+
);
|
|
898
|
+
})}
|
|
899
|
+
</div>
|
|
900
|
+
) : null}
|
|
901
|
+
</div>
|
|
902
|
+
);
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
/** A VENDOR'S PLACE, as TablePlus opens a database: what it stores by type down the left, the chosen type's records as
|
|
906
|
+
* rows, the selected record's fields at the right; its own screens and its events a click away. */
|
|
907
|
+
function VendorView({ name, token, vendor, mirrored, tick }: { name: string; token: string; vendor: string; mirrored: boolean; tick: number }): React.JSX.Element {
|
|
908
|
+
const tree = useDoor<{ tree: Array<Record<string, unknown> & { type: string; id: string }> }>(token, `/-/${name}/checkpoint/${vendor}`, Math.floor(tick / 6));
|
|
909
|
+
const rows = tree.state === 'ok' ? tree.data.tree.filter((r) => r.deleted !== true) : [];
|
|
910
|
+
const types = [...new Set(rows.map((r) => r.type))].map((t) => ({ type: t, count: rows.filter((r) => r.type === t).length })).sort((a, b) => Number(a.type.startsWith('_')) - Number(b.type.startsWith('_')) || kindOf(a.type).localeCompare(kindOf(b.type)));
|
|
911
|
+
const [type, setType] = useState<string | null>(null); const [picked, setPicked] = useState<string | null>(null);
|
|
912
|
+
const shownType = types.some((t) => t.type === type) ? type! : types[0]?.type;
|
|
913
|
+
const mine = rows.filter((r) => r.type === shownType);
|
|
914
|
+
const sel = mine.find((r) => r.id === picked) ?? mine[0];
|
|
915
|
+
// the columns a person reads a row by: its id, the first of its naming fields, a few short ones, and when it changed
|
|
916
|
+
const NAMING = ['name', 'email', 'title', 'text', 'description', 'subject', 'amount', 'percent_off', 'unit_amount', 'currency', 'status', 'channel', 'customer', 'product'];
|
|
917
|
+
const SKIP = new Set(['id', 'type', 'object', 'livemode', 'created', 'updated', 'updatedAt', 'deleted']);
|
|
918
|
+
const scalar = (k: string): boolean => !SKIP.has(k) && mine.some((r) => r[k] !== undefined && r[k] !== null && typeof r[k] !== 'object');
|
|
919
|
+
const keys = [...new Set(mine.flatMap((r) => Object.keys(r)))];
|
|
920
|
+
const cols = [...NAMING.filter(scalar), ...keys.filter((k) => !NAMING.includes(k) && scalar(k))].slice(0, 4);
|
|
921
|
+
return (
|
|
922
|
+
<div className="ws-table-app">
|
|
923
|
+
<nav className="ws-tables" aria-label={`${vendorName(vendor)}'s records`}>
|
|
924
|
+
<div className="ws-list-head"><span className="vendor-cell"><Mark vendor={vendor} />{vendorName(vendor)}</span></div>
|
|
925
|
+
<Pending load={tree} what="what it stores" lines={4} />
|
|
926
|
+
<p className="menu-label">Stores</p>
|
|
927
|
+
{types.map((t) => <a key={t.type} href={`#${t.type}`} className={shownType === t.type ? 'on' : undefined} aria-current={shownType === t.type ? 'true' : undefined} onClick={(e) => { e.preventDefault(); setType(t.type); setPicked(null); }}><Icon name="table-2" size={16} /><span>{kindOf(t.type)}</span><span className="count">{t.count}</span></a>)}
|
|
928
|
+
<p className="menu-label">Also</p>
|
|
929
|
+
<a href={`${BASE}/${name}/~/events/${vendor}`} onClick={(e) => { e.preventDefault(); go([...name.split('/'), '~', 'events', vendor]); }}><Icon name="activity" size={16} /><span>Its events</span></a>
|
|
930
|
+
{mirrored ? <a href={`${BASE}/${name}/~/screens/${vendor}`} onClick={(e) => { e.preventDefault(); go([...name.split('/'), '~', 'screens', vendor]); }}><Icon name="monitor" size={16} /><span>Its screens</span></a> : null}
|
|
931
|
+
</nav>
|
|
932
|
+
<div className="ws-grid-wrap">
|
|
933
|
+
{tree.state === 'ok' && types.length === 0 ? <Empty title={`${vendorName(vendor)} stores nothing yet`}><p>What your app makes there shows here, by type.</p></Empty> : null}
|
|
934
|
+
{mine.length ? (
|
|
935
|
+
<table className="ws-grid" aria-label={`${kindOf(shownType ?? '')} records`}>
|
|
936
|
+
<thead><tr><th>id</th>{cols.map((c) => <th key={c}>{c}</th>)}<th>updated</th></tr></thead>
|
|
937
|
+
<tbody>{mine.map((r) => (
|
|
938
|
+
<tr key={r.id} className={sel?.id === r.id ? 'on' : undefined} aria-selected={sel?.id === r.id} onClick={() => setPicked(r.id)}>
|
|
939
|
+
<td><code>{r.id}</code></td>
|
|
940
|
+
{cols.map((c) => <td key={c} className="ws-cell-clip">{r[c] === undefined || r[c] === null ? <span className="mode">—</span> : String(r[c])}</td>)}
|
|
941
|
+
<td className="mode">{typeof r.updatedAt === 'string' ? <At iso={r.updatedAt} /> : '—'}</td>
|
|
942
|
+
</tr>
|
|
943
|
+
))}</tbody>
|
|
944
|
+
</table>
|
|
706
945
|
) : null}
|
|
707
946
|
</div>
|
|
947
|
+
<aside className="ws-record" aria-label="The selected record">
|
|
948
|
+
{sel ? (
|
|
949
|
+
<>
|
|
950
|
+
<div className="ws-list-head"><span>{kindOf(sel.type)}</span><code>{sel.id}</code></div>
|
|
951
|
+
<dl className="ws-record-fields">{Object.entries(sel).filter(([k]) => k !== 'type' && k !== 'id').map(([k, v]) => <React.Fragment key={k}><dt>{k}</dt><dd><Value v={v} /></dd></React.Fragment>)}</dl>
|
|
952
|
+
</>
|
|
953
|
+
) : null}
|
|
954
|
+
</aside>
|
|
955
|
+
<div className="ws-foot mode">{sel ? `${mine.indexOf(sel) + 1} of ${counted(mine.length, kindOf(shownType ?? 'record'))}` : ''}</div>
|
|
956
|
+
</div>
|
|
957
|
+
);
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
/** A VENDOR'S SCREENS: its own site over this World's state (the twin's mirror), the whole pane; what it holds and the
|
|
961
|
+
* way to its records or a tab of its own above it. */
|
|
962
|
+
function ScreenView({ name, vendor, holding, session, retry, show }: { name: string; vendor: string; holding: string; session: MirrorSession; retry: () => void; /** a record to open the screens on (`<type>:<id>`): the vendor's screens find where they show it */ show?: string }): React.JSX.Element {
|
|
963
|
+
return (
|
|
964
|
+
<div className="ws-screen">
|
|
965
|
+
<div className="ws-screen-bar">
|
|
966
|
+
<span className="vendor-cell"><Mark vendor={vendor} /><b>{vendorName(vendor)}</b></span>
|
|
967
|
+
<span className="mode">{holding}</span>
|
|
968
|
+
<a href={`${BASE}/${name}/~/vendors/${vendor}`} onClick={(e) => { e.preventDefault(); go([...name.split('/'), '~', 'vendors', vendor]); }}><Icon name="table-2" size={16} />Its records</a>
|
|
969
|
+
<a href={mirrorUrl(name, vendor)} target="_blank" rel="noopener noreferrer"><Icon name="external-link" size={16} />New tab</a>
|
|
970
|
+
</div>
|
|
971
|
+
{session.state === 'ok' && session.scope === 'read' ? <p className="mode ws-screens-note">You are looking read only: the screens browse, and refuse changes.</p> : null}
|
|
972
|
+
{session.state === 'pending' ? <p className="mode ws-screens-note" role="status">Opening…</p> : null}
|
|
973
|
+
{session.state === 'error' ? <p role="alert" className="ws-screens-note">The World did not open its screens: {session.error} <button type="button" onClick={retry}>Try again</button></p> : null}
|
|
974
|
+
{session.state === 'ok' ? <iframe key={`${vendor}:${show ?? ''}`} title={`${vendorName(vendor)}, as this World shows it`} src={`${mirrorUrl(name, vendor)}${show ? `?show=${encodeURIComponent(show)}` : ''}`} /> : null}
|
|
975
|
+
</div>
|
|
976
|
+
);
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
/** CHANGESETS, as GitHub Desktop's Repository Settings › Git Config says how a repository commits: whom a changeset cut
|
|
980
|
+
* here is recorded as, and what makes one ready to deploy. Each rule is the World's as it stands; where one is set
|
|
981
|
+
* elsewhere, the pane says where. */
|
|
982
|
+
function ChangesetsPane({ name, token, who, scope }: { name: string; token: string; who: { local: boolean; person: string | null; admin: boolean }; scope: Scope }): React.JSX.Element {
|
|
983
|
+
const [version, setVersion] = useState(0);
|
|
984
|
+
const twins = useDoor<{ twins: Twin[] }>(token, `/-/${name}/twins`);
|
|
985
|
+
const rules = useDoor<{ changesets: ChangesetRules }>(token, `/-/${name}/rules`, version);
|
|
986
|
+
const held = rules.state === 'ok' ? rules.data.changesets : null;
|
|
987
|
+
const [approvals, setApprovals] = useState<number | null>(null); const [summary, setSummary] = useState<'required' | 'generated' | null>(null);
|
|
988
|
+
const [busy, setBusy] = useState(false); const [log, setLog] = useState<Said[]>([]);
|
|
989
|
+
const nowApprovals = approvals ?? held?.approvals ?? 1; const nowSummary = summary ?? held?.summary ?? 'required';
|
|
990
|
+
const changed = held !== null && (nowApprovals !== (held.approvals ?? 1) || nowSummary !== (held.summary ?? 'required'));
|
|
991
|
+
const say = (ok: boolean, text: string): void => { setLog((l) => [{ ok, text, at: new Date().toISOString(), n: ++saidCount }, ...l].slice(0, 3)); if (!ok) toast(text, 'error'); };
|
|
992
|
+
const save = async (e: React.FormEvent): Promise<void> => {
|
|
993
|
+
e.preventDefault(); setBusy(true);
|
|
994
|
+
try {
|
|
995
|
+
const r = await door(token, `/-/${name}/rules`, { method: 'PUT', body: JSON.stringify({ changesets: { approvals: nowApprovals, summary: nowSummary } }) });
|
|
996
|
+
if (!r.ok) { say(false, `Save: ${await failure(r)}`); return; }
|
|
997
|
+
say(true, `Saved: ${nowApprovals === 0 ? 'no approval' : counted(nowApprovals, 'approval')} before a deploy; a summary ${nowSummary === 'required' ? 'required' : 'generated when none is given'}.`);
|
|
998
|
+
setApprovals(null); setSummary(null); setVersion((v) => v + 1);
|
|
999
|
+
} finally { setBusy(false); }
|
|
1000
|
+
};
|
|
1001
|
+
const POLICY: Record<string, string> = { gated: 'a person deploys an approved changeset', auto: 'a push deploys at once', hold: 'nothing deploys' };
|
|
1002
|
+
const as = who.person ? <><b>{who.person}</b>{who.local ? ', signed in on this machine' : ''}</> : who.local ? <><b>this machine</b>: no one is signed in here, so no person owns them until they are pushed by one</> : who.admin ? <><b>the host's admin</b></> : <><b>this token</b>, a service</>;
|
|
1003
|
+
const rooted = twins.state === 'ok' ? twins.data.twins.filter((t) => t.root?.url) : [];
|
|
1004
|
+
const off = scope !== 'write' ? 'This session reads the World; it changes no rules' : null;
|
|
1005
|
+
return (
|
|
1006
|
+
<>
|
|
1007
|
+
<h3 className="settings-head">Changesets</h3>
|
|
1008
|
+
<p className="mode">This World's rules, kept with it and shared by everyone who works in it. A person or the World's token changes them; a key never does, so an agent cannot relax the review its own changes go through.</p>
|
|
1009
|
+
<Pending load={rules} what="the rules" lines={2} />
|
|
1010
|
+
<form className="ws-rules" onSubmit={(e) => void save(e)} aria-label="Changeset rules">
|
|
1011
|
+
<div className="ws-rule">
|
|
1012
|
+
<h4>Recorded as</h4>
|
|
1013
|
+
<p>What you cut here is recorded as {as}. An agent's or an app's key cuts as itself, for the person it was made for; a World's token cuts as a service.</p>
|
|
1014
|
+
</div>
|
|
1015
|
+
<div className="ws-rule">
|
|
1016
|
+
<h4>A cut's summary</h4>
|
|
1017
|
+
<fieldset className="choice" disabled={Boolean(off) || held === null}>
|
|
1018
|
+
<label className="check"><input type="radio" name="summary" checked={nowSummary === 'required'} onChange={() => setSummary('required')} /> <span>Required: whoever cuts says what it does and why</span></label>
|
|
1019
|
+
<label className="check"><input type="radio" name="summary" checked={nowSummary === 'generated'} onChange={() => setSummary('generated')} /> <span>Generated from its changes when none is given</span></label>
|
|
1020
|
+
</fieldset>
|
|
1021
|
+
<p className="mode">Either way a cut takes every change not yet in a changeset: there is no staging.</p>
|
|
1022
|
+
</div>
|
|
1023
|
+
<div className="ws-rule">
|
|
1024
|
+
<h4>Ready to deploy</h4>
|
|
1025
|
+
<ul className="ws-rule-list"><li><Icon name="check" size={16} />Verified: replayed in a throwaway World, the World's checks passing</li></ul>
|
|
1026
|
+
<label className="ws-rule-row"><span>Approved by</span>
|
|
1027
|
+
<select value={nowApprovals} onChange={(e) => setApprovals(Number(e.target.value))} disabled={Boolean(off) || held === null} aria-label="Approvals required">
|
|
1028
|
+
{[0, 1, 2, 3, 4, 5].map((n) => <option key={n} value={n}>{n === 0 ? 'no one: verified is enough' : n === 1 ? '1 person' : `${n} different people`}</option>)}
|
|
1029
|
+
</select>
|
|
1030
|
+
<span className="mode">for its contents as they stand</span>
|
|
1031
|
+
</label>
|
|
1032
|
+
</div>
|
|
1033
|
+
<div className="ws-rule">
|
|
1034
|
+
<h4>Deploys to</h4>
|
|
1035
|
+
{twins.state === 'ok' && rooted.length === 0 ? <p className="mode">No vendor here is connected to its real service, so nothing deploys from this World.</p> : null}
|
|
1036
|
+
{rooted.length ? <ul className="ws-rule-list">{rooted.map((t) => <li key={t.twin}><Mark vendor={t.twin} />{vendorName(t.twin)}: {POLICY[(t.root as { deploy?: string }).deploy ?? 'gated'] ?? 'gated'}</li>)}</ul> : null}
|
|
1037
|
+
<p className="mode"><a href={`${BASE}/${name}/~/settings/services`} onClick={(e) => { e.preventDefault(); go([...name.split('/'), '~', 'settings', 'services']); }}>Real services</a> sets each vendor's deploy policy.</p>
|
|
1038
|
+
</div>
|
|
1039
|
+
<Narration log={log} name={name} />
|
|
1040
|
+
<div className="row-actions">
|
|
1041
|
+
<button type="submit" className="primary" disabled={Boolean(off) || busy || !changed} title={off ?? undefined}>{busy ? 'Saving…' : 'Save'}</button>
|
|
1042
|
+
{changed ? <button type="button" onClick={() => { setApprovals(null); setSummary(null); }}>Cancel</button> : null}
|
|
1043
|
+
{off ? <span className="mode">{off}.</span> : null}
|
|
1044
|
+
</div>
|
|
1045
|
+
</form>
|
|
708
1046
|
</>
|
|
709
1047
|
);
|
|
710
1048
|
}
|
|
711
1049
|
|
|
712
|
-
/**
|
|
713
|
-
*
|
|
714
|
-
|
|
715
|
-
const
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
1050
|
+
/** ACCOUNT, as GitHub Desktop's Settings › Accounts is: who this page is for (this machine, a person the platform signed in,
|
|
1051
|
+
* the host's admin, a link or key, or a snapshot's reader), what it may do here, and the way out where there is one. */
|
|
1052
|
+
function AccountPane({ who, scope, snapshot, portal: platform, onSignOut }: { who: { local: boolean; person: string | null; admin: boolean }; scope: Scope; snapshot: Snapshot | null; portal: string; onSignOut: () => void }): React.JSX.Element {
|
|
1053
|
+
const access = scope === 'write' ? 'Reads and changes this World' : scope === 'read' ? 'Reads this World; changes nothing' : '…';
|
|
1054
|
+
const [title, line] = snapshot ? ['A snapshot', `of ${snapshot.parent}, read only`]
|
|
1055
|
+
: who.local && who.person ? [who.person, 'Signed in on this machine: this World is served here, and acts as you']
|
|
1056
|
+
: who.local ? ['This machine', 'Served here, with no one signed in: what you do is recorded as this machine']
|
|
1057
|
+
: who.person ? [who.person, platform ? `Signed in through ${new URL(platform).host}` : 'Signed in']
|
|
1058
|
+
: who.admin ? ["The host's admin", 'Opened with the host admin token: every World it serves']
|
|
1059
|
+
: ['A link or key', 'Opened with a token for this World'];
|
|
1060
|
+
return (
|
|
1061
|
+
<>
|
|
1062
|
+
<h3 className="settings-head">Account</h3>
|
|
1063
|
+
<div className="ws-account">
|
|
1064
|
+
<span className="ws-account-face" aria-hidden="true"><Icon name={who.person ? 'user' : who.local ? 'monitor' : snapshot ? 'clock' : 'key-round'} size={20} /></span>
|
|
1065
|
+
<div className="cell-label"><b>{title}</b><small>{line}</small><small>{access}</small></div>
|
|
1066
|
+
</div>
|
|
1067
|
+
{who.local || snapshot ? null : (
|
|
1068
|
+
<p><button type="button" onClick={onSignOut}><Icon name="log-out" size={16} />{who.person ? 'Sign out' : who.admin ? 'Sign out of the host' : 'Use another token'}</button></p>
|
|
1069
|
+
)}
|
|
1070
|
+
{platform && who.person ? <p className="mode">Your profile, orgs and keys are on <a href={`${platform}/`}>{new URL(platform).host}</a>.</p> : null}
|
|
1071
|
+
</>
|
|
1072
|
+
);
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
/** THE WORKSPACE: the World this machine serves, as a desktop git app opens a repository (docs/contributing/console.md,
|
|
1076
|
+
* On this machine). One short toolbar, as TablePlus's and Proxyman's are: the World and the branch served here at its
|
|
1077
|
+
* left, where this is in its middle (the World, the branch, the origin and what waits for it, the World's clock), Pull,
|
|
1078
|
+
* Push and Settings at its right. A sidebar of places (Changes, History, Events; the vendors; the remote) beside the place
|
|
1079
|
+
* chosen. Settings and Branches open as windows over it, as GitHub Desktop's do. */
|
|
1080
|
+
function Workspace({ name, token, view, sub, arg, win, who, snapshot, onReturn, onSignOut, worlds, session, retry, twins, map, branches, clock, canWrite, refused, signsAs, opens, tick, admin, onRemoved, onClock, scope }: { name: string; token: string; view: WorkspaceView; sub?: string; arg?: string; win: WorkspaceWindow; /** who is here: this machine, a person signed in, the host's admin, a link or key */ who: { local: boolean; person: string | null; admin: boolean }; /** a snapshot in view: read only, with the way back to live */ snapshot: Snapshot | null; onReturn: (s: Snapshot) => Promise<void>; onSignOut: () => void; /** the Worlds this page opens (the World menu's switcher) */ worlds: string[]; /** the World's session, which vendors' screens read with, and its retry */ session: MirrorSession; retry: () => void; twins: Load<{ twins: Twin[] }>; map: Load<WorldMap>; branches: Load<BranchesDoor> | null; clock: Load<Clock>; canWrite: boolean; refused: (r: Response) => void; signsAs: string | null; opens: (name: string) => boolean; tick: number; admin: string | null; onRemoved: () => void; onClock: () => void; scope: Scope }): React.JSX.Element {
|
|
1081
|
+
const remote = usePushPull(name, token, tick, refused);
|
|
1082
|
+
const [version, setVersion] = useState(0);
|
|
1083
|
+
const every = version * 100_000 + Math.floor(tick / 2);
|
|
1084
|
+
const diff = useDoor<Diff>(token, `/-/${name}/diff`, every);
|
|
1085
|
+
const sets = useDoor<{ changesets: ChangesetView[] }>(token, `/-/${name}/changesets`, every);
|
|
1086
|
+
const rules = useDoor<{ changesets: ChangesetRules }>(token, `/-/${name}/rules`, every);
|
|
1087
|
+
const [picked, setPicked] = useState<string | null>(null);
|
|
1088
|
+
const to = (parts: string[]) => (e?: React.MouseEvent): void => { e?.preventDefault(); go([...name.split('/'), '~', ...parts]); };
|
|
1089
|
+
const vendors = twins.state === 'ok' ? twins.data.twins.map((t) => t.twin) : [];
|
|
1090
|
+
const mirrored = map.state === 'ok' ? map.data.twins.filter((t) => t.mirror).map((t) => t.twin) : [];
|
|
1091
|
+
const rooted = map.state === 'ok' ? map.data.twins.filter((t) => t.root).map((t) => t.twin) : [];
|
|
1092
|
+
const held = (v: string): number => (map.state === 'ok' ? (map.data.twins.find((x) => x.twin === v)?.resources ?? []).reduce((n, r) => n + r.count, 0) : 0);
|
|
1093
|
+
const served = (branches?.state === 'ok' && branches.data.checkedOut) || name.split('/')[1]!;
|
|
1094
|
+
|
|
1095
|
+
const records = useMemo((): ChangedRecord[] => {
|
|
1096
|
+
if (diff.state !== 'ok') return [];
|
|
1097
|
+
const by = new Map<string, ChangedRecord>();
|
|
1098
|
+
for (const c of diff.data.changes) { const key = `${c.twin}:${c.entry.subject.type}:${c.entry.subject.id}`; const r = by.get(key) ?? { key, twin: c.twin, writes: [] }; r.writes.push(c.entry); by.set(key, r); }
|
|
1099
|
+
return [...by.values()];
|
|
1100
|
+
}, [diff]);
|
|
1101
|
+
const record = records.find((r) => r.key === picked) ?? records[0];
|
|
1102
|
+
const log = useDoor<{ entries: Entry[] }>(token, view === 'changes' && record ? `/-/${name}/log/${record.twin}` : null, every);
|
|
1103
|
+
const writes = diff.state === 'ok' ? diff.data.changes.length : 0;
|
|
1104
|
+
const waiting = remote.waiting.length;
|
|
1105
|
+
const there = remote.there;
|
|
1106
|
+
const origin = remote.origin.state === 'ok' ? remote.origin.data.origin : null;
|
|
1107
|
+
const off = !canWrite ? 'This session reads the World; it changes nothing' : !there ? 'No remote: name one in Settings › Remote' : null;
|
|
1108
|
+
const place = (on: boolean, icon: React.ReactNode, label: string, href: string, onClick: (e: React.MouseEvent) => void, count?: string): React.JSX.Element => (
|
|
1109
|
+
<a href={`${BASE}/${name}/~/${href}`} className={on ? 'on' : undefined} aria-current={on ? 'page' : undefined} onClick={onClick}>{icon}<span>{label}</span>{count ? <span className="count">{count}</span> : null}</a>
|
|
1110
|
+
);
|
|
1111
|
+
const closeMenu = (e: React.MouseEvent): void => { (e.currentTarget as HTMLElement).closest('details')?.removeAttribute('open'); };
|
|
1112
|
+
|
|
726
1113
|
return (
|
|
727
|
-
<div className="
|
|
728
|
-
<
|
|
729
|
-
<div className="
|
|
730
|
-
<
|
|
731
|
-
<
|
|
732
|
-
|
|
1114
|
+
<div className="ws">
|
|
1115
|
+
<div className="ws-bar" role="toolbar" aria-label={`${name} toolbar`}>
|
|
1116
|
+
<div className="ws-left">
|
|
1117
|
+
<Brand href={`${BASE}/${name}`} onClick={(e) => { e.preventDefault(); to(['changes'])(); }} />
|
|
1118
|
+
<details className="menu ws-pick">
|
|
1119
|
+
<summary title="This World"><Icon name="globe" size={16} /><span className="ws-pick-label"><small>World</small><b>{name}</b></span><Icon name="chevron-down" size={16} /></summary>
|
|
1120
|
+
<div className="popover menu-list ws-world-menu">
|
|
1121
|
+
{who.local ? null : (
|
|
1122
|
+
<>
|
|
1123
|
+
<p className="menu-label">Worlds</p>
|
|
1124
|
+
{worlds.map((w) => <a key={w} href={`${BASE}/${w}`} className={w === name ? 'on' : undefined} onClick={(e) => { e.preventDefault(); closeMenu(e); go(w.split('/')); }}>{w === name ? <Icon name="check" size={16} /> : <Icon name="globe" size={16} />}{w}</a>)}
|
|
1125
|
+
<a href={`${home() && home() !== ORIGIN ? home() : ''}${BASE}/`} onClick={(e) => { if (home() && home() !== ORIGIN) return; e.preventDefault(); closeMenu(e); go([]); }}><Icon name="layout-grid" size={16} />All Worlds{portal() ? ` on ${new URL(portal()).host}` : ''}</a>
|
|
1126
|
+
<hr />
|
|
1127
|
+
</>
|
|
1128
|
+
)}
|
|
1129
|
+
<p className="menu-label">Connect an app</p>
|
|
1130
|
+
{portal() ? (
|
|
1131
|
+
<>
|
|
1132
|
+
<p className="ws-menu-text">Point your app's SDKs here with a key made on the platform; its calls to these vendors land in this World:</p>
|
|
1133
|
+
<pre>{WORLDS}/{name}/<vendor></pre>
|
|
1134
|
+
<CopyAgentPrompt text={agentPrompt({ platform: portal(), world: name })} />
|
|
1135
|
+
</>
|
|
1136
|
+
) : !who.local ? (
|
|
1137
|
+
<>
|
|
1138
|
+
<p className="ws-menu-text">Point your app's SDKs here with a key from Settings › Access; its calls to these vendors land in this World:</p>
|
|
1139
|
+
<pre>{WORLDS}/{name}/<vendor></pre>
|
|
1140
|
+
<CopyAgentPrompt text={agentPrompt()} />
|
|
1141
|
+
</>
|
|
1142
|
+
) : (
|
|
1143
|
+
<>
|
|
1144
|
+
<p className="ws-menu-text">Run it in this World; its calls to these vendors land here:</p>
|
|
1145
|
+
<pre>volter world run -- npm start</pre>
|
|
1146
|
+
<p className="ws-menu-text mode">Or point an SDK at <code>{WORLDS}/{name}/<vendor></code> with the token in <code>.volter/token</code>.</p>
|
|
1147
|
+
<CopyAgentPrompt text={agentPrompt()} />
|
|
1148
|
+
</>
|
|
1149
|
+
)}
|
|
1150
|
+
<hr />
|
|
1151
|
+
<a href={`${BASE}/${name}/~/settings`} onClick={(e) => { closeMenu(e); to(['settings'])(e); }}><Icon name="settings" size={16} />Settings…</a>
|
|
1152
|
+
</div>
|
|
1153
|
+
</details>
|
|
1154
|
+
<BranchMenu name={name} token={token} branches={branches} opens={opens} refused={refused} onChanged={onClock} />
|
|
1155
|
+
</div>
|
|
1156
|
+
<div className="ws-where" title="Where this is">
|
|
1157
|
+
<span>{name}</span><span className="sep">:</span><span>{served}</span><span className="sep">:</span>
|
|
1158
|
+
{origin ? <a href={`${BASE}/${name}/~/remote`} onClick={to(['remote'])}>origin {origin.namespace}{waiting ? <b> ↑{waiting}</b> : null}</a> : <a href={`${BASE}/${name}/~/remote`} onClick={to(['remote'])}>no remote</a>}
|
|
1159
|
+
<span className="sep">:</span>
|
|
1160
|
+
<a href={`${BASE}/${name}/~/settings/clock`} onClick={to(['settings', 'clock'])}>{clock.state === 'ok' ? <>{clock.data.frozen ? 'stopped at ' : ''}<At iso={clock.data.at} time /></> : '…'}</a>
|
|
1161
|
+
</div>
|
|
1162
|
+
<div className="ws-right">
|
|
1163
|
+
<button type="button" className="ws-btn" onClick={() => void remote.pull()} disabled={Boolean(off) || remote.busy !== null} title={off ?? `Fetch what ${there} has and move this branch onto it`}><Icon name="arrow-down-to-line" size={16} />{remote.busy === 'Pull' ? 'Pulling…' : 'Pull'}</button>
|
|
1164
|
+
<button type="button" className="ws-btn" onClick={() => void remote.push()} disabled={Boolean(off) || remote.busy !== null || waiting === 0} title={off ?? (waiting ? `Push ${counted(waiting, 'changeset')} to ${there}` : `Every changeset is at ${there}`)}><Icon name="arrow-up-from-line" size={16} />{remote.busy === 'Push' ? 'Pushing…' : 'Push'}{waiting ? <span className="ws-btn-count">{waiting}</span> : null}</button>
|
|
1165
|
+
<button type="button" className="ws-btn icon-only" aria-label="Settings" title="Settings" onClick={() => to(['settings'])()}><Icon name="settings" size={16} /></button>
|
|
733
1166
|
</div>
|
|
734
|
-
{session.state === 'ok' ? (
|
|
735
|
-
<ul className="thumbs">
|
|
736
|
-
{vendors.map((v) => (
|
|
737
|
-
<li key={v}>
|
|
738
|
-
<a href={`${BASE}/${name}/~/vendors/${v}`} onClick={open(['~', 'vendors', v])} aria-label={`${vendorName(v)}'s screens`}>
|
|
739
|
-
<span className="thumb">{screens.includes(v)
|
|
740
|
-
? <iframe title={`${vendorName(v)} preview`} src={mirrorUrl(name, v)} loading="lazy" tabIndex={-1} style={{ width: `${100 / THUMB_SCALE}%`, height: `${100 / THUMB_SCALE}%`, transform: `scale(${THUMB_SCALE})` }} />
|
|
741
|
-
: <span className="thumb-none">No screens</span>}<span className={rows.find((x) => x.twin === v)?.root ? 'badge rooted thumb-status' : 'badge thumb-status'}>{rows.find((x) => x.twin === v)?.root ? 'connected' : 'stand-in'}</span></span>
|
|
742
|
-
<span className="thumb-foot"><Mark vendor={v} /><b>{vendorName(v)}</b><span className="mode">{heldOf(v)}</span></span>
|
|
743
|
-
</a>
|
|
744
|
-
</li>
|
|
745
|
-
))}
|
|
746
|
-
</ul>
|
|
747
|
-
) : <p className="mode pad">{session.state === 'error' ? `The World did not open its screens: ${session.error}` : 'Opening…'}</p>}
|
|
748
|
-
</section>
|
|
749
|
-
<div className="dash-side">
|
|
750
|
-
{branches ? <section className="block" aria-labelledby="branches-head">
|
|
751
|
-
<div className="block-head"><h3 id="branches-head">Branches</h3><a href={`${BASE}/${name}/~/branches`} onClick={open(['~', 'branches'])}>All</a></div>
|
|
752
|
-
<ul className="mini-feed">
|
|
753
|
-
<li><svg className="branch-glyph" viewBox="0 0 16 16" width="14" height="14" aria-hidden="true"><path fill="currentColor" d="M5 3.25a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Zm0 2.122a2.25 2.25 0 1 0-1.5 0v5.256a2.25 2.25 0 1 0 1.5 0V9.5h4.25A2.25 2.25 0 0 0 11.5 7.25v-.878a2.25 2.25 0 1 0-1.5 0v.878a.75.75 0 0 1-.75.75H5V5.372ZM4.25 12a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Zm6.5-9.5a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Z" /></svg><span className="mini-what">{snapshotOf ? <><b>{name.split('/')[1]}</b> <span className="badge">snapshot</span> <span className="mode">of {snapshotOf.split('/')[1]}</span></> : <><b>{name.split('/')[1]}</b> <span className="badge">live</span></>}</span></li>
|
|
754
|
-
{list.slice(0, 3).map((b) => <li key={b.name}><svg className="branch-glyph" viewBox="0 0 16 16" width="14" height="14" aria-hidden="true"><path fill="currentColor" d="M5 3.25a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Zm0 2.122a2.25 2.25 0 1 0-1.5 0v5.256a2.25 2.25 0 1 0 1.5 0V9.5h4.25A2.25 2.25 0 0 0 11.5 7.25v-.878a2.25 2.25 0 1 0-1.5 0v.878a.75.75 0 0 1-.75.75H5V5.372ZM4.25 12a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Zm6.5-9.5a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Z" /></svg><span className="mini-what">{b.name.split('/').pop()} <span className="mode">{b.at?.instant ? `as of ${new Date(b.at.instant).toLocaleString()}` : 'copy'}</span></span></li>)}
|
|
755
|
-
</ul>
|
|
756
|
-
{canWrite ? <p className="mode pad">“View an earlier moment…” above opens a branch as the World was then.</p> : null}
|
|
757
|
-
</section> : null}
|
|
758
1167
|
</div>
|
|
759
|
-
|
|
760
|
-
<div className="
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
1168
|
+
{snapshot ? (
|
|
1169
|
+
<div className="ws-banner" role="region" aria-label="Snapshot">
|
|
1170
|
+
<Icon name="clock" size={16} /><span>You are looking at <b>{snapshot.parent}</b> as it was at <At iso={snapshot.at} />: a read-only snapshot{snapshot.expiresAt ? <>, gone <At iso={snapshot.expiresAt} /></> : null}.</span>
|
|
1171
|
+
<button type="button" onClick={() => void onReturn(snapshot)}>Return to live</button>
|
|
1172
|
+
</div>
|
|
1173
|
+
) : null}
|
|
1174
|
+
{remote.log.length ? <div className="ws-said"><Narration log={remote.log.slice(0, 1)} name={name} /></div> : null}
|
|
1175
|
+
|
|
1176
|
+
<div className="ws-body">
|
|
1177
|
+
<nav className="ws-side" aria-label={`${name} places`}>
|
|
1178
|
+
{/* the screens: one way in, saying how many there are and whose; the canvas lists them along its left */}
|
|
1179
|
+
{mirrored.length ? (
|
|
1180
|
+
<a href={`${BASE}/${name}/~/screens`} className={view === 'screen' ? 'ws-screens-entry on' : 'ws-screens-entry'} onClick={to(['screens'])} title={`${counted(mirrored.length, 'screen')}: ${listed(mirrored.map(vendorName))}`}>
|
|
1181
|
+
<Icon name="layout-grid" size={16} /><span>Screens</span>
|
|
1182
|
+
<span className="ws-screens-marks">{mirrored.slice(0, 3).map((v) => <Mark key={v} vendor={v} />)}{mirrored.length > 3 ? <span className="more">+{mirrored.length - 3}</span> : null}</span>
|
|
1183
|
+
<span className="count">{mirrored.length}</span>
|
|
1184
|
+
</a>
|
|
1185
|
+
) : null}
|
|
1186
|
+
<p className="side-group">Workspace</p>
|
|
1187
|
+
{place(view === 'changes' || view === 'history', <Icon name="file-diff" size={16} />, 'Changes', 'changes', to(['changes']), records.length ? String(records.length) : undefined)}
|
|
1188
|
+
{place((view === 'events' && !arg) || view === 'trace', <Icon name="activity" size={16} />, 'Events', 'events', to(['events']))}
|
|
1189
|
+
<p className="side-group">Records</p>
|
|
1190
|
+
{vendors.map((v) => <React.Fragment key={v}>{place(view === 'vendor' && arg === v, <Mark vendor={v} />, vendorName(v), `vendors/${v}`, to(['vendors', v]), held(v) ? String(held(v)) : undefined)}</React.Fragment>)}
|
|
1191
|
+
<p className="side-group">Remote</p>
|
|
1192
|
+
{place(false, <Icon name="cloud" size={16} />, there ? 'origin' : 'Name a remote…', 'remote', to(['remote']), waiting ? `↑${waiting}` : undefined)}
|
|
1193
|
+
</nav>
|
|
1194
|
+
|
|
1195
|
+
<div className="ws-main" role="main">
|
|
1196
|
+
{view === 'changes' ? (
|
|
1197
|
+
<div className="ws-status">
|
|
1198
|
+
<div className="ws-files">
|
|
1199
|
+
<ChangesTabs name={name} view="changes" changes={records.length} changesets={sets.state === 'ok' ? sets.data.changesets.length : 0} />
|
|
1200
|
+
<div className="ws-list-head"><span>Not in a changeset</span><span className="mode">{diff.state === 'ok' ? `${counted(records.length, 'record')} · ${counted(writes, 'write')}` : ''}</span></div>
|
|
1201
|
+
<Pending load={diff} what="the changes" lines={4} />
|
|
1202
|
+
<RecordList records={records} {...(record ? { selected: record.key } : {})} onPick={setPicked} label="Changed records" />
|
|
1203
|
+
</div>
|
|
1204
|
+
<div className="ws-detail">
|
|
1205
|
+
{diff.state === 'ok' && !record ? (
|
|
1206
|
+
<Empty title="No changes on this branch" action={waiting > 0 && there ? <button type="button" className="primary" disabled={Boolean(off) || remote.busy !== null} onClick={() => void remote.push()}>Push {counted(waiting, 'changeset')} to origin</button> : undefined}>
|
|
1207
|
+
<p>What your app writes to this World lands here, record by record, to cut into a changeset. Everything since {diff.data.base.at ? <At iso={diff.data.base.at} /> : <code>{diff.data.base.id}</code>} is in one already.</p>
|
|
1208
|
+
</Empty>
|
|
1209
|
+
) : null}
|
|
1210
|
+
{record ? <RecordDiff record={record} history={log.state === 'ok' ? log.data.entries : log.state === 'error' ? [] : null} {...(mirrored.includes(record.twin) ? { screens: name } : {})} /> : null}
|
|
1211
|
+
</div>
|
|
1212
|
+
<CutChangeset pinned generated={rules.state === 'ok' && rules.data.changesets.summary === 'generated'} author={{ face: <Icon name={who.person ? 'user' : who.local ? 'monitor' : 'key-round'} size={18} />, who: who.person ?? (who.local ? 'This machine' : who.admin ? "The host's admin" : 'A token'), account: `${BASE}/${name}/~/settings/account`, branch: served }} name={name} token={token} changes={writes} canWrite={canWrite} refused={refused} onCut={() => { setVersion((v) => v + 1); setPicked(null); }} />
|
|
1213
|
+
</div>
|
|
1214
|
+
) : null}
|
|
1215
|
+
{view === 'history' ? <HistoryView name={name} token={token} sets={sets} changes={records.length} mirrored={mirrored} required={rules.state === 'ok' ? rules.data.changesets.approvals ?? 1 : 1} {...(sub ? { sub } : {})} signsAs={signsAs} canWrite={canWrite} rooted={rooted} refused={refused} onChanged={() => setVersion((v) => v + 1)} /> : null}
|
|
1216
|
+
{view === 'events' ? <EventsView key={arg ?? ''} name={name} token={token} vendors={vendors} {...(arg && vendors.includes(arg) ? { vendor: arg } : {})} tick={tick} /> : null}
|
|
1217
|
+
{view === 'trace' ? <TraceView name={name} token={token} id={arg ?? ''} /> : null}
|
|
1218
|
+
{view === 'vendor' && arg ? <VendorView key={arg} name={name} token={token} vendor={arg} mirrored={mirrored.includes(arg)} tick={tick} /> : null}
|
|
1219
|
+
{view === 'screen' && arg ? <ScreenView key={arg} name={name} vendor={arg} {...(sub ? { show: sub } : {})} holding={holds(map.state === 'ok' ? map.data.twins.find((x) => x.twin === arg)?.resources ?? [] : [])} session={session} retry={retry} /> : null}
|
|
1220
|
+
</div>
|
|
1221
|
+
</div>
|
|
1222
|
+
|
|
1223
|
+
<Dialog open={win?.kind === 'settings'} onClose={() => to(['changes'])()} title="Settings" wide="window">
|
|
1224
|
+
{win?.kind === 'settings' ? (
|
|
1225
|
+
<div className="ws-settings">
|
|
1226
|
+
<nav aria-label="Settings">{SETTINGS_TABS.map(([k, label, icon]) => <a key={k} href={`${BASE}/${name}/~/settings/${k}`} className={win.tab === k ? 'on' : undefined} aria-current={win.tab === k ? 'page' : undefined} onClick={to(['settings', k])}><Icon name={icon} size={16} />{label}</a>)}</nav>
|
|
1227
|
+
<div className="ws-settings-pane">
|
|
1228
|
+
{scope === 'read' ? <p className="mode"><span className="badge read-only">Read only</span> This session reads the World; it changes nothing.</p> : null}
|
|
1229
|
+
{win.tab === 'account' ? <AccountPane who={who} scope={scope} snapshot={snapshot} portal={portal()} onSignOut={onSignOut} /> : null}
|
|
1230
|
+
{win.tab === 'changesets' ? <ChangesetsPane name={name} token={token} who={who} scope={scope} /> : null}
|
|
1231
|
+
{win.tab === 'remote' ? <Remote name={name} token={token} canWrite={canWrite} tick={tick} refused={refused} /> : null}
|
|
1232
|
+
{win.tab === 'access' || win.tab === 'services' || win.tab === 'checks' || win.tab === 'clock' ? <Settings name={name} token={token} admin={admin} canWrite={canWrite} refused={refused} onRemoved={onRemoved} only={win.tab} /> : null}
|
|
1233
|
+
{win.tab === 'vault' ? <Vault name={name} token={token} canWrite={canWrite} refused={refused} /> : null}
|
|
1234
|
+
{win.tab === 'appearance' ? <><h3 className="settings-head">Appearance</h3><ThemeChoice /></> : null}
|
|
1235
|
+
</div>
|
|
1236
|
+
</div>
|
|
1237
|
+
) : null}
|
|
1238
|
+
</Dialog>
|
|
1239
|
+
<Dialog open={win?.kind === 'branches'} onClose={() => to(['changes'])()} title="Branches" wide>
|
|
1240
|
+
{win?.kind === 'branches' ? <Branches name={name} token={token} canWrite={canWrite} branches={branches} opens={opens} {...(win.open ? { open: win.open } : {})} onChanged={onClock} refused={refused} /> : null}
|
|
1241
|
+
</Dialog>
|
|
771
1242
|
</div>
|
|
772
1243
|
);
|
|
773
1244
|
}
|
|
774
1245
|
|
|
1246
|
+
/** Connect's last line: the setup as a prompt for the person's coding agent (packages/cli/src/agent-prompt.ts, the one
|
|
1247
|
+
* text), copied whole. */
|
|
1248
|
+
function CopyAgentPrompt({ text }: { text: string }): React.JSX.Element {
|
|
1249
|
+
const copy = (): void => { void navigator.clipboard?.writeText(text).then(() => toast('Copied the prompt: paste it into your coding agent'), () => toast('Could not copy the prompt', 'error')); };
|
|
1250
|
+
return <button type="button" className="agent-prompt-button" onClick={copy}>Copy a prompt for your coding agent</button>;
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
|
|
1254
|
+
|
|
1255
|
+
/** The World's origin as its door answers it: the World this one pushes to and pulls from, or none. */
|
|
1256
|
+
type OriginDoor = { origin: { url: string; namespace: string; clonedAt?: string; fetchedAt?: string } | null };
|
|
1257
|
+
/** The origin's address, as `volter remote add` names it: `<host>/<org>/<world>`. */
|
|
1258
|
+
const originAddress = (o: NonNullable<OriginDoor['origin']>): string => `${o.url.replace(/\/+$/, '')}/${o.namespace}`;
|
|
1259
|
+
/** The changesets a push would send: never sent, or refused (the push door's own queue). */
|
|
1260
|
+
const unpushed = (cs: ChangesetView[]): ChangesetView[] => cs.filter((c) => c.applied === null || c.applied.outcome === 'refused');
|
|
1261
|
+
/** What a vendor holds, in words: its resource counts, the largest first. */
|
|
1262
|
+
const holds = (resources: Array<{ type: string; count: number }>, most = 4): string => {
|
|
1263
|
+
const top = [...resources].filter((r) => r.count > 0).sort((a, b) => b.count - a.count);
|
|
1264
|
+
return top.length ? top.slice(0, most).map((r) => counted(r.count, r.type.replace(/^_+/, ''))).join(' · ') + (top.length > most ? ` · +${top.length - most} more` : '') : 'nothing yet';
|
|
1265
|
+
};
|
|
1266
|
+
const BRANCH_GLYPH = <svg className="branch-glyph" viewBox="0 0 16 16" width="14" height="14" aria-hidden="true"><path fill="currentColor" d="M5 3.25a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Zm0 2.122a2.25 2.25 0 1 0-1.5 0v5.256a2.25 2.25 0 1 0 1.5 0V9.5h4.25A2.25 2.25 0 0 0 11.5 7.25v-.878a2.25 2.25 0 1 0-1.5 0v.878a.75.75 0 0 1-.75.75H5V5.372ZM4.25 12a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Zm6.5-9.5a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Z" /></svg>;
|
|
1267
|
+
|
|
1268
|
+
|
|
775
1269
|
|
|
776
1270
|
/** The World's clock, like a test clock's simulation banner: its time, and for a write token the controls that move it. */
|
|
777
|
-
function ClockBar({ name, token, clock, canWrite, onChanged, refused }: { name: string; token: string; clock: Load<Clock>; canWrite: boolean; onChanged: () => void; refused: (r: Response) => void }): React.JSX.Element {
|
|
1271
|
+
function ClockBar({ name, token, clock, canWrite, onChanged, refused, open: inline }: { name: string; token: string; clock: Load<Clock>; canWrite: boolean; onChanged: () => void; refused: (r: Response) => void; /** Settings' Clock: the controls laid out, not behind a disclosure */ open?: boolean }): React.JSX.Element {
|
|
778
1272
|
const [setting, setSetting] = useState(false); const [value, setValue] = useState(''); const [by, setBy] = useState('');
|
|
779
1273
|
const [note, setNote] = useState<{ ok: boolean; text: string } | null>(null); const [busy, setBusy] = useState(false);
|
|
780
1274
|
const now = clock.state === 'ok' ? clock.data : null;
|
|
@@ -804,8 +1298,8 @@ function ClockBar({ name, token, clock, canWrite, onChanged, refused }: { name:
|
|
|
804
1298
|
: <>World time <strong><At iso={now!.at} /></strong> <span className="mode">· real time</span></>}
|
|
805
1299
|
</p>
|
|
806
1300
|
{canWrite && now ? (
|
|
807
|
-
<details className=
|
|
808
|
-
<p className="mode">Move this World's clock forward to see what happens later: renewals, expiries, scheduled sends. Like a test clock, it only moves forward; to look back,
|
|
1301
|
+
<details className={inline ? 'clock-more inline' : 'clock-more'} open={inline || undefined}><summary>Simulate time…</summary><div className={inline ? undefined : 'popover'}>
|
|
1302
|
+
<p className="mode">Move this World's clock forward to see what happens later: renewals, expiries, scheduled sends. Like a test clock, it only moves forward; to look back, the branch picker’s Manage branches… views an earlier moment.</p>
|
|
809
1303
|
<div className="clock-controls">
|
|
810
1304
|
<button type="button" aria-expanded={setting} onClick={() => { setSetting(!setting); setValue(localInput(nextMinute(now.at))); setNote(null); }}>Set time…</button>
|
|
811
1305
|
<div role="group" aria-label="Advance the clock" className="advance">
|
|
@@ -879,440 +1373,314 @@ function AsOf({ name, token, clock, refused }: { name: string; token: string; cl
|
|
|
879
1373
|
);
|
|
880
1374
|
}
|
|
881
1375
|
|
|
882
|
-
/**
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
const r = await door(token, `/-/${name}/branches/${b}`, { method: 'DELETE' }).catch((e: unknown) => e as Error);
|
|
889
|
-
if (r instanceof Error) { setNote(`delete ${b}: the World did not answer (${r.message})`); return; }
|
|
890
|
-
if (!r.ok) { refused(r); setNote(`delete ${b}: ${await failure(r)}`); return; }
|
|
891
|
-
forgetSnapshot(b); setNote(`deleted ${b}`); onBranchesChanged();
|
|
892
|
-
};
|
|
893
|
-
const total = map.state === 'ok' ? map.data.twins.reduce((n, t) => n + t.resources.reduce((m, x) => m + x.count, 0), 0) : 0;
|
|
894
|
-
// the Branches tab: this World's copies; what it holds per vendor is the Vendors tab's list (kept here for a host
|
|
895
|
-
// without branches, where this tab is not shown)
|
|
896
|
-
if (branches) return <BranchList name={name} token={token} canWrite={canWrite} branches={branches} opens={opens} note={note} remove={remove} onChanged={onBranchesChanged} refused={refused} />;
|
|
897
|
-
return (
|
|
898
|
-
<>
|
|
899
|
-
<h3>Map</h3>
|
|
900
|
-
<Pending load={map} what="the World's map" />
|
|
901
|
-
{map.state === 'ok' ? (map.data.twins.length === 0 ? <p>This World holds no twins.</p> : (
|
|
902
|
-
<>
|
|
903
|
-
<p className="mode">{map.data.twins.length} twin{map.data.twins.length === 1 ? '' : 's'} · {total} resource{total === 1 ? '' : 's'}</p>
|
|
904
|
-
<ul className="map" aria-label="Twins in this World">
|
|
905
|
-
{map.data.twins.map((t) => {
|
|
906
|
-
const top = [...t.resources].sort((a, b) => b.count - a.count);
|
|
907
|
-
return (
|
|
908
|
-
<li key={t.twin} className="card" aria-labelledby={`map-${t.twin}`}>
|
|
909
|
-
<div className="card-head">
|
|
910
|
-
<h4 id={`map-${t.twin}`} className="vendor">{t.twin}</h4>
|
|
911
|
-
<span className={t.root ? 'badge rooted' : 'badge'}>{t.root ? 'rooted' : 'simulated'}</span>
|
|
912
|
-
</div>
|
|
913
|
-
<p className="mode">{t.root ? <>rooted at <code>{t.root.url}</code></> : 'simulated — no vendor behind it'} · position {t.position}</p>
|
|
914
|
-
{top.length === 0 ? <p className="mode">Holds nothing yet.</p> : (
|
|
915
|
-
<ul className="resources">{top.slice(0, 6).map((r) => <li key={r.type}><span>{r.type}</span> <strong>{r.count}</strong></li>)}{top.length > 6 ? <li className="mode">+{top.length - 6} more types</li> : null}</ul>
|
|
916
|
-
)}
|
|
917
|
-
<p className="card-actions">
|
|
918
|
-
{t.mirror ? <a className="button" href={mirrorUrl(name, t.twin)} target="_blank" rel="noopener noreferrer">Open {t.twin}</a> : <span className="mode">no mirror</span>}
|
|
919
|
-
<a href={`${BASE}/${name}/${t.twin}`} aria-label={`Log and tree of ${t.twin}`} onClick={(e) => { e.preventDefault(); go([...name.split('/'), t.twin]); }}>Log & tree</a>
|
|
920
|
-
</p>
|
|
921
|
-
</li>
|
|
922
|
-
);
|
|
923
|
-
})}
|
|
924
|
-
</ul>
|
|
925
|
-
</>
|
|
926
|
-
)) : null}
|
|
927
|
-
</>
|
|
928
|
-
);
|
|
929
|
-
}
|
|
930
|
-
|
|
931
|
-
/** A World's branches, as a database's are (Neon's Branches page): each a copy of it as of a moment, made here (as of now
|
|
932
|
-
* or a moment you pick, for as long as you say), opened read-only in this tab, compared with its parent (what it changed
|
|
933
|
-
* since it branched), reset to its parent as it is now, or removed. Each is the World's own branches door. */
|
|
934
|
-
function BranchList({ name, token, canWrite, branches, opens, note, remove, onChanged, refused }: { name: string; token: string; canWrite: boolean; branches: Load<BranchesDoor>; opens: (name: string) => boolean; note: string | null; remove: (b: string) => Promise<void>; onChanged: () => void; refused: (r: Response) => void }): React.JSX.Element {
|
|
1376
|
+
/** THE BRANCHES, as Neon's Branches page is: one table of every branch (the World's own, the one served here marked,
|
|
1377
|
+
* and the host's copies of it), each row opening its page: what it is, its own changes, and its acts. "New branch"
|
|
1378
|
+
* makes one as of now or of a moment before it. Each is the World's own branches door. */
|
|
1379
|
+
/** New branch: from now (live, or stopped now) or as the World was at an instant, for how long; made, it is kept with
|
|
1380
|
+
* its tokens so it opens in this tab (the Branches page's, and the workspace's branch picker's). */
|
|
1381
|
+
function NewBranch({ name, token, open, onClose, onMade, refused }: { name: string; token: string; open: boolean; onClose: () => void; onMade: () => void; refused: (r: Response) => void }): React.JSX.Element {
|
|
935
1382
|
const short = (n: string): string => n.split('/').pop() ?? n;
|
|
936
|
-
const
|
|
937
|
-
const [
|
|
938
|
-
|
|
939
|
-
// SERVE ANOTHER of the World's own branches here: the World switches under this page, which then reads it afresh
|
|
940
|
-
const [serving, setServing] = useState<string | null>(null); const [serveError, setServeError] = useState<string | null>(null);
|
|
941
|
-
const serveHere = async (branch: string): Promise<void> => {
|
|
942
|
-
setServing(branch); setServeError(null);
|
|
943
|
-
const r = await door(token, `/-/${name}/branches/${encodeURIComponent(branch)}/serve`, { method: 'POST' }).catch(() => null);
|
|
944
|
-
if (r?.ok) { location.reload(); return; }
|
|
945
|
-
setServing(null);
|
|
946
|
-
setServeError(r ? ((await r.json().catch(() => null)) as { error?: string } | null)?.error ?? `${r.status}` : `${ORIGIN} did not answer`);
|
|
947
|
-
};
|
|
948
|
-
// made here: kept with its tokens so it opens in this tab; not `made` (a snapshot's "Return to live" never removes it: it lives as long as it was given)
|
|
949
|
-
const make = async (body: Record<string, unknown>, opened: string): Promise<{ name: string } | string> => {
|
|
950
|
-
const r = await door(token, `/-/${name}/branches`, { method: 'POST', body: JSON.stringify(body) }).catch(() => null);
|
|
951
|
-
if (!r) return `${ORIGIN} did not answer`;
|
|
952
|
-
if (!r.ok) { refused(r); return await failure(r); }
|
|
953
|
-
const b = (await r.json()) as { name: string; token?: string; readToken: string; expiresAt: string | null; origin?: string };
|
|
954
|
-
keepSnapshot({ name: b.name, parent: name, token: b.token ?? '', readToken: b.readToken, at: opened, expiresAt: b.expiresAt ?? null, parentOrigin: ORIGIN, ...(b.origin ? { origin: b.origin } : {}) });
|
|
955
|
-
return b;
|
|
956
|
-
};
|
|
1383
|
+
const [busy, setBusy] = useState(false); const [error, setError] = useState<string | null>(null);
|
|
1384
|
+
const [label, setLabel] = useState(''); const [when, setWhen] = useState<'now' | 'at'>('now'); const [at, setAt] = useState(''); const [hours, setHours] = useState('24'); const [live, setLive] = useState(true);
|
|
1385
|
+
useEffect(() => { if (open) { setError(null); setAt(localInput(new Date().toISOString())); } }, [open]);
|
|
957
1386
|
const create = async (e: React.FormEvent): Promise<void> => {
|
|
958
1387
|
e.preventDefault(); setError(null);
|
|
959
1388
|
const instant = when === 'at' ? new Date(at) : null;
|
|
960
1389
|
if (instant && Number.isNaN(instant.getTime())) { setError('Pick a date and time, or make it as of now.'); return; }
|
|
961
1390
|
if (instant && instant.getTime() > Date.now()) { setError('A branch is made as of now or a moment before it.'); return; }
|
|
962
|
-
const ttl = Math.round(Number(hours) * 3600);
|
|
963
1391
|
setBusy(true);
|
|
964
|
-
const
|
|
1392
|
+
const r = await door(token, `/-/${name}/branches`, { method: 'POST', body: JSON.stringify({ ttl: Math.round(Number(hours) * 3600), ...(instant ? { at: { instant: instant.toISOString() } } : live ? { live: true } : {}), ...(label.trim() ? { label: label.trim() } : {}) }) }).catch(() => null);
|
|
965
1393
|
setBusy(false);
|
|
966
|
-
if (
|
|
967
|
-
|
|
1394
|
+
if (!r) { setError(`${ORIGIN} did not answer`); return; }
|
|
1395
|
+
if (!r.ok) { refused(r); setError(await failure(r)); return; }
|
|
1396
|
+
// made here: kept with its tokens so it opens in this tab, and its own changes read with them
|
|
1397
|
+
const b = (await r.json()) as { name: string; token?: string; readToken: string; expiresAt: string | null; origin?: string };
|
|
1398
|
+
keepSnapshot({ name: b.name, parent: name, token: b.token ?? '', readToken: b.readToken, at: (instant ?? new Date()).toISOString(), expiresAt: b.expiresAt ?? null, parentOrigin: ORIGIN, ...(b.origin ? { origin: b.origin } : {}) });
|
|
1399
|
+
onClose(); setLabel(''); toast(`Made ${short(b.name)}`); onMade();
|
|
968
1400
|
};
|
|
969
|
-
const
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
1401
|
+
const dialog = (
|
|
1402
|
+
<Dialog open={open} onClose={() => { if (!busy) onClose(); }} title={`New branch of ${short(name)}`} footer={<><button type="button" onClick={() => onClose()} disabled={busy}>Cancel</button><button type="submit" form="new-branch" className="primary" disabled={busy}>{busy ? 'Making…' : 'Make branch'}</button></>}>
|
|
1403
|
+
<form id="new-branch" onSubmit={(e) => void create(e)} className="stack">
|
|
1404
|
+
<label><span>Label <span className="mode">(optional)</span></span><input name="branchLabel" value={label} onChange={(e) => setLabel(e.target.value.toLowerCase())} placeholder="checkout-flow" pattern="[a-z0-9][a-z0-9\-]{0,23}" maxLength={24} /></label>
|
|
1405
|
+
<fieldset className="choice"><legend>From</legend>
|
|
1406
|
+
<label className="check"><input type="radio" name="branchWhen" checked={when === 'now'} onChange={() => setWhen('now')} /> <span>Now: the World as it is</span></label>
|
|
1407
|
+
{when === 'now' ? <label className="check sub"><input type="checkbox" name="branchLive" checked={live} onChange={(e) => setLive(e.target.checked)} /> <span>Live: its clock keeps this World's time, as a pull request's preview does (else it stops now)</span></label> : null}
|
|
1408
|
+
<label className="check"><input type="radio" name="branchWhen" checked={when === 'at'} onChange={() => setWhen('at')} /> <span>An instant before now: the World as it was then, its clock stopped there</span></label>
|
|
1409
|
+
{when === 'at' ? <input type="datetime-local" aria-label="As of" value={at} max={localInput(new Date().toISOString())} onChange={(e) => setAt(e.target.value)} /> : null}
|
|
1410
|
+
</fieldset>
|
|
1411
|
+
<label><span>Goes away after</span><select name="branchLife" value={hours} onChange={(e) => setHours(e.target.value)}><option value="1">1 hour</option><option value="24">1 day</option><option value="72">3 days</option><option value="168">7 days</option></select></label>
|
|
1412
|
+
{error ? <p role="alert">{error}</p> : null}
|
|
1413
|
+
</form>
|
|
1414
|
+
</Dialog>
|
|
1415
|
+
);
|
|
1416
|
+
return dialog;
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
function Branches({ name, token, canWrite, branches, opens, open: opened, onChanged, refused, snapshotOf }: { name: string; token: string; canWrite: boolean; branches: Load<BranchesDoor> | null; opens: (name: string) => boolean; open?: string; onChanged: () => void; refused: (r: Response) => void; /** the World this one is a snapshot of: a snapshot is not branched again */ snapshotOf?: string }): React.JSX.Element {
|
|
1420
|
+
const clock = useDoor<Clock>(token, `/-/${name}/clock`);
|
|
1421
|
+
const short = (n: string): string => n.split('/').pop() ?? n;
|
|
1422
|
+
const [making, setMaking] = useState(false);
|
|
1423
|
+
// a host's branch is routed by its full name (`<org>/<world>`), one of the World's own by its name: the two never collide
|
|
1424
|
+
const page = (b?: string) => (e: React.MouseEvent): void => { e.preventDefault(); go([...name.split('/'), '~', 'branches', ...(b ? b.split('/') : [])]); };
|
|
1425
|
+
if (!branches) return <p className="mode">This host makes no branches of a World.</p>;
|
|
1426
|
+
const list = branches.state === 'ok' ? branches.data.branches : [];
|
|
1427
|
+
const named = branches.state === 'ok' && (branches.data.named?.length ?? 0) > 0 ? branches.data.named! : [{ name: short(name), running: true }];
|
|
1428
|
+
const served = branches.state === 'ok' ? branches.data.checkedOut ?? short(name) : short(name);
|
|
1429
|
+
const remove = async (b: string): Promise<void> => {
|
|
1430
|
+
if (!(await confirmAct({ title: `Remove the branch ${short(b)}?`, body: 'Whoever holds its tokens loses it, and its history goes with it.', action: 'Remove branch', danger: true }))) return;
|
|
1431
|
+
const r = await door(token, `/-/${name}/branches/${b}`, { method: 'DELETE' }).catch((e: unknown) => e as Error);
|
|
1432
|
+
if (r instanceof Error) { toast(`Remove ${short(b)}: the World did not answer (${r.message})`, 'error'); return; }
|
|
1433
|
+
if (!r.ok) { refused(r); toast(`Remove ${short(b)}: ${await failure(r)}`, 'error'); return; }
|
|
1434
|
+
forgetSnapshot(b); toast(`Removed ${short(b)}`); onChanged(); go([...name.split('/'), '~', 'branches']);
|
|
980
1435
|
};
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
1436
|
+
if (opened) {
|
|
1437
|
+
const copy = opened.includes('/') ? list.find((b) => b.name === opened) : undefined;
|
|
1438
|
+
const own = opened.includes('/') ? undefined : named.find((b) => b.name === opened);
|
|
1439
|
+
return (
|
|
1440
|
+
<>
|
|
1441
|
+
<p className="crumbs-line"><a href={`${BASE}/${name}/~/branches`} onClick={page()}>← Branches</a></p>
|
|
1442
|
+
<Pending load={branches} what="the branches" />
|
|
1443
|
+
{branches.state === 'ok' && !copy && !own ? <p className="mode">This World has no branch named {opened}.</p> : null}
|
|
1444
|
+
{copy ? <BranchPage name={name} b={copy} canWrite={canWrite} opens={opens(copy.name)} remove={() => void remove(copy.name)} /> : null}
|
|
1445
|
+
{own && !copy ? <OwnBranchPage name={name} token={token} b={own} served={own.name === served} canWrite={canWrite} canServe={branches.state === 'ok' && Boolean(branches.data.canServe)} /> : null}
|
|
1446
|
+
</>
|
|
1447
|
+
);
|
|
1448
|
+
}
|
|
985
1449
|
return (
|
|
986
1450
|
<div aria-labelledby="branches">
|
|
987
1451
|
<div className="branches-head">
|
|
988
|
-
<p className="lede">Branch this World to look at it as it was, or to change it without touching <b>{
|
|
989
|
-
{canWrite ? <button type="button" className="primary" onClick={() =>
|
|
1452
|
+
<p className="lede">Branch this World to look at it as it was, or to change it without touching <b>{served}</b>. Each branch's name opens its page.</p>
|
|
1453
|
+
{canWrite ? <div className="head-actions">{snapshotOf ? null : <AsOf name={name} token={token} clock={clock} refused={refused} />}<button type="button" className="primary" onClick={() => setMaking(true)}>New branch</button></div> : null}
|
|
990
1454
|
</div>
|
|
991
|
-
{note ? <p role="status">{note}</p> : null}
|
|
992
1455
|
<Pending load={branches} what="the branches" />
|
|
993
1456
|
{branches.state === 'ok' ? (
|
|
994
1457
|
<div className="block">
|
|
995
1458
|
<table className="dense" aria-label="Branches">
|
|
996
|
-
<thead><tr><th id="branches">Branch</th><th>Parent</th><th>As of</th><th>Made</th><th>Goes away</th
|
|
1459
|
+
<thead><tr><th id="branches">Branch</th><th>Parent</th><th>As of</th><th>Made</th><th>Goes away</th></tr></thead>
|
|
997
1460
|
<tbody>
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
<td>{b.at?.instant ? <At iso={b.at.instant} /> : 'when it was made'}</td>
|
|
1006
|
-
<td><At iso={b.createdAt} /></td>
|
|
1007
|
-
<td>{b.expiresAt ? <At iso={b.expiresAt} /> : 'kept'}</td>
|
|
1008
|
-
<td className="num row-actions">
|
|
1009
|
-
{opens(b.name) ? <button type="button" aria-label={`Open ${b.name}`} onClick={() => goBranch(b.name, b.name.split('/'))}>Open</button> : <span className="mode" title="Its token was handed to whoever made it">opened by whoever made it</span>}
|
|
1010
|
-
{items.length ? <Menu label={`More for ${short(b.name)}`} items={items} /> : null}
|
|
1011
|
-
</td>
|
|
1012
|
-
</tr>
|
|
1013
|
-
);
|
|
1014
|
-
})}
|
|
1015
|
-
</tbody>
|
|
1016
|
-
</table>
|
|
1017
|
-
{list.length === 0 ? <p className="mode pad">No snapshots yet.</p> : null}
|
|
1018
|
-
</div>
|
|
1019
|
-
) : null}
|
|
1020
|
-
{branches.state === 'ok' && (branches.data.named?.length ?? 0) > 0 ? (
|
|
1021
|
-
<>
|
|
1022
|
-
<p className="lede">This World's own branches (<code>volter world branch</code>). One runs at a time; {branches.data.canServe ? <>“Serve here” stops this one's twins and serves that one in its place.</> : <>this host serves one of them; another is served by <code>volter world checkout <branch></code> where the World lives, then serving again.</>}</p>
|
|
1023
|
-
{serveError ? <p role="alert">{serveError}</p> : null}
|
|
1024
|
-
<div className="block">
|
|
1025
|
-
<table className="dense" aria-label="World branches">
|
|
1026
|
-
<thead><tr><th>Branch</th><th>State</th><th /></tr></thead>
|
|
1027
|
-
<tbody>
|
|
1028
|
-
{branches.data.named!.map((b) => (
|
|
1461
|
+
{named.map((b) => (
|
|
1462
|
+
<tr key={`own:${b.name}`}>
|
|
1463
|
+
<td><a href={`${BASE}/${name}/~/branches/${b.name}`} onClick={page(b.name)}><b>{b.name}</b></a>{b.name === served ? <> <span className="badge live">served here</span></> : b.running ? null : <> <span className="badge">stopped</span></>}</td>
|
|
1464
|
+
<td className="mode">—</td><td className="mode">live</td><td className="mode">—</td><td className="mode">kept</td>
|
|
1465
|
+
</tr>
|
|
1466
|
+
))}
|
|
1467
|
+
{list.map((b) => (
|
|
1029
1468
|
<tr key={b.name}>
|
|
1030
|
-
<td><b
|
|
1031
|
-
<td
|
|
1032
|
-
<td
|
|
1469
|
+
<td><a href={`${BASE}/${name}/~/branches/${b.name}`} onClick={page(b.name)}><b>{short(b.name)}</b></a></td>
|
|
1470
|
+
<td>{short(b.from ?? name)}</td>
|
|
1471
|
+
<td>{b.at?.instant ? <At iso={b.at.instant} /> : <span className="mode">live</span>}</td>
|
|
1472
|
+
<td><At iso={b.createdAt} /></td>
|
|
1473
|
+
<td>{b.expiresAt ? <At iso={b.expiresAt} /> : 'kept'}</td>
|
|
1033
1474
|
</tr>
|
|
1034
1475
|
))}
|
|
1035
1476
|
</tbody>
|
|
1036
1477
|
</table>
|
|
1037
1478
|
</div>
|
|
1038
|
-
</>
|
|
1039
1479
|
) : null}
|
|
1040
|
-
<
|
|
1041
|
-
<form id="new-branch" onSubmit={(e) => void create(e)} className="stack">
|
|
1042
|
-
<label><span>Name <span className="mode">(optional)</span></span><input name="branchLabel" value={label} onChange={(e) => setLabel(e.target.value.toLowerCase())} placeholder="checkout-flow" pattern="[a-z0-9][a-z0-9-]{0,23}" maxLength={24} /></label>
|
|
1043
|
-
<fieldset className="choice"><legend>As of</legend>
|
|
1044
|
-
<label className="check"><input type="radio" name="branchWhen" checked={when === 'now'} onChange={() => setWhen('now')} /> <span>Now: the World as it is, its clock running on</span></label>
|
|
1045
|
-
<label className="check"><input type="radio" name="branchWhen" checked={when === 'at'} onChange={() => setWhen('at')} /> <span>A moment before now: the World as it was then, its clock stopped there</span></label>
|
|
1046
|
-
{when === 'at' ? <input type="datetime-local" aria-label="As of" value={at} max={localInput(new Date().toISOString())} onChange={(e) => setAt(e.target.value)} /> : null}
|
|
1047
|
-
</fieldset>
|
|
1048
|
-
<label><span>Goes away after</span><select name="branchLife" value={hours} onChange={(e) => setHours(e.target.value)}><option value="1">1 hour</option><option value="24">1 day</option><option value="72">3 days</option><option value="168">7 days</option></select></label>
|
|
1049
|
-
{error ? <p role="alert">{error}</p> : null}
|
|
1050
|
-
</form>
|
|
1051
|
-
</Dialog>
|
|
1480
|
+
<NewBranch name={name} token={token} open={making} onClose={() => setMaking(false)} onMade={onChanged} refused={refused} />
|
|
1052
1481
|
</div>
|
|
1053
1482
|
);
|
|
1054
1483
|
}
|
|
1055
1484
|
|
|
1056
|
-
/**
|
|
1057
|
-
*
|
|
1058
|
-
function
|
|
1059
|
-
const
|
|
1060
|
-
const
|
|
1061
|
-
const
|
|
1062
|
-
const t = map.state === 'ok' ? map.data.twins.find((x) => x.twin === v) : undefined;
|
|
1063
|
-
if (!t) return '';
|
|
1064
|
-
const top = [...t.resources].filter((r) => r.count > 0).sort((a, b) => b.count - a.count);
|
|
1065
|
-
return top.length === 0 ? 'nothing yet' : top.slice(0, 3).map((r) => counted(r.count, r.type)).join(' · ') + (top.length > 3 ? ' · …' : '');
|
|
1066
|
-
};
|
|
1067
|
-
const rootOf = (v: string): string | null => (map.state === 'ok' ? map.data.twins.find((x) => x.twin === v)?.root?.url ?? null : null);
|
|
1068
|
-
const chosen = list.find((t) => t.twin === selected);
|
|
1069
|
-
if (!chosen) return twins.state === 'ok' ? <p className="mode">This World has no vendor named {selected}. <a href={`${BASE}/${name}/~/canvas`} onClick={(e) => { e.preventDefault(); go([...name.split('/'), '~', 'canvas']); }}>All screens</a></p> : <Pending load={twins} what="the vendors" />;
|
|
1485
|
+
/** One of the host's branches of this World (Neon's branch page): what it is, its own changes since it branched (read
|
|
1486
|
+
* with its own token, which only the tab that made it holds), and its acts. */
|
|
1487
|
+
function BranchPage({ name, b, canWrite, opens, remove }: { name: string; b: Branch; canWrite: boolean; opens: boolean; remove: () => void }): React.JSX.Element {
|
|
1488
|
+
const short = (n: string): string => n.split('/').pop() ?? n;
|
|
1489
|
+
const own = snapshotToken(b.name);
|
|
1490
|
+
const diff = useDoor<Diff>(own, own ? `/-/${b.name}/diff` : null);
|
|
1070
1491
|
return (
|
|
1071
|
-
|
|
1072
|
-
<
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
<
|
|
1077
|
-
<div className="stage-bar">
|
|
1078
|
-
<Mark vendor={chosen.twin} />
|
|
1079
|
-
<span className="mode">{held(chosen.twin) || 'empty'}{rootOf(chosen.twin) ? ` · connected to ${rootOf(chosen.twin)}` : ' · stand-in'}</span>
|
|
1080
|
-
<span className="stage-actions">
|
|
1081
|
-
{list.filter((t) => t.mirror).length > 1 ? <a className="button" href={`${BASE}/${name}/~/vendors`} onClick={(e) => { e.preventDefault(); go([...name.split('/'), '~', 'vendors']); }}>All vendors</a> : null}
|
|
1082
|
-
{chosen.mirror ? <a className="button" href={mirrorUrl(name, chosen.twin)} target="_blank" rel="noopener noreferrer" aria-label={`Open ${vendorName(chosen.twin)} in a new tab`}>Open in new tab ↗</a> : null}
|
|
1083
|
-
</span>
|
|
1084
|
-
</div>
|
|
1085
|
-
{session.state === 'ok' && session.scope === 'read' ? <p className="mode stage-note">You are looking read only: the screens browse, and refuse changes.</p> : null}
|
|
1086
|
-
{session.state === 'pending' ? <p className="mode" role="status">Opening…</p> : null}
|
|
1087
|
-
{session.state === 'error' ? <p role="alert">The World did not open its screens: {session.error} <button type="button" onClick={retry}>Try again</button></p> : null}
|
|
1088
|
-
{session.state === 'ok' ? (chosen.mirror
|
|
1089
|
-
? <iframe key={chosen.twin} title={`${chosen.twin} mirror`} src={mirrorUrl(name, chosen.twin)} />
|
|
1090
|
-
: <p className="empty">{vendorName(chosen.twin)} has no screens here yet. What your app did there is in <a href={`${BASE}/${name}/~/activity`} onClick={(e) => { e.preventDefault(); go([...name.split('/'), '~', 'activity']); }}>Activity</a>.</p>) : null}
|
|
1091
|
-
</div>
|
|
1492
|
+
<div className="branch-page">
|
|
1493
|
+
<div className="changeset-head">
|
|
1494
|
+
<h3>{BRANCH_GLYPH} {short(b.name)}</h3>
|
|
1495
|
+
<div className="changeset-actions">
|
|
1496
|
+
{opens ? <button type="button" className="primary" onClick={() => goBranch(b.name, b.name.split('/'))}>Open</button> : null}
|
|
1497
|
+
{canWrite ? <button type="button" className="danger-button" onClick={remove}>Remove…</button> : null}
|
|
1092
1498
|
</div>
|
|
1499
|
+
</div>
|
|
1500
|
+
<div className="block">
|
|
1501
|
+
<dl className="fields pad">
|
|
1502
|
+
<dt>Name</dt><dd><code>{b.name}</code></dd>
|
|
1503
|
+
<dt>Parent</dt><dd>{short(b.from ?? name)}</dd>
|
|
1504
|
+
<dt>As of</dt><dd>{b.at?.instant ? <At iso={b.at.instant} /> : 'live: it keeps its parent\'s time'}</dd>
|
|
1505
|
+
<dt>Made</dt><dd><At iso={b.createdAt} /></dd>
|
|
1506
|
+
<dt>Goes away</dt><dd>{b.expiresAt ? <At iso={b.expiresAt} /> : 'kept'}</dd>
|
|
1507
|
+
</dl>
|
|
1508
|
+
</div>
|
|
1509
|
+
{!opens ? <p className="mode">Its tokens were handed to whoever made it: it opens, and shows its own changes, in their tab.</p> : null}
|
|
1510
|
+
{diff.state === 'ok' ? (
|
|
1511
|
+
<>
|
|
1512
|
+
<h4>Its changes</h4>
|
|
1513
|
+
{diff.data.changes.length === 0 ? <p className="mode">Nothing written on it since it branched.</p> : <RecordChanges changes={diff.data.changes} idPrefix={`branch-${short(b.name)}`} />}
|
|
1514
|
+
</>
|
|
1093
1515
|
) : null}
|
|
1094
|
-
|
|
1516
|
+
</div>
|
|
1095
1517
|
);
|
|
1096
1518
|
}
|
|
1097
1519
|
|
|
1098
|
-
/**
|
|
1099
|
-
function
|
|
1100
|
-
const
|
|
1101
|
-
|
|
1102
|
-
|
|
1520
|
+
/** One of the World's own branches (`volter world branch`): whether it runs, and serving it here. */
|
|
1521
|
+
function OwnBranchPage({ name, token, b, served, canWrite, canServe }: { name: string; token: string; b: { name: string; running: boolean }; served: boolean; canWrite: boolean; canServe: boolean }): React.JSX.Element {
|
|
1522
|
+
const [serving, setServing] = useState(false); const [error, setError] = useState<string | null>(null);
|
|
1523
|
+
const serveHere = async (): Promise<void> => {
|
|
1524
|
+
setServing(true); setError(null);
|
|
1525
|
+
const r = await door(token, `/-/${name}/branches/${encodeURIComponent(b.name)}/serve`, { method: 'POST' }).catch(() => null);
|
|
1526
|
+
if (r?.ok) { location.reload(); return; }
|
|
1527
|
+
setServing(false);
|
|
1528
|
+
setError(r ? await failure(r) : `${ORIGIN} did not answer`);
|
|
1529
|
+
};
|
|
1530
|
+
return (
|
|
1531
|
+
<div className="branch-page">
|
|
1532
|
+
<div className="changeset-head">
|
|
1533
|
+
<h3>{BRANCH_GLYPH} {b.name} {served ? <span className="badge live">served here</span> : null}</h3>
|
|
1534
|
+
<div className="changeset-actions">{canWrite && canServe && !served ? <button type="button" className="primary" disabled={serving} onClick={() => void serveHere()}>{serving ? 'Switching…' : 'Serve here'}</button> : null}</div>
|
|
1535
|
+
</div>
|
|
1536
|
+
<div className="block">
|
|
1537
|
+
<dl className="fields pad">
|
|
1538
|
+
<dt>Name</dt><dd><code>{b.name}</code></dd>
|
|
1539
|
+
<dt>Kind</dt><dd>one of this World's own branches</dd>
|
|
1540
|
+
<dt>State</dt><dd>{b.running ? 'running' : 'stopped'}</dd>
|
|
1541
|
+
</dl>
|
|
1542
|
+
</div>
|
|
1543
|
+
{served ? <p className="mode">This is the branch these pages show: its changes are on <a href={`${BASE}/${name}/~/changes`} onClick={(e) => { e.preventDefault(); go([...name.split('/'), '~', 'changes']); }}>Changes</a>.</p> : canServe ? <p className="mode">“Serve here” stops this one's twins and serves {b.name} in its place.</p> : <p className="mode">This host serves one of the World's own branches at a time, and does not switch them from this page.</p>}
|
|
1544
|
+
{error ? <p role="alert">{error}</p> : null}
|
|
1545
|
+
</div>
|
|
1546
|
+
);
|
|
1103
1547
|
}
|
|
1104
1548
|
|
|
1105
|
-
/** Rows that say the same thing (nine labels created at one instant) as one line that opens to them. */
|
|
1106
|
-
function collapsed(items: Placed[]): Array<Placed | Placed[]> {
|
|
1107
|
-
const out: Array<Placed | Placed[]> = [];
|
|
1108
|
-
for (const p of items) {
|
|
1109
|
-
const last = out[out.length - 1];
|
|
1110
|
-
const head = Array.isArray(last) ? last[0]! : last;
|
|
1111
|
-
if (head && head.twin === p.twin && (head.entry.operation ?? head.entry.op) === (p.entry.operation ?? p.entry.op)) {
|
|
1112
|
-
if (Array.isArray(last)) last.push(p); else out[out.length - 1] = [head, p];
|
|
1113
|
-
} else out.push(p);
|
|
1114
|
-
}
|
|
1115
|
-
// a run of two stays two rows; three or more is one line
|
|
1116
|
-
return out.flatMap((x) => (Array.isArray(x) && x.length < 3 ? x : [x]));
|
|
1117
|
-
}
|
|
1118
1549
|
|
|
1119
1550
|
const PAGE = 50;
|
|
1120
1551
|
const keyOf = (p: Placed): string => `${p.twin}:${p.position}`;
|
|
1121
1552
|
/** Newest first — the door's order: the instant, then the twin, then the position. */
|
|
1122
1553
|
const newestFirst = (a: Placed, b: Placed): number => Date.parse(b.entry.occurredAt) - Date.parse(a.entry.occurredAt) || a.twin.localeCompare(b.twin) || b.position - a.position;
|
|
1123
|
-
/**
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
return
|
|
1554
|
+
/** Who made it: the vendor itself for what it records on its own, the actor's id where it has one, else the app. */
|
|
1555
|
+
/** Who made it: the World's own word for the credential that called (`caller`: a key by its name, a person, the World's
|
|
1556
|
+
* token) when the entry carries it; else the vendor itself for what it records on its own, the actor's id where it has
|
|
1557
|
+
* one, else the app. */
|
|
1558
|
+
/** A caller as a person reads it: a key as itself, for its person (`key:claude for:ada` → "claude, for ada"), a person by
|
|
1559
|
+
* name, this machine's page and a World's token as what they are. */
|
|
1560
|
+
const callerName = (c: string | undefined): string => {
|
|
1561
|
+
if (!c) return 'someone';
|
|
1562
|
+
if (c.startsWith('key:')) { const [key, person] = c.slice(4).split(' for:'); return person ? `${key}, for ${person}` : key || 'a key'; }
|
|
1563
|
+
if (c.startsWith('person:')) return c.slice(7);
|
|
1564
|
+
return c === 'machine' ? 'this machine' : c === 'person' ? 'a session with no name' : c === 'token:read' ? 'the read-only token' : c === 'token' ? "the World's token" : c;
|
|
1565
|
+
};
|
|
1566
|
+
const whoDid = (e: Entry, caller?: string): string => {
|
|
1567
|
+
const c = typeof caller === 'string' ? caller : typeof e.caller === 'string' ? e.caller : '';
|
|
1568
|
+
if (c.startsWith('key:') && c.length > 4) return `by ${callerName(c)}`;
|
|
1569
|
+
if (c.startsWith('person:') && c.length > 7) return `by ${c.slice(7)}`;
|
|
1570
|
+
if (c === 'machine') return 'by this machine';
|
|
1571
|
+
if (c === 'person') return 'by a session with no name';
|
|
1572
|
+
if (c === 'key') return 'by a key';
|
|
1573
|
+
if (c === 'token:read') return 'by the read-only token';
|
|
1574
|
+
if (c === 'token') return "by the World's token";
|
|
1575
|
+
return e.actor?.kind === 'system' ? 'by the vendor' : e.actor?.id ? `by ${e.actor.id}` : 'by an app';
|
|
1576
|
+
};
|
|
1577
|
+
const opOf = (e: Entry): string => e.operation ?? e.op;
|
|
1578
|
+
/** What an entry's write cost, where it says: tokens in and out (a model's `usage`, or a usage record's own fields) and a cost. */
|
|
1579
|
+
function usageOf(e: Entry): Array<[string, string]> {
|
|
1580
|
+
const f = (e.fields ?? {}) as Record<string, unknown>;
|
|
1581
|
+
const u = f.usage !== null && typeof f.usage === 'object' ? (f.usage as Record<string, unknown>) : f;
|
|
1582
|
+
const n = (...keys: string[]): number | undefined => keys.map((k) => u[k] ?? f[k]).find((v): v is number => typeof v === 'number');
|
|
1583
|
+
const out: Array<[string, string]> = [];
|
|
1584
|
+
const input = n('prompt_tokens', 'input_tokens'); const output = n('completion_tokens', 'output_tokens'); const total = n('total_tokens');
|
|
1585
|
+
if (input !== undefined) out.push(['tokens in', input.toLocaleString()]);
|
|
1586
|
+
if (output !== undefined) out.push(['tokens out', output.toLocaleString()]);
|
|
1587
|
+
if (total !== undefined) out.push(['tokens in all', total.toLocaleString()]);
|
|
1588
|
+
const cost = n('cost_usd', 'cost');
|
|
1589
|
+
if (cost !== undefined) out.push(['cost', `$${cost < 0.01 ? cost.toPrecision(2) : cost.toFixed(2)}`]);
|
|
1590
|
+
return out;
|
|
1134
1591
|
}
|
|
1135
1592
|
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
return
|
|
1139
|
-
|
|
1140
|
-
<span className="twin-chip"><Mark vendor={p.twin} />{vendorName(p.twin)}</span>
|
|
1141
|
-
<span className="op">{s.what}</span>
|
|
1142
|
-
{s.label ? <span className="label">“{s.label}”</span> : <code className="subject">{shown(e)}</code>}
|
|
1143
|
-
<span className="actor mode">{byWhom(e, p.twin)}</span>
|
|
1144
|
-
{n ? <span className="standing">{n}</span> : null}
|
|
1145
|
-
{t ? <a className="trace-link" href={`${BASE}/${name}/~/trace/${t.trace}`} aria-label={`View trace ${t.trace.slice(0, 8)}`} onClick={(ev) => { ev.preventDefault(); go([...name.split('/'), '~', 'trace', t.trace]); }}>trace</a> : null}
|
|
1146
|
-
</li>
|
|
1147
|
-
);
|
|
1593
|
+
/** Text as a person reads it: whole when short, its start with the rest a click away when long. */
|
|
1594
|
+
function Folded({ text, at = 280 }: { text: string; at?: number }): React.JSX.Element {
|
|
1595
|
+
if (text.length <= at) return <span className="text">{text}</span>;
|
|
1596
|
+
return <details className="folded"><summary><span className="text">{text.slice(0, at)}…</span> <span className="mode">show all {text.length.toLocaleString()} characters</span></summary><span className="text">{text}</span></details>;
|
|
1148
1597
|
}
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
const
|
|
1154
|
-
|
|
1155
|
-
const [fresh, setFresh] = useState<{ items: Placed[]; gap: Timeline | null }>({ items: [], gap: null });
|
|
1156
|
-
const path = useCallback((before?: string): string => `/-/${name}/timeline?limit=${PAGE}${only ? `&twin=${encodeURIComponent(only)}` : ''}${before ? `&before=${encodeURIComponent(before)}` : ''}`, [name, only]);
|
|
1157
|
-
const itemsRef = useRef<Placed[] | null>(null); itemsRef.current = items;
|
|
1158
|
-
useEffect(() => {
|
|
1159
|
-
let live = true;
|
|
1160
|
-
setItems(null); setError(null); setFresh({ items: [], gap: null });
|
|
1161
|
-
void (async () => {
|
|
1162
|
-
const r = await door(token, path());
|
|
1163
|
-
if (!live) return;
|
|
1164
|
-
if (!r.ok) { setError(await failure(r)); return; }
|
|
1165
|
-
const b = (await r.json()) as Timeline; if (!live) return;
|
|
1166
|
-
setItems(b.entries); setNext(b.next);
|
|
1167
|
-
})().catch((e: unknown) => { if (live) setError(`the World did not answer: ${e instanceof Error ? e.message : String(e)}`); });
|
|
1168
|
-
// live: newer entries are counted, not inserted — the list never moves under the reader
|
|
1169
|
-
const id = setInterval(() => {
|
|
1170
|
-
const cur = itemsRef.current; if (cur === null || document.hidden) return;
|
|
1171
|
-
void door(token, path()).then(async (r) => {
|
|
1172
|
-
if (!r.ok || !live) return;
|
|
1173
|
-
const b = (await r.json()) as Timeline; if (!live) return;
|
|
1174
|
-
if (cur.length === 0) { if (b.entries.length) { setItems(b.entries); setNext(b.next); } return; }
|
|
1175
|
-
const known = new Set((itemsRef.current ?? cur).map(keyOf));
|
|
1176
|
-
const unseen = b.entries.filter((p) => !known.has(keyOf(p)));
|
|
1177
|
-
// a full page with nothing known in it: more arrived than one page holds, and showing them starts over
|
|
1178
|
-
setFresh({ items: unseen, gap: unseen.length === b.entries.length && b.next ? b : null });
|
|
1179
|
-
}).catch(() => null);
|
|
1180
|
-
}, 3000);
|
|
1181
|
-
return () => { live = false; clearInterval(id); };
|
|
1182
|
-
}, [token, path]);
|
|
1183
|
-
const show = (): void => {
|
|
1184
|
-
if (fresh.gap) { setItems(fresh.gap.entries); setNext(fresh.gap.next); } else setItems((cur) => [...fresh.items, ...(cur ?? [])].sort(newestFirst));
|
|
1185
|
-
setFresh({ items: [], gap: null });
|
|
1186
|
-
};
|
|
1187
|
-
const loadOlder = async (): Promise<void> => {
|
|
1188
|
-
if (!next) return; setOlder(true); setOlderError(null);
|
|
1189
|
-
try {
|
|
1190
|
-
const r = await door(token, path(next));
|
|
1191
|
-
// what is shown stays shown: only the older page failed
|
|
1192
|
-
if (!r.ok) { setOlderError(await failure(r)); return; }
|
|
1193
|
-
const b = (await r.json()) as Timeline;
|
|
1194
|
-
setItems((cur) => { const known = new Set((cur ?? []).map(keyOf)); return [...(cur ?? []), ...b.entries.filter((p) => !known.has(keyOf(p)))]; }); setNext(b.next);
|
|
1195
|
-
} catch (e) { setOlderError(`the World did not answer: ${e instanceof Error ? e.message : String(e)}`); } finally { setOlder(false); }
|
|
1196
|
-
};
|
|
1197
|
-
const days = useMemo(() => grouped(items ?? []), [items]);
|
|
1198
|
-
const n = fresh.items.length;
|
|
1199
|
-
return (
|
|
1200
|
-
<>
|
|
1201
|
-
<div role="group" aria-label="Filter by twin" className="chips">
|
|
1202
|
-
<button type="button" aria-pressed={only === null} onClick={() => setOnly(null)}>All vendors</button>
|
|
1203
|
-
{vendors.map((v) => <button key={v} type="button" aria-pressed={only === v} aria-label={v} onClick={() => setOnly(only === v ? null : v)}>{vendorName(v)}</button>)}
|
|
1204
|
-
</div>
|
|
1205
|
-
<div aria-live="polite" className="pill-slot">{n > 0 ? <button type="button" className="pill" onClick={show}>{fresh.gap ? `${n}+` : n} new change{n === 1 ? '' : 's'} — show</button> : null}</div>
|
|
1206
|
-
{error ? <p role="alert">Could not read the timeline: {error}</p> : items === null ? <p className="mode" role="status">Loading the timeline…</p> : items.length === 0 ? (
|
|
1207
|
-
only ? <p>Nothing has happened in {only} yet.</p> : (
|
|
1208
|
-
<div className="empty">
|
|
1209
|
-
<p><strong>Nothing has happened in this World yet.</strong></p>
|
|
1210
|
-
<p className="mode">An entry arrives each time something changes a twin: an app (or a seed script) calling a twin's API at <code>{WORLDS}/{name}/<twin></code>, a mirror's form, a changeset landing. They show here within a few seconds.</p>
|
|
1211
|
-
</div>
|
|
1212
|
-
)
|
|
1213
|
-
) : (
|
|
1214
|
-
<div className="feed" aria-label="Timeline">
|
|
1215
|
-
{days.map((d) => (
|
|
1216
|
-
<div key={d.day} role="group" aria-label={d.label} className="day">
|
|
1217
|
-
<h3 className="day-head">{d.label}</h3>
|
|
1218
|
-
<ol className="instants">
|
|
1219
|
-
{d.instants.map((g) => (
|
|
1220
|
-
<li key={`${g.at}:${keyOf(g.items[0]!)}`} className="instant">
|
|
1221
|
-
<div className="instant-time" title={g.items.length > 1 ? "These happened at the same moment of the World's clock: their order across vendors is not known." : undefined}><At iso={g.at} time /></div>
|
|
1222
|
-
<ul className="entries">{collapsed(g.items).map((x) => (Array.isArray(x)
|
|
1223
|
-
? <li key={keyOf(x[0]!)} className="entry run"><details><summary><span className="twin-chip"><Mark vendor={x[0]!.twin} />{vendorName(x[0]!.twin)}</span> <span className="op">{counted(x.length, x[0]!.entry.subject.type)} {said(x[0]!.entry).what.split(' ').slice(-1)[0]}</span> <span className="actor mode">{byWhom(x[0]!.entry, x[0]!.twin)}</span></summary><ul className="entries">{x.map((p) => <EntryRow key={keyOf(p)} name={name} p={p} />)}</ul></details></li>
|
|
1224
|
-
: <EntryRow key={keyOf(x)} name={name} p={x} />))}</ul>
|
|
1225
|
-
</li>
|
|
1226
|
-
))}
|
|
1227
|
-
</ol>
|
|
1228
|
-
</div>
|
|
1229
|
-
))}
|
|
1230
|
-
{next ? <button type="button" onClick={() => void loadOlder()} disabled={older} aria-busy={older}>Load older</button> : <p className="mode">The beginning of this World{only ? `'s ${only}` : ''}.</p>}
|
|
1231
|
-
{olderError ? <p role="alert">Could not load older changes: {olderError}</p> : null}
|
|
1232
|
-
</div>
|
|
1233
|
-
)}
|
|
1234
|
-
</>
|
|
1235
|
-
);
|
|
1598
|
+
/** A value read as itself: text as text, anything else as its JSON, folded when long. */
|
|
1599
|
+
function Value({ v }: { v: unknown }): React.JSX.Element {
|
|
1600
|
+
if (typeof v === 'string') return <Folded text={v} />;
|
|
1601
|
+
if (v === null || typeof v !== 'object') return <code>{String(v)}</code>;
|
|
1602
|
+
const json = JSON.stringify(v, null, 2);
|
|
1603
|
+
return json.length <= 160 ? <code>{JSON.stringify(v)}</code> : <details className="folded"><summary><code>{JSON.stringify(v).slice(0, 120)}…</code></summary><pre>{json}</pre></details>;
|
|
1236
1604
|
}
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
const
|
|
1243
|
-
const
|
|
1244
|
-
const
|
|
1245
|
-
const back = <a href={`${BASE}/${name}/~/timeline`} onClick={(e) => { e.preventDefault(); go([...name.split('/'), '~', 'timeline']); }}>← Timeline</a>;
|
|
1605
|
+
/** A message's words: its content when it is text, the text parts of it when it is parts. */
|
|
1606
|
+
const wordsOf = (content: unknown): string => (typeof content === 'string' ? content : Array.isArray(content) ? content.map((c) => (c && typeof c === 'object' && typeof (c as { text?: unknown }).text === 'string' ? (c as { text: string }).text : '')).filter(Boolean).join('\n') : JSON.stringify(content ?? ''));
|
|
1607
|
+
/** The request an entry carried (`input.body`): a chat's messages as a conversation, every other field by name. */
|
|
1608
|
+
function RequestBody({ body }: { body: unknown }): React.JSX.Element {
|
|
1609
|
+
if (body === null || typeof body !== 'object' || Array.isArray(body)) return <Value v={body} />;
|
|
1610
|
+
const b = body as Record<string, unknown>;
|
|
1611
|
+
const messages = Array.isArray(b.messages) ? (b.messages as Array<{ role?: unknown; content?: unknown }>) : null;
|
|
1612
|
+
const rest = Object.entries(b).filter(([k]) => !(messages && k === 'messages'));
|
|
1246
1613
|
return (
|
|
1247
1614
|
<>
|
|
1248
|
-
<
|
|
1249
|
-
<
|
|
1250
|
-
{!valid ? <p role="alert">{id ? `${id} is not a trace id` : 'No trace named'}: a W3C trace id is 32 hex characters.</p> : <Pending load={load} what="the trace" />}
|
|
1251
|
-
{load.state === 'ok' && steps.length === 0 ? (
|
|
1252
|
-
<div className="empty">
|
|
1253
|
-
<p><strong>Nothing in this World carries this trace.</strong></p>
|
|
1254
|
-
<p className="mode">Causes are followed through the W3C <code>traceparent</code> header: an app instrumented with OpenTelemetry sends it with each call, and every entry that call writes keeps it — so one click in the app can be read across Stripe, Slack and the rest. Without it the World knows the order of what happened, not what caused what.</p>
|
|
1255
|
-
</div>
|
|
1256
|
-
) : null}
|
|
1257
|
-
{steps.length > 0 ? (
|
|
1258
|
-
<>
|
|
1259
|
-
<p className="mode">{steps.length} step{steps.length === 1 ? '' : 's'} across {lanes.length} twin{lanes.length === 1 ? '' : 's'}{load.state === 'ok' && load.data.next ? ' · the first 500 shown' : ''}</p>
|
|
1260
|
-
<div role="table" aria-label="Trace waterfall" className="waterfall" style={{ gridTemplateColumns: `minmax(130px, auto) repeat(${lanes.length}, minmax(140px, 1fr))` }}>
|
|
1261
|
-
<div role="row" className="wf-row wf-head">
|
|
1262
|
-
<span role="columnheader">time</span>
|
|
1263
|
-
{lanes.map((l) => <span role="columnheader" key={l}>{l}</span>)}
|
|
1264
|
-
</div>
|
|
1265
|
-
{steps.map((s) => {
|
|
1266
|
-
const ms = Date.parse(s.entry.occurredAt) - t0; const span = traceOf(s.entry.traceparent)?.span;
|
|
1267
|
-
return (
|
|
1268
|
-
<div role="row" className="wf-row" key={keyOf(s)}>
|
|
1269
|
-
<span role="cell" className="wf-time"><At iso={s.entry.occurredAt} time /> <span className="mode">+{ms} ms</span></span>
|
|
1270
|
-
{lanes.map((l) => (
|
|
1271
|
-
<span role="cell" key={l} className={l === s.twin ? 'wf-cell on' : 'wf-cell'}>
|
|
1272
|
-
{l === s.twin ? (
|
|
1273
|
-
<details>
|
|
1274
|
-
<summary>{s.entry.operation ?? s.entry.op} <code>{s.entry.subject.type}:{s.entry.subject.id}</code></summary>
|
|
1275
|
-
<p className="mode">{actorOf(s.entry)} · {standing(s.entry)} · position {s.position}{span ? <> · span <code>{span}</code></> : null}</p>
|
|
1276
|
-
<Fields fields={s.entry.fields} />
|
|
1277
|
-
</details>
|
|
1278
|
-
) : null}
|
|
1279
|
-
</span>
|
|
1280
|
-
))}
|
|
1281
|
-
</div>
|
|
1282
|
-
);
|
|
1283
|
-
})}
|
|
1284
|
-
</div>
|
|
1285
|
-
</>
|
|
1286
|
-
) : null}
|
|
1615
|
+
{rest.length ? <dl className="fields">{rest.map(([k, v]) => <React.Fragment key={k}><dt>{k}</dt><dd><Value v={v} /></dd></React.Fragment>)}</dl> : null}
|
|
1616
|
+
{messages ? <ol className="messages">{messages.map((m, i) => <li key={i}><span className="role">{typeof m.role === 'string' ? m.role : 'message'}</span><Folded text={wordsOf(m.content)} /></li>)}</ol> : null}
|
|
1287
1617
|
</>
|
|
1288
1618
|
);
|
|
1289
1619
|
}
|
|
1290
|
-
|
|
1291
1620
|
function Fields({ fields }: { fields?: Record<string, unknown> }): React.JSX.Element {
|
|
1292
1621
|
const rows = Object.entries(fields ?? {});
|
|
1293
1622
|
if (rows.length === 0) return <p className="mode">No fields.</p>;
|
|
1294
|
-
return <dl className="fields">{rows.map(([k, v]) => <React.Fragment key={k}><dt>{k}</dt><dd><
|
|
1623
|
+
return <dl className="fields">{rows.map(([k, v]) => <React.Fragment key={k}><dt>{k}</dt><dd><Value v={v} /></dd></React.Fragment>)}</dl>;
|
|
1624
|
+
}
|
|
1625
|
+
/** Where an entry stands: a receipt when a push or a deploy gave it one, else where it came from. */
|
|
1626
|
+
const receiptOf = (e: Entry): string => (e.receipt ? `${e.receipt.status ?? ''}${e.receipt.externalId ? ` · ${e.receipt.externalId}` : ''}${e.receipt.reason ? ` · ${e.receipt.reason}` : ''}` : e.operation === 'observed' ? 'observed from the vendor' : e.provenance === 'placeholder' ? 'default data' : 'none: it has not left this World');
|
|
1627
|
+
|
|
1628
|
+
/** One event: when, at which vendor, what in words, on which record, by whom; opened, what it carried and what it wrote. */
|
|
1629
|
+
/** An event and the landed copies that are its receipts: a copy (`landsId`, or `aliasOf` under the vendor's id) is not an
|
|
1630
|
+
* event of its own but what a push or a deploy recorded of the entry it names. */
|
|
1631
|
+
type Folded = Placed & { copies: Placed[] };
|
|
1632
|
+
/** Each write once, its landed copies folded into it. A copy whose entry is not among these stays its own row, so nothing
|
|
1633
|
+
* the World answered is hidden. */
|
|
1634
|
+
function foldLanded(items: Placed[]): Folded[] {
|
|
1635
|
+
const byId = new Map<string, Folded>(); const out: Folded[] = []; const copies: Placed[] = [];
|
|
1636
|
+
const bySubject = new Map<string, Folded>();
|
|
1637
|
+
for (const p of items) {
|
|
1638
|
+
if (p.entry.landsId || p.entry.aliasOf) { copies.push(p); continue; }
|
|
1639
|
+
const f = { ...p, copies: [] }; out.push(f);
|
|
1640
|
+
if (!byId.has(`${p.twin}:${p.entry.id}`)) byId.set(`${p.twin}:${p.entry.id}`, f);
|
|
1641
|
+
if (!bySubject.has(`${p.twin}:${p.entry.subject.type}:${p.entry.subject.id}`)) bySubject.set(`${p.twin}:${p.entry.subject.type}:${p.entry.subject.id}`, f);
|
|
1642
|
+
}
|
|
1643
|
+
for (const c of copies) {
|
|
1644
|
+
// a copy folds into the entry its landsId names, and only there; the subject fallback is for a copy with no landsId
|
|
1645
|
+
const into = c.entry.landsId ? byId.get(`${c.twin}:${c.entry.landsId}`) : c.entry.aliasOf ? bySubject.get(`${c.twin}:${c.entry.subject.type}:${c.entry.aliasOf}`) : undefined;
|
|
1646
|
+
if (into) into.copies.push(c); else out.push({ ...c, copies: [] });
|
|
1647
|
+
}
|
|
1648
|
+
return out.sort(newestFirst);
|
|
1649
|
+
}
|
|
1650
|
+
/** How settled a receipt is: performed at the vendor over landed at the remote, a refusal or failure said as one. */
|
|
1651
|
+
const SETTLED: Record<string, number> = { deployed: 5, confirmed: 4, landed: 3, replayed: 2, skipped: 1 };
|
|
1652
|
+
function settledOf(f: Folded): { status: string; bad: boolean } | null {
|
|
1653
|
+
const all = [f.entry.receipt, ...f.copies.map((c) => c.entry.receipt)].filter((r): r is NonNullable<Entry['receipt']> => Boolean(r?.status));
|
|
1654
|
+
if (all.length === 0) return null;
|
|
1655
|
+
const bad = all.find((r) => r.status === 'failed' || r.status === 'refused');
|
|
1656
|
+
const best = [...all].sort((a, b) => (SETTLED[b.status!] ?? 0) - (SETTLED[a.status!] ?? 0))[0]!;
|
|
1657
|
+
return bad && !(SETTLED[best.status!]! >= 5) ? { status: bad.status!, bad: true } : { status: best.status!, bad: false };
|
|
1295
1658
|
}
|
|
1296
1659
|
|
|
1660
|
+
type RequestReport = { hours: number; requests: number; journalFailures?: number; twins: Array<{ twin: string; requests: number; byStatus: Record<string, number>; p50Ms: number | null; p95Ms: number | null; topRoutes: Array<{ route: string; requests: number }> }> };
|
|
1661
|
+
|
|
1662
|
+
|
|
1663
|
+
|
|
1297
1664
|
type MirrorSession = { state: 'pending' } | { state: 'ok'; scope: string } | { state: 'error'; error: string };
|
|
1298
1665
|
/** THE CANVAS: every vendor's own screens as frames on one surface, the way a canvas app holds its frames (React
|
|
1299
1666
|
* Flow; Figma's and tldraw's feel). Pan by dragging the background, zoom with the wheel or the controls, move a frame
|
|
1300
1667
|
* by its title bar (kept in this browser), click a frame to use that vendor's screens and the background or Esc to
|
|
1301
1668
|
* leave it, so a frame never captures the pan. A frame is its vendor's site at a desktop's size. */
|
|
1302
1669
|
const FRAME_W = 1280; const FRAME_H = 820; const FRAME_GAP = 160;
|
|
1303
|
-
type Screen = { world: string; vendor: string; active: boolean; open: (vendor: string) => void };
|
|
1670
|
+
type Screen = { world: string; vendor: string; active: boolean; open: (vendor: string) => void; /** its records changed and not yet cut: the screen's mark, as a changed file's */ changed: number };
|
|
1304
1671
|
function ScreenFrame({ data }: NodeProps<Node<Screen>>): React.JSX.Element {
|
|
1305
|
-
const { world, vendor, active, open } = data;
|
|
1672
|
+
const { world, vendor, active, open, changed } = data;
|
|
1306
1673
|
return (
|
|
1307
1674
|
<>
|
|
1308
1675
|
<NodeToolbar isVisible position={Position.Top} align="start" className="frame-label">
|
|
1309
1676
|
<Mark vendor={vendor} /><span>{vendorName(vendor)}</span>
|
|
1677
|
+
{changed ? <a className="frame-changed nodrag" href={`${BASE}/${world}/~/changes`} onClick={(e) => { e.preventDefault(); go([...world.split('/'), '~', 'changes']); }} title={`${counted(changed, 'record')} changed here, not in a changeset: see them in Changes`}><Icon name="square-dot" size={14} />{changed}</a> : null}
|
|
1310
1678
|
</NodeToolbar>
|
|
1311
1679
|
<div className={active ? 'frame on' : 'frame'} style={{ width: FRAME_W, height: FRAME_H }}>
|
|
1312
1680
|
<div className="frame-bar" title="Drag to move">
|
|
1313
1681
|
<Mark vendor={vendor} /><strong>{vendorName(vendor)}</strong>
|
|
1314
1682
|
<span className="frame-url">{mirrorUrl(world, vendor).replace(/^https?:\/\//, '')}</span>
|
|
1315
|
-
<a className="nodrag" href={`${BASE}/${world}/~/
|
|
1683
|
+
<a className="nodrag" href={`${BASE}/${world}/~/screens/${vendor}`} onClick={(e) => { e.preventDefault(); open(vendor); }}>Open</a>
|
|
1316
1684
|
<a className="nodrag" href={mirrorUrl(world, vendor)} target="_blank" rel="noopener noreferrer" aria-label={`Open ${vendorName(vendor)} in a new tab`}>↗</a>
|
|
1317
1685
|
</div>
|
|
1318
1686
|
<div className={active ? 'frame-body nodrag nopan nowheel' : 'frame-body'}>
|
|
@@ -1323,25 +1691,33 @@ function ScreenFrame({ data }: NodeProps<Node<Screen>>): React.JSX.Element {
|
|
|
1323
1691
|
);
|
|
1324
1692
|
}
|
|
1325
1693
|
const FRAME_TYPES = { screen: ScreenFrame };
|
|
1326
|
-
function Canvas(props: { name: string; twins: Load<{ twins: Twin[] }>; session: MirrorSession; retry: () => void; snapshot: Snapshot | null; back: () => void }): React.JSX.Element {
|
|
1694
|
+
function Canvas(props: { name: string; token: string; twins: Load<{ twins: Twin[] }>; session: MirrorSession; retry: () => void; snapshot: Snapshot | null; back: () => void }): React.JSX.Element {
|
|
1327
1695
|
return <ReactFlowProvider><CanvasSurface {...props} /></ReactFlowProvider>;
|
|
1328
1696
|
}
|
|
1329
1697
|
/** The canvas fills the window, as Figma's, tldraw's and Miro's do: the way back and the frames' list float at the top
|
|
1330
1698
|
* left (a canvas app's layers), zoom at the bottom left, the minimap at the bottom right. */
|
|
1331
|
-
function CanvasSurface({ name, twins, session, retry, snapshot, back }: { name: string; twins: Load<{ twins: Twin[] }>; session: MirrorSession; retry: () => void; snapshot: Snapshot | null; back: () => void }): React.JSX.Element {
|
|
1699
|
+
function CanvasSurface({ name, token, twins, session, retry, snapshot, back }: { name: string; token: string; twins: Load<{ twins: Twin[] }>; session: MirrorSession; retry: () => void; snapshot: Snapshot | null; back: () => void }): React.JSX.Element {
|
|
1700
|
+
// each screen's changes not yet cut, by vendor: the records, as Changes counts them
|
|
1701
|
+
const diff = useDoor<Diff>(token, `/-/${name}/diff`);
|
|
1702
|
+
const changed = useMemo((): Record<string, number> => {
|
|
1703
|
+
const by: Record<string, Set<string>> = {};
|
|
1704
|
+
if (diff.state === 'ok') for (const c of diff.data.changes) (by[c.twin] ??= new Set()).add(`${c.entry.subject.type}:${c.entry.subject.id}`);
|
|
1705
|
+
return Object.fromEntries(Object.entries(by).map(([v, rs]) => [v, rs.size]));
|
|
1706
|
+
}, [diff]);
|
|
1707
|
+
const changedAt = (v: string): number => changed[v] ?? 0;
|
|
1332
1708
|
const frames = twins.state === 'ok' ? twins.data.twins.filter((t) => t.mirror).map((t) => t.twin) : [];
|
|
1333
1709
|
const saved = `volter.canvas.${name}`;
|
|
1334
1710
|
const [active, setActive] = useState<string | null>(null);
|
|
1335
1711
|
const [layers, setLayers] = useState(true);
|
|
1336
1712
|
const flow = useReactFlow();
|
|
1337
|
-
const open = useCallback((v: string): void => go([...name.split('/'), '~', '
|
|
1713
|
+
const open = useCallback((v: string): void => go([...name.split('/'), '~', 'screens', v]), [name]);
|
|
1338
1714
|
const cols = Math.max(1, Math.ceil(Math.sqrt(frames.length)));
|
|
1339
1715
|
const placed = useCallback((): Array<Node<Screen>> => {
|
|
1340
1716
|
let kept: Record<string, { x: number; y: number }> = {};
|
|
1341
1717
|
try { kept = JSON.parse(localStorage.getItem(saved) ?? '{}') as Record<string, { x: number; y: number }>; } catch { /* a convenience */ }
|
|
1342
|
-
return frames.map((v, i) => ({ id: v, type: 'screen', dragHandle: '.frame-bar', position: kept[v] ?? { x: (i % cols) * (FRAME_W + FRAME_GAP), y: Math.floor(i / cols) * (FRAME_H + FRAME_GAP) }, data: { world: name, vendor: v, active: false, open } }));
|
|
1718
|
+
return frames.map((v, i) => ({ id: v, type: 'screen', dragHandle: '.frame-bar', position: kept[v] ?? { x: (i % cols) * (FRAME_W + FRAME_GAP), y: Math.floor(i / cols) * (FRAME_H + FRAME_GAP) }, data: { world: name, vendor: v, active: false, open, changed: changed[v] ?? 0 } }));
|
|
1343
1719
|
// biome-ignore lint/correctness/useExhaustiveDependencies: the frames' names are what change it
|
|
1344
|
-
}, [frames.join(','), name, cols, open]);
|
|
1720
|
+
}, [frames.join(','), name, cols, open, changed]);
|
|
1345
1721
|
const [nodes, setNodes, onNodesChange] = useNodesState<Node<Screen>>([]);
|
|
1346
1722
|
useEffect(() => { setNodes(placed()); }, [placed, setNodes]);
|
|
1347
1723
|
useEffect(() => { setNodes((all) => all.map((n) => (n.data.active === (n.id === active) ? n : { ...n, data: { ...n.data, active: n.id === active } }))); }, [active, setNodes]);
|
|
@@ -1372,8 +1748,7 @@ function CanvasSurface({ name, twins, session, retry, snapshot, back }: { name:
|
|
|
1372
1748
|
</div>
|
|
1373
1749
|
{layers ? (
|
|
1374
1750
|
<ul className="layer-list" aria-label="Screens">
|
|
1375
|
-
{frames.map((v) => <li key={v}><button type="button" className={active === v ? 'on' : undefined} onClick={() => focus(v)}><Mark vendor={v} /><span>{vendorName(v)}</span
|
|
1376
|
-
{twins.state === 'ok' && frames.length === 0 ? <li className="mode">No vendor in this World has screens.</li> : null}
|
|
1751
|
+
{frames.map((v) => <li key={v}><button type="button" className={active === v ? 'on' : undefined} onClick={() => focus(v)}><Mark vendor={v} /><span>{vendorName(v)}</span>{changedAt(v) ? <span className="layer-changed" title={`${counted(changedAt(v), 'record')} changed, not in a changeset`}><Icon name="square-dot" size={14} />{changedAt(v)}</span> : null}</button></li>)}
|
|
1377
1752
|
</ul>
|
|
1378
1753
|
) : null}
|
|
1379
1754
|
<Pending load={twins} what="the vendors" />
|
|
@@ -1388,117 +1763,109 @@ function CanvasSurface({ name, twins, session, retry, snapshot, back }: { name:
|
|
|
1388
1763
|
);
|
|
1389
1764
|
}
|
|
1390
1765
|
|
|
1391
|
-
/** What this World changed since its base — what a changeset would cut — in a compare view, by twin. */
|
|
1392
1766
|
/** A changeset as the World's changesets door answers it: what it does at each vendor, the checks it ran, who signed
|
|
1393
|
-
* which bytes, and
|
|
1767
|
+
* which bytes, and where it went (`applied`: a push's or a deploy's receipts, one per change). */
|
|
1394
1768
|
type ChangesetView = {
|
|
1395
|
-
|
|
1769
|
+
/** who cut it, owns it and pushed it (a caller, a person, a caller), when recorded */
|
|
1770
|
+
cutBy?: string; owner?: string; pushedBy?: string;
|
|
1771
|
+
name: string; message?: string; narration?: string; createdAt: string; contentHash: string; /** the kernel's readiness under this World's rules (GET …/changesets) */ readiness?: { approvers: number; required: number; ready: boolean }; /** the body's hash as it stands (GET …/changesets) */ currentHash?: string;
|
|
1396
1772
|
actions: Array<{ service: string; action: Omit<Entry, 'service' | 'op'> & { op?: string; operation?: string } }>;
|
|
1397
1773
|
verification: { at: string; contentHash: string; passed: boolean; results: Array<{ id: string; service: string; passed: boolean; actual?: unknown }>; refusals?: Array<{ service: string; check: string; reason: string }> } | null;
|
|
1398
1774
|
approvals: Array<{ principal: string; at: string; contentHash: string; note?: string }>;
|
|
1399
1775
|
applied: { at: string; outcome: 'applied' | 'partial' | 'refused'; refusal?: string[]; receipts: Array<{ actionId: string; service: string; status: string; externalId?: string; url?: string; error?: string }> } | null;
|
|
1776
|
+
/** what a deploy did of each action, from the landed copies on each twin's log: only `deployed` was performed against the vendor */
|
|
1777
|
+
deployed?: Array<{ service: string; actionId: string; status: 'landed' | 'deployed' | 'refused' | 'failed' | 'skipped'; at: string; externalId?: string; url?: string; reason?: string }>;
|
|
1400
1778
|
};
|
|
1401
|
-
/** Its actions as the Changes
|
|
1402
|
-
const
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1779
|
+
/** Its actions as the Changes page shows a diff's: each with its vendor. */
|
|
1780
|
+
const changesetChanges = (c: ChangesetView): Array<{ twin: string; entry: Entry }> => c.actions.map((a) => ({ twin: a.service, entry: { ...a.action, service: a.service, op: a.action.op ?? a.action.operation ?? '' } as Entry }));
|
|
1781
|
+
/** Where it went, from its receipts alone. Performed at the vendor: an action with a `deployed` receipt (a deploy from
|
|
1782
|
+
* here), or a push receipt `confirmed` (the remote performed it). Every action performed is Deployed, some is Partly
|
|
1783
|
+
* deployed; a refused or failed one is said as a failure; otherwise the push decides: every change replayed at the remote
|
|
1784
|
+
* is Pushed (the remote World, never a vendor), else Not sent. */
|
|
1785
|
+
function sentOf(c: ChangesetView): { label: string; tone: 'done' | 'good' | 'warn' | 'bad' | 'plain'; performed: number; failed: number; total: number } {
|
|
1786
|
+
const total = c.actions.length;
|
|
1787
|
+
const pushed = c.applied?.receipts ?? []; const deployed = c.deployed ?? [];
|
|
1788
|
+
const performed = new Set([...deployed.filter((r) => r.status === 'deployed'), ...pushed.filter((r) => r.status === 'confirmed')].map((r) => r.actionId)).size;
|
|
1789
|
+
const failed = new Set([...deployed.filter((r) => r.status === 'refused' || r.status === 'failed'), ...pushed.filter((r) => r.status === 'failed')].map((r) => r.actionId)).size;
|
|
1790
|
+
const n = { performed, failed, total };
|
|
1791
|
+
if (total > 0 && performed >= total) return { label: 'Deployed', tone: 'done', ...n };
|
|
1792
|
+
if (performed > 0) return { label: `Partly deployed: ${performed} of ${total}`, tone: 'warn', ...n };
|
|
1793
|
+
if (failed > 0) return { label: `${failed} refused or failed`, tone: 'bad', ...n };
|
|
1794
|
+
if (pushed.some((r) => r.status === 'replayed') && pushed.every((r) => r.status === 'replayed' || r.status === 'skipped')) return { label: 'Pushed', tone: 'good', ...n };
|
|
1795
|
+
return { label: 'Not sent', tone: 'plain', ...n };
|
|
1796
|
+
}
|
|
1797
|
+
/** Its review, in a word: a verification or an approval of other bytes is stale and does not count. */
|
|
1798
|
+
/** How this World's changesets are made and readied (its `rules` door): approvals needed, and whether a cut's summary is required. */
|
|
1799
|
+
type ChangesetRules = { approvals?: number; summary?: 'required' | 'generated' };
|
|
1800
|
+
/** A changeset's title: its author's summary, else what the summary generated from its changes did first (without the
|
|
1801
|
+
* fields it set, which its page lists), else its name. */
|
|
1802
|
+
const titleOf = (c: ChangesetView): string => c.message ?? c.narration?.split('\n')[0]?.replace(/ setting .*$/, '') ?? c.name;
|
|
1803
|
+
function reviewOf(c: ChangesetView, required = 1): { label: string; tone: 'good' | 'bad' | 'plain' } {
|
|
1410
1804
|
const now = c.currentHash ?? c.contentHash;
|
|
1411
|
-
|
|
1805
|
+
// the different people who approved its current contents, as readiness counts them (a key counts for its person)
|
|
1806
|
+
const signed = c.readiness?.approvers ?? new Set(c.approvals.filter((a) => a.contentHash === now).map((a) => a.principal)).size;
|
|
1412
1807
|
const checked = c.verification && c.verification.contentHash === now ? c.verification : null;
|
|
1413
1808
|
if (checked && !checked.passed) return { label: 'Checks failed', tone: 'bad' };
|
|
1809
|
+
if (signed > 0 && signed < required) return { label: `Approved by ${signed} of ${required}`, tone: 'plain' };
|
|
1414
1810
|
if (signed > 0) return { label: `Approved by ${signed}`, tone: 'good' };
|
|
1415
1811
|
if (checked) return { label: 'Verified', tone: 'good' };
|
|
1416
1812
|
return { label: 'Not verified', tone: 'plain' };
|
|
1417
1813
|
}
|
|
1418
1814
|
|
|
1419
|
-
/** THE CHANGESETS: what this World would do, cut into reviewable pieces, listed as a repository lists pull requests;
|
|
1420
|
-
* each one's page reviews it (its changes by vendor, its checks, its approvals) and, where the World has a real root,
|
|
1421
|
-
* deploys it. Every button is the World's own door; a refusal is the door's words. */
|
|
1422
|
-
function Changesets({ name, token, signsAs, canWrite, rooted, open: opened, refused }: { name: string; token: string; signsAs: string | null; canWrite: boolean; rooted: string[]; open?: string; refused: (r: Response) => void }): React.JSX.Element {
|
|
1423
|
-
const [version, setVersion] = useState(0);
|
|
1424
|
-
const list = useDoor<{ changesets: ChangesetView[] }>(token, `/-/${name}/changesets`, version);
|
|
1425
|
-
const at = (c: string) => (e: React.MouseEvent): void => { e.preventDefault(); go([...name.split('/'), '~', 'changesets', c]); };
|
|
1426
|
-
if (opened) {
|
|
1427
|
-
const c = list.state === 'ok' ? list.data.changesets.find((x) => x.name === opened) : undefined;
|
|
1428
|
-
return (
|
|
1429
|
-
<>
|
|
1430
|
-
<p className="crumbs-line"><a href={`${BASE}/${name}/~/changesets`} onClick={(e) => { e.preventDefault(); go([...name.split('/'), '~', 'changesets']); }}>← Changesets</a></p>
|
|
1431
|
-
<Pending load={list} what="the changesets" />
|
|
1432
|
-
{list.state === 'ok' && !c ? <p className="mode">This World has no changeset named {opened}.</p> : null}
|
|
1433
|
-
{c ? <ChangesetPage name={name} token={token} signsAs={signsAs} c={c} canWrite={canWrite} rooted={rooted} refused={refused} onChanged={() => setVersion((v) => v + 1)} /> : null}
|
|
1434
|
-
</>
|
|
1435
|
-
);
|
|
1436
|
-
}
|
|
1437
|
-
const rows = list.state === 'ok' ? [...list.data.changesets].sort((a, b) => b.createdAt.localeCompare(a.createdAt)) : [];
|
|
1438
|
-
return (
|
|
1439
|
-
<>
|
|
1440
|
-
<p className="lede">A changeset is what this World changed, cut into a piece a person reviews: what it does at each vendor, the World's checks over it, and who approved it. Where the World has a real account behind a vendor, an approved changeset deploys there, with a receipt for each change.</p>
|
|
1441
|
-
<Pending load={list} what="the changesets" />
|
|
1442
|
-
{list.state === 'ok' && rows.length === 0 ? (
|
|
1443
|
-
<Empty title="No changesets yet">
|
|
1444
|
-
<p>Cut one from what your app did in this World: <code>volter world changeset -m "What it does and why"</code>. Pushed to a shared World, it shows up here for review.</p>
|
|
1445
|
-
</Empty>
|
|
1446
|
-
) : null}
|
|
1447
|
-
{rows.length > 0 ? (
|
|
1448
|
-
<div className="block">
|
|
1449
|
-
<table className="dense" aria-label="Changesets">
|
|
1450
|
-
<thead><tr><th>Changeset</th><th>Changes</th><th>Made</th><th>Status</th></tr></thead>
|
|
1451
|
-
<tbody>{rows.map((c) => {
|
|
1452
|
-
const s = changesetStatus(c);
|
|
1453
|
-
const counts = changesetGroups(c).map(([v, es]) => `${es.length} at ${vendorName(v)}`).join(' · ');
|
|
1454
|
-
return (
|
|
1455
|
-
<tr key={c.name}>
|
|
1456
|
-
<td><a href={`${BASE}/${name}/~/changesets/${c.name}`} onClick={at(c.name)}><b>{c.message ?? c.name}</b></a>{c.message ? <span className="mode mono"> {c.name}</span> : null}</td>
|
|
1457
|
-
<td className="mode">{counts || 'nothing'}</td>
|
|
1458
|
-
<td className="mode"><At iso={c.createdAt} /></td>
|
|
1459
|
-
<td><span className={`badge cs-${s.tone}`}>{s.label}</span></td>
|
|
1460
|
-
</tr>
|
|
1461
|
-
);
|
|
1462
|
-
})}</tbody>
|
|
1463
|
-
</table>
|
|
1464
|
-
</div>
|
|
1465
|
-
) : null}
|
|
1466
|
-
</>
|
|
1467
|
-
);
|
|
1468
|
-
}
|
|
1469
1815
|
|
|
1470
|
-
/** One changeset, reviewed: its words, its changes
|
|
1471
|
-
*
|
|
1472
|
-
function ChangesetPage({ name, token, signsAs, c, canWrite, rooted, refused, onChanged }: { name: string; token: string; signsAs: string | null; c: ChangesetView; canWrite: boolean; rooted: string[]; refused: (r: Response) => void; onChanged: () => void }): React.JSX.Element {
|
|
1816
|
+
/** One changeset, reviewed: its words, its changes, its checks, its approvals, and where it went; Verify, Approve
|
|
1817
|
+
* (signed as the person this session is for) and Deploy where they apply, each saying why when it does not. */
|
|
1818
|
+
function ChangesetPage({ name, token, signsAs, c, canWrite, rooted, refused, onChanged, part, required }: { /** approvals this World requires */ required: number; name: string; token: string; signsAs: string | null; c: ChangesetView; canWrite: boolean; rooted: string[]; refused: (r: Response) => void; onChanged: () => void; /** which part of it (the workspace's History): its head with the acts, or its review (where it went, checks, approvals) */ part: 'head' | 'review' }): React.JSX.Element {
|
|
1819
|
+
const showHead = part === 'head'; const showReview = part === 'review';
|
|
1473
1820
|
const [busy, setBusy] = useState<string | null>(null);
|
|
1474
1821
|
const [approving, setApproving] = useState(false); const [note, setNote] = useState(''); const [as, setAs] = useState('');
|
|
1475
|
-
|
|
1476
|
-
const
|
|
1477
|
-
const
|
|
1822
|
+
// why the last act was refused, in this page's words, with the acts that answer it
|
|
1823
|
+
const [problem, setProblem] = useState<{ what: string; text: string; acts: Act[] } | null>(null);
|
|
1824
|
+
const origin = useDoor<OriginDoor>(token, `/-/${name}/origin`);
|
|
1825
|
+
const sent = sentOf(c); const review = reviewOf(c, required);
|
|
1826
|
+
const changes = changesetChanges(c);
|
|
1827
|
+
const touches = [...new Set(changes.map((x) => x.twin))];
|
|
1478
1828
|
const deployable = touches.some((v) => rooted.includes(v));
|
|
1479
1829
|
const current = (h: string): boolean => h === (c.currentHash ?? c.contentHash);
|
|
1830
|
+
const receipts = c.applied?.receipts ?? [];
|
|
1831
|
+
const atVendor = (c.deployed ?? []).filter((r) => r.status !== 'landed');
|
|
1832
|
+
const byRemote = receipts.filter((r) => r.status === 'confirmed').length;
|
|
1833
|
+
const here = atVendor.filter((r) => r.status === 'deployed');
|
|
1480
1834
|
const act = async (what: string, path: string, body?: unknown, done?: string): Promise<boolean> => {
|
|
1481
|
-
setBusy(what);
|
|
1835
|
+
setBusy(what); setProblem(null);
|
|
1482
1836
|
try {
|
|
1483
1837
|
const r = await door(token, path, { method: 'POST', ...(body === undefined ? {} : { body: JSON.stringify(body) }) });
|
|
1484
|
-
if (!r.ok) { refused(r);
|
|
1838
|
+
if (!r.ok) { refused(r); setProblem({ what, ...plainly(await doorWords(r)) }); return false; }
|
|
1485
1839
|
if (done) toast(done);
|
|
1486
1840
|
onChanged(); return true;
|
|
1487
|
-
} catch (e) {
|
|
1841
|
+
} catch (e) { setProblem({ what, text: `the World did not answer (${e instanceof Error ? e.message : String(e)})`, acts: [] }); return false; } finally { setBusy(null); }
|
|
1488
1842
|
};
|
|
1489
1843
|
const verify = (): void => { void act('Verify', `/-/${name}/changesets/${c.name}/verify`, undefined, 'Verified: the checks ran over it'); };
|
|
1490
1844
|
const approve = async (e: React.FormEvent): Promise<void> => { e.preventDefault(); if (await act('Approve', `/-/${name}/changesets/${c.name}/approve`, { ...(note.trim() ? { note: note.trim() } : {}), ...(signsAs === null && as.trim() ? { as: as.trim() } : {}) }, 'Approved')) { setApproving(false); setNote(''); } };
|
|
1491
1845
|
const deploy = async (): Promise<void> => {
|
|
1492
|
-
|
|
1493
|
-
|
|
1846
|
+
const to = touches.filter((v) => rooted.includes(v));
|
|
1847
|
+
if (!(await confirmAct({ title: `Deploy ${c.name} to the real vendors?`, body: <>It performs its {c.actions.length} change{c.actions.length === 1 ? '' : 's'} against the real account{to.length === 1 ? '' : 's'} behind {listed(to.map(vendorName))}. This cannot be taken back here.</>, action: 'Deploy', danger: true, typed: c.name }))) return;
|
|
1848
|
+
setBusy('Deploy'); setProblem(null);
|
|
1849
|
+
try {
|
|
1850
|
+
const r = await door(token, `/-/${name}/deploy/${c.name}`, { method: 'POST', body: JSON.stringify({ confirm: c.name }) });
|
|
1851
|
+
if (!r.ok) { refused(r); setProblem({ what: 'Deploy', ...plainly(await doorWords(r)) }); return; }
|
|
1852
|
+
// what it did is the changeset's own receipts, read again: its actions' `deployed` receipts, never a count the door sums
|
|
1853
|
+
const again = await door(token, `/-/${name}/changesets/${c.name}`);
|
|
1854
|
+
const now = again.ok ? ((await again.json()) as ChangesetView) : null;
|
|
1855
|
+
if (!now) { toast('Deploy: sent; reading its receipts failed', 'info'); onChanged(); return; }
|
|
1856
|
+
const s = sentOf(now);
|
|
1857
|
+
toast(`Deploy: ${s.performed} of ${s.total} change${s.total === 1 ? '' : 's'} performed at the vendor${s.failed ? `; ${s.failed} refused or failed` : ''}`, s.failed ? 'error' : s.performed ? 'ok' : 'info');
|
|
1858
|
+
onChanged();
|
|
1859
|
+
} catch (e) { setProblem({ what: 'Deploy', text: `the World did not answer (${e instanceof Error ? e.message : String(e)})`, acts: [] }); } finally { setBusy(null); }
|
|
1494
1860
|
};
|
|
1495
|
-
// why a button is off, in words: the reader's scope, what already happened
|
|
1496
|
-
const offWhy = !canWrite ? 'This session reads the World; it changes nothing' :
|
|
1861
|
+
// why a button is off, in words: the reader's scope, or what already happened
|
|
1862
|
+
const offWhy = !canWrite ? 'This session reads the World; it changes nothing' : sent.label === 'Deployed' ? 'Already deployed: every change was performed at the vendor' : null;
|
|
1863
|
+
const there = origin.state === 'ok' && origin.data.origin ? originAddress(origin.data.origin) : null;
|
|
1497
1864
|
return (
|
|
1498
1865
|
<div className="changeset" aria-labelledby="changeset-title">
|
|
1499
|
-
|
|
1866
|
+
{showHead ? <><div className="changeset-head">
|
|
1500
1867
|
<div>
|
|
1501
|
-
<h3 id="changeset-title">{c.message ?? c.name} <span className={`badge cs-${
|
|
1868
|
+
<h3 id="changeset-title">{c.message ?? c.name} <span className={`badge cs-${sent.tone}`}>{sent.label}</span> <span className={`badge cs-${review.tone}`}>{review.label}</span></h3>
|
|
1502
1869
|
<p className="mode"><code>{c.name}</code> · made <At iso={c.createdAt} /> · {c.actions.length} change{c.actions.length === 1 ? '' : 's'} at {listed(touches.map(vendorName)) || 'no vendor'}</p>
|
|
1503
1870
|
</div>
|
|
1504
1871
|
<div className="changeset-actions">
|
|
@@ -1507,12 +1874,35 @@ function ChangesetPage({ name, token, signsAs, c, canWrite, rooted, refused, onC
|
|
|
1507
1874
|
{deployable ? <button type="button" className="primary" onClick={() => void deploy()} disabled={Boolean(offWhy) || busy !== null} title={offWhy ?? 'Perform it against the real accounts behind this World'}>{busy === 'Deploy' ? 'Deploying…' : 'Deploy…'}</button> : null}
|
|
1508
1875
|
</div>
|
|
1509
1876
|
</div>
|
|
1510
|
-
{offWhy &&
|
|
1511
|
-
{
|
|
1877
|
+
{offWhy && sent.label !== 'Deployed' ? <p className="mode">{offWhy}.</p> : null}
|
|
1878
|
+
{problem ? (
|
|
1879
|
+
<div role="alert" className="refusal">
|
|
1880
|
+
<span><b>{problem.what}</b>: {problem.text}</span>
|
|
1881
|
+
{problem.acts.includes('verify') && canWrite ? <button type="button" onClick={verify} disabled={busy !== null}>Verify</button> : null}
|
|
1882
|
+
{problem.acts.includes('approve') && canWrite ? <button type="button" onClick={() => setApproving(true)} disabled={busy !== null}>Approve…</button> : null}
|
|
1883
|
+
<ActLinks name={name} acts={problem.acts} />
|
|
1884
|
+
</div>
|
|
1885
|
+
) : null}
|
|
1886
|
+
</> : null}
|
|
1887
|
+
{showReview ? <>
|
|
1512
1888
|
{c.narration ? <p className="narration">{c.narration}</p> : null}
|
|
1513
1889
|
|
|
1514
|
-
<h4>
|
|
1515
|
-
|
|
1890
|
+
<h4>Where it went</h4>
|
|
1891
|
+
<div className="block where">
|
|
1892
|
+
{sent.performed > 0 ? (
|
|
1893
|
+
<p className="pad">{sent.performed >= sent.total ? 'Deployed' : 'Partly deployed'}: {sent.performed} of {sent.total} change{sent.total === 1 ? '' : 's'} performed at {listed([...new Set([...here.map((r) => r.service), ...receipts.filter((r) => r.status === 'confirmed').map((r) => r.service)].map(vendorName))])}{here.length ? ` (${here.length} from here` : ' ('}{here.length && byRemote ? ', ' : ''}{byRemote ? `${byRemote} by the remote` : ''}).</p>
|
|
1894
|
+
) : sent.label === 'Pushed' ? (
|
|
1895
|
+
<p className="pad">Pushed to the remote{there ? <> <code>{there}</code></> : null}{c.applied ? <> · <At iso={c.applied.at} /></> : null}: every change was replayed there. No vendor has performed any of it.</p>
|
|
1896
|
+
) : sent.failed === 0 ? (
|
|
1897
|
+
<p className="pad">Not sent: it is only in this World.{c.applied?.outcome === 'refused' ? ' A push was refused (below); Push sends it again.' : <> Push sends it to the remote World{deployable ? '; Deploy, to the real vendors behind it' : ''}.</>}</p>
|
|
1898
|
+
) : null}
|
|
1899
|
+
{sent.failed > 0 ? <p className="pad failed" role="alert">{sent.failed} change{sent.failed === 1 ? ' was' : 's were'} refused or failed at the vendor{[...atVendor.filter((r) => r.reason && (r.status === 'refused' || r.status === 'failed')).map((r) => r.reason!), ...receipts.filter((r) => r.status === 'failed' && r.error).map((r) => r.error!)].slice(0, 3).map((w) => `: ${plainly(w).text}`).join('')}.</p> : null}
|
|
1900
|
+
{c.applied?.refusal?.length ? <ul className="checks pad">{c.applied.refusal.map((r, i) => <li key={i} className="mode">{plainly(r).text}</li>)}</ul> : null}
|
|
1901
|
+
{atVendor.length ? <><p className="pad where-sub mode">At the vendor</p><ul className="receipts pad">{atVendor.map((r) => <li key={`v:${r.actionId}`}><span className={r.status === 'refused' || r.status === 'failed' ? 'badge cs-bad' : r.status === 'deployed' ? 'badge cs-done' : 'badge'}>{r.status}</span> {vendorName(r.service)} {r.status === 'deployed' ? (r.url ? <a href={r.url} target="_blank" rel="noopener noreferrer">{r.externalId ?? r.url}</a> : r.externalId ? <code>{r.externalId}</code> : null) : null}{r.reason ? <span className="mode"> · {plainly(r.reason).text}</span> : null} <span className="mode">· <At iso={r.at} /></span></li>)}</ul></> : null}
|
|
1902
|
+
{receipts.length ? <><p className="pad where-sub mode">At the remote</p><ul className="receipts pad">{receipts.map((r) => <li key={`r:${r.actionId}`}><span className={r.status === 'failed' ? 'badge cs-bad' : r.status === 'confirmed' ? 'badge cs-done' : 'badge'}>{r.status}</span> {vendorName(r.service)} <span className="mode">{r.status === 'replayed' ? 'replayed at the remote World' : r.status === 'confirmed' ? 'performed at the vendor by the remote' : ''}</span>{r.status === 'confirmed' && r.externalId ? <> <code>{r.externalId}</code></> : null}{r.error ? <span className="mode"> · {plainly(r.error).text}</span> : null}</li>)}</ul></> : null}
|
|
1903
|
+
</div>
|
|
1904
|
+
{!deployable && sent.label !== 'Deployed' ? <p className="mode">No vendor it changes is connected to its real service here, so it does not deploy from this World. <a href={`${BASE}/${name}/~/settings/services`} onClick={(e) => { e.preventDefault(); go([...name.split('/'), '~', 'settings', 'services']); }}>Connect a vendor in Settings › Real services</a> to deploy.</p> : null}
|
|
1905
|
+
|
|
1516
1906
|
|
|
1517
1907
|
<h4>Checks</h4>
|
|
1518
1908
|
{!c.verification ? <p className="mode">Not verified yet. Verify replays it in a throwaway World and runs this World's checks over the result.</p> : (
|
|
@@ -1527,15 +1917,7 @@ function ChangesetPage({ name, token, signsAs, c, canWrite, rooted, refused, onC
|
|
|
1527
1917
|
{c.approvals.length === 0 ? <p className="mode">No one has approved it.</p> : (
|
|
1528
1918
|
<ul className="approvals">{c.approvals.map((a, i) => <li key={i}><b>{a.principal}</b> <span className="mode"><At iso={a.at} />{current(a.contentHash) ? '' : ' · signed an earlier version'}</span>{a.note ? <div className="mode">“{a.note}”</div> : null}</li>)}</ul>
|
|
1529
1919
|
)}
|
|
1530
|
-
|
|
1531
|
-
{c.applied ? (
|
|
1532
|
-
<>
|
|
1533
|
-
<h4>Deployed</h4>
|
|
1534
|
-
<p>{c.applied.outcome === 'applied' ? 'Every change reached the real vendors' : c.applied.outcome === 'partial' ? 'Some changes reached the real vendors' : 'The deploy was refused'} · <At iso={c.applied.at} /></p>
|
|
1535
|
-
{c.applied.refusal?.length ? <ul className="checks">{c.applied.refusal.map((r, i) => <li key={i} className="mode">{r}</li>)}</ul> : null}
|
|
1536
|
-
{c.applied.receipts.length ? <ul className="receipts">{c.applied.receipts.map((r) => <li key={r.actionId}><span className={r.status === 'failed' ? 'badge cs-bad' : 'badge cs-done'}>{r.status}</span> {vendorName(r.service)} {r.url ? <a href={r.url} target="_blank" rel="noopener noreferrer">{r.externalId ?? r.url}</a> : r.externalId ? <code>{r.externalId}</code> : null}{r.error ? <span className="mode"> · {r.error}</span> : null}</li>)}</ul> : null}
|
|
1537
|
-
</>
|
|
1538
|
-
) : null}
|
|
1920
|
+
</> : null}
|
|
1539
1921
|
|
|
1540
1922
|
<Dialog open={approving} onClose={() => { if (!busy) setApproving(false); }} title={`Approve ${c.name}`} footer={<><button type="button" onClick={() => setApproving(false)} disabled={busy !== null}>Cancel</button><button type="submit" form="approve-changeset" className="primary" disabled={busy !== null}>{busy === 'Approve' ? 'Approving…' : 'Approve'}</button></>}>
|
|
1541
1923
|
<form id="approve-changeset" onSubmit={(e) => void approve(e)} className="stack">
|
|
@@ -1548,56 +1930,100 @@ function ChangesetPage({ name, token, signsAs, c, canWrite, rooted, refused, onC
|
|
|
1548
1930
|
);
|
|
1549
1931
|
}
|
|
1550
1932
|
|
|
1551
|
-
/**
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1933
|
+
/** Two field values the same, whatever their shape. */
|
|
1934
|
+
const same = (a: unknown, b: unknown): boolean => JSON.stringify(a) === JSON.stringify(b);
|
|
1935
|
+
/** What a set of changes does, as a working-tree diff reads (GitHub's): by vendor, then record by record, each write
|
|
1936
|
+
* with the fields it set, field by field: the value it had before (an earlier write here) and the value now. A field a
|
|
1937
|
+
* later write sets to what it already was is not a change and is not shown. The Changes page's, a changeset's and a
|
|
1938
|
+
* branch's one rendering. */
|
|
1939
|
+
function RecordChanges({ changes, idPrefix }: { changes: Array<{ twin: string; entry: Entry }>; idPrefix: string }): React.JSX.Element {
|
|
1940
|
+
const vendors = [...new Set(changes.map((c) => c.twin))];
|
|
1941
|
+
return (
|
|
1942
|
+
<>{vendors.map((t) => {
|
|
1943
|
+
const mine = changes.filter((c) => c.twin === t).map((c) => c.entry);
|
|
1944
|
+
const records = [...new Set(mine.map((e) => `${e.subject.type}:${e.subject.id}`))];
|
|
1945
|
+
return (
|
|
1946
|
+
<div key={t} className="compare" role="group" aria-labelledby={`${idPrefix}-${t}`}>
|
|
1947
|
+
<h3 id={`${idPrefix}-${t}`} className="vendor-cell"><Mark vendor={t} />{vendorName(t)} <span className="mode">· {counted(records.length, 'record')}, {counted(mine.length, 'write')}</span></h3>
|
|
1948
|
+
<ul>{records.map((rid) => {
|
|
1949
|
+
const writes = mine.filter((e) => `${e.subject.type}:${e.subject.id}` === rid);
|
|
1950
|
+
const head = writes[writes.length - 1]!; const s = said(head);
|
|
1951
|
+
let before: Record<string, unknown> = {};
|
|
1952
|
+
return (
|
|
1953
|
+
<li key={rid} className="record">
|
|
1954
|
+
<p className="record-head"><span className="kind">{head.subject.type.replace(/^_+/, '').replace(/[_-]+/g, ' ')}</span> {s.label ? <span className="label">“{s.label}”</span> : null} <code>{shown(head)}</code></p>
|
|
1955
|
+
{writes.map((e) => {
|
|
1956
|
+
const f = e.fields ?? {};
|
|
1957
|
+
const rows = Object.entries(f).filter(([k, v]) => !(k in before) || !same(before[k], v));
|
|
1958
|
+
const was = before; before = { ...before, ...f };
|
|
1959
|
+
return (
|
|
1960
|
+
<div key={e.id} className="write">
|
|
1961
|
+
<p className="mode"><span className="op">{said(e).what}</span> · <At iso={e.occurredAt} /> · {whoDid(e)}</p>
|
|
1962
|
+
{rows.length === 0 ? <p className="mode">It set no field to a new value.</p> : (
|
|
1963
|
+
<dl className="fields field-diff" aria-label={`Fields ${said(e).what} set`}>{rows.map(([k, v]) => (
|
|
1964
|
+
<React.Fragment key={k}>
|
|
1965
|
+
<dt>{k}</dt>
|
|
1966
|
+
<dd>{k in was ? <span className="changed"><del><Value v={was[k]} /></del><span className="arrow" aria-label="becomes">→</span><ins><Value v={v} /></ins></span> : <Value v={v} />}</dd>
|
|
1967
|
+
</React.Fragment>
|
|
1968
|
+
))}</dl>
|
|
1969
|
+
)}
|
|
1970
|
+
</div>
|
|
1971
|
+
);
|
|
1972
|
+
})}
|
|
1973
|
+
</li>
|
|
1974
|
+
);
|
|
1975
|
+
})}</ul>
|
|
1976
|
+
</div>
|
|
1977
|
+
);
|
|
1978
|
+
})}</>
|
|
1979
|
+
);
|
|
1574
1980
|
}
|
|
1575
1981
|
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1982
|
+
/** Cut a changeset (committing from the working changes): this branch's changes not cut, as one changeset to review
|
|
1983
|
+
* and push. Said on the page when it is done or refused. */
|
|
1984
|
+
function CutChangeset({ name, token, changes, canWrite, refused, onCut, pinned, generated, inputRef, author }: { name: string; token: string; changes: number; canWrite: boolean; refused: (r: Response) => void; onCut: () => void; /** the workspace's: pinned along the foot of Changes, there (and off) when there is nothing to cut, as a commit box is */ pinned?: boolean; /** this World's rules take the summary generated from the changes when none is given */ generated?: boolean; /** who cuts it and onto which branch, as GitHub Desktop's commit box shows its author and "Commit 1 file to main" */ author?: { face: React.ReactNode; who: string; account: string; branch: string }; /** the message box, for the toolbar's Cut to focus */ inputRef?: React.RefObject<HTMLInputElement | null> }): React.JSX.Element {
|
|
1985
|
+
const [message, setMessage] = useState(''); const [busy, setBusy] = useState(false); const [log, setLog] = useState<Said[]>([]);
|
|
1986
|
+
const say = (ok: boolean, text: string, acts?: Act[]): void => { setLog((l) => [{ ok, text, at: new Date().toISOString(), n: ++saidCount, ...(acts?.length ? { acts } : {}) }, ...l].slice(0, 4)); };
|
|
1987
|
+
const cut = async (e: React.FormEvent): Promise<void> => {
|
|
1988
|
+
e.preventDefault(); setBusy(true);
|
|
1989
|
+
try {
|
|
1990
|
+
const r = await door(token, `/-/${name}/changesets`, { method: 'POST', body: JSON.stringify(message.trim() ? { message: message.trim() } : {}) });
|
|
1991
|
+
if (!r.ok) { refused(r); const p = plainly(await doorWords(r)); say(false, `Cut a changeset: ${p.text}`, p.acts); return; }
|
|
1992
|
+
const b = (await r.json()) as { name?: string; actions?: unknown[] };
|
|
1993
|
+
say(true, `Cut ${b.name ?? 'a changeset'}: ${counted(b.actions?.length ?? 0, 'change')}, ready to review in History and push.`); setMessage(''); onCut();
|
|
1994
|
+
} catch (err) { say(false, `Cut a changeset: the World did not answer (${err instanceof Error ? err.message : String(err)})`); } finally { setBusy(false); }
|
|
1995
|
+
};
|
|
1996
|
+
if (pinned) {
|
|
1997
|
+
const off = !canWrite ? 'This session reads the World; it changes nothing' : changes === 0 ? 'Nothing to cut: no change is outside a changeset' : null;
|
|
1998
|
+
return (
|
|
1999
|
+
<div className="ws-cut">
|
|
2000
|
+
<Narration log={log} name={name} />
|
|
2001
|
+
<form onSubmit={(e) => void cut(e)} aria-label="Cut a changeset">
|
|
2002
|
+
{author ? <a className="ws-author" href={author.account} title={`${author.who}: your account`} onClick={(e) => { e.preventDefault(); go(author.account.slice(BASE.length + 1).split('/')); }}>{author.face}</a> : null}
|
|
2003
|
+
<input ref={inputRef} name="changesetMessage" aria-label="Summary" value={message} onChange={(e) => setMessage(e.target.value)} placeholder={changes === 0 ? 'Nothing to cut' : generated ? 'Summary (optional)' : 'Summary (required)'} required={!generated} maxLength={200} disabled={Boolean(off)} title={off ?? undefined} />
|
|
2004
|
+
<button type="submit" className="primary" disabled={Boolean(off) || busy} title={off ?? undefined}>{busy ? 'Cutting…' : changes === 0 ? 'Cut a changeset' : <>Cut {counted(changes, 'change')}{author ? <> on <b>{author.branch}</b></> : null}</>}</button>
|
|
2005
|
+
</form>
|
|
2006
|
+
</div>
|
|
2007
|
+
);
|
|
2008
|
+
}
|
|
2009
|
+
// nothing to cut (or no write): only what the last cut said stays
|
|
2010
|
+
if (!canWrite || changes === 0) return <Narration log={log} name={name} />;
|
|
1583
2011
|
return (
|
|
1584
2012
|
<>
|
|
1585
|
-
<
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
</>
|
|
1591
|
-
) : null}
|
|
2013
|
+
<form className="block cut pad" onSubmit={(e) => void cut(e)} aria-label="Cut a changeset">
|
|
2014
|
+
<label><span>Cut {changes === 1 ? 'this change' : `these ${changes} changes`} into a changeset: what it does and why</span><input name="changesetMessage" value={message} onChange={(e) => setMessage(e.target.value)} placeholder="Welcome email for new sign-ups" required maxLength={200} disabled={changes === 0} /></label>
|
|
2015
|
+
<button type="submit" className="primary" disabled={busy || changes === 0}>{busy ? 'Cutting…' : 'Cut a changeset'}</button>
|
|
2016
|
+
</form>
|
|
2017
|
+
<Narration log={log} name={name} />
|
|
1592
2018
|
</>
|
|
1593
2019
|
);
|
|
1594
2020
|
}
|
|
1595
2021
|
|
|
1596
|
-
|
|
2022
|
+
|
|
1597
2023
|
/** A World's NAMED KEYS, as a service's API keys page is (Neon's, Resend's): each named for what holds it, shown once when
|
|
1598
2024
|
* made, listed with when it was last used, and revoked alone; `volter remote add` makes one through the platform. */
|
|
1599
2025
|
function Keys({ name, token }: { name: string; token: string }): React.JSX.Element {
|
|
1600
|
-
type Key = { id: string; name: string; scope: string; createdAt: string; lastUsedAt: string | null };
|
|
2026
|
+
type Key = { id: string; name: string; scope: string; createdAt: string; lastUsedAt: string | null; /** the person it acts for, when it names one */ person?: string | null };
|
|
1601
2027
|
const [version, setVersion] = useState(0);
|
|
1602
2028
|
const keys = useDoor<{ keys: Key[] }>(token, `/-/${name}/keys`, version);
|
|
1603
2029
|
const [making, setMaking] = useState(false); const [keyName, setKeyName] = useState(''); const [scope, setScope] = useState<'write' | 'read'>('write'); const [busy, setBusy] = useState(false); const [error, setError] = useState<string | null>(null);
|
|
@@ -1609,7 +2035,7 @@ function Keys({ name, token }: { name: string; token: string }): React.JSX.Eleme
|
|
|
1609
2035
|
const label = `Read-only link · ${new Date().toLocaleDateString(undefined, { month: 'short', day: 'numeric' })}`;
|
|
1610
2036
|
const r = await door(token, `/-/${name}/keys`, { method: 'POST', body: JSON.stringify({ name: label, scope: 'read' }) }).catch(() => null);
|
|
1611
2037
|
const b = r ? ((await r.json().catch(() => ({}))) as { key?: string; error?: string }) : { error: `${ORIGIN} did not answer` };
|
|
1612
|
-
if (!r?.ok || !b.key) { toast(b.error
|
|
2038
|
+
if (!r?.ok || !b.key) { toast(b.error ? plainly(b.error).text : 'refused', 'error'); return; }
|
|
1613
2039
|
setLink(`${window.location.origin}${BASE}/${name}#${new URLSearchParams({ token: b.key }).toString()}`); setVersion((v) => v + 1);
|
|
1614
2040
|
};
|
|
1615
2041
|
const make = async (e: React.FormEvent): Promise<void> => {
|
|
@@ -1617,7 +2043,7 @@ function Keys({ name, token }: { name: string; token: string }): React.JSX.Eleme
|
|
|
1617
2043
|
const r = await door(token, `/-/${name}/keys`, { method: 'POST', body: JSON.stringify({ name: keyName.trim(), scope }) }).catch(() => null);
|
|
1618
2044
|
setBusy(false);
|
|
1619
2045
|
const b = r ? ((await r.json().catch(() => ({}))) as { key?: string; error?: string }) : { error: `${ORIGIN} did not answer` };
|
|
1620
|
-
if (!r?.ok || !b.key) { setError(b.error
|
|
2046
|
+
if (!r?.ok || !b.key) { setError(b.error ? plainly(b.error).text : 'refused'); return; }
|
|
1621
2047
|
setMaking(false); setMade({ key: b.key, name: keyName.trim() }); setKeyName(''); setVersion((v) => v + 1);
|
|
1622
2048
|
};
|
|
1623
2049
|
const revoke = async (k: Key): Promise<void> => {
|
|
@@ -1633,9 +2059,9 @@ function Keys({ name, token }: { name: string; token: string }): React.JSX.Eleme
|
|
|
1633
2059
|
<Pending load={keys} what="the keys" retry={() => setVersion((v) => v + 1)} />
|
|
1634
2060
|
{keys.state === 'ok' ? (keys.data.keys.length === 0 ? <p className="mode">No keys yet. The World's own token opens it too.</p> : (
|
|
1635
2061
|
<table className="dense" aria-label="Keys">
|
|
1636
|
-
<thead><tr><th>Name</th><th>Access</th><th>Made</th><th>Last used</th><th className="num"><span className="sr">Actions</span></th></tr></thead>
|
|
2062
|
+
<thead><tr><th>Name</th><th>For</th><th>Access</th><th>Made</th><th>Last used</th><th className="num"><span className="sr">Actions</span></th></tr></thead>
|
|
1637
2063
|
<tbody>{keys.data.keys.map((k) => (
|
|
1638
|
-
<tr key={k.id}><td>{k.name}</td><td>{k.scope === 'write' ? 'Read and write' : 'Read only'}</td><td className="mode"><span title={when(k.createdAt)}>{ago(k.createdAt)}</span></td><td className="mode">{k.lastUsedAt ? ago(k.lastUsedAt) : 'never'}</td><td className="num"><Menu label={`More for ${k.name}`} items={[{ label: 'Revoke…', danger: true, onSelect: () => void revoke(k) }]} /></td></tr>
|
|
2064
|
+
<tr key={k.id}><td>{k.name}</td><td>{k.person ?? <span className="mode" title="It acts as itself: a service">no one: a service</span>}</td><td>{k.scope === 'write' ? 'Read and write' : 'Read only'}</td><td className="mode"><span title={when(k.createdAt)}>{ago(k.createdAt)}</span></td><td className="mode">{k.lastUsedAt ? ago(k.lastUsedAt) : 'never'}</td><td className="num"><Menu label={`More for ${k.name}`} items={[{ label: 'Revoke…', danger: true, onSelect: () => void revoke(k) }]} /></td></tr>
|
|
1639
2065
|
))}</tbody>
|
|
1640
2066
|
</table>
|
|
1641
2067
|
)) : null}
|
|
@@ -1658,182 +2084,298 @@ function Keys({ name, token }: { name: string; token: string }): React.JSX.Eleme
|
|
|
1658
2084
|
);
|
|
1659
2085
|
}
|
|
1660
2086
|
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
2087
|
+
/** What an act did, said on the page where it was done: its words, and whether it went. */
|
|
2088
|
+
type Said = { ok: boolean; text: string; at: string; n: number; acts?: Act[] };
|
|
2089
|
+
/** Each narrated line's own key: two acts can finish within one millisecond. */
|
|
2090
|
+
let saidCount = 0;
|
|
2091
|
+
function Narration({ log, name }: { log: Said[]; name: string }): React.JSX.Element | null {
|
|
2092
|
+
if (log.length === 0) return null;
|
|
2093
|
+
return <ul className="narration-log" role="status" aria-live="polite">{log.map((l) => <li key={l.n} className={l.ok ? 'ok' : 'bad'}><span className="mode"><At iso={l.at} time /></span> {l.text}{l.acts ? <ActLinks name={name} acts={l.acts} /> : null}</li>)}</ul>;
|
|
2094
|
+
}
|
|
2095
|
+
|
|
2096
|
+
/** Push and pull through the origin's doors, and what they read (the origin, the changes not cut, the changesets not
|
|
2097
|
+
* pushed): Remote's acts, and the workspace toolbar's. Each act is said in `log`, a refusal as a toast too. */
|
|
2098
|
+
function usePushPull(name: string, token: string, tick: number, refused: (r: Response) => void) {
|
|
2099
|
+
const [version, setVersion] = useState(0);
|
|
2100
|
+
const every = version * 100_000 + Math.floor(tick / 2);
|
|
2101
|
+
const origin = useDoor<OriginDoor>(token, `/-/${name}/origin`, every);
|
|
2102
|
+
const diff = useDoor<Diff>(token, `/-/${name}/diff`, every);
|
|
2103
|
+
const sets = useDoor<{ changesets: ChangesetView[] }>(token, `/-/${name}/changesets`, every);
|
|
2104
|
+
const [log, setLog] = useState<Said[]>([]); const [busy, setBusy] = useState<string | null>(null);
|
|
2105
|
+
const say = (ok: boolean, text: string, acts?: Act[]): void => { setLog((l) => [{ ok, text, at: new Date().toISOString(), n: ++saidCount, ...(acts?.length ? { acts } : {}) }, ...l].slice(0, 8)); if (!ok) toast(text, 'error'); };
|
|
2106
|
+
/** One act through its door: the answer's body, or null when it was refused (and said so). */
|
|
2107
|
+
const send = async <T,>(what: string, path: string, init: RequestInit): Promise<T | null> => {
|
|
2108
|
+
setBusy(what);
|
|
2109
|
+
try {
|
|
2110
|
+
const r = await door(token, `/-/${name}/${path}`, init);
|
|
2111
|
+
if (!r.ok) { refused(r); const p = plainly(await doorWords(r)); say(false, `${what}: ${p.text}`, p.acts); return null; }
|
|
2112
|
+
return (await r.json()) as T;
|
|
2113
|
+
} catch (e) { say(false, `${what}: the World did not answer (${e instanceof Error ? e.message : String(e)})`); return null; } finally { setBusy(null); setVersion((v) => v + 1); }
|
|
2114
|
+
};
|
|
2115
|
+
const there = origin.state === 'ok' && origin.data.origin ? originAddress(origin.data.origin) : null;
|
|
2116
|
+
const changes = diff.state === 'ok' ? diff.data.changes.length : null;
|
|
2117
|
+
const waiting = sets.state === 'ok' ? unpushed(sets.data.changesets).sort((a, b) => a.createdAt.localeCompare(b.createdAt)) : [];
|
|
2118
|
+
const push = async (): Promise<void> => {
|
|
2119
|
+
const b = await send<{ pushed: Array<{ changeset: string; outcome: string; receipts?: Array<{ status: string }>; refusal?: string[] }> }>('Push', 'origin/push', { method: 'POST' });
|
|
2120
|
+
if (!b) return;
|
|
2121
|
+
if (b.pushed.length === 0) { say(true, 'Nothing to push: every changeset is already at the remote.'); return; }
|
|
2122
|
+
for (const p of b.pushed) {
|
|
2123
|
+
const rs = p.receipts ?? [];
|
|
2124
|
+
const replayed = rs.filter((r) => r.status === 'replayed').length; const confirmed = rs.filter((r) => r.status === 'confirmed').length; const failed = rs.filter((r) => r.status === 'failed').length;
|
|
2125
|
+
const ok = p.outcome === 'applied' && failed === 0;
|
|
2126
|
+
if (p.outcome === 'refused') { say(false, `Push of ${p.changeset} was refused${p.refusal?.length ? `: ${p.refusal.map((w) => plainly(w).text).join('; ')}` : ''}.`); continue; }
|
|
2127
|
+
const parts = [`${replayed} of ${counted(rs.length, 'change')} replayed there`, ...(confirmed ? [`${confirmed} performed at the vendor by the remote`] : []), ...(failed ? [`${failed} failed`] : [])];
|
|
2128
|
+
say(ok, `${p.outcome === 'partial' ? 'Partly pushed' : 'Pushed'} ${p.changeset} to ${there ?? 'the remote'}: ${parts.join('; ')}${p.refusal?.length ? ` (${p.refusal.map((w) => plainly(w).text).join('; ')})` : ''}.`);
|
|
2129
|
+
}
|
|
2130
|
+
};
|
|
2131
|
+
const pull = async (): Promise<void> => {
|
|
2132
|
+
const b = await send<{ appended: Record<string, number>; rebased: Array<{ twin: string; moved: boolean; conflicts: Array<{ subject: { type: string; id: string }; field: string }> }> }>('Pull', 'origin/pull', { method: 'POST' });
|
|
2133
|
+
if (!b) return;
|
|
2134
|
+
const came = Object.entries(b.appended).filter(([, n]) => n > 0);
|
|
2135
|
+
const conflicts = b.rebased.flatMap((r) => r.conflicts.map((c) => `${vendorName(r.twin)} ${c.subject.type}:${c.subject.id} ${c.field}`));
|
|
2136
|
+
say(conflicts.length === 0, `Pulled from ${there ?? 'the remote'}: ${came.length ? came.map(([t, n]) => `${counted(n, 'entry')} at ${vendorName(t)}`).join(', ') : 'nothing new'}${conflicts.length ? `; conflicts at ${conflicts.join(', ')}` : ''}.`);
|
|
2137
|
+
};
|
|
2138
|
+
return { origin, diff, sets, there, changes, waiting, log, busy, say, send, push, pull };
|
|
2139
|
+
}
|
|
2140
|
+
|
|
2141
|
+
/** THE REMOTE, as GitHub Desktop's push and pull are: the World this one pushes to and pulls from (its origin), how much
|
|
2142
|
+
* is not yet pushed there, and the acts: cut the changes into a changeset, push, pull, name the origin. Each act says
|
|
2143
|
+
* what it did on this page. */
|
|
2144
|
+
function Remote({ name, token, canWrite, tick, refused }: { name: string; token: string; canWrite: boolean; tick: number; refused: (r: Response) => void }): React.JSX.Element {
|
|
2145
|
+
const { origin, there, changes, log, busy, say, send, push, pull } = usePushPull(name, token, tick, refused);
|
|
2146
|
+
const [url, setUrl] = useState(''); const [secret, setSecret] = useState(''); const [naming, setNaming] = useState(false);
|
|
2147
|
+
const name_ = async (e: React.FormEvent): Promise<void> => {
|
|
2148
|
+
e.preventDefault();
|
|
2149
|
+
const b = await send<{ origin: { url: string; namespace: string }; appended: Record<string, number> }>('Name the remote', 'origin', { method: 'PUT', body: JSON.stringify({ url: url.trim(), token: secret.trim() }) });
|
|
2150
|
+
if (b) { const n = Object.values(b.appended ?? {}).reduce((a, x) => a + x, 0); say(true, `The remote is ${originAddress(b.origin)}: its history came in (${counted(n, 'entry')}).`); setSecret(''); setNaming(false); }
|
|
2151
|
+
};
|
|
2152
|
+
const off = !canWrite ? 'This session reads the World; it changes nothing' : null;
|
|
2153
|
+
return (
|
|
2154
|
+
<>
|
|
2155
|
+
<div className="block remote">
|
|
2156
|
+
<div className="block-head"><h3>Origin</h3>{canWrite ? <button type="button" onClick={() => { setUrl(there ?? ''); setNaming(!naming); }} aria-expanded={naming}>{there ? 'Change the remote…' : 'Name a remote…'}</button> : null}</div>
|
|
2157
|
+
<div className="pad">
|
|
2158
|
+
{origin.state === 'ok' ? (there
|
|
2159
|
+
? <p><code className="big">{there}</code>{origin.data.origin?.fetchedAt ? <span className="mode"> · last fetched <At iso={origin.data.origin.fetchedAt} /></span> : null}</p>
|
|
2160
|
+
: <p>No remote — this World keeps its default data. Name one to push its changesets to a shared World and pull what others pushed there.</p>) : <Pending load={origin} what="the remote" lines={1} />}
|
|
2161
|
+
{naming ? (
|
|
2162
|
+
<form className="stack origin-form" onSubmit={(e) => void name_(e)} aria-label="Name the remote">
|
|
2163
|
+
<label><span>The World's address</span><input name="originUrl" value={url} onChange={(e) => setUrl(e.target.value)} placeholder="https://worlds.example.com/acme/team" required /></label>
|
|
2164
|
+
<label><span>A key or token that opens it</span><input name="originToken" type="password" value={secret} onChange={(e) => setSecret(e.target.value)} required autoComplete="off" /></label>
|
|
2165
|
+
<p className="mode">Naming it brings its history in. Your pushes are recorded there as whoever this key was made for, or as the key itself.</p>
|
|
2166
|
+
<div className="row-actions"><button type="submit" className="primary" disabled={busy !== null}>{busy === 'Name the remote' ? 'Bringing its history in…' : 'Save the remote'}</button><button type="button" onClick={() => setNaming(false)}>Cancel</button></div>
|
|
2167
|
+
</form>
|
|
2168
|
+
) : null}
|
|
2169
|
+
</div>
|
|
2170
|
+
</div>
|
|
2171
|
+
<Narration log={log} name={name} />
|
|
2172
|
+
</>
|
|
2173
|
+
);
|
|
2174
|
+
}
|
|
2175
|
+
|
|
2176
|
+
/** THE VAULT, as Supabase's is: the credentials this World holds for real vendors (each sealed for the one place it may
|
|
2177
|
+
* go: a vendor's root, or a link), whether each is sealed, when and by what, and sealing one; then the links, each a
|
|
2178
|
+
* vendor this World forwards to with its sealed credential, with their requests. A credential is never shown back. */
|
|
2179
|
+
function Vault({ name, token, canWrite, refused }: { name: string; token: string; canWrite: boolean; refused: (r: Response) => void }): React.JSX.Element {
|
|
2180
|
+
const [version, setVersion] = useState(0);
|
|
2181
|
+
const twins = useDoor<{ twins: Twin[] }>(token, `/-/${name}/twins`, version);
|
|
2182
|
+
const links = useDoor<{ links: Record<string, { origin: string }> }>(token, `/-/${name}/links`, version);
|
|
2183
|
+
const report = useDoor<RequestReport>(token, `/-/${name}/requests?hours=24`, version);
|
|
2184
|
+
const [linkCreds, setLinkCreds] = useState<Record<string, NonNullable<Twin['credential']> | null>>({});
|
|
2185
|
+
const [sealing, setSealing] = useState<{ vendor: string; to: string } | null>(null); const [secret, setSecret] = useState(''); const [busy, setBusy] = useState(false);
|
|
2186
|
+
const [log, setLog] = useState<Said[]>([]);
|
|
2187
|
+
const say = (ok: boolean, text: string, acts?: Act[]): void => { setLog((l) => [{ ok, text, at: new Date().toISOString(), n: ++saidCount, ...(acts?.length ? { acts } : {}) }, ...l].slice(0, 6)); if (!ok) toast(text, 'error'); };
|
|
2188
|
+
const linkNames = links.state === 'ok' ? Object.keys(links.data.links).sort() : [];
|
|
2189
|
+
// a link's credential is its own door's answer (`…/twins/<link>/credential`): sealed or not, never the secret
|
|
2190
|
+
useEffect(() => {
|
|
2191
|
+
let live = true;
|
|
2192
|
+
void Promise.all(linkNames.map(async (l) => { const r = await door(token, `/-/${name}/twins/${l}/credential`).catch(() => null); const b = r?.ok ? ((await r.json()) as { credential?: Twin['credential'] }) : null; return [l, b?.credential ?? null] as const; })).then((all) => { if (live) setLinkCreds(Object.fromEntries(all)); });
|
|
2193
|
+
return () => { live = false; };
|
|
2194
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: the links' names are what change it
|
|
2195
|
+
}, [name, token, linkNames.join(','), version]);
|
|
2196
|
+
const seal = async (e: React.FormEvent): Promise<void> => {
|
|
2197
|
+
e.preventDefault(); if (!sealing || !secret) return; setBusy(true);
|
|
2198
|
+
const r = await door(token, `/-/${name}/twins/${sealing.vendor}/credential`, { method: 'PUT', body: secret, headers: { 'content-type': 'text/plain' } }).catch(() => null);
|
|
2199
|
+
setBusy(false);
|
|
2200
|
+
if (!r) { say(false, `Seal ${sealing.vendor}: ${ORIGIN} did not answer`); return; }
|
|
2201
|
+
if (!r.ok) { refused(r); const p = plainly(await doorWords(r)); say(false, `Seal ${vendorName(sealing.vendor)}: ${p.text}`, p.acts); return; }
|
|
2202
|
+
const b = (await r.json().catch(() => ({}))) as { fingerprint?: string; boundTo?: string };
|
|
2203
|
+
say(true, `Sealed a credential for ${vendorName(sealing.vendor)}${b.fingerprint ? ` (${b.fingerprint})` : ''}: it opens for ${b.boundTo ?? sealing.to} only, and is never shown again.`);
|
|
2204
|
+
setSecret(''); setSealing(null); setVersion((v) => v + 1);
|
|
2205
|
+
};
|
|
2206
|
+
const removeLink = async (l: string): Promise<void> => {
|
|
2207
|
+
if (!(await confirmAct({ title: `Remove the link ${l}?`, body: `Requests to ${WORLDS}/${name}/${l}/… stop being forwarded to ${links.state === 'ok' ? links.data.links[l]?.origin : 'its origin'}.`, action: 'Remove link', danger: true }))) return;
|
|
2208
|
+
const r = await door(token, `/-/${name}/links/${l}`, { method: 'DELETE' }).catch(() => null);
|
|
2209
|
+
if (!r?.ok) { if (r) refused(r); say(false, `Remove ${l}: ${r ? await failure(r) : 'the World did not answer'}`); return; }
|
|
2210
|
+
say(true, `Removed the link ${l}.`); setVersion((v) => v + 1);
|
|
2211
|
+
};
|
|
2212
|
+
const sealedText = (c: Twin['credential']): React.JSX.Element => (c ? <><span className="badge rooted">sealed</span> <span className="mode">{c.fingerprint ? <code>{c.fingerprint}</code> : null} · <At iso={c.placedAt} />{c.sealedBy ? ` · by ${c.sealedBy === 'vault' ? 'the vault' : 'the host\'s key'}` : ''}</span></> : <span className="mode">none</span>);
|
|
2213
|
+
const list = twins.state === 'ok' ? twins.data.twins : [];
|
|
2214
|
+
return (
|
|
2215
|
+
<>
|
|
2216
|
+
<p className="lede">A real vendor's credential lives in one place, sealed: beside this World, bound to where it goes. It is never readable back; this page says only that one is sealed, when, by what, and for where.</p>
|
|
2217
|
+
<Narration log={log} name={name} />
|
|
2218
|
+
<section className="block" aria-labelledby="creds-head">
|
|
2219
|
+
<div className="block-head"><h3 id="creds-head">Vendors</h3></div>
|
|
2220
|
+
<Pending load={twins} what="the vendors" />
|
|
2221
|
+
{twins.state === 'ok' ? (
|
|
2222
|
+
<table className="dense" aria-label="Credentials by vendor">
|
|
2223
|
+
<thead><tr><th>Vendor</th><th>Credential</th><th>Bound to</th><th>Real service</th>{canWrite ? <th className="num"><span className="sr">Actions</span></th> : null}</tr></thead>
|
|
2224
|
+
<tbody>{list.map((t) => (
|
|
2225
|
+
<tr key={t.twin}>
|
|
2226
|
+
<td><span className="vendor-cell"><Mark vendor={t.twin} />{vendorName(t.twin)}</span></td>
|
|
2227
|
+
<td>{sealedText(t.credential)}</td>
|
|
2228
|
+
<td>{t.credential?.boundTo ? <code>{t.credential.boundTo}</code> : <span className="mode">—</span>}</td>
|
|
2229
|
+
<td>{t.root?.url ? <code>{t.root.url}</code> : <span className="mode">none: a stand-in</span>}</td>
|
|
2230
|
+
{canWrite ? <td className="num"><button type="button" disabled={!t.root?.url} title={t.root?.url ? `Seal ${vendorName(t.twin)}'s real credential for ${t.root.url}` : 'Connect it to its real service first (Settings › Real services): a credential is sealed for where it goes'} onClick={() => { setSecret(''); setSealing({ vendor: t.twin, to: t.root!.url! }); }}>{t.credential ? 'Replace…' : 'Seal a credential…'}</button></td> : null}
|
|
2231
|
+
</tr>
|
|
2232
|
+
))}</tbody>
|
|
2233
|
+
</table>
|
|
2234
|
+
) : null}
|
|
2235
|
+
{twins.state === 'ok' && list.every((t) => !t.root?.url) ? <p className="mode pad">No vendor here has a real service behind it, so none holds a credential. Connect one in Settings › Real services; its credential is sealed here.</p> : null}
|
|
2236
|
+
</section>
|
|
2237
|
+
<section className="block" aria-labelledby="links-head">
|
|
2238
|
+
<div className="block-head"><h3 id="links-head">Links</h3><span className="mode">a vendor with no twin, forwarded with its sealed credential</span></div>
|
|
2239
|
+
<Pending load={links} what="the links" />
|
|
2240
|
+
{links.state === 'ok' && linkNames.length === 0 ? <p className="mode pad">No links. A link forwards requests for a vendor with no twin here, at <code>{WORLDS}/{name}/<link>/…</code>, to that vendor's real API with the credential sealed for it.</p> : null}
|
|
2241
|
+
{linkNames.length ? (
|
|
2242
|
+
<table className="dense" aria-label="Links">
|
|
2243
|
+
<thead><tr><th>Link</th><th>Origin</th><th className="num">Requests, 24h</th><th>Credential</th>{canWrite ? <th className="num"><span className="sr">Actions</span></th> : null}</tr></thead>
|
|
2244
|
+
<tbody>{linkNames.map((l) => {
|
|
2245
|
+
const at = links.state === 'ok' ? links.data.links[l]!.origin : '';
|
|
2246
|
+
const traffic = report.state === 'ok' ? report.data.twins.find((t) => t.twin === l) : undefined;
|
|
2247
|
+
return (
|
|
2248
|
+
<tr key={l}>
|
|
2249
|
+
<td><b>{l}</b></td>
|
|
2250
|
+
<td><code>{at}</code></td>
|
|
2251
|
+
<td className="num">{traffic ? <span title={Object.entries(traffic.byStatus).map(([k, v]) => `${k}: ${v}`).join(' · ')}>{traffic.requests}</span> : <span className="mode">—</span>}</td>
|
|
2252
|
+
<td>{sealedText(linkCreds[l] ?? null)}{linkCreds[l]?.boundTo ? <div className="mode">bound to <code>{linkCreds[l]!.boundTo}</code></div> : null}</td>
|
|
2253
|
+
{canWrite ? <td className="num row-actions"><button type="button" onClick={() => { setSecret(''); setSealing({ vendor: l, to: at }); }}>{linkCreds[l] ? 'Replace…' : 'Seal a credential…'}</button><button type="button" onClick={() => void removeLink(l)}>Remove…</button></td> : null}
|
|
2254
|
+
</tr>
|
|
2255
|
+
);
|
|
2256
|
+
})}</tbody>
|
|
2257
|
+
</table>
|
|
2258
|
+
) : null}
|
|
2259
|
+
</section>
|
|
2260
|
+
<Dialog open={Boolean(sealing)} onClose={() => { if (!busy) setSealing(null); }} title={`Seal ${sealing ? vendorName(sealing.vendor) : ''}'s credential`} footer={<><button type="button" onClick={() => setSealing(null)} disabled={busy}>Cancel</button><button type="submit" form="seal-credential" className="primary" disabled={busy || !secret}>{busy ? 'Sealing…' : 'Seal it'}</button></>}>
|
|
2261
|
+
<form id="seal-credential" className="stack" onSubmit={(e) => void seal(e)}>
|
|
2262
|
+
<p className="mode">It is sealed for <code>{sealing?.to}</code> and opens there only. It is never shown again, here or anywhere.</p>
|
|
2263
|
+
<label><span>The real key</span><input name="credential" type="password" value={secret} onChange={(e) => setSecret(e.target.value)} autoComplete="off" required autoFocus /></label>
|
|
2264
|
+
</form>
|
|
2265
|
+
</Dialog>
|
|
2266
|
+
</>
|
|
2267
|
+
);
|
|
2268
|
+
}
|
|
2269
|
+
|
|
2270
|
+
/** THE SETTINGS, as Neon's and Supabase's project settings are: who may reach this World (its keys and the read-only
|
|
2271
|
+
* link), its connections to the real services (a vendor's root, reading from it, its deploy policy), its checks, and the
|
|
2272
|
+
* host admin's acts on the World itself. */
|
|
2273
|
+
function Settings({ name, token, admin, canWrite, refused, onRemoved, only }: { name: string; token: string; admin: string | null; canWrite: boolean; refused: (r: Response) => void; onRemoved: () => void; /** one section alone (a tab of the workspace's Settings window); every section when absent */ only?: 'access' | 'services' | 'clock' | 'checks' }): React.JSX.Element {
|
|
2274
|
+
const shows = (k: 'access' | 'services' | 'clock' | 'checks'): boolean => !only || only === k;
|
|
2275
|
+
const [version, setVersion] = useState(0);
|
|
2276
|
+
const twins = useDoor<{ twins: Twin[] }>(token, `/-/${name}/twins`, version);
|
|
2277
|
+
const checks = useDoor<{ checks: string[] }>(token, `/-/${name}/checks`, version);
|
|
2278
|
+
const [clockVersion, setClockVersion] = useState(0);
|
|
2279
|
+
const clock = useDoor<Clock>(token, `/-/${name}/clock`, clockVersion);
|
|
2280
|
+
const [log, setLog] = useState<Said[]>([]);
|
|
1665
2281
|
const [tokens, setTokens] = useState<{ token: string; readToken: string } | null>(null);
|
|
1666
|
-
|
|
1667
|
-
const [
|
|
1668
|
-
const
|
|
1669
|
-
const
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
if (!r.ok) refused(r);
|
|
1680
|
-
setNote(r.ok ? `${label}: done` : `${label}: ${await failure(r)}`);
|
|
1681
|
-
await refresh();
|
|
2282
|
+
const [connecting, setConnecting] = useState<Twin | null>(null); const [url, setUrl] = useState(''); const [policy, setPolicy] = useState<'gated' | 'auto' | 'hold'>('gated'); const [busy, setBusy] = useState(false);
|
|
2283
|
+
const say = (ok: boolean, text: string, acts?: Act[]): void => { setLog((l) => [{ ok, text, at: new Date().toISOString(), n: ++saidCount, ...(acts?.length ? { acts } : {}) }, ...l].slice(0, 6)); if (!ok) toast(text, 'error'); };
|
|
2284
|
+
const act = async (label: string, path: string, init: RequestInit): Promise<boolean> => {
|
|
2285
|
+
const r = await door(token, path, init).catch(() => null);
|
|
2286
|
+
if (!r) { say(false, `${label}: the World did not answer`); return false; }
|
|
2287
|
+
if (!r.ok) { refused(r); const p = plainly(await doorWords(r)); say(false, `${label}: ${p.text}`, p.acts); return false; }
|
|
2288
|
+
say(true, `${label}: done.`); setVersion((v) => v + 1); return true;
|
|
2289
|
+
};
|
|
2290
|
+
const connect = async (e: React.FormEvent): Promise<void> => {
|
|
2291
|
+
e.preventDefault(); if (!connecting) return; setBusy(true);
|
|
2292
|
+
const prior = (connecting.root ?? {}) as Record<string, unknown>;
|
|
2293
|
+
const ok = await act(url.trim() ? `Connect ${vendorName(connecting.twin)} to ${url.trim()}` : `Disconnect ${vendorName(connecting.twin)}`, `/-/${name}/twins/${connecting.twin}/root`, { method: 'PUT', body: JSON.stringify(url.trim() ? { ...prior, url: url.trim(), deploy: policy } : null) });
|
|
2294
|
+
setBusy(false); if (ok) setConnecting(null);
|
|
1682
2295
|
};
|
|
1683
2296
|
const rotate = async (): Promise<void> => {
|
|
1684
2297
|
if (!(await confirmAct({ title: `Rotate the tokens of ${name}?`, body: 'Every app, script and person holding the current tokens loses access at once. The new ones are shown once.', action: 'Rotate tokens', danger: true }))) return;
|
|
1685
2298
|
const r = await door(admin!, `/-/worlds/${name}/rotate`, { method: 'POST' });
|
|
1686
|
-
if (r.ok) { setTokens((await r.json()) as { token: string; readToken: string });
|
|
2299
|
+
if (r.ok) { setTokens((await r.json()) as { token: string; readToken: string }); say(true, 'Rotated: the new tokens are shown once, below.'); } else say(false, `Rotate: ${await failure(r)}`);
|
|
1687
2300
|
};
|
|
1688
2301
|
const remove = async (): Promise<void> => {
|
|
1689
2302
|
if (!(await confirmAct({ title: `Remove ${name}?`, body: 'The host stops serving it and its whole history goes with it. This cannot be undone.', action: 'Remove World', danger: true, typed: name }))) return;
|
|
1690
2303
|
const r = await door(admin!, `/-/worlds/${name}`, { method: 'DELETE' }).catch((e: unknown) => e as Error);
|
|
1691
|
-
if (r instanceof Error) {
|
|
1692
|
-
if (r.ok) onRemoved(); else
|
|
2304
|
+
if (r instanceof Error) { say(false, `Remove: the host did not answer (${r.message})`); return; }
|
|
2305
|
+
if (r.ok) onRemoved(); else say(false, `Remove: ${await failure(r)}`);
|
|
1693
2306
|
};
|
|
2307
|
+
const list = twins.state === 'ok' ? twins.data.twins : [];
|
|
2308
|
+
const POLICY: Record<string, string> = { gated: 'gated: a person deploys an approved changeset', auto: 'auto: a push deploys at once', hold: 'hold: nothing deploys' };
|
|
1694
2309
|
return (
|
|
1695
2310
|
<>
|
|
1696
|
-
|
|
1697
|
-
{
|
|
1698
|
-
|
|
1699
|
-
<
|
|
1700
|
-
|
|
1701
|
-
{
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
{
|
|
1713
|
-
<td>
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
<
|
|
1733
|
-
<
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
<td>
|
|
1745
|
-
<button onClick={() => void act(`verify ${c.name}`, `/-/${name}/changesets/${encodeURIComponent(c.name)}/verify`, { method: 'POST' })}>Verify</button>{' '}
|
|
1746
|
-
<button onClick={() => { const as = window.prompt(`Approve ${c.name} as`); if (!as) return; void act(`approve ${c.name}`, `/-/${name}/changesets/${encodeURIComponent(c.name)}/approve`, { method: 'POST', body: JSON.stringify({ as }) }); }}>Approve</button>{' '}
|
|
1747
|
-
<button onClick={() => void confirmAct({ title: `Deploy ${c.name}?`, body: 'Its changes are sent to the real services now. That is what the World exists to keep you from doing by accident.', action: 'Deploy to the real services', danger: true, typed: c.name }).then((ok) => { if (ok) void act(`deploy ${c.name}`, `/-/${name}/deploy/${encodeURIComponent(c.name)}`, { method: 'POST', body: JSON.stringify({ confirm: c.name }) }); })}>Deploy</button>
|
|
1748
|
-
</td>
|
|
1749
|
-
) : null}
|
|
1750
|
-
</tr>
|
|
1751
|
-
))}
|
|
1752
|
-
</tbody>
|
|
1753
|
-
</table>
|
|
1754
|
-
)}
|
|
1755
|
-
{admin ? (
|
|
2311
|
+
<Narration log={log} name={name} />
|
|
2312
|
+
{shows('access') ? <>
|
|
2313
|
+
<h3 className="settings-head">Access</h3>
|
|
2314
|
+
{canWrite ? <Keys name={name} token={token} /> : <p className="mode">This session reads the World: its keys are managed by whoever holds its token.</p>}
|
|
2315
|
+
</> : null}
|
|
2316
|
+
{shows('services') ? <>
|
|
2317
|
+
<h3 className="settings-head">Real services</h3>
|
|
2318
|
+
<p className="mode">{list.some((t) => t.root?.url) ? null : 'Every vendor here is a stand-in. '}Connect a vendor to its real service to read the real state into this World; its real credential is sealed in the Vault, and an approved changeset deploys there by its policy.</p>
|
|
2319
|
+
<Pending load={twins} what="the vendors" />
|
|
2320
|
+
{twins.state === 'ok' ? (
|
|
2321
|
+
<div className="block">
|
|
2322
|
+
<table className="dense" aria-label="Real services">
|
|
2323
|
+
<thead><tr><th>Vendor</th><th>Real service</th><th>Deploy policy</th><th>Last read from it</th>{canWrite ? <th className="num"><span className="sr">Actions</span></th> : null}</tr></thead>
|
|
2324
|
+
<tbody>{list.map((t) => {
|
|
2325
|
+
const root = t.root as { url?: string; deploy?: string } | null | undefined;
|
|
2326
|
+
return (
|
|
2327
|
+
<tr key={t.twin}>
|
|
2328
|
+
<td><span className="vendor-cell"><Mark vendor={t.twin} /><a href={`${BASE}/${name}/${t.twin}`} title={`Its log and stored state · ${t.position ?? 0} entries`} onClick={(e) => { e.preventDefault(); go([...name.split('/'), t.twin]); }}>{vendorName(t.twin)}</a></span></td>
|
|
2329
|
+
<td>{root?.url ? <code>{root.url}</code> : <span className="mode">not connected</span>}</td>
|
|
2330
|
+
<td className="mode">{root?.url ? POLICY[root.deploy ?? ''] ?? root.deploy ?? '—' : '—'}</td>
|
|
2331
|
+
<td>{t.refresh?.failed ? <span role="alert">failed ×{t.refresh.failed.count ?? 1}: {t.refresh.failed.reason ?? ''}</span> : root?.url ? when(t.refresh?.observedAt ?? t.refresh?.at) : <span className="mode">—</span>}</td>
|
|
2332
|
+
{canWrite ? (
|
|
2333
|
+
<td className="num row-actions">
|
|
2334
|
+
<button type="button" onClick={() => { setUrl(root?.url ?? ''); setPolicy(root?.deploy === 'auto' || root?.deploy === 'hold' ? root.deploy : 'gated'); setConnecting(t); }}>{root?.url ? 'Change…' : 'Connect…'}</button>
|
|
2335
|
+
<button type="button" disabled={!root?.url} title={root?.url ? "Read the real service's current state into this World" : 'Connect it first'} onClick={() => void confirmAct({ title: `Read ${vendorName(t.twin)} now?`, body: `The real ${vendorName(t.twin)}'s current state is read into this World. Nothing is written to ${vendorName(t.twin)}.`, action: 'Read from it' }).then((ok) => { if (ok) void act(`Read from ${vendorName(t.twin)}`, `/-/${name}/twins/${t.twin}/refresh`, { method: 'POST' }); })}>Read from it</button>
|
|
2336
|
+
</td>
|
|
2337
|
+
) : null}
|
|
2338
|
+
</tr>
|
|
2339
|
+
);
|
|
2340
|
+
})}</tbody>
|
|
2341
|
+
</table>
|
|
2342
|
+
</div>
|
|
2343
|
+
) : null}
|
|
2344
|
+
</> : null}
|
|
2345
|
+
{shows('clock') ? <>
|
|
2346
|
+
<h3 className="settings-head">Clock</h3>
|
|
2347
|
+
<p className="mode">The instant every vendor here stamps from. Set it and move it forward to see what happens later: renewals, expiries, scheduled sends.</p>
|
|
2348
|
+
<ClockBar name={name} token={token} clock={clock} canWrite={canWrite} onChanged={() => setClockVersion((v) => v + 1)} refused={refused} open />
|
|
2349
|
+
</> : null}
|
|
2350
|
+
{shows('checks') ? <>
|
|
2351
|
+
<h3 className="settings-head">Checks</h3>
|
|
2352
|
+
<p className="mode">JavaScript the World runs over every change before it is performed (a changeset's Verify runs them too).</p>
|
|
2353
|
+
<Pending load={checks} what="the checks" />
|
|
2354
|
+
{checks.state === 'ok' ? (checks.data.checks.length === 0 ? <p className="mode">No checks: every change is performed as it comes.</p> : (
|
|
2355
|
+
<div className="block"><table className="dense" aria-label="Checks"><tbody>{checks.data.checks.map((c) => <tr key={c}><td><code>{c}</code></td></tr>)}</tbody></table></div>
|
|
2356
|
+
)) : null}
|
|
2357
|
+
</> : null}
|
|
2358
|
+
{admin && !only ? (
|
|
1756
2359
|
<div className="danger" role="region" aria-label="Danger zone">
|
|
1757
2360
|
<h3>Danger zone</h3>
|
|
1758
|
-
|
|
1759
|
-
<p><button onClick={() => void
|
|
2361
|
+
{tokens ? <p role="status">token <code>{tokens.token}</code> · read token <code>{tokens.readToken}</code></p> : null}
|
|
2362
|
+
<p><button type="button" onClick={() => void rotate()}>Rotate tokens</button> <span className="mode">New tokens for this World; every app, script and browser using the old ones is signed out.</span></p>
|
|
2363
|
+
<p><button type="button" onClick={() => void remove()}>Remove world</button> <span className="mode">Deletes this World and everything in it.</span></p>
|
|
1760
2364
|
</div>
|
|
1761
2365
|
) : null}
|
|
2366
|
+
<Dialog open={Boolean(connecting)} onClose={() => { if (!busy) setConnecting(null); }} title={`Connect ${connecting ? vendorName(connecting.twin) : ''} to its real service`} footer={<><button type="button" onClick={() => setConnecting(null)} disabled={busy}>Cancel</button><button type="submit" form="connect-root" className="primary" disabled={busy}>{busy ? 'Saving…' : url.trim() ? 'Connect' : 'Disconnect'}</button></>}>
|
|
2367
|
+
<form id="connect-root" className="stack" onSubmit={(e) => void connect(e)}>
|
|
2368
|
+
<label><span>The real service's API address</span><input name="rootUrl" value={url} onChange={(e) => setUrl(e.target.value)} placeholder="https://api.openai.com" autoFocus /><span className="mode">Empty disconnects it.</span></label>
|
|
2369
|
+
<label><span>Deploy policy</span><select name="rootDeploy" value={policy} onChange={(e) => setPolicy(e.target.value === 'auto' ? 'auto' : e.target.value === 'hold' ? 'hold' : 'gated')}>{(['gated', 'auto', 'hold'] as const).map((p) => <option key={p} value={p}>{POLICY[p]}</option>)}</select></label>
|
|
2370
|
+
</form>
|
|
2371
|
+
</Dialog>
|
|
1762
2372
|
</>
|
|
1763
2373
|
);
|
|
1764
2374
|
}
|
|
1765
2375
|
|
|
1766
|
-
function TwinView({ name, token, twin }: { name: string; token: string; twin: string }): React.JSX.Element {
|
|
1767
|
-
const [tab, setTab] = useState<'log' | 'tree'>('log');
|
|
1768
|
-
const [log, setLog] = useState<{ position: number; entries: Entry[] } | null>(null);
|
|
1769
|
-
const [tree, setTree] = useState<Array<Record<string, unknown>> | null>(null);
|
|
1770
|
-
useEffect(() => {
|
|
1771
|
-
void (async () => {
|
|
1772
|
-
const head = await door(token, `/-/${name}/log/${twin}?limit=1`);
|
|
1773
|
-
if (!head.ok) { setLog({ position: 0, entries: [] }); return; }
|
|
1774
|
-
const { position } = (await head.json()) as { position: number };
|
|
1775
|
-
const after = Math.max(0, position - 200);
|
|
1776
|
-
const r = await door(token, `/-/${name}/log/${twin}?after=${after}&limit=200&receipts=1`);
|
|
1777
|
-
const body = (await r.json()) as { position: number; entries: Entry[] };
|
|
1778
|
-
setLog({ position: body.position, entries: [...body.entries].reverse() });
|
|
1779
|
-
const c = await door(token, `/-/${name}/checkpoint/${twin}`);
|
|
1780
|
-
setTree(c.ok ? ((await c.json()) as { tree: Array<Record<string, unknown>> }).tree : []);
|
|
1781
|
-
})();
|
|
1782
|
-
}, [name, token, twin]);
|
|
1783
|
-
// the twin's mirror (its vendor's own UI over this World's state), when its pack has one. Opening it
|
|
1784
|
-
// posts the token to the World's own session door on the Worlds' origin — a form, so the session is
|
|
1785
|
-
// set there first-party and the browser goes on to the mirror; the token never enters a URL
|
|
1786
|
-
const [mirror, setMirror] = useState(false);
|
|
1787
|
-
useEffect(() => { void door(token, `/-/${name}/twins/${twin}/status`).then(async (r) => setMirror(r.ok && ((await r.json()) as { mirror?: boolean }).mirror === true), () => setMirror(false)); }, [name, token, twin]);
|
|
1788
|
-
const mirrorAt = `${WORLDS}/${name}/${twin}/mirror/`;
|
|
1789
|
-
const openMirror = (): void => {
|
|
1790
|
-
const form = document.createElement('form');
|
|
1791
|
-
form.method = 'POST'; form.action = `${WORLDS}/-/${name}/session`;
|
|
1792
|
-
for (const [k, v] of [['token', token], ['next', `/${name}/${twin}/mirror/`]]) { const input = document.createElement('input'); input.type = 'hidden'; input.name = k!; input.value = v!; form.append(input); }
|
|
1793
|
-
document.body.append(form); form.submit(); form.remove();
|
|
1794
|
-
};
|
|
1795
|
-
const receipt = (e: Entry): string => (e.operation === 'observed' ? `observed${e.provenance === 'placeholder' ? ' (default data)' : ' (vendor)'}` : e.receipt ? `${e.receipt.status ?? ''} ${e.receipt.externalId ?? ''} ${e.receipt.reason ?? ''}`.trim() : e.provenance === 'placeholder' ? 'default data' : 'unpushed');
|
|
1796
|
-
return (
|
|
1797
|
-
<section aria-labelledby="twin">
|
|
1798
|
-
<h2 id="twin">{twin} in {name}</h2>
|
|
1799
|
-
<p>position {log?.position ?? '…'} · <code>{WORLDS}/{name}/{twin}</code>{mirror ? <> · <a href={mirrorAt} onClick={(e) => { e.preventDefault(); openMirror(); }}>Open as {twin} shows it</a></> : null}</p>
|
|
1800
|
-
<div role="tablist">
|
|
1801
|
-
<button role="tab" aria-selected={tab === 'log'} onClick={() => setTab('log')}>Log</button>
|
|
1802
|
-
<button role="tab" aria-selected={tab === 'tree'} onClick={() => setTab('tree')}>Tree</button>
|
|
1803
|
-
</div>
|
|
1804
|
-
{tab === 'log' ? (
|
|
1805
|
-
log === null ? <p>Loading…</p> : log.entries.length === 0 ? <p>No changes yet.</p> : (
|
|
1806
|
-
<table>
|
|
1807
|
-
<thead><tr><th>when</th><th>operation</th><th>subject</th><th>actor</th><th>standing</th></tr></thead>
|
|
1808
|
-
<tbody>
|
|
1809
|
-
{log.entries.map((e) => (
|
|
1810
|
-
<tr key={e.id}>
|
|
1811
|
-
<td>{when(e.occurredAt)}</td>
|
|
1812
|
-
<td>{e.operation ?? e.op}</td>
|
|
1813
|
-
<td><code>{e.subject.type}:{e.subject.id}</code></td>
|
|
1814
|
-
<td>{e.actor ? `${e.actor.kind}${e.actor.id ? ` ${e.actor.id}` : ''}` : '—'}</td>
|
|
1815
|
-
<td>{standing(e)}</td>
|
|
1816
|
-
</tr>
|
|
1817
|
-
))}
|
|
1818
|
-
</tbody>
|
|
1819
|
-
</table>
|
|
1820
|
-
)
|
|
1821
|
-
) : (
|
|
1822
|
-
tree === null ? <p>Loading…</p> : tree.length === 0 ? <p>The tree is empty.</p> : (
|
|
1823
|
-
<table>
|
|
1824
|
-
<thead><tr><th>type</th><th>id</th><th>fields</th></tr></thead>
|
|
1825
|
-
<tbody>
|
|
1826
|
-
{tree.filter((r) => r.deleted !== true).slice(0, 500).map((r) => {
|
|
1827
|
-
const { type, id, ...rest } = r as { type: string; id: string } & Record<string, unknown>;
|
|
1828
|
-
return <tr key={`${type}:${id}`}><td>{type}</td><td><code>{id}</code></td><td><pre>{JSON.stringify(rest, null, 1).slice(0, 600)}</pre></td></tr>;
|
|
1829
|
-
})}
|
|
1830
|
-
</tbody>
|
|
1831
|
-
</table>
|
|
1832
|
-
)
|
|
1833
|
-
)}
|
|
1834
|
-
</section>
|
|
1835
|
-
);
|
|
1836
|
-
}
|
|
1837
2376
|
|
|
1838
|
-
|
|
1839
|
-
|
|
2377
|
+
/** The console's whole page: what main.tsx mounts, and what a story renders over recorded doors. */
|
|
2378
|
+
export function ConsolePage(): React.JSX.Element {
|
|
2379
|
+
return <><App /><Toaster /><ConfirmHost /><MenuDismiss /></>;
|
|
2380
|
+
}
|
|
2381
|
+
export { ROOT };
|