@volter/world-console 2.0.5 → 2.0.7
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/client/console.css +24 -0
- package/client/console.tsx +310 -45
- package/dist/client/console.bundle.js +96 -96
- package/dist/client/console.css +24 -0
- package/dist/client/console.tsx +310 -45
- package/package.json +3 -3
package/client/console.css
CHANGED
|
@@ -197,3 +197,27 @@ section.pitch { background: none; border: 0; box-shadow: none; padding: 0; margi
|
|
|
197
197
|
/* a phone: the top bar keeps the menu, the mark, where you are, the way back as an arrow and Sign out */
|
|
198
198
|
@media (max-width: 720px) { main.app > header .mode, main.app > header .back-link .back-text { display: none; } main.app > header .back-link { flex: none; } }
|
|
199
199
|
@media (max-width: 720px) { .head-actions .clock { flex-wrap: wrap; max-width: 100%; } }
|
|
200
|
+
|
|
201
|
+
/* changesets, reviewed as pull requests are: a status word, the changes by vendor, checks, approvals, receipts */
|
|
202
|
+
.badge.cs-done { background: var(--accent-soft); color: var(--ink); }
|
|
203
|
+
.badge.cs-good { color: var(--ok); border-color: var(--ok); }
|
|
204
|
+
.badge.cs-warn { background: var(--warn-soft); color: var(--warn); }
|
|
205
|
+
.badge.cs-bad { background: var(--danger-soft); color: var(--danger-text); }
|
|
206
|
+
.changeset-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; flex-wrap: wrap; margin-bottom: 8px; }
|
|
207
|
+
.changeset-head h3 { margin: 0 0 4px; }
|
|
208
|
+
.changeset-actions { display: flex; gap: 8px; flex-wrap: wrap; }
|
|
209
|
+
.changeset h4 { margin: 22px 0 8px; font-size: 13px; text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); }
|
|
210
|
+
.changeset .narration { white-space: pre-wrap; background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); padding: 10px 12px; font-size: 13px; }
|
|
211
|
+
.changeset .stale { opacity: 0.75; }
|
|
212
|
+
.changeset ul.checks, .changeset ul.approvals, .changeset ul.receipts { list-style: none; padding: 0; margin: 6px 0; display: grid; gap: 6px; }
|
|
213
|
+
.crumbs-line { margin: 0 0 12px; font-size: 13px; }
|
|
214
|
+
/* the branches page: its words, and New branch beside them */
|
|
215
|
+
.branches-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; }
|
|
216
|
+
.branches-head .lede { margin-top: 0; }
|
|
217
|
+
/* a dialog's text box reads in the page's face, not the browser's monospace default */
|
|
218
|
+
dialog textarea { font: inherit; width: 100%; }
|
|
219
|
+
/* a set of choices (a branch's moment): its legend a label, its options one per line */
|
|
220
|
+
fieldset.choice { border: 0; padding: 0; margin: 0; display: grid; gap: 8px; min-width: 0; }
|
|
221
|
+
fieldset.choice > legend { padding: 0; margin-bottom: 4px; color: var(--muted); font-size: 12.5px; }
|
|
222
|
+
fieldset.choice > label.check { display: flex; align-items: flex-start; gap: 8px; color: var(--ink); font-size: 13px; }
|
|
223
|
+
fieldset.choice > label.check input { margin-top: 3px; }
|
package/client/console.tsx
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
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, ThemeChoice, At, Brand, ConfirmHost, MenuDismiss, confirmAct, CopyField, counted, Dialog, DrawerButton, failure, listed, Mark, Menu, Pending, Toaster, toast, useDoor as useKitDoor, vendorName, when, ago, type Load } from './kit/kit.tsx';
|
|
7
|
+
import { applyTheme, ThemeChoice, At, Brand, ConfirmHost, MenuDismiss, confirmAct, CopyField, counted, Dialog, DrawerButton, Empty, failure, listed, Mark, Menu, Pending, Toaster, toast, useDoor as useKitDoor, vendorName, when, ago, type Load } from './kit/kit.tsx';
|
|
8
8
|
import { createRoot } from 'react-dom/client';
|
|
9
9
|
import { agentPrompt } from '../../../packages/cli/src/agent-prompt.ts';
|
|
10
10
|
|
|
@@ -86,7 +86,7 @@ const actorOf = (e: Entry): string => (e.actor ? `${e.actor.kind}${e.actor.id ?
|
|
|
86
86
|
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
87
|
|
|
88
88
|
/** A past moment of a World, opened by this tab: a branch the host made, held with its tokens for the tab's life. */
|
|
89
|
-
type Snapshot = { name: string; parent: string; token: string; readToken: string; at: string; expiresAt: string | null; /** where the parent's pages live, the way back */ parentOrigin?: string; /** this tab's own "View as of" made it here: only such a branch is ever removed */ made?: boolean };
|
|
89
|
+
type Snapshot = { name: string; parent: string; token: string; readToken: string; at: string; expiresAt: string | null; /** where the parent's pages live, the way back */ parentOrigin?: string; /** this tab's own "View as of" made it here: only such a branch is ever removed */ made?: boolean; /** the branch's own origin, where its pages live */ origin?: string };
|
|
90
90
|
const SNAP_KEY = 'volter.console.snapshots';
|
|
91
91
|
const snapshots = (): Record<string, Snapshot> => { try { return JSON.parse(sessionStorage.getItem(SNAP_KEY) ?? '{}') as Record<string, Snapshot>; } catch { return {}; } };
|
|
92
92
|
// on a World origin a snapshot's tokens stay in this module (no frame can reach it), never in storage
|
|
@@ -168,6 +168,12 @@ const WORLD_PART = /^[a-z0-9](?:[a-z0-9]|-(?!-))*$/i;
|
|
|
168
168
|
* encoded `/` or `..`) ever reaches a door's path. */
|
|
169
169
|
const worldOf = (o?: string, w?: string): string => (o && w && WORLD_PART.test(o) && WORLD_PART.test(w) ? `${o}/${w}` : '');
|
|
170
170
|
function go(parts: string[]): void { window.history.pushState(null, '', `${BASE}/${parts.map(encodeURIComponent).join('/')}`); window.dispatchEvent(new PopStateEvent('popstate')); }
|
|
171
|
+
/** 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
|
+
function goBranch(branch: string, parts: string[]): void {
|
|
173
|
+
const there = worldOriginOf(snapshots()[branch]?.origin);
|
|
174
|
+
if (there && there !== ORIGIN) { moveTo(there, snapshotToken(branch), branch, `${BASE}/${parts.map(encodeURIComponent).join('/')}`); return; }
|
|
175
|
+
go(parts);
|
|
176
|
+
}
|
|
171
177
|
|
|
172
178
|
/** A World this machine serves on loopback opens its session for its own page with no token (served-world.ts
|
|
173
179
|
* localSession): nothing is ever pasted, and a restart or a rotated token is picked up by asking again. Asked of the
|
|
@@ -325,9 +331,9 @@ function App(): React.JSX.Element {
|
|
|
325
331
|
const back = worldOriginOf(s.parentOrigin);
|
|
326
332
|
if (back && back !== ORIGIN) { forgetSnapshot(s.name); window.location.assign(`${back}${BASE}/${s.parent}#close=${encodeURIComponent(s.name)}`); return; }
|
|
327
333
|
// the branch goes with the way back: a snapshot is a throwaway World, not something to leave running (one this
|
|
328
|
-
// tab made; a carried one is only left)
|
|
329
|
-
if (s.made) await door(worldToken(s.parent), `/-/${s.parent}/branches/${s.name}`, { method: 'DELETE' }).catch(() => null);
|
|
330
|
-
|
|
334
|
+
// tab made as a snapshot; a carried one, or a branch made on the Branches page, is only left)
|
|
335
|
+
if (s.made) { await door(worldToken(s.parent), `/-/${s.parent}/branches/${s.name}`, { method: 'DELETE' }).catch(() => null); forgetSnapshot(s.name); }
|
|
336
|
+
go(s.parent.split('/'));
|
|
331
337
|
};
|
|
332
338
|
return (
|
|
333
339
|
<main className={name ? 'app' : undefined}>
|
|
@@ -421,13 +427,16 @@ type Placed = { twin: string; position: number; entry: Entry };
|
|
|
421
427
|
type Timeline = { entries: Placed[]; next: string | null };
|
|
422
428
|
type Diff = { base: { id: string; at: string | null }; changes: Array<{ twin: string; entry: Entry }> };
|
|
423
429
|
type Branch = { name: string; at?: { instant?: string }; createdAt: string; expiresAt: string | null };
|
|
430
|
+
/** The branches door: the host's snapshots of this World, and the World's own named branches (`volter world branch`),
|
|
431
|
+
* one of which, `checkedOut`, is the one this serve holds. */
|
|
432
|
+
type BranchesDoor = { branches: Branch[]; checkedOut?: string; named?: Array<{ name: string; running: boolean }> };
|
|
424
433
|
/** What the World's session answered: its scope; `unknown` when the host has no session door (a write then says). */
|
|
425
434
|
type Scope = 'pending' | 'read' | 'write' | 'unknown';
|
|
426
435
|
|
|
427
436
|
// A World's page is laid out as Neon's console is (Volter is Neon for every SaaS, docs/concepts/the-model.md): the
|
|
428
437
|
// World's own pages, the branch picker, then the branch's pages and its services, which here are its vendors, each a
|
|
429
438
|
// sibling. All screens is a canvas app's page, the whole window. The older page names still open their page.
|
|
430
|
-
const TABS = [['overview', 'Dashboard'], ['branches', 'Branches'], ['settings', 'Settings'], ['branch', 'Overview'], ['changes', 'Changes'], ['activity', 'Activity'], ['vendors', 'Vendors'], ['canvas', 'All screens']] as const;
|
|
439
|
+
const TABS = [['overview', 'Dashboard'], ['branches', 'Branches'], ['settings', 'Settings'], ['branch', 'Overview'], ['changes', 'Changes'], ['changesets', 'Changesets'], ['activity', 'Activity'], ['vendors', 'Vendors'], ['canvas', 'All screens']] as const;
|
|
431
440
|
type Tab = (typeof TABS)[number][0];
|
|
432
441
|
const TAB_ALIASES: Record<string, Tab> = { dashboard: 'overview', mirrors: 'vendors', screens: 'canvas', timeline: 'activity', monitoring: 'activity', trace: 'activity', operations: 'settings' };
|
|
433
442
|
/** 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). */
|
|
@@ -462,14 +471,17 @@ function WorldView({ name, token, twin, tab, arg, admin, snapshot, onReturn, ope
|
|
|
462
471
|
const [scope, setScope] = useState<Scope>('pending');
|
|
463
472
|
// why the session was not opened (the wall says it, with a way to try again), and that try
|
|
464
473
|
const [sessionError, setSessionError] = useState<string | null>(null); const [attempt, setAttempt] = useState(0);
|
|
474
|
+
// whom an approval from this session is recorded as; null: the page names them (a session the World's token opened)
|
|
475
|
+
const [signsAs, setSignsAs] = useState<string | null>(null);
|
|
465
476
|
// biome-ignore lint/correctness/useExhaustiveDependencies: `attempt` is the wall's "Try again"
|
|
466
477
|
useEffect(() => {
|
|
467
478
|
let live = true; setSessionError(null);
|
|
468
479
|
void door(token, `/-/${name}/session`, { method: 'POST' }).then(async (r) => {
|
|
469
|
-
const b = (await r.json().catch(() => ({}))) as { scope?: string; origin?: string; error?: string; who?: string; issuer?: string };
|
|
480
|
+
const b = (await r.json().catch(() => ({}))) as { scope?: string; origin?: string; error?: string; who?: string; issuer?: string; signsAs?: string | null };
|
|
470
481
|
// the person a platform's pass opened this session for: the page says who is signed in, and the way back to it
|
|
471
482
|
keepPortal(b.issuer);
|
|
472
483
|
if (b.who) onWho?.(b.who);
|
|
484
|
+
setSignsAs(typeof b.signsAs === 'string' ? b.signsAs : null);
|
|
473
485
|
// the World's session lives at its own origin: its pages go there
|
|
474
486
|
// the host's own answer names the World's origin (a console apart is on another host and port): an http(s) origin only
|
|
475
487
|
const own = httpOrigin(b.origin)?.origin;
|
|
@@ -493,7 +505,7 @@ function WorldView({ name, token, twin, tab, arg, admin, snapshot, onReturn, ope
|
|
|
493
505
|
const [clockVersion, setClockVersion] = useState(0);
|
|
494
506
|
const clock = useDoor<Clock>(token, `/-/${name}/clock`, tick + clockVersion);
|
|
495
507
|
const twins = useDoor<{ twins: Twin[] }>(token, `/-/${name}/twins`);
|
|
496
|
-
const branches = useDoor<
|
|
508
|
+
const branches = useDoor<BranchesDoor>(token, `/-/${name}/branches`, clockVersion);
|
|
497
509
|
const map = useDoor<WorldMap>(token, `/-/${name}/map`, Math.floor(tick / 2));
|
|
498
510
|
// branches are the host's: a host that does not answer the door has no "as of", and the console shows none
|
|
499
511
|
const hasBranches = !(branches.state === 'error' && (branches.status === 404 || branches.status === 405));
|
|
@@ -527,6 +539,7 @@ function WorldView({ name, token, twin, tab, arg, admin, snapshot, onReturn, ope
|
|
|
527
539
|
<p className="side-group">Branch</p>
|
|
528
540
|
{item('branch')}
|
|
529
541
|
{item('changes')}
|
|
542
|
+
{item('changesets')}
|
|
530
543
|
{item('activity')}
|
|
531
544
|
<p className="side-group">Vendors</p>
|
|
532
545
|
<a href={`${BASE}/${name}/~/canvas`} className="all-screens" onClick={open(['~', 'canvas'])}><span className="mark" aria-hidden="true">▦</span><span>All screens</span></a>
|
|
@@ -559,6 +572,7 @@ function WorldView({ name, token, twin, tab, arg, admin, snapshot, onReturn, ope
|
|
|
559
572
|
{current === 'activity' && tab === 'trace' ? <Trace name={name} token={token} id={arg ?? ''} /> : null}
|
|
560
573
|
{current === 'activity' && tab !== 'trace' ? <TimelineTab name={name} token={token} vendors={vendors} /> : null}
|
|
561
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}
|
|
562
576
|
{current === 'branches' ? <Overview name={name} token={token} canWrite={canWrite} branches={hasBranches ? branches : null} opens={opens} onBranchesChanged={() => setClockVersion((v) => v + 1)} refused={refused} /> : null}
|
|
563
577
|
{current === 'settings' ? <Operations name={name} token={token} admin={admin} canWrite={canWrite} refused={refused} onRemoved={onRemoved} /> : null}
|
|
564
578
|
</div>
|
|
@@ -602,13 +616,13 @@ function Connect({ name }: { name: string }): React.JSX.Element {
|
|
|
602
616
|
|
|
603
617
|
/** The branch picker, between the World's pages and the branch's (where Neon's sits): this World live, or one of the
|
|
604
618
|
* host's branches of it; a snapshot names its parent as the way back. */
|
|
605
|
-
function BranchPicker({ name, snapshot, branches, opens }: { name: string; snapshot: Snapshot | null; branches: Load<
|
|
619
|
+
function BranchPicker({ name, snapshot, branches, opens }: { name: string; snapshot: Snapshot | null; branches: Load<BranchesDoor> | null; opens: (name: string) => boolean }): React.JSX.Element {
|
|
606
620
|
const short = (n: string): string => n.split('/').pop() ?? n;
|
|
607
621
|
const live = snapshot?.parent ?? name;
|
|
608
622
|
const others = branches?.state === 'ok' ? branches.data.branches.filter((b) => b.name !== name) : [];
|
|
609
623
|
return (
|
|
610
624
|
<details className="menu branch-picker">
|
|
611
|
-
<summary><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="branch-name">{snapshot ? short(name) : short(live)}</span><span className="badge">{snapshot ? 'snapshot' : 'live'}</span></summary>
|
|
625
|
+
<summary><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="branch-name">{snapshot ? short(name) : (branches?.state === 'ok' && branches.data.checkedOut) || short(live)}</span><span className="badge">{snapshot ? 'snapshot' : 'live'}</span></summary>
|
|
612
626
|
<div className="popover">
|
|
613
627
|
<p className="mode">Branch</p>
|
|
614
628
|
<ul className="branch-list">
|
|
@@ -616,11 +630,21 @@ function BranchPicker({ name, snapshot, branches, opens }: { name: string; snaps
|
|
|
616
630
|
{snapshot ? <li><a href={`${BASE}/${name}`} aria-current="page" onClick={(e) => e.preventDefault()}>{short(name)} <span className="mode">as of <At iso={snapshot.at} /></span></a></li> : null}
|
|
617
631
|
{others.map((b) => (
|
|
618
632
|
<li key={b.name}>{opens(b.name)
|
|
619
|
-
? <a href={`${BASE}/${b.name}`} onClick={(e) => { e.preventDefault();
|
|
633
|
+
? <a href={`${BASE}/${b.name}`} onClick={(e) => { e.preventDefault(); goBranch(b.name, b.name.split('/')); }}>{short(b.name)} <span className="mode">{b.at?.instant ? <>as of <At iso={b.at.instant} /></> : 'copy'}</span></a>
|
|
620
634
|
: <span className="mode" title="Its token was handed to whoever made it">{short(b.name)}</span>}</li>
|
|
621
635
|
))}
|
|
622
636
|
</ul>
|
|
623
|
-
{branches?.state === 'ok' &&
|
|
637
|
+
{branches?.state === 'ok' && (branches.data.named?.length ?? 0) > 1 ? (
|
|
638
|
+
<>
|
|
639
|
+
<p className="mode">This World's branches</p>
|
|
640
|
+
<ul className="branch-list">
|
|
641
|
+
{branches.data.named!.map((b) => (
|
|
642
|
+
<li key={b.name}><span aria-current={b.name === branches.data.checkedOut ? 'true' : undefined}>{b.name} <span className="mode">{b.name === branches.data.checkedOut ? 'served here' : <code>volter world checkout {b.name}</code>}</span></span></li>
|
|
643
|
+
))}
|
|
644
|
+
</ul>
|
|
645
|
+
</>
|
|
646
|
+
) : 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}
|
|
624
648
|
</div>
|
|
625
649
|
</details>
|
|
626
650
|
);
|
|
@@ -689,7 +713,7 @@ function BranchOverview({ name, token, map, vendors, tick }: { name: string; tok
|
|
|
689
713
|
* Neon with how to connect and the branches. A World's live preview is its vendors' own screens; beside them, how an
|
|
690
714
|
* app connects and the branches; under them, what just happened. Counts sit with what they count, not in a row. */
|
|
691
715
|
const THUMB_SCALE = 0.2;
|
|
692
|
-
function WorldOverview({ name, token, map, vendors, twins, session, branches, canWrite, tick, snapshotOf }: { name: string; token: string; map: Load<WorldMap>; vendors: string[]; twins: Load<{ twins: Twin[] }>; session: MirrorSession; branches: Load<
|
|
716
|
+
function WorldOverview({ name, token, map, vendors, twins, session, branches, canWrite, tick, snapshotOf }: { name: string; token: string; map: Load<WorldMap>; vendors: string[]; twins: Load<{ twins: Twin[] }>; session: MirrorSession; branches: Load<BranchesDoor> | null; canWrite: boolean; tick: number; /** the World this one is a snapshot of, when it is one */ snapshotOf?: string }): React.JSX.Element {
|
|
693
717
|
const diff = useDoor<Diff>(token, `/-/${name}/diff`, Math.floor(tick / 2));
|
|
694
718
|
const recent = useDoor<Timeline>(token, `/-/${name}/timeline?limit=8`, tick);
|
|
695
719
|
const rows = map.state === 'ok' ? map.data.twins : [];
|
|
@@ -856,7 +880,7 @@ function AsOf({ name, token, clock, refused }: { name: string; token: string; cl
|
|
|
856
880
|
}
|
|
857
881
|
|
|
858
882
|
/** Overview: what the World holds, one card per twin (a resource explorer), and the branches cut from it. */
|
|
859
|
-
function Overview({ name, token, canWrite, branches, opens, onBranchesChanged, refused }: { name: string; token: string; canWrite: boolean; branches: Load<
|
|
883
|
+
function Overview({ name, token, canWrite, branches, opens, onBranchesChanged, refused }: { name: string; token: string; canWrite: boolean; branches: Load<BranchesDoor> | null; opens: (name: string) => boolean; onBranchesChanged: () => void; refused: (r: Response) => void }): React.JSX.Element {
|
|
860
884
|
const map = useDoor<WorldMap>(token, `/-/${name}/map`);
|
|
861
885
|
const [note, setNote] = useState<string | null>(null);
|
|
862
886
|
const remove = async (b: string): Promise<void> => {
|
|
@@ -869,7 +893,7 @@ function Overview({ name, token, canWrite, branches, opens, onBranchesChanged, r
|
|
|
869
893
|
const total = map.state === 'ok' ? map.data.twins.reduce((n, t) => n + t.resources.reduce((m, x) => m + x.count, 0), 0) : 0;
|
|
870
894
|
// the Branches tab: this World's copies; what it holds per vendor is the Vendors tab's list (kept here for a host
|
|
871
895
|
// without branches, where this tab is not shown)
|
|
872
|
-
if (branches) return <BranchList name={name} token={token} canWrite={canWrite} branches={branches} opens={opens} note={note} remove={remove} />;
|
|
896
|
+
if (branches) return <BranchList name={name} token={token} canWrite={canWrite} branches={branches} opens={opens} note={note} remove={remove} onChanged={onBranchesChanged} refused={refused} />;
|
|
873
897
|
return (
|
|
874
898
|
<>
|
|
875
899
|
<h3>Map</h3>
|
|
@@ -904,14 +928,57 @@ function Overview({ name, token, canWrite, branches, opens, onBranchesChanged, r
|
|
|
904
928
|
);
|
|
905
929
|
}
|
|
906
930
|
|
|
907
|
-
/** A World's branches
|
|
908
|
-
|
|
909
|
-
|
|
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 {
|
|
910
935
|
const short = (n: string): string => n.split('/').pop() ?? n;
|
|
911
936
|
const list = branches.state === 'ok' ? branches.data.branches : [];
|
|
937
|
+
const [making, setMaking] = useState(false); const [busy, setBusy] = useState(false); const [error, setError] = useState<string | null>(null);
|
|
938
|
+
const [label, setLabel] = useState(''); const [when, setWhen] = useState<'now' | 'at'>('now'); const [at, setAt] = useState(''); const [hours, setHours] = useState('24');
|
|
939
|
+
// 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)
|
|
940
|
+
const make = async (body: Record<string, unknown>, opened: string): Promise<{ name: string } | string> => {
|
|
941
|
+
const r = await door(token, `/-/${name}/branches`, { method: 'POST', body: JSON.stringify(body) }).catch(() => null);
|
|
942
|
+
if (!r) return `${ORIGIN} did not answer`;
|
|
943
|
+
if (!r.ok) { refused(r); return await failure(r); }
|
|
944
|
+
const b = (await r.json()) as { name: string; token?: string; readToken: string; expiresAt: string | null; origin?: string };
|
|
945
|
+
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 } : {}) });
|
|
946
|
+
return b;
|
|
947
|
+
};
|
|
948
|
+
const create = async (e: React.FormEvent): Promise<void> => {
|
|
949
|
+
e.preventDefault(); setError(null);
|
|
950
|
+
const instant = when === 'at' ? new Date(at) : null;
|
|
951
|
+
if (instant && Number.isNaN(instant.getTime())) { setError('Pick a date and time, or make it as of now.'); return; }
|
|
952
|
+
if (instant && instant.getTime() > Date.now()) { setError('A branch is made as of now or a moment before it.'); return; }
|
|
953
|
+
const ttl = Math.round(Number(hours) * 3600);
|
|
954
|
+
setBusy(true);
|
|
955
|
+
const b = await make({ ttl, ...(instant ? { at: { instant: instant.toISOString() } } : { live: true }), ...(label.trim() ? { label: label.trim() } : {}) }, (instant ?? new Date()).toISOString());
|
|
956
|
+
setBusy(false);
|
|
957
|
+
if (typeof b === 'string') { setError(b); return; }
|
|
958
|
+
setMaking(false); setLabel(''); toast(`Made ${short(b.name)}`); onChanged();
|
|
959
|
+
};
|
|
960
|
+
const reset = async (b: Branch): Promise<void> => {
|
|
961
|
+
if (!(await confirmAct({ title: `Reset ${short(b.name)} to ${short(name)}?`, body: `A fresh copy of ${short(name)} as it is now replaces it, with the same label and the time it had left; what was written to it goes. The copy has a new address and new keys: an app, a CI job or a pull request's preview that holds this branch's must be given the new one.`, action: 'Reset branch', danger: true }))) return;
|
|
962
|
+
const left = b.expiresAt ? Math.max(3600, Math.round((Date.parse(b.expiresAt) - Date.now()) / 1000)) : 86_400;
|
|
963
|
+
// a branch is named `<parent>-<label>-<4 hex>`: its label is what lies between
|
|
964
|
+
const own = short(b.name).startsWith(`${short(name)}-`) ? short(b.name).slice(short(name).length + 1) : '';
|
|
965
|
+
const labelOf = /^([a-z0-9][a-z0-9-]*)-[0-9a-f]{4}$/.exec(own)?.[1];
|
|
966
|
+
const fresh = await make({ ttl: left, live: true, ...(labelOf && !labelOf.startsWith('at-') ? { label: labelOf } : {}) }, new Date().toISOString());
|
|
967
|
+
if (typeof fresh === 'string') { toast(`Reset ${short(b.name)}: ${fresh}`, 'error'); return; }
|
|
968
|
+
const r = await door(token, `/-/${name}/branches/${b.name}`, { method: 'DELETE' }).catch(() => null);
|
|
969
|
+
if (r?.ok) forgetSnapshot(b.name);
|
|
970
|
+
toast(r?.ok ? `Reset: ${short(fresh.name)} is ${short(name)} as it is now` : `Made ${short(fresh.name)}; ${short(b.name)} was not removed`); onChanged();
|
|
971
|
+
};
|
|
972
|
+
const menu = (b: Branch): Array<{ label: string; onSelect: () => void; danger?: boolean }> => [
|
|
973
|
+
...(opens(b.name) ? [{ label: 'Compare with parent', onSelect: () => goBranch(b.name, [...b.name.split('/'), '~', 'changes']) }] : []),
|
|
974
|
+
...(canWrite ? [{ label: 'Reset to parent…', onSelect: () => void reset(b) }, { label: 'Delete…', danger: true, onSelect: () => void remove(b.name) }] : []),
|
|
975
|
+
];
|
|
912
976
|
return (
|
|
913
977
|
<div aria-labelledby="branches">
|
|
914
|
-
<
|
|
978
|
+
<div className="branches-head">
|
|
979
|
+
<p className="lede">Branch this World to look at it as it was, or to change it without touching <b>{short(name)}</b>. A branch made here opens read-only in this tab; its parent removes it.</p>
|
|
980
|
+
{canWrite ? <button type="button" className="primary" onClick={() => { setError(null); setAt(localInput(new Date().toISOString())); setMaking(true); }}>New branch</button> : null}
|
|
981
|
+
</div>
|
|
915
982
|
{note ? <p role="status">{note}</p> : null}
|
|
916
983
|
<Pending load={branches} what="the branches" />
|
|
917
984
|
{branches.state === 'ok' ? (
|
|
@@ -919,25 +986,58 @@ function BranchList({ name, canWrite, branches, opens, note, remove }: { name: s
|
|
|
919
986
|
<table className="dense" aria-label="Branches">
|
|
920
987
|
<thead><tr><th id="branches">Branch</th><th>Parent</th><th>As of</th><th>Made</th><th>Goes away</th><th /></tr></thead>
|
|
921
988
|
<tbody>
|
|
922
|
-
<tr><td><b>{short(name)}</b> <span className="badge">default</span></td><td className="mode">—</td><td className="mode">live</td><td className="mode">—</td><td className="mode">kept</td><td /></tr>
|
|
923
|
-
{list.map((b) =>
|
|
989
|
+
<tr><td><b>{branches.data.checkedOut ?? short(name)}</b> <span className="badge">{branches.data.checkedOut ? 'served here' : 'default'}</span></td><td className="mode">—</td><td className="mode">live</td><td className="mode">—</td><td className="mode">kept</td><td /></tr>
|
|
990
|
+
{list.map((b) => {
|
|
991
|
+
const items = menu(b);
|
|
992
|
+
return (
|
|
993
|
+
<tr key={b.name}>
|
|
994
|
+
<td><b>{short(b.name)}</b></td>
|
|
995
|
+
<td>{short(name)}</td>
|
|
996
|
+
<td>{b.at?.instant ? <At iso={b.at.instant} /> : 'when it was made'}</td>
|
|
997
|
+
<td><At iso={b.createdAt} /></td>
|
|
998
|
+
<td>{b.expiresAt ? <At iso={b.expiresAt} /> : 'kept'}</td>
|
|
999
|
+
<td className="num row-actions">
|
|
1000
|
+
{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>}
|
|
1001
|
+
{items.length ? <Menu label={`More for ${short(b.name)}`} items={items} /> : null}
|
|
1002
|
+
</td>
|
|
1003
|
+
</tr>
|
|
1004
|
+
);
|
|
1005
|
+
})}
|
|
1006
|
+
</tbody>
|
|
1007
|
+
</table>
|
|
1008
|
+
{list.length === 0 ? <p className="mode pad">No snapshots yet.</p> : null}
|
|
1009
|
+
</div>
|
|
1010
|
+
) : null}
|
|
1011
|
+
{branches.state === 'ok' && (branches.data.named?.length ?? 0) > 0 ? (
|
|
1012
|
+
<>
|
|
1013
|
+
<p className="lede">This World's own branches (<code>volter world branch</code>). One runs at a time; this page shows the one served here. Another is served by stopping this serve, <code>volter world checkout <branch></code>, and serving again.</p>
|
|
1014
|
+
<div className="block">
|
|
1015
|
+
<table className="dense" aria-label="World branches">
|
|
1016
|
+
<thead><tr><th>Branch</th><th>State</th></tr></thead>
|
|
1017
|
+
<tbody>
|
|
1018
|
+
{branches.data.named!.map((b) => (
|
|
924
1019
|
<tr key={b.name}>
|
|
925
|
-
<td><b>{
|
|
926
|
-
<td>{
|
|
927
|
-
<td>{b.at?.instant ? <At iso={b.at.instant} /> : 'when it was made'}</td>
|
|
928
|
-
<td><At iso={b.createdAt} /></td>
|
|
929
|
-
<td>{b.expiresAt ? <At iso={b.expiresAt} /> : 'kept'}</td>
|
|
930
|
-
<td className="num">
|
|
931
|
-
{opens(b.name) ? <button type="button" aria-label={`Open ${b.name}`} onClick={() => go(b.name.split('/'))}>Open</button> : <span className="mode" title="Its token was handed to whoever made it">opened by whoever made it</span>}{' '}
|
|
932
|
-
{canWrite ? <button type="button" aria-label={`Delete ${b.name}`} onClick={() => void remove(b.name)}>Delete</button> : null}
|
|
933
|
-
</td>
|
|
1020
|
+
<td><b>{b.name}</b>{b.name === branches.data.checkedOut ? <> <span className="badge">served here</span></> : null}</td>
|
|
1021
|
+
<td className="mode">{b.running ? 'running' : 'stopped'}</td>
|
|
934
1022
|
</tr>
|
|
935
1023
|
))}
|
|
936
1024
|
</tbody>
|
|
937
1025
|
</table>
|
|
938
|
-
{list.length === 0 ? <p className="mode pad">No branches yet.</p> : null}
|
|
939
1026
|
</div>
|
|
1027
|
+
</>
|
|
940
1028
|
) : null}
|
|
1029
|
+
<Dialog open={making} onClose={() => { if (!busy) setMaking(false); }} title={`New branch of ${short(name)}`} footer={<><button type="button" onClick={() => setMaking(false)} disabled={busy}>Cancel</button><button type="submit" form="new-branch" className="primary" disabled={busy}>{busy ? 'Making…' : 'Make branch'}</button></>}>
|
|
1030
|
+
<form id="new-branch" onSubmit={(e) => void create(e)} className="stack">
|
|
1031
|
+
<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>
|
|
1032
|
+
<fieldset className="choice"><legend>As of</legend>
|
|
1033
|
+
<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>
|
|
1034
|
+
<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>
|
|
1035
|
+
{when === 'at' ? <input type="datetime-local" aria-label="As of" value={at} max={localInput(new Date().toISOString())} onChange={(e) => setAt(e.target.value)} /> : null}
|
|
1036
|
+
</fieldset>
|
|
1037
|
+
<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>
|
|
1038
|
+
{error ? <p role="alert">{error}</p> : null}
|
|
1039
|
+
</form>
|
|
1040
|
+
</Dialog>
|
|
941
1041
|
</div>
|
|
942
1042
|
);
|
|
943
1043
|
}
|
|
@@ -1278,22 +1378,171 @@ function CanvasSurface({ name, twins, session, retry, snapshot, back }: { name:
|
|
|
1278
1378
|
}
|
|
1279
1379
|
|
|
1280
1380
|
/** What this World changed since its base — what a changeset would cut — in a compare view, by twin. */
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1381
|
+
/** A changeset as the World's changesets door answers it: what it does at each vendor, the checks it ran, who signed
|
|
1382
|
+
* which bytes, and whether it went to the real vendors (with a receipt for each change). */
|
|
1383
|
+
type ChangesetView = {
|
|
1384
|
+
name: string; message?: string; narration?: string; createdAt: string; contentHash: string; /** the body's hash as it stands (GET …/changesets) */ currentHash?: string;
|
|
1385
|
+
actions: Array<{ service: string; action: Omit<Entry, 'service' | 'op'> & { op?: string; operation?: string } }>;
|
|
1386
|
+
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;
|
|
1387
|
+
approvals: Array<{ principal: string; at: string; contentHash: string; note?: string }>;
|
|
1388
|
+
applied: { at: string; outcome: 'applied' | 'partial' | 'refused'; refusal?: string[]; receipts: Array<{ actionId: string; service: string; status: string; externalId?: string; url?: string; error?: string }> } | null;
|
|
1389
|
+
};
|
|
1390
|
+
/** Its actions as the Changes view shows entries: by vendor. */
|
|
1391
|
+
const changesetGroups = (c: ChangesetView): Array<[string, Entry[]]> => {
|
|
1392
|
+
const m = new Map<string, Entry[]>();
|
|
1393
|
+
for (const a of c.actions) m.set(a.service, [...(m.get(a.service) ?? []), { ...a.action, service: a.service, op: a.action.op ?? a.action.operation ?? '' } as Entry]);
|
|
1394
|
+
return [...m.entries()];
|
|
1395
|
+
};
|
|
1396
|
+
/** Where it stands, in a word, and the state behind the word: a verification or an approval of other bytes is stale. */
|
|
1397
|
+
function changesetStatus(c: ChangesetView): { label: string; tone: 'done' | 'good' | 'warn' | 'bad' | 'plain' } {
|
|
1398
|
+
if (c.applied) return c.applied.outcome === 'applied' ? { label: 'Deployed', tone: 'done' } : c.applied.outcome === 'partial' ? { label: 'Partly deployed', tone: 'warn' } : { label: 'Deploy refused', tone: 'bad' };
|
|
1399
|
+
const now = c.currentHash ?? c.contentHash;
|
|
1400
|
+
const signed = c.approvals.filter((a) => a.contentHash === now).length;
|
|
1401
|
+
const checked = c.verification && c.verification.contentHash === now ? c.verification : null;
|
|
1402
|
+
if (checked && !checked.passed) return { label: 'Checks failed', tone: 'bad' };
|
|
1403
|
+
if (signed > 0) return { label: `Approved by ${signed}`, tone: 'good' };
|
|
1404
|
+
if (checked) return { label: 'Verified', tone: 'good' };
|
|
1405
|
+
return { label: 'Not verified', tone: 'plain' };
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
/** THE CHANGESETS: what this World would do, cut into reviewable pieces, listed as a repository lists pull requests;
|
|
1409
|
+
* each one's page reviews it (its changes by vendor, its checks, its approvals) and, where the World has a real root,
|
|
1410
|
+
* deploys it. Every button is the World's own door; a refusal is the door's words. */
|
|
1411
|
+
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 {
|
|
1412
|
+
const [version, setVersion] = useState(0);
|
|
1413
|
+
const list = useDoor<{ changesets: ChangesetView[] }>(token, `/-/${name}/changesets`, version);
|
|
1414
|
+
const at = (c: string) => (e: React.MouseEvent): void => { e.preventDefault(); go([...name.split('/'), '~', 'changesets', c]); };
|
|
1415
|
+
if (opened) {
|
|
1416
|
+
const c = list.state === 'ok' ? list.data.changesets.find((x) => x.name === opened) : undefined;
|
|
1417
|
+
return (
|
|
1418
|
+
<>
|
|
1419
|
+
<p className="crumbs-line"><a href={`${BASE}/${name}/~/changesets`} onClick={(e) => { e.preventDefault(); go([...name.split('/'), '~', 'changesets']); }}>← Changesets</a></p>
|
|
1420
|
+
<Pending load={list} what="the changesets" />
|
|
1421
|
+
{list.state === 'ok' && !c ? <p className="mode">This World has no changeset named {opened}.</p> : null}
|
|
1422
|
+
{c ? <ChangesetPage name={name} token={token} signsAs={signsAs} c={c} canWrite={canWrite} rooted={rooted} refused={refused} onChanged={() => setVersion((v) => v + 1)} /> : null}
|
|
1423
|
+
</>
|
|
1424
|
+
);
|
|
1425
|
+
}
|
|
1426
|
+
const rows = list.state === 'ok' ? [...list.data.changesets].sort((a, b) => b.createdAt.localeCompare(a.createdAt)) : [];
|
|
1288
1427
|
return (
|
|
1289
1428
|
<>
|
|
1290
|
-
<
|
|
1291
|
-
{
|
|
1429
|
+
<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>
|
|
1430
|
+
<Pending load={list} what="the changesets" />
|
|
1431
|
+
{list.state === 'ok' && rows.length === 0 ? (
|
|
1432
|
+
<Empty title="No changesets yet">
|
|
1433
|
+
<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>
|
|
1434
|
+
</Empty>
|
|
1435
|
+
) : null}
|
|
1436
|
+
{rows.length > 0 ? (
|
|
1437
|
+
<div className="block">
|
|
1438
|
+
<table className="dense" aria-label="Changesets">
|
|
1439
|
+
<thead><tr><th>Changeset</th><th>Changes</th><th>Made</th><th>Status</th></tr></thead>
|
|
1440
|
+
<tbody>{rows.map((c) => {
|
|
1441
|
+
const s = changesetStatus(c);
|
|
1442
|
+
const counts = changesetGroups(c).map(([v, es]) => `${es.length} at ${vendorName(v)}`).join(' · ');
|
|
1443
|
+
return (
|
|
1444
|
+
<tr key={c.name}>
|
|
1445
|
+
<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>
|
|
1446
|
+
<td className="mode">{counts || 'nothing'}</td>
|
|
1447
|
+
<td className="mode"><At iso={c.createdAt} /></td>
|
|
1448
|
+
<td><span className={`badge cs-${s.tone}`}>{s.label}</span></td>
|
|
1449
|
+
</tr>
|
|
1450
|
+
);
|
|
1451
|
+
})}</tbody>
|
|
1452
|
+
</table>
|
|
1453
|
+
</div>
|
|
1454
|
+
) : null}
|
|
1455
|
+
</>
|
|
1456
|
+
);
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
/** One changeset, reviewed: its words, its changes by vendor, its checks, its approvals and its receipts; Verify,
|
|
1460
|
+
* Approve (signed as the person this session is for) and Deploy where they apply, each saying why when it does not. */
|
|
1461
|
+
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 {
|
|
1462
|
+
const [busy, setBusy] = useState<string | null>(null);
|
|
1463
|
+
const [approving, setApproving] = useState(false); const [note, setNote] = useState(''); const [as, setAs] = useState('');
|
|
1464
|
+
const s = changesetStatus(c);
|
|
1465
|
+
const groups = changesetGroups(c);
|
|
1466
|
+
const touches = groups.map(([v]) => v);
|
|
1467
|
+
const deployable = touches.some((v) => rooted.includes(v));
|
|
1468
|
+
const current = (h: string): boolean => h === (c.currentHash ?? c.contentHash);
|
|
1469
|
+
const act = async (what: string, path: string, body?: unknown, done?: string): Promise<boolean> => {
|
|
1470
|
+
setBusy(what);
|
|
1471
|
+
try {
|
|
1472
|
+
const r = await door(token, path, { method: 'POST', ...(body === undefined ? {} : { body: JSON.stringify(body) }) });
|
|
1473
|
+
if (!r.ok) { refused(r); toast(`${what}: ${await failure(r)}`, 'error'); return false; }
|
|
1474
|
+
if (done) toast(done);
|
|
1475
|
+
onChanged(); return true;
|
|
1476
|
+
} catch (e) { toast(`${what}: the World did not answer (${e instanceof Error ? e.message : String(e)})`, 'error'); return false; } finally { setBusy(null); }
|
|
1477
|
+
};
|
|
1478
|
+
const verify = (): void => { void act('Verify', `/-/${name}/changesets/${c.name}/verify`, undefined, 'Verified: the checks ran over it'); };
|
|
1479
|
+
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(''); } };
|
|
1480
|
+
const deploy = async (): Promise<void> => {
|
|
1481
|
+
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{touches.filter((v) => rooted.includes(v)).length === 1 ? '' : 's'} behind {listed(touches.filter((v) => rooted.includes(v)).map(vendorName))}. This cannot be taken back here.</>, action: 'Deploy', danger: true, typed: c.name }))) return;
|
|
1482
|
+
void act('Deploy', `/-/${name}/deploy/${c.name}`, { confirm: c.name }, 'Deployed: the receipts are below');
|
|
1483
|
+
};
|
|
1484
|
+
// why a button is off, in words: the reader's scope, what already happened, or the World's own rule
|
|
1485
|
+
const offWhy = !canWrite ? 'This session reads the World; it changes nothing' : c.applied ? 'Already deployed' : null;
|
|
1486
|
+
return (
|
|
1487
|
+
<div className="changeset" aria-labelledby="changeset-title">
|
|
1488
|
+
<div className="changeset-head">
|
|
1489
|
+
<div>
|
|
1490
|
+
<h3 id="changeset-title">{c.message ?? c.name} <span className={`badge cs-${s.tone}`}>{s.label}</span></h3>
|
|
1491
|
+
<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>
|
|
1492
|
+
</div>
|
|
1493
|
+
<div className="changeset-actions">
|
|
1494
|
+
<button type="button" onClick={verify} disabled={Boolean(offWhy) || busy !== null} title={offWhy ?? 'Replay it in a throwaway World and run the World\'s checks'}>{busy === 'Verify' ? 'Verifying…' : 'Verify'}</button>
|
|
1495
|
+
<button type="button" onClick={() => setApproving(true)} disabled={Boolean(offWhy) || busy !== null} title={offWhy ?? 'Sign its current contents as yourself'}>Approve…</button>
|
|
1496
|
+
{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}
|
|
1497
|
+
</div>
|
|
1498
|
+
</div>
|
|
1499
|
+
{offWhy && !c.applied ? <p className="mode">{offWhy}.</p> : null}
|
|
1500
|
+
{!deployable && !c.applied ? <p className="mode">It stays in this World: no vendor it changes has a real account behind it here (a shared World with a root deploys; see Settings › Real services).</p> : null}
|
|
1501
|
+
{c.narration ? <p className="narration">{c.narration}</p> : null}
|
|
1502
|
+
|
|
1503
|
+
<h4>Changes</h4>
|
|
1504
|
+
{groups.length === 0 ? <p className="mode">It changes nothing.</p> : <ByVendor groups={groups} idPrefix={`cs-${c.name}`} />}
|
|
1505
|
+
|
|
1506
|
+
<h4>Checks</h4>
|
|
1507
|
+
{!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> : (
|
|
1508
|
+
<div className={current(c.verification.contentHash) ? '' : 'stale'}>
|
|
1509
|
+
<p>{c.verification.passed ? '✓ Passed' : '✗ Failed'} · <At iso={c.verification.at} />{current(c.verification.contentHash) ? null : <span className="mode"> · on an earlier version of it: verify again</span>}</p>
|
|
1510
|
+
{c.verification.results.length ? <ul className="checks">{c.verification.results.map((r) => <li key={r.id}>{r.passed ? '✓' : '✗'} <code>{r.id}</code> <span className="mode">{vendorName(r.service)}</span></li>)}</ul> : null}
|
|
1511
|
+
{c.verification.refusals?.length ? <ul className="checks">{c.verification.refusals.map((r, i) => <li key={i}>✗ <code>{r.check}</code> <span className="mode">{vendorName(r.service)}: {r.reason}</span></li>)}</ul> : null}
|
|
1512
|
+
</div>
|
|
1513
|
+
)}
|
|
1514
|
+
|
|
1515
|
+
<h4>Approvals</h4>
|
|
1516
|
+
{c.approvals.length === 0 ? <p className="mode">No one has approved it.</p> : (
|
|
1517
|
+
<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>
|
|
1518
|
+
)}
|
|
1519
|
+
|
|
1520
|
+
{c.applied ? (
|
|
1292
1521
|
<>
|
|
1293
|
-
<
|
|
1294
|
-
{
|
|
1295
|
-
|
|
1296
|
-
|
|
1522
|
+
<h4>Deployed</h4>
|
|
1523
|
+
<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>
|
|
1524
|
+
{c.applied.refusal?.length ? <ul className="checks">{c.applied.refusal.map((r, i) => <li key={i} className="mode">{r}</li>)}</ul> : null}
|
|
1525
|
+
{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}
|
|
1526
|
+
</>
|
|
1527
|
+
) : null}
|
|
1528
|
+
|
|
1529
|
+
<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></>}>
|
|
1530
|
+
<form id="approve-changeset" onSubmit={(e) => void approve(e)} className="stack">
|
|
1531
|
+
<p className="mode">You sign its contents as they stand now{signsAs ? <>, recorded as <b>{signsAs}</b></> : null}. A later change to it shows your approval as for an earlier version.</p>
|
|
1532
|
+
{signsAs === null ? <label><span>Approving as</span><input name="approveAs" value={as} onChange={(e) => setAs(e.target.value)} maxLength={80} required placeholder="your name or email" autoComplete="off" /><span className="mode">This World was opened with its token, which names no person: the approval records the name you give.</span></label> : null}
|
|
1533
|
+
<label><span>Note <span className="mode">(optional)</span></span><textarea name="approveNote" value={note} onChange={(e) => setNote(e.target.value)} rows={3} maxLength={500} /></label>
|
|
1534
|
+
</form>
|
|
1535
|
+
</Dialog>
|
|
1536
|
+
</div>
|
|
1537
|
+
);
|
|
1538
|
+
}
|
|
1539
|
+
|
|
1540
|
+
/** What a set of entries does at each vendor, grouped by vendor, runs of three alike folded: the Changes view's and a
|
|
1541
|
+
* changeset's page's one rendering. */
|
|
1542
|
+
function ByVendor({ groups, idPrefix }: { groups: Array<[string, Entry[]]>; idPrefix: string }): React.JSX.Element {
|
|
1543
|
+
return (<>{groups.map(([t, entries]) => (
|
|
1544
|
+
<div key={t} className="compare" role="group" aria-labelledby={`${idPrefix}-${t}`}>
|
|
1545
|
+
<h3 id={`${idPrefix}-${t}`} className="vendor-cell"><Mark vendor={t} />{vendorName(t)} <span className="mode">· {entries.length} change{entries.length === 1 ? '' : 's'}</span></h3>
|
|
1297
1546
|
<ul>{runsOf(entries).map((run) => run.length >= 3 ? (
|
|
1298
1547
|
<li key={run[0]!.id}>
|
|
1299
1548
|
<details>
|
|
@@ -1310,7 +1559,23 @@ function Changes({ name, token }: { name: string; token: string }): React.JSX.El
|
|
|
1310
1559
|
</li>
|
|
1311
1560
|
)))}</ul>
|
|
1312
1561
|
</div>
|
|
1313
|
-
|
|
1562
|
+
))}</>);
|
|
1563
|
+
}
|
|
1564
|
+
|
|
1565
|
+
function Changes({ name, token }: { name: string; token: string }): React.JSX.Element {
|
|
1566
|
+
const diff = useDoor<Diff>(token, `/-/${name}/diff`);
|
|
1567
|
+
const byTwin = useMemo(() => {
|
|
1568
|
+
const m = new Map<string, Entry[]>();
|
|
1569
|
+
if (diff.state === 'ok') for (const c of diff.data.changes) m.set(c.twin, [...(m.get(c.twin) ?? []), c.entry]);
|
|
1570
|
+
return [...m.entries()];
|
|
1571
|
+
}, [diff]);
|
|
1572
|
+
return (
|
|
1573
|
+
<>
|
|
1574
|
+
<Pending load={diff} what="the changes" />
|
|
1575
|
+
{diff.state === 'ok' ? (
|
|
1576
|
+
<>
|
|
1577
|
+
<p className="mode">What this World changed since it started{diff.data.base.at ? <> (<At iso={diff.data.base.at} />, <code>{diff.data.base.id}</code>)</> : <> (<code>{diff.data.base.id}</code>)</>}: {diff.data.changes.length} change{diff.data.changes.length === 1 ? '' : 's'} at {byTwin.length} vendor{byTwin.length === 1 ? '' : 's'}. A changeset takes these to the real services.</p>
|
|
1578
|
+
{byTwin.length === 0 ? <p className="empty">No changes since {diff.data.base.id}{diff.data.base.at ? <> (<At iso={diff.data.base.at} />)</> : null}.</p> : <ByVendor groups={byTwin} idPrefix="changes" />}
|
|
1314
1579
|
</>
|
|
1315
1580
|
) : null}
|
|
1316
1581
|
</>
|