@volter/world-console 2.0.1 → 2.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -4
- package/client/console.css +192 -106
- package/client/console.tsx +1239 -492
- package/client/kit/kit.css +279 -0
- package/client/kit/kit.tsx +259 -0
- package/client/vendor-logos.ts +42 -0
- package/dist/client/console.bundle.js +126 -113
- package/dist/client/console.css +192 -106
- package/dist/client/console.tsx +1239 -492
- package/dist/client/kit/kit.css +279 -0
- package/dist/client/kit/kit.tsx +259 -0
- package/dist/client/vendor-logos.ts +42 -0
- package/dist/src/console.d.ts +20 -11
- package/dist/src/console.js +36 -24
- package/package.json +7 -5
- package/src/console.ts +43 -30
package/dist/client/console.tsx
CHANGED
|
@@ -2,12 +2,15 @@
|
|
|
2
2
|
// of the host (`/-/worlds`, `/-/ping`) or of a world (`/-/<org>/<world>/…`), called with the token the
|
|
3
3
|
// person typed, from the same origin the host serves. The client holds no state a door did not
|
|
4
4
|
// answer, and offers nothing `volter world` or `curl` could not do. Locators are roles and text.
|
|
5
|
-
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
|
5
|
+
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
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';
|
|
6
8
|
import { createRoot } from 'react-dom/client';
|
|
9
|
+
import { agentPrompt } from '../../../packages/cli/src/agent-prompt.ts';
|
|
7
10
|
|
|
8
|
-
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 };
|
|
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 };
|
|
9
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 }> };
|
|
10
|
-
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
|
|
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 };
|
|
11
14
|
type Changeset = { name: string; actions?: unknown[]; receipts?: unknown; verification?: unknown; approvals?: unknown[] };
|
|
12
15
|
type Mode = { kind: 'admin'; worlds: Row[] } | { kind: 'world'; worlds: Array<{ name: string; base: string; twins: Row['twins'] }> };
|
|
13
16
|
|
|
@@ -20,82 +23,263 @@ const ORIGIN = window.location.origin;
|
|
|
20
23
|
const WORLDS = (ROOT.dataset.worlds ?? ORIGIN).replace(/\/+$/, '');
|
|
21
24
|
/** The Worlds a World token may open, when the host does not list them (a hosted host keeps its tenants' names to itself). */
|
|
22
25
|
const NAMES = (ROOT.dataset.names ?? '').split(',').filter((n) => n !== '');
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
//
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
26
|
+
// Served on a World's own origin, beside the World's twins and mirrors (same-origin frames of this tab), the console
|
|
27
|
+
// keeps no token any script there could read: a token (typed, or carried in the fragment) is exchanged once for the
|
|
28
|
+
// World's HttpOnly session, and the doors ride that cookie. SESSION stands in for the token from then on.
|
|
29
|
+
const ON_WORLD = ROOT.dataset.worldOrigin === '1';
|
|
30
|
+
// the browser's fetch as it was when the console loaded, before any mirror frame existed: a same-origin mirror that
|
|
31
|
+
// later replaces `parent.fetch` does not see the console's requests through it. Hardening, not isolation (such a frame
|
|
32
|
+
// reaches much else); a sign-out's fresh document is what ends its reach into this one
|
|
33
|
+
const nativeFetch = window.fetch.bind(window);
|
|
34
|
+
const SESSION = '(session)';
|
|
35
|
+
/** Stands in for the token while a pass the platform signed (in the fragment) is exchanged for the World's session. */
|
|
36
|
+
const PASSED = '(pass)';
|
|
29
37
|
|
|
30
38
|
// the tab's own storage, never localStorage: the token the person typed ends with the tab and is not left on
|
|
31
39
|
// an origin that also serves the Worlds' twins (a served World mounts this console beside them)
|
|
32
|
-
const keep = (t: string): void => { try { if (t) sessionStorage.setItem(TOKEN_KEY, t); else sessionStorage.removeItem(TOKEN_KEY); localStorage.removeItem(TOKEN_KEY); } catch { /* private window */ } };
|
|
33
|
-
// a
|
|
34
|
-
// to a server); it is taken once and dropped from the address bar
|
|
40
|
+
const keep = (t: string): void => { try { if (t && !ON_WORLD) sessionStorage.setItem(TOKEN_KEY, t); else sessionStorage.removeItem(TOKEN_KEY); localStorage.removeItem(TOKEN_KEY); } catch { /* private window */ } };
|
|
41
|
+
// a token handed in the URL fragment (never sent to a server) is taken once and dropped from the address bar
|
|
35
42
|
const PORTAL_KEY = 'volter.console.portal';
|
|
43
|
+
// the way back to the platform a person came from: only the issuer the World verified on their pass (the session door
|
|
44
|
+
// says it), never an address from this page's URL, which anyone can write
|
|
45
|
+
const keepPortal = (issuer: unknown): void => { try { if (typeof issuer === 'string' && /^https?:$/.test(new URL(issuer).protocol)) sessionStorage.setItem(PORTAL_KEY, new URL(issuer).origin); } catch { /* fine */ } };
|
|
36
46
|
const fromFragment = (): string => {
|
|
37
|
-
|
|
38
|
-
const m = /(?:^#|&)token=([^&]+)/.exec(h); const pm = /(?:^#|&)portal=([^&]+)/.exec(h);
|
|
39
|
-
if (pm) { try { sessionStorage.setItem(PORTAL_KEY, decodeURIComponent(pm[1]!)); } catch { /* fine */ } }
|
|
40
|
-
if (!m) return '';
|
|
41
|
-
try { window.history.replaceState(null, '', window.location.pathname + window.location.search); } catch { /* fine */ }
|
|
42
|
-
return decodeURIComponent(m[1]!);
|
|
47
|
+
return FRAGMENT.get('token') ?? '';
|
|
43
48
|
};
|
|
44
49
|
/** The portal this host console was opened from, when it was: the way back for a person. */
|
|
45
|
-
|
|
46
|
-
const
|
|
47
|
-
const
|
|
50
|
+
// a fragment is anyone's to write: only an http(s) origin is a way back, never a path or another scheme
|
|
51
|
+
const portal = (): string => { try { const p = sessionStorage.getItem(PORTAL_KEY) ?? ''; return p && /^https?:$/.test(new URL(p).protocol) ? new URL(p).origin : ''; } catch { return ''; } };
|
|
52
|
+
const held = (): string => {
|
|
53
|
+
const f = fromFragment();
|
|
54
|
+
// on a World origin a carried token is only exchanged (App's load); without one, the session is the credential,
|
|
55
|
+
// unless this document is the fresh one a sign-out loaded (`?signin`): then a token is asked for
|
|
56
|
+
if (ON_WORLD) {
|
|
57
|
+
keep('');
|
|
58
|
+
// a person the platform sent here with a pass: it is spent on the World's session (App's load)
|
|
59
|
+
if (FRAGMENT.get('pass')) return PASSED;
|
|
60
|
+
const signingIn = new URLSearchParams(window.location.search).has('signin');
|
|
61
|
+
if (signingIn) { try { window.history.replaceState(null, '', window.location.pathname + window.location.hash); } catch { /* fine */ } }
|
|
62
|
+
return f || (signingIn ? '' : SESSION);
|
|
63
|
+
}
|
|
64
|
+
if (f) { keep(f); return f; }
|
|
65
|
+
try { return sessionStorage.getItem(TOKEN_KEY) ?? ''; } catch { return ''; }
|
|
66
|
+
};
|
|
48
67
|
|
|
49
68
|
async function door(token: string, path: string, init: RequestInit = {}): Promise<Response> {
|
|
50
|
-
|
|
69
|
+
// SESSION: no token header; the World's session cookie (same-origin) is the credential
|
|
70
|
+
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' } : {}) } });
|
|
51
71
|
}
|
|
52
72
|
|
|
53
|
-
/**
|
|
54
|
-
function
|
|
73
|
+
/** One door read with this token (the kit's useDoor). */
|
|
74
|
+
function useDoor<T>(token: string, path: string | null, version = 0): Load<T> { return useKitDoor<T>((p) => door(token, p), path, version, token); }
|
|
75
|
+
|
|
76
|
+
/** A datetime-local input's value for an instant, in the viewer's zone. */
|
|
77
|
+
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())}`; };
|
|
78
|
+
|
|
79
|
+
/** The first whole minute at or after an instant: a datetime-local picks minutes, and the clock only moves forward. */
|
|
80
|
+
const nextMinute = (iso: string): string => new Date(Math.ceil(Date.parse(iso) / 60_000) * 60_000).toISOString();
|
|
81
|
+
|
|
82
|
+
/** An entry's standing: observed from the vendor, a receipt, default data, or not yet pushed. */
|
|
83
|
+
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
|
+
/** The trace id of a W3C traceparent (`00-<32 hex>-<16 hex>-<2 hex>`), or null. */
|
|
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
|
+
|
|
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 };
|
|
90
|
+
const SNAP_KEY = 'volter.console.snapshots';
|
|
91
|
+
const snapshots = (): Record<string, Snapshot> => { try { return JSON.parse(sessionStorage.getItem(SNAP_KEY) ?? '{}') as Record<string, Snapshot>; } catch { return {}; } };
|
|
92
|
+
// on a World origin a snapshot's tokens stay in this module (no frame can reach it), never in storage
|
|
93
|
+
const SNAP_TOKENS = new Map<string, { token: string; readToken: string }>(); // cache: this tab's snapshot tokens on a World origin
|
|
94
|
+
const keepSnapshot = (s: Snapshot): void => {
|
|
95
|
+
if (ON_WORLD) { if (s.token || s.readToken) SNAP_TOKENS.set(s.name, { token: s.token, readToken: s.readToken }); s = { ...s, token: '', readToken: '' }; }
|
|
96
|
+
try { sessionStorage.setItem(SNAP_KEY, JSON.stringify({ ...snapshots(), [s.name]: s })); } catch { /* no storage */ }
|
|
97
|
+
};
|
|
98
|
+
/** The read token a snapshot this tab made opens with, wherever it is kept. */
|
|
99
|
+
const snapshotToken = (name: string): string => SNAP_TOKENS.get(name)?.readToken || snapshots()[name]?.readToken || '';
|
|
100
|
+
const forgetSnapshot = (name: string): void => { try { const all = snapshots(); delete all[name]; sessionStorage.setItem(SNAP_KEY, JSON.stringify(all)); } catch { /* no storage */ } };
|
|
101
|
+
|
|
102
|
+
// A World's pages live at the World's own origin, where its browser session lives (a host that gives
|
|
103
|
+
// each World one answers `POST …/session` elsewhere with a 409 naming it). The console moves there
|
|
104
|
+
// carrying, in the URL's fragment (which no server sees), the token, a snapshot this tab made, or the
|
|
105
|
+
// snapshot a page on another origin is returning from.
|
|
106
|
+
const FRAGMENT = new URLSearchParams(window.location.hash.slice(1));
|
|
107
|
+
const CLOSING = FRAGMENT.get('close') ?? '';
|
|
108
|
+
/** `value` as a World origin this console may send the tab to, or null: an http(s) origin and nothing else (no
|
|
109
|
+
* path, no `javascript:`), with this page's scheme and port, that is this page's own host or a World origin of this
|
|
110
|
+
* host: `<world>--<org>.` before this page's host (a console on the host's origin), or before the same host as this
|
|
111
|
+
* page's (a sibling World origin). A fragment is anyone's to write, so nothing it carries reaches a navigation
|
|
112
|
+
* unchecked; a host that merely shares a last label (`evil.dev`, another IP) is none of these. */
|
|
113
|
+
/** `value` as a pure http(s) origin (no path, no other scheme), or null. */
|
|
114
|
+
function httpOrigin(value: unknown): URL | null {
|
|
115
|
+
if (typeof value !== 'string') return null;
|
|
116
|
+
let at: URL; try { at = new URL(value); } catch { return null; }
|
|
117
|
+
return (at.protocol === 'http:' || at.protocol === 'https:') && at.origin === value.replace(/\/+$/, '') ? at : null;
|
|
118
|
+
}
|
|
119
|
+
function worldOriginOf(value: unknown): string | null {
|
|
120
|
+
const at = httpOrigin(value); if (!at) return null;
|
|
121
|
+
const here = new URL(ORIGIN);
|
|
122
|
+
if (at.protocol !== here.protocol || at.port !== here.port) return null;
|
|
123
|
+
if (at.hostname === here.hostname) return at.origin;
|
|
124
|
+
// a World origin's first label is `<world>--<org>` (world-origins.ts)
|
|
125
|
+
const label = /^[a-z0-9](?:[a-z0-9]|-(?!-))*--[a-z0-9](?:[a-z0-9]|-(?!-))*\./;
|
|
126
|
+
if (!label.test(at.hostname)) return null;
|
|
127
|
+
const rest = at.hostname.replace(label, '');
|
|
128
|
+
return rest === here.hostname || (label.test(here.hostname) && rest === here.hostname.replace(label, '')) ? at.origin : null;
|
|
129
|
+
}
|
|
130
|
+
/** A carried snapshot, kept only when it has a snapshot's shape; its way back only when it is a World origin. */
|
|
131
|
+
function carriedSnapshot(raw: string): Snapshot | null {
|
|
132
|
+
let s: Record<string, unknown>; try { s = JSON.parse(raw) as Record<string, unknown>; } catch { return null; }
|
|
133
|
+
const world = /^[a-z0-9][a-z0-9-]*\/[a-z0-9][a-z0-9-]*$/i;
|
|
134
|
+
if (typeof s.name !== 'string' || !world.test(s.name) || typeof s.parent !== 'string' || !world.test(s.parent) || typeof s.at !== 'string') return null;
|
|
135
|
+
const back = s.parentOrigin === undefined ? undefined : worldOriginOf(s.parentOrigin);
|
|
136
|
+
return { name: s.name, parent: s.parent, token: typeof s.token === 'string' ? s.token : '', readToken: typeof s.readToken === 'string' ? s.readToken : '', at: s.at, expiresAt: typeof s.expiresAt === 'string' ? s.expiresAt : null, ...(back ? { parentOrigin: back } : {}) };
|
|
137
|
+
}
|
|
138
|
+
// a carried snapshot is kept to be viewed, never as one this tab made (`made` is never carried), and never over a
|
|
139
|
+
// record this tab already holds: a fragment cannot create or replace what a later fragment's `close` is checked against
|
|
140
|
+
{ const carried = FRAGMENT.get('snapshot'); if (carried) { const s = carriedSnapshot(carried); if (s && !snapshots()[s.name]) keepSnapshot(s); } }
|
|
141
|
+
// the console's home, when a World's pages moved it away from it: the host's origin, where the list of
|
|
142
|
+
// Worlds (and an admin's token) lives
|
|
143
|
+
const HOME_KEY = 'volter.console.home';
|
|
144
|
+
/** A carried home only when it is this host's own: an http(s) origin (never a `javascript:` or a path) whose host is this
|
|
145
|
+
* page's, or the one this World's origin sits under (`<world>--<org>.<that host>`), on any port. A fragment is anyone's to
|
|
146
|
+
* write; a look-alike site never becomes the logo's link. */
|
|
147
|
+
const homeOrigin = (value: unknown): string | null => {
|
|
148
|
+
const at = httpOrigin(value); if (!at) return null;
|
|
149
|
+
const here = new URL(ORIGIN).hostname;
|
|
150
|
+
const under = here.replace(/^[a-z0-9](?:[a-z0-9]|-(?!-))*--[a-z0-9](?:[a-z0-9]|-(?!-))*\./, '');
|
|
151
|
+
return at.hostname === here || at.hostname === under ? at.origin : null;
|
|
152
|
+
};
|
|
153
|
+
{ const carried = homeOrigin(FRAGMENT.get('home')); if (carried) { try { sessionStorage.setItem(HOME_KEY, carried); } catch { /* fine */ } } }
|
|
154
|
+
// what the fragment carried is read (FRAGMENT, above); it leaves the address bar now, whoever supplied the token
|
|
155
|
+
if (['token', 'pass', 'portal', 'snapshot', 'home', 'close'].some((k) => FRAGMENT.has(k))) { try { window.history.replaceState(null, '', window.location.pathname + window.location.search); } catch { /* fine */ } }
|
|
156
|
+
const home = (): string => { try { return homeOrigin(sessionStorage.getItem(HOME_KEY)) ?? ''; } catch { return ''; } };
|
|
157
|
+
function moveTo(origin: string, token: string, name: string, path?: string): void {
|
|
158
|
+
const carried = new URLSearchParams({ token, home: home() || ORIGIN });
|
|
159
|
+
const snap = snapshots()[name]; if (snap) carried.set('snapshot', JSON.stringify(snap));
|
|
160
|
+
window.location.replace(`${origin}${path ?? `${window.location.pathname}${window.location.search}`}#${carried.toString()}`);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** 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
|
+
function route(): string[] { return window.location.pathname.slice(BASE.length).split('/').filter(Boolean).map((p) => { try { return decodeURIComponent(p); } catch { return ''; } }); }
|
|
165
|
+
/** An org's or a World's name: letters, digits and single hyphens (world-origins.ts), never a separator or a dot. */
|
|
166
|
+
const WORLD_PART = /^[a-z0-9](?:[a-z0-9]|-(?!-))*$/i;
|
|
167
|
+
/** The World a path names, `<org>/<world>`, or '' when its segments are not names: nothing decoded from a path (an
|
|
168
|
+
* encoded `/` or `..`) ever reaches a door's path. */
|
|
169
|
+
const worldOf = (o?: string, w?: string): string => (o && w && WORLD_PART.test(o) && WORLD_PART.test(w) ? `${o}/${w}` : '');
|
|
55
170
|
function go(parts: string[]): void { window.history.pushState(null, '', `${BASE}/${parts.map(encodeURIComponent).join('/')}`); window.dispatchEvent(new PopStateEvent('popstate')); }
|
|
56
171
|
|
|
172
|
+
/** A World this machine serves on loopback opens its session for its own page with no token (served-world.ts
|
|
173
|
+
* localSession): nothing is ever pasted, and a restart or a rotated token is picked up by asking again. Asked of the
|
|
174
|
+
* World in the path, else of each World the origin names. The answer's `token` comes only where the World has no
|
|
175
|
+
* origin of its own (a shared origin, where a session cannot reach its doors); else the session is the credential.
|
|
176
|
+
* 'moved': the World's session lives at its own origin, where the tab now goes. null: no World here opens itself
|
|
177
|
+
* (a remote host), and the page asks for a token. */
|
|
178
|
+
async function localSession(): Promise<{ token: string } | 'moved' | null> {
|
|
179
|
+
const [o, w] = route();
|
|
180
|
+
const here = worldOf(o, w);
|
|
181
|
+
let names = here ? [here] : NAMES;
|
|
182
|
+
if (names.length === 0) {
|
|
183
|
+
const ping = await nativeFetch(`${ORIGIN}/-/ping`).catch(() => null);
|
|
184
|
+
names = ping?.ok ? (((await ping.json().catch(() => ({}))) as { worlds?: string[] }).worlds ?? []) : [];
|
|
185
|
+
}
|
|
186
|
+
names = names.filter((n) => { const [a, b, ...rest] = n.split('/'); return rest.length === 0 && worldOf(a, b) === n; });
|
|
187
|
+
for (const name of names) {
|
|
188
|
+
const r = await nativeFetch(`${ORIGIN}/-/${name}/session`, { method: 'POST' }).catch(() => null);
|
|
189
|
+
if (!r) continue;
|
|
190
|
+
const b = (await r.json().catch(() => ({}))) as { local?: boolean; token?: string; origin?: string };
|
|
191
|
+
// the host's own answer names the World's origin (an http(s) origin only)
|
|
192
|
+
const own = httpOrigin(b.origin)?.origin;
|
|
193
|
+
if (r.status === 409 && own && own !== ORIGIN) { window.location.replace(`${own}${BASE}/${name}`); return 'moved'; }
|
|
194
|
+
if (r.ok && b.local) return { token: typeof b.token === 'string' && b.token ? b.token : SESSION };
|
|
195
|
+
}
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
|
|
57
199
|
function App(): React.JSX.Element {
|
|
58
200
|
const [token, setToken] = useState<string>(held());
|
|
201
|
+
// a World this machine serves (localSession): no sign-in, no sign-out; `probed`: that was asked, so a sign-in form
|
|
202
|
+
// is shown only once it is known nothing opens by itself
|
|
203
|
+
const [local, setLocal] = useState(false); const [probed, setProbed] = useState(false);
|
|
59
204
|
const [draft, setDraft] = useState<string>('');
|
|
60
205
|
const [mode, setMode] = useState<Mode | null>(null);
|
|
61
206
|
const [error, setError] = useState<string | null>(null);
|
|
62
207
|
const [parts, setParts] = useState<string[]>(route());
|
|
63
208
|
useEffect(() => { const on = () => setParts(route()); window.addEventListener('popstate', on); return () => window.removeEventListener('popstate', on); }, []);
|
|
209
|
+
// a snapshot's page on its own origin sent the way back here (`#close=<branch>`): this World's token
|
|
210
|
+
// removes the branch it was
|
|
211
|
+
useEffect(() => {
|
|
212
|
+
const [ro, rw] = route(); const here = worldOf(ro, rw); const [o, w] = here ? here.split('/') : [];
|
|
213
|
+
// only a snapshot this tab made of this World: a fragment is anyone's to write
|
|
214
|
+
const made = snapshots()[CLOSING];
|
|
215
|
+
if (!CLOSING || !token || !o || !w || !made?.made || made.parent !== `${o}/${w}` || worldOf(...CLOSING.split('/')) !== CLOSING) return;
|
|
216
|
+
try { window.history.replaceState(null, '', window.location.pathname + window.location.search); } catch { /* fine */ }
|
|
217
|
+
void door(token, `/-/${o}/${w}/branches/${CLOSING}`, { method: 'DELETE' }).catch(() => null).then(() => forgetSnapshot(CLOSING));
|
|
218
|
+
}, [token]);
|
|
64
219
|
|
|
65
220
|
/** The mode is what the origin answers: a host's inventory to the admin token, else the worlds this token opens. */
|
|
66
221
|
const load = useCallback(async (t: string) => {
|
|
67
222
|
setError(null);
|
|
68
|
-
if (!t) {
|
|
69
|
-
|
|
223
|
+
if (!t) {
|
|
224
|
+
const found = await localSession();
|
|
225
|
+
if (found === 'moved') return;
|
|
226
|
+
if (found) { setLocal(true); if (found.token !== SESSION) keep(found.token); setToken(found.token); return; }
|
|
227
|
+
setMode(null); setProbed(true); return;
|
|
228
|
+
}
|
|
229
|
+
// on a World's own origin the session is the credential: a local World opens it again itself (after a restart, a
|
|
230
|
+
// cleared cookie or a rotated token), and says it is local
|
|
231
|
+
if (ON_WORLD && t === SESSION) { const found = await localSession(); if (found === 'moved') return; if (found) setLocal(true); }
|
|
232
|
+
// on a World origin a pass the platform signed for this person is spent on the World's session, once
|
|
233
|
+
if (ON_WORLD && t === PASSED) {
|
|
234
|
+
const [ro, rw] = route(); const here = worldOf(ro, rw); const [o, w] = here ? here.split('/') : [];
|
|
235
|
+
const r = o && w ? await nativeFetch(`${ORIGIN}/-/${o}/${w}/session`, { method: 'POST', headers: { 'x-volter-pass': FRAGMENT.get('pass') ?? '' } }).catch(() => null) : null;
|
|
236
|
+
if (!r) { setError(`${ORIGIN} did not answer: no session was opened`); setMode(null); return; }
|
|
237
|
+
if (!r.ok) { setError(`${await failure(r)}. Open it again from the platform.`); setMode(null); return; }
|
|
238
|
+
keepPortal(((await r.json().catch(() => ({}))) as { issuer?: string }).issuer);
|
|
239
|
+
setToken(SESSION); return;
|
|
240
|
+
}
|
|
241
|
+
// on a World origin a token is exchanged for the World's session before anything renders, and then dropped
|
|
242
|
+
if (ON_WORLD && t !== SESSION) {
|
|
243
|
+
const [ro, rw] = route(); const here = worldOf(ro, rw); const [o, w] = here ? here.split('/') : [];
|
|
244
|
+
const r = o && w ? await nativeFetch(`${ORIGIN}/-/${o}/${w}/session`, { method: 'POST', headers: { 'x-volter-token': t } }).catch(() => null) : null;
|
|
245
|
+
// a refused token, a session kept elsewhere and a host that failed are three different things to say
|
|
246
|
+
if (!r) { setError(`${ORIGIN} did not answer: no session was opened`); setMode(null); return; }
|
|
247
|
+
if (!r.ok) {
|
|
248
|
+
const why = await failure(r);
|
|
249
|
+
setError(r.status === 401 || r.status === 403 ? `That token does not open ${o}/${w}.` : r.status === 409 ? `${o}/${w}'s session lives at another origin: ${why}` : `The World could not open a session (${r.status}): ${why}`);
|
|
250
|
+
setMode(null); return;
|
|
251
|
+
}
|
|
252
|
+
setToken(SESSION); return;
|
|
253
|
+
}
|
|
254
|
+
// a World's own origin serves that World, never the host's inventory: asked only where a host could answer
|
|
255
|
+
const inventory = ON_WORLD ? new Response(null, { status: 404 }) : await door(t, '/-/worlds');
|
|
70
256
|
if (inventory.ok) { const { worlds } = (await inventory.json()) as { worlds: Row[] }; setMode({ kind: 'admin', worlds }); return; }
|
|
71
257
|
if (inventory.status === 429) { setError('You are asking faster than the platform allows — wait a moment and reload.'); setMode(null); return; } // never misread a rate limit as "not a host"
|
|
72
258
|
// A host's inventory accepts only its admin token. A world token gets 401 here but
|
|
73
259
|
// still opens its own world below; only a managed portal's 401 means sign-in expired.
|
|
74
|
-
if (inventory.status === 401 && MANAGED) { setError('Your sign-in no longer opens this console — sign in again.'); setMode(null); return; }
|
|
75
260
|
if (inventory.status >= 500) { const body = (await inventory.json().catch(() => ({}))) as { error?: string }; setError(`The platform could not list your worlds${body.error ? `: ${body.error}` : ''}. Reload; if it stays, the status page says what is wrong.`); setMode(null); return; }
|
|
76
261
|
let worlds = NAMES;
|
|
77
262
|
if (worlds.length === 0) {
|
|
78
|
-
const ping = await
|
|
263
|
+
const ping = await nativeFetch(`${ORIGIN}/-/ping`); // not a platform answer: perhaps this origin is a bare host
|
|
79
264
|
if (!ping.ok) { setError(`${ORIGIN} does not answer as a host`); setMode(null); return; }
|
|
80
265
|
worlds = ((await ping.json()) as { worlds?: string[] }).worlds ?? [];
|
|
81
266
|
if (worlds.length === 0) { setError(`${ORIGIN} does not list its worlds: open the console with the World's URL (volter world console <url>/<org>/<world>)`); setMode(null); return; }
|
|
82
267
|
}
|
|
83
268
|
const opened: Array<{ name: string; base: string; twins: Row['twins'] }> = [];
|
|
84
269
|
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 })) }); } }
|
|
85
|
-
if (opened.length === 0) { setError('
|
|
270
|
+
if (opened.length === 0) { setError('This link or key opens no World here: it was revoked, or it has ended.'); setMode(null); return; }
|
|
86
271
|
setMode({ kind: 'world', worlds: opened });
|
|
87
272
|
}, []);
|
|
88
273
|
useEffect(() => { void load(token); }, [token, load]);
|
|
89
274
|
|
|
90
275
|
const worldToken = (name: string): string => (mode?.kind === 'admin' ? mode.worlds.find((w) => w.name === name)?.token ?? token : token);
|
|
91
|
-
const [who, setWho] = useState<string>('');
|
|
92
|
-
useEffect(() => { if (!MANAGED || !token) return; void door(token, '/-/orgs').then(async (r) => { if (r.ok) { const b = (await r.json()) as { person?: { email?: string; name?: string; support?: { until: string; reason: string } } }; setSupport(b.person?.support ?? null); setWho(b.person?.email ?? b.person?.name ?? ''); } }); }, [token]);
|
|
276
|
+
const [who, setWho] = useState<string>('');
|
|
93
277
|
|
|
94
278
|
if (!token || (!mode && !error)) {
|
|
95
279
|
return (
|
|
96
280
|
<main>
|
|
97
|
-
<
|
|
98
|
-
{token && !mode && !error ? <p>Opening…</p> : (
|
|
281
|
+
<Brand href={`${BASE}/`} />
|
|
282
|
+
{(token && !mode && !error) || (!token && !probed) ? <p>Opening…</p> : (
|
|
99
283
|
<form onSubmit={(e) => { e.preventDefault(); keep(draft.trim()); setToken(draft.trim()); }}>
|
|
100
284
|
<p>A host's admin token opens every world it serves; a world's token opens that world.</p>
|
|
101
285
|
<label>Token <input name="token" type="password" value={draft} onChange={(e) => setDraft(e.target.value)} autoFocus /></label>
|
|
@@ -105,542 +289,1100 @@ function App(): React.JSX.Element {
|
|
|
105
289
|
</main>
|
|
106
290
|
);
|
|
107
291
|
}
|
|
108
|
-
const signOut = (): void => {
|
|
292
|
+
const signOut = (): void => {
|
|
293
|
+
// a local World has no sign-in to leave: its page opens itself again
|
|
294
|
+
if (local) { window.location.reload(); return; }
|
|
295
|
+
if (ON_WORLD) {
|
|
296
|
+
// on a World origin the page is replaced, not re-rendered: nothing a frame beside the console may have hooked
|
|
297
|
+
// into this document survives to see the next token typed
|
|
298
|
+
const [ro, rw] = route(); const here = worldOf(ro, rw); const [o, w] = here ? here.split('/') : [];
|
|
299
|
+
// opened from a platform, the way out is back to it (where the person signs in); otherwise the World asks for a token
|
|
300
|
+
const fresh = (): void => window.location.replace(portal() ? `${portal()}/` : `${ORIGIN}${BASE}/${o && w ? `${o}/${w}` : ''}?signin`);
|
|
301
|
+
setTimeout(fresh, 2000); // the page is replaced whether or not the host answers
|
|
302
|
+
if (o && w) void nativeFetch(`${ORIGIN}/-/${o}/${w}/session/end`, { method: 'POST' }).catch(() => null).then(fresh); else fresh();
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
if (mode) for (const w of mode.worlds) navigator.sendBeacon(`${w.base.replace(/\/[^/]+\/[^/]+$/, '')}/-/${w.name}/session/end`); keep(''); setToken(''); setDraft(''); setMode(null); go([]);
|
|
306
|
+
};
|
|
109
307
|
if (error || !mode) {
|
|
110
|
-
return <main><
|
|
308
|
+
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>;
|
|
111
309
|
}
|
|
112
|
-
|
|
310
|
+
// `/<org>/<world>/<twin>` is a twin; `/<org>/<world>/~/<tab>[/<arg>]` is a tab of the World — `~`
|
|
311
|
+
// is never a vendor name, so a tab can never shadow a twin
|
|
312
|
+
const [org, world, third, tabPart, argPart] = parts;
|
|
313
|
+
const tabbed = third === '~';
|
|
314
|
+
// 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
|
|
315
|
+
const PART = /^[A-Za-z0-9_-]+$/;
|
|
316
|
+
const twin = tabbed || !third || !PART.test(third) ? undefined : third;
|
|
317
|
+
const tabArg = argPart && PART.test(argPart) ? argPart : undefined;
|
|
318
|
+
// only a World's name opens a World page (and so reaches its doors); any other path is the list of Worlds
|
|
319
|
+
const name = worldOf(org, world);
|
|
320
|
+
const snapshot = name ? snapshots()[name] ?? null : null;
|
|
321
|
+
/** The token a World is opened with: a snapshot this tab made opens with its read token. */
|
|
322
|
+
const tokenOf = (n: string): string => snapshotToken(n) || worldToken(n);
|
|
323
|
+
const returnToLive = async (s: Snapshot): Promise<void> => {
|
|
324
|
+
// the parent's pages (and its token) live at its own origin: that page removes the branch
|
|
325
|
+
const back = worldOriginOf(s.parentOrigin);
|
|
326
|
+
if (back && back !== ORIGIN) { forgetSnapshot(s.name); window.location.assign(`${back}${BASE}/${s.parent}#close=${encodeURIComponent(s.name)}`); return; }
|
|
327
|
+
// 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
|
+
forgetSnapshot(s.name); go(s.parent.split('/'));
|
|
331
|
+
};
|
|
113
332
|
return (
|
|
114
|
-
<main>
|
|
115
|
-
{support ? <p role="alert">Viewing as Volter support — read only, until {when(support.until)}. Reason on record: {support.reason}.</p> : null}
|
|
333
|
+
<main className={name ? 'app' : undefined}>
|
|
116
334
|
<header>
|
|
117
|
-
|
|
118
|
-
<
|
|
119
|
-
|
|
335
|
+
{name ? <DrawerButton /> : null}
|
|
336
|
+
<Brand href={`${home() && home() !== ORIGIN ? home() : ''}${BASE}/`} onClick={(e) => { if (home() && home() !== ORIGIN) return; e.preventDefault(); go([]); }} />
|
|
337
|
+
<nav aria-label="breadcrumb" className="crumbs">
|
|
338
|
+
{name ? <><span className="crumb-sep" aria-hidden="true">/</span><span>{name.split('/')[0]}</span><span className="crumb-sep" aria-hidden="true">/</span><a href={`${BASE}/${name}`} className="crumb-world" onClick={(e) => { e.preventDefault(); go(name.split('/')); }}>{name.split('/')[1]}</a></> : null}
|
|
120
339
|
{twin ? <span> / {twin}</span> : null}
|
|
340
|
+
{tabbed && tabPart === 'trace' ? <span> / trace</span> : null}
|
|
121
341
|
</nav>
|
|
122
|
-
{
|
|
123
|
-
<
|
|
124
|
-
{
|
|
125
|
-
{
|
|
126
|
-
{MANAGED ? <a href="#help">Help</a> : null}
|
|
127
|
-
<button onClick={signOut}>Sign out</button>
|
|
342
|
+
{/* 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 */}
|
|
343
|
+
{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}
|
|
344
|
+
<span className="mode">{local ? 'on this machine' : snapshot ? 'a snapshot, read only' : who ? `signed in as ${who}` : mode.kind === 'admin' ? 'host: admin' : 'opened with a link or key'}</span>
|
|
345
|
+
{local ? null : <button onClick={signOut}>Sign out</button>}
|
|
128
346
|
</header>
|
|
129
|
-
{
|
|
130
|
-
? <WorldView
|
|
347
|
+
{name
|
|
348
|
+
? <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} />
|
|
131
349
|
: <Worlds mode={mode} admin={mode.kind === 'admin' ? token : null} reload={() => load(token)} />}
|
|
132
350
|
</main>
|
|
133
351
|
);
|
|
134
352
|
}
|
|
135
353
|
|
|
354
|
+
/** A HOST's home (self-hosted, or a World token's list): the Worlds this origin serves, each opened in the page; a host's
|
|
355
|
+
* admin rotates, removes and makes them here, where the admin token lives. People and orgs are the platform's pages. */
|
|
136
356
|
function Worlds({ mode, admin, reload }: { mode: Mode; admin: string | null; reload: () => Promise<void> }): React.JSX.Element {
|
|
137
357
|
const [org, setOrg] = useState(''); const [world, setWorld] = useState(''); const [vendors, setVendors] = useState('');
|
|
138
358
|
const [note, setNote] = useState<string | null>(null);
|
|
139
|
-
const
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
const names = ((await r.json()) as { orgs: Array<{ slug: string | null; name: string }> }).orgs.map((o) => o.slug ?? o.name);
|
|
148
|
-
setMyOrgs(names);
|
|
149
|
-
const pick = names.length === 1 ? names[0]! : names.includes(remembered()) ? remembered() : '';
|
|
150
|
-
if (pick) { setOrg(pick); setCurrent(pick); }
|
|
151
|
-
// the org worked in no longer exists (deleted, or left): stand in the first one that does
|
|
152
|
-
setCurrent((c) => (c && !names.includes(c) ? (names[0] ?? '') : c)); setOrg((o) => (o && !names.includes(o) ? (names[0] ?? '') : o));
|
|
153
|
-
}, [admin]);
|
|
154
|
-
useEffect(() => { void loadOrgs(); }, [loadOrgs]);
|
|
155
|
-
const [worldsVersion, setWorldsVersion] = useState(0);
|
|
156
|
-
// a world deleted from the portal: the platform's door (walk 3b: nothing removed a world, so an org could never be deleted)
|
|
157
|
-
const deleteWorld = async (name: string): Promise<void> => {
|
|
158
|
-
setNote(null);
|
|
359
|
+
const rotateWorld = async (name: string): Promise<void> => {
|
|
360
|
+
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;
|
|
361
|
+
const r = await door(admin!, `/-/worlds/${name}/rotate`, { method: 'POST' });
|
|
362
|
+
const b = (await r.json().catch(() => ({}))) as { token?: string; readToken?: string; error?: string };
|
|
363
|
+
setNote(r.ok ? `rotated ${name}: the new tokens are shown once — token ${b.token} · read token ${b.readToken}` : b.error ?? `answered ${r.status}`);
|
|
364
|
+
await reload();
|
|
365
|
+
};
|
|
366
|
+
const removeWorld = async (name: string): Promise<void> => {
|
|
159
367
|
const r = await door(admin!, `/-/worlds/${name}`, { method: 'DELETE' });
|
|
160
|
-
const
|
|
161
|
-
setNote(r.ok ? `
|
|
162
|
-
await reload();
|
|
368
|
+
const b = (await r.json().catch(() => ({}))) as { error?: string };
|
|
369
|
+
setNote(r.ok ? `removed ${name}` : b.error ?? `answered ${r.status}`);
|
|
370
|
+
await reload();
|
|
163
371
|
};
|
|
164
372
|
const [provisioning, setProvisioning] = useState(false);
|
|
165
373
|
const provision = async (e: React.FormEvent): Promise<void> => {
|
|
166
374
|
e.preventDefault(); setNote(null); setProvisioning(true);
|
|
167
375
|
try {
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
376
|
+
const r = await door(admin!, '/-/worlds', { method: 'POST', body: JSON.stringify({ org, world, vendors: vendors.split(',').map((v) => v.trim()).filter(Boolean) }) });
|
|
377
|
+
const body = (await r.json()) as { error?: string; name?: string };
|
|
378
|
+
if (!r.ok) { setNote(body.error ?? `the host answered ${r.status}`); return; }
|
|
379
|
+
setNote(`provisioned ${body.name}`); setWorld(''); setVendors(''); await reload(); // the org stays: the next world usually goes to the same org
|
|
172
380
|
} finally { setProvisioning(false); }
|
|
173
381
|
};
|
|
174
|
-
const allRows: Array<{ name: string; base: string; token?: string; twins: Row['twins'] }> = mode.worlds;
|
|
175
|
-
const rows = MANAGED && current && myOrgs.length > 1 ? allRows.filter((r) => r.name.startsWith(`${current}/`)) : allRows;
|
|
176
382
|
return (
|
|
177
383
|
<section aria-labelledby="worlds">
|
|
178
|
-
{MANAGED && admin ? <GettingStarted token={admin} orgs={myOrgs} worlds={allRows.length} firstWorld={rows[0]?.name} version={worldsVersion} /> : null}
|
|
179
384
|
<h2 id="worlds">Worlds</h2>
|
|
180
|
-
|
|
385
|
+
<p className="lede">A World is a copy of the services your app uses (Stripe, Slack, GitHub…), with none of the real consequences: your app talks to it, and you can see everything it did on each vendor's own screens.</p>
|
|
181
386
|
<table>
|
|
182
|
-
<thead><tr><th>world</th><th>address</th><th>
|
|
387
|
+
<thead><tr><th>world</th><th>address</th><th>stand-ins for</th>{admin ? <th>actions</th> : null}</tr></thead>
|
|
183
388
|
<tbody>
|
|
184
|
-
{
|
|
389
|
+
{mode.worlds.map((w) => (
|
|
185
390
|
<tr key={w.name}>
|
|
186
|
-
|
|
187
|
-
<td>{MANAGED
|
|
188
|
-
? <a href={`${w.base.replace(/\/[^/]+\/[^/]+$/, '')}/-/console/${w.name}#token=${encodeURIComponent(w.token ?? '')}&portal=${encodeURIComponent(ORIGIN)}`}>{w.name}</a>
|
|
189
|
-
: <a href={`${BASE}/${w.name}`} onClick={(e) => { e.preventDefault(); go(w.name.split('/')); }}>{w.name}</a>}</td>
|
|
391
|
+
<td><a href={`${BASE}/${w.name}`} onClick={(e) => { e.preventDefault(); go(w.name.split('/')); }}>{w.name}</a></td>
|
|
190
392
|
<td><code>{w.base}</code></td>
|
|
191
|
-
<td>{w.twins.map((t) => t.vendor).join(', ') || '—'}</td>
|
|
192
|
-
{
|
|
393
|
+
<td>{w.twins.map((t) => vendorName(t.vendor)).join(', ') || '—'}</td>
|
|
394
|
+
{/* a host's admin manages a World here, on the host's origin, where the admin token lives: a World's own
|
|
395
|
+
pages (its origin) never hold it */}
|
|
396
|
+
{admin ? <td>
|
|
397
|
+
<button type="button" aria-label={`Rotate the tokens of ${w.name}`} onClick={() => void rotateWorld(w.name)}>Rotate tokens</button>{' '}
|
|
398
|
+
<button type="button" aria-label={`Remove ${w.name}`} onClick={() => 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>
|
|
399
|
+
</td> : null}
|
|
193
400
|
</tr>
|
|
194
401
|
))}
|
|
195
402
|
</tbody>
|
|
196
403
|
</table>
|
|
197
|
-
{admin && MANAGED ? <Orgs token={admin} version={worldsVersion} only={myOrgs.length > 1 ? current : ''} onChanged={async () => { await loadOrgs(); await reload(); }} /> : null}
|
|
198
|
-
{admin && MANAGED ? <PersonalTokens token={admin} onChanged={() => setWorldsVersion((v) => v + 1)} /> : null}
|
|
199
|
-
{admin && MANAGED ? <Help token={admin} orgs={myOrgs} onChanged={() => setWorldsVersion((v) => v + 1)} /> : null}
|
|
200
404
|
{admin ? (
|
|
201
405
|
<form onSubmit={provision} aria-labelledby="provision">
|
|
202
406
|
<h3 id="provision">Provision a world</h3>
|
|
203
|
-
{
|
|
204
|
-
? <label>Org <select name="org" aria-label="Org" value={org} onChange={(e) => setOrg(e.target.value)} required>{myOrgs.map((o) => <option key={o} value={o}>{o}</option>)}</select></label>
|
|
205
|
-
: <label>Org <input name="org" value={org} onChange={(e) => setOrg(e.target.value)} required /></label>}
|
|
407
|
+
<label>Org <input name="org" value={org} onChange={(e) => setOrg(e.target.value)} required /></label>
|
|
206
408
|
<label>World <input name="world" value={world} onChange={(e) => setWorld(e.target.value)} required /></label>
|
|
207
409
|
<label>Twins <input name="vendors" value={vendors} onChange={(e) => setVendors(e.target.value)} placeholder="github, slack" required /></label>
|
|
208
410
|
<button type="submit" disabled={provisioning}>{provisioning ? 'Provisioning… (a host boots the world; ten seconds or so)' : 'Provision'}</button>
|
|
209
411
|
{note ? <p role="status">{note}</p> : null}
|
|
210
412
|
</form>
|
|
211
|
-
) : null}
|
|
413
|
+
) : note ? <p role="status">{note}</p> : null}
|
|
212
414
|
</section>
|
|
213
415
|
);
|
|
214
416
|
}
|
|
215
417
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
)
|
|
418
|
+
type Clock = { at: string; frozen: boolean };
|
|
419
|
+
type WorldMap = { world: string; twins: Array<{ twin: string; position: number; mirror: boolean; root: { url: string } | null; resources: Array<{ type: string; count: number }> }> };
|
|
420
|
+
type Placed = { twin: string; position: number; entry: Entry };
|
|
421
|
+
type Timeline = { entries: Placed[]; next: string | null };
|
|
422
|
+
type Diff = { base: { id: string; at: string | null }; changes: Array<{ twin: string; entry: Entry }> };
|
|
423
|
+
type Branch = { name: string; at?: { instant?: string }; createdAt: string; expiresAt: string | null };
|
|
424
|
+
/** What the World's session answered: its scope; `unknown` when the host has no session door (a write then says). */
|
|
425
|
+
type Scope = 'pending' | 'read' | 'write' | 'unknown';
|
|
426
|
+
|
|
427
|
+
// 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
|
+
// World's own pages, the branch picker, then the branch's pages and its services, which here are its vendors, each a
|
|
429
|
+
// 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;
|
|
431
|
+
type Tab = (typeof TABS)[number][0];
|
|
432
|
+
const TAB_ALIASES: Record<string, Tab> = { dashboard: 'overview', mirrors: 'vendors', screens: 'canvas', timeline: 'activity', monitoring: 'activity', trace: 'activity', operations: 'settings' };
|
|
433
|
+
/** 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). */
|
|
434
|
+
const mirrorUrl = (name: string, vendor: string): string => `${WORLDS}/${name}/${vendor}/`;
|
|
435
|
+
/** What an entry did, in words: the thing's kind and the vendor's verb, the thing's own label where it has one. */
|
|
436
|
+
const DONE: Record<string, string> = { verify: 'verified', use: 'used', issue: 'issued', revoke: 'revoked', rotate: 'rotated', seed: 'seeded', upsert: 'saved', set: 'set', start: 'started', finish: 'finished', fail: 'failed', schedule: 'scheduled', deliver: 'delivered', like: 'liked', follow: 'followed', reply: 'replied', create: 'created', update: 'updated', delete: 'deleted', record: 'recorded', remove: 'removed', add: 'added', send: 'sent', post: 'posted', postmessage: 'posted', cancel: 'canceled', close: 'closed', open: 'opened', publish: 'published', upload: 'uploaded', invite: 'invited', archive: 'archived', merge: 'merged', refund: 'refunded', capture: 'captured', confirm: 'confirmed', pay: 'paid' };
|
|
437
|
+
function said(e: Entry): { what: string; label: string | null } {
|
|
438
|
+
const op = e.operation ?? e.op;
|
|
439
|
+
const verb = (op.includes('.') ? op.slice(op.lastIndexOf('.') + 1) : op).toLowerCase();
|
|
440
|
+
const kind = e.subject.type.replace(/[_-]+/g, ' ').trim();
|
|
441
|
+
const Kind = `${kind.charAt(0).toUpperCase()}${kind.slice(1)}`;
|
|
442
|
+
// `<vendor>.twin.<thing>` and `seed_<thing>`: the World itself set it up (a seed), not the app
|
|
443
|
+
const seeded = /(^|\.)twin\./.test(op) || /^seed_/.test(verb);
|
|
444
|
+
// an operation with no past tense here reads as words (`channel.mark` → Channel mark), never as its identifier
|
|
445
|
+
const words = op.replace(/[._]+/g, ' ').trim();
|
|
446
|
+
const what = seeded ? `${Kind} seeded` : DONE[verb] ? `${Kind} ${DONE[verb]}` : `${words.charAt(0).toUpperCase()}${words.slice(1)}`;
|
|
447
|
+
const f = e.fields ?? {};
|
|
448
|
+
const label = ['title', 'name', 'email', 'text', 'subject', 'summary', 'description'].map((k) => f[k]).find((v): v is string => typeof v === 'string' && v.trim() !== '') ?? null;
|
|
449
|
+
// a vendor's own markup (Slack's <@U1|ada>, <#C1|general>, <https://…|text>) reads as it does on the vendor's screen
|
|
450
|
+
const plain = label === null ? null : label.replace(/<([@#!])[^|>]*\|([^>]+)>/g, (_m, sigil: string, name: string) => `${sigil === '!' ? '@' : sigil}${name}`).replace(/<(?:https?:[^|>]+)\|([^>]+)>/g, '$1').replace(/<(https?:[^>]+)>/g, '$1');
|
|
451
|
+
return { what, label: plain === null ? null : plain.length > 80 ? `${plain.slice(0, 79)}…` : plain };
|
|
245
452
|
}
|
|
453
|
+
/** A secret's id is never shown whole (a token, a password, a key): its last four characters. */
|
|
454
|
+
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);
|
|
455
|
+
/** Who did it: an app or agent by its id; the vendor itself for what it does on its own (an event it records). */
|
|
456
|
+
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}`);
|
|
457
|
+
/** An entry's standing worth saying: where it came from, or what its push did. Not yet pushed is the ordinary case. */
|
|
458
|
+
const notable = (e: Entry): string | null => (e.operation === 'observed' || e.receipt || e.provenance === 'placeholder' ? standing(e) : null);
|
|
246
459
|
|
|
247
|
-
|
|
248
|
-
|
|
460
|
+
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 {
|
|
461
|
+
// the session: its scope says which controls this token may use, and its cookie is what a mirror reads with
|
|
462
|
+
const [scope, setScope] = useState<Scope>('pending');
|
|
463
|
+
// why the session was not opened (the wall says it, with a way to try again), and that try
|
|
464
|
+
const [sessionError, setSessionError] = useState<string | null>(null); const [attempt, setAttempt] = useState(0);
|
|
465
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: `attempt` is the wall's "Try again"
|
|
466
|
+
useEffect(() => {
|
|
467
|
+
let live = true; setSessionError(null);
|
|
468
|
+
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 };
|
|
470
|
+
// the person a platform's pass opened this session for: the page says who is signed in, and the way back to it
|
|
471
|
+
keepPortal(b.issuer);
|
|
472
|
+
if (b.who) onWho?.(b.who);
|
|
473
|
+
// the World's session lives at its own origin: its pages go there
|
|
474
|
+
// 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
|
+
const own = httpOrigin(b.origin)?.origin;
|
|
476
|
+
if (r.status === 409 && own && own !== ORIGIN) { moveTo(own, token, name); return; }
|
|
477
|
+
if (!live) return;
|
|
478
|
+
setScope(!r.ok ? 'unknown' : b.scope === 'read' ? 'read' : b.scope === 'write' ? 'write' : 'unknown');
|
|
479
|
+
if (!r.ok) setSessionError(b.error ?? `answered ${r.status}`);
|
|
480
|
+
}).catch((e: unknown) => { if (live) { setScope('unknown'); setSessionError(`the World did not answer: ${e instanceof Error ? e.message : String(e)}`); } });
|
|
481
|
+
return () => { live = false; };
|
|
482
|
+
}, [name, token, attempt]);
|
|
483
|
+
/** The session as the wall shows it: opening, opened (with its scope), or refused. */
|
|
484
|
+
const session: MirrorSession = sessionError ? { state: 'error', error: sessionError } : scope === 'pending' ? { state: 'pending' } : { state: 'ok', scope };
|
|
485
|
+
// fail closed: a token whose scope is not known (the session request failed) is shown no control it might not
|
|
486
|
+
// hold; the server refuses such writes anyway, and a read-only viewer should not be offered them
|
|
487
|
+
const canWrite = scope === 'write';
|
|
488
|
+
/** A write the door refused as out of scope: this token reads only, whatever the session said. */
|
|
489
|
+
const refused = (r: Response): void => { if (r.status === 403) setScope('read'); };
|
|
249
490
|
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
const [
|
|
253
|
-
const
|
|
254
|
-
const
|
|
255
|
-
const
|
|
256
|
-
const
|
|
257
|
-
|
|
258
|
-
const
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
const
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
const
|
|
272
|
-
const
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
const body = (await r.json()) as { url?: string; error?: string };
|
|
276
|
-
if (!r.ok || !body.url) { setNote(body.error ?? `answered ${r.status}`); return; }
|
|
277
|
-
setPortal(body.url);
|
|
491
|
+
const [tick, setTick] = useState(0);
|
|
492
|
+
useEffect(() => { const id = setInterval(() => { if (!document.hidden) setTick((t) => t + 1); }, 5000); return () => clearInterval(id); }, []);
|
|
493
|
+
const [clockVersion, setClockVersion] = useState(0);
|
|
494
|
+
const clock = useDoor<Clock>(token, `/-/${name}/clock`, tick + clockVersion);
|
|
495
|
+
const twins = useDoor<{ twins: Twin[] }>(token, `/-/${name}/twins`);
|
|
496
|
+
const branches = useDoor<{ branches: Branch[] }>(token, `/-/${name}/branches`, clockVersion);
|
|
497
|
+
const map = useDoor<WorldMap>(token, `/-/${name}/map`, Math.floor(tick / 2));
|
|
498
|
+
// branches are the host's: a host that does not answer the door has no "as of", and the console shows none
|
|
499
|
+
const hasBranches = !(branches.state === 'error' && (branches.status === 404 || branches.status === 405));
|
|
500
|
+
|
|
501
|
+
if (twin) return <TwinView name={name} token={token} twin={twin} />;
|
|
502
|
+
const current: Tab = TABS.some(([k]) => k === tab) ? (tab as Tab) : TAB_ALIASES[tab] ?? 'overview';
|
|
503
|
+
const vendors = twins.state === 'ok' ? twins.data.twins.map((t) => t.twin) : [];
|
|
504
|
+
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);
|
|
505
|
+
const chosen = current === 'vendors' && arg ? arg : undefined;
|
|
506
|
+
const retry = (): void => { setScope('pending'); setAttempt((a) => a + 1); };
|
|
507
|
+
// ALL SCREENS is a canvas app's own page: the whole window, the World's pages a click away (Figma's, tldraw's)
|
|
508
|
+
if (current === 'canvas' || (current === 'vendors' && !chosen)) {
|
|
509
|
+
return <Canvas name={name} twins={twins} session={session} retry={retry} snapshot={snapshot} back={() => go(name.split('/'))} />;
|
|
510
|
+
}
|
|
511
|
+
const title = chosen ? vendorName(chosen) : TABS.find(([k]) => k === current)?.[1] ?? '';
|
|
512
|
+
const open = (parts: string[]) => (e: React.MouseEvent): void => { e.preventDefault(); go([...name.split('/'), ...parts]); };
|
|
513
|
+
const item = (k: Tab, label: string = TABS.find(([t]) => t === k)?.[1] ?? k): React.JSX.Element => {
|
|
514
|
+
const on = current === k && !chosen;
|
|
515
|
+
return <a href={`${BASE}/${name}/~/${k}`} aria-current={on ? 'page' : undefined} className={on ? 'on' : undefined} onClick={open(['~', k])}>{label}</a>;
|
|
278
516
|
};
|
|
279
|
-
if (!info) return <p className="mode" role={failed ? 'alert' : undefined}>{failed ? `Billing: ${failed}` : 'Billing: loading…'}</p>;
|
|
280
|
-
const lim = (n: number | null): string => (n === null ? 'unlimited' : String(n));
|
|
281
|
-
const when = (iso: string | null): string => (iso ? day(iso) : '');
|
|
282
517
|
return (
|
|
283
|
-
<div className="
|
|
284
|
-
<
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
518
|
+
<div className="shell">
|
|
519
|
+
<aside className="side">
|
|
520
|
+
<nav aria-label={`${name} pages`} className="side-nav">
|
|
521
|
+
<Connect name={name} />
|
|
522
|
+
<p className="side-group">World</p>
|
|
523
|
+
{item('overview')}
|
|
524
|
+
{hasBranches ? item('branches') : null}
|
|
525
|
+
{item('settings')}
|
|
526
|
+
<BranchPicker name={name} snapshot={snapshot} branches={hasBranches ? branches : null} opens={opens} />
|
|
527
|
+
<p className="side-group">Branch</p>
|
|
528
|
+
{item('branch')}
|
|
529
|
+
{item('changes')}
|
|
530
|
+
{item('activity')}
|
|
531
|
+
<p className="side-group">Vendors</p>
|
|
532
|
+
<a href={`${BASE}/${name}/~/canvas`} className="all-screens" onClick={open(['~', 'canvas'])}><span className="mark" aria-hidden="true">▦</span><span>All screens</span></a>
|
|
533
|
+
<ul className="side-vendors">
|
|
534
|
+
{vendors.map((v) => (
|
|
535
|
+
<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>
|
|
536
|
+
))}
|
|
537
|
+
</ul>
|
|
538
|
+
</nav>
|
|
539
|
+
<div className="side-foot"><ThemeChoice /></div>
|
|
540
|
+
</aside>
|
|
541
|
+
<div className="content">
|
|
542
|
+
{snapshot ? (
|
|
543
|
+
<div className="snapshot" role="region" aria-label="Snapshot">
|
|
544
|
+
<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>
|
|
545
|
+
<button type="button" onClick={() => void onReturn(snapshot)}>Return to live</button>
|
|
546
|
+
</div>
|
|
547
|
+
) : null}
|
|
548
|
+
<div className="content-head">
|
|
549
|
+
<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>
|
|
550
|
+
<div className="head-actions">
|
|
551
|
+
<ClockBar name={name} token={token} clock={clock} canWrite={canWrite && !snapshot} onChanged={() => setClockVersion((v) => v + 1)} refused={refused} />
|
|
552
|
+
{!snapshot && canWrite && hasBranches && branches.state !== 'loading' ? <AsOf name={name} token={token} clock={clock} refused={refused} /> : null}
|
|
553
|
+
</div>
|
|
554
|
+
</div>
|
|
555
|
+
<div role="region" aria-labelledby="world" className="panel">
|
|
556
|
+
{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}
|
|
557
|
+
{current === 'branch' ? <BranchOverview name={name} token={token} map={map} vendors={vendors} tick={tick} /> : null}
|
|
558
|
+
{current === 'vendors' ? <Vendors name={name} token={token} twins={twins} session={session} retry={retry} selected={chosen} /> : null}
|
|
559
|
+
{current === 'activity' && tab === 'trace' ? <Trace name={name} token={token} id={arg ?? ''} /> : null}
|
|
560
|
+
{current === 'activity' && tab !== 'trace' ? <TimelineTab name={name} token={token} vendors={vendors} /> : null}
|
|
561
|
+
{current === 'changes' ? <Changes name={name} token={token} /> : null}
|
|
562
|
+
{current === 'branches' ? <Overview name={name} token={token} canWrite={canWrite} branches={hasBranches ? branches : null} opens={opens} onBranchesChanged={() => setClockVersion((v) => v + 1)} refused={refused} /> : null}
|
|
563
|
+
{current === 'settings' ? <Operations name={name} token={token} admin={admin} canWrite={canWrite} refused={refused} onRemoved={onRemoved} /> : null}
|
|
564
|
+
</div>
|
|
565
|
+
</div>
|
|
303
566
|
</div>
|
|
304
567
|
);
|
|
305
568
|
}
|
|
306
569
|
|
|
307
|
-
/**
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
/** What the restriction machine's position means for this org, in the person's words — the reason named (layer 5: Twenty's NO_SUBSCRIPTION, Dub's downgrade copy). */
|
|
314
|
-
function restrictionCopy(info: BillingInfo): string {
|
|
315
|
-
const r = info.restriction!; const until = when(r.graceEnds); const allows = info.plan.limits.worlds ?? 0;
|
|
316
|
-
if (r.meter === 'worlds') {
|
|
317
|
-
const lead = r.reason === 'no_subscription' ? `Your Team subscription ended, so this org is on the Free plan, which allows ${allows} world${allows === 1 ? '' : 's'}; it holds ${info.worlds}.` : `The ${info.plan.name} plan allows ${allows} world${allows === 1 ? '' : 's'}; this org holds ${info.worlds}.`;
|
|
318
|
-
return r.status === 'restricted' ? `${lead} Nothing was deleted, but the grace period ended ${until}: the worlds you hold no longer refresh and new ones are refused, until you upgrade or delete worlds down to ${allows}.` : `${lead} Nothing is deleted, and the worlds you hold keep working — refreshing and taking pushes — until ${until}. New worlds beyond ${allows} are refused from now on. After ${until} the ones you hold stop refreshing too, until you upgrade or delete worlds down to ${allows}.`;
|
|
319
|
-
}
|
|
320
|
-
return r.status === 'restricted' ? `Restricted: the ${info.plan.name} plan's world-hours were exceeded and the grace period ended ${until} — upgrade to provision and refresh again.` : `Over the plan's world-hours: you can keep working until ${until}; after that, provisioning and refresh are restricted until you upgrade or the next period.`;
|
|
570
|
+
/** Connect's last line: the setup as a prompt for the person's coding agent (packages/cli/src/agent-prompt.ts, the one
|
|
571
|
+
* text), copied whole. */
|
|
572
|
+
function CopyAgentPrompt({ text }: { text: string }): React.JSX.Element {
|
|
573
|
+
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')); };
|
|
574
|
+
return <button type="button" className="agent-prompt-button" onClick={copy}>Copy a prompt for your coding agent</button>;
|
|
321
575
|
}
|
|
322
576
|
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
/** The org's members — the Volter identity service's memberships — with the admin's invite, remove and role. */
|
|
326
|
-
function Members({ token, org, admin, onChanged }: { token: string; org: string; admin: boolean; onChanged?: () => void }): React.JSX.Element {
|
|
327
|
-
const [members, setMembers] = useState<MemberRow[] | null>(null); const [pending, setPending] = useState<PendingRow[]>([]);
|
|
328
|
-
const [email, setEmail] = useState(''); const [note, setNote] = useState<string | null>(null);
|
|
329
|
-
const load = useCallback(async () => { const r = await door(token, `/-/orgs/${org}/members`); const body = r.ok ? ((await r.json()) as { members: MemberRow[]; pending?: PendingRow[] }) : { members: [] as MemberRow[], pending: [] as PendingRow[] }; setMembers(body.members); setPending(body.pending ?? []); }, [token, org]);
|
|
330
|
-
useEffect(() => { void load(); }, [load]);
|
|
331
|
-
const act = async (label: string, path: string, init: RequestInit): Promise<void> => {
|
|
332
|
-
setNote(null); const r = await door(token, path, init); const body = (await r.json().catch(() => ({}))) as { error?: string };
|
|
333
|
-
setNote(r.ok ? label : body.error ?? `answered ${r.status}`); await load(); if (r.ok) onChanged?.(); // the Activity list re-reads (walk 4: no row for the invite until a reload)
|
|
334
|
-
};
|
|
577
|
+
/** "Connect", heading the sidebar as Neon's does: how an app's calls to these vendors land in this World. */
|
|
578
|
+
function Connect({ name }: { name: string }): React.JSX.Element {
|
|
335
579
|
return (
|
|
336
|
-
<
|
|
337
|
-
<
|
|
338
|
-
{
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
580
|
+
<details className="menu connect-side">
|
|
581
|
+
<summary className="button connect-button">Connect</summary>
|
|
582
|
+
{portal() ? (
|
|
583
|
+
// a World a platform opened lives on a host: an app reaches it with a key, a local World pushes to it
|
|
584
|
+
<div className="popover">
|
|
585
|
+
<p>Point your app's SDKs here, with a key made on the platform; its calls to these vendors land in this World:</p>
|
|
586
|
+
<pre>{WORLDS}/{name}/<vendor></pre>
|
|
587
|
+
<a className="button" href={`${portal()}/${encodeURIComponent(name.split('/')[0] ?? '')}`}>Make a key on the platform</a>
|
|
588
|
+
<p className="mode">Or push a World from your machine: <code>volter remote add origin {name}</code>, then <code>volter world push</code>.</p>
|
|
589
|
+
<CopyAgentPrompt text={agentPrompt({ platform: portal(), world: name })} />
|
|
590
|
+
</div>
|
|
591
|
+
) : (
|
|
592
|
+
<div className="popover">
|
|
593
|
+
<p>Run your app in this World; its calls to these vendors land here:</p>
|
|
594
|
+
<pre>volter world run -- npm start</pre>
|
|
595
|
+
<p className="mode">Or point an SDK at <code>{WORLDS}/{name}/<vendor></code> with the token in <code>.volter/token</code>.</p>
|
|
596
|
+
<CopyAgentPrompt text={agentPrompt()} />
|
|
597
|
+
</div>
|
|
342
598
|
)}
|
|
343
|
-
|
|
344
|
-
{admin ? <form onSubmit={(e) => { e.preventDefault(); const to = email; void door(token, `/-/orgs/${org}/members`, { method: 'POST', body: JSON.stringify({ email: to }) }).then(async (r) => { const body = (await r.json().catch(() => ({}))) as { error?: string; pending?: boolean }; setNote(r.ok ? (body.pending ? `invited ${to} — they get an email and join when they accept` : `added ${to} — we emailed them`) : body.error ?? `answered ${r.status}`); await load(); if (r.ok) { setEmail(''); onChanged?.(); } }); }}>
|
|
345
|
-
<label>Add a member by email <input name="inviteEmail" type="email" value={email} onChange={(e) => setEmail(e.target.value)} placeholder="teammate@example.com" required /></label>
|
|
346
|
-
<button type="submit">Add member</button>
|
|
347
|
-
<p className="mode">Someone with an account here is added at once; anyone else is invited by email and joins when they accept.</p>
|
|
348
|
-
</form> : null}
|
|
349
|
-
{note ? <p role="status">{note}</p> : null}
|
|
350
|
-
</div>
|
|
599
|
+
</details>
|
|
351
600
|
);
|
|
352
601
|
}
|
|
353
602
|
|
|
354
|
-
/** The
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
const
|
|
359
|
-
|
|
360
|
-
const r = await door(token, `/-/orgs/${org}`, { method: 'PATCH', body: JSON.stringify({ name: newName }) });
|
|
361
|
-
const body = (await r.json().catch(() => ({}))) as { error?: string };
|
|
362
|
-
setNote(r.ok ? `renamed to ${newName}` : body.error ?? `answered ${r.status}`); if (r.ok) await onChanged();
|
|
363
|
-
};
|
|
364
|
-
const removeEverything = async (): Promise<void> => {
|
|
365
|
-
setNote(null);
|
|
366
|
-
const r = await door(token, `/-/orgs/${org}?everything=1`, { method: 'DELETE' });
|
|
367
|
-
const body = (await r.json().catch(() => ({}))) as { error?: string };
|
|
368
|
-
if (r.ok) { await onChanged(`deleted ${org} and its worlds`); return; } // said above the list: this org's own card is about to go
|
|
369
|
-
setNote(body.error ?? `answered ${r.status}`);
|
|
370
|
-
};
|
|
371
|
-
const remove = async (): Promise<void> => {
|
|
372
|
-
setNote(null);
|
|
373
|
-
const r = await door(token, `/-/orgs/${org}`, { method: 'DELETE' });
|
|
374
|
-
const body = (await r.json().catch(() => ({}))) as { error?: string };
|
|
375
|
-
setNote(r.ok ? `deleted ${org}` : body.error ?? `answered ${r.status}`); if (r.ok) await onChanged();
|
|
376
|
-
};
|
|
603
|
+
/** The branch picker, between the World's pages and the branch's (where Neon's sits): this World live, or one of the
|
|
604
|
+
* 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<{ branches: Branch[] }> | null; opens: (name: string) => boolean }): React.JSX.Element {
|
|
606
|
+
const short = (n: string): string => n.split('/').pop() ?? n;
|
|
607
|
+
const live = snapshot?.parent ?? name;
|
|
608
|
+
const others = branches?.state === 'ok' ? branches.data.branches.filter((b) => b.name !== name) : [];
|
|
377
609
|
return (
|
|
378
|
-
<details className="
|
|
379
|
-
<
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
610
|
+
<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>
|
|
612
|
+
<div className="popover">
|
|
613
|
+
<p className="mode">Branch</p>
|
|
614
|
+
<ul className="branch-list">
|
|
615
|
+
<li><a href={`${BASE}/${live}`} aria-current={!snapshot ? 'page' : undefined} onClick={(e) => { e.preventDefault(); go(live.split('/')); }}>{short(live)} <span className="mode">live</span></a></li>
|
|
616
|
+
{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
|
+
{others.map((b) => (
|
|
618
|
+
<li key={b.name}>{opens(b.name)
|
|
619
|
+
? <a href={`${BASE}/${b.name}`} onClick={(e) => { e.preventDefault(); go(b.name.split('/')); }}>{short(b.name)} <span className="mode">{b.at?.instant ? <>as of <At iso={b.at.instant} /></> : 'copy'}</span></a>
|
|
620
|
+
: <span className="mode" title="Its token was handed to whoever made it">{short(b.name)}</span>}</li>
|
|
621
|
+
))}
|
|
622
|
+
</ul>
|
|
623
|
+
{branches?.state === 'ok' && others.length === 0 && !snapshot ? <p className="mode">No branches yet. “View an earlier moment…” opens one.</p> : null}
|
|
391
624
|
</div>
|
|
392
|
-
{note ? <p role="status">{note}</p> : null}
|
|
393
625
|
</details>
|
|
394
626
|
);
|
|
395
627
|
}
|
|
396
628
|
|
|
397
|
-
/**
|
|
398
|
-
|
|
399
|
-
function
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
const
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
}
|
|
410
|
-
|
|
629
|
+
/** THE BRANCH'S OVERVIEW, as Neon's branch Overview is: its services in a table (each vendor with its state and what it
|
|
630
|
+
* holds), then its monitoring: here the writes the World recorded, hour by hour over the last day, by vendor. */
|
|
631
|
+
function BranchOverview({ name, token, map, vendors, tick }: { name: string; token: string; map: Load<WorldMap>; vendors: string[]; tick: number }): React.JSX.Element {
|
|
632
|
+
const recent = useDoor<Timeline>(token, `/-/${name}/timeline?limit=500`, Math.floor(tick / 3));
|
|
633
|
+
const rows = map.state === 'ok' ? map.data.twins : [];
|
|
634
|
+
const open = (parts: string[]) => (e: React.MouseEvent): void => { e.preventDefault(); go([...name.split('/'), ...parts]); };
|
|
635
|
+
// 24 hourly buckets ending now, each counting the entries that happened in it, per vendor
|
|
636
|
+
const now = Date.now(); const hours = 24; const bucket = 3_600_000;
|
|
637
|
+
const entries = recent.state === 'ok' ? recent.data.entries : [];
|
|
638
|
+
const series = vendors.map((v) => {
|
|
639
|
+
const counts = new Array<number>(hours).fill(0);
|
|
640
|
+
for (const p of entries) { if (p.twin !== v) continue; const t = Date.parse(p.entry.occurredAt); const i = hours - 1 - Math.floor((now - t) / bucket); if (i >= 0 && i < hours) counts[i]! += 1; }
|
|
641
|
+
return { vendor: v, counts };
|
|
642
|
+
});
|
|
643
|
+
const totals = new Array<number>(hours).fill(0).map((_, i) => series.reduce((n, s) => n + s.counts[i]!, 0));
|
|
644
|
+
const peak = Math.max(1, ...totals);
|
|
645
|
+
const W = 720; const H = 160; const bw = W / hours;
|
|
646
|
+
const hues = ['var(--volter-data-base)', '#7c5cff', '#e3572b', '#1f9d6b', '#d9a400', '#2b7de3', '#c2417e', '#6b7280', '#0ea5a4'];
|
|
411
647
|
return (
|
|
412
648
|
<>
|
|
413
|
-
<
|
|
414
|
-
|
|
415
|
-
|
|
649
|
+
<div className="block">
|
|
650
|
+
<table className="dense services" aria-label="Vendors on this branch">
|
|
651
|
+
<thead><tr><th>Vendor</th><th>Details</th><th /></tr></thead>
|
|
652
|
+
<tbody>{vendors.map((v) => {
|
|
653
|
+
const t = rows.find((x) => x.twin === v);
|
|
654
|
+
const top = [...(t?.resources ?? [])].filter((r) => r.count > 0).sort((a, b) => b.count - a.count);
|
|
655
|
+
return (
|
|
656
|
+
<tr key={v}>
|
|
657
|
+
<td><span className="vendor-cell"><Mark vendor={v} /><a href={`${BASE}/${name}/~/vendors/${v}`} onClick={open(['~', 'vendors', v])}>{vendorName(v)}</a> <span className={t?.root ? 'badge rooted' : 'badge'}>{t?.root ? 'connected' : 'stand-in'}</span></span></td>
|
|
658
|
+
<td className="mode">{top.length ? top.slice(0, 4).map((r) => counted(r.count, r.type)).join(' · ') : 'nothing yet'}</td>
|
|
659
|
+
<td className="num">{t?.mirror ? <a href={`${BASE}/${name}/~/vendors/${v}`} onClick={open(['~', 'vendors', v])}>Screens</a> : null}</td>
|
|
660
|
+
</tr>
|
|
661
|
+
);
|
|
662
|
+
})}</tbody>
|
|
663
|
+
</table>
|
|
664
|
+
</div>
|
|
665
|
+
<div className="block chart-block">
|
|
666
|
+
<div className="block-head"><h3>Activity, last 24 hours</h3><a href={`${BASE}/${name}/~/activity`} onClick={open(['~', 'activity'])}>All activity</a></div>
|
|
667
|
+
<Pending load={recent} what="the activity" />
|
|
668
|
+
{recent.state === 'ok' ? (
|
|
669
|
+
<div className="pad">
|
|
670
|
+
<svg viewBox={`0 0 ${W} ${H + 22}`} className="chart" role="img" aria-label={`Writes per hour over the last day: ${totals.reduce((a, b) => a + b, 0)} in all`}>
|
|
671
|
+
{[0.5, 1].map((f) => <line key={f} x1={0} x2={W} y1={H - f * H} y2={H - f * H} className="grid" />)}
|
|
672
|
+
<line x1={0} x2={W} y1={H} y2={H} className="axis" />
|
|
673
|
+
{totals.map((_, i) => {
|
|
674
|
+
let y = H;
|
|
675
|
+
return <g key={i}>{series.map((s, si) => { const h = (s.counts[i]! / peak) * (H - 8); if (h === 0) return null; y -= h; return <rect key={s.vendor} x={i * bw + 2} y={y} width={bw - 4} height={h} fill={hues[si % hues.length]} rx={2}><title>{`${vendorName(s.vendor)}: ${s.counts[i]} in the hour ${hours - 1 - i === 0 ? 'now' : `${hours - 1 - i}h ago`}`}</title></rect>; })}</g>;
|
|
676
|
+
})}
|
|
677
|
+
{[0, 6, 12, 18, 23].map((i) => <text key={i} x={i * bw + bw / 2} y={H + 16} className="tick">{hours - 1 - i === 0 ? 'now' : `-${hours - 1 - i}h`}</text>)}
|
|
678
|
+
</svg>
|
|
679
|
+
<ul className="legend">{series.filter((s) => s.counts.some((c) => c > 0)).map((s) => <li key={s.vendor}><span className="swatch" style={{ background: hues[vendors.indexOf(s.vendor) % hues.length] }} />{vendorName(s.vendor)} <span className="mode">{s.counts.reduce((a, b) => a + b, 0)}</span></li>)}</ul>
|
|
680
|
+
{totals.every((n) => n === 0) ? <p className="mode">Nothing was written in the last day. Run your app in this World and its calls show up here.</p> : null}
|
|
681
|
+
</div>
|
|
682
|
+
) : null}
|
|
683
|
+
</div>
|
|
416
684
|
</>
|
|
417
685
|
);
|
|
418
686
|
}
|
|
419
687
|
|
|
420
|
-
/**
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
const
|
|
688
|
+
/** THE DASHBOARD, as the references open a project: Vercel leads with the live deployment (its preview, its address),
|
|
689
|
+
* Neon with how to connect and the branches. A World's live preview is its vendors' own screens; beside them, how an
|
|
690
|
+
* app connects and the branches; under them, what just happened. Counts sit with what they count, not in a row. */
|
|
691
|
+
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<{ branches: Branch[] }> | null; canWrite: boolean; tick: number; /** the World this one is a snapshot of, when it is one */ snapshotOf?: string }): React.JSX.Element {
|
|
693
|
+
const diff = useDoor<Diff>(token, `/-/${name}/diff`, Math.floor(tick / 2));
|
|
694
|
+
const recent = useDoor<Timeline>(token, `/-/${name}/timeline?limit=8`, tick);
|
|
695
|
+
const rows = map.state === 'ok' ? map.data.twins : [];
|
|
696
|
+
const latest = recent.state === 'ok' ? recent.data.entries : [];
|
|
697
|
+
const screens = twins.state === 'ok' ? twins.data.twins.filter((t) => t.mirror).map((t) => t.twin) : [];
|
|
698
|
+
const open = (parts: string[]) => (e: React.MouseEvent): void => { e.preventDefault(); go([...name.split('/'), ...parts]); };
|
|
699
|
+
const heldOf = (v: string): string => { const top = [...(rows.find((x) => x.twin === v)?.resources ?? [])].filter((r) => r.count > 0).sort((a, b) => b.count - a.count); return top.length ? top.slice(0, 2).map((r) => counted(r.count, r.type)).join(' · ') : 'empty'; };
|
|
700
|
+
const changes = diff.state === 'ok' ? diff.data.changes.length : null;
|
|
701
|
+
const list = branches?.state === 'ok' ? branches.data.branches : [];
|
|
427
702
|
return (
|
|
428
|
-
<div
|
|
429
|
-
<
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
703
|
+
<div className="dash">
|
|
704
|
+
<section className="block dash-screens" aria-labelledby="screens-head">
|
|
705
|
+
<div className="block-head">
|
|
706
|
+
<h3 id="screens-head">Screens <span className="badge live">live</span></h3>
|
|
707
|
+
<span className="mode">{vendors.length} vendor{vendors.length === 1 ? '' : 's'}{changes !== null ? <> · <a href={`${BASE}/${name}/~/changes`} onClick={open(['~', 'changes'])}>{changes} change{changes === 1 ? '' : 's'} since start</a></> : null}</span>
|
|
708
|
+
<a className="button" href={`${BASE}/${name}/~/canvas`} onClick={open(['~', 'canvas'])}>Open all screens</a>
|
|
709
|
+
</div>
|
|
710
|
+
{session.state === 'ok' ? (
|
|
711
|
+
<ul className="thumbs">
|
|
712
|
+
{vendors.map((v) => (
|
|
713
|
+
<li key={v}>
|
|
714
|
+
<a href={`${BASE}/${name}/~/vendors/${v}`} onClick={open(['~', 'vendors', v])} aria-label={`${vendorName(v)}'s screens`}>
|
|
715
|
+
<span className="thumb">{screens.includes(v)
|
|
716
|
+
? <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})` }} />
|
|
717
|
+
: <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>
|
|
718
|
+
<span className="thumb-foot"><Mark vendor={v} /><b>{vendorName(v)}</b><span className="mode">{heldOf(v)}</span></span>
|
|
719
|
+
</a>
|
|
720
|
+
</li>
|
|
721
|
+
))}
|
|
722
|
+
</ul>
|
|
723
|
+
) : <p className="mode pad">{session.state === 'error' ? `The World did not open its screens: ${session.error}` : 'Opening…'}</p>}
|
|
724
|
+
</section>
|
|
725
|
+
<div className="dash-side">
|
|
726
|
+
{branches ? <section className="block" aria-labelledby="branches-head">
|
|
727
|
+
<div className="block-head"><h3 id="branches-head">Branches</h3><a href={`${BASE}/${name}/~/branches`} onClick={open(['~', 'branches'])}>All</a></div>
|
|
728
|
+
<ul className="mini-feed">
|
|
729
|
+
<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>
|
|
730
|
+
{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>)}
|
|
731
|
+
</ul>
|
|
732
|
+
{canWrite ? <p className="mode pad">“View an earlier moment…” above opens a branch as the World was then.</p> : null}
|
|
733
|
+
</section> : null}
|
|
734
|
+
</div>
|
|
735
|
+
<section className="block dash-activity" aria-labelledby="activity-head">
|
|
736
|
+
<div className="block-head"><h3 id="activity-head">Recent activity</h3><a href={`${BASE}/${name}/~/activity`} onClick={open(['~', 'activity'])}>View all</a></div>
|
|
737
|
+
<Pending load={recent} what="the activity" />
|
|
738
|
+
{recent.state === 'ok' && latest.length === 0 ? (
|
|
739
|
+
<div className="empty-block"><strong>No activity yet</strong><span className="mode">Run your app in this World and its calls show up here.</span></div>
|
|
740
|
+
) : (
|
|
741
|
+
<ul className="mini-feed">{latest.map((p) => {
|
|
742
|
+
const s = said(p.entry);
|
|
743
|
+
return <li key={keyOf(p)}><Mark vendor={p.twin} /><span className="mini-what"><strong>{s.what}</strong>{s.label ? <span className="mode"> {s.label}</span> : null}</span><span className="mode num" title={p.entry.occurredAt}>{ago(p.entry.occurredAt)}</span></li>;
|
|
744
|
+
})}</ul>
|
|
745
|
+
)}
|
|
746
|
+
</section>
|
|
434
747
|
</div>
|
|
435
748
|
);
|
|
436
749
|
}
|
|
437
750
|
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
const [
|
|
443
|
-
const
|
|
444
|
-
const
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
751
|
+
|
|
752
|
+
/** The World's clock, like a test clock's simulation banner: its time, and for a write token the controls that move it. */
|
|
753
|
+
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 {
|
|
754
|
+
const [setting, setSetting] = useState(false); const [value, setValue] = useState(''); const [by, setBy] = useState('');
|
|
755
|
+
const [note, setNote] = useState<{ ok: boolean; text: string } | null>(null); const [busy, setBusy] = useState(false);
|
|
756
|
+
const now = clock.state === 'ok' ? clock.data : null;
|
|
757
|
+
const send = async (label: string, path: string, body: unknown): Promise<void> => {
|
|
758
|
+
setBusy(true); setNote(null);
|
|
759
|
+
try {
|
|
760
|
+
const r = await door(token, `/-/${name}/${path}`, { method: path === 'clock' ? 'PUT' : 'POST', body: JSON.stringify(body) });
|
|
761
|
+
if (!r.ok) { refused(r); setNote({ ok: false, text: await failure(r) }); return; }
|
|
762
|
+
const c = (await r.json()) as Clock;
|
|
763
|
+
setNote({ ok: true, text: `${label} — the World is at ${new Date(c.at).toLocaleString()}` }); setSetting(false); onChanged();
|
|
764
|
+
} catch (e) { setNote({ ok: false, text: `the World did not answer: ${e instanceof Error ? e.message : String(e)}` }); } finally { setBusy(false); }
|
|
765
|
+
};
|
|
766
|
+
const setTime = (e: React.FormEvent): void => {
|
|
767
|
+
e.preventDefault();
|
|
768
|
+
const at = new Date(value);
|
|
769
|
+
if (Number.isNaN(at.getTime())) { setNote({ ok: false, text: 'Pick a date and time.' }); return; }
|
|
770
|
+
if (now && at.getTime() < Date.parse(now.at)) { setNote({ ok: false, text: `The World's clock only moves forward: pick a time after ${new Date(now.at).toLocaleString()}.` }); return; }
|
|
771
|
+
void send('Clock set', 'clock', { at: at.toISOString() });
|
|
454
772
|
};
|
|
455
|
-
const
|
|
456
|
-
const
|
|
773
|
+
const advance = (amount: string, label: string): void => { void send(`Advanced ${label}`, 'clock/advance', { by: amount }); };
|
|
774
|
+
const presets: Array<[string, string]> = [['1h', '1 hour'], ['1d', '1 day'], ['7d', '1 week'], ['30d', '30 days']];
|
|
457
775
|
return (
|
|
458
|
-
<div aria-label="
|
|
459
|
-
<
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
<
|
|
467
|
-
<
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
776
|
+
<div className={`clock${now?.frozen ? ' frozen' : ''}`} role="region" aria-label="World clock">
|
|
777
|
+
<p className="clock-now">
|
|
778
|
+
{clock.state === 'loading' ? <span className="mode">clock…</span> : clock.state === 'error' ? <span className="mode">Clock: {clock.error}</span> : now!.frozen
|
|
779
|
+
? <><span className="badge">Simulation</span> World time <strong><At iso={now!.at} /></strong></>
|
|
780
|
+
: <>World time <strong><At iso={now!.at} /></strong> <span className="mode">· real time</span></>}
|
|
781
|
+
</p>
|
|
782
|
+
{canWrite && now ? (
|
|
783
|
+
<details className="clock-more"><summary>Simulate time…</summary><div className="popover">
|
|
784
|
+
<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, use “View an earlier moment…”.</p>
|
|
785
|
+
<div className="clock-controls">
|
|
786
|
+
<button type="button" aria-expanded={setting} onClick={() => { setSetting(!setting); setValue(localInput(nextMinute(now.at))); setNote(null); }}>Set time…</button>
|
|
787
|
+
<div role="group" aria-label="Advance the clock" className="advance">
|
|
788
|
+
<span className="mode">Advance</span>
|
|
789
|
+
{presets.map(([amount, label]) => <button key={amount} type="button" aria-label={`Advance ${label}`} disabled={busy || !now.frozen} title={now.frozen ? undefined : 'Set the time first: advancing moves a set clock'} onClick={() => advance(amount, label)}>{label}</button>)}
|
|
790
|
+
<form className="inline" onSubmit={(e) => { e.preventDefault(); if (!/^\d+[smhd]$/.test(by.trim())) { setNote({ ok: false, text: 'Advance by a number and a unit: 90m, 12h, 3d.' }); return; } advance(by.trim(), `by ${by.trim()}`); }}>
|
|
791
|
+
<label className="sr">Advance by <input name="advanceBy" value={by} onChange={(e) => setBy(e.target.value)} placeholder="90m, 12h, 3d" size={9} disabled={!now.frozen} /></label>
|
|
792
|
+
<button type="submit" disabled={busy || !now.frozen}>Advance</button>
|
|
793
|
+
</form>
|
|
794
|
+
</div>
|
|
795
|
+
{!now.frozen ? <p className="mode">The World runs on real time. Set a time to start simulating; then advance it.</p> : null}
|
|
796
|
+
{setting ? (
|
|
797
|
+
<form className="inline" aria-label="Set the World time" onSubmit={setTime} noValidate>
|
|
798
|
+
<label>World time <input name="worldTime" type="datetime-local" value={value} min={localInput(nextMinute(now.at))} onChange={(e) => setValue(e.target.value)} required /></label>
|
|
799
|
+
<span className="mode">{Intl.DateTimeFormat().resolvedOptions().timeZone}</span>
|
|
800
|
+
<button type="submit" disabled={busy}>Set time</button>
|
|
801
|
+
<button type="button" onClick={() => setSetting(false)}>Cancel</button>
|
|
802
|
+
</form>
|
|
803
|
+
) : null}
|
|
804
|
+
</div>
|
|
805
|
+
</div></details>
|
|
806
|
+
) : null}
|
|
807
|
+
{note ? <p role={note.ok ? 'status' : 'alert'}>{note.text}</p> : null}
|
|
473
808
|
</div>
|
|
474
809
|
);
|
|
475
810
|
}
|
|
476
811
|
|
|
477
|
-
/**
|
|
478
|
-
function
|
|
479
|
-
const [
|
|
480
|
-
const
|
|
481
|
-
|
|
482
|
-
|
|
812
|
+
/** "View as of…": a branch of this World at a past instant, opened read only, gone within the hour. */
|
|
813
|
+
function AsOf({ name, token, clock, refused }: { name: string; token: string; clock: Load<Clock>; refused: (r: Response) => void }): React.JSX.Element {
|
|
814
|
+
const [open, setOpen] = useState(false); const [value, setValue] = useState(''); const [note, setNote] = useState<string | null>(null); const [busy, setBusy] = useState(false);
|
|
815
|
+
const now = clock.state === 'ok' ? clock.data.at : new Date().toISOString();
|
|
816
|
+
// closes as a popover does: Esc (focus back on its button) or a click outside it
|
|
817
|
+
const box = useRef<HTMLDivElement>(null);
|
|
818
|
+
useEffect(() => {
|
|
819
|
+
if (!open) return;
|
|
820
|
+
const esc = (e: KeyboardEvent): void => { if (e.key === 'Escape' && !document.querySelector('dialog[open]')) { setOpen(false); box.current?.querySelector<HTMLButtonElement>('button')?.focus(); } };
|
|
821
|
+
const outside = (e: MouseEvent): void => { if (!box.current?.contains(e.target as globalThis.Node)) setOpen(false); };
|
|
822
|
+
document.addEventListener('keydown', esc); document.addEventListener('click', outside);
|
|
823
|
+
return () => { document.removeEventListener('keydown', esc); document.removeEventListener('click', outside); };
|
|
824
|
+
}, [open]);
|
|
825
|
+
const create = async (e: React.FormEvent): Promise<void> => {
|
|
826
|
+
e.preventDefault(); setNote(null);
|
|
827
|
+
const at = new Date(value);
|
|
828
|
+
if (Number.isNaN(at.getTime())) { setNote('Pick a date and time.'); return; }
|
|
829
|
+
if (at.getTime() > Date.parse(now)) { setNote(`That is after the World's present (${new Date(now).toLocaleString()}); a snapshot looks back.`); return; }
|
|
830
|
+
setBusy(true);
|
|
831
|
+
try {
|
|
832
|
+
const r = await door(token, `/-/${name}/branches`, { method: 'POST', body: JSON.stringify({ at: { instant: at.toISOString() }, ttl: 3600 }) });
|
|
833
|
+
if (!r.ok) { refused(r); setNote(await failure(r)); return; }
|
|
834
|
+
const b = (await r.json()) as { name: string; token: string; readToken: string; expiresAt: string | null; origin?: string };
|
|
835
|
+
keepSnapshot({ name: b.name, parent: name, token: b.token, readToken: b.readToken, at: at.toISOString(), expiresAt: b.expiresAt ?? null, parentOrigin: ORIGIN, made: true });
|
|
836
|
+
// a branch with an origin of its own is viewed there (this origin serves this World only): the page moves, carrying
|
|
837
|
+
// the branch's read token and the snapshot in the fragment, and "Return to live" comes back here to remove it
|
|
838
|
+
const there = httpOrigin(b.origin)?.origin;
|
|
839
|
+
if (there && there !== ORIGIN) { moveTo(there, b.readToken, b.name, `${BASE}/${b.name}`); return; }
|
|
840
|
+
go(b.name.split('/'));
|
|
841
|
+
} catch (e) { setNote(`the World did not answer: ${e instanceof Error ? e.message : String(e)}`); } finally { setBusy(false); }
|
|
842
|
+
};
|
|
483
843
|
return (
|
|
484
|
-
<div
|
|
485
|
-
<
|
|
486
|
-
{
|
|
487
|
-
|
|
844
|
+
<div className="as-of" ref={box}>
|
|
845
|
+
<button type="button" aria-expanded={open} title="Open a read-only copy of this World as it was at a moment you pick" onClick={() => { setOpen(!open); setValue(localInput(now)); setNote(null); }}>View an earlier moment…</button>
|
|
846
|
+
{open ? (
|
|
847
|
+
<form className="inline" aria-label="View the World as of a past moment" onSubmit={create} noValidate>
|
|
848
|
+
<label>As of <input name="asOf" type="datetime-local" value={value} max={localInput(now)} onChange={(e) => setValue(e.target.value)} required /></label>
|
|
849
|
+
<button type="submit" disabled={busy}>{busy ? 'Opening…' : 'Open snapshot'}</button>
|
|
850
|
+
<span className="mode">Opens a read-only copy of this World as it was then; the copy goes away within the hour.</span>
|
|
851
|
+
</form>
|
|
852
|
+
) : null}
|
|
853
|
+
{note ? <p role="alert">{note}</p> : null}
|
|
488
854
|
</div>
|
|
489
855
|
);
|
|
490
856
|
}
|
|
491
857
|
|
|
492
|
-
/**
|
|
493
|
-
function
|
|
494
|
-
const
|
|
495
|
-
const [
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
setNote(`Sent — your ticket is ${body.id}${body.copyTo ? `; we emailed a copy to ${body.copyTo}` : ''}. A person answers, usually within a working day.${body.access ? ` Support may open ${org} until ${when(body.access.until)}; revoke it under Org settings.` : ''}`);
|
|
503
|
-
setSubject(''); setMessage(''); setWorld(''); setAllow(false); onChanged?.();
|
|
858
|
+
/** 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<{ branches: Branch[] }> | null; opens: (name: string) => boolean; onBranchesChanged: () => void; refused: (r: Response) => void }): React.JSX.Element {
|
|
860
|
+
const map = useDoor<WorldMap>(token, `/-/${name}/map`);
|
|
861
|
+
const [note, setNote] = useState<string | null>(null);
|
|
862
|
+
const remove = async (b: string): Promise<void> => {
|
|
863
|
+
if (!(await confirmAct({ title: `Delete the branch ${b}?`, body: 'Whoever holds its tokens loses it, and its history goes with it.', action: 'Delete branch', danger: true }))) return;
|
|
864
|
+
const r = await door(token, `/-/${name}/branches/${b}`, { method: 'DELETE' }).catch((e: unknown) => e as Error);
|
|
865
|
+
if (r instanceof Error) { setNote(`delete ${b}: the World did not answer (${r.message})`); return; }
|
|
866
|
+
if (!r.ok) { refused(r); setNote(`delete ${b}: ${await failure(r)}`); return; }
|
|
867
|
+
forgetSnapshot(b); setNote(`deleted ${b}`); onBranchesChanged();
|
|
504
868
|
};
|
|
869
|
+
const total = map.state === 'ok' ? map.data.twins.reduce((n, t) => n + t.resources.reduce((m, x) => m + x.count, 0), 0) : 0;
|
|
870
|
+
// the Branches tab: this World's copies; what it holds per vendor is the Vendors tab's list (kept here for a host
|
|
871
|
+
// 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} />;
|
|
873
|
+
return (
|
|
874
|
+
<>
|
|
875
|
+
<h3>Map</h3>
|
|
876
|
+
<Pending load={map} what="the World's map" />
|
|
877
|
+
{map.state === 'ok' ? (map.data.twins.length === 0 ? <p>This World holds no twins.</p> : (
|
|
878
|
+
<>
|
|
879
|
+
<p className="mode">{map.data.twins.length} twin{map.data.twins.length === 1 ? '' : 's'} · {total} resource{total === 1 ? '' : 's'}</p>
|
|
880
|
+
<ul className="map" aria-label="Twins in this World">
|
|
881
|
+
{map.data.twins.map((t) => {
|
|
882
|
+
const top = [...t.resources].sort((a, b) => b.count - a.count);
|
|
883
|
+
return (
|
|
884
|
+
<li key={t.twin} className="card" aria-labelledby={`map-${t.twin}`}>
|
|
885
|
+
<div className="card-head">
|
|
886
|
+
<h4 id={`map-${t.twin}`} className="vendor">{t.twin}</h4>
|
|
887
|
+
<span className={t.root ? 'badge rooted' : 'badge'}>{t.root ? 'rooted' : 'simulated'}</span>
|
|
888
|
+
</div>
|
|
889
|
+
<p className="mode">{t.root ? <>rooted at <code>{t.root.url}</code></> : 'simulated — no vendor behind it'} · position {t.position}</p>
|
|
890
|
+
{top.length === 0 ? <p className="mode">Holds nothing yet.</p> : (
|
|
891
|
+
<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>
|
|
892
|
+
)}
|
|
893
|
+
<p className="card-actions">
|
|
894
|
+
{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>}
|
|
895
|
+
<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>
|
|
896
|
+
</p>
|
|
897
|
+
</li>
|
|
898
|
+
);
|
|
899
|
+
})}
|
|
900
|
+
</ul>
|
|
901
|
+
</>
|
|
902
|
+
)) : null}
|
|
903
|
+
</>
|
|
904
|
+
);
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
/** A World's branches: copies of it, each as of a moment (a snapshot "as of" is one, short-lived), openable and removable. */
|
|
908
|
+
function BranchList({ name, canWrite, branches, opens, note, remove }: { name: string; token: string; canWrite: boolean; branches: Load<{ branches: Branch[] }>; opens: (name: string) => boolean; note: string | null; remove: (b: string) => Promise<void> }): React.JSX.Element {
|
|
909
|
+
// as Neon's Branches page: what a branch is for, then every branch with its parent, the moment it copies and its life
|
|
910
|
+
const short = (n: string): string => n.split('/').pop() ?? n;
|
|
911
|
+
const list = branches.state === 'ok' ? branches.data.branches : [];
|
|
505
912
|
return (
|
|
506
|
-
<
|
|
507
|
-
<
|
|
508
|
-
<p className="mode">Ask a person. Say what you saw and where; the console's address, your org and the world go along with it.</p>
|
|
509
|
-
<form onSubmit={send}>
|
|
510
|
-
<label>About <select name="helpCategory" aria-label="About" value={category} onChange={(e) => setCategory(e.target.value)}><option value="worlds">a world or a twin</option><option value="billing">billing</option><option value="account">my account or team</option><option value="security">security</option><option value="other">something else</option></select></label>
|
|
511
|
-
<label>How urgent <select name="helpSeverity" aria-label="How urgent" value={severity} onChange={(e) => setSeverity(e.target.value)}><option value="low">low — a question</option><option value="normal">normal</option><option value="high">high — something is broken for us</option><option value="urgent">urgent — we cannot work</option></select></label>
|
|
512
|
-
{orgs.length > 0 ? <label>Org <select name="helpOrg" aria-label="Help org" value={org} onChange={(e) => setOrg(e.target.value)}>{orgs.map((o) => <option key={o} value={o}>{o}</option>)}</select></label> : null}
|
|
513
|
-
<label>World (optional) <input name="helpWorld" value={world} onChange={(e) => setWorld(e.target.value)} placeholder="acme/team" /></label>
|
|
514
|
-
<label>Subject <input name="helpSubject" value={subject} onChange={(e) => setSubject(e.target.value)} required /></label>
|
|
515
|
-
<label>What happened <textarea name="helpMessage" value={message} onChange={(e) => setMessage(e.target.value)} rows={5} required /></label>
|
|
516
|
-
{org ? <label><input type="checkbox" name="helpAllow" checked={allow} onChange={(e) => setAllow(e.target.checked)} /> Allow Volter support to open this org, read only, for 7 days — so they can see what you see. Admins only; revocable under Org settings.</label> : null}
|
|
517
|
-
<button type="submit">Send to support</button>
|
|
518
|
-
</form>
|
|
913
|
+
<div aria-labelledby="branches">
|
|
914
|
+
<p className="lede">Branch this World to look at it as it was, or to change it without touching <b>{short(name)}</b>. {canWrite ? '“View an earlier moment…” above makes one as the World was then.' : ''}</p>
|
|
519
915
|
{note ? <p role="status">{note}</p> : null}
|
|
520
|
-
|
|
916
|
+
<Pending load={branches} what="the branches" />
|
|
917
|
+
{branches.state === 'ok' ? (
|
|
918
|
+
<div className="block">
|
|
919
|
+
<table className="dense" aria-label="Branches">
|
|
920
|
+
<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
|
+
<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) => (
|
|
924
|
+
<tr key={b.name}>
|
|
925
|
+
<td><b>{short(b.name)}</b></td>
|
|
926
|
+
<td>{short(name)}</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>
|
|
934
|
+
</tr>
|
|
935
|
+
))}
|
|
936
|
+
</tbody>
|
|
937
|
+
</table>
|
|
938
|
+
{list.length === 0 ? <p className="mode pad">No branches yet.</p> : null}
|
|
939
|
+
</div>
|
|
940
|
+
) : null}
|
|
941
|
+
</div>
|
|
521
942
|
);
|
|
522
943
|
}
|
|
523
944
|
|
|
524
|
-
/**
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
const
|
|
528
|
-
const
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
const
|
|
533
|
-
|
|
534
|
-
setNote(r.ok ? 'security settings saved' : body.error ?? `answered ${r.status}`); await load(); if (r.ok) await onChanged();
|
|
945
|
+
/** THE VENDORS: each vendor's own screens (its mirror), the World's live preview. A list of the vendors with what each
|
|
946
|
+
* holds, and the chosen one's screens at full size, at their own address; "Side by side" shows them all at once. */
|
|
947
|
+
function Vendors({ name, token, twins, session, retry, selected }: { name: string; token: string; twins: Load<{ twins: Twin[] }>; session: MirrorSession; retry: () => void; selected?: string }): React.JSX.Element {
|
|
948
|
+
const map = useDoor<WorldMap>(token, `/-/${name}/map`);
|
|
949
|
+
const list = twins.state === 'ok' ? twins.data.twins : [];
|
|
950
|
+
const held = (v: string): string => {
|
|
951
|
+
const t = map.state === 'ok' ? map.data.twins.find((x) => x.twin === v) : undefined;
|
|
952
|
+
if (!t) return '';
|
|
953
|
+
const top = [...t.resources].filter((r) => r.count > 0).sort((a, b) => b.count - a.count);
|
|
954
|
+
return top.length === 0 ? 'nothing yet' : top.slice(0, 3).map((r) => counted(r.count, r.type)).join(' · ') + (top.length > 3 ? ' · …' : '');
|
|
535
955
|
};
|
|
536
|
-
|
|
956
|
+
const rootOf = (v: string): string | null => (map.state === 'ok' ? map.data.twins.find((x) => x.twin === v)?.root?.url ?? null : null);
|
|
957
|
+
const chosen = list.find((t) => t.twin === selected);
|
|
958
|
+
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" />;
|
|
537
959
|
return (
|
|
538
|
-
|
|
539
|
-
<
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
960
|
+
<>
|
|
961
|
+
<Pending load={twins} what="the vendors" />
|
|
962
|
+
{twins.state === 'ok' && list.length === 0 ? <p>No vendors in this World yet.</p> : null}
|
|
963
|
+
{chosen ? (
|
|
964
|
+
<div className="vendors">
|
|
965
|
+
<div className="stage">
|
|
966
|
+
<div className="stage-bar">
|
|
967
|
+
<Mark vendor={chosen.twin} />
|
|
968
|
+
<span className="mode">{held(chosen.twin) || 'empty'}{rootOf(chosen.twin) ? ` · connected to ${rootOf(chosen.twin)}` : ' · stand-in'}</span>
|
|
969
|
+
<span className="stage-actions">
|
|
970
|
+
{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}
|
|
971
|
+
{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}
|
|
972
|
+
</span>
|
|
973
|
+
</div>
|
|
974
|
+
{session.state === 'ok' && session.scope === 'read' ? <p className="mode stage-note">You are looking read only: the screens browse, and refuse changes.</p> : null}
|
|
975
|
+
{session.state === 'pending' ? <p className="mode" role="status">Opening…</p> : null}
|
|
976
|
+
{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}
|
|
977
|
+
{session.state === 'ok' ? (chosen.mirror
|
|
978
|
+
? <iframe key={chosen.twin} title={`${chosen.twin} mirror`} src={mirrorUrl(name, chosen.twin)} />
|
|
979
|
+
: <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}
|
|
980
|
+
</div>
|
|
981
|
+
</div>
|
|
982
|
+
) : null}
|
|
983
|
+
</>
|
|
546
984
|
);
|
|
547
985
|
}
|
|
548
986
|
|
|
549
|
-
/**
|
|
550
|
-
function
|
|
551
|
-
const [
|
|
552
|
-
const
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
987
|
+
/** Consecutive changes that are one action on one kind of thing, as runs (a single change is a run of one). */
|
|
988
|
+
function runsOf(entries: Entry[]): Entry[][] {
|
|
989
|
+
const out: Entry[][] = [];
|
|
990
|
+
for (const e of entries) { const last = out[out.length - 1]; if (last && (last[0]!.operation ?? last[0]!.op) === (e.operation ?? e.op)) last.push(e); else out.push([e]); }
|
|
991
|
+
return out;
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
/** Rows that say the same thing (nine labels created at one instant) as one line that opens to them. */
|
|
995
|
+
function collapsed(items: Placed[]): Array<Placed | Placed[]> {
|
|
996
|
+
const out: Array<Placed | Placed[]> = [];
|
|
997
|
+
for (const p of items) {
|
|
998
|
+
const last = out[out.length - 1];
|
|
999
|
+
const head = Array.isArray(last) ? last[0]! : last;
|
|
1000
|
+
if (head && head.twin === p.twin && (head.entry.operation ?? head.entry.op) === (p.entry.operation ?? p.entry.op)) {
|
|
1001
|
+
if (Array.isArray(last)) last.push(p); else out[out.length - 1] = [head, p];
|
|
1002
|
+
} else out.push(p);
|
|
1003
|
+
}
|
|
1004
|
+
// a run of two stays two rows; three or more is one line
|
|
1005
|
+
return out.flatMap((x) => (Array.isArray(x) && x.length < 3 ? x : [x]));
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
const PAGE = 50;
|
|
1009
|
+
const keyOf = (p: Placed): string => `${p.twin}:${p.position}`;
|
|
1010
|
+
/** Newest first — the door's order: the instant, then the twin, then the position. */
|
|
1011
|
+
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;
|
|
1012
|
+
/** The feed in days, each day in instants: entries at one instant stay together, in no finer order than the World knows. */
|
|
1013
|
+
function grouped(items: Placed[]): Array<{ day: string; label: string; instants: Array<{ at: string; items: Placed[] }> }> {
|
|
1014
|
+
const days: Array<{ day: string; label: string; instants: Array<{ at: string; items: Placed[] }> }> = [];
|
|
1015
|
+
for (const p of items) {
|
|
1016
|
+
const d = new Date(p.entry.occurredAt); const key = d.toDateString();
|
|
1017
|
+
let day = days[days.length - 1];
|
|
1018
|
+
if (!day || day.day !== key) { day = { day: key, label: d.toLocaleDateString(undefined, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' }), instants: [] }; days.push(day); }
|
|
1019
|
+
const last = day.instants[day.instants.length - 1];
|
|
1020
|
+
if (last && Date.parse(last.at) === d.getTime()) last.items.push(p); else day.instants.push({ at: p.entry.occurredAt, items: [p] });
|
|
1021
|
+
}
|
|
1022
|
+
return days;
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
function EntryRow({ name, p }: { name: string; p: Placed }): React.JSX.Element {
|
|
1026
|
+
const e = p.entry; const t = traceOf(e.traceparent); const s = said(e); const n = notable(e);
|
|
1027
|
+
return (
|
|
1028
|
+
<li className="entry" title={`${e.operation ?? e.op} ${e.subject.type}:${e.subject.id} · ${actorOf(e)} · ${standing(e)}`}>
|
|
1029
|
+
<span className="twin-chip"><Mark vendor={p.twin} />{vendorName(p.twin)}</span>
|
|
1030
|
+
<span className="op">{s.what}</span>
|
|
1031
|
+
{s.label ? <span className="label">“{s.label}”</span> : <code className="subject">{shown(e)}</code>}
|
|
1032
|
+
<span className="actor mode">{byWhom(e, p.twin)}</span>
|
|
1033
|
+
{n ? <span className="standing">{n}</span> : null}
|
|
1034
|
+
{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}
|
|
1035
|
+
</li>
|
|
1036
|
+
);
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
/** The feed of everything that happened, across twins — an activity feed: newest first, by day, newer changes held behind a pill. */
|
|
1040
|
+
function TimelineTab({ name, token, vendors }: { name: string; token: string; vendors: string[] }): React.JSX.Element {
|
|
1041
|
+
const [only, setOnly] = useState<string | null>(null);
|
|
1042
|
+
const [items, setItems] = useState<Placed[] | null>(null); const [next, setNext] = useState<string | null>(null);
|
|
1043
|
+
const [error, setError] = useState<string | null>(null); const [older, setOlder] = useState(false); const [olderError, setOlderError] = useState<string | null>(null);
|
|
1044
|
+
const [fresh, setFresh] = useState<{ items: Placed[]; gap: Timeline | null }>({ items: [], gap: null });
|
|
1045
|
+
const path = useCallback((before?: string): string => `/-/${name}/timeline?limit=${PAGE}${only ? `&twin=${encodeURIComponent(only)}` : ''}${before ? `&before=${encodeURIComponent(before)}` : ''}`, [name, only]);
|
|
1046
|
+
const itemsRef = useRef<Placed[] | null>(null); itemsRef.current = items;
|
|
1047
|
+
useEffect(() => {
|
|
1048
|
+
let live = true;
|
|
1049
|
+
setItems(null); setError(null); setFresh({ items: [], gap: null });
|
|
1050
|
+
void (async () => {
|
|
1051
|
+
const r = await door(token, path());
|
|
1052
|
+
if (!live) return;
|
|
1053
|
+
if (!r.ok) { setError(await failure(r)); return; }
|
|
1054
|
+
const b = (await r.json()) as Timeline; if (!live) return;
|
|
1055
|
+
setItems(b.entries); setNext(b.next);
|
|
1056
|
+
})().catch((e: unknown) => { if (live) setError(`the World did not answer: ${e instanceof Error ? e.message : String(e)}`); });
|
|
1057
|
+
// live: newer entries are counted, not inserted — the list never moves under the reader
|
|
1058
|
+
const id = setInterval(() => {
|
|
1059
|
+
const cur = itemsRef.current; if (cur === null || document.hidden) return;
|
|
1060
|
+
void door(token, path()).then(async (r) => {
|
|
1061
|
+
if (!r.ok || !live) return;
|
|
1062
|
+
const b = (await r.json()) as Timeline; if (!live) return;
|
|
1063
|
+
if (cur.length === 0) { if (b.entries.length) { setItems(b.entries); setNext(b.next); } return; }
|
|
1064
|
+
const known = new Set((itemsRef.current ?? cur).map(keyOf));
|
|
1065
|
+
const unseen = b.entries.filter((p) => !known.has(keyOf(p)));
|
|
1066
|
+
// a full page with nothing known in it: more arrived than one page holds, and showing them starts over
|
|
1067
|
+
setFresh({ items: unseen, gap: unseen.length === b.entries.length && b.next ? b : null });
|
|
1068
|
+
}).catch(() => null);
|
|
1069
|
+
}, 3000);
|
|
1070
|
+
return () => { live = false; clearInterval(id); };
|
|
1071
|
+
}, [token, path]);
|
|
1072
|
+
const show = (): void => {
|
|
1073
|
+
if (fresh.gap) { setItems(fresh.gap.entries); setNext(fresh.gap.next); } else setItems((cur) => [...fresh.items, ...(cur ?? [])].sort(newestFirst));
|
|
1074
|
+
setFresh({ items: [], gap: null });
|
|
562
1075
|
};
|
|
563
|
-
const
|
|
1076
|
+
const loadOlder = async (): Promise<void> => {
|
|
1077
|
+
if (!next) return; setOlder(true); setOlderError(null);
|
|
1078
|
+
try {
|
|
1079
|
+
const r = await door(token, path(next));
|
|
1080
|
+
// what is shown stays shown: only the older page failed
|
|
1081
|
+
if (!r.ok) { setOlderError(await failure(r)); return; }
|
|
1082
|
+
const b = (await r.json()) as Timeline;
|
|
1083
|
+
setItems((cur) => { const known = new Set((cur ?? []).map(keyOf)); return [...(cur ?? []), ...b.entries.filter((p) => !known.has(keyOf(p)))]; }); setNext(b.next);
|
|
1084
|
+
} catch (e) { setOlderError(`the World did not answer: ${e instanceof Error ? e.message : String(e)}`); } finally { setOlder(false); }
|
|
1085
|
+
};
|
|
1086
|
+
const days = useMemo(() => grouped(items ?? []), [items]);
|
|
1087
|
+
const n = fresh.items.length;
|
|
564
1088
|
return (
|
|
565
|
-
|
|
566
|
-
<
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
{
|
|
572
|
-
|
|
1089
|
+
<>
|
|
1090
|
+
<div role="group" aria-label="Filter by twin" className="chips">
|
|
1091
|
+
<button type="button" aria-pressed={only === null} onClick={() => setOnly(null)}>All vendors</button>
|
|
1092
|
+
{vendors.map((v) => <button key={v} type="button" aria-pressed={only === v} aria-label={v} onClick={() => setOnly(only === v ? null : v)}>{vendorName(v)}</button>)}
|
|
1093
|
+
</div>
|
|
1094
|
+
<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>
|
|
1095
|
+
{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 ? (
|
|
1096
|
+
only ? <p>Nothing has happened in {only} yet.</p> : (
|
|
1097
|
+
<div className="empty">
|
|
1098
|
+
<p><strong>Nothing has happened in this World yet.</strong></p>
|
|
1099
|
+
<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>
|
|
1100
|
+
</div>
|
|
1101
|
+
)
|
|
1102
|
+
) : (
|
|
1103
|
+
<div className="feed" aria-label="Timeline">
|
|
1104
|
+
{days.map((d) => (
|
|
1105
|
+
<div key={d.day} role="group" aria-label={d.label} className="day">
|
|
1106
|
+
<h3 className="day-head">{d.label}</h3>
|
|
1107
|
+
<ol className="instants">
|
|
1108
|
+
{d.instants.map((g) => (
|
|
1109
|
+
<li key={`${g.at}:${keyOf(g.items[0]!)}`} className="instant">
|
|
1110
|
+
<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>
|
|
1111
|
+
<ul className="entries">{collapsed(g.items).map((x) => (Array.isArray(x)
|
|
1112
|
+
? <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>
|
|
1113
|
+
: <EntryRow key={keyOf(x)} name={name} p={x} />))}</ul>
|
|
1114
|
+
</li>
|
|
1115
|
+
))}
|
|
1116
|
+
</ol>
|
|
1117
|
+
</div>
|
|
1118
|
+
))}
|
|
1119
|
+
{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>}
|
|
1120
|
+
{olderError ? <p role="alert">Could not load older changes: {olderError}</p> : null}
|
|
1121
|
+
</div>
|
|
1122
|
+
)}
|
|
1123
|
+
</>
|
|
573
1124
|
);
|
|
574
1125
|
}
|
|
575
1126
|
|
|
576
|
-
/**
|
|
577
|
-
function
|
|
578
|
-
const
|
|
579
|
-
const
|
|
580
|
-
const
|
|
581
|
-
const
|
|
582
|
-
|
|
583
|
-
const
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
1127
|
+
/** A cause followed across vendors — a waterfall with a lane per twin, each step openable to its fields. */
|
|
1128
|
+
function Trace({ name, token, id }: { name: string; token: string; id: string }): React.JSX.Element {
|
|
1129
|
+
const valid = /^[0-9a-f]{32}$/.test(id);
|
|
1130
|
+
const load = useDoor<Timeline>(token, valid ? `/-/${name}/timeline?trace=${id}&limit=500` : null);
|
|
1131
|
+
const steps = load.state === 'ok' ? [...load.data.entries].sort((a, b) => -newestFirst(a, b)) : [];
|
|
1132
|
+
const lanes = [...new Set(steps.map((s) => s.twin))];
|
|
1133
|
+
const t0 = steps[0] ? Date.parse(steps[0].entry.occurredAt) : 0;
|
|
1134
|
+
const back = <a href={`${BASE}/${name}/~/timeline`} onClick={(e) => { e.preventDefault(); go([...name.split('/'), '~', 'timeline']); }}>← Timeline</a>;
|
|
1135
|
+
return (
|
|
1136
|
+
<>
|
|
1137
|
+
<p>{back}</p>
|
|
1138
|
+
<h3>Trace <code>{id}</code></h3>
|
|
1139
|
+
{!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" />}
|
|
1140
|
+
{load.state === 'ok' && steps.length === 0 ? (
|
|
1141
|
+
<div className="empty">
|
|
1142
|
+
<p><strong>Nothing in this World carries this trace.</strong></p>
|
|
1143
|
+
<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>
|
|
1144
|
+
</div>
|
|
1145
|
+
) : null}
|
|
1146
|
+
{steps.length > 0 ? (
|
|
1147
|
+
<>
|
|
1148
|
+
<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>
|
|
1149
|
+
<div role="table" aria-label="Trace waterfall" className="waterfall" style={{ gridTemplateColumns: `minmax(130px, auto) repeat(${lanes.length}, minmax(140px, 1fr))` }}>
|
|
1150
|
+
<div role="row" className="wf-row wf-head">
|
|
1151
|
+
<span role="columnheader">time</span>
|
|
1152
|
+
{lanes.map((l) => <span role="columnheader" key={l}>{l}</span>)}
|
|
1153
|
+
</div>
|
|
1154
|
+
{steps.map((s) => {
|
|
1155
|
+
const ms = Date.parse(s.entry.occurredAt) - t0; const span = traceOf(s.entry.traceparent)?.span;
|
|
1156
|
+
return (
|
|
1157
|
+
<div role="row" className="wf-row" key={keyOf(s)}>
|
|
1158
|
+
<span role="cell" className="wf-time"><At iso={s.entry.occurredAt} time /> <span className="mode">+{ms} ms</span></span>
|
|
1159
|
+
{lanes.map((l) => (
|
|
1160
|
+
<span role="cell" key={l} className={l === s.twin ? 'wf-cell on' : 'wf-cell'}>
|
|
1161
|
+
{l === s.twin ? (
|
|
1162
|
+
<details>
|
|
1163
|
+
<summary>{s.entry.operation ?? s.entry.op} <code>{s.entry.subject.type}:{s.entry.subject.id}</code></summary>
|
|
1164
|
+
<p className="mode">{actorOf(s.entry)} · {standing(s.entry)} · position {s.position}{span ? <> · span <code>{span}</code></> : null}</p>
|
|
1165
|
+
<Fields fields={s.entry.fields} />
|
|
1166
|
+
</details>
|
|
1167
|
+
) : null}
|
|
1168
|
+
</span>
|
|
1169
|
+
))}
|
|
1170
|
+
</div>
|
|
1171
|
+
);
|
|
1172
|
+
})}
|
|
1173
|
+
</div>
|
|
1174
|
+
</>
|
|
1175
|
+
) : null}
|
|
1176
|
+
</>
|
|
1177
|
+
);
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
function Fields({ fields }: { fields?: Record<string, unknown> }): React.JSX.Element {
|
|
1181
|
+
const rows = Object.entries(fields ?? {});
|
|
1182
|
+
if (rows.length === 0) return <p className="mode">No fields.</p>;
|
|
1183
|
+
return <dl className="fields">{rows.map(([k, v]) => <React.Fragment key={k}><dt>{k}</dt><dd><code>{typeof v === 'string' ? v : JSON.stringify(v)}</code></dd></React.Fragment>)}</dl>;
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
type MirrorSession = { state: 'pending' } | { state: 'ok'; scope: string } | { state: 'error'; error: string };
|
|
1187
|
+
/** THE CANVAS: every vendor's own screens as frames on one surface, the way a canvas app holds its frames (React
|
|
1188
|
+
* Flow; Figma's and tldraw's feel). Pan by dragging the background, zoom with the wheel or the controls, move a frame
|
|
1189
|
+
* by its title bar (kept in this browser), click a frame to use that vendor's screens and the background or Esc to
|
|
1190
|
+
* leave it, so a frame never captures the pan. A frame is its vendor's site at a desktop's size. */
|
|
1191
|
+
const FRAME_W = 1280; const FRAME_H = 820; const FRAME_GAP = 160;
|
|
1192
|
+
type Screen = { world: string; vendor: string; active: boolean; open: (vendor: string) => void };
|
|
1193
|
+
function ScreenFrame({ data }: NodeProps<Node<Screen>>): React.JSX.Element {
|
|
1194
|
+
const { world, vendor, active, open } = data;
|
|
1195
|
+
return (
|
|
1196
|
+
<>
|
|
1197
|
+
<NodeToolbar isVisible position={Position.Top} align="start" className="frame-label">
|
|
1198
|
+
<Mark vendor={vendor} /><span>{vendorName(vendor)}</span>
|
|
1199
|
+
</NodeToolbar>
|
|
1200
|
+
<div className={active ? 'frame on' : 'frame'} style={{ width: FRAME_W, height: FRAME_H }}>
|
|
1201
|
+
<div className="frame-bar" title="Drag to move">
|
|
1202
|
+
<Mark vendor={vendor} /><strong>{vendorName(vendor)}</strong>
|
|
1203
|
+
<span className="frame-url">{mirrorUrl(world, vendor).replace(/^https?:\/\//, '')}</span>
|
|
1204
|
+
<a className="nodrag" href={`${BASE}/${world}/~/vendors/${vendor}`} onClick={(e) => { e.preventDefault(); open(vendor); }}>Open</a>
|
|
1205
|
+
<a className="nodrag" href={mirrorUrl(world, vendor)} target="_blank" rel="noopener noreferrer" aria-label={`Open ${vendorName(vendor)} in a new tab`}>↗</a>
|
|
1206
|
+
</div>
|
|
1207
|
+
<div className={active ? 'frame-body nodrag nopan nowheel' : 'frame-body'}>
|
|
1208
|
+
<iframe title={`${vendor} mirror`} src={mirrorUrl(world, vendor)} loading="lazy" style={{ pointerEvents: active ? 'auto' : 'none' }} />
|
|
1209
|
+
</div>
|
|
1210
|
+
</div>
|
|
1211
|
+
</>
|
|
1212
|
+
);
|
|
1213
|
+
}
|
|
1214
|
+
const FRAME_TYPES = { screen: ScreenFrame };
|
|
1215
|
+
function Canvas(props: { name: string; twins: Load<{ twins: Twin[] }>; session: MirrorSession; retry: () => void; snapshot: Snapshot | null; back: () => void }): React.JSX.Element {
|
|
1216
|
+
return <ReactFlowProvider><CanvasSurface {...props} /></ReactFlowProvider>;
|
|
1217
|
+
}
|
|
1218
|
+
/** 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
|
|
1219
|
+
* left (a canvas app's layers), zoom at the bottom left, the minimap at the bottom right. */
|
|
1220
|
+
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 {
|
|
1221
|
+
const frames = twins.state === 'ok' ? twins.data.twins.filter((t) => t.mirror).map((t) => t.twin) : [];
|
|
1222
|
+
const saved = `volter.canvas.${name}`;
|
|
1223
|
+
const [active, setActive] = useState<string | null>(null);
|
|
1224
|
+
const [layers, setLayers] = useState(true);
|
|
1225
|
+
const flow = useReactFlow();
|
|
1226
|
+
const open = useCallback((v: string): void => go([...name.split('/'), '~', 'vendors', v]), [name]);
|
|
1227
|
+
const cols = Math.max(1, Math.ceil(Math.sqrt(frames.length)));
|
|
1228
|
+
const placed = useCallback((): Array<Node<Screen>> => {
|
|
1229
|
+
let kept: Record<string, { x: number; y: number }> = {};
|
|
1230
|
+
try { kept = JSON.parse(localStorage.getItem(saved) ?? '{}') as Record<string, { x: number; y: number }>; } catch { /* a convenience */ }
|
|
1231
|
+
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 } }));
|
|
1232
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: the frames' names are what change it
|
|
1233
|
+
}, [frames.join(','), name, cols, open]);
|
|
1234
|
+
const [nodes, setNodes, onNodesChange] = useNodesState<Node<Screen>>([]);
|
|
1235
|
+
useEffect(() => { setNodes(placed()); }, [placed, setNodes]);
|
|
1236
|
+
useEffect(() => { setNodes((all) => all.map((n) => (n.data.active === (n.id === active) ? n : { ...n, data: { ...n.data, active: n.id === active } }))); }, [active, setNodes]);
|
|
1237
|
+
useEffect(() => { const on = (e: KeyboardEvent): void => { if (e.key === 'Escape') setActive(null); }; document.addEventListener('keydown', on); return () => document.removeEventListener('keydown', on); }, []);
|
|
1238
|
+
const keep = (): void => { try { localStorage.setItem(saved, JSON.stringify(Object.fromEntries(nodes.map((n) => [n.id, n.position])))); } catch { /* a convenience */ } };
|
|
1239
|
+
/** A layer's row: bring that frame to the middle of the window, and use it. */
|
|
1240
|
+
const focus = (v: string): void => {
|
|
1241
|
+
setActive(v);
|
|
1242
|
+
const n = flow.getNode(v); if (!n) return;
|
|
1243
|
+
// the frame in the room the floating panels leave, at the largest zoom that shows it whole
|
|
1244
|
+
const zoom = Math.min((window.innerWidth - 300) / FRAME_W, (window.innerHeight - 120) / FRAME_H, 1);
|
|
1245
|
+
void flow.setCenter(n.position.x + FRAME_W / 2 - 130 / zoom, n.position.y + FRAME_H / 2, { zoom, duration: 400 });
|
|
589
1246
|
};
|
|
590
|
-
const
|
|
1247
|
+
const dark = typeof window !== 'undefined' && window.matchMedia?.('(prefers-color-scheme: dark)').matches;
|
|
1248
|
+
const ready = session.state === 'ok' && twins.state === 'ok';
|
|
591
1249
|
return (
|
|
592
|
-
<
|
|
593
|
-
<
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
1250
|
+
<div className="canvas full" role="region" aria-label="Every vendor's screens">
|
|
1251
|
+
<ReactFlow nodes={ready ? nodes : []} onNodesChange={onNodesChange} nodeTypes={FRAME_TYPES} onNodeDragStop={keep}
|
|
1252
|
+
onNodeClick={(_e, n) => setActive(n.id)} onPaneClick={() => setActive(null)}
|
|
1253
|
+
fitView fitViewOptions={{ padding: { top: '64px', left: '272px', right: '32px', bottom: '32px' } }} minZoom={0.05} maxZoom={1.5} nodesConnectable={false} elementsSelectable={false}
|
|
1254
|
+
panOnScroll={false} zoomOnDoubleClick={false} colorMode={dark ? 'dark' : 'light'} proOptions={{ hideAttribution: true }}>
|
|
1255
|
+
<Background variant={BackgroundVariant.Dots} gap={28} size={1.4} />
|
|
1256
|
+
<Panel position="top-left" className="float layers">
|
|
1257
|
+
<div className="float-head">
|
|
1258
|
+
<button type="button" className="back" onClick={back} aria-label={`Back to ${name}`}>←</button>
|
|
1259
|
+
<span className="float-title"><strong>{name.split('/')[1]}</strong><span className="mode">{name.split('/')[0]}{snapshot ? ' · snapshot' : ''}</span></span>
|
|
1260
|
+
<button type="button" className="ghost" aria-expanded={layers} aria-label={layers ? 'Hide the screens list' : 'Show the screens list'} onClick={() => setLayers(!layers)}>{layers ? '▾' : '▸'}</button>
|
|
1261
|
+
</div>
|
|
1262
|
+
{layers ? (
|
|
1263
|
+
<ul className="layer-list" aria-label="Screens">
|
|
1264
|
+
{frames.map((v) => <li key={v}><button type="button" className={active === v ? 'on' : undefined} onClick={() => focus(v)}><Mark vendor={v} /><span>{vendorName(v)}</span></button></li>)}
|
|
1265
|
+
{twins.state === 'ok' && frames.length === 0 ? <li className="mode">No vendor in this World has screens.</li> : null}
|
|
1266
|
+
</ul>
|
|
1267
|
+
) : null}
|
|
1268
|
+
<Pending load={twins} what="the vendors" />
|
|
1269
|
+
{session.state === 'pending' ? <p className="mode" role="status">Opening…</p> : null}
|
|
1270
|
+
{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}
|
|
1271
|
+
</Panel>
|
|
1272
|
+
<Panel position="top-center" className="float hint mode">{active ? `Using ${vendorName(active)} · Esc to leave` : 'Drag to pan · scroll to zoom · click a screen to use it'}</Panel>
|
|
1273
|
+
<Controls showInteractive={false} position="bottom-left" />
|
|
1274
|
+
<MiniMap pannable zoomable nodeBorderRadius={8} position="bottom-right" />
|
|
1275
|
+
</ReactFlow>
|
|
1276
|
+
</div>
|
|
601
1277
|
);
|
|
602
1278
|
}
|
|
603
1279
|
|
|
604
|
-
/**
|
|
605
|
-
function
|
|
606
|
-
const
|
|
607
|
-
const
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
1280
|
+
/** What this World changed since its base — what a changeset would cut — in a compare view, by twin. */
|
|
1281
|
+
function Changes({ name, token }: { name: string; token: string }): React.JSX.Element {
|
|
1282
|
+
const diff = useDoor<Diff>(token, `/-/${name}/diff`);
|
|
1283
|
+
const byTwin = useMemo(() => {
|
|
1284
|
+
const m = new Map<string, Entry[]>();
|
|
1285
|
+
if (diff.state === 'ok') for (const c of diff.data.changes) m.set(c.twin, [...(m.get(c.twin) ?? []), c.entry]);
|
|
1286
|
+
return [...m.entries()];
|
|
1287
|
+
}, [diff]);
|
|
612
1288
|
return (
|
|
613
|
-
|
|
614
|
-
<
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
1289
|
+
<>
|
|
1290
|
+
<Pending load={diff} what="the changes" />
|
|
1291
|
+
{diff.state === 'ok' ? (
|
|
1292
|
+
<>
|
|
1293
|
+
<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>
|
|
1294
|
+
{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> : byTwin.map(([t, entries]) => (
|
|
1295
|
+
<div key={t} className="compare" role="group" aria-labelledby={`changes-${t}`}>
|
|
1296
|
+
<h3 id={`changes-${t}`} className="vendor-cell"><Mark vendor={t} />{vendorName(t)} <span className="mode">· {entries.length} change{entries.length === 1 ? '' : 's'}</span></h3>
|
|
1297
|
+
<ul>{runsOf(entries).map((run) => run.length >= 3 ? (
|
|
1298
|
+
<li key={run[0]!.id}>
|
|
1299
|
+
<details>
|
|
1300
|
+
<summary><span className="op">{counted(run.length, run[0]!.subject.type)} {said(run[0]!).what.split(' ').slice(-1)[0]}</span> <span className="mode">{run.map((e) => said(e).label ?? shown(e)).slice(0, 4).join(', ')}{run.length > 4 ? ', …' : ''}</span></summary>
|
|
1301
|
+
<ul>{run.map((e) => <li key={e.id}><span className="op">{said(e).what}</span> {said(e).label ? <span className="label">“{said(e).label}”</span> : <code>{shown(e)}</code>} <span className="mode"><At iso={e.occurredAt} /> · {byWhom(e, t)}</span></li>)}</ul>
|
|
1302
|
+
</details>
|
|
1303
|
+
</li>
|
|
1304
|
+
) : run.map((e) => (
|
|
1305
|
+
<li key={e.id}>
|
|
1306
|
+
<details>
|
|
1307
|
+
<summary><span className="op">{said(e).what}</span> {said(e).label ? <span className="label">“{said(e).label}”</span> : <code>{shown(e)}</code>} <span className="mode"><At iso={e.occurredAt} /> · {byWhom(e, t)}</span></summary>
|
|
1308
|
+
<Fields fields={e.fields} />
|
|
1309
|
+
</details>
|
|
1310
|
+
</li>
|
|
1311
|
+
)))}</ul>
|
|
1312
|
+
</div>
|
|
1313
|
+
))}
|
|
1314
|
+
</>
|
|
1315
|
+
) : null}
|
|
1316
|
+
</>
|
|
623
1317
|
);
|
|
624
1318
|
}
|
|
625
1319
|
|
|
626
|
-
|
|
627
|
-
/**
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
const
|
|
1320
|
+
/** The operator's tables: each twin's standing and root, the requests, the changesets — and the host admin's rotate and remove. */
|
|
1321
|
+
/** 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
|
|
1322
|
+
* made, listed with when it was last used, and revoked alone; `volter remote add` makes one through the platform. */
|
|
1323
|
+
function Keys({ name, token }: { name: string; token: string }): React.JSX.Element {
|
|
1324
|
+
type Key = { id: string; name: string; scope: string; createdAt: string; lastUsedAt: string | null };
|
|
1325
|
+
const [version, setVersion] = useState(0);
|
|
1326
|
+
const keys = useDoor<{ keys: Key[] }>(token, `/-/${name}/keys`, version);
|
|
1327
|
+
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);
|
|
1328
|
+
const [made, setMade] = useState<{ key: string; name: string } | null>(null);
|
|
1329
|
+
// a read-only link: a read key carried in the dashboard's address (the fragment, which no server sees); whoever opens
|
|
1330
|
+
// it reads this World and changes nothing, until the key is revoked here
|
|
1331
|
+
const [link, setLink] = useState<string | null>(null);
|
|
1332
|
+
const share = async (): Promise<void> => {
|
|
1333
|
+
const label = `Read-only link · ${new Date().toLocaleDateString(undefined, { month: 'short', day: 'numeric' })}`;
|
|
1334
|
+
const r = await door(token, `/-/${name}/keys`, { method: 'POST', body: JSON.stringify({ name: label, scope: 'read' }) }).catch(() => null);
|
|
1335
|
+
const b = r ? ((await r.json().catch(() => ({}))) as { key?: string; error?: string }) : { error: `${ORIGIN} did not answer` };
|
|
1336
|
+
if (!r?.ok || !b.key) { toast(b.error ?? 'refused', 'error'); return; }
|
|
1337
|
+
setLink(`${window.location.origin}${BASE}/${name}#${new URLSearchParams({ token: b.key }).toString()}`); setVersion((v) => v + 1);
|
|
1338
|
+
};
|
|
1339
|
+
const make = async (e: React.FormEvent): Promise<void> => {
|
|
1340
|
+
e.preventDefault(); setBusy(true); setError(null);
|
|
1341
|
+
const r = await door(token, `/-/${name}/keys`, { method: 'POST', body: JSON.stringify({ name: keyName.trim(), scope }) }).catch(() => null);
|
|
1342
|
+
setBusy(false);
|
|
1343
|
+
const b = r ? ((await r.json().catch(() => ({}))) as { key?: string; error?: string }) : { error: `${ORIGIN} did not answer` };
|
|
1344
|
+
if (!r?.ok || !b.key) { setError(b.error ?? 'refused'); return; }
|
|
1345
|
+
setMaking(false); setMade({ key: b.key, name: keyName.trim() }); setKeyName(''); setVersion((v) => v + 1);
|
|
1346
|
+
};
|
|
1347
|
+
const revoke = async (k: Key): Promise<void> => {
|
|
1348
|
+
if (!(await confirmAct({ title: `Revoke ${k.name}?`, body: 'Whatever holds it loses this World at once.', action: 'Revoke key', danger: true }))) return;
|
|
1349
|
+
const r = await door(token, `/-/${name}/keys/${k.id}`, { method: 'DELETE' });
|
|
1350
|
+
if (r.ok) toast(`Revoked ${k.name}`); else toast(await failure(r), 'error');
|
|
1351
|
+
setVersion((v) => v + 1);
|
|
1352
|
+
};
|
|
632
1353
|
return (
|
|
633
|
-
<
|
|
634
|
-
|
|
635
|
-
<
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
1354
|
+
<section aria-labelledby="keys-head" className="keys">
|
|
1355
|
+
<div className="keys-head"><div><h3 id="keys-head">Keys</h3><p className="mode">What an app, a script or a CI job holds to reach this World: each named for what holds it and revoked alone. People open the World as themselves; keys are for software.</p></div>{/* opened from a platform, keys are made there (a session here makes none): its Worlds page has "Make a key…" */}
|
|
1356
|
+
{portal() ? <a className="button primary" href={`${portal()}/${encodeURIComponent(name.split('/')[0] ?? '')}`}>Make a key on the platform</a> : <div className="head-actions"><button type="button" onClick={() => void share()}>Share a read-only link</button><button type="button" className="primary" onClick={() => { setError(null); setMaking(true); }}>Make a key</button></div>}</div>
|
|
1357
|
+
<Pending load={keys} what="the keys" retry={() => setVersion((v) => v + 1)} />
|
|
1358
|
+
{keys.state === 'ok' ? (keys.data.keys.length === 0 ? <p className="mode">No keys yet. The World's own token opens it too.</p> : (
|
|
1359
|
+
<table className="dense" aria-label="Keys">
|
|
1360
|
+
<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>
|
|
1361
|
+
<tbody>{keys.data.keys.map((k) => (
|
|
1362
|
+
<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>
|
|
1363
|
+
))}</tbody>
|
|
1364
|
+
</table>
|
|
1365
|
+
)) : null}
|
|
1366
|
+
<Dialog open={making} onClose={() => { if (!busy) setMaking(false); }} title="Make a key" footer={<><button type="button" onClick={() => setMaking(false)} disabled={busy}>Cancel</button><button type="submit" form="make-key" disabled={busy || !keyName.trim()}>{busy ? 'Making…' : 'Make key'}</button></>}>
|
|
1367
|
+
<form id="make-key" onSubmit={make} aria-label="Make a key">
|
|
1368
|
+
<label>Name <input autoFocus name="keyName" value={keyName} onChange={(e) => setKeyName(e.target.value)} placeholder="ci, staging app…" maxLength={80} required /></label>
|
|
1369
|
+
<label>Access <select value={scope} onChange={(e) => setScope(e.target.value === 'read' ? 'read' : 'write')}><option value="write">Read and write</option><option value="read">Read only</option></select></label>
|
|
1370
|
+
{error ? <p role="alert">{error}</p> : null}
|
|
1371
|
+
</form>
|
|
1372
|
+
</Dialog>
|
|
1373
|
+
<Dialog open={Boolean(made)} onClose={() => setMade(null)} title={`${made?.name ?? 'The key'} is ready`} footer={<button type="button" className="primary" onClick={() => setMade(null)}>Done</button>}>
|
|
1374
|
+
<p className="mode">Copy it now: it is shown this once. Put it where the app reads its secrets.</p>
|
|
1375
|
+
{made ? <CopyField label="Key" value={made.key} secret /> : null}
|
|
1376
|
+
</Dialog>
|
|
1377
|
+
<Dialog open={Boolean(link)} onClose={() => setLink(null)} title="A read-only link" footer={<button type="button" className="primary" onClick={() => setLink(null)}>Done</button>}>
|
|
1378
|
+
<p className="mode">Whoever opens it sees this World's pages and changes nothing. Copy it now: it is shown this once. It is listed under Keys, where revoking it closes the link at once.</p>
|
|
1379
|
+
{link ? <CopyField label="Link" value={link} secret /> : null}
|
|
1380
|
+
</Dialog>
|
|
1381
|
+
</section>
|
|
640
1382
|
);
|
|
641
1383
|
}
|
|
642
1384
|
|
|
643
|
-
function
|
|
1385
|
+
function Operations({ name, token, admin, canWrite, refused, onRemoved }: { name: string; token: string; admin: string | null; canWrite: boolean; refused: (r: Response) => void; onRemoved: () => void }): React.JSX.Element {
|
|
644
1386
|
const [twins, setTwins] = useState<Twin[] | null>(null);
|
|
645
1387
|
const [changesets, setChangesets] = useState<Changeset[] | null>(null);
|
|
646
1388
|
const [note, setNote] = useState<string | null>(null);
|
|
@@ -658,85 +1400,89 @@ function WorldView({ name, token, twin, admin, onRemoved }: { name: string; toke
|
|
|
658
1400
|
const act = async (label: string, path: string, init: RequestInit): Promise<void> => {
|
|
659
1401
|
setNote(null);
|
|
660
1402
|
const r = await door(token, path, init);
|
|
661
|
-
|
|
662
|
-
setNote(r.ok ? `${label}: done` : `${label}: ${
|
|
1403
|
+
if (!r.ok) refused(r);
|
|
1404
|
+
setNote(r.ok ? `${label}: done` : `${label}: ${await failure(r)}`);
|
|
663
1405
|
await refresh();
|
|
664
1406
|
};
|
|
665
1407
|
const rotate = async (): Promise<void> => {
|
|
666
|
-
if (!
|
|
1408
|
+
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;
|
|
667
1409
|
const r = await door(admin!, `/-/worlds/${name}/rotate`, { method: 'POST' });
|
|
668
1410
|
if (r.ok) { setTokens((await r.json()) as { token: string; readToken: string }); setNote('rotated: the new tokens are shown once'); } else setNote(`rotate: answered ${r.status}`);
|
|
669
1411
|
};
|
|
670
1412
|
const remove = async (): Promise<void> => {
|
|
671
|
-
if (!
|
|
672
|
-
const r = await door(admin!, `/-/worlds/${name}`, { method: 'DELETE' });
|
|
1413
|
+
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;
|
|
1414
|
+
const r = await door(admin!, `/-/worlds/${name}`, { method: 'DELETE' }).catch((e: unknown) => e as Error);
|
|
1415
|
+
if (r instanceof Error) { setNote(`remove: the host did not answer (${r.message})`); return; }
|
|
673
1416
|
if (r.ok) onRemoved(); else setNote(`remove: answered ${r.status}`);
|
|
674
1417
|
};
|
|
675
|
-
if (twin) return <TwinView name={name} token={token} twin={twin} />;
|
|
676
1418
|
return (
|
|
677
|
-
|
|
678
|
-
<h2 id="world">{name}</h2>
|
|
679
|
-
<p><code>{WORLDS}/{name}</code> — point an app's SDK at <code>{WORLDS}/{name}/<twin></code></p>
|
|
1419
|
+
<>
|
|
680
1420
|
{note ? <p role="status">{note}</p> : null}
|
|
681
1421
|
{tokens ? <p role="status">token <code>{tokens.token}</code> · read token <code>{tokens.readToken}</code></p> : null}
|
|
682
|
-
<
|
|
1422
|
+
{canWrite ? <Keys name={name} token={token} /> : null}
|
|
1423
|
+
<h3>Real services</h3>
|
|
1424
|
+
<p className="mode">Every vendor here is a stand-in. Connect one to its real service to read the real state into this World; give it the real key to send this World's changes there (a changeset's Deploy, below).</p>
|
|
683
1425
|
{twins === null ? <p>Loading…</p> : (
|
|
684
|
-
<table>
|
|
685
|
-
<thead><tr><th>
|
|
1426
|
+
<table aria-label="Real services">
|
|
1427
|
+
<thead><tr><th>vendor</th><th>real service</th><th>real key</th><th>last read from it</th><th>last sent</th>{canWrite ? <th></th> : null}</tr></thead>
|
|
686
1428
|
<tbody>
|
|
687
1429
|
{twins.map((t) => (
|
|
688
1430
|
<tr key={t.twin}>
|
|
689
|
-
<td><a href={`${BASE}/${name}/${t.twin}`} onClick={(e) => { e.preventDefault(); go([...name.split('/'), t.twin]); }}>{t.twin}</a></td>
|
|
690
|
-
<td>{t.
|
|
691
|
-
<td>{t.
|
|
692
|
-
<td>{t.
|
|
693
|
-
<td>{t.
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
1431
|
+
<td><a href={`${BASE}/${name}/${t.twin}`} title={`Its log and stored state · ${t.position ?? 0} changes held${t.protocol?.major !== undefined ? ` · protocol ${t.protocol.major}${t.protocol.standing && t.protocol.standing !== 'current' ? ` (${t.protocol.standing})` : ''}` : ''}`} onClick={(e) => { e.preventDefault(); go([...name.split('/'), t.twin]); }}>{vendorName(t.twin)}</a></td>
|
|
1432
|
+
<td>{t.root?.url ? <code>{t.root.url}</code> : <span className="mode">not connected</span>}</td>
|
|
1433
|
+
<td>{t.credential ? `sealed ${t.credential.fingerprint ?? ''} · ${when(t.credential.placedAt)}` : <span className="mode">none</span>}</td>
|
|
1434
|
+
<td>{t.refresh?.failed ? <span role="alert">failed ×{t.refresh.failed.count ?? 1}: {t.refresh.failed.reason ?? ''}</span> : t.root ? when(t.refresh?.observedAt ?? t.refresh?.at) : <span className="mode">—</span>}</td>
|
|
1435
|
+
<td>{t.lastReceipt ? `${t.lastReceipt.status ?? ''} ${t.lastReceipt.externalId ?? ''} ${t.lastReceipt.reason ?? ''}`.trim() : <span className="mode">—</span>}</td>
|
|
1436
|
+
{canWrite ? (
|
|
1437
|
+
<td>
|
|
1438
|
+
<button 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(`refresh ${t.twin}`, `/-/${name}/twins/${t.twin}/refresh`, { method: 'POST' }); })} disabled={!t.root} title="Read the real service's current state into this World">Read from it</button>{' '}
|
|
1439
|
+
<button onClick={() => { const url = window.prompt(`The real ${vendorName(t.twin)}'s address (its API's URL); empty disconnects it`, t.root?.url ?? ''); if (url === null) return; void act(`root ${t.twin}`, `/-/${name}/twins/${t.twin}/root`, { method: 'PUT', body: JSON.stringify(url ? { url } : null) }); }} title="Connect this vendor to the real service's URL (empty disconnects it)">Connect…</button>{' '}
|
|
1440
|
+
<button onClick={() => { const secret = window.prompt(`The real ${vendorName(t.twin)} key: it is sealed beside this World and never shown again`); if (!secret) return; void act(`seal ${t.twin}`, `/-/${name}/twins/${t.twin}/credential`, { method: 'PUT', body: secret, headers: { 'content-type': 'text/plain' } }); }} title="Give this World the real service's key; it is sealed and never shown again">Real key…</button>
|
|
1441
|
+
</td>
|
|
1442
|
+
) : null}
|
|
701
1443
|
</tr>
|
|
702
1444
|
))}
|
|
703
1445
|
</tbody>
|
|
704
1446
|
</table>
|
|
705
1447
|
)}
|
|
706
|
-
<h3>
|
|
707
|
-
{report === null ? <p className="mode">No request report for this world.</p> : report.requests === 0 ? <p className="mode">No requests in the last 24 hours.</p> : (
|
|
1448
|
+
<h3>Traffic (last 24 hours)</h3>
|
|
1449
|
+
{report === null ? <p className="mode">No request report for this world.</p> : report.requests === 0 ? <p className="mode">No requests recorded in the last 24 hours. Recording is off unless the World runs with <code>VOLTER_TWIN_REQUEST_JOURNAL=1</code>; what the requests changed is always in Activity.</p> : (
|
|
708
1450
|
<table aria-label="Requests by twin">
|
|
709
|
-
<thead><tr><th>
|
|
1451
|
+
<thead><tr><th>vendor</th><th>requests</th><th>by status</th><th>p50</th><th>p95</th><th>top routes</th></tr></thead>
|
|
710
1452
|
<tbody>{report.twins.map((t) => <tr key={t.twin}><td>{t.twin}</td><td>{t.requests}</td><td>{Object.entries(t.byStatus).map(([k, v]) => `${k}: ${v}`).join(' · ') || '—'}</td><td>{t.p50Ms === null ? '—' : `${t.p50Ms} ms`}</td><td>{t.p95Ms === null ? '—' : `${t.p95Ms} ms`}</td><td>{t.topRoutes.slice(0, 5).map((r) => <div key={r.route}><code>{r.route}</code> <span className="mode">×{r.requests}</span></div>)}</td></tr>)}</tbody>
|
|
711
1453
|
</table>
|
|
712
1454
|
)}
|
|
713
|
-
<h3>
|
|
714
|
-
|
|
1455
|
+
<h3>Sending changes to the real services</h3>
|
|
1456
|
+
<p className="mode">A changeset is a set of this World's changes (<code>volter world changeset -m "…"</code>), verified and approved, then deployed to each connected real service.</p>
|
|
1457
|
+
{changesets === null ? <p>Loading…</p> : changesets.length === 0 ? <p className="mode">No changesets yet.</p> : (
|
|
715
1458
|
<table>
|
|
716
|
-
<thead><tr><th>name</th><th>changes</th><th>approvals</th
|
|
1459
|
+
<thead><tr><th>name</th><th>changes</th><th>approvals</th>{canWrite ? <th>actions</th> : null}</tr></thead>
|
|
717
1460
|
<tbody>
|
|
718
1461
|
{changesets.map((c) => (
|
|
719
1462
|
<tr key={c.name}>
|
|
720
1463
|
<td>{c.name}</td>
|
|
721
1464
|
<td>{Array.isArray(c.actions) ? c.actions.length : '—'}</td>
|
|
722
1465
|
<td>{Array.isArray(c.approvals) ? c.approvals.length : 0}</td>
|
|
723
|
-
|
|
724
|
-
<
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
1466
|
+
{canWrite ? (
|
|
1467
|
+
<td>
|
|
1468
|
+
<button onClick={() => void act(`verify ${c.name}`, `/-/${name}/changesets/${encodeURIComponent(c.name)}/verify`, { method: 'POST' })}>Verify</button>{' '}
|
|
1469
|
+
<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>{' '}
|
|
1470
|
+
<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>
|
|
1471
|
+
</td>
|
|
1472
|
+
) : null}
|
|
728
1473
|
</tr>
|
|
729
1474
|
))}
|
|
730
1475
|
</tbody>
|
|
731
1476
|
</table>
|
|
732
1477
|
)}
|
|
733
1478
|
{admin ? (
|
|
734
|
-
<
|
|
735
|
-
<
|
|
736
|
-
<button onClick={() => void
|
|
737
|
-
|
|
1479
|
+
<div className="danger" role="region" aria-label="Danger zone">
|
|
1480
|
+
<h3>Danger zone</h3>
|
|
1481
|
+
<p><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>
|
|
1482
|
+
<p><button onClick={() => void remove()}>Remove world</button> <span className="mode">Deletes this World and everything in it.</span></p>
|
|
1483
|
+
</div>
|
|
738
1484
|
) : null}
|
|
739
|
-
|
|
1485
|
+
</>
|
|
740
1486
|
);
|
|
741
1487
|
}
|
|
742
1488
|
|
|
@@ -789,7 +1535,7 @@ function TwinView({ name, token, twin }: { name: string; token: string; twin: st
|
|
|
789
1535
|
<td>{e.operation ?? e.op}</td>
|
|
790
1536
|
<td><code>{e.subject.type}:{e.subject.id}</code></td>
|
|
791
1537
|
<td>{e.actor ? `${e.actor.kind}${e.actor.id ? ` ${e.actor.id}` : ''}` : '—'}</td>
|
|
792
|
-
<td>{
|
|
1538
|
+
<td>{standing(e)}</td>
|
|
793
1539
|
</tr>
|
|
794
1540
|
))}
|
|
795
1541
|
</tbody>
|
|
@@ -812,4 +1558,5 @@ function TwinView({ name, token, twin }: { name: string; token: string; twin: st
|
|
|
812
1558
|
);
|
|
813
1559
|
}
|
|
814
1560
|
|
|
815
|
-
|
|
1561
|
+
applyTheme(); // the person's theme before the first paint
|
|
1562
|
+
createRoot(ROOT).render(<><App /><Toaster /><ConfirmHost /><MenuDismiss /></>);
|