portal-mojo 0.2.20 → 0.2.21

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.
Files changed (131) hide show
  1. package/docs/README.md +7 -1
  2. package/docs/admin-deployments.md +201 -0
  3. package/docs/admin-dns-certificates.md +3 -3
  4. package/docs/admin-dns-domains.md +92 -0
  5. package/docs/admin-dns.md +4 -2
  6. package/docs/admin-edge.md +200 -196
  7. package/docs/admin-geolocation.md +100 -0
  8. package/docs/admin-maintenance.md +133 -0
  9. package/docs/admin-modals.md +17 -2
  10. package/docs/admin-operational-fleet.md +206 -0
  11. package/docs/admin-presentation.md +51 -0
  12. package/docs/admin-signin.md +12 -2
  13. package/docs/client.md +7 -0
  14. package/docs/combobox.md +18 -0
  15. package/docs/popover.md +5 -4
  16. package/package.json +1 -1
  17. package/src/admin/core/OperationsHelp.tsx +10 -0
  18. package/src/admin/core/index.ts +6 -4
  19. package/src/admin/core/route-context.ts +6 -0
  20. package/src/admin/deployments/ApiDeploymentDetail.tsx +58 -0
  21. package/src/admin/deployments/DeploymentsPage.tsx +73 -0
  22. package/src/admin/deployments/WebAppDeploySetup.tsx +49 -0
  23. package/src/admin/deployments/WebAppDeploymentDetail.tsx +59 -0
  24. package/src/admin/deployments/WebAppDetail.tsx +104 -0
  25. package/src/admin/deployments/WebAppEditDialog.tsx +70 -0
  26. package/src/admin/deployments/WebAppKeyDialog.tsx +25 -0
  27. package/src/admin/deployments/WebAppOnboardingDialog.tsx +217 -0
  28. package/src/admin/deployments/WebAppRollbackDialog.tsx +82 -0
  29. package/src/admin/deployments/WebAppUploadPanel.tsx +132 -0
  30. package/src/admin/deployments/api.ts +158 -0
  31. package/src/admin/deployments/authority-state.ts +9 -0
  32. package/src/admin/deployments/deployment-state.ts +13 -0
  33. package/src/admin/deployments/index.ts +4 -0
  34. package/src/admin/deployments/onboarding-state.ts +11 -0
  35. package/src/admin/deployments/permissions.ts +37 -0
  36. package/src/admin/deployments/receipts.ts +78 -0
  37. package/src/admin/deployments/sanitize.ts +73 -0
  38. package/src/admin/deployments/shared.tsx +39 -0
  39. package/src/admin/deployments/types.ts +238 -0
  40. package/src/admin/deployments/upload.ts +53 -0
  41. package/src/admin/deployments/useDeploymentObservation.ts +104 -0
  42. package/src/admin/dns/DomainDetail.tsx +10 -6
  43. package/src/admin/dns/DomainOnboardingContent.tsx +44 -0
  44. package/src/admin/dns/DomainOnboardingDialog.tsx +5 -24
  45. package/src/admin/dns/DomainPurchaseWizard.tsx +1 -1
  46. package/src/admin/dns/DomainRegisterExisting.tsx +63 -0
  47. package/src/admin/dns/DomainRegistrarSettings.tsx +52 -0
  48. package/src/admin/dns/DomainsPage.tsx +22 -13
  49. package/src/admin/dns/SharedValidationDialog.tsx +1 -1
  50. package/src/admin/dns/api.ts +4 -2
  51. package/src/admin/dns/domain-navigation.ts +16 -0
  52. package/src/admin/dns/registrar-api.ts +13 -0
  53. package/src/admin/domains/assistant.ts +5 -4
  54. package/src/admin/domains/communications.ts +10 -9
  55. package/src/admin/domains/identity.ts +8 -7
  56. package/src/admin/domains/infrastructure.ts +19 -13
  57. package/src/admin/domains/lazy-page.ts +6 -0
  58. package/src/admin/domains/observability.ts +6 -5
  59. package/src/admin/domains/operations.ts +22 -7
  60. package/src/admin/domains/security.ts +16 -15
  61. package/src/admin/edge/EdgeHelp.tsx +10 -0
  62. package/src/admin/edge/ServingReadiness.tsx +54 -0
  63. package/src/admin/edge/VhostDetail.tsx +145 -139
  64. package/src/admin/edge/VhostWizard.tsx +204 -115
  65. package/src/admin/edge/VhostsPage.tsx +27 -28
  66. package/src/admin/edge/api.ts +24 -10
  67. package/src/admin/edge/models.ts +4 -4
  68. package/src/admin/edge/serving-data.ts +78 -0
  69. package/src/admin/fleet/CapacityEditor.tsx +81 -0
  70. package/src/admin/fleet/CapacityInspector.tsx +19 -0
  71. package/src/admin/fleet/CapacityProgress.tsx +9 -0
  72. package/src/admin/fleet/FleetHelp.tsx +7 -0
  73. package/src/admin/fleet/FleetOverview.tsx +150 -0
  74. package/src/admin/fleet/FleetPage.tsx +11 -0
  75. package/src/admin/fleet/FleetWorkspace.tsx +82 -0
  76. package/src/admin/fleet/ScaleFleetDialog.tsx +169 -0
  77. package/src/admin/fleet/access.ts +22 -0
  78. package/src/admin/fleet/api.ts +77 -0
  79. package/src/admin/fleet/data.ts +148 -0
  80. package/src/admin/fleet/index.ts +8 -0
  81. package/src/admin/fleet/operations.ts +179 -0
  82. package/src/admin/fleet/standalone-data.ts +87 -0
  83. package/src/admin/fleet/types.ts +208 -0
  84. package/src/admin/geolocation/GeoLocationPage.tsx +171 -0
  85. package/src/admin/geolocation/data.ts +138 -0
  86. package/src/admin/geolocation/index.ts +3 -0
  87. package/src/admin/identity/members/GroupMembersPanel.tsx +2 -8
  88. package/src/admin/index.ts +8 -0
  89. package/src/admin/maintenance/MaintenanceDialogs.tsx +161 -0
  90. package/src/admin/maintenance/MaintenancePage.tsx +442 -0
  91. package/src/admin/maintenance/data.ts +486 -0
  92. package/src/admin/maintenance/index.ts +3 -0
  93. package/src/admin/maintenance/operations.ts +208 -0
  94. package/src/admin/public/infrastructure.ts +1 -0
  95. package/src/admin/public/operations.ts +3 -0
  96. package/src/admin/registry.ts +6 -2
  97. package/src/admin/settings/SettingDetail.tsx +7 -2
  98. package/src/admin/settings/SettingEditor.tsx +2 -1
  99. package/src/admin/settings/SettingsPage.tsx +3 -1
  100. package/src/admin/settings/model.ts +1 -0
  101. package/src/admin/signin/SigninPage.tsx +17 -3
  102. package/src/admin/signin/api.ts +2 -0
  103. package/src/charts/MetricsChartView.tsx +4 -11
  104. package/src/charts/MetricsMiniWidget.tsx +3 -15
  105. package/src/client/client.ts +6 -0
  106. package/src/client/deployments-mock.ts +375 -0
  107. package/src/client/domain-operations-mock.ts +37 -0
  108. package/src/client/geo-provider-mock.ts +82 -0
  109. package/src/client/maintenance-mock.ts +285 -0
  110. package/src/client/mock.ts +52 -10
  111. package/src/client/operational-fleet-mock.ts +279 -0
  112. package/src/client/operations-mock.ts +85 -0
  113. package/src/client/serving-mock.ts +22 -0
  114. package/src/styles/components/admin-deployments.css +35 -0
  115. package/src/styles/components/admin-domains.css +12 -0
  116. package/src/styles/components/admin-edge.css +125 -1
  117. package/src/styles/components/admin-geolocation.css +40 -0
  118. package/src/styles/components/admin-maintenance.css +127 -0
  119. package/src/styles/components/admin-operational-fleet.css +38 -0
  120. package/src/styles/components/admin-operations.css +9 -0
  121. package/src/styles/components/admin-signin.css +2 -0
  122. package/src/styles/components/combobox.css +12 -2
  123. package/src/styles/components/fleet-page.css +9 -0
  124. package/src/styles/index.css +7 -0
  125. package/src/ui/ComboBox.tsx +39 -8
  126. package/src/ui/FilterBar.tsx +3 -5
  127. package/src/ui/ModelTable.tsx +2 -8
  128. package/src/ui/Popover.tsx +15 -7
  129. package/src/ui/date/TimePicker.tsx +4 -14
  130. package/src/ui/image-editor/ImageEditor.tsx +4 -5
  131. package/src/ui/schema-form-core.tsx +3 -5
