@volter/world-console 2.0.17 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -11
- package/client/console.css +0 -1
- package/client/console.tsx +51 -41
- package/client/main.tsx +1 -1
- package/dist/client/console.bundle.js +13 -13
- package/dist/client/console.css +0 -1
- package/dist/client/console.tsx +51 -41
- package/dist/client/main.bundle.js +68 -68
- package/dist/client/main.tsx +1 -1
- package/dist/src/console.d.ts +1 -1
- package/dist/src/console.js +2 -2
- package/package.json +5 -8
- package/src/console.ts +3 -3
package/README.md
CHANGED
|
@@ -19,15 +19,5 @@ volter-host serve --dir ./worlds # prints the console URL alongside the admin
|
|
|
19
19
|
|
|
20
20
|
Built on `@volter/world-host` and `@volter/world-runtime`. Apache-2.0.
|
|
21
21
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
`bun run storybook` (in `apps/console`) serves every page at http://127.0.0.1:6106 as the real console over a recorded
|
|
25
|
-
World's doors (`stories/demo-world.json`) and a host's inventory as its admin reads it (`stories/demo-host.json`);
|
|
26
|
-
`stories/doors.ts` answers them, and an act answers ok and changes nothing. A change to `client/` shows in the open story
|
|
27
|
-
at once. A recording is a served World's doors read with its token (each door's answer, the World named `acme/web`), its
|
|
28
|
-
tokens never kept: a World the stories show is made through the product (`volter world init`, the app's own writes
|
|
29
|
-
through `volter world run`, cuts, pushes and keys through its doors), never written by hand. A vendor's screens are its own site, which no recording holds:
|
|
30
|
-
`STORYBOOK_SCREENS=http://127.0.0.1:<port>/<org>/<world> STORYBOOK_SCREENS_TOKEN=<its token> bun run storybook` shows them
|
|
31
|
-
from a World served on this machine. The pages' map is
|
|
32
|
-
[docs/contributing/console.md](../../docs/contributing/console.md).
|
|
22
|
+
The pages' map is [docs/contributing/console.md](../../docs/contributing/console.md).
|
|
33
23
|
|
package/client/console.css
CHANGED
|
@@ -592,7 +592,6 @@ main.app.home { max-width: none; }
|
|
|
592
592
|
.ws-screens-marks .mark { width: 16px; height: 16px; }
|
|
593
593
|
.ws-screens-marks .more { font: 11px var(--mono); color: var(--faint); }
|
|
594
594
|
.ws-screens-entry .count { margin-left: 6px !important; }
|
|
595
|
-
.ws-on-screens { margin-left: auto; display: inline-flex; align-items: center; gap: 6px; font-size: 12.5px; white-space: nowrap; }
|
|
596
595
|
.ws-account { display: flex; align-items: center; gap: 14px; margin: 8px 0 16px; padding: 14px; border: 1px solid var(--line); border-radius: 8px; background: var(--surface); }
|
|
597
596
|
.ws-account-face { display: grid; place-items: center; width: 40px; height: 40px; border-radius: 50%; background: var(--surface-2); font-weight: 600; }
|
|
598
597
|
.ws-account .cell-label small + small { margin-top: 2px; }
|
package/client/console.tsx
CHANGED
|
@@ -8,7 +8,7 @@ import { applyTheme, storedTheme, type Theme, At, Brand, ConfirmHost, MenuDismis
|
|
|
8
8
|
import { agentPrompt } from '../../../packages/cli/src/agent-prompt.ts';
|
|
9
9
|
import { ICONS } from './brand/icons.ts';
|
|
10
10
|
|
|
11
|
-
type Twin = { twin: string; protocol?: { major?: number; standing?: string } | null; root?: { url?: string; deploy?: string } | null; credential?: { placedAt?: string; fingerprint?: string; sealedBy?: 'vault' | 'key'; boundTo?: string } | null; refresh?: { at?: string; observedAt?: string; failed?: { at?: string; reason?: string; count?: number } } | null; position?: number; lastReceipt?: { status?: string; externalId?: string; reason?: string } | null;
|
|
11
|
+
type Twin = { twin: string; protocol?: { major?: number; standing?: string } | null; root?: { url?: string; deploy?: string } | null; credential?: { placedAt?: string; fingerprint?: string; sealedBy?: 'vault' | 'key'; boundTo?: string } | null; refresh?: { at?: string; observedAt?: string; failed?: { at?: string; reason?: string; count?: number } } | null; position?: number; lastReceipt?: { status?: string; externalId?: string; reason?: string } | null; screens?: Workspace[] };
|
|
12
12
|
type Row = { org: string; world: string; name: string; base: string; token: string; readToken: string; twins: Array<{ vendor: string; protocol?: { major?: number } | null; root?: unknown }> };
|
|
13
13
|
type Entry = { id: string; service: string; op: string; operation?: string; subject: { type: string; id: string }; occurredAt: string; actor?: { kind: string; id?: string }; provenance?: string; batch?: string; receipt?: { status?: string; at?: string; externalId?: string; reason?: string }; landsId?: string; /** a landed copy under the vendor's id: the local subject id it stands for */ aliasOf?: string; fields?: Record<string, unknown>; traceparent?: string; input?: unknown; /** the credential that called, as the World names it: `key:<name>`, `key`, `person:<who>`, `person`, `token`, `token:read` */ caller?: string };
|
|
14
14
|
type Mode = { kind: 'admin'; worlds: Row[] } | { kind: 'world'; worlds: Array<{ name: string; base: string; twins: Row['twins'] }> };
|
|
@@ -22,11 +22,11 @@ const ORIGIN = window.location.origin;
|
|
|
22
22
|
const WORLDS = (ROOT.dataset.worlds ?? ORIGIN).replace(/\/+$/, '');
|
|
23
23
|
/** The Worlds a World token may open, when the host does not list them (a hosted host keeps its tenants' names to itself). */
|
|
24
24
|
const NAMES = (ROOT.dataset.names ?? '').split(',').filter((n) => n !== '');
|
|
25
|
-
// Served on a World's own origin, beside the World's twins and
|
|
25
|
+
// Served on a World's own origin, beside the World's twins and their screens (same-origin frames of this tab), the console
|
|
26
26
|
// keeps no token any script there could read: a token (typed, or carried in the fragment) is exchanged once for the
|
|
27
27
|
// World's HttpOnly session, and the doors ride that cookie. SESSION stands in for the token from then on.
|
|
28
28
|
const ON_WORLD = ROOT.dataset.worldOrigin === '1';
|
|
29
|
-
// the browser's fetch as it was when the console loaded, before any
|
|
29
|
+
// the browser's fetch as it was when the console loaded, before any screen frame existed: a same-origin screen that
|
|
30
30
|
// later replaces `parent.fetch` does not see the console's requests through it. Hardening, not isolation (such a frame
|
|
31
31
|
// reaches much else); a sign-out's fresh document is what ends its reach into this one
|
|
32
32
|
const nativeFetch = window.fetch.bind(window);
|
|
@@ -206,7 +206,7 @@ function moveTo(origin: string, token: string, name: string, path?: string): voi
|
|
|
206
206
|
}
|
|
207
207
|
|
|
208
208
|
/** The path under the console's base: [] worlds; [org, world] a world; [org, world, twin] a twin; [org, world, '~', tab, arg?] a tab of the world. */
|
|
209
|
-
// where the path is kept: the address bar, or (a page whose root says `data-route="memory"`,
|
|
209
|
+
// where the path is kept: the address bar, or (a page whose root says `data-route="memory"`, one embedded where it has no addresses of its own) this
|
|
210
210
|
// page's memory, so a page that is not served at its own addresses still moves between its pages
|
|
211
211
|
let memoryPath: string | null = ROOT.dataset.route === 'memory' ? '' : null;
|
|
212
212
|
/** Move a memory-routed page to `path` (under the base), as following a link there would. */
|
|
@@ -395,7 +395,8 @@ function App(): React.JSX.Element {
|
|
|
395
395
|
const tabSub = tabPart === 'changes' && argPart === 'changesets' && argMore && PART.test(argMore) ? argMore
|
|
396
396
|
// a record on a vendor's screens: `~/screens/<vendor>/<type>/<id>`
|
|
397
397
|
// (the type is the vendor's, dotted where it is namespaced — `treasury.inbound_transfer`; the id is the vendor's own — a
|
|
398
|
-
// Slack message's is its timestamp;
|
|
398
|
+
// Slack message's is its timestamp); it opens the vendor's screens, which open no record by name yet (architecture,
|
|
399
|
+
// "Workspace")
|
|
399
400
|
: tabPart === 'screens' && argMore && argLast && /^[A-Za-z0-9_.-]+$/.test(argMore) && RECORD_ID.test(argLast) ? `${argMore}:${argLast}` : undefined;
|
|
400
401
|
// only a World's name opens a World page (and so reaches its doors); any other path is the list of Worlds
|
|
401
402
|
const name = worldOf(org, world);
|
|
@@ -516,7 +517,7 @@ function Worlds({ mode, admin, reload }: { mode: Mode; admin: string | null; rel
|
|
|
516
517
|
}
|
|
517
518
|
|
|
518
519
|
type Clock = { at: string; frozen: boolean };
|
|
519
|
-
type WorldMap = { world: string; twins: Array<{ twin: string; position: number;
|
|
520
|
+
type WorldMap = { world: string; twins: Array<{ twin: string; position: number; screens?: Workspace[]; root: { url: string } | null; resources: Array<{ type: string; count: number }> }> };
|
|
520
521
|
type Placed = { twin: string; position: number; entry: Entry; /** the credential that called, as the World names it from its request journal (the timeline door): `key:<name>`, `person:<who>`, `person`, `token` */ caller?: string };
|
|
521
522
|
type Timeline = { entries: Placed[]; next: string | null };
|
|
522
523
|
type Diff = { base: { id: string; at: string | null }; changes: Array<{ twin: string; entry: Entry }> };
|
|
@@ -532,8 +533,14 @@ type Scope = 'pending' | 'read' | 'write' | 'unknown';
|
|
|
532
533
|
* settings tabs addressed on their own, remote and vault). Anything else opens Changes. */
|
|
533
534
|
const TABS = ['changes', 'events', 'trace', 'vendors', 'screens', 'canvas', 'branches', 'settings', 'remote', 'vault'] as const;
|
|
534
535
|
type Tab = (typeof TABS)[number] | 'overview';
|
|
535
|
-
/** A
|
|
536
|
-
|
|
536
|
+
/** A workspace screen of a twin's vendor, as its `GET /twin` lists it (Viewing a World). */
|
|
537
|
+
type Workspace = { id: string; path: string };
|
|
538
|
+
/** Where a twin's vendor screens are shown: its first workspace that names no parameter, at the twin's place in the
|
|
539
|
+
* World; null when it lists none. */
|
|
540
|
+
const screenUrl = (name: string, t: { twin: string; screens?: Workspace[] } | undefined): string | null => {
|
|
541
|
+
const shown = t?.screens?.find((w) => !/[{<]/.test(w.path));
|
|
542
|
+
return t && shown ? `${WORLDS}/${name}/${t.twin}${shown.path.startsWith('/') ? shown.path : `/${shown.path}`}` : null;
|
|
543
|
+
};
|
|
537
544
|
/** What an entry did, in words: the thing's kind and the vendor's verb, the thing's own label where it has one. */
|
|
538
545
|
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' };
|
|
539
546
|
function said(e: Entry): { what: string; label: string | null } {
|
|
@@ -556,7 +563,7 @@ function said(e: Entry): { what: string; label: string | null } {
|
|
|
556
563
|
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);
|
|
557
564
|
|
|
558
565
|
function WorldView({ name, token, twin, tab, arg, sub, admin, snapshot, parentToken, who, onSignOut, onReturn, opens, onRemoved, onWho, worlds = [] }: { /** the Worlds this page opens, for the World menu's switcher */ worlds?: string[]; /** who is at this page (the account block) */ who: { local: boolean; person: string | null; admin: boolean }; onSignOut: () => void; name: string; token: string; twin?: string; tab: string; arg?: string; /** with a snapshot in view: the credential this page holds for the World it is a snapshot of, or null */ parentToken?: string | null; /** a page under a tab's argument: a changeset under Changes › Changesets */ sub?: string; admin: string | null; snapshot: Snapshot | null; onReturn: (s: Snapshot) => Promise<void>; opens: (name: string) => boolean; onRemoved: () => void; onWho?: (who: string) => void }): React.JSX.Element {
|
|
559
|
-
// the session: its scope says which controls this token may use, and its cookie is what a
|
|
566
|
+
// the session: its scope says which controls this token may use, and its cookie is what a screen reads with
|
|
560
567
|
const [scope, setScope] = useState<Scope>('pending');
|
|
561
568
|
// why the session was not opened (the wall says it, with a way to try again), and that try
|
|
562
569
|
const [sessionError, setSessionError] = useState<string | null>(null); const [attempt, setAttempt] = useState(0);
|
|
@@ -582,7 +589,7 @@ function WorldView({ name, token, twin, tab, arg, sub, admin, snapshot, parentTo
|
|
|
582
589
|
return () => { live = false; };
|
|
583
590
|
}, [name, token, attempt]);
|
|
584
591
|
/** The session as the wall shows it: opening, opened (with its scope), or refused. */
|
|
585
|
-
const session:
|
|
592
|
+
const session: ViewSession = sessionError ? { state: 'error', error: sessionError } : scope === 'pending' ? { state: 'pending' } : { state: 'ok', scope };
|
|
586
593
|
// fail closed: a token whose scope is not known (the session request failed) is shown no control it might not
|
|
587
594
|
// hold; the server refuses such writes anyway, and a read-only viewer should not be offered them
|
|
588
595
|
const canWrite = scope === 'write';
|
|
@@ -640,7 +647,7 @@ const kindOf = (t: string): string => t.replace(/^_+/, '').replace(/[_-]+/g, ' '
|
|
|
640
647
|
|
|
641
648
|
/** One changed record as a diff reads: its fields as they stood before this branch's changes (folded from its history
|
|
642
649
|
* ahead of them) and as they stand now, only those that moved; the writes that moved them listed above. */
|
|
643
|
-
function RecordDiff({ record, history
|
|
650
|
+
function RecordDiff({ record, history }: { record: ChangedRecord; history: Entry[] | null }): React.JSX.Element {
|
|
644
651
|
const head = record.writes[record.writes.length - 1]!; const s = said(head); const k = changeKind(record.writes);
|
|
645
652
|
const mine = new Set(record.writes.map((e) => e.id));
|
|
646
653
|
const first = record.writes[0]!.occurredAt;
|
|
@@ -656,7 +663,6 @@ function RecordDiff({ record, history, screens }: { record: ChangedRecord; histo
|
|
|
656
663
|
<span className={`ws-kind ${k.tone}`} title={k.word}><Icon name={k.icon} size={16} /></span>
|
|
657
664
|
<Mark vendor={record.twin} />
|
|
658
665
|
<div className="cell-label"><b>{s.label ?? shown(head)}</b><small>{vendorName(record.twin)} · {head.subject.type.replace(/^_+/, '').replace(/[_-]+/g, ' ')} <code>{shown(head)}</code> · {k.word} here</small></div>
|
|
659
|
-
{screens && k.tone !== 'removed' ? <a className="ws-on-screens" href={`${BASE}/${screens}/~/screens/${record.twin}/${head.subject.type}/${encodeURIComponent(head.subject.id)}`} onClick={(e) => { e.preventDefault(); go([...screens.split('/'), '~', 'screens', record.twin, head.subject.type, head.subject.id]); }}><Icon name="monitor" size={16} />On {vendorName(record.twin)}'s screens</a> : null}
|
|
660
666
|
</div>
|
|
661
667
|
<ol className="ws-writes">{record.writes.map((e) => <li key={e.id}><b>{said(e).what}</b> <span className="mode"><At iso={e.occurredAt} /> · {whoDid(e)}</span></li>)}</ol>
|
|
662
668
|
{history === null ? <p className="mode pad">Reading its history…</p> : rows.length === 0 ? <p className="mode pad">No field holds a new value.</p> : (
|
|
@@ -750,7 +756,7 @@ function ChangesTabs({ name, view, changes, changesets }: { name: string; view:
|
|
|
750
756
|
* then its records beside the selected one's fields before and after. Review (where it went, the checks, the approvals,
|
|
751
757
|
* and Verify, Approve and Deploy) is one button in that head, a window over it: GitHub Desktop has no review, so this
|
|
752
758
|
* one is ours. */
|
|
753
|
-
function HistoryView({ name, token, sets, sub, signsAs, canWrite, rooted, refused, onChanged, changes,
|
|
759
|
+
function HistoryView({ name, token, sets, sub, signsAs, canWrite, rooted, refused, onChanged, changes, withScreens, required }: { name: string; token: string; sets: Load<{ changesets: ChangesetView[] }>; sub?: string; /** records not in a changeset, the Changes tab's count */ changes: number; /** the vendors with screens */ withScreens: string[]; /** approvals this World requires before a changeset deploys */ required: number; signsAs: string | null; canWrite: boolean; rooted: string[]; refused: (r: Response) => void; onChanged: () => void }): React.JSX.Element {
|
|
754
760
|
const [picked, setPicked] = useState<string | null>(null); const [reviewing, setReviewing] = useState(false);
|
|
755
761
|
const list = sets.state === 'ok' ? [...sets.data.changesets].sort((a, b) => b.createdAt.localeCompare(a.createdAt)) : [];
|
|
756
762
|
const c = list.find((x) => x.name === sub) ?? list[0];
|
|
@@ -795,7 +801,7 @@ function HistoryView({ name, token, sets, sub, signsAs, canWrite, rooted, refuse
|
|
|
795
801
|
</div>
|
|
796
802
|
<div className="ws-two inner">
|
|
797
803
|
<div className="ws-files"><RecordList records={records} {...(record ? { selected: record.key } : {})} onPick={setPicked} label="Records it changed" /></div>
|
|
798
|
-
<div className="ws-detail">{record ? <RecordDiff record={record} history={log.state === 'ok' ? log.data.entries : log.state === 'error' ? [] : null}
|
|
804
|
+
<div className="ws-detail">{record ? <RecordDiff record={record} history={log.state === 'ok' ? log.data.entries : log.state === 'error' ? [] : null} /> : <p className="mode pad">It changes nothing.</p>}</div>
|
|
799
805
|
</div>
|
|
800
806
|
<Dialog open={reviewing} onClose={() => setReviewing(false)} title={`Review: ${titleOf(c)}`} wide>
|
|
801
807
|
<ChangesetPage key={`h:${c.name}`} name={name} token={token} signsAs={signsAs} c={c} canWrite={canWrite} rooted={rooted} refused={refused} onChanged={onChanged} part="head" required={required} />
|
|
@@ -904,7 +910,7 @@ function TraceView({ name, token, id }: { name: string; token: string; id: strin
|
|
|
904
910
|
|
|
905
911
|
/** A VENDOR'S PLACE, as TablePlus opens a database: what it stores by type down the left, the chosen type's records as
|
|
906
912
|
* rows, the selected record's fields at the right; its own screens and its events a click away. */
|
|
907
|
-
function VendorView({ name, token, vendor,
|
|
913
|
+
function VendorView({ name, token, vendor, withScreens, tick }: { name: string; token: string; vendor: string; withScreens: boolean; tick: number }): React.JSX.Element {
|
|
908
914
|
const tree = useDoor<{ tree: Array<Record<string, unknown> & { type: string; id: string }> }>(token, `/-/${name}/checkpoint/${vendor}`, Math.floor(tick / 6));
|
|
909
915
|
const rows = tree.state === 'ok' ? tree.data.tree.filter((r) => r.deleted !== true) : [];
|
|
910
916
|
const types = [...new Set(rows.map((r) => r.type))].map((t) => ({ type: t, count: rows.filter((r) => r.type === t).length })).sort((a, b) => Number(a.type.startsWith('_')) - Number(b.type.startsWith('_')) || kindOf(a.type).localeCompare(kindOf(b.type)));
|
|
@@ -927,7 +933,7 @@ function VendorView({ name, token, vendor, mirrored, tick }: { name: string; tok
|
|
|
927
933
|
{types.map((t) => <a key={t.type} href={`#${t.type}`} className={shownType === t.type ? 'on' : undefined} aria-current={shownType === t.type ? 'true' : undefined} onClick={(e) => { e.preventDefault(); setType(t.type); setPicked(null); }}><Icon name="table-2" size={16} /><span>{kindOf(t.type)}</span><span className="count">{t.count}</span></a>)}
|
|
928
934
|
<p className="menu-label">Also</p>
|
|
929
935
|
<a href={`${BASE}/${name}/~/events/${vendor}`} onClick={(e) => { e.preventDefault(); go([...name.split('/'), '~', 'events', vendor]); }}><Icon name="activity" size={16} /><span>Its events</span></a>
|
|
930
|
-
{
|
|
936
|
+
{withScreens ? <a href={`${BASE}/${name}/~/screens/${vendor}`} onClick={(e) => { e.preventDefault(); go([...name.split('/'), '~', 'screens', vendor]); }}><Icon name="monitor" size={16} /><span>Its screens</span></a> : null}
|
|
931
937
|
</nav>
|
|
932
938
|
<div className="ws-grid-wrap">
|
|
933
939
|
{tree.state === 'ok' && types.length === 0 ? <Empty title={`${vendorName(vendor)} stores nothing yet`}><p>What your app makes there shows here, by type.</p></Empty> : null}
|
|
@@ -957,21 +963,22 @@ function VendorView({ name, token, vendor, mirrored, tick }: { name: string; tok
|
|
|
957
963
|
);
|
|
958
964
|
}
|
|
959
965
|
|
|
960
|
-
/** A VENDOR'S SCREENS: its own site over this World's state (
|
|
966
|
+
/** A VENDOR'S SCREENS: its own site over this World's state (its pack's workspaces), the whole pane; what it holds and the
|
|
961
967
|
* way to its records or a tab of its own above it. */
|
|
962
|
-
function ScreenView({ name, vendor, holding, session, retry
|
|
968
|
+
function ScreenView({ name, vendor, url, mapError, holding, session, retry }: { name: string; vendor: string; /** where its screens are: null when its pack lists none, undefined while the World's map is read */ url: string | null | undefined; /** the World's map did not answer */ mapError?: string; holding: string; session: ViewSession; retry: () => void }): React.JSX.Element {
|
|
963
969
|
return (
|
|
964
970
|
<div className="ws-screen">
|
|
965
971
|
<div className="ws-screen-bar">
|
|
966
972
|
<span className="vendor-cell"><Mark vendor={vendor} /><b>{vendorName(vendor)}</b></span>
|
|
967
973
|
<span className="mode">{holding}</span>
|
|
968
974
|
<a href={`${BASE}/${name}/~/vendors/${vendor}`} onClick={(e) => { e.preventDefault(); go([...name.split('/'), '~', 'vendors', vendor]); }}><Icon name="table-2" size={16} />Its records</a>
|
|
969
|
-
<a href={
|
|
975
|
+
{url ? <a href={url} target="_blank" rel="noopener noreferrer"><Icon name="external-link" size={16} />New tab</a> : null}
|
|
970
976
|
</div>
|
|
971
977
|
{session.state === 'ok' && session.scope === 'read' ? <p className="mode ws-screens-note">You are looking read only: the screens browse, and refuse changes.</p> : null}
|
|
972
|
-
{session.state === 'pending' ? <p className="mode ws-screens-note" role="status">Opening…</p> : null}
|
|
978
|
+
{mapError ? <p role="alert" className="ws-screens-note">The World did not say where its screens are: {mapError}</p> : session.state === 'pending' || url === undefined ? <p className="mode ws-screens-note" role="status">Opening…</p> : null}
|
|
973
979
|
{session.state === 'error' ? <p role="alert" className="ws-screens-note">The World did not open its screens: {session.error} <button type="button" onClick={retry}>Try again</button></p> : null}
|
|
974
|
-
{session.state === 'ok' ? <
|
|
980
|
+
{session.state === 'ok' && url === null ? <p className="mode ws-screens-note">{vendorName(vendor)}'s pack lists no screens.</p> : null}
|
|
981
|
+
{session.state === 'ok' && url ? <iframe key={vendor} title={`${vendorName(vendor)}, as this World shows it`} src={url} /> : null}
|
|
975
982
|
</div>
|
|
976
983
|
);
|
|
977
984
|
}
|
|
@@ -1077,7 +1084,7 @@ function AccountPane({ who, scope, snapshot, portal: platform, onSignOut }: { wh
|
|
|
1077
1084
|
* left, where this is in its middle (the World, the branch, the origin and what waits for it, the World's clock), Pull,
|
|
1078
1085
|
* Push and Settings at its right. A sidebar of places (Changes, History, Events; the vendors; the remote) beside the place
|
|
1079
1086
|
* chosen. Settings and Branches open as windows over it, as GitHub Desktop's do. */
|
|
1080
|
-
function Workspace({ name, token, view, sub, arg, win, who, snapshot, onReturn, onSignOut, worlds, session, retry, twins, map, branches, clock, canWrite, refused, signsAs, opens, tick, admin, onRemoved, onClock, scope }: { name: string; token: string; view: WorkspaceView; sub?: string; arg?: string; win: WorkspaceWindow; /** who is here: this machine, a person signed in, the host's admin, a link or key */ who: { local: boolean; person: string | null; admin: boolean }; /** a snapshot in view: read only, with the way back to live */ snapshot: Snapshot | null; onReturn: (s: Snapshot) => Promise<void>; onSignOut: () => void; /** the Worlds this page opens (the World menu's switcher) */ worlds: string[]; /** the World's session, which vendors' screens read with, and its retry */ session:
|
|
1087
|
+
function Workspace({ name, token, view, sub, arg, win, who, snapshot, onReturn, onSignOut, worlds, session, retry, twins, map, branches, clock, canWrite, refused, signsAs, opens, tick, admin, onRemoved, onClock, scope }: { name: string; token: string; view: WorkspaceView; sub?: string; arg?: string; win: WorkspaceWindow; /** who is here: this machine, a person signed in, the host's admin, a link or key */ who: { local: boolean; person: string | null; admin: boolean }; /** a snapshot in view: read only, with the way back to live */ snapshot: Snapshot | null; onReturn: (s: Snapshot) => Promise<void>; onSignOut: () => void; /** the Worlds this page opens (the World menu's switcher) */ worlds: string[]; /** the World's session, which vendors' screens read with, and its retry */ session: ViewSession; retry: () => void; twins: Load<{ twins: Twin[] }>; map: Load<WorldMap>; branches: Load<BranchesDoor> | null; clock: Load<Clock>; canWrite: boolean; refused: (r: Response) => void; signsAs: string | null; opens: (name: string) => boolean; tick: number; admin: string | null; onRemoved: () => void; onClock: () => void; scope: Scope }): React.JSX.Element {
|
|
1081
1088
|
const remote = usePushPull(name, token, tick, refused);
|
|
1082
1089
|
const [version, setVersion] = useState(0);
|
|
1083
1090
|
const every = version * 100_000 + Math.floor(tick / 2);
|
|
@@ -1087,7 +1094,8 @@ function Workspace({ name, token, view, sub, arg, win, who, snapshot, onReturn,
|
|
|
1087
1094
|
const [picked, setPicked] = useState<string | null>(null);
|
|
1088
1095
|
const to = (parts: string[]) => (e?: React.MouseEvent): void => { e?.preventDefault(); go([...name.split('/'), '~', ...parts]); };
|
|
1089
1096
|
const vendors = twins.state === 'ok' ? twins.data.twins.map((t) => t.twin) : [];
|
|
1090
|
-
const
|
|
1097
|
+
const screens = new Map((map.state === 'ok' ? map.data.twins : []).flatMap((t) => { const at = screenUrl(name, t); return at ? [[t.twin, at] as const] : []; }));
|
|
1098
|
+
const withScreens = [...screens.keys()];
|
|
1091
1099
|
const rooted = map.state === 'ok' ? map.data.twins.filter((t) => t.root).map((t) => t.twin) : [];
|
|
1092
1100
|
const held = (v: string): number => (map.state === 'ok' ? (map.data.twins.find((x) => x.twin === v)?.resources ?? []).reduce((n, r) => n + r.count, 0) : 0);
|
|
1093
1101
|
const served = (branches?.state === 'ok' && branches.data.checkedOut) || name.split('/')[1]!;
|
|
@@ -1176,11 +1184,11 @@ function Workspace({ name, token, view, sub, arg, win, who, snapshot, onReturn,
|
|
|
1176
1184
|
<div className="ws-body">
|
|
1177
1185
|
<nav className="ws-side" aria-label={`${name} places`}>
|
|
1178
1186
|
{/* the screens: one way in, saying how many there are and whose; the canvas lists them along its left */}
|
|
1179
|
-
{
|
|
1180
|
-
<a href={`${BASE}/${name}/~/screens`} className={view === 'screen' ? 'ws-screens-entry on' : 'ws-screens-entry'} onClick={to(['screens'])} title={`${counted(
|
|
1187
|
+
{withScreens.length ? (
|
|
1188
|
+
<a href={`${BASE}/${name}/~/screens`} className={view === 'screen' ? 'ws-screens-entry on' : 'ws-screens-entry'} onClick={to(['screens'])} title={`${counted(withScreens.length, 'screen')}: ${listed(withScreens.map(vendorName))}`}>
|
|
1181
1189
|
<Icon name="layout-grid" size={16} /><span>Screens</span>
|
|
1182
|
-
<span className="ws-screens-marks">{
|
|
1183
|
-
<span className="count">{
|
|
1190
|
+
<span className="ws-screens-marks">{withScreens.slice(0, 3).map((v) => <Mark key={v} vendor={v} />)}{withScreens.length > 3 ? <span className="more">+{withScreens.length - 3}</span> : null}</span>
|
|
1191
|
+
<span className="count">{withScreens.length}</span>
|
|
1184
1192
|
</a>
|
|
1185
1193
|
) : null}
|
|
1186
1194
|
<p className="side-group">Workspace</p>
|
|
@@ -1207,16 +1215,16 @@ function Workspace({ name, token, view, sub, arg, win, who, snapshot, onReturn,
|
|
|
1207
1215
|
<p>What your app writes to this World lands here, record by record, to cut into a changeset. Everything since {diff.data.base.at ? <At iso={diff.data.base.at} /> : <code>{diff.data.base.id}</code>} is in one already.</p>
|
|
1208
1216
|
</Empty>
|
|
1209
1217
|
) : null}
|
|
1210
|
-
{record ? <RecordDiff record={record} history={log.state === 'ok' ? log.data.entries : log.state === 'error' ? [] : null}
|
|
1218
|
+
{record ? <RecordDiff record={record} history={log.state === 'ok' ? log.data.entries : log.state === 'error' ? [] : null} /> : null}
|
|
1211
1219
|
</div>
|
|
1212
1220
|
<CutChangeset pinned generated={rules.state === 'ok' && rules.data.changesets.summary === 'generated'} author={{ face: <Icon name={who.person ? 'user' : who.local ? 'monitor' : 'key-round'} size={18} />, who: who.person ?? (who.local ? 'This machine' : who.admin ? "The host's admin" : 'A token'), account: `${BASE}/${name}/~/settings/account`, branch: served }} name={name} token={token} changes={writes} canWrite={canWrite} refused={refused} onCut={() => { setVersion((v) => v + 1); setPicked(null); }} />
|
|
1213
1221
|
</div>
|
|
1214
1222
|
) : null}
|
|
1215
|
-
{view === 'history' ? <HistoryView name={name} token={token} sets={sets} changes={records.length}
|
|
1223
|
+
{view === 'history' ? <HistoryView name={name} token={token} sets={sets} changes={records.length} withScreens={withScreens} required={rules.state === 'ok' ? rules.data.changesets.approvals ?? 1 : 1} {...(sub ? { sub } : {})} signsAs={signsAs} canWrite={canWrite} rooted={rooted} refused={refused} onChanged={() => setVersion((v) => v + 1)} /> : null}
|
|
1216
1224
|
{view === 'events' ? <EventsView key={arg ?? ''} name={name} token={token} vendors={vendors} {...(arg && vendors.includes(arg) ? { vendor: arg } : {})} tick={tick} /> : null}
|
|
1217
1225
|
{view === 'trace' ? <TraceView name={name} token={token} id={arg ?? ''} /> : null}
|
|
1218
|
-
{view === 'vendor' && arg ? <VendorView key={arg} name={name} token={token} vendor={arg}
|
|
1219
|
-
{view === 'screen' && arg ? <ScreenView key={arg} name={name} vendor={arg} {...(
|
|
1226
|
+
{view === 'vendor' && arg ? <VendorView key={arg} name={name} token={token} vendor={arg} withScreens={withScreens.includes(arg)} tick={tick} /> : null}
|
|
1227
|
+
{view === 'screen' && arg ? <ScreenView key={arg} name={name} vendor={arg} url={map.state === 'ok' ? screens.get(arg) ?? null : undefined} {...(map.state === 'error' ? { mapError: map.error } : {})} holding={holds(map.state === 'ok' ? map.data.twins.find((x) => x.twin === arg)?.resources ?? [] : [])} session={session} retry={retry} /> : null}
|
|
1220
1228
|
</div>
|
|
1221
1229
|
</div>
|
|
1222
1230
|
|
|
@@ -1661,15 +1669,15 @@ type RequestReport = { hours: number; requests: number; journalFailures?: number
|
|
|
1661
1669
|
|
|
1662
1670
|
|
|
1663
1671
|
|
|
1664
|
-
type
|
|
1672
|
+
type ViewSession = { state: 'pending' } | { state: 'ok'; scope: string } | { state: 'error'; error: string };
|
|
1665
1673
|
/** THE CANVAS: every vendor's own screens as frames on one surface, the way a canvas app holds its frames (React
|
|
1666
1674
|
* Flow; Figma's and tldraw's feel). Pan by dragging the background, zoom with the wheel or the controls, move a frame
|
|
1667
1675
|
* by its title bar (kept in this browser), click a frame to use that vendor's screens and the background or Esc to
|
|
1668
1676
|
* leave it, so a frame never captures the pan. A frame is its vendor's site at a desktop's size. */
|
|
1669
1677
|
const FRAME_W = 1280; const FRAME_H = 820; const FRAME_GAP = 160;
|
|
1670
|
-
type Screen = { world: string; vendor: string; active: boolean; open: (vendor: string) => void; /** its records changed and not yet cut: the screen's mark, as a changed file's */ changed: number };
|
|
1678
|
+
type Screen = { world: string; vendor: string; url: string; active: boolean; open: (vendor: string) => void; /** its records changed and not yet cut: the screen's mark, as a changed file's */ changed: number };
|
|
1671
1679
|
function ScreenFrame({ data }: NodeProps<Node<Screen>>): React.JSX.Element {
|
|
1672
|
-
const { world, vendor, active, open, changed } = data;
|
|
1680
|
+
const { world, vendor, url, active, open, changed } = data;
|
|
1673
1681
|
return (
|
|
1674
1682
|
<>
|
|
1675
1683
|
<NodeToolbar isVisible position={Position.Top} align="start" className="frame-label">
|
|
@@ -1679,24 +1687,24 @@ function ScreenFrame({ data }: NodeProps<Node<Screen>>): React.JSX.Element {
|
|
|
1679
1687
|
<div className={active ? 'frame on' : 'frame'} style={{ width: FRAME_W, height: FRAME_H }}>
|
|
1680
1688
|
<div className="frame-bar" title="Drag to move">
|
|
1681
1689
|
<Mark vendor={vendor} /><strong>{vendorName(vendor)}</strong>
|
|
1682
|
-
<span className="frame-url">{
|
|
1690
|
+
<span className="frame-url">{url.replace(/^https?:\/\//, '')}</span>
|
|
1683
1691
|
<a className="nodrag" href={`${BASE}/${world}/~/screens/${vendor}`} onClick={(e) => { e.preventDefault(); open(vendor); }}>Open</a>
|
|
1684
|
-
<a className="nodrag" href={
|
|
1692
|
+
<a className="nodrag" href={url} target="_blank" rel="noopener noreferrer" aria-label={`Open ${vendorName(vendor)} in a new tab`}>↗</a>
|
|
1685
1693
|
</div>
|
|
1686
1694
|
<div className={active ? 'frame-body nodrag nopan nowheel' : 'frame-body'}>
|
|
1687
|
-
<iframe title={`${vendor}
|
|
1695
|
+
<iframe title={`${vendor} screens`} src={url} loading="lazy" style={{ pointerEvents: active ? 'auto' : 'none' }} />
|
|
1688
1696
|
</div>
|
|
1689
1697
|
</div>
|
|
1690
1698
|
</>
|
|
1691
1699
|
);
|
|
1692
1700
|
}
|
|
1693
1701
|
const FRAME_TYPES = { screen: ScreenFrame };
|
|
1694
|
-
function Canvas(props: { name: string; token: string; twins: Load<{ twins: Twin[] }>; session:
|
|
1702
|
+
function Canvas(props: { name: string; token: string; twins: Load<{ twins: Twin[] }>; session: ViewSession; retry: () => void; snapshot: Snapshot | null; back: () => void }): React.JSX.Element {
|
|
1695
1703
|
return <ReactFlowProvider><CanvasSurface {...props} /></ReactFlowProvider>;
|
|
1696
1704
|
}
|
|
1697
1705
|
/** 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
|
|
1698
1706
|
* left (a canvas app's layers), zoom at the bottom left, the minimap at the bottom right. */
|
|
1699
|
-
function CanvasSurface({ name, token, twins, session, retry, snapshot, back }: { name: string; token: string; twins: Load<{ twins: Twin[] }>; session:
|
|
1707
|
+
function CanvasSurface({ name, token, twins, session, retry, snapshot, back }: { name: string; token: string; twins: Load<{ twins: Twin[] }>; session: ViewSession; retry: () => void; snapshot: Snapshot | null; back: () => void }): React.JSX.Element {
|
|
1700
1708
|
// each screen's changes not yet cut, by vendor: the records, as Changes counts them
|
|
1701
1709
|
const diff = useDoor<Diff>(token, `/-/${name}/diff`);
|
|
1702
1710
|
const changed = useMemo((): Record<string, number> => {
|
|
@@ -1705,7 +1713,8 @@ function CanvasSurface({ name, token, twins, session, retry, snapshot, back }: {
|
|
|
1705
1713
|
return Object.fromEntries(Object.entries(by).map(([v, rs]) => [v, rs.size]));
|
|
1706
1714
|
}, [diff]);
|
|
1707
1715
|
const changedAt = (v: string): number => changed[v] ?? 0;
|
|
1708
|
-
const
|
|
1716
|
+
const urls = new Map((twins.state === 'ok' ? twins.data.twins : []).flatMap((t) => { const at = screenUrl(name, t); return at ? [[t.twin, at] as const] : []; }));
|
|
1717
|
+
const frames = [...urls.keys()];
|
|
1709
1718
|
const saved = `volter.canvas.${name}`;
|
|
1710
1719
|
const [active, setActive] = useState<string | null>(null);
|
|
1711
1720
|
const [layers, setLayers] = useState(true);
|
|
@@ -1715,7 +1724,7 @@ function CanvasSurface({ name, token, twins, session, retry, snapshot, back }: {
|
|
|
1715
1724
|
const placed = useCallback((): Array<Node<Screen>> => {
|
|
1716
1725
|
let kept: Record<string, { x: number; y: number }> = {};
|
|
1717
1726
|
try { kept = JSON.parse(localStorage.getItem(saved) ?? '{}') as Record<string, { x: number; y: number }>; } catch { /* a convenience */ }
|
|
1718
|
-
return frames.map((v, i) => ({ id: v, type: 'screen', dragHandle: '.frame-bar', position: kept[v] ?? { x: (i % cols) * (FRAME_W + FRAME_GAP), y: Math.floor(i / cols) * (FRAME_H + FRAME_GAP) }, data: { world: name, vendor: v, active: false, open, changed: changed[v] ?? 0 } }));
|
|
1727
|
+
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, url: urls.get(v)!, active: false, open, changed: changed[v] ?? 0 } }));
|
|
1719
1728
|
// biome-ignore lint/correctness/useExhaustiveDependencies: the frames' names are what change it
|
|
1720
1729
|
}, [frames.join(','), name, cols, open, changed]);
|
|
1721
1730
|
const [nodes, setNodes, onNodesChange] = useNodesState<Node<Screen>>([]);
|
|
@@ -2237,6 +2246,7 @@ function Vault({ name, token, canWrite, refused }: { name: string; token: string
|
|
|
2237
2246
|
<section className="block" aria-labelledby="links-head">
|
|
2238
2247
|
<div className="block-head"><h3 id="links-head">Links</h3><span className="mode">a vendor with no twin, forwarded with its sealed credential</span></div>
|
|
2239
2248
|
<Pending load={links} what="the links" />
|
|
2249
|
+
{report.state === 'ok' && (report.data.journalFailures ?? 0) > 0 ? <p className="mode pad" role="alert">{report.data.journalFailures} request {report.data.journalFailures === 1 ? 'line was' : 'lines were'} not kept by this World's server since it started (its links, and the twins it runs in its own process): those requests are missing from what this page counts.</p> : null}
|
|
2240
2250
|
{links.state === 'ok' && linkNames.length === 0 ? <p className="mode pad">No links. A link forwards requests for a vendor with no twin here, at <code>{WORLDS}/{name}/<link>/…</code>, to that vendor's real API with the credential sealed for it.</p> : null}
|
|
2241
2251
|
{linkNames.length ? (
|
|
2242
2252
|
<table className="dense" aria-label="Links">
|
package/client/main.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// The console's page, mounted: the host serves this entry (src/console.ts)
|
|
1
|
+
// The console's page, mounted: the host serves this entry (src/console.ts).
|
|
2
2
|
import React from 'react';
|
|
3
3
|
import { createRoot } from 'react-dom/client';
|
|
4
4
|
import { applyTheme } from './kit/kit.tsx';
|