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
@@ -1,11 +1,12 @@
1
- import { useEffect, useMemo, useRef, useState } from 'react';
1
+ import { useEffect, useRef, useState, type ReactNode } from 'react';
2
2
  import { useQueryClient } from '@tanstack/react-query';
3
- import { useMe } from '../../client/runtime';
4
- import { CollectionSelect, FormWizard, modal, type FormWizardSection } from '../../ui';
3
+ import { getAuthSessionRevision, getAuthSnapshot, MojoError, useAuthSnapshot, useCan, useMe } from '../../client/runtime';
4
+ import { CollectionSelect, Tabs, modal } from '../../ui';
5
+ import { EdgeHelp } from './EdgeHelp';
5
6
  import { CertificateModel, DomainModel, type CertificateRow, type DomainRow } from '../dns/models';
6
7
  import { listVhostRoutes, saveVhostRoute, saveVhost } from './api';
7
8
  import {
8
- EdgeRouteModel, EdgeUpstreamModel, EdgeVhostModel, VHOST_KIND_CARDS,
9
+ EDGE_MANAGE_PERMS, EdgeRouteModel, EdgeUpstreamModel, EdgeVhostModel, VHOST_KIND_CARDS,
9
10
  buildVhostPayload, certificateCovers, deriveServerName, quietPathUncovered,
10
11
  validateBodySizeMb, validatePoolName, validateRedirectTarget, validateRequestPath,
11
12
  validateRouteDrafts, validateVhostLabel, vhostKindTitle,
@@ -18,6 +19,18 @@ function reason(error: unknown): string {
18
19
 
19
20
  interface RouteRowDraft extends RouteDraft { key: number; upstreamName?: string }
20
21
 
22
+ const submittedFieldNames: Record<string, string> = { label: 'Label', kind: 'Serving type', certificate: 'Certificate', pool: 'Pool', is_enabled: 'Enabled', upstream: 'Upstream', redirect_to: 'Redirect destination', spa: 'Single-page app', serve_static: 'Static files', quiet_paths: 'Quiet paths', body_size_mb: 'Maximum request body' };
23
+ function observedDifferences(submitted: Record<string, unknown>, row: EdgeVhostRow): string[] {
24
+ const fields = row as unknown as Record<string, unknown>;
25
+ const relation = (key: string, value: unknown) => ['domain', 'certificate', 'upstream'].includes(key) && value != null && typeof value === 'object' ? (value as { id?: unknown }).id : value;
26
+ const display = (value: unknown) => value === undefined ? 'unknown' : JSON.stringify(value).slice(0, 120);
27
+ return Object.entries(submitted).flatMap(([key, expected]) => {
28
+ const observed = relation(key, fields[key]);
29
+ return JSON.stringify(observed) === JSON.stringify(expected) ? [] : [`${submittedFieldNames[key] ?? key}: expected ${display(expected)}, observed ${display(observed)}`];
30
+ });
31
+ }
32
+
33
+
21
34
  /** One path per row; the server takes a list of exact request paths. */
22
35
  function PathListEditor({ label, help, paths, onChange, disabled }: { label: string; help: string; paths: string[]; onChange: (paths: string[]) => void; disabled: boolean }) {
23
36
  return <div className="field edge-path-list">
@@ -32,16 +45,28 @@ function PathListEditor({ label, help, paths, onChange, disabled }: { label: str
32
45
  }
33
46
 
34
47
  export interface VhostWizardProps {
48
+ initialTab?: string;
49
+ signal?: AbortSignal;
35
50
  /** Omit to create; pass the `default` graph row to edit. */
36
51
  row?: EdgeVhostRow | null;
37
52
  onDone: (saved: EdgeVhostRow | null) => void;
38
53
  onBusyChange?: (busy: boolean) => void;
39
54
  }
40
55
 
41
- export function VhostWizard({ row = null, onDone, onBusyChange }: VhostWizardProps) {
56
+ export function VhostWizard({ row = null, onDone, onBusyChange, initialTab = 'hostname', signal }: VhostWizardProps) {
42
57
  const queryClient = useQueryClient();
43
58
  const { data: me } = useMe();
44
59
  const superuser = me?.is_superuser === true;
60
+ const canManage = useCan(EDGE_MANAGE_PERMS).can;
61
+ const authority = useRef(canManage); authority.current = canManage;
62
+ const auth = useAuthSnapshot();
63
+ const scope = `${auth.uid}:${getAuthSessionRevision()}`;
64
+ const openedScope = useRef(scope);
65
+ const mounted = useRef(true);
66
+ useEffect(() => { mounted.current = true; return () => { mounted.current = false; }; }, []);
67
+ const current = () => mounted.current && !signal?.aborted && `${getAuthSnapshot().uid}:${getAuthSessionRevision()}` === openedScope.current;
68
+ const guard = () => { if (!current() || !authority.current) throw new MojoError('Session or permission changed. Reopen Serving before changing this hostname.', 403); };
69
+
45
70
  const editing = row != null;
46
71
 
47
72
  const [kind, setKind] = useState<VhostKind | null>((row?.kind as VhostKind | undefined) ?? null);
@@ -61,10 +86,67 @@ export function VhostWizard({ row = null, onDone, onBusyChange }: VhostWizardPro
61
86
  const [storedRoutes, setStoredRoutes] = useState<string[] | null>(editing ? null : []);
62
87
  const [routesUnread, setRoutesUnread] = useState(false);
63
88
  const nextRouteKey = useRef(1);
64
- // Finish reruns whole on every retry, so what already landed is recorded
89
+ // Explicit continuation retains acknowledged steps rather than duplicating them
65
90
  // here and skipped: never a second vhost, never a duplicate route.
66
91
  const progress = useRef<{ vhostId: number | null; routes: Map<string, number> }>({ vhostId: null, routes: new Map() });
67
92
  const [createdId, setCreatedId] = useState<number | null>(null);
93
+ // Keep older callers' focused entry points compatible with the combined task.
94
+ const [active, setActive] = useState(['address', 'routing'].includes(initialTab) ? 'hostname' : initialTab);
95
+ const [busy, setBusy] = useState(false);
96
+ const pending = useRef(false);
97
+ const body = useRef<HTMLDivElement>(null);
98
+ const [error, setError] = useState('');
99
+ const [domainOwner, setDomainOwner] = useState<{ id: number; group: number | null } | null>(null);
100
+ const [ownerError, setOwnerError] = useState('');
101
+ const submitted = useRef<Record<string, unknown> | null>(null);
102
+ const uncertain = useRef<{ step: 'create' | 'route' | 'finish' | 'edit'; prefix?: string } | null>(null);
103
+ const [unconfirmed, setUnconfirmed] = useState('');
104
+ const reveal = (message: string, tab = 'settings', selector = 'input:not(:disabled):not([readonly]):not([type=radio]):not([type=checkbox]),select:not(:disabled),textarea:not(:disabled)'): never => {
105
+ setActive(['address', 'routing'].includes(tab) ? 'hostname' : tab);
106
+ setTimeout(() => body.current?.querySelector<HTMLElement>(selector)?.focus(), 0);
107
+ throw new Error(message);
108
+ };
109
+ // A server 4xx / explicit envelope rejection proves refusal. Network,
110
+ // malformed responses and 5xx responses cannot prove the write did not land.
111
+ const refused = (error: unknown) => error instanceof MojoError && (error.status === 200 || (error.status >= 400 && error.status < 500));
112
+ const unknown = (step: 'create' | 'route' | 'finish' | 'edit', prefix?: string): never => {
113
+ uncertain.current = { step, prefix };
114
+ const text = step === 'create'
115
+ ? 'Create outcome unconfirmed. The hostname may already exist. Close this wizard and inspect Serving before starting another create; this wizard will not replay it.'
116
+ : `Hostname #${progress.current.vhostId ?? row?.id}: ${step === 'route' ? `route ${prefix}` : 'final save / enablement'} outcome unconfirmed. Observe the saved configuration before continuing. It may already have applied.`;
117
+ setUnconfirmed(text);
118
+ throw new Error(text);
119
+ };
120
+ const observe = async () => {
121
+ const pending = uncertain.current;
122
+ if (!pending || pending.step === 'create') return;
123
+ const observedId = progress.current.vhostId ?? row?.id;
124
+ if (observedId == null) return;
125
+ try {
126
+ // Observe outcome shares the submit lifecycle and never sends a write.
127
+ if (pending.step === 'route') {
128
+ const observed = await listVhostRoutes(observedId);
129
+ const expected = routes.find(route => route.path_prefix === pending.prefix);
130
+ const match = observed.find(route => route.path_prefix === pending.prefix && route.upstream?.id === expected?.upstream);
131
+ if (!match) throw new Error('The submitted route was not found in this bounded observation. Inspect the hostname’s routes before starting another create; no route is replayed here.');
132
+ progress.current.routes.set(match.path_prefix, match.id);
133
+ } else {
134
+ await EdgeVhostModel.invalidate(queryClient);
135
+ const observed = await EdgeVhostModel.fetchOne(queryClient, observedId);
136
+ if (!current()) return;
137
+ const differences = submitted.current ? observedDifferences(submitted.current, observed) : ['The submitted configuration receipt is missing.'];
138
+ if (observed.id !== observedId) differences.unshift(`Hostname ID: expected ${observedId}, observed ${observed.id ?? 'unknown'}`);
139
+ if (differences.length) {
140
+ setUnconfirmed(`Outcome still unconfirmed. ${differences.slice(0, 3).join('; ')}${differences.length > 3 ? `; ${differences.length - 3} more differences` : ''}. No write was replayed.`);
141
+ return;
142
+ }
143
+ setUnconfirmed(`The exact submitted configuration was observed. Select ${editing ? 'Save changes' : 'Add hostname'} again to continue. Fleet application remains unverified.`);
144
+ }
145
+ if (!current()) return;
146
+ uncertain.current = null;
147
+ if (pending.step === 'route') setUnconfirmed('Route observed. Select Add hostname again to continue from acknowledged progress.');
148
+ } catch (error) { throw new Error(`Observation unavailable: ${reason(error)}. No write was replayed.`); }
149
+ };
68
150
 
69
151
  // The row embeds `certificate: basic`, which has no `sans`; the coverage
70
152
  // check needs the certificate's own graph.
@@ -93,181 +175,188 @@ export function VhostWizard({ row = null, onDone, onBusyChange }: VhostWizardPro
93
175
  });
94
176
 
95
177
  const checkShape = () => {
96
- if (!kind) throw new Error('Choose what this address serves.');
178
+ if (!kind) reveal('Choose what this address serves.', 'routing', 'input[type=radio]');
97
179
  if (editing && row.kind === 'site_api' && kind !== 'site_api' && (storedRoutes?.length ?? 0) > 0) {
98
- throw new Error(`This vhost has ${storedRoutes!.length} route${storedRoutes!.length === 1 ? '' : 's'}. Delete them in the detail view before changing what it serves.`);
180
+ reveal(`This hostname has ${storedRoutes!.length} route${storedRoutes!.length === 1 ? '' : 's'}. Delete them in the detail view before changing what it serves.`, 'routing', 'input[type=radio]');
99
181
  }
100
182
  };
101
183
  const checkName = () => {
102
- if (!domain) throw new Error('Choose a domain.');
184
+ if (!domain) reveal('Choose a domain.', 'address', 'input[role=combobox]');
103
185
  const refusal = validateVhostLabel(label);
104
- if (refusal) throw new Error(refusal);
186
+ if (refusal) reveal(refusal, 'address', '[aria-label="Hostname label"]');
105
187
  };
106
188
  const checkKnobs = () => {
107
189
  if (kind === 'redirect') {
108
190
  const refusal = validateRedirectTarget(redirectTo.trim().toLowerCase());
109
- if (refusal) throw new Error(refusal);
191
+ if (refusal) reveal(refusal, 'routing', '[placeholder="www.example.com"]');
110
192
  return;
111
193
  }
112
194
  const size = validateBodySizeMb(/^\d+$/.test(bodySize) ? Number(bodySize) : bodySize);
113
- if (size) throw new Error(size);
114
- if (kind === 'api' && upstream == null) throw new Error('Choose the upstream this host proxies to.');
195
+ if (size) reveal(size, 'settings', '[aria-label="Maximum request body (MB)"]');
196
+ if (kind === 'api' && upstream == null) reveal('Choose an upstream.', 'routing', '[placeholder="Search enabled upstreams…"]');
115
197
  if (kind === 'site_api' && !editing) {
116
198
  const refusal = validateRouteDrafts(routes);
117
- if (refusal) throw new Error(refusal);
199
+ if (refusal) reveal(refusal, 'routing', '.edge-route-list [aria-label="Path prefix"],.edge-route-list input[role=combobox]');
118
200
  }
119
201
  if (kind === 'api' || kind === 'site_api') {
120
202
  for (const path of cleanQuietPaths) {
121
203
  const refusal = validateRequestPath(path, 'A quiet path');
122
- if (refusal) throw new Error(refusal);
204
+ if (refusal) reveal(refusal, 'settings', '[aria-label^="Quiet paths"]');
123
205
  }
124
- if (new Set(cleanQuietPaths).size !== cleanQuietPaths.length) throw new Error('A quiet path is listed twice.');
206
+ if (new Set(cleanQuietPaths).size !== cleanQuietPaths.length) reveal('A quiet path is listed twice.', 'settings', '[aria-label^="Quiet paths"]');
125
207
  // Stored routes not read yet, or unreadable: the server checks.
126
208
  if (kind === 'site_api' && !(editing && storedRoutes == null)) {
127
209
  const uncovered = quietPathUncovered(cleanQuietPaths, routePrefixes);
128
- if (uncovered) throw new Error(`Quiet path ${uncovered} is not under any route prefix.`);
210
+ if (uncovered) reveal(`Quiet path ${uncovered} is not under any route prefix.`, 'settings', '[aria-label^="Quiet paths"]');
129
211
  }
130
212
  }
131
213
  };
132
214
  const checkCertificate = () => {
133
- if (certificateId == null) throw new Error('Choose a certificate.');
215
+ if (certificateId == null) reveal('Choose a certificate.', 'certificate', 'input[role=combobox]');
134
216
  const refusal = validatePoolName(pool.trim());
135
- if (refusal) throw new Error(refusal);
217
+ if (refusal) reveal(refusal, 'settings', '[aria-label="Fleet pool"]');
136
218
  };
137
219
  const checkAll = () => { checkShape(); checkName(); checkKnobs(); checkCertificate(); };
138
220
 
139
221
  const finish = async (): Promise<void> => {
222
+ if (uncertain.current) {
223
+ if (uncertain.current.step === 'create') throw new Error(unconfirmed);
224
+ await observe();
225
+ return;
226
+ }
227
+ guard();
140
228
  checkAll();
141
229
  const values = draft();
142
230
  if (editing) {
143
- const saved = await saveVhost(row.id, buildVhostPayload(values, { create: false }));
231
+ let saved: EdgeVhostRow;
232
+ submitted.current = buildVhostPayload(values, { create: false });
233
+ try { saved = await saveVhost(row.id, submitted.current, guard); }
234
+ catch (error) { if (!refused(error)) unknown('edit'); throw error; }
235
+ if (saved.id !== row.id || saved.kind !== values.kind || saved.is_enabled !== values.is_enabled) unknown('edit');
144
236
  await EdgeVhostModel.invalidate(queryClient);
145
- onDone(saved);
237
+ if (current()) onDone(saved);
146
238
  return;
147
239
  }
148
240
  const state = progress.current;
149
241
  let saved: EdgeVhostRow | null = null;
150
242
  if (values.kind !== 'site_api') {
151
- saved = await saveVhost(null, buildVhostPayload(values, { create: true }));
243
+ try { saved = await saveVhost(null, buildVhostPayload(values, { create: true }), guard); }
244
+ catch (error) { if (!refused(error)) unknown('create'); throw error; }
245
+ if (!Number.isSafeInteger(saved.id) || saved.id <= 0 || saved.kind !== values.kind || saved.is_enabled !== values.is_enabled) unknown('create');
152
246
  } else {
153
247
  // The server checks a quiet path against the routes of a STORED
154
248
  // row, and an enabled row is live at once. So: create disabled
155
249
  // and without quiet paths, land the routes, then one update that
156
250
  // carries the quiet paths and the real enablement.
157
251
  if (state.vhostId == null) {
158
- const created = await saveVhost(null, buildVhostPayload({ ...values, quiet_paths: [], is_enabled: false }, { create: true }));
252
+ let created: EdgeVhostRow;
253
+ try { created = await saveVhost(null, buildVhostPayload({ ...values, quiet_paths: [], is_enabled: false }, { create: true }), guard); }
254
+ catch (error) { if (!refused(error)) unknown('create'); throw error; }
255
+ if (!Number.isSafeInteger(created.id) || created.id <= 0 || created.kind !== 'site_api' || created.is_enabled !== false) unknown('create');
256
+ guard();
159
257
  state.vhostId = created.id;
160
258
  setCreatedId(created.id);
161
259
  }
162
260
  for (const route of routes) {
163
261
  if (state.routes.has(route.path_prefix)) continue;
164
262
  try {
165
- const stored = await saveVhostRoute(null, { vhost: state.vhostId, path_prefix: route.path_prefix, upstream: route.upstream! });
263
+ const stored = await saveVhostRoute(null, { vhost: state.vhostId, path_prefix: route.path_prefix, upstream: route.upstream! }, guard);
264
+ if (!Number.isSafeInteger(stored.id) || stored.id <= 0 || stored.path_prefix !== route.path_prefix || stored.upstream?.id !== route.upstream) unknown('route', route.path_prefix);
265
+ guard();
166
266
  state.routes.set(route.path_prefix, stored.id);
167
267
  } catch (error) {
168
268
  await Promise.allSettled([EdgeVhostModel.invalidate(queryClient), EdgeRouteModel.invalidate(queryClient)]);
169
- throw new Error(`Vhost #${state.vhostId} was created disabled, but the route ${route.path_prefix} was refused: ${reason(error)}. Fix it and finish again; nothing is created twice.`);
269
+ if (!refused(error)) unknown('route', route.path_prefix);
270
+ throw new Error(`Hostname #${state.vhostId} was created disabled, but the route ${route.path_prefix} was refused: ${reason(error)}. Fix it and finish again; nothing is created twice.`);
170
271
  }
171
272
  }
273
+ submitted.current = buildVhostPayload(values, { create: false });
172
274
  try {
173
- saved = await saveVhost(state.vhostId, buildVhostPayload(values, { create: false }));
275
+ saved = await saveVhost(state.vhostId, submitted.current, guard);
276
+ if (saved.id !== state.vhostId || saved.kind !== values.kind || saved.is_enabled !== values.is_enabled) unknown('finish');
174
277
  } catch (error) {
175
278
  await Promise.allSettled([EdgeVhostModel.invalidate(queryClient), EdgeRouteModel.invalidate(queryClient)]);
176
- throw new Error(`Vhost #${state.vhostId} and its routes were created, and it is still disabled. The last step was refused: ${reason(error)}. Fix it and finish again; nothing is created twice.`);
279
+ if (!refused(error)) unknown('finish');
280
+ throw new Error(`Hostname #${state.vhostId} and its routes were created disabled. The last step was refused: ${reason(error)}. Fix it and finish again; nothing is created twice.`);
177
281
  }
178
282
  }
179
283
  await Promise.allSettled([EdgeVhostModel.invalidate(queryClient), EdgeRouteModel.invalidate(queryClient)]);
180
- onDone(saved);
284
+ if (current()) onDone(saved);
181
285
  };
182
286
 
183
- const locked = createdId != null;
184
- const sections = useMemo<FormWizardSection[]>(() => {
185
- const shape: FormWizardSection = {
186
- key: 'shape', label: 'Shape', fields: [], description: 'What should this address do? Each choice shows only the settings it uses.',
187
- content: ({ busy }) => <div className="edge-kind-cards" role="radiogroup" aria-label="What this address serves">
188
- {VHOST_KIND_CARDS.map((card) => <label key={card.value} className={`edge-kind-card${kind === card.value ? ' is-selected' : ''}`}>
189
- <input type="radio" name="edge-vhost-kind" checked={kind === card.value} disabled={busy || locked} onChange={() => setKind(card.value)} />
190
- <i className={`bi ${card.icon}`} />
191
- <b>{card.title}</b>
192
- <span>{card.description}</span>
193
- </label>)}
194
- </div>,
195
- onNext: checkShape,
196
- };
197
- const name: FormWizardSection = {
198
- key: 'name', label: 'Name', fields: [], description: 'The address is the label joined to a domain you hold. It is never typed whole.',
199
- content: ({ busy }) => <div className="edge-form-grid">
200
- <CollectionSelect<DomainRow> model={DomainModel} value={domain} onChange={(_id, picked) => { setDomain(picked ? { id: picked.id, name: picked.name } : null); setCertificate(null); setCertificateId(null); }} label="Domain" required readOnly={editing || locked} disabled={busy} placeholder="Search active domains…"
201
- // A house domain would dead-end at the server's platform gate for anyone else.
202
- defaultParams={{ status: 'active', sort: 'name', ...(superuser ? {} : { group__isnull: false }) }}
203
- help={editing ? 'A vhost cannot be moved to another domain.' : undefined} />
204
- <label className="field"><span className="field-label">Label</span><input className="input" value={label} disabled={busy} onChange={(event) => setLabel(event.target.value.trim().toLowerCase())} placeholder="www" autoComplete="off" /><span className="field-help">Empty serves the bare domain. * serves every one-label subdomain.</span></label>
205
- <div className="edge-server-name" aria-live="polite"><span>Address</span><code>{serverName || '—'}</code></div>
206
- </div>,
207
- onNext: checkName,
208
- };
209
- const knobs: FormWizardSection = {
210
- key: 'knobs', label: 'Settings', fields: [], description: kind ? `Settings for: ${vhostKindTitle(kind)}.` : undefined,
211
- content: ({ busy }) => <div className="edge-form-grid">
212
- {kind === 'api' && <CollectionSelect<EdgeUpstreamRow> model={EdgeUpstreamModel} value={upstream} onChange={(id) => setUpstream(id == null ? null : Number(id))} label="Upstream" required disabled={busy} placeholder="Search enabled upstreams…" defaultParams={{ is_enabled: true, sort: 'name' }} help="Every request on this address goes here. Upstreams are declared by a platform administrator." />}
213
- {kind === 'redirect' && <label className="field"><span className="field-label">Redirect to <em>*</em></span><input className="input" value={redirectTo} disabled={busy} onChange={(event) => setRedirectTo(event.target.value)} placeholder="www.example.com" autoComplete="off" /><span className="field-help">A host name only: no https://, path or port.</span></label>}
214
- {(kind === 'site' || kind === 'site_api') && <label className="field switch-field"><input type="checkbox" checked={spa} disabled={busy} onChange={(event) => setSpa(event.target.checked)} /> Single-page app: unknown paths return index.html instead of a 404</label>}
215
- {kind === 'site_api' && (editing
216
- ? <div className="edge-note"><b>Routes</b><span>{storedRoutes == null ? (routesUnread ? 'Could not be read.' : 'Loading…') : storedRoutes.length ? storedRoutes.join(', ') : 'None yet.'} Manage routes in the detail view.</span></div>
217
- : <div className="field edge-route-list">
218
- <span className="field-label">Routes <em>*</em></span>
219
- {routes.map((route) => <div className="edge-route-row" key={route.key}>
220
- <input className="input" value={route.path_prefix} disabled={busy || progress.current.routes.has(route.path_prefix)} placeholder="/api" aria-label="Path prefix" onChange={(event) => setRoutes((rows) => rows.map((item) => item.key === route.key ? { ...item, path_prefix: event.target.value.trim() } : item))} />
221
- <CollectionSelect<EdgeUpstreamRow> model={EdgeUpstreamModel} value={route.upstream == null ? null : { id: route.upstream, name: route.upstreamName ?? '' }} onChange={(id, picked) => setRoutes((rows) => rows.map((item) => item.key === route.key ? { ...item, upstream: id == null ? null : Number(id), upstreamName: picked?.name } : item))} disabled={busy || progress.current.routes.has(route.path_prefix)} placeholder="Upstream…" defaultParams={{ is_enabled: true, sort: 'name' }} />
222
- <button type="button" className="btn btn-compact" disabled={busy || progress.current.routes.has(route.path_prefix)} aria-label={`Remove route ${route.path_prefix}`} onClick={() => setRoutes((rows) => rows.filter((item) => item.key !== route.key))}><i className="bi bi-x-lg" /></button>
223
- </div>)}
224
- <div><button type="button" className="btn btn-compact" disabled={busy} onClick={() => setRoutes((rows) => [...rows, { key: nextRouteKey.current++, path_prefix: '', upstream: null }])}><i className="bi bi-plus-lg" /> Add route</button></div>
225
- <span className="field-help">Requests under each prefix go to its upstream; everything else is served from the site. At least one is required.</span>
226
- </div>)}
227
- {(kind === 'api' || kind === 'site_api') && <label className="field switch-field"><input type="checkbox" checked={serveStatic} disabled={busy} onChange={(event) => setServeStatic(event.target.checked)} /> Serve the fleet's static files at /static/ instead of proxying them</label>}
228
- {(kind === 'api' || kind === 'site_api') && <PathListEditor label="Quiet paths" help={kind === 'site_api' ? 'Exact paths kept out of the access log, such as health checks. Each must sit under a route prefix.' : 'Exact paths kept out of the access log, such as health checks.'} paths={quietPaths} onChange={setQuietPaths} disabled={busy} />}
229
- {kind !== 'redirect' && <label className="field"><span className="field-label">Largest request body (MB)</span><input className="input" inputMode="numeric" value={bodySize} disabled={busy} onChange={(event) => setBodySize(event.target.value.trim())} /><span className="field-help">1 to 4096. The default is 50.</span></label>}
230
- </div>,
231
- onNext: checkKnobs,
232
- };
233
- const serving: FormWizardSection = {
234
- key: 'certificate', label: 'Certificate', fields: [],
235
- content: ({ busy }) => <div className="edge-form-grid">
236
- <CollectionSelect<CertificateRow> key={domain?.id ?? 'none'} model={CertificateModel} labelField="common_name" value={certificateId == null ? null : { id: certificateId, common_name: certificate?.id === certificateId ? certificate.common_name : row?.certificate?.common_name ?? '' }} onChange={(id, picked) => { setCertificateId(id == null ? null : Number(id)); setCertificate(picked ?? null); }} label="Certificate" required disabled={busy || !domain} placeholder={domain ? 'Search this domain’s certificates…' : 'Choose a domain first'} defaultParams={domain ? { domain__exact: domain.id } : {}} />
237
- {covered === false && <div className="edge-warning" role="status"><i className="bi bi-exclamation-triangle" /> This certificate does not cover <code>{serverName}</code>. The vhost will not enable until it does. Save it disabled to stage it.</div>}
238
- <label className="field"><span className="field-label">Pool</span><input className="input" value={pool} disabled={busy} onChange={(event) => setPool(event.target.value)} autoComplete="off" /><span className="field-help">Which group of edge nodes serves this address. It must be a pool this deployment declares; most have only “default”.</span></label>
239
- <label className="field switch-field"><input type="checkbox" checked={enabled} disabled={busy} onChange={(event) => setEnabled(event.target.checked)} /> Enabled: start serving once saved</label>
240
- </div>,
241
- onNext: checkCertificate,
242
- };
243
- const review: FormWizardSection = {
244
- key: 'review', label: 'Review', fields: [],
245
- content: <dl className="edge-review">
246
- <dt>Address</dt><dd><code>{serverName || '—'}</code></dd>
247
- <dt>Serves</dt><dd>{kind ? vhostKindTitle(kind) : '—'}</dd>
248
- {kind === 'redirect' && <><dt>Redirects to</dt><dd><code>{redirectTo || '—'}</code></dd></>}
249
- {kind === 'site_api' && !editing && <><dt>Routes</dt><dd>{routes.length ? routes.map((route) => `${route.path_prefix} → ${route.upstreamName ?? `#${route.upstream}`}`).join(', ') : 'None'}</dd></>}
250
- {(kind === 'site' || kind === 'site_api') && <><dt>Single-page app</dt><dd>{spa ? 'Yes' : 'No'}</dd></>}
251
- {(kind === 'api' || kind === 'site_api') && <><dt>Static files</dt><dd>{serveStatic ? 'Served at /static/' : 'Not served here'}</dd><dt>Quiet paths</dt><dd>{cleanQuietPaths.length ? cleanQuietPaths.join(', ') : 'None'}</dd></>}
252
- {kind !== 'redirect' && <><dt>Largest body</dt><dd>{bodySize} MB</dd></>}
253
- <dt>Pool</dt><dd>{pool}</dd>
254
- <dt>State</dt><dd>{enabled ? 'Enabled' : 'Disabled'}{covered === false && enabled ? ' — the certificate does not cover this address, so the save will be refused' : ''}</dd>
255
- {locked && <><dt>Progress</dt><dd>Vhost #{createdId} already exists, disabled. Finishing again continues from there.</dd></>}
256
- </dl>,
257
- };
258
- return editing ? [shape, name, knobs, serving] : [shape, name, knobs, serving, review];
259
- // The check* closures read the same state listed here.
260
- // eslint-disable-next-line react-hooks/exhaustive-deps
261
- }, [bodySize, certificate, certificateId, covered, createdId, domain, editing, enabled, kind, label, locked, pool, quietPaths, redirectTo, routes, routesUnread, serveStatic, serverName, spa, storedRoutes, superuser, upstream]);
262
-
263
- return <FormWizard mode={editing ? 'tabs' : 'wizard'} sections={sections} onBusyChange={onBusyChange} onCancel={() => onDone(null)} onFinish={finish} finishText="Create vhost" saveText="Save changes" busyText="Saving…" />;
287
+ useEffect(() => {
288
+ if (!domain) { setDomainOwner(null); return; }
289
+ let live = true;
290
+ setDomainOwner(null); setOwnerError('');
291
+ DomainModel.fetchOne(queryClient, domain.id).then(full => {
292
+ if (!live) return;
293
+ const group = typeof full.group === 'number' ? full.group : full.group?.id ?? null;
294
+ setDomainOwner({ id: full.id, group });
295
+ }).catch(cause => { if (live) setOwnerError(reason(cause)); });
296
+ return () => { live = false; };
297
+ }, [domain?.id, queryClient]);
298
+ const ownerReady = domainOwner?.id === domain?.id;
299
+ const upstreamParams = { is_enabled: true, sort: 'name', ...(domainOwner?.group != null ? { group: domainOwner.group } : { group__isnull: true }) };
300
+ const locked = createdId != null || !!uncertain.current;
301
+ const disabled = busy || !!uncertain.current || !canManage || scope !== openedScope.current;
302
+ const proxies = kind === 'api' || kind === 'site_api';
303
+ const tabs = editing ? ['hostname', 'certificate', 'settings'] : ['hostname', 'certificate', 'review'];
304
+ const titles: Record<string, string> = { hostname: 'Hostname', certificate: 'Certificate', settings: 'Settings', review: 'Review' };
305
+ const submit = async () => {
306
+ if (pending.current || !current()) return;
307
+ pending.current = true; setBusy(true); onBusyChange?.(true); setError('');
308
+ try { await finish(); }
309
+ catch (cause) { if (current()) setError(cause instanceof Error ? cause.message : 'The configuration could not be saved.'); }
310
+ finally { pending.current = false; if (mounted.current) { setBusy(false); onBusyChange?.(false); } }
311
+ };
312
+ const next = () => {
313
+ try {
314
+ if (active === 'hostname') { checkName(); checkShape(); checkKnobs(); }
315
+ if (active === 'certificate') checkCertificate();
316
+ setError(''); setActive(tabs[tabs.indexOf(active) + 1]!);
317
+ } catch (cause) { setError(cause instanceof Error ? cause.message : 'Check these fields.'); }
318
+ };
319
+ const settings = <div className="edge-form-grid">
320
+ <label className="field"><span className="field-label">Pool <EdgeHelp label="About fleet pools">The server must declare this pool. The default is <code>default</code>.</EdgeHelp></span><input className="input" aria-label="Fleet pool" value={pool} disabled={disabled} onChange={event => setPool(event.target.value)} autoComplete="off" /></label>
321
+ {kind !== 'redirect' && <label className="field"><span className="field-label">Maximum request body (MB)</span><input className="input" aria-label="Maximum request body (MB)" inputMode="numeric" value={bodySize} disabled={disabled} onChange={event => setBodySize(event.target.value.trim())} /><span className="field-help">1–4096 MB</span></label>}
322
+ {proxies && <><label className="field switch-field"><input type="checkbox" checked={serveStatic} disabled={disabled} onChange={event => setServeStatic(event.target.checked)} /> Serve static files <EdgeHelp label="About static files">Serve the fleet's files at /static/ rather than proxying them.</EdgeHelp></label><PathListEditor label="Quiet paths" help={kind === 'site_api' ? 'Each exact path must be under a route prefix.' : 'Exact paths omitted from the access log.'} paths={quietPaths} onChange={setQuietPaths} disabled={disabled} /></>}
323
+ </div>;
324
+ const panels: Record<string, ReactNode> = {
325
+ hostname: <div className="edge-form-grid">
326
+ <CollectionSelect<DomainRow> model={DomainModel} value={domain} onChange={(_id, picked) => { setDomain(picked ? { id: picked.id, name: picked.name } : null); setCertificate(null); setCertificateId(null); setUpstream(null); setRoutes([]); }} label="Domain" required readOnly={editing || locked} disabled={disabled} placeholder="Search active domains…" defaultParams={{ status: 'active', sort: 'name', ...(superuser ? {} : { group__isnull: false }) }} />
327
+ <label className="field"><span className="field-label">Label <EdgeHelp label="About hostname labels">Leave empty for the bare domain. Use * for every one-label subdomain.</EdgeHelp></span><input className="input" aria-label="Hostname label" value={label} disabled={disabled || locked} onChange={event => setLabel(event.target.value.trim().toLowerCase())} placeholder="www" autoComplete="off" /></label>
328
+ <div className="edge-server-name" aria-live="polite"><span>Hostname</span><code>{serverName || 'Choose a domain'}</code></div>
329
+ <fieldset className="edge-kind-picker" disabled={disabled || locked}><legend className="field-label">Serves <EdgeHelp label="About serving types">API proxies every request. Site serves published files. Site + API proxies chosen prefixes. Redirect sends requests to another hostname.</EdgeHelp></legend><div className="edge-kind-options">{VHOST_KIND_CARDS.map(card => <label key={card.value} className={`edge-kind-option${kind === card.value ? ' is-selected' : ''}`}><input type="radio" name="edge-vhost-kind" checked={kind === card.value} onChange={() => setKind(card.value)} /><i className={`bi ${card.icon}`} aria-hidden="true" />{card.title}</label>)}</div></fieldset>
330
+ {ownerError && proxies && <div className="form-alert" role="alert">Could not load the domain's upstream scope: {ownerError}</div>}
331
+ {kind === 'api' && <CollectionSelect<EdgeUpstreamRow> model={EdgeUpstreamModel} value={upstream} onChange={id => setUpstream(id == null ? null : Number(id))} label="Upstream" required disabled={disabled || !ownerReady} placeholder="Search enabled upstreams…" defaultParams={upstreamParams} />}
332
+ {kind === 'redirect' && <label className="field"><span className="field-label">Redirect to <em>*</em> <EdgeHelp label="About redirect destinations">Enter a hostname only, without https://, a path or port. The redirect preserves the request path.</EdgeHelp></span><input className="input" value={redirectTo} disabled={disabled} onChange={event => setRedirectTo(event.target.value)} placeholder="www.example.com" autoComplete="off" /></label>}
333
+ {kind === 'site_api' && (editing ? <div className="edge-note"><b>API routes</b><span>{storedRoutes == null ? routesUnread ? 'Unavailable. The server will validate existing routes.' : 'Loading…' : storedRoutes.length ? storedRoutes.join(', ') : 'No routes yet.'} Add or change routes from the hostname's Routes tab after saving.</span></div> : <div className="field edge-route-list"><span className="field-label">API routes <em>*</em></span>{routes.map(route => <div className="edge-route-row" key={route.key}><input className="input" value={route.path_prefix} disabled={disabled || progress.current.routes.has(route.path_prefix)} placeholder="/api" aria-label="Path prefix" onChange={event => setRoutes(rows => rows.map(item => item.key === route.key ? { ...item, path_prefix: event.target.value.trim() } : item))} /><CollectionSelect<EdgeUpstreamRow> model={EdgeUpstreamModel} value={route.upstream == null ? null : { id: route.upstream, name: route.upstreamName ?? '' }} onChange={(id, picked) => setRoutes(rows => rows.map(item => item.key === route.key ? { ...item, upstream: id == null ? null : Number(id), upstreamName: picked?.name } : item))} disabled={disabled || !ownerReady || progress.current.routes.has(route.path_prefix)} label="Upstream" placeholder="Upstream…" defaultParams={upstreamParams} /><button type="button" className="btn btn-compact" disabled={disabled || progress.current.routes.has(route.path_prefix)} aria-label={`Remove route ${route.path_prefix}`} onClick={() => setRoutes(rows => rows.filter(item => item.key !== route.key))}><i className="bi bi-x-lg" /></button></div>)}<div><button type="button" className="btn btn-compact" disabled={disabled} onClick={() => setRoutes(rows => [...rows, { key: nextRouteKey.current++, path_prefix: '', upstream: null }])}><i className="bi bi-plus-lg" /> Add route</button></div></div>)}
334
+ {(kind === 'site' || kind === 'site_api') && <label className="field switch-field"><input type="checkbox" checked={spa} disabled={disabled} onChange={event => setSpa(event.target.checked)} /> Single-page app <EdgeHelp label="About single-page apps">Unknown paths return index.html instead of a 404.</EdgeHelp></label>}
335
+ </div>,
336
+ certificate: <div className="edge-form-grid"><CollectionSelect<CertificateRow> key={domain?.id ?? 'none'} model={CertificateModel} labelField="common_name" value={certificateId == null ? null : { id: certificateId, common_name: certificate?.id === certificateId ? certificate.common_name : row?.certificate?.common_name ?? '' }} onChange={(id, picked) => { setCertificateId(id == null ? null : Number(id)); setCertificate(picked ?? null); }} label="Certificate" required disabled={disabled || !domain} placeholder={domain ? 'Search this domain’s certificates…' : 'Choose a domain first'} defaultParams={domain ? { domain__exact: domain.id } : {}} />
337
+ <p className={covered === false ? 'text-warn' : 'dim'} role="status">{covered == null ? 'Certificate coverage unknown.' : covered ? 'Certificate covers this hostname.' : 'This certificate does not cover the hostname. Save disabled to stage it.'}</p>
338
+ <label className="field switch-field"><input type="checkbox" checked={enabled} disabled={disabled} onChange={event => setEnabled(event.target.checked)} /> Enabled <EdgeHelp label="About enabled configuration">Enabled is saved intent. It does not prove that the fleet has applied this configuration.</EdgeHelp></label>
339
+ </div>,
340
+ settings,
341
+ review: <div className="edge-form-grid"><dl className="edge-review"><dt>Hostname</dt><dd><code>{serverName}</code></dd><dt>Serves</dt><dd>{kind ? vhostKindTitle(kind) : '—'}</dd>{kind === 'redirect' && <><dt>Destination</dt><dd>{redirectTo}</dd></>}{kind === 'api' && <><dt>Upstream</dt><dd>{upstream == null ? 'None' : `#${upstream}`}</dd></>}{kind === 'site_api' && <><dt>Routes</dt><dd>{routes.map(route => `${route.path_prefix} → ${route.upstreamName ?? `#${route.upstream}`}`).join(', ')}</dd></>}<dt>Certificate</dt><dd>{certificate?.common_name ?? row?.certificate?.common_name ?? 'Unknown'}</dd><dt>State</dt><dd>{enabled ? 'Enabled intent' : 'Disabled'}</dd></dl><button type="button" className="btn edge-settings-button" disabled={busy} onClick={() => setActive('settings')}>Other settings <i className="bi bi-sliders" /></button></div>,
342
+ };
343
+ const inspect = async () => {
344
+ if (pending.current || createdId == null) return;
345
+ pending.current = true; setBusy(true); onBusyChange?.(true);
346
+ try { await EdgeVhostModel.invalidate(queryClient); const observed = await EdgeVhostModel.fetchOne(queryClient, createdId); if (current()) onDone(observed); }
347
+ catch (cause) { setError(`Could not inspect saved hostname #${createdId}: ${reason(cause)}`); }
348
+ finally { pending.current = false; if (mounted.current) { setBusy(false); onBusyChange?.(false); } }
349
+ };
350
+ return <div className="edge-vhost-editor" aria-busy={busy || undefined}>
351
+ {error && <div className="form-alert" role="alert">{error}</div>}
352
+ {unconfirmed && <div className="edge-warning" role="status">{unconfirmed}</div>}
353
+ {editing ? <Tabs ariaLabel="Hostname settings" activeKey={active} onActiveKeyChange={key => { if (!busy) { setActive(key); setError(''); } }} items={tabs.map(key => ({ key, label: titles[key], disabled: busy && key !== active, panel: <div className="edge-editor-panel" ref={body}>{panels[key]}</div> }))} /> : <><ol className="edge-create-progress" aria-label="Setup progress">{tabs.map((key, index) => <li key={key} aria-current={key === (active === 'settings' ? 'review' : active) ? 'step' : undefined}><span>{index + 1}</span>{titles[key]}</li>)}</ol><div className="edge-editor-panel" ref={body}>{panels[active]}</div></>}
354
+ <div className="modal-actions edge-editor-actions"><button type="button" className="btn" disabled={busy} onClick={() => onDone(null)}>Cancel</button>{createdId != null && <button type="button" className="btn" disabled={busy} onClick={() => void inspect()}>Inspect saved hostname</button>}{!editing && tabs.indexOf(active) > 0 && <button type="button" className="btn" disabled={busy} onClick={() => { setError(''); setActive(tabs[tabs.indexOf(active) - 1]!); }}>Back</button>}{!editing && active !== 'review' && active !== 'settings' && !uncertain.current ? <button type="button" className="btn btn-primary" disabled={busy} onClick={next}>Next</button> : <button type="button" className="btn btn-primary" disabled={busy || uncertain.current?.step === 'create'} onClick={() => { if (active === 'settings' && !editing && !uncertain.current) { try { checkKnobs(); checkCertificate(); setActive('review'); } catch (cause) { setError(reason(cause)); } } else void submit(); }}>{busy ? 'Saving…' : uncertain.current ? 'Observe outcome' : editing ? 'Save changes' : active === 'settings' ? 'Back to review' : 'Add hostname'}</button>}</div>
355
+ </div>;
264
356
  }
265
357
 
266
- /** Opens the wizard in a modal; resolves with the saved row, or null when cancelled. */
267
- export function openVhostWizard(row: EdgeVhostRow | null = null): Promise<EdgeVhostRow | null> {
358
+ /** Native focused editor; no generic record rail. */
359
+ export function openVhostWizard(row: EdgeVhostRow | null = null, options: { initialTab?: string; signal?: AbortSignal } = {}): Promise<EdgeVhostRow | null> {
268
360
  let pending = false;
269
- return modal.open<EdgeVhostRow | null>((close) => <div className="modal-pad edge-vhost-wizard">
270
- <h2 className="modal-title">{row ? `Edit ${row.server_name ?? 'vhost'}` : 'New vhost'}</h2>
271
- <VhostWizard row={row} onBusyChange={(busy) => { pending = busy; }} onDone={(saved) => close(saved)} />
272
- </div>, { size: 'lg', canDismiss: () => !pending }).then((value) => value ?? null);
361
+ return modal.open<EdgeVhostRow | null>(close => <div className="modal-pad edge-vhost-wizard"><h2 className="modal-title">{row ? `Edit ${row.server_name ?? 'hostname'}` : 'Add hostname'}</h2><VhostWizard row={row} initialTab={options.initialTab} signal={options.signal} onBusyChange={value => { pending = value; }} onDone={saved => { if (!pending) close(saved); else if (saved) close(saved); }} /></div>, { size: 'md', canDismiss: () => !pending, signal: options.signal }).then(value => value ?? null);
273
362
  }
@@ -1,47 +1,46 @@
1
+ import { useEffect, useRef, useState } from 'react';
1
2
  import { useQueryClient } from '@tanstack/react-query';
2
- import { Badge, ModelTable, fmt, modal, toast, type Column, type FilterDef } from '../../ui';
3
- import { useCan } from '../../client/runtime';
3
+ import { Badge, CollectionSelect, ModelTable, modal, toast, type Column, type FilterDef } from '../../ui';
4
+ import { getAuthSessionRevision, useAuthSnapshot, useCan, useTableParams } from '../../client/runtime';
5
+ import { DomainModel, type DomainRow } from '../dns/models';
4
6
  import { EDGE_MANAGE_PERMS, EdgeVhostModel, VHOST_KIND_CARDS, vhostKindTitle, type EdgeVhostRow } from './models';
5
7
  import { VhostDetail } from './VhostDetail';
8
+ import { ServingReadiness } from './ServingReadiness';
6
9
  import { openVhostWizard } from './VhostWizard';
7
10
 
8
11
  const COLUMNS: Column<EdgeVhostRow>[] = [
9
- { key: 'server_name', label: 'Address', sortable: false, hideable: false, render: (row) => <code>{row.server_name ?? '—'}</code> },
10
- { key: 'kind', label: 'Serves', sortable: true, render: (row) => <Badge tone="info">{vhostKindTitle(row.kind)}</Badge> },
11
- { key: 'pool', label: 'Pool', sortable: true },
12
- { key: 'is_enabled', label: 'State', sortable: true, render: (row) => row.is_enabled ? <Badge tone="success">Enabled</Badge> : <Badge tone="muted">Disabled</Badge> },
13
- { key: 'domain', label: 'Domain', sortable: false, render: (row) => row.domain?.name ?? '—' },
14
- { key: 'created', label: 'Created', sortable: true, render: (row) => fmt.date(row.created) },
12
+ { key: 'server_name', label: 'Hostname', sortable: false, hideable: false, render: row => <span className="ops-cell"><strong>{row.server_name ?? '—'}</strong><small>{row.domain?.name}</small></span> },
13
+ { key: 'kind', label: 'Serves', sortable: true, render: row => <Badge tone="info">{vhostKindTitle(row.kind)}</Badge> },
14
+ { key: 'is_enabled', label: 'State', sortable: true, render: row => <Badge tone={row.is_enabled ? 'info' : 'muted'}>{row.is_enabled ? 'Enabled' : 'Disabled'}</Badge> },
15
15
  ];
16
-
17
16
  const FILTERS: FilterDef[] = [
18
- { key: 'kind', label: 'Serves', type: 'select', options: VHOST_KIND_CARDS.map((card) => ({ value: card.value, label: card.title })) },
17
+ { key: 'kind', label: 'Serves', type: 'select', options: VHOST_KIND_CARDS.map(card => ({ value: card.value, label: card.title })) },
19
18
  { key: 'is_enabled', label: 'State', type: 'boolean', trueLabel: 'Enabled', falseLabel: 'Disabled' },
20
- { key: 'pool', label: 'Pool', type: 'text' },
21
19
  ];
22
20
 
23
21
  export function VhostsPage() {
24
22
  const queryClient = useQueryClient();
23
+ const [revision, setRevision] = useState(0);
24
+ const changed = () => setRevision(value => value + 1);
25
25
  const canManage = useCan(EDGE_MANAGE_PERMS).can;
26
+ const auth = useAuthSnapshot();
27
+ const scope = `${auth.uid}:${getAuthSessionRevision()}`;
28
+ const lifetime = useRef<AbortController | null>(null);
29
+ useEffect(() => { const controller = new AbortController(); lifetime.current = controller; return () => controller.abort(); }, [scope, canManage]);
30
+ const params = useTableParams({ sort: 'label' });
31
+ const domain = params.filterValue('domain');
26
32
  const open = async (row: EdgeVhostRow) => {
27
- try {
28
- await EdgeVhostModel.fetchOne(queryClient, row.id);
29
- } catch (error) {
30
- toast.error(error instanceof Error ? error.message : 'Could not open the vhost');
31
- return;
32
- }
33
- await modal.detail((close) => <VhostDetail id={row.id} onClose={() => close(null)} />);
33
+ const signal = lifetime.current?.signal;
34
+ try { await EdgeVhostModel.fetchOne(queryClient, row.id); }
35
+ catch (error) { if (!signal?.aborted) toast.error(error instanceof Error ? error.message : 'Could not open the hostname'); return; }
36
+ let pending = false;
37
+ await modal.open(close => <VhostDetail id={row.id} onChanged={changed} onBusyChange={value => { pending = value; }} onClose={() => { if (!pending) close(null); }} />, { size: 'lg', signal, canDismiss: () => !pending });
34
38
  };
35
39
  const create = async () => {
36
- const saved = await openVhostWizard();
37
- if (saved) toast.success(`${saved.server_name ?? 'Vhost'} created${saved.is_enabled ? '' : ', disabled'}.`);
40
+ if (!canManage) return;
41
+ const signal = lifetime.current?.signal;
42
+ const saved = await openVhostWizard(null, { signal });
43
+ if (saved && !signal?.aborted) { changed(); toast.success(`${saved.server_name ?? 'Hostname'} configuration saved${saved.is_enabled ? ', enabled intent' : ', disabled'}.`); await open(saved); }
38
44
  };
39
- return <ModelTable<EdgeVhostRow>
40
- model={EdgeVhostModel} eyebrow="Infrastructure · Edge" title="Vhosts"
41
- searchable searchPlaceholder="Search label, kind or pool"
42
- columns={COLUMNS} filters={FILTERS} defaultSort="label"
43
- columnChooser persistState persistKey="admin:edge:vhosts"
44
- onRowClick={(row) => void open(row)}
45
- {...(canManage ? { addLabel: 'New vhost', onAdd: () => void create() } : {})}
46
- />;
45
+ return <div className="edge-serving-page"><header className="page-header edge-serving-heading"><div><div className="eyebrow">Network &amp; hosting</div><h1>Serving</h1><ServingReadiness refreshRevision={revision} /></div>{canManage && <button type="button" className="btn btn-primary" onClick={() => void create()}><i className="bi bi-plus-lg" /> Add hostname</button>}</header><div className="edge-domain-filter"><CollectionSelect<DomainRow> model={DomainModel} value={domain ? Number(domain) : null} label="Domain" placeholder="All domains" defaultParams={{ sort: 'name' }} onChange={id => params.setFilter('domain', id == null ? null : String(id))} /></div><ModelTable<EdgeVhostRow> model={EdgeVhostModel} title="Hostnames" searchable searchPlaceholder="Search label, type or pool" columns={COLUMNS} filters={FILTERS} defaultSort="label" columnChooser persistState persistKey="admin:edge:vhosts" onRowClick={row => void open(row)} /></div>;
47
46
  }