@@ -0,0 +1,54 @@
1
+ import { useEffect, useRef, useState } from 'react';
2
+ import { getAuthSessionRevision, getAuthSnapshot, useAuthSnapshot, useMe } from '../../client/runtime';
3
+ import { Badge, FlatRow, modal } from '../../ui';
4
+ import { EdgeHelp } from './EdgeHelp';
5
+ import { datetime } from '../../ui/format';
6
+ import { backendGlobalCan, FLEET_DASHBOARD_GRANTS } from '../fleet/access';
7
+ import { readHostingReadiness, readServingBootstrap, type HostingReadiness, type ServingBootstrap } from './serving-data';
8
+
9
+ /** Read-only evidence, deliberately outside Query: raw bootstrap never enters a cache. */
10
+ export function ServingReadiness({ refreshRevision = 0 }: { refreshRevision?: number }) {
11
+ const auth = useAuthSnapshot(), { data: me } = useMe();
12
+ const scope = auth.authenticated && auth.uid && me?.id === Number(auth.uid) ? `${auth.uid}:${getAuthSessionRevision()}` : null;
13
+ const admitted = !!scope && backendGlobalCan(me, ['view_dns', 'manage_dns', 'security']);
14
+ const bootstrapAllowed = admitted && backendGlobalCan(me, FLEET_DASHBOARD_GRANTS);
15
+ const [refresh, setRefresh] = useState(0);
16
+ const [state, setState] = useState<{ scope: string | null; busy: boolean; bootstrap?: ServingBootstrap; report?: HostingReadiness; error?: string; reportError?: string }>({ scope: null, busy: false });
17
+ const flight = useRef(0);
18
+ const owner = useRef<AbortController | null>(null);
19
+ const evidenceAuthority = `${scope}:${admitted}:${bootstrapAllowed}:${me?.is_superuser === true}:${state.scope === scope && state.bootstrap?.user.is_superuser === true}:${state.scope === scope && state.bootstrap?.capabilities.setup === true}`;
20
+ useEffect(() => { const controller = new AbortController(); owner.current = controller; return () => controller.abort(); }, [evidenceAuthority]);
21
+ useEffect(() => {
22
+ const serial = ++flight.current, controller = new AbortController();
23
+ const current = () => !controller.signal.aborted && flight.current === serial && getAuthSnapshot().authenticated && `${getAuthSnapshot().uid}:${getAuthSessionRevision()}` === scope;
24
+ setState({ scope, busy: bootstrapAllowed });
25
+ if (!bootstrapAllowed || !me || !scope) return () => controller.abort();
26
+ const userId = me.id, superuser = me.is_superuser === true;
27
+ void (async () => {
28
+ try {
29
+ const bootstrap = await readServingBootstrap(userId, controller.signal);
30
+ if (!current()) return;
31
+ setState({ scope, busy: superuser && bootstrap.user.is_superuser && bootstrap.capabilities.setup, bootstrap });
32
+ if (superuser && bootstrap.user.is_superuser && bootstrap.capabilities.setup === true) {
33
+ try {
34
+ const report = await readHostingReadiness(controller.signal);
35
+ if (current()) setState({ scope, busy: false, bootstrap, report });
36
+ } catch (error) {
37
+ if (current()) setState({ scope, busy: false, bootstrap, reportError: error instanceof Error ? error.message : 'Readiness observation failed.' });
38
+ }
39
+ }
40
+ } catch (error) {
41
+ if (current()) setState({ scope, busy: false, error: error instanceof Error ? error.message : 'Node posture observation failed.' });
42
+ }
43
+ })();
44
+ return () => controller.abort();
45
+ }, [scope, bootstrapAllowed, me?.id, me?.is_superuser, refresh, refreshRevision]);
46
+ if (!bootstrapAllowed) return null;
47
+ const visible: typeof state = state.scope === scope ? state : { scope, busy: false };
48
+ const edge = visible.bootstrap?.edge, report = visible.report, selected = report?.sections.find(section => section.code === 'hosting_vhosts');
49
+ const unavailable = visible.error;
50
+ const showDetails = () => modal.open(close => <div className="modal-pad edge-check-dialog"><h2 className="modal-title">Serving configuration checks</h2><div className="edge-check-body"><FlatRow label="Public HTTP">{edge?.available && edge.http_enabled !== null ? edge.http_enabled ? 'Enabled' : 'Disabled' : 'Unknown'}</FlatRow><FlatRow label="Certificate issuance">{edge?.available && edge.dnsman_issuance === 'dns-01' ? 'DNS-01' : 'Unknown'}</FlatRow>{unavailable && <p role="status">Unavailable: {unavailable}</p>}
51
+ {selected && report ? <><FlatRow label="Generated"><time dateTime={report.generated_at}>{datetime(report.generated_at)}</time></FlatRow><FlatRow label="Coverage">{selected.coverage.returned} of {selected.coverage.total} checks · {selected.coverage.omitted} omitted</FlatRow><FlatRow label="Report status">{report.overall} · {report.summary.pass} pass · {report.summary.warn} warn · {report.summary.fail} fail · {report.summary.pending} pending</FlatRow>{(report.truncated || selected.coverage.omitted > 0 || report.coverage.checks.omitted > 0 || report.coverage.sections.omitted > 0) && <p className="text-warn">Partial report · some checks are omitted.</p>}{selected.checks.map((check, index) => <div key={`${check.code}:${index}`} className="edge-serving-check"><b>{check.status} · {check.code}</b><p>{check.explanation}</p>{check.remediation && <p>{check.remediation}</p>}{check.details && Object.entries(check.details).map(([key, value]) => <p key={key}>{key}: {String(value)}</p>)}</div>)}</> : <p role="status">{visible.busy ? 'Loading observation…' : `Unavailable: ${visible.reportError ?? unavailable ?? 'Requires a literal superuser and successful same-identity bootstrap with setup capability.'}`}</p>}
52
+ <p className="dim">These checks inspect domain and certificate configuration. They do not prove certificate expiry, coverage, installed material, fleet convergence, expected nodes or the running generation. HTTP is a file rendering setting; DNS-01 identifies the issuance method. A hostname absent from this bounded report has no individual verdict.</p></div><div className="modal-actions"><button type="button" className="btn" onClick={() => close(null)}>Close</button></div></div>, { size: 'md', signal: owner.current?.signal });
53
+ return <section className="edge-serving-evidence" aria-label="Serving evidence"><div className="edge-check-summary"><div className="edge-check-status"><i className="bi bi-shield-check" aria-hidden="true" /><strong>Configuration checks</strong><Badge tone={selected?.status === 'fail' ? 'danger' : 'muted'}>{visible.busy ? 'Checking…' : selected?.status ?? 'Unavailable'}</Badge><EdgeHelp label="About configuration checks">Checks saved domain and certificate configuration. Fleet application and live serving remain unverified.</EdgeHelp></div><div className="edge-row-actions"><button type="button" className="btn btn-compact" onClick={() => void showDetails()}>Check details</button><button type="button" className="btn-icon" aria-label="Refresh serving evidence" title="Refresh" disabled={visible.busy} onClick={() => setRefresh(value => value + 1)}><i className={`bi bi-arrow-repeat${visible.busy ? ' spin' : ''}`} /></button></div></div>{report && <small className="dim">As of <time dateTime={report.generated_at}>{datetime(report.generated_at)}</time>{report.truncated || selected?.coverage.omitted || report.coverage.checks.omitted || report.coverage.sections.omitted ? ' · Partial report' : ''}</small>}{(visible.error || visible.reportError) && <p className="text-warn" role="status">Configuration check unavailable. Open Check details for the reason.</p>}</section>;
54
+ }
@@ -1,166 +1,172 @@
1
- import { useState } from 'react';
1
+ import { useEffect, useRef, useState } from 'react';
2
2
  import { useQueryClient } from '@tanstack/react-query';
