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.
- package/docs/README.md +7 -1
- package/docs/admin-deployments.md +201 -0
- package/docs/admin-dns-certificates.md +3 -3
- package/docs/admin-dns-domains.md +92 -0
- package/docs/admin-dns.md +4 -2
- package/docs/admin-edge.md +200 -196
- package/docs/admin-geolocation.md +100 -0
- package/docs/admin-maintenance.md +133 -0
- package/docs/admin-modals.md +17 -2
- package/docs/admin-operational-fleet.md +206 -0
- package/docs/admin-presentation.md +51 -0
- package/docs/admin-signin.md +12 -2
- package/docs/client.md +7 -0
- package/docs/combobox.md +18 -0
- package/docs/popover.md +5 -4
- package/package.json +1 -1
- package/src/admin/core/OperationsHelp.tsx +10 -0
- package/src/admin/core/index.ts +6 -4
- package/src/admin/core/route-context.ts +6 -0
- package/src/admin/deployments/ApiDeploymentDetail.tsx +58 -0
- package/src/admin/deployments/DeploymentsPage.tsx +73 -0
- package/src/admin/deployments/WebAppDeploySetup.tsx +49 -0
- package/src/admin/deployments/WebAppDeploymentDetail.tsx +59 -0
- package/src/admin/deployments/WebAppDetail.tsx +104 -0
- package/src/admin/deployments/WebAppEditDialog.tsx +70 -0
- package/src/admin/deployments/WebAppKeyDialog.tsx +25 -0
- package/src/admin/deployments/WebAppOnboardingDialog.tsx +217 -0
- package/src/admin/deployments/WebAppRollbackDialog.tsx +82 -0
- package/src/admin/deployments/WebAppUploadPanel.tsx +132 -0
- package/src/admin/deployments/api.ts +158 -0
- package/src/admin/deployments/authority-state.ts +9 -0
- package/src/admin/deployments/deployment-state.ts +13 -0
- package/src/admin/deployments/index.ts +4 -0
- package/src/admin/deployments/onboarding-state.ts +11 -0
- package/src/admin/deployments/permissions.ts +37 -0
- package/src/admin/deployments/receipts.ts +78 -0
- package/src/admin/deployments/sanitize.ts +73 -0
- package/src/admin/deployments/shared.tsx +39 -0
- package/src/admin/deployments/types.ts +238 -0
- package/src/admin/deployments/upload.ts +53 -0
- package/src/admin/deployments/useDeploymentObservation.ts +104 -0
- package/src/admin/dns/DomainDetail.tsx +10 -6
- package/src/admin/dns/DomainOnboardingContent.tsx +44 -0
- package/src/admin/dns/DomainOnboardingDialog.tsx +5 -24
- package/src/admin/dns/DomainPurchaseWizard.tsx +1 -1
- package/src/admin/dns/DomainRegisterExisting.tsx +63 -0
- package/src/admin/dns/DomainRegistrarSettings.tsx +52 -0
- package/src/admin/dns/DomainsPage.tsx +22 -13
- package/src/admin/dns/SharedValidationDialog.tsx +1 -1
- package/src/admin/dns/api.ts +4 -2
- package/src/admin/dns/domain-navigation.ts +16 -0
- package/src/admin/dns/registrar-api.ts +13 -0
- package/src/admin/domains/assistant.ts +5 -4
- package/src/admin/domains/communications.ts +10 -9
- package/src/admin/domains/identity.ts +8 -7
- package/src/admin/domains/infrastructure.ts +19 -13
- package/src/admin/domains/lazy-page.ts +6 -0
- package/src/admin/domains/observability.ts +6 -5
- package/src/admin/domains/operations.ts +22 -7
- package/src/admin/domains/security.ts +16 -15
- package/src/admin/edge/EdgeHelp.tsx +10 -0
- package/src/admin/edge/ServingReadiness.tsx +54 -0
- package/src/admin/edge/VhostDetail.tsx +145 -139
- package/src/admin/edge/VhostWizard.tsx +204 -115
- package/src/admin/edge/VhostsPage.tsx +27 -28
- package/src/admin/edge/api.ts +24 -10
- package/src/admin/edge/models.ts +4 -4
- package/src/admin/edge/serving-data.ts +78 -0
- package/src/admin/fleet/CapacityEditor.tsx +81 -0
- package/src/admin/fleet/CapacityInspector.tsx +19 -0
- package/src/admin/fleet/CapacityProgress.tsx +9 -0
- package/src/admin/fleet/FleetHelp.tsx +7 -0
- package/src/admin/fleet/FleetOverview.tsx +150 -0
- package/src/admin/fleet/FleetPage.tsx +11 -0
- package/src/admin/fleet/FleetWorkspace.tsx +82 -0
- package/src/admin/fleet/ScaleFleetDialog.tsx +169 -0
- package/src/admin/fleet/access.ts +22 -0
- package/src/admin/fleet/api.ts +77 -0
- package/src/admin/fleet/data.ts +148 -0
- package/src/admin/fleet/index.ts +8 -0
- package/src/admin/fleet/operations.ts +179 -0
- package/src/admin/fleet/standalone-data.ts +87 -0
- package/src/admin/fleet/types.ts +208 -0
- package/src/admin/geolocation/GeoLocationPage.tsx +171 -0
- package/src/admin/geolocation/data.ts +138 -0
- package/src/admin/geolocation/index.ts +3 -0
- package/src/admin/identity/members/GroupMembersPanel.tsx +2 -8
- package/src/admin/index.ts +8 -0
- package/src/admin/maintenance/MaintenanceDialogs.tsx +161 -0
- package/src/admin/maintenance/MaintenancePage.tsx +442 -0
- package/src/admin/maintenance/data.ts +486 -0
- package/src/admin/maintenance/index.ts +3 -0
- package/src/admin/maintenance/operations.ts +208 -0
- package/src/admin/public/infrastructure.ts +1 -0
- package/src/admin/public/operations.ts +3 -0
- package/src/admin/registry.ts +6 -2
- package/src/admin/settings/SettingDetail.tsx +7 -2
- package/src/admin/settings/SettingEditor.tsx +2 -1
- package/src/admin/settings/SettingsPage.tsx +3 -1
- package/src/admin/settings/model.ts +1 -0
- package/src/admin/signin/SigninPage.tsx +17 -3
- package/src/admin/signin/api.ts +2 -0
- package/src/charts/MetricsChartView.tsx +4 -11
- package/src/charts/MetricsMiniWidget.tsx +3 -15
- package/src/client/client.ts +6 -0
- package/src/client/deployments-mock.ts +375 -0
- package/src/client/domain-operations-mock.ts +37 -0
- package/src/client/geo-provider-mock.ts +82 -0
- package/src/client/maintenance-mock.ts +285 -0
- package/src/client/mock.ts +52 -10
- package/src/client/operational-fleet-mock.ts +279 -0
- package/src/client/operations-mock.ts +85 -0
- package/src/client/serving-mock.ts +22 -0
- package/src/styles/components/admin-deployments.css +35 -0
- package/src/styles/components/admin-domains.css +12 -0
- package/src/styles/components/admin-edge.css +125 -1
- package/src/styles/components/admin-geolocation.css +40 -0
- package/src/styles/components/admin-maintenance.css +127 -0
- package/src/styles/components/admin-operational-fleet.css +38 -0
- package/src/styles/components/admin-operations.css +9 -0
- package/src/styles/components/admin-signin.css +2 -0
- package/src/styles/components/combobox.css +12 -2
- package/src/styles/components/fleet-page.css +9 -0
- package/src/styles/index.css +7 -0
- package/src/ui/ComboBox.tsx +39 -8
- package/src/ui/FilterBar.tsx +3 -5
- package/src/ui/ModelTable.tsx +2 -8
- package/src/ui/Popover.tsx +15 -7
- package/src/ui/date/TimePicker.tsx +4 -14
- package/src/ui/image-editor/ImageEditor.tsx +4 -5
- package/src/ui/schema-form-core.tsx +3 -5
|
@@ -1,11 +1,12 @@
|
|
|
1
|
-
import { useEffect,
|
|
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,
|
|
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
|
-
//
|
|
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)
|
|
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
|
-
|
|
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)
|
|
184
|
+
if (!domain) reveal('Choose a domain.', 'address', 'input[role=combobox]');
|
|
103
185
|
const refusal = validateVhostLabel(label);
|
|
104
|
-
if (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)
|
|
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)
|
|
114
|
-
if (kind === 'api' && upstream == null)
|
|
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)
|
|
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)
|
|
204
|
+
if (refusal) reveal(refusal, 'settings', '[aria-label^="Quiet paths"]');
|
|
123
205
|
}
|
|
124
|
-
if (new Set(cleanQuietPaths).size !== cleanQuietPaths.length)
|
|
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)
|
|
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)
|
|
215
|
+
if (certificateId == null) reveal('Choose a certificate.', 'certificate', 'input[role=combobox]');
|
|
134
216
|
const refusal = validatePoolName(pool.trim());
|
|
135
|
-
if (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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
};
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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
|
-
/**
|
|
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>(
|
|
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,
|
|
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: '
|
|
10
|
-
{ key: 'kind', label: 'Serves', sortable: true, render:
|
|
11
|
-
{ key: '
|
|
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(
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
37
|
-
|
|
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 & 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
|
}
|