portal-mojo 0.2.8 → 0.2.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +59 -11
- package/docs/DetailView.md +3 -2
- package/docs/README.md +6 -2
- package/docs/account.md +4 -3
- package/docs/admin-bouncer.md +3 -3
- package/docs/admin-credentials.md +4 -3
- package/docs/admin-devices-geoip.md +1 -1
- package/docs/admin-dns-certificates.md +2 -2
- package/docs/admin-dns-records.md +1 -1
- package/docs/admin-edge.md +210 -0
- package/docs/admin-identity-directories-sources.json +2 -2
- package/docs/admin-identity-directories.md +9 -0
- package/docs/admin-identity-users.md +2 -2
- package/docs/admin-jobs.md +4 -4
- package/docs/admin-media-rendering.md +142 -0
- package/docs/admin-modals.md +7 -0
- package/docs/admin-monitoring.md +4 -4
- package/docs/admin-network-security.md +2 -2
- package/docs/admin-phonehub.md +4 -1
- package/docs/admin-storage.md +5 -2
- package/docs/calendar.md +1 -1
- package/docs/client.md +159 -26
- package/docs/collection-multiselect.md +2 -2
- package/docs/combobox.md +2 -2
- package/docs/dataview.md +1 -1
- package/docs/datepicker.md +3 -3
- package/docs/daterange.md +1 -1
- package/docs/datetimepicker.md +1 -1
- package/docs/defineModel.md +40 -7
- package/docs/detail-primitives.md +1 -1
- package/docs/feedback.md +9 -8
- package/docs/file-upload-ux.md +2 -2
- package/docs/idioms.md +1 -1
- package/docs/loading.md +2 -2
- package/docs/location-address.md +2 -1
- package/docs/markdown.md +3 -3
- package/docs/menus-and-access.md +21 -1
- package/docs/multiselect-dropdown.md +1 -1
- package/docs/popover.md +1 -1
- package/docs/record-feed.md +2 -2
- package/docs/taginput.md +2 -1
- package/docs/timepicker.md +1 -1
- package/docs/timezone-select.md +4 -3
- package/docs/user-menu.md +4 -3
- package/docs/worldmap.md +2 -2
- package/package.json +3 -2
- package/src/account/AccountModal.tsx +3 -2
- package/src/account/index.ts +2 -2
- package/src/admin/core/index.ts +1 -1
- package/src/admin/dns/models.ts +1 -1
- package/src/admin/domains/infrastructure.ts +12 -2
- package/src/admin/domains/security.ts +3 -1
- package/src/admin/edge/BlocklistPage.tsx +110 -0
- package/src/admin/edge/UpstreamsPage.tsx +113 -0
- package/src/admin/edge/VhostDetail.tsx +166 -0
- package/src/admin/edge/VhostWizard.tsx +273 -0
- package/src/admin/edge/VhostsPage.tsx +47 -0
- package/src/admin/edge/api.ts +56 -0
- package/src/admin/edge/index.ts +15 -0
- package/src/admin/edge/models.ts +395 -0
- package/src/admin/edge/permissions.ts +13 -0
- package/src/admin/identity/users/PersonalApiKeysPage.tsx +10 -0
- package/src/admin/identity/users/sections/actions.tsx +3 -3
- package/src/admin/index.ts +1 -0
- package/src/admin/phonehub/api.ts +5 -3
- package/src/admin/public/infrastructure.ts +1 -0
- package/src/admin/registry.ts +2 -1
- package/src/admin/storage/MediaRenderingPage.tsx +258 -0
- package/src/admin/storage/file-renditions.ts +12 -0
- package/src/admin/storage/index.ts +2 -0
- package/src/admin/storage/rendition-settings.ts +264 -0
- package/src/charts/exportChart.ts +1 -1
- package/src/client/action-result.ts +23 -61
- package/src/client/active-group.ts +9 -3
- package/src/client/auth.ts +18 -4
- package/src/client/client.ts +49 -10
- package/src/client/errors.ts +57 -2
- package/src/client/group-context.ts +2 -0
- package/src/client/group.tsx +112 -20
- package/src/client/hooks.ts +7 -3
- package/src/client/mock.ts +958 -14
- package/src/client/model.ts +15 -3
- package/src/client/renditions-mock.ts +212 -0
- package/src/client/scoped.ts +19 -5
- package/src/styles/components/account.css +304 -0
- package/src/styles/components/admin-assistant.css +28 -0
- package/src/styles/components/admin-bouncer.css +53 -0
- package/src/styles/components/admin-cloudwatch.css +23 -0
- package/src/styles/components/admin-dashboard.css +16 -0
- package/src/styles/components/admin-devices.css +260 -0
- package/src/styles/components/admin-dns-certificates.css +28 -0
- package/src/styles/components/admin-dns-registrar.css +1 -0
- package/src/styles/components/admin-dns.css +43 -0
- package/src/styles/components/admin-edge.css +1 -0
- package/src/styles/components/admin-incidents.css +14 -0
- package/src/styles/components/admin-jobs.css +237 -0
- package/src/styles/components/admin-media-rendering.css +38 -0
- package/src/styles/components/admin-messaging.css +3 -0
- package/src/styles/components/admin-monitoring.css +152 -0
- package/src/styles/components/admin-network.css +406 -0
- package/src/styles/components/admin-phonehub.css +61 -0
- package/src/styles/components/admin-push.css +30 -0
- package/src/styles/components/admin-rules.css +23 -0
- package/src/styles/components/admin-security.css +37 -0
- package/src/styles/components/admin-shortlinks.css +8 -0
- package/src/styles/components/admin-signin.css +64 -0
- package/src/styles/components/admin-storage.css +4 -0
- package/src/styles/components/auth.css +162 -0
- package/src/styles/components/calendar.css +228 -0
- package/src/styles/components/charts-c2.css +249 -0
- package/src/styles/components/collection-multiselect.css +114 -0
- package/src/styles/components/collection-select.css +63 -0
- package/src/styles/components/combobox.css +42 -0
- package/src/styles/components/dataview.css +139 -0
- package/src/styles/components/datepicker.css +106 -0
- package/src/styles/components/daterange.css +119 -0
- package/src/styles/components/datetimepicker.css +230 -0
- package/src/styles/components/detail-primitives.css +246 -0
- package/src/styles/components/detailview.css +69 -0
- package/src/styles/components/drawer.css +111 -0
- package/src/styles/components/file-upload.css +112 -0
- package/src/styles/components/form-wizard.css +11 -0
- package/src/styles/components/formview.css +74 -0
- package/src/styles/components/group-admin.css +302 -0
- package/src/styles/components/guardrail.css +29 -0
- package/src/styles/components/idioms.css +84 -0
- package/src/styles/components/image-editor.css +80 -0
- package/src/styles/components/loading.css +200 -0
- package/src/styles/components/location-address.css +32 -0
- package/src/styles/components/markdown.css +192 -0
- package/src/styles/components/multiselect-dropdown.css +92 -0
- package/src/styles/components/popover.css +35 -0
- package/src/styles/components/record-feed.css +161 -0
- package/src/styles/components/right-panel.css +37 -0
- package/src/styles/components/tabs.css +26 -0
- package/src/styles/components/taginput.css +132 -0
- package/src/styles/components/timepicker.css +282 -0
- package/src/styles/components/user-admin.css +189 -0
- package/src/styles/components/user-menu.css +98 -0
- package/src/styles/components/worldmap.css +97 -0
- package/src/styles/core.css +807 -0
- package/src/styles/index.css +69 -0
- package/src/styles/required-tokens.json +32 -0
- package/src/ui/FormWizard.tsx +9 -3
- package/src/ui/ThemeProvider.tsx +1 -1
- package/src/ui/detail/index.ts +2 -2
- package/src/ui/index.ts +3 -2
- package/src/ui/loading.tsx +1 -1
- package/src/ui/toast.tsx +1 -1
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { useQueryClient } from '@tanstack/react-query';
|
|
3
|
+
import { useCan } from '../../client/runtime';
|
|
4
|
+
import { ArmedButton, CollectionSelect, DetailView, Eyebrow, FlatRow, fmt, modal, toast } from '../../ui';
|
|
5
|
+
import { deleteVhostRoute, deleteVhost, saveVhostRoute, saveVhost } from './api';
|
|
6
|
+
import {
|
|
7
|
+
EDGE_MANAGE_PERMS, EdgeRouteModel, EdgeUpstreamModel, EdgeVhostModel, validateRoutePrefix, vhostKindTitle,
|
|
8
|
+
type EdgeRouteRow, type EdgeUpstreamRow, type EdgeVhostRow,
|
|
9
|
+
} from './models';
|
|
10
|
+
import { openVhostWizard } from './VhostWizard';
|
|
11
|
+
|
|
12
|
+
function message(error: unknown, fallback: string): string {
|
|
13
|
+
return error instanceof Error ? error.message : fallback;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Route CRUD lives here: the wizard creates routes, the detail owns them after. */
|
|
17
|
+
function VhostRoutes({ vhost, canManage }: { vhost: EdgeVhostRow; canManage: boolean }) {
|
|
18
|
+
const queryClient = useQueryClient();
|
|
19
|
+
const query = EdgeRouteModel.useList({ vhost: vhost.id, sort: 'path_prefix', size: 200 });
|
|
20
|
+
const [editing, setEditing] = useState<number | 'new' | null>(null);
|
|
21
|
+
const [prefix, setPrefix] = useState('');
|
|
22
|
+
const [upstream, setUpstream] = useState<{ id: number; name: string } | null>(null);
|
|
23
|
+
const [busy, setBusy] = useState(false);
|
|
24
|
+
const [error, setError] = useState('');
|
|
25
|
+
const rows = query.data?.rows ?? [];
|
|
26
|
+
|
|
27
|
+
const begin = (route: EdgeRouteRow | null) => {
|
|
28
|
+
setEditing(route ? route.id : 'new');
|
|
29
|
+
setPrefix(route?.path_prefix ?? '');
|
|
30
|
+
setUpstream(route?.upstream ? { id: route.upstream.id, name: route.upstream.name } : null);
|
|
31
|
+
setError('');
|
|
32
|
+
};
|
|
33
|
+
const refresh = () => Promise.allSettled([EdgeRouteModel.invalidate(queryClient), EdgeVhostModel.invalidate(queryClient)]);
|
|
34
|
+
const submit = async () => {
|
|
35
|
+
const refusal = validateRoutePrefix(prefix) ?? (upstream ? null : 'Choose an upstream.');
|
|
36
|
+
if (refusal) { setError(refusal); return; }
|
|
37
|
+
setBusy(true); setError('');
|
|
38
|
+
try {
|
|
39
|
+
await saveVhostRoute(editing === 'new' ? null : editing, { ...(editing === 'new' ? { vhost: vhost.id } : {}), path_prefix: prefix, upstream: upstream!.id });
|
|
40
|
+
setEditing(null);
|
|
41
|
+
} catch (cause) {
|
|
42
|
+
setError(message(cause, 'The route was not saved'));
|
|
43
|
+
} finally {
|
|
44
|
+
await refresh();
|
|
45
|
+
setBusy(false);
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
const remove = async (route: EdgeRouteRow) => {
|
|
49
|
+
try {
|
|
50
|
+
await deleteVhostRoute(route.id);
|
|
51
|
+
toast.success(`Route ${route.path_prefix} deleted.`);
|
|
52
|
+
} catch (cause) {
|
|
53
|
+
toast.error(message(cause, 'The route was not deleted'));
|
|
54
|
+
} finally {
|
|
55
|
+
await refresh();
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
return <div className="edge-routes">
|
|
60
|
+
<Eyebrow>Proxied paths</Eyebrow>
|
|
61
|
+
<p className="dim">Requests under each prefix go to its upstream. Everything else is served from the site.</p>
|
|
62
|
+
{query.isPending && <div className="dim">Loading routes…</div>}
|
|
63
|
+
{query.error && <div className="text-bad">{query.error.message}</div>}
|
|
64
|
+
{!query.isPending && !query.error && rows.length === 0 && <div className="edge-warning"><i className="bi bi-exclamation-triangle" /> No routes. This address proxies nothing until one is added.</div>}
|
|
65
|
+
{rows.map((route) => <div className="edge-route-item" key={route.id}>
|
|
66
|
+
<code>{route.path_prefix}</code>
|
|
67
|
+
<span><i className="bi bi-arrow-right" /> {route.upstream?.name ?? '—'}</span>
|
|
68
|
+
{canManage && <span className="edge-route-actions">
|
|
69
|
+
<button type="button" className="btn btn-compact" disabled={busy} onClick={() => begin(route)}>Edit</button>
|
|
70
|
+
<ArmedButton className="btn-compact" label="Delete" armedLabel={`Click again to stop proxying ${route.path_prefix}`} disabled={busy} onConfirm={() => remove(route)} />
|
|
71
|
+
</span>}
|
|
72
|
+
</div>)}
|
|
73
|
+
{canManage && editing == null && <div><button type="button" className="btn" onClick={() => begin(null)}><i className="bi bi-plus-lg" /> Add route</button></div>}
|
|
74
|
+
{canManage && editing != null && <div className="edge-route-editor">
|
|
75
|
+
{error && <div className="form-alert" role="alert">{error}</div>}
|
|
76
|
+
<label className="field"><span className="field-label">Path prefix <em>*</em></span><input className="input" value={prefix} disabled={busy} onChange={(event) => setPrefix(event.target.value.trim())} placeholder="/api" autoComplete="off" /></label>
|
|
77
|
+
<CollectionSelect<EdgeUpstreamRow> model={EdgeUpstreamModel} value={upstream} onChange={(id, picked) => setUpstream(id == null ? null : { id: Number(id), name: picked?.name ?? '' })} label="Upstream" required disabled={busy} placeholder="Search enabled upstreams…" defaultParams={{ is_enabled: true, sort: 'name' }} />
|
|
78
|
+
<div className="modal-actions"><button type="button" className="btn" disabled={busy} onClick={() => setEditing(null)}>Cancel</button><button type="button" className="btn btn-primary" disabled={busy} onClick={() => void submit()}>{busy ? 'Saving…' : editing === 'new' ? 'Add route' : 'Save route'}</button></div>
|
|
79
|
+
</div>}
|
|
80
|
+
</div>;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function VhostDetail({ id, onClose }: { id: number; onClose: () => void }) {
|
|
84
|
+
const queryClient = useQueryClient();
|
|
85
|
+
const query = EdgeVhostModel.useOne(id);
|
|
86
|
+
const canManage = useCan(EDGE_MANAGE_PERMS).can;
|
|
87
|
+
const [busy, setBusy] = useState(false);
|
|
88
|
+
const [error, setError] = useState('');
|
|
89
|
+
const vhost = query.data;
|
|
90
|
+
if (query.isPending) return <div className="modal-pad dim">Loading vhost…</div>;
|
|
91
|
+
if (!vhost || query.error) return <div className="modal-pad text-bad">{query.error?.message ?? 'Vhost not found'}</div>;
|
|
92
|
+
|
|
93
|
+
const setEnabled = async (next: boolean) => {
|
|
94
|
+
setBusy(true); setError('');
|
|
95
|
+
try {
|
|
96
|
+
const saved = await saveVhost(vhost.id, { is_enabled: next });
|
|
97
|
+
queryClient.setQueryData(EdgeVhostModel.keys.one(vhost.id), saved);
|
|
98
|
+
toast.success(`${saved.server_name ?? 'Vhost'} ${next ? 'enabled' : 'disabled'}.`);
|
|
99
|
+
} catch (cause) {
|
|
100
|
+
// The server's own words: coverage, a duplicate name, an orphaned quiet path.
|
|
101
|
+
setError(message(cause, 'The change was refused'));
|
|
102
|
+
} finally {
|
|
103
|
+
await EdgeVhostModel.invalidate(queryClient);
|
|
104
|
+
setBusy(false);
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
const edit = async () => {
|
|
108
|
+
const saved = await openVhostWizard(vhost);
|
|
109
|
+
if (saved) { queryClient.setQueryData(EdgeVhostModel.keys.one(vhost.id), saved); toast.success(`${saved.server_name ?? 'Vhost'} saved.`); }
|
|
110
|
+
};
|
|
111
|
+
const remove = async () => {
|
|
112
|
+
const name = vhost.server_name ?? 'this address';
|
|
113
|
+
const ok = await modal.confirm({
|
|
114
|
+
title: `Delete ${name}?`,
|
|
115
|
+
message: `${name} stops being served and its routes are deleted with it. This cannot be undone. To take it out of service and keep it, disable it instead.`,
|
|
116
|
+
confirmText: 'Delete vhost', danger: true,
|
|
117
|
+
});
|
|
118
|
+
if (!ok) return;
|
|
119
|
+
try {
|
|
120
|
+
await deleteVhost(vhost.id);
|
|
121
|
+
await Promise.allSettled([EdgeVhostModel.invalidate(queryClient), EdgeRouteModel.invalidate(queryClient)]);
|
|
122
|
+
toast.success(`${vhost.server_name ?? 'Vhost'} deleted.`);
|
|
123
|
+
onClose();
|
|
124
|
+
} catch (cause) {
|
|
125
|
+
setError(message(cause, 'The vhost was not deleted'));
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
const proxies = vhost.kind === 'api' || vhost.kind === 'site_api';
|
|
129
|
+
const policy = vhost.mojosec_policy && Object.keys(vhost.mojosec_policy).length ? vhost.mojosec_policy : null;
|
|
130
|
+
|
|
131
|
+
return <DetailView
|
|
132
|
+
icon="bi-hdd-network" title={vhost.server_name ?? `Vhost #${vhost.id}`} subtitle={`${vhostKindTitle(vhost.kind)} · ${vhost.domain?.name ?? ''}`}
|
|
133
|
+
chips={[{ text: vhost.is_enabled ? 'Enabled' : 'Disabled', tone: vhost.is_enabled ? 'success' : 'muted' }]}
|
|
134
|
+
{...(canManage ? { active: { value: vhost.is_enabled, onChange: (next: boolean) => void setEnabled(next), disabled: busy } } : {})}
|
|
135
|
+
contextMenu={[
|
|
136
|
+
{ label: 'Edit vhost', icon: 'bi-pencil', permissions: EDGE_MANAGE_PERMS, disabled: busy, onSelect: () => void edit() },
|
|
137
|
+
{ divider: true },
|
|
138
|
+
{ label: 'Delete vhost', icon: 'bi-trash', permissions: EDGE_MANAGE_PERMS, danger: true, disabled: busy, onSelect: () => void remove() },
|
|
139
|
+
]}
|
|
140
|
+
sections={[
|
|
141
|
+
{ key: 'overview', label: 'Overview', icon: 'bi-grid-1x2', render: () => <div className="edge-vhost-overview">
|
|
142
|
+
{error && <div className="form-alert" role="alert">{error}</div>}
|
|
143
|
+
<Eyebrow>Address</Eyebrow>
|
|
144
|
+
<FlatRow label="Server name"><code>{vhost.server_name ?? '—'}</code></FlatRow>
|
|
145
|
+
<FlatRow label="Domain">{vhost.domain?.name ?? '—'}</FlatRow>
|
|
146
|
+
<FlatRow label="Label">{vhost.label === '' ? 'Apex (bare domain)' : vhost.label === '*' ? 'Wildcard (*)' : <code>{vhost.label}</code>}</FlatRow>
|
|
147
|
+
<FlatRow label="Serves">{vhostKindTitle(vhost.kind)}</FlatRow>
|
|
148
|
+
<Eyebrow>Settings</Eyebrow>
|
|
149
|
+
{vhost.kind === 'api' && <FlatRow label="Upstream">{vhost.upstream?.name ?? '—'}</FlatRow>}
|
|
150
|
+
{vhost.kind === 'redirect' && <FlatRow label="Redirects to"><code>{vhost.redirect_to ?? '—'}</code></FlatRow>}
|
|
151
|
+
{(vhost.kind === 'site' || vhost.kind === 'site_api') && <FlatRow label="Single-page app">{vhost.spa ? 'Yes: unknown paths return index.html' : 'No'}</FlatRow>}
|
|
152
|
+
{proxies && <FlatRow label="Static files">{vhost.serve_static ? 'Served at /static/' : 'Not served here'}</FlatRow>}
|
|
153
|
+
{proxies && <FlatRow label="Quiet paths">{vhost.quiet_paths?.length ? vhost.quiet_paths.map((path) => <code key={path} className="edge-inline-code">{path}</code>) : 'None'}</FlatRow>}
|
|
154
|
+
{vhost.kind !== 'redirect' && <FlatRow label="Largest request body">{vhost.body_size_mb} MB</FlatRow>}
|
|
155
|
+
{policy && <FlatRow label="Edge evidence policy"><span title="Set by the platform. Not editable here.">Version {String(policy.version)} · {String(policy.response_class)} · {Array.isArray(policy.impossible_path_families) && policy.impossible_path_families.length ? policy.impossible_path_families.join(', ') : 'no path families'}</span></FlatRow>}
|
|
156
|
+
<Eyebrow>Serving</Eyebrow>
|
|
157
|
+
<FlatRow label="Certificate">{vhost.certificate ? <>{vhost.certificate.common_name} · {vhost.certificate.status}{vhost.certificate.not_after ? ` · expires ${fmt.date(vhost.certificate.not_after)}` : ''}</> : '—'}</FlatRow>
|
|
158
|
+
<FlatRow label="Pool">{vhost.pool}</FlatRow>
|
|
159
|
+
<FlatRow label="Created">{fmt.datetime(vhost.created)}</FlatRow>
|
|
160
|
+
<FlatRow label="Changed">{fmt.datetime(vhost.modified)}</FlatRow>
|
|
161
|
+
</div> },
|
|
162
|
+
...(vhost.kind === 'site_api' ? [{ key: 'routes', label: 'Routes', icon: 'bi-diagram-3', render: () => <VhostRoutes vhost={vhost} canManage={canManage} /> }] : []),
|
|
163
|
+
]}
|
|
164
|
+
initialSection="overview" onClose={onClose}
|
|
165
|
+
/>;
|
|
166
|
+
}
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
2
|
+
import { useQueryClient } from '@tanstack/react-query';
|
|
3
|
+
import { useMe } from '../../client/runtime';
|
|
4
|
+
import { CollectionSelect, FormWizard, modal, type FormWizardSection } from '../../ui';
|
|
5
|
+
import { CertificateModel, DomainModel, type CertificateRow, type DomainRow } from '../dns/models';
|
|
6
|
+
import { listVhostRoutes, saveVhostRoute, saveVhost } from './api';
|
|
7
|
+
import {
|
|
8
|
+
EdgeRouteModel, EdgeUpstreamModel, EdgeVhostModel, VHOST_KIND_CARDS,
|
|
9
|
+
buildVhostPayload, certificateCovers, deriveServerName, quietPathUncovered,
|
|
10
|
+
validateBodySizeMb, validatePoolName, validateRedirectTarget, validateRequestPath,
|
|
11
|
+
validateRouteDrafts, validateVhostLabel, vhostKindTitle,
|
|
12
|
+
type EdgeUpstreamRow, type EdgeVhostRow, type RouteDraft, type VhostDraft, type VhostKind,
|
|
13
|
+
} from './models';
|
|
14
|
+
|
|
15
|
+
function reason(error: unknown): string {
|
|
16
|
+
return (error instanceof Error ? error.message : 'save failed').replace(/[.\s]+$/, '');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface RouteRowDraft extends RouteDraft { key: number; upstreamName?: string }
|
|
20
|
+
|
|
21
|
+
/** One path per row; the server takes a list of exact request paths. */
|
|
22
|
+
function PathListEditor({ label, help, paths, onChange, disabled }: { label: string; help: string; paths: string[]; onChange: (paths: string[]) => void; disabled: boolean }) {
|
|
23
|
+
return <div className="field edge-path-list">
|
|
24
|
+
<span className="field-label">{label}</span>
|
|
25
|
+
{paths.map((path, index) => <div className="edge-path-row" key={index}>
|
|
26
|
+
<input className="input" value={path} disabled={disabled} placeholder="/health" aria-label={`${label} ${index + 1}`} onChange={(event) => onChange(paths.map((value, at) => at === index ? event.target.value : value))} />
|
|
27
|
+
<button type="button" className="btn btn-compact" disabled={disabled} aria-label={`Remove ${path || 'path'}`} onClick={() => onChange(paths.filter((_value, at) => at !== index))}><i className="bi bi-x-lg" /></button>
|
|
28
|
+
</div>)}
|
|
29
|
+
<div><button type="button" className="btn btn-compact" disabled={disabled} onClick={() => onChange([...paths, ''])}><i className="bi bi-plus-lg" /> Add path</button></div>
|
|
30
|
+
<span className="field-help">{help}</span>
|
|
31
|
+
</div>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface VhostWizardProps {
|
|
35
|
+
/** Omit to create; pass the `default` graph row to edit. */
|
|
36
|
+
row?: EdgeVhostRow | null;
|
|
37
|
+
onDone: (saved: EdgeVhostRow | null) => void;
|
|
38
|
+
onBusyChange?: (busy: boolean) => void;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function VhostWizard({ row = null, onDone, onBusyChange }: VhostWizardProps) {
|
|
42
|
+
const queryClient = useQueryClient();
|
|
43
|
+
const { data: me } = useMe();
|
|
44
|
+
const superuser = me?.is_superuser === true;
|
|
45
|
+
const editing = row != null;
|
|
46
|
+
|
|
47
|
+
const [kind, setKind] = useState<VhostKind | null>((row?.kind as VhostKind | undefined) ?? null);
|
|
48
|
+
const [domain, setDomain] = useState<{ id: number; name: string } | null>(row?.domain ? { id: row.domain.id, name: row.domain.name } : null);
|
|
49
|
+
const [label, setLabel] = useState(row?.label ?? '');
|
|
50
|
+
const [upstream, setUpstream] = useState<number | null>(row?.upstream?.id ?? null);
|
|
51
|
+
const [serveStatic, setServeStatic] = useState(row?.serve_static ?? false);
|
|
52
|
+
const [quietPaths, setQuietPaths] = useState<string[]>(row?.quiet_paths ?? []);
|
|
53
|
+
const [bodySize, setBodySize] = useState(String(row?.body_size_mb ?? 50));
|
|
54
|
+
const [spa, setSpa] = useState(row?.spa ?? false);
|
|
55
|
+
const [redirectTo, setRedirectTo] = useState(row?.redirect_to ?? '');
|
|
56
|
+
const [routes, setRoutes] = useState<RouteRowDraft[]>([]);
|
|
57
|
+
const [certificate, setCertificate] = useState<Pick<CertificateRow, 'id' | 'common_name' | 'sans'> | null>(null);
|
|
58
|
+
const [certificateId, setCertificateId] = useState<number | null>(row?.certificate?.id ?? null);
|
|
59
|
+
const [pool, setPool] = useState(row?.pool ?? 'default');
|
|
60
|
+
const [enabled, setEnabled] = useState(row?.is_enabled ?? true);
|
|
61
|
+
const [storedRoutes, setStoredRoutes] = useState<string[] | null>(editing ? null : []);
|
|
62
|
+
const [routesUnread, setRoutesUnread] = useState(false);
|
|
63
|
+
const nextRouteKey = useRef(1);
|
|
64
|
+
// Finish reruns whole on every retry, so what already landed is recorded
|
|
65
|
+
// here and skipped: never a second vhost, never a duplicate route.
|
|
66
|
+
const progress = useRef<{ vhostId: number | null; routes: Map<string, number> }>({ vhostId: null, routes: new Map() });
|
|
67
|
+
const [createdId, setCreatedId] = useState<number | null>(null);
|
|
68
|
+
|
|
69
|
+
// The row embeds `certificate: basic`, which has no `sans`; the coverage
|
|
70
|
+
// check needs the certificate's own graph.
|
|
71
|
+
useEffect(() => {
|
|
72
|
+
if (certificateId == null || certificate?.id === certificateId) return;
|
|
73
|
+
let live = true;
|
|
74
|
+
CertificateModel.fetchOne(queryClient, certificateId).then((full) => { if (live) setCertificate(full); }).catch(() => undefined);
|
|
75
|
+
return () => { live = false; };
|
|
76
|
+
}, [certificate, certificateId, queryClient]);
|
|
77
|
+
|
|
78
|
+
useEffect(() => {
|
|
79
|
+
if (!row || row.kind !== 'site_api') { if (editing) setStoredRoutes([]); return; }
|
|
80
|
+
let live = true;
|
|
81
|
+
listVhostRoutes(row.id).then((rows) => { if (live) setStoredRoutes(rows.map((route) => route.path_prefix)); }).catch(() => { if (live) setRoutesUnread(true); });
|
|
82
|
+
return () => { live = false; };
|
|
83
|
+
}, [editing, row]);
|
|
84
|
+
|
|
85
|
+
const serverName = domain ? deriveServerName(domain.name, label) : '';
|
|
86
|
+
const covered = certificate != null && certificate.id === certificateId && serverName ? certificateCovers(certificate, serverName) : null;
|
|
87
|
+
const cleanQuietPaths = quietPaths.map((path) => path.trim()).filter(Boolean);
|
|
88
|
+
const routePrefixes = editing ? storedRoutes ?? [] : routes.map((route) => route.path_prefix);
|
|
89
|
+
|
|
90
|
+
const draft = (): VhostDraft => ({
|
|
91
|
+
kind: kind!, domain: domain?.id ?? null, label, certificate: certificateId, pool: pool.trim(), is_enabled: enabled,
|
|
92
|
+
upstream, serve_static: serveStatic, quiet_paths: cleanQuietPaths, body_size_mb: Number(bodySize), spa, redirect_to: redirectTo.trim().toLowerCase(),
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
const checkShape = () => {
|
|
96
|
+
if (!kind) throw new Error('Choose what this address serves.');
|
|
97
|
+
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.`);
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
const checkName = () => {
|
|
102
|
+
if (!domain) throw new Error('Choose a domain.');
|
|
103
|
+
const refusal = validateVhostLabel(label);
|
|
104
|
+
if (refusal) throw new Error(refusal);
|
|
105
|
+
};
|
|
106
|
+
const checkKnobs = () => {
|
|
107
|
+
if (kind === 'redirect') {
|
|
108
|
+
const refusal = validateRedirectTarget(redirectTo.trim().toLowerCase());
|
|
109
|
+
if (refusal) throw new Error(refusal);
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
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.');
|
|
115
|
+
if (kind === 'site_api' && !editing) {
|
|
116
|
+
const refusal = validateRouteDrafts(routes);
|
|
117
|
+
if (refusal) throw new Error(refusal);
|
|
118
|
+
}
|
|
119
|
+
if (kind === 'api' || kind === 'site_api') {
|
|
120
|
+
for (const path of cleanQuietPaths) {
|
|
121
|
+
const refusal = validateRequestPath(path, 'A quiet path');
|
|
122
|
+
if (refusal) throw new Error(refusal);
|
|
123
|
+
}
|
|
124
|
+
if (new Set(cleanQuietPaths).size !== cleanQuietPaths.length) throw new Error('A quiet path is listed twice.');
|
|
125
|
+
// Stored routes not read yet, or unreadable: the server checks.
|
|
126
|
+
if (kind === 'site_api' && !(editing && storedRoutes == null)) {
|
|
127
|
+
const uncovered = quietPathUncovered(cleanQuietPaths, routePrefixes);
|
|
128
|
+
if (uncovered) throw new Error(`Quiet path ${uncovered} is not under any route prefix.`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
const checkCertificate = () => {
|
|
133
|
+
if (certificateId == null) throw new Error('Choose a certificate.');
|
|
134
|
+
const refusal = validatePoolName(pool.trim());
|
|
135
|
+
if (refusal) throw new Error(refusal);
|
|
136
|
+
};
|
|
137
|
+
const checkAll = () => { checkShape(); checkName(); checkKnobs(); checkCertificate(); };
|
|
138
|
+
|
|
139
|
+
const finish = async (): Promise<void> => {
|
|
140
|
+
checkAll();
|
|
141
|
+
const values = draft();
|
|
142
|
+
if (editing) {
|
|
143
|
+
const saved = await saveVhost(row.id, buildVhostPayload(values, { create: false }));
|
|
144
|
+
await EdgeVhostModel.invalidate(queryClient);
|
|
145
|
+
onDone(saved);
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
const state = progress.current;
|
|
149
|
+
let saved: EdgeVhostRow | null = null;
|
|
150
|
+
if (values.kind !== 'site_api') {
|
|
151
|
+
saved = await saveVhost(null, buildVhostPayload(values, { create: true }));
|
|
152
|
+
} else {
|
|
153
|
+
// The server checks a quiet path against the routes of a STORED
|
|
154
|
+
// row, and an enabled row is live at once. So: create disabled
|
|
155
|
+
// and without quiet paths, land the routes, then one update that
|
|
156
|
+
// carries the quiet paths and the real enablement.
|
|
157
|
+
if (state.vhostId == null) {
|
|
158
|
+
const created = await saveVhost(null, buildVhostPayload({ ...values, quiet_paths: [], is_enabled: false }, { create: true }));
|
|
159
|
+
state.vhostId = created.id;
|
|
160
|
+
setCreatedId(created.id);
|
|
161
|
+
}
|
|
162
|
+
for (const route of routes) {
|
|
163
|
+
if (state.routes.has(route.path_prefix)) continue;
|
|
164
|
+
try {
|
|
165
|
+
const stored = await saveVhostRoute(null, { vhost: state.vhostId, path_prefix: route.path_prefix, upstream: route.upstream! });
|
|
166
|
+
state.routes.set(route.path_prefix, stored.id);
|
|
167
|
+
} catch (error) {
|
|
168
|
+
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.`);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
try {
|
|
173
|
+
saved = await saveVhost(state.vhostId, buildVhostPayload(values, { create: false }));
|
|
174
|
+
} catch (error) {
|
|
175
|
+
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.`);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
await Promise.allSettled([EdgeVhostModel.invalidate(queryClient), EdgeRouteModel.invalidate(queryClient)]);
|
|
180
|
+
onDone(saved);
|
|
181
|
+
};
|
|
182
|
+
|
|
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…" />;
|
|
264
|
+
}
|
|
265
|
+
|
|
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> {
|
|
268
|
+
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);
|
|
273
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
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';
|
|
4
|
+
import { EDGE_MANAGE_PERMS, EdgeVhostModel, VHOST_KIND_CARDS, vhostKindTitle, type EdgeVhostRow } from './models';
|
|
5
|
+
import { VhostDetail } from './VhostDetail';
|
|
6
|
+
import { openVhostWizard } from './VhostWizard';
|
|
7
|
+
|
|
8
|
+
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) },
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
const FILTERS: FilterDef[] = [
|
|
18
|
+
{ key: 'kind', label: 'Serves', type: 'select', options: VHOST_KIND_CARDS.map((card) => ({ value: card.value, label: card.title })) },
|
|
19
|
+
{ key: 'is_enabled', label: 'State', type: 'boolean', trueLabel: 'Enabled', falseLabel: 'Disabled' },
|
|
20
|
+
{ key: 'pool', label: 'Pool', type: 'text' },
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
export function VhostsPage() {
|
|
24
|
+
const queryClient = useQueryClient();
|
|
25
|
+
const canManage = useCan(EDGE_MANAGE_PERMS).can;
|
|
26
|
+
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)} />);
|
|
34
|
+
};
|
|
35
|
+
const create = async () => {
|
|
36
|
+
const saved = await openVhostWizard();
|
|
37
|
+
if (saved) toast.success(`${saved.server_name ?? 'Vhost'} created${saved.is_enabled ? '' : ', disabled'}.`);
|
|
38
|
+
};
|
|
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
|
+
/>;
|
|
47
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { mojoCall, mojoDelete, mojoList, mojoSave, withFreshAuth } from '../../client/runtime';
|
|
2
|
+
import {
|
|
3
|
+
EdgeRouteModel, EdgeUpstreamModel, EdgeVhostModel,
|
|
4
|
+
normalizeRouteListParams, sanitizeEdgeRouteRow, sanitizeEdgeUpstreamRow, sanitizeEdgeVhostRow,
|
|
5
|
+
type EdgeRouteRow, type EdgeUpstreamRow, type EdgeVhostRow,
|
|
6
|
+
} from './models';
|
|
7
|
+
|
|
8
|
+
// Every call goes through the one client boundary; a refused write rejects
|
|
9
|
+
// with the server's own message.
|
|
10
|
+
|
|
11
|
+
export async function saveVhost(id: number | null, changes: Record<string, unknown>): Promise<EdgeVhostRow> {
|
|
12
|
+
return sanitizeEdgeVhostRow(await withFreshAuth(() => mojoSave<EdgeVhostRow>(EdgeVhostModel.endpoint, id, changes)));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function deleteVhost(id: number): Promise<void> {
|
|
16
|
+
await withFreshAuth(() => mojoDelete(EdgeVhostModel.endpoint, id));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function listVhostRoutes(vhost: number): Promise<EdgeRouteRow[]> {
|
|
20
|
+
const page = await mojoList<EdgeRouteRow>(EdgeRouteModel.endpoint, normalizeRouteListParams({ vhost, sort: 'path_prefix', size: 200 }));
|
|
21
|
+
return page.rows.map(sanitizeEdgeRouteRow);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function saveVhostRoute(id: number | null, changes: { vhost?: number; path_prefix?: string; upstream?: number }): Promise<EdgeRouteRow> {
|
|
25
|
+
return sanitizeEdgeRouteRow(await withFreshAuth(() => mojoSave<EdgeRouteRow>(EdgeRouteModel.endpoint, id, changes)));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function deleteVhostRoute(id: number): Promise<void> {
|
|
29
|
+
await withFreshAuth(() => mojoDelete(EdgeRouteModel.endpoint, id));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The only field a `manage_dns` holder can write on an upstream. */
|
|
33
|
+
export async function setUpstreamEnabled(id: number, enabled: boolean): Promise<EdgeUpstreamRow> {
|
|
34
|
+
return sanitizeEdgeUpstreamRow(await withFreshAuth(() => mojoSave<EdgeUpstreamRow>(EdgeUpstreamModel.endpoint, id, { is_enabled: enabled })));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type DeclareUpstreamInput =
|
|
38
|
+
| { name: string; kind: 'http'; host: string; port: number; group?: number | null }
|
|
39
|
+
| { name: string; kind: 'unix'; socket_path: string; group?: number | null };
|
|
40
|
+
|
|
41
|
+
/** Platform administrators only; the server refuses everyone else. */
|
|
42
|
+
export async function declareUpstream(input: DeclareUpstreamInput): Promise<EdgeUpstreamRow> {
|
|
43
|
+
// Only the chosen kind's destination fields are sent.
|
|
44
|
+
const body: Record<string, unknown> = input.kind === 'http'
|
|
45
|
+
? { name: input.name, kind: 'http', host: input.host, port: input.port }
|
|
46
|
+
: { name: input.name, kind: 'unix', socket_path: input.socket_path };
|
|
47
|
+
if (input.group != null) body.group = input.group;
|
|
48
|
+
const response = await withFreshAuth(() => mojoCall('/api/edge/upstream/declare', { method: 'POST', body }));
|
|
49
|
+
return sanitizeEdgeUpstreamRow(response.data);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Retiring disables; the row and its history stay. */
|
|
53
|
+
export async function retireUpstream(upstream: number): Promise<EdgeUpstreamRow> {
|
|
54
|
+
const response = await withFreshAuth(() => mojoCall('/api/edge/upstream/retire', { method: 'POST', body: { upstream } }));
|
|
55
|
+
return sanitizeEdgeUpstreamRow(response.data);
|
|
56
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// Edge — vhosts and upstreams as one section under Infrastructure, and the
|
|
2
|
+
// fleet blocklist as a route of Network Security. The blocklist cannot live
|
|
3
|
+
// in the Edge section: a section gate composes with its route gates, so a
|
|
4
|
+
// security-only operator would be denied by any DNS-gated section.
|
|
5
|
+
|
|
6
|
+
export * from './permissions';
|
|
7
|
+
export * from './models';
|
|
8
|
+
export * from './api';
|
|
9
|
+
export * from './VhostWizard';
|
|
10
|
+
export * from './VhostsPage';
|
|
11
|
+
export * from './VhostDetail';
|
|
12
|
+
export * from './UpstreamsPage';
|
|
13
|
+
export * from './BlocklistPage';
|
|
14
|
+
|
|
15
|
+
export { EDGE_ADMIN_SECTION } from '../domains/infrastructure';
|