3
- import { useCan } from '../../client/runtime';
4
- import { ArmedButton, CollectionSelect, DetailView, Eyebrow, FlatRow, fmt, modal, toast } from '../../ui';
5
- import { deleteVhostRoute, deleteVhost, saveVhostRoute, saveVhost } from './api';
6
- import {
7
- EDGE_MANAGE_PERMS, EdgeRouteModel, EdgeUpstreamModel, EdgeVhostModel, validateRoutePrefix, vhostKindTitle,
8
- type EdgeRouteRow, type EdgeUpstreamRow, type EdgeVhostRow,
9
- } from './models';
3
+ import { getAuthSessionRevision, getAuthSnapshot, MojoError, useAuthSnapshot, useCan } from '../../client/runtime';
4
+ import { Badge, CollectionSelect, FlatRow, ModelTable, Popover, Tabs, fmt, modal, toast, type Column } from '../../ui';
5
+ import { DomainModel } from '../dns/models';
6
+ import { deleteVhostRoute, deleteVhost, listVhostRoutes, saveVhostRoute, saveVhost } from './api';
7
+ import { EDGE_MANAGE_PERMS, EdgeRouteModel, EdgeUpstreamModel, EdgeVhostModel, quietPathUncovered, validateRoutePrefix, vhostKindTitle, type EdgeRouteRow, type EdgeUpstreamRow, type EdgeVhostRow } from './models';
8
+ import { EdgeHelp } from './EdgeHelp';
10
9
  import { openVhostWizard } from './VhostWizard';
11
10
 
12
- function message(error: unknown, fallback: string): string {
13
- return error instanceof Error ? error.message : fallback;
11
+ const reason = (error: unknown) => error instanceof Error ? error.message : 'The request could not be completed.';
12
+ const refused = (error: unknown) => error instanceof MojoError && (error.status === 200 || error.status >= 400 && error.status < 500);
13
+ const identity = () => `${getAuthSnapshot().uid}:${getAuthSessionRevision()}`;
14
+ type Operation = { label: string; observe: () => Promise<boolean> };
15
+ type Run = (write: () => Promise<void>, operation: Operation) => Promise<boolean>;
16
+
17
+ function confirmRemoval({ title, message, confirmText, signal }: { title: string; message: string; confirmText: string; danger: boolean; signal?: AbortSignal }) {
18
+ return modal.open<boolean>(close => <div className="modal-pad"><h2 className="modal-title">{title}</h2><p className="modal-message">{message}</p><div className="modal-actions"><button type="button" className="btn" onClick={() => close(false)}>Cancel</button><button type="button" className="btn btn-danger" onClick={() => close(true)}>{confirmText}</button></div></div>, { size: 'sm', signal });
14
19
  }
15
20
 
16
- /** Route CRUD lives here: the wizard creates routes, the detail owns them after. */
17
- function VhostRoutes({ vhost, canManage }: { vhost: EdgeVhostRow; canManage: boolean }) {
21
+
22
+ function RouteEditor({ route, vhost, run, onDone, signal, beforeWrite }: { route: EdgeRouteRow | null; vhost: EdgeVhostRow; run: Run; onDone: () => void; signal: AbortSignal; beforeWrite: () => void }) {
18
23
  const queryClient = useQueryClient();
19
- const query = EdgeRouteModel.useList({ vhost: vhost.id, sort: 'path_prefix', size: 200 });
20
- const [editing, setEditing] = useState<number | 'new' | null>(null);
21
- const [prefix, setPrefix] = useState('');
22
- const [upstream, setUpstream] = useState<{ id: number; name: string } | null>(null);
24
+ const [prefix, setPrefix] = useState(route?.path_prefix ?? '');
25
+ const [upstream, setUpstream] = useState<{ id: number; name: string } | null>(route?.upstream ? { id: route.upstream.id, name: route.upstream.name } : null);
26
+ const [owner, setOwner] = useState<{ group: number | null } | null>(null);
23
27
  const [busy, setBusy] = useState(false);
24
28
  const [error, setError] = useState('');
25
- const rows = query.data?.rows ?? [];
26
-
27
- const begin = (route: EdgeRouteRow | null) => {
28
- setEditing(route ? route.id : 'new');
29
- setPrefix(route?.path_prefix ?? '');
30
- setUpstream(route?.upstream ? { id: route.upstream.id, name: route.upstream.name } : null);
31
- setError('');
32
- };
33
- const refresh = () => Promise.allSettled([EdgeRouteModel.invalidate(queryClient), EdgeVhostModel.invalidate(queryClient)]);
29
+ const [unknown, setUnknown] = useState(false);
30
+ const pending = useRef(false);
31
+ useEffect(() => {
32
+ let live = true;
33
+ if (vhost.domain) DomainModel.fetchOne(queryClient, vhost.domain.id).then(domain => { if (live) setOwner({ group: typeof domain.group === 'number' ? domain.group : domain.group?.id ?? null }); }).catch(cause => { if (live) setError(`Upstream choices unavailable: ${reason(cause)}`); });
34
+ return () => { live = false; };
35
+ }, [queryClient, vhost.domain?.id]);
34
36
  const submit = async () => {
35
- const refusal = validateRoutePrefix(prefix) ?? (upstream ? null : 'Choose an upstream.');
36
- if (refusal) { setError(refusal); return; }
37
- setBusy(true); setError('');
38
- try {
39
- await saveVhostRoute(editing === 'new' ? null : editing, { ...(editing === 'new' ? { vhost: vhost.id } : {}), path_prefix: prefix, upstream: upstream!.id });
40
- setEditing(null);
41
- } catch (cause) {
42
- setError(message(cause, 'The route was not saved'));
43
- } finally {
44
- await refresh();
45
- setBusy(false);
46
- }
47
- };
48
- const remove = async (route: EdgeRouteRow) => {
37
+ if (pending.current || unknown || signal.aborted) return;
38
+ const validation = validateRoutePrefix(prefix) ?? (upstream ? null : 'Choose an upstream.');
39
+ if (validation) { setError(validation); return; }
40
+ pending.current = true; setBusy(true); setError('');
49
41
  try {
50
- await deleteVhostRoute(route.id);
51
- toast.success(`Route ${route.path_prefix} deleted.`);
52
- } catch (cause) {
53
- toast.error(message(cause, 'The route was not deleted'));
54
- } finally {
55
- await refresh();
56
- }
42
+ const target = { path_prefix: prefix, upstream: upstream!.id };
43
+ const routeGuard = () => { beforeWrite(); if (signal.aborted) throw new MojoError('Session or permission changed. Reopen the hostname.', 403); };
44
+ const ok = await run(async () => {
45
+ let current: EdgeRouteRow[], currentVhost: EdgeVhostRow;
46
+ try { current = await listVhostRoutes(vhost.id); await EdgeVhostModel.invalidate(queryClient); currentVhost = await EdgeVhostModel.fetchOne(queryClient, vhost.id); }
47
+ catch (cause) { throw new MojoError(`Could not inspect current routes: ${reason(cause)}`, 400); }
48
+ routeGuard();
49
+ const prefixes = current.filter(item => item.id !== route?.id).map(item => item.path_prefix).concat(prefix);
50
+ const orphan = quietPathUncovered(currentVhost.quiet_paths ?? [], prefixes);
51
+ if (orphan) throw new MojoError(`This change would leave quiet path ${orphan} without a route. Update Quiet paths in the hostname’s Settings first.`, 400);
52
+ const saved = await saveVhostRoute(route?.id ?? null, { ...(route ? {} : { vhost: vhost.id }), ...target }, routeGuard);
53
+ if (!Number.isSafeInteger(saved.id) || saved.id <= 0 || route && saved.id !== route.id || saved.path_prefix !== target.path_prefix || saved.upstream?.id !== target.upstream) throw new TypeError('The route response did not confirm the submitted configuration.');
54
+ }, { label: `Route ${prefix}`, observe: async () => (await listVhostRoutes(vhost.id)).some(item => (!route || item.id === route.id) && item.path_prefix === target.path_prefix && item.upstream?.id === target.upstream) });
55
+ if (ok) onDone(); else setUnknown(true);
56
+ } catch (cause) { setError(reason(cause)); }
57
+ finally { pending.current = false; setBusy(false); }
57
58
  };
58
-
59
- return <div className="edge-routes">
60
- <Eyebrow>Proxied paths</Eyebrow>
61
- <p className="dim">Requests under each prefix go to its upstream. Everything else is served from the site.</p>
62
- {query.isPending && <div className="dim">Loading routes…</div>}
63
- {query.error && <div className="text-bad">{query.error.message}</div>}
64
- {!query.isPending && !query.error && rows.length === 0 && <div className="edge-warning"><i className="bi bi-exclamation-triangle" /> No routes. This address proxies nothing until one is added.</div>}
65
- {rows.map((route) => <div className="edge-route-item" key={route.id}>
66
- <code>{route.path_prefix}</code>
67
- <span><i className="bi bi-arrow-right" /> {route.upstream?.name ?? '—'}</span>
68
- {canManage && <span className="edge-route-actions">
69
- <button type="button" className="btn btn-compact" disabled={busy} onClick={() => begin(route)}>Edit</button>
70
- <ArmedButton className="btn-compact" label="Delete" armedLabel={`Click again to stop proxying ${route.path_prefix}`} disabled={busy} onConfirm={() => remove(route)} />
71
- </span>}
72
- </div>)}
73
- {canManage && editing == null && <div><button type="button" className="btn" onClick={() => begin(null)}><i className="bi bi-plus-lg" /> Add route</button></div>}
74
- {canManage && editing != null && <div className="edge-route-editor">
75
- {error && <div className="form-alert" role="alert">{error}</div>}
76
- <label className="field"><span className="field-label">Path prefix <em>*</em></span><input className="input" value={prefix} disabled={busy} onChange={(event) => setPrefix(event.target.value.trim())} placeholder="/api" autoComplete="off" /></label>
77
- <CollectionSelect<EdgeUpstreamRow> model={EdgeUpstreamModel} value={upstream} onChange={(id, picked) => setUpstream(id == null ? null : { id: Number(id), name: picked?.name ?? '' })} label="Upstream" required disabled={busy} placeholder="Search enabled upstreams…" defaultParams={{ is_enabled: true, sort: 'name' }} />
78
- <div className="modal-actions"><button type="button" className="btn" disabled={busy} onClick={() => setEditing(null)}>Cancel</button><button type="button" className="btn btn-primary" disabled={busy} onClick={() => void submit()}>{busy ? 'Saving…' : editing === 'new' ? 'Add route' : 'Save route'}</button></div>
79
- </div>}
80
- </div>;
59
+ // run() owns the parent pending state and the native editor's guard.
60
+ return <div className="modal-pad edge-route-dialog"><h2 className="modal-title">{route ? 'Edit route' : 'Add route'}</h2>{error && <div className="form-alert" role="alert">{error}</div>}{unknown && <p className="text-warn" role="status">Outcome unconfirmed. Close this editor and use Observe outcome in the hostname dialog. This route will not be resubmitted here.</p>}<div className="edge-form-grid"><label className="field"><span className="field-label">Path prefix <em>*</em><EdgeHelp label="About route prefixes">Requests under this prefix go to the chosen upstream. Everything else stays on the site.</EdgeHelp></span><input className="input" aria-label="Path prefix" value={prefix} disabled={busy || unknown} onChange={event => setPrefix(event.target.value.trim())} placeholder="/api" /></label><CollectionSelect<EdgeUpstreamRow> model={EdgeUpstreamModel} value={upstream} onChange={(id, picked) => setUpstream(id == null ? null : { id: Number(id), name: picked?.name ?? '' })} label="Upstream" required disabled={busy || unknown || !owner} defaultParams={{ is_enabled: true, sort: 'name', ...(owner?.group != null ? { group: owner.group } : { group__isnull: true }) }} /></div><div className="modal-actions"><button type="button" className="btn" disabled={busy} onClick={onDone}>{unknown ? 'Close' : 'Cancel'}</button><button type="button" className="btn btn-primary" disabled={busy || unknown || !owner} onClick={() => void submit()}>{busy ? 'Saving…' : route ? 'Save route' : 'Add route'}</button></div></div>;
81
61
  }
82
62
 
83
- export function VhostDetail({ id, onClose }: { id: number; onClose: () => void }) {
63
+ export function VhostDetail({ id, onClose, onChanged, onBusyChange }: { id: number; onClose: () => void; onChanged?: () => void; onBusyChange?: (busy: boolean) => void }) {
84
64
  const queryClient = useQueryClient();
85
65
  const query = EdgeVhostModel.useOne(id);
86
66
  const canManage = useCan(EDGE_MANAGE_PERMS).can;
67
+ const auth = useAuthSnapshot();
68
+ const scope = `${auth.uid}:${getAuthSessionRevision()}`;
69
+ const openedScope = useRef(scope);
70
+ const openedManage = useRef(canManage);
71
+ const authority = useRef(canManage); authority.current = canManage;
72
+ const owner = useRef<AbortController | null>(null);
87
73
  const [busy, setBusy] = useState(false);
74
+ const pending = useRef(false);
88
75
  const [error, setError] = useState('');
89
- const vhost = query.data;
90
- if (query.isPending) return <div className="modal-pad dim">Loading vhost…</div>;
91
- if (!vhost || query.error) return <div className="modal-pad text-bad">{query.error?.message ?? 'Vhost not found'}</div>;
92
-
93
- const setEnabled = async (next: boolean) => {
94
- setBusy(true); setError('');
76
+ const [active, setActive] = useState('overview');
77
+ const [unknown, setUnknown] = useState<Operation | null>(null);
78
+ const [menu, setMenu] = useState(false);
79
+ const menuAnchor = useRef<HTMLButtonElement>(null);
80
+ useEffect(() => {
81
+ const controller = new AbortController(); owner.current = controller;
82
+ if (scope !== openedScope.current) onClose();
83
+ return () => { controller.abort(); onBusyChange?.(false); };
84
+ }, [scope]);
85
+ useEffect(() => { if (openedManage.current && !canManage) { owner.current?.abort(); onClose(); } }, [canManage]);
86
+ const current = () => !owner.current?.signal.aborted && identity() === openedScope.current;
87
+ const lock = (value: boolean) => { pending.current = value; setBusy(value); onBusyChange?.(value); };
88
+ const guard = () => { if (!authority.current || !current()) throw new MojoError('Session or permission changed. Reopen Serving.', 403); };
89
+ const refresh = async () => { await Promise.allSettled([EdgeVhostModel.invalidate(queryClient), EdgeRouteModel.invalidate(queryClient)]); if (current()) onChanged?.(); };
90
+ const run: Run = async (write, operation) => {
91
+ if (pending.current || unknown || !authority.current || !current()) return false;
92
+ lock(true); setError('');
93
+ try { await write(); if (current()) toast.success(`${operation.label} saved.`); return true; }
94
+ catch (cause) {
95
+ if (!current()) return false;
96
+ if (refused(cause)) { setError(reason(cause)); throw cause; }
97
+ setUnknown(operation); setError(`${operation.label}: outcome unconfirmed. Observe before making another change.`); return false;
98
+ } finally { await refresh(); if (current()) lock(false); }
99
+ };
100
+ const observe = async () => {
101
+ if (!unknown || pending.current || !current()) return;
102
+ lock(true);
95
103
  try {
96
- const saved = await saveVhost(vhost.id, { is_enabled: next });
97
- queryClient.setQueryData(EdgeVhostModel.keys.one(vhost.id), saved);
98
- toast.success(`${saved.server_name ?? 'Vhost'} ${next ? 'enabled' : 'disabled'}.`);
99
- } catch (cause) {
100
- // The server's own words: coverage, a duplicate name, an orphaned quiet path.
101
- setError(message(cause, 'The change was refused'));
102
- } finally {
103
- await EdgeVhostModel.invalidate(queryClient);
104
- setBusy(false);
105
- }
104
+ const confirmed = await unknown.observe();
105
+ if (!current()) return;
106
+ if (confirmed) { setUnknown(null); setError(''); toast.success(`${unknown.label}: submitted result observed.`); }
107
+ else setError('The submitted result was not observed. The outcome remains unconfirmed; no write was replayed.');
108
+ } catch (cause) { if (current()) setError(`Observation unavailable: ${reason(cause)}. No write was replayed.`); }
109
+ finally { await refresh(); if (current()) lock(false); }
106
110
  };
107
- const edit = async () => {
108
- const saved = await openVhostWizard(vhost);
109
- if (saved) { queryClient.setQueryData(EdgeVhostModel.keys.one(vhost.id), saved); toast.success(`${saved.server_name ?? 'Vhost'} saved.`); }
111
+ const vhost = query.data;
112
+ if (query.isPending) return <div className="modal-pad"><p className="dim">Loading hostname…</p><button type="button" className="btn" onClick={onClose}>Close</button></div>;
113
+ if (!vhost || query.error) return <div className="modal-pad"><div className="form-alert" role="alert">{query.error?.message ?? 'Hostname unavailable.'}</div><div className="modal-actions"><button type="button" className="btn" onClick={onClose}>Close</button><button type="button" className="btn" onClick={() => void query.refetch()}>Retry</button></div></div>;
114
+ const edit = async (initialTab = 'address') => {
115
+ if (pending.current || unknown || !canManage || !current()) return;
116
+ setMenu(false); lock(true);
117
+ try { const saved = await openVhostWizard(vhost, { initialTab, signal: owner.current?.signal }); if (saved && current()) { queryClient.setQueryData(EdgeVhostModel.keys.one(id), saved); onChanged?.(); } }
118
+ finally { if (current()) lock(false); }
119
+ };
120
+ const setEnabled = async (next: boolean) => {
121
+ try { await run(async () => { const saved = await saveVhost(id, { is_enabled: next }, guard); if (saved.id !== id || saved.is_enabled !== next) throw new TypeError('Enablement was not confirmed.'); }, { label: `${vhost.server_name} configuration`, observe: async () => { await EdgeVhostModel.invalidate(queryClient); return (await EdgeVhostModel.fetchOne(queryClient, id)).is_enabled === next; } }); } catch { /* run exposes the authoritative refusal. */ }
110
122
  };
111
123
  const remove = async () => {
112
- const name = vhost.server_name ?? 'this address';
113
- const ok = await modal.confirm({
114
- title: `Delete ${name}?`,
115
- message: `${name} stops being served and its routes are deleted with it. This cannot be undone. To take it out of service and keep it, disable it instead.`,
116
- confirmText: 'Delete vhost', danger: true,
117
- });
118
- if (!ok) return;
124
+ if (pending.current || unknown || !canManage || !current()) return;
125
+ setMenu(false); lock(true);
126
+ const ok = await confirmRemoval({ title: `Delete ${vhost.server_name}?`, message: 'This stops serving the hostname and deletes its routes. Linked sites can stop serving. The domain and certificate remain. Disable it instead to keep the configuration.', confirmText: 'Delete hostname', danger: true, signal: owner.current?.signal });
127
+ lock(false);
128
+ if (!ok || !current()) return;
129
+ try { if (await run(() => deleteVhost(id, guard), { label: 'Hostname deletion', observe: async () => { try { await EdgeVhostModel.invalidate(queryClient); await EdgeVhostModel.fetchOne(queryClient, id); return false; } catch (cause) { if (cause instanceof MojoError && cause.status === 404) { onClose(); return true; } throw cause; } } })) onClose(); } catch { /* shown above */ }
130
+ };
131
+ const openRoute = async (route: EdgeRouteRow | null) => {
132
+ if (pending.current || unknown || !canManage || !current()) return;
133
+ let childBusy = false;
134
+ await modal.open(close => <RouteEditor route={route} vhost={vhost} beforeWrite={guard} run={async (write, operation) => { childBusy = true; try { return await run(write, operation); } finally { childBusy = false; } }} signal={owner.current!.signal} onDone={() => { if (!childBusy) close(null); }} />, { size: 'md', canDismiss: () => !childBusy, signal: owner.current?.signal });
135
+ };
136
+ const removeRoute = async (route: EdgeRouteRow) => {
137
+ if (pending.current || unknown || !current()) return;
138
+ lock(true); setError('');
119
139
  try {
120
- await deleteVhost(vhost.id);
121
- await Promise.allSettled([EdgeVhostModel.invalidate(queryClient), EdgeRouteModel.invalidate(queryClient)]);
122
- toast.success(`${vhost.server_name ?? 'Vhost'} deleted.`);
123
- onClose();
124
- } catch (cause) {
125
- setError(message(cause, 'The vhost was not deleted'));
126
- }
140
+ const routes = await listVhostRoutes(id);
141
+ await EdgeVhostModel.invalidate(queryClient);
142
+ const observed = await EdgeVhostModel.fetchOne(queryClient, id);
143
+ guard();
144
+ const orphan = quietPathUncovered(observed.quiet_paths ?? [], routes.filter(item => item.id !== route.id).map(item => item.path_prefix));
145
+ if (orphan) { setError(`Removing ${route.path_prefix} would leave quiet path ${orphan} without a route. Edit Quiet paths in Settings first.`); return; }
146
+ const ok = await confirmRemoval({ title: `Remove ${route.path_prefix}?`, message: 'Requests under this prefix will stop being proxied. The site handles them instead.', confirmText: 'Remove route', danger: true, signal: owner.current?.signal });
147
+ lock(false);
148
+ if (!ok || !current()) return;
149
+ await run(() => deleteVhostRoute(route.id, guard), { label: `Removal of ${route.path_prefix}`, observe: async () => !(await listVhostRoutes(id)).some(item => item.id === route.id) });
150
+ } catch (cause) { if (current()) setError(reason(cause)); }
151
+ finally { if (current()) lock(false); }
127
152
  };
128
153
  const proxies = vhost.kind === 'api' || vhost.kind === 'site_api';
129
- const policy = vhost.mojosec_policy && Object.keys(vhost.mojosec_policy).length ? vhost.mojosec_policy : null;
154
+ const frozen = busy || unknown != null;
155
+ return <div className="modal-pad edge-hostname-detail"><header className="edge-detail-header"><div className="edge-detail-identity"><h2 className="modal-title">{vhost.server_name ?? `Hostname #${id}`}</h2><div className="edge-detail-subtitle"><span>{vhostKindTitle(vhost.kind)}</span><Badge tone={vhost.is_enabled ? 'info' : 'muted'}>{vhost.is_enabled ? 'Enabled' : 'Disabled'}</Badge><EdgeHelp label="About hostname status">Enabled is saved configuration intent. Fleet application and live serving are not verified by this record.</EdgeHelp></div></div><div className="edge-detail-actions">{canManage && <><label className="edge-enabled-switch"><input aria-label="Hostname enabled" type="checkbox" checked={vhost.is_enabled} disabled={frozen} onChange={event => void setEnabled(event.target.checked)} /><span>Enabled</span></label><button type="button" className="btn btn-compact" disabled={frozen} onClick={() => void edit()}>Edit</button><button type="button" ref={menuAnchor} className="btn-icon" aria-label="Hostname actions" aria-expanded={menu} disabled={frozen} onClick={() => setMenu(value => !value)}><i className="bi bi-three-dots-vertical" /></button><Popover anchorRef={menuAnchor} open={menu} onClose={() => setMenu(false)} className="edge-action-menu"><button type="button" className="btn text-bad" onClick={() => void remove()}><i className="bi bi-trash" /> Delete hostname</button></Popover></>}<button type="button" className="btn-icon" aria-label="Close hostname" disabled={busy} onClick={onClose}><i className="bi bi-x-lg" /></button></div></header>
156
+ {error && <div className="form-alert" role="alert">{error}{unknown && <button type="button" className="btn btn-compact" disabled={busy} onClick={() => void observe()}>{busy ? 'Observing…' : 'Observe outcome'}</button>}</div>}
157
+ <Tabs ariaLabel="Hostname details" activeKey={active} onActiveKeyChange={setActive} items={[
158
+ { key: 'overview', label: 'Overview', panel: <div className="edge-detail-panel">{vhost.kind === 'api' && <FlatRow label="Upstream">{vhost.upstream?.name ?? 'Missing upstream'}</FlatRow>}{vhost.kind === 'redirect' && <FlatRow label="Redirects to"><code>{vhost.redirect_to ?? 'Missing destination'}</code></FlatRow>}{(vhost.kind === 'site' || vhost.kind === 'site_api') && <FlatRow label="Single-page app">{vhost.spa ? 'Yes' : 'No'}</FlatRow>}<FlatRow label="Domain">{vhost.domain?.name ?? 'Unknown'}</FlatRow><FlatRow label="Certificate"><div className="edge-certificate-summary"><span>{vhost.certificate?.common_name ?? 'Not configured'} <EdgeHelp label="About certificate status">Stored certificate status and expiry do not prove installation or live serving.</EdgeHelp></span><small className="dim">{vhost.certificate?.status ?? 'Unknown status'} · Expires {vhost.certificate?.not_after ? fmt.datetime(vhost.certificate.not_after) : 'unknown'}</small>{canManage && <button type="button" className="btn btn-compact" disabled={frozen} onClick={() => void edit('certificate')}>Change certificate</button>}</div></FlatRow>{vhost.kind === 'site_api' && <button type="button" className="btn" onClick={() => setActive('routes')}>View API routes</button>}</div> },
159
+ ...(vhost.kind === 'site_api' ? [{ key: 'routes', label: 'Routes', panel: <RoutesContent id={id} canManage={canManage} disabled={frozen} onEdit={route => void openRoute(route)} onRemove={route => void removeRoute(route)} /> }] : []),
160
+ { key: 'settings', label: 'Settings', panel: <div className="edge-detail-panel"><FlatRow label="Pool">{vhost.pool}</FlatRow>{vhost.kind !== 'redirect' && <FlatRow label="Maximum body">{vhost.body_size_mb} MB</FlatRow>}{proxies && <><FlatRow label="Static files">{vhost.serve_static ? 'Served at /static/' : 'Not served here'}</FlatRow><FlatRow label="Quiet paths">{vhost.quiet_paths?.length ? vhost.quiet_paths.join(', ') : 'None'}</FlatRow></>}<FlatRow label="Created">{fmt.datetime(vhost.created)}</FlatRow><FlatRow label="Changed">{fmt.datetime(vhost.modified)}</FlatRow>{vhost.mojosec_policy && Object.keys(vhost.mojosec_policy).length > 0 && <FlatRow label="Evidence policy">Platform-managed <EdgeHelp label="About the evidence policy">Version {String(vhost.mojosec_policy.version)} · {String(vhost.mojosec_policy.response_class)}. Not editable here.</EdgeHelp></FlatRow>}{canManage && <div><button type="button" className="btn" disabled={frozen} onClick={() => void edit('settings')}>Edit settings</button></div>}</div> },
161
+ ]} />
162
+ </div>;
163
+ }
130
164
 
131
- return <DetailView
132
- icon="bi-hdd-network" title={vhost.server_name ?? `Vhost #${vhost.id}`} subtitle={`${vhostKindTitle(vhost.kind)} · ${vhost.domain?.name ?? ''}`}
133
- chips={[{ text: vhost.is_enabled ? 'Enabled' : 'Disabled', tone: vhost.is_enabled ? 'success' : 'muted' }]}
134
- {...(canManage ? { active: { value: vhost.is_enabled, onChange: (next: boolean) => void setEnabled(next), disabled: busy } } : {})}
135
- contextMenu={[
136
- { label: 'Edit vhost', icon: 'bi-pencil', permissions: EDGE_MANAGE_PERMS, disabled: busy, onSelect: () => void edit() },
137
- { divider: true },
138
- { label: 'Delete vhost', icon: 'bi-trash', permissions: EDGE_MANAGE_PERMS, danger: true, disabled: busy, onSelect: () => void remove() },
139
- ]}
140
- sections={[
141
- { key: 'overview', label: 'Overview', icon: 'bi-grid-1x2', render: () => <div className="edge-vhost-overview">
142
- {error && <div className="form-alert" role="alert">{error}</div>}
143
- <Eyebrow>Address</Eyebrow>
144
- <FlatRow label="Server name"><code>{vhost.server_name ?? '—'}</code></FlatRow>
145
- <FlatRow label="Domain">{vhost.domain?.name ?? '—'}</FlatRow>
146
- <FlatRow label="Label">{vhost.label === '' ? 'Apex (bare domain)' : vhost.label === '*' ? 'Wildcard (*)' : <code>{vhost.label}</code>}</FlatRow>
147
- <FlatRow label="Serves">{vhostKindTitle(vhost.kind)}</FlatRow>
148
- <Eyebrow>Settings</Eyebrow>
149
- {vhost.kind === 'api' && <FlatRow label="Upstream">{vhost.upstream?.name ?? '—'}</FlatRow>}
150
- {vhost.kind === 'redirect' && <FlatRow label="Redirects to"><code>{vhost.redirect_to ?? '—'}</code></FlatRow>}
151
- {(vhost.kind === 'site' || vhost.kind === 'site_api') && <FlatRow label="Single-page app">{vhost.spa ? 'Yes: unknown paths return index.html' : 'No'}</FlatRow>}
152
- {proxies && <FlatRow label="Static files">{vhost.serve_static ? 'Served at /static/' : 'Not served here'}</FlatRow>}
153
- {proxies && <FlatRow label="Quiet paths">{vhost.quiet_paths?.length ? vhost.quiet_paths.map((path) => <code key={path} className="edge-inline-code">{path}</code>) : 'None'}</FlatRow>}
154
- {vhost.kind !== 'redirect' && <FlatRow label="Largest request body">{vhost.body_size_mb} MB</FlatRow>}
155
- {policy && <FlatRow label="Edge evidence policy"><span title="Set by the platform. Not editable here.">Version {String(policy.version)} · {String(policy.response_class)} · {Array.isArray(policy.impossible_path_families) && policy.impossible_path_families.length ? policy.impossible_path_families.join(', ') : 'no path families'}</span></FlatRow>}
156
- <Eyebrow>Serving</Eyebrow>
157
- <FlatRow label="Certificate">{vhost.certificate ? <>{vhost.certificate.common_name} · {vhost.certificate.status}{vhost.certificate.not_after ? ` · expires ${fmt.date(vhost.certificate.not_after)}` : ''}</> : '—'}</FlatRow>
158
- <FlatRow label="Pool">{vhost.pool}</FlatRow>
159
- <FlatRow label="Created">{fmt.datetime(vhost.created)}</FlatRow>
160
- <FlatRow label="Changed">{fmt.datetime(vhost.modified)}</FlatRow>
161
- </div> },
162
- ...(vhost.kind === 'site_api' ? [{ key: 'routes', label: 'Routes', icon: 'bi-diagram-3', render: () => <VhostRoutes vhost={vhost} canManage={canManage} /> }] : []),
163
- ]}
164
- initialSection="overview" onClose={onClose}
165
- />;
165
+ function RoutesContent({ id, canManage, disabled, onEdit, onRemove }: { id: number; canManage: boolean; disabled: boolean; onEdit: (route: EdgeRouteRow | null) => void; onRemove: (route: EdgeRouteRow) => void }) {
166
+ const columns: Column<EdgeRouteRow>[] = [
167
+ { key: 'path_prefix', label: 'Path prefix', sortable: true, hideable: false, render: row => <code>{row.path_prefix}</code> },
168
+ { key: 'upstream', label: 'Upstream', sortable: false, render: row => row.upstream?.name ?? 'Missing upstream' },
169
+ ...(canManage ? [{ key: 'actions', label: '', sortable: false, render: (row: EdgeRouteRow) => <span className="edge-route-actions"><button type="button" className="btn btn-compact" disabled={disabled} onClick={() => onEdit(row)}>Edit</button><button type="button" className="btn btn-compact" disabled={disabled} onClick={() => onRemove(row)}>Remove</button></span> }] : []),
170
+ ];
171
+ return <div className="edge-detail-panel edge-routes"><div className="edge-serving-heading"><span>Requests under each prefix go to its upstream.</span>{canManage && <button type="button" className="btn btn-primary btn-compact" disabled={disabled} onClick={() => onEdit(null)}><i className="bi bi-plus-lg" /> Add route</button>}</div><ModelTable<EdgeRouteRow> model={EdgeRouteModel} paramsMode="local" fixedParams={{ vhost: id }} title="API routes" searchable={false} columns={columns} defaultSort="path_prefix" /></div>;
166
172
  }