portal-mojo 0.2.20 → 0.2.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (131) hide show
  1. package/docs/README.md +7 -1
  2. package/docs/admin-deployments.md +201 -0
  3. package/docs/admin-dns-certificates.md +3 -3
  4. package/docs/admin-dns-domains.md +92 -0
  5. package/docs/admin-dns.md +4 -2
  6. package/docs/admin-edge.md +200 -196
  7. package/docs/admin-geolocation.md +100 -0
  8. package/docs/admin-maintenance.md +133 -0
  9. package/docs/admin-modals.md +17 -2
  10. package/docs/admin-operational-fleet.md +206 -0
  11. package/docs/admin-presentation.md +51 -0
  12. package/docs/admin-signin.md +12 -2
  13. package/docs/client.md +7 -0
  14. package/docs/combobox.md +18 -0
  15. package/docs/popover.md +5 -4
  16. package/package.json +1 -1
  17. package/src/admin/core/OperationsHelp.tsx +10 -0
  18. package/src/admin/core/index.ts +6 -4
  19. package/src/admin/core/route-context.ts +6 -0
  20. package/src/admin/deployments/ApiDeploymentDetail.tsx +58 -0
  21. package/src/admin/deployments/DeploymentsPage.tsx +73 -0
  22. package/src/admin/deployments/WebAppDeploySetup.tsx +49 -0
  23. package/src/admin/deployments/WebAppDeploymentDetail.tsx +59 -0
  24. package/src/admin/deployments/WebAppDetail.tsx +104 -0
  25. package/src/admin/deployments/WebAppEditDialog.tsx +70 -0
  26. package/src/admin/deployments/WebAppKeyDialog.tsx +25 -0
  27. package/src/admin/deployments/WebAppOnboardingDialog.tsx +217 -0
  28. package/src/admin/deployments/WebAppRollbackDialog.tsx +82 -0
  29. package/src/admin/deployments/WebAppUploadPanel.tsx +132 -0
  30. package/src/admin/deployments/api.ts +158 -0
  31. package/src/admin/deployments/authority-state.ts +9 -0
  32. package/src/admin/deployments/deployment-state.ts +13 -0
  33. package/src/admin/deployments/index.ts +4 -0
  34. package/src/admin/deployments/onboarding-state.ts +11 -0
  35. package/src/admin/deployments/permissions.ts +37 -0
  36. package/src/admin/deployments/receipts.ts +78 -0
  37. package/src/admin/deployments/sanitize.ts +73 -0
  38. package/src/admin/deployments/shared.tsx +39 -0
  39. package/src/admin/deployments/types.ts +238 -0
  40. package/src/admin/deployments/upload.ts +53 -0
  41. package/src/admin/deployments/useDeploymentObservation.ts +104 -0
  42. package/src/admin/dns/DomainDetail.tsx +10 -6
  43. package/src/admin/dns/DomainOnboardingContent.tsx +44 -0
  44. package/src/admin/dns/DomainOnboardingDialog.tsx +5 -24
  45. package/src/admin/dns/DomainPurchaseWizard.tsx +1 -1
  46. package/src/admin/dns/DomainRegisterExisting.tsx +63 -0
  47. package/src/admin/dns/DomainRegistrarSettings.tsx +52 -0
  48. package/src/admin/dns/DomainsPage.tsx +22 -13
  49. package/src/admin/dns/SharedValidationDialog.tsx +1 -1
  50. package/src/admin/dns/api.ts +4 -2
  51. package/src/admin/dns/domain-navigation.ts +16 -0
  52. package/src/admin/dns/registrar-api.ts +13 -0
  53. package/src/admin/domains/assistant.ts +5 -4
  54. package/src/admin/domains/communications.ts +10 -9
  55. package/src/admin/domains/identity.ts +8 -7
  56. package/src/admin/domains/infrastructure.ts +19 -13
  57. package/src/admin/domains/lazy-page.ts +6 -0
  58. package/src/admin/domains/observability.ts +6 -5
  59. package/src/admin/domains/operations.ts +22 -7
  60. package/src/admin/domains/security.ts +16 -15
  61. package/src/admin/edge/EdgeHelp.tsx +10 -0
  62. package/src/admin/edge/ServingReadiness.tsx +54 -0
  63. package/src/admin/edge/VhostDetail.tsx +145 -139
  64. package/src/admin/edge/VhostWizard.tsx +204 -115
  65. package/src/admin/edge/VhostsPage.tsx +27 -28
  66. package/src/admin/edge/api.ts +24 -10
  67. package/src/admin/edge/models.ts +4 -4
  68. package/src/admin/edge/serving-data.ts +78 -0
  69. package/src/admin/fleet/CapacityEditor.tsx +81 -0
  70. package/src/admin/fleet/CapacityInspector.tsx +19 -0
  71. package/src/admin/fleet/CapacityProgress.tsx +9 -0
  72. package/src/admin/fleet/FleetHelp.tsx +7 -0
  73. package/src/admin/fleet/FleetOverview.tsx +150 -0
  74. package/src/admin/fleet/FleetPage.tsx +11 -0
  75. package/src/admin/fleet/FleetWorkspace.tsx +82 -0
  76. package/src/admin/fleet/ScaleFleetDialog.tsx +169 -0
  77. package/src/admin/fleet/access.ts +22 -0
  78. package/src/admin/fleet/api.ts +77 -0
  79. package/src/admin/fleet/data.ts +148 -0
  80. package/src/admin/fleet/index.ts +8 -0
  81. package/src/admin/fleet/operations.ts +179 -0
  82. package/src/admin/fleet/standalone-data.ts +87 -0
  83. package/src/admin/fleet/types.ts +208 -0
  84. package/src/admin/geolocation/GeoLocationPage.tsx +171 -0
  85. package/src/admin/geolocation/data.ts +138 -0
  86. package/src/admin/geolocation/index.ts +3 -0
  87. package/src/admin/identity/members/GroupMembersPanel.tsx +2 -8
  88. package/src/admin/index.ts +8 -0
  89. package/src/admin/maintenance/MaintenanceDialogs.tsx +161 -0
  90. package/src/admin/maintenance/MaintenancePage.tsx +442 -0
  91. package/src/admin/maintenance/data.ts +486 -0
  92. package/src/admin/maintenance/index.ts +3 -0
  93. package/src/admin/maintenance/operations.ts +208 -0
  94. package/src/admin/public/infrastructure.ts +1 -0
  95. package/src/admin/public/operations.ts +3 -0
  96. package/src/admin/registry.ts +6 -2
  97. package/src/admin/settings/SettingDetail.tsx +7 -2
  98. package/src/admin/settings/SettingEditor.tsx +2 -1
  99. package/src/admin/settings/SettingsPage.tsx +3 -1
  100. package/src/admin/settings/model.ts +1 -0
  101. package/src/admin/signin/SigninPage.tsx +17 -3
  102. package/src/admin/signin/api.ts +2 -0
  103. package/src/charts/MetricsChartView.tsx +4 -11
  104. package/src/charts/MetricsMiniWidget.tsx +3 -15
  105. package/src/client/client.ts +6 -0
  106. package/src/client/deployments-mock.ts +375 -0
  107. package/src/client/domain-operations-mock.ts +37 -0
  108. package/src/client/geo-provider-mock.ts +82 -0
  109. package/src/client/maintenance-mock.ts +285 -0
  110. package/src/client/mock.ts +52 -10
  111. package/src/client/operational-fleet-mock.ts +279 -0
  112. package/src/client/operations-mock.ts +85 -0
  113. package/src/client/serving-mock.ts +22 -0
  114. package/src/styles/components/admin-deployments.css +35 -0
  115. package/src/styles/components/admin-domains.css +12 -0
  116. package/src/styles/components/admin-edge.css +125 -1
  117. package/src/styles/components/admin-geolocation.css +40 -0
  118. package/src/styles/components/admin-maintenance.css +127 -0
  119. package/src/styles/components/admin-operational-fleet.css +38 -0
  120. package/src/styles/components/admin-operations.css +9 -0
  121. package/src/styles/components/admin-signin.css +2 -0
  122. package/src/styles/components/combobox.css +12 -2
  123. package/src/styles/components/fleet-page.css +9 -0
  124. package/src/styles/index.css +7 -0
  125. package/src/ui/ComboBox.tsx +39 -8
  126. package/src/ui/FilterBar.tsx +3 -5
  127. package/src/ui/ModelTable.tsx +2 -8
  128. package/src/ui/Popover.tsx +15 -7
  129. package/src/ui/date/TimePicker.tsx +4 -14
  130. package/src/ui/image-editor/ImageEditor.tsx +4 -5
  131. package/src/ui/schema-form-core.tsx +3 -5
@@ -0,0 +1,171 @@
1
+ import { useCallback, useEffect, useId, useRef, useState, type ReactNode } from 'react';
2
+ import { Link, useLocation, useNavigate } from 'react-router-dom';
3
+ import { requestFreshAuth, useMe } from '../../client/runtime';
4
+ import { modal } from '../../ui';
5
+ import { Popover } from '../../ui/Popover';
6
+ import { OperatorError, usePrivateOperator } from '../fleet-configuration/shared-validation-data';
7
+ import { GeoLocationError, geoDraft, geoFleetRoute, geoProviderLabel, geoProviderOptions, geoTestBlock, geoWriteBlock, readGeoLocation, saveGeoLocation, testGeoLocation, type GeoDraft, type GeoKeyEdit, type GeoLocationState, type GeoVerdict } from './data';
8
+
9
+ // The hosted MojoVerify service has one fixed origin; only its key is editable.
10
+ const mojoverifyDraft = (value: GeoDraft): GeoDraft => ({ ...geoDraft(value), GEOIP_MOJO_PROVIDER_URL: 'https://api.mojoverify.com' });
11
+
12
+ function GeoHelp({ label, children }: { label: string; children: ReactNode }) {
13
+ const anchor = useRef<HTMLButtonElement>(null), id = useId();
14
+ const [open, setOpen] = useState(false);
15
+ return <><button ref={anchor} type="button" className="ops-help" aria-label={`About ${label}`} aria-expanded={open} aria-controls={open ? id : undefined} onClick={event => { event.preventDefault(); setOpen(value => !value); }}><svg width="16" height="16" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true"><circle cx="10" cy="10" r="8" /><path d="M10 9v5" /><circle cx="10" cy="6" r=".8" fill="currentColor" stroke="none" /></svg></button><Popover anchorRef={anchor} open={open} onClose={() => setOpen(false)} id={id} aria-label={`About ${label}`} className="ops-help-content">{children}</Popover></>;
16
+ }
17
+
18
+ export function GeoLocationPage({ fleetConfigurationPath }: { fleetConfigurationPath?: string } = {}) {
19
+ const owner = usePrivateOperator(), { data: me } = useMe();
20
+ if (me?.is_superuser !== true || !owner.allowed) return <div className="panel panel-pad"><h1>GeoLocation</h1><p>GeoLocation requires a superuser account with an interactive session.</p></div>;
21
+ return <GeoLocationContent key={owner.key} fleetConfigurationPath={fleetConfigurationPath} />;
22
+ }
23
+ function GeoLocationContent({ fleetConfigurationPath }: { fleetConfigurationPath?: string }) {
24
+ const owner = usePrivateOperator(), location = useLocation(), navigate = useNavigate();
25
+ const [state, setState] = useState<GeoLocationState | null>(null), [draft, setDraft] = useState<GeoDraft | null>(null);
26
+ const [keyMode, setKeyMode] = useState<GeoKeyEdit['mode']>('untouched'), [keyChanged, setKeyChanged] = useState(false);
27
+ const [verdict, setVerdict] = useState<GeoVerdict | null>(null), [error, setError] = useState(''), [notice, setNotice] = useState('');
28
+ const [loading, setLoading] = useState(true), [busy, setBusy] = useState(false), [blocked, setBlocked] = useState(true);
29
+ const input = useRef<HTMLInputElement>(null), flight = useRef(false), generation = useRef(0), draftEpoch = useRef(0);
30
+ const keyRef = useCallback((node: HTMLInputElement | null) => {
31
+ if (input.current && input.current !== node) input.current.value = '';
32
+ input.current = node;
33
+ }, []);
34
+ const clearKey = () => { if (input.current) input.current.value = ''; setKeyMode('untouched'); setKeyChanged(false); };
35
+ const invalidate = () => { draftEpoch.current++; setVerdict(null); setNotice(''); };
36
+ useEffect(() => () => { generation.current++; draftEpoch.current++; if (input.current) input.current.value = ''; }, [owner.key]);
37
+ const failure = async (reason: unknown, forceBlock = false) => {
38
+ const safe = reason instanceof GeoLocationError ? reason : new GeoLocationError(reason instanceof OperatorError && reason.kind === 'denied' ? 'denied' : 'unavailable');
39
+ setError(safe.message);
40
+ if (forceBlock || ['invalid', 'unsupported', 'denied', 'fresh', 'conflict', 'uncertain', 'unavailable'].includes(safe.kind)) setBlocked(true);
41
+ if (safe.kind === 'fresh' || safe.kind === 'denied') { clearKey(); invalidate(); }
42
+ if (safe.kind === 'fresh') await requestFreshAuth();
43
+ };
44
+ const refresh = async () => {
45
+ if (flight.current || !owner.allowed) return;
46
+ const fence = owner.fence(), run = ++generation.current;
47
+ invalidate(); clearKey(); setLoading(true); setBlocked(true); setError('');
48
+ try {
49
+ const next = await readGeoLocation(fence); fence.beforeSend();
50
+ if (run !== generation.current) return;
51
+ setState(next); setDraft(mojoverifyDraft(next.geoip)); setBlocked(false);
52
+ } catch (reason) { if (!fence.signal?.aborted && run === generation.current) await failure(reason, true); }
53
+ finally { if (!fence.signal?.aborted && run === generation.current) setLoading(false); }
54
+ };
55
+ useEffect(() => { void refresh(); }, [owner.key]);
56
+ const dirty = !!state && !!draft && (JSON.stringify(draft) !== JSON.stringify(mojoverifyDraft(state.geoip)) || keyMode === 'clear' || keyChanged);
57
+ const change = <K extends keyof GeoDraft>(key: K, value: GeoDraft[K]) => { invalidate(); setDraft(old => old ? { ...old, [key]: value } : old); };
58
+ const discard = () => { invalidate(); clearKey(); if (state) setDraft(mojoverifyDraft(state.geoip)); setError(''); };
59
+ const confirmDiscard = async (destination: 'refresh' | 'fleet') => {
60
+ flight.current = true; setBusy(true); const fence = owner.fence();
61
+ try {
62
+ const confirmed = await modal.open<boolean>(close => <div className="modal-pad"><h2 className="modal-title">Discard unsaved changes?</h2><p className="modal-message">{destination === 'refresh' ? 'Refreshing replaces this draft and clears any entered API key.' : 'Opening Fleet Configuration discards this draft and clears any entered API key.'}</p><div className="modal-actions"><button className="btn" onClick={() => close(false)}>Keep editing</button><button className="btn btn-danger" onClick={() => close(true)}>{destination === 'refresh' ? 'Discard and refresh' : 'Discard and continue'}</button></div></div>, { size: 'md', signal: fence.signal });
63
+ fence.beforeSend();
64
+ return confirmed === true;
65
+ } catch (reason) { if (!fence.signal?.aborted) await failure(reason); return false; }
66
+ finally { flight.current = false; if (!fence.signal?.aborted) setBusy(false); }
67
+ };
68
+ const requestRefresh = async () => {
69
+ if (flight.current || loading) return;
70
+ if (dirty && !await confirmDiscard('refresh')) return;
71
+ await refresh();
72
+ };
73
+ const openFleet = async () => {
74
+ if (flight.current || loading || !owner.allowed) return;
75
+ if (dirty && !await confirmDiscard('fleet')) return;
76
+ discard();
77
+ navigate(fleetConfigurationPath ?? geoFleetRoute(location.pathname));
78
+ };
79
+ const chooseKeyMode = async (mode: GeoKeyEdit['mode']) => {
80
+ if (flight.current || loading || blocked || !state) return;
81
+ if (mode === 'clear') {
82
+ flight.current = true; setBusy(true); const fence = owner.fence();
83
+ try {
84
+ const confirmed = await modal.open<boolean>(close => <div className="modal-pad"><h2 className="modal-title">Remove the stored MojoVerify key?</h2><p className="modal-message">Saving will remove the key. MojoVerify lookups and threat-signal sharing may stop. This does not revoke the key at the service.</p><div className="modal-actions"><button className="btn" onClick={() => close(false)}>Cancel</button><button className="btn btn-danger" onClick={() => close(true)}>Confirm key removal</button></div></div>, { size: 'md', signal: fence.signal });
85
+ fence.beforeSend(); if (confirmed) { invalidate(); clearKey(); setKeyMode('clear'); }
86
+ } catch (reason) { if (!fence.signal?.aborted) await failure(reason); }
87
+ finally { flight.current = false; if (!fence.signal?.aborted) setBusy(false); }
88
+ } else { invalidate(); clearKey(); setKeyMode(mode); }
89
+ };
90
+ const run = async (action: 'test' | 'save') => {
91
+ if (flight.current || !owner.allowed || blocked || loading || !state || !draft || (action === 'save' ? !dirty || geoWriteBlock(state) : geoTestBlock(state) || keyMode === 'clear')) return;
92
+ flight.current = true; setBusy(true); setError(''); setNotice('');
93
+ const fence = owner.fence(), epoch = draftEpoch.current, runGeneration = generation.current, base = state;
94
+ const saving = action === 'save';
95
+ try {
96
+ fence.beforeSend();
97
+ const edit: GeoKeyEdit = keyMode === 'replace' ? { mode: 'replace', value: input.current?.value ?? '' } : keyMode === 'clear' ? { mode: 'clear', confirmed: true } : { mode: 'untouched' };
98
+ if (action === 'test') {
99
+ const next = await testGeoLocation(base.configuration_revision, draft, edit, fence); fence.beforeSend();
100
+ if (epoch !== draftEpoch.current || runGeneration !== generation.current) return;
101
+ setVerdict(next);
102
+ try {
103
+ const observed = await readGeoLocation(fence); fence.beforeSend();
104
+ if (epoch !== draftEpoch.current || runGeneration !== generation.current) return;
105
+ // Refresh observations/prerequisites without rebasing a changed revision or the draft.
106
+ if (observed.configuration_revision !== base.configuration_revision || geoTestBlock(observed)) {
107
+ setBlocked(true); setError('Authoritative configuration changed or became unavailable. Refresh and review before continuing.');
108
+ } else setState(observed);
109
+ } catch (reason) { if (!fence.signal?.aborted) await failure(reason, true); }
110
+ } else {
111
+ clearKey();
112
+ const result = await saveGeoLocation(base.configuration_revision, draft, edit, fence); fence.beforeSend();
113
+ if (epoch !== draftEpoch.current || runGeneration !== generation.current) return;
114
+ invalidate(); setBlocked(true);
115
+ setNotice(result.published ? 'Saved and published. Apply the pending configuration in Fleet Configuration.' : 'Saved. Published provider values were unchanged; the stored credential may have changed.');
116
+ const authoritative = await readGeoLocation(fence); fence.beforeSend();
117
+ if (runGeneration !== generation.current) return;
118
+ setState(authoritative); setDraft(mojoverifyDraft(authoritative.geoip)); setBlocked(false);
119
+ }
120
+ } catch (reason) {
121
+ if (!fence.signal?.aborted && runGeneration === generation.current) {
122
+ if (saving) clearKey();
123
+ await failure(reason, saving && !(reason instanceof GeoLocationError && reason.kind === 'validation'));
124
+ }
125
+ } finally { flight.current = false; if (!fence.signal?.aborted && runGeneration === generation.current) setBusy(false); }
126
+ };
127
+ const block = state ? geoWriteBlock(state) : null, diagnosticBlock = state ? geoTestBlock(state) : null;
128
+ const disabled = busy || loading || blocked, options = state && draft ? geoProviderOptions(state, draft) : [];
129
+ const additionalOptions = draft ? options.filter(p => p !== draft.GEOIP_PRIMARY_PROVIDER && p !== draft.GEOIP_FALLBACK_PROVIDER && !draft.GEOIP_ADDITIONAL_PROVIDERS.includes(p)) : [];
130
+ const meta = (key: keyof GeoDraft) => state?.fleet_fields.find(f => f.key === key);
131
+ const help = (label: string, content: ReactNode) => <GeoHelp label={label}>{content}</GeoHelp>;
132
+ const providerSelect = (key: 'GEOIP_PRIMARY_PROVIDER' | 'GEOIP_FALLBACK_PROVIDER', label: string) => <label className="field"><span className="field-label">{label} {help(label, <>{meta(key)?.description} Each provider is queried only once at runtime, even if it appears in more than one position. Other provider credentials are managed in deployment settings.</>)}</span><select className="input" aria-label={label} value={draft?.[key] ?? ''} onChange={e => change(key, e.target.value)}>{options.map(p => <option key={p} value={p}>{geoProviderLabel(p)}{state?.geoip_providers.includes(p) ? '' : ' (configured; unavailable)'}</option>)}</select></label>;
133
+ const move = (index: number, offset: number) => { if (!draft) return; const next = [...draft.GEOIP_ADDITIONAL_PROVIDERS], target = index + offset; [next[index], next[target]] = [next[target]!, next[index]!]; change('GEOIP_ADDITIONAL_PROVIDERS', next); };
134
+ const stored = state?.verify_state.geoip, storedDate = stored?.at && Number.isFinite(Date.parse(stored.at)) ? new Date(stored.at) : null;
135
+ const details = () => {
136
+ if (!state) return;
137
+ void modal.open(close => <div className="modal-pad geo-admin-dialog"><h2 className="modal-title">Configuration information</h2><dl>{[['Published revision', state.published_revision ?? 'Not published'], ['This request node loaded', state.loaded_revision ?? 'Not reported'], ['Configuration revision', state.configuration_revision ?? 'Not published'], ['Published version', state.published_version ?? 'Not reported'], ['Remote configuration', state.remote_error ?? 'No read error reported']].map(([label, value]) => <div key={label}><dt>{label}</dt><dd><code>{value}</code></dd></div>)}</dl><p className="dim">A key check does not prove that the provider chain or every Fleet node is healthy. Stored checks are historical observations, separate from this draft.</p>{stored && <p>{stored.message}{stored.code && <> · <code>{stored.code}</code></>}</p>}<div className="modal-actions"><button className="btn" onClick={() => close(undefined)}>Close</button></div></div>, { size: 'md', signal: owner.fence().signal });
138
+ };
139
+ return <div className="page geo-admin"><header className="page-header"><div><div className="eyebrow">Operations</div><h1>GeoLocation</h1><p className="dim">Set up your IP location providers.</p></div><button className="btn" disabled={busy || loading} onClick={() => void requestRefresh()}>Refresh</button></header>
140
+ {loading && <p role="status">Loading GeoLocation configuration…</p>}
141
+ {error && <p className="form-alert" role="alert">{error}</p>}{notice && <p className="geo-admin-notice" role="status">{notice}</p>}
142
+ {block && <div className="geo-admin-notice" role="alert"><strong>Publishing unavailable</strong><p>{block}</p><span>You can review a local draft{!diagnosticBlock && ' and test the MojoVerify key'}.</span></div>}
143
+ {state && draft && <><section className="panel geo-admin-setup" aria-label="GeoLocation provider setup">
144
+ <div className="geo-admin-form"><fieldset disabled={disabled} className="geo-admin-fields">
145
+ <div className="geo-admin-provider-fields">
146
+ {providerSelect('GEOIP_PRIMARY_PROVIDER', 'Primary provider')}
147
+ {providerSelect('GEOIP_FALLBACK_PROVIDER', 'Fallback provider')}
148
+ </div>
149
+ <div className="field"><span className="field-label">Additional providers {help('additional providers', <>{meta('GEOIP_ADDITIONAL_PROVIDERS')?.description} Tried in the order shown after the primary and fallback providers. Other provider credentials are managed in deployment settings.</>)}</span>
150
+ <ol className="geo-admin-provider-list">{draft.GEOIP_ADDITIONAL_PROVIDERS.map((provider, index) => <li key={provider}><span><span className="dim">{index + 1}.</span> {geoProviderLabel(provider)}{!state.geoip_providers.includes(provider) && <small className="dim"> Configured; unavailable</small>}</span><div className="geo-admin-actions"><button className="btn" aria-label={`Move ${geoProviderLabel(provider)} up`} disabled={disabled || index === 0} onClick={() => move(index, -1)}>↑</button><button className="btn" aria-label={`Move ${geoProviderLabel(provider)} down`} disabled={disabled || index === draft.GEOIP_ADDITIONAL_PROVIDERS.length - 1} onClick={() => move(index, 1)}>↓</button><button className="btn" aria-label={`Remove ${geoProviderLabel(provider)}`} onClick={() => change('GEOIP_ADDITIONAL_PROVIDERS', draft.GEOIP_ADDITIONAL_PROVIDERS.filter(p => p !== provider))}>Remove</button></div></li>)}</ol>
151
+ {!draft.GEOIP_ADDITIONAL_PROVIDERS.length && <p className="field-help">No additional providers.</p>}
152
+ <select className="input" aria-label="Add provider" value="" disabled={disabled || !additionalOptions.length || draft.GEOIP_ADDITIONAL_PROVIDERS.length >= 16} onChange={e => { if (e.target.value) change('GEOIP_ADDITIONAL_PROVIDERS', [...draft.GEOIP_ADDITIONAL_PROVIDERS, e.target.value]); }}><option value="">{additionalOptions.length ? 'Add provider…' : 'All providers added'}</option>{additionalOptions.map(p => <option value={p} key={p}>{geoProviderLabel(p)}</option>)}</select>
153
+ </div>
154
+ <div className="geo-admin-key"><div className="geo-admin-key-heading"><span className="field-label">MojoVerify API key</span>{help('MojoVerify API key', 'Write only. Tests check only MojoVerify. Saving also checks MojoVerify, even when another provider is primary, unless you explicitly remove its key. Other provider credentials are managed in deployment settings.')}</div>
155
+ <p className="dim">{state.geoip.GEOIP_API_KEY_MOJO_CONFIGURED ? <>Key set{state.geoip.GEOIP_API_KEY_MOJO_HINT && <> · <span aria-label="Stored key suffix">••••{state.geoip.GEOIP_API_KEY_MOJO_HINT}</span></>}</> : 'No stored key'}</p>
156
+ <div className="geo-admin-actions"><button className="btn" aria-pressed={keyMode === 'replace'} onClick={() => void chooseKeyMode('replace')}>{state.geoip.GEOIP_API_KEY_MOJO_CONFIGURED ? 'Replace key' : 'Add key'}</button>{state.geoip.GEOIP_API_KEY_MOJO_CONFIGURED && <button className="btn" onClick={() => void chooseKeyMode('clear')}>Remove key</button>}<button className="btn" disabled={disabled || !!diagnosticBlock || keyMode === 'clear'} onClick={() => void run('test')}>Test key</button>{keyMode !== 'untouched' && <button className="btn" onClick={() => void chooseKeyMode('untouched')}>Cancel key change</button>}</div>
157
+ {/* Retained while editing; the raw value lives only in this uncontrolled DOM input. */}
158
+ <label className="field" hidden={keyMode !== 'replace'}><span className="field-label">{state.geoip.GEOIP_API_KEY_MOJO_CONFIGURED ? 'Replacement key' : 'New key'}</span><input className="input" ref={keyRef} type="password" aria-label="MojoVerify API key" autoComplete="new-password" spellCheck={false} maxLength={4096} onChange={event => { invalidate(); setKeyChanged(event.currentTarget.value !== ''); }} /><span className="field-help">Leave blank to keep the stored key.</span></label>
159
+ {keyMode === 'clear' && <p className="text-warn">Save changes to remove the stored key. Key removal is not a connection test.</p>}
160
+ {draft.GEOIP_MOJO_PROVIDER_URL.replace(/\/$/, '') !== state.geoip.GEOIP_MOJO_PROVIDER_URL.replace(/\/$/, '') && <p className="text-warn">Switching to MojoVerify requires a replacement key. The stored key will not be sent to a different service.</p>}
161
+ </div>
162
+ <div className="geo-admin-sharing"><label><input type="checkbox" checked={draft.GEOIP_MOJO_SYNC_ENABLED} onChange={e => change('GEOIP_MOJO_SYNC_ENABLED', e.target.checked)} /> Share threat signals with MojoVerify</label>{help('threat-signal sharing', <>{meta('GEOIP_MOJO_SYNC_ENABLED')?.description} The remote key needs the geoip_sync permission. Sharing uses MojoVerify regardless of the primary provider.</>)}</div>
163
+ </fieldset></div>
164
+ {options.some(p => !state.geoip_providers.includes(p)) && <p className="geo-admin-context dim">Unavailable provider IDs are retained from your configuration. They cannot be verified against the current registry; review them before saving.</p>}
165
+ {verdict && <div className={`geo-admin-result ${verdict.success && verdict.code !== 'clear_requested' ? 'text-ok' : 'text-warn'}`} role="status" aria-label="Draft MojoVerify key test"><strong>{verdict.success && verdict.code !== 'clear_requested' ? 'Draft MojoVerify key verified' : 'Draft MojoVerify key not verified'}</strong><p>{verdict.message}</p>{verdict.code && <code>{verdict.code}</code>}{!verdict.success && <p>Supply a replacement key, or apply pending configuration in Fleet Configuration and refresh here.</p>}</div>}
166
+ {keyMode !== 'clear' && <p className={`geo-admin-context ${draft.GEOIP_PRIMARY_PROVIDER !== 'mojo' && !state.geoip.GEOIP_API_KEY_MOJO_CONFIGURED && !keyChanged ? 'text-warn' : 'dim'}`}>{draft.GEOIP_PRIMARY_PROVIDER !== 'mojo' && !state.geoip.GEOIP_API_KEY_MOJO_CONFIGURED && !keyChanged ? 'Saving also checks MojoVerify. Add its key before saving, or the backend may reject this change.' : 'Saving also checks the MojoVerify key.'}</p>}
167
+ <footer className="geo-admin-footer"><span className="dim" role="status">{dirty ? 'Unsaved changes' : 'No unsaved changes'}</span><div className="geo-admin-actions"><button className="btn" disabled={busy || loading || !dirty} onClick={discard}>Discard changes</button><button className="btn btn-primary" disabled={disabled || !!block || !dirty} onClick={() => void run('save')}>{busy ? 'Working…' : 'Save changes'}</button></div></footer>
168
+ </section>
169
+ <section className="panel geo-admin-status" aria-label="Stored configuration status"><div className="geo-admin-status-heading"><div className="geo-admin-key-heading"><h2>Last stored check</h2>{help('last stored check', 'This historical MojoVerify result is separate from your current draft and Fleet activation. A successful key check does not prove the provider chain or every node is healthy.')}</div><button className="btn" onClick={details}>Configuration information</button></div><p><strong>MojoVerify:</strong> {stored ? stored.ok && stored.code !== 'clear_requested' ? 'Verified' : stored.code === 'clear_requested' ? 'Key removal requested; not tested' : 'Failed' : 'Not checked'}{storedDate && <> · <time dateTime={storedDate.toISOString()}>{storedDate.toLocaleString()}</time></>}</p>{(blocked || loading) && <p className="text-warn">Earlier observations are not current confirmation. Refresh to check the configuration.</p>}<div className="geo-admin-activation">{state.pending_restart ? <span className="text-warn">Activation pending on this request node.</span> : <span className="dim">Review application and node health in Fleet.</span>}<Link to={fleetConfigurationPath ?? geoFleetRoute(location.pathname)} aria-disabled={busy || loading} onClick={event => { if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return; event.preventDefault(); void openFleet(); }}>Open Fleet Configuration</Link></div></section></>}
170
+ </div>;
171
+ }
@@ -0,0 +1,138 @@
1
+ /** Private GeoLocation protocol. Raw credentials never enter Query/Mutation state. */
2
+ import { mojoCall, MojoError } from '../../client/runtime';
3
+ import { OperatorError, operatorOrigin, type PrivateScope } from '../fleet-configuration/shared-validation-data';
4
+
5
+ export const GEOLOCATION_ENDPOINT = '/api/account/admin/settings';
6
+ export const GEOLOCATION_KEYS = ['GEOIP_PRIMARY_PROVIDER', 'GEOIP_FALLBACK_PROVIDER', 'GEOIP_ADDITIONAL_PROVIDERS', 'GEOIP_MOJO_PROVIDER_URL', 'GEOIP_MOJO_SYNC_ENABLED'] as const;
7
+ export interface GeoDraft {
8
+ GEOIP_PRIMARY_PROVIDER: string;
9
+ GEOIP_FALLBACK_PROVIDER: string;
10
+ GEOIP_ADDITIONAL_PROVIDERS: string[];
11
+ GEOIP_MOJO_PROVIDER_URL: string;
12
+ GEOIP_MOJO_SYNC_ENABLED: boolean;
13
+ }
14
+ export interface GeoVerdict { success: boolean; code: string | null; message: string }
15
+ export interface StoredGeoVerification { ok: boolean; code: string | null; message: string; at: string }
16
+ export interface GeoField { key: string; label: string; description: string; value_type: string; constraints: string }
17
+ export interface GeoLocationState {
18
+ available: boolean; bucket_configured: boolean; restart_configured: boolean; pending_restart: boolean;
19
+ object_key: string; delegated_keys: string[]; geoip_providers: string[]; fleet_fields: GeoField[];
20
+ loaded_revision: string | null; published_revision: string | null; configuration_revision: string | null;
21
+ published_version: string | null; remote_error: string | null;
22
+ geoip: GeoDraft & { GEOIP_API_KEY_MOJO_CONFIGURED: boolean; GEOIP_API_KEY_MOJO_HINT: string };
23
+ verify_state: { geoip?: StoredGeoVerification };
24
+ }
25
+ export interface GeoPublication { published: boolean; unchanged: boolean; topic: 'geoip'; revision: string; published_revision: string | null; version_id: string | null; pending_restart: boolean; results: { geoip: GeoVerdict } }
26
+ export type GeoKeyEdit = { mode: 'untouched' } | { mode: 'replace'; value: string } | { mode: 'clear'; confirmed: true };
27
+ export class GeoLocationError extends Error {
28
+ constructor(public kind: 'invalid' | 'validation' | 'unsupported' | 'denied' | 'fresh' | 'conflict' | 'unavailable' | 'uncertain', message?: string) {
29
+ super(message ?? ({ invalid: 'GeoLocation returned an incompatible response. Refresh before continuing.', validation: 'Check the provider settings and try again.', unsupported: 'This backend does not support GeoLocation provider setup.', denied: 'GeoLocation requires a current interactive superuser session.', fresh: 'Authenticate again, then Refresh and deliberately retry. The action was not replayed.', conflict: 'Provider configuration changed; reload before publishing. Refresh and review the configuration.', unavailable: 'GeoLocation could not be read. Refresh before continuing.', uncertain: 'The save may have persisted. Refresh authoritative configuration before another attempt.' }[kind]));
30
+ }
31
+ }
32
+ const invalid = (): never => { throw new GeoLocationError('invalid'); };
33
+ const object = (v: unknown): Record<string, unknown> => v !== null && typeof v === 'object' && !Array.isArray(v) ? v as Record<string, unknown> : invalid();
34
+ const text = (v: unknown, max = 512): string => typeof v === 'string' && v.length <= max && !/[\x00-\x1f\x7f]/.test(v) ? v : invalid();
35
+ const flag = (v: unknown): boolean => typeof v === 'boolean' ? v : invalid();
36
+ const list = (v: unknown, max = 128): unknown[] => Array.isArray(v) && v.length <= max ? v : invalid();
37
+ const nullable = <T>(v: unknown, parse: (v: unknown) => T): T | null => v === null ? null : parse(v);
38
+ export const geoRevision = (v: unknown): string => typeof v === 'string' && /^[a-f0-9]{32,64}$/.test(v) ? v : invalid();
39
+ export const geoProvider = (v: unknown): string => typeof v === 'string' && /^[a-z][a-z0-9_-]{0,31}$/.test(v) ? v : invalid();
40
+ export function geoOrigin(v: unknown): string {
41
+ const value = text(v);
42
+ try {
43
+ const u = new URL(value);
44
+ if (value.trim() !== value || /[?#]/.test(value) || u.protocol !== 'https:' || !u.hostname || u.username || u.password || u.search || u.hash || !['', '/'].includes(u.pathname) || u.port && u.port !== '443') invalid();
45
+ // Match backend normalization: retain an explicitly supplied :443, remove only slash.
46
+ return value.replace(/\/$/, '');
47
+ } catch { return invalid(); }
48
+ }
49
+ export function geoDraft(value: GeoDraft): GeoDraft {
50
+ const additional = list(value.GEOIP_ADDITIONAL_PROVIDERS, 16).map(geoProvider);
51
+ if (new Set(additional).size !== additional.length) invalid();
52
+ return { GEOIP_PRIMARY_PROVIDER: geoProvider(value.GEOIP_PRIMARY_PROVIDER), GEOIP_FALLBACK_PROVIDER: geoProvider(value.GEOIP_FALLBACK_PROVIDER), GEOIP_ADDITIONAL_PROVIDERS: [...additional], GEOIP_MOJO_PROVIDER_URL: geoOrigin(value.GEOIP_MOJO_PROVIDER_URL), GEOIP_MOJO_SYNC_ENABLED: flag(value.GEOIP_MOJO_SYNC_ENABLED) };
53
+ }
54
+ /** Positive projection discards the catalog, SMS, credentials, and every unknown field. */
55
+ export function parseGeoLocation(value: unknown): GeoLocationState {
56
+ const root = object(value); if (root.schema_version !== 1 || root.provider_setup == null) throw new GeoLocationError('unsupported');
57
+ const r = object(root.provider_setup), g = object(r.geoip), verify = object(r.verify_state);
58
+ const fields = list(r.fleet_fields).map(value => { const f = object(value); return { key: text(f.key, 80), label: text(f.label, 200), description: text(f.description, 2000), value_type: text(f.value_type, 32), constraints: text(f.constraints, 500) }; });
59
+ if (new Set(fields.map(f => f.key)).size !== fields.length) invalid();
60
+ const hint = text(g.GEOIP_API_KEY_MOJO_HINT, 4); if (hint !== '' && hint.length !== 4) invalid();
61
+ const stored = verify.geoip == null ? undefined : object(verify.geoip);
62
+ return { available: flag(r.available), bucket_configured: flag(r.bucket_configured), restart_configured: flag(r.restart_configured), pending_restart: flag(r.pending_restart),
63
+ object_key: text(r.object_key, 1024), delegated_keys: list(r.delegated_keys, 1024).map(v => text(v, 100)), geoip_providers: list(r.geoip_providers).map(geoProvider), fleet_fields: fields,
64
+ loaded_revision: nullable(r.loaded_revision, geoRevision), published_revision: nullable(r.published_revision, geoRevision), configuration_revision: nullable(r.configuration_revision, geoRevision),
65
+ published_version: nullable(r.published_version, v => text(v, 1024)), remote_error: nullable(r.remote_error, v => text(v, 200)),
66
+ geoip: { ...geoDraft(g as unknown as GeoDraft), GEOIP_API_KEY_MOJO_CONFIGURED: flag(g.GEOIP_API_KEY_MOJO_CONFIGURED), GEOIP_API_KEY_MOJO_HINT: hint },
67
+ verify_state: stored ? { geoip: { ok: flag(stored.ok), code: nullable(stored.code, v => text(v, 64)), message: text(stored.message, 200), at: text(stored.at, 40) } } : {},
68
+ };
69
+ }
70
+ /** Credential diagnostics need the provider schema, independently of Fleet publishing. */
71
+ export function geoTestBlock(state: GeoLocationState): string | null {
72
+ const types = ['string', 'string', 'list', 'origin', 'boolean'];
73
+ if (!GEOLOCATION_KEYS.every((key, i) => state.delegated_keys.includes(key) && state.fleet_fields.some(f => f.key === key && f.value_type === types[i]))) return 'All five GeoIP fields must be registered and delegated before provider setup can change.';
74
+ return null;
75
+ }
76
+ export function geoWriteBlock(state: GeoLocationState): string | null {
77
+ if (state.remote_error) return 'The published configuration could not be read. Resolve the remote read failure and Refresh.';
78
+ const schema = geoTestBlock(state); if (schema) return schema;
79
+ if (!state.available || !state.bucket_configured || !state.restart_configured || !state.object_key) return 'Fleet publishing is unavailable. Configure its exact bucket and object, all provider delegations, KMS encryption and config-sync restart in deployment settings.';
80
+ return null;
81
+ }
82
+ const providerLabels: Record<string, string> = { mojo: 'MojoVerify', ipinfo: 'IPinfo', ipstack: 'IPstack', 'ip-api': 'IP-API', maxmind: 'MaxMind' };
83
+ export const geoProviderLabel = (provider: string): string => providerLabels[provider] ?? provider;
84
+ export function geoProviderOptions(state: GeoLocationState, draft: GeoDraft): string[] {
85
+ return [...new Set([...state.geoip_providers, draft.GEOIP_PRIMARY_PROVIDER, draft.GEOIP_FALLBACK_PROVIDER, ...draft.GEOIP_ADDITIONAL_PROVIDERS])];
86
+ }
87
+ export function buildGeoPayload(action: 'test_providers' | 'configure_providers', revision: string | null, draft: GeoDraft, edit: GeoKeyEdit = { mode: 'untouched' }): Record<string, unknown> {
88
+ nullable(revision, geoRevision);
89
+ let geoip: GeoDraft & { GEOIP_API_KEY_MOJO?: string; clear_api_key?: true };
90
+ try { geoip = geoDraft(draft); } catch { throw new GeoLocationError('validation', 'Use valid provider identifiers, up to 16 unique additional providers, and one HTTPS origin without a path, query, fragment or alternate port.'); }
91
+ if (edit.mode === 'replace') {
92
+ if (typeof edit.value !== 'string' || edit.value.length > 4096) throw new GeoLocationError('validation', 'The replacement key must be at most 4096 characters.');
93
+ if (edit.value !== '') geoip.GEOIP_API_KEY_MOJO = edit.value;
94
+ } else if (edit.mode === 'clear') {
95
+ if (edit.confirmed !== true) throw new GeoLocationError('validation', 'Confirm key removal first.');
96
+ geoip.clear_api_key = true;
97
+ }
98
+ return { action, topic: 'geoip', providers: { expected_revision: revision, geoip } };
99
+ }
100
+ export function parseGeoVerdict(value: unknown): GeoVerdict { const r = object(value); return { success: flag(r.success), code: nullable(r.code, v => text(v, 64)), message: text(r.message, 200) }; }
101
+ export function parseGeoTest(value: unknown): GeoVerdict {
102
+ const r = object(value); if (r.tested !== true || r.topic !== 'geoip') invalid();
103
+ const result = parseGeoVerdict(object(r.results).geoip); if (flag(r.success) !== result.success) invalid(); return result;
104
+ }
105
+ export function parseGeoPublication(value: unknown): GeoPublication {
106
+ const r = object(value); if (r.topic !== 'geoip') invalid();
107
+ const out: GeoPublication = { published: flag(r.published), unchanged: flag(r.unchanged), topic: 'geoip', revision: geoRevision(r.revision), published_revision: nullable(r.published_revision, geoRevision), version_id: nullable(r.version_id, v => text(v, 1024)), pending_restart: flag(r.pending_restart), results: { geoip: parseGeoVerdict(object(r.results).geoip) } };
108
+ if (out.published === out.unchanged || !out.results.geoip.success || out.published && (!out.pending_restart || out.published_revision !== out.revision)) invalid();
109
+ return out;
110
+ }
111
+ async function geoCall(body: Record<string, unknown> | null, scope: PrivateScope = {}): Promise<unknown> {
112
+ try {
113
+ if (body) operatorOrigin();
114
+ const reply = await mojoCall(GEOLOCATION_ENDPOINT, { ...(body ? { method: 'POST', body, refusal: 'return' } as const : {}), signal: scope.signal, beforeSend: () => { if (body) operatorOrigin(); scope.beforeSend?.(); } });
115
+ scope.beforeSend?.();
116
+ return reply.data ?? reply;
117
+ } catch (reason) {
118
+ if (reason instanceof GeoLocationError) throw reason;
119
+ if (reason instanceof OperatorError && reason.kind === 'denied') throw new GeoLocationError('denied');
120
+ if (scope.signal?.aborted) throw new GeoLocationError('denied');
121
+ if (reason instanceof MojoError) {
122
+ if (reason.status === 440) throw new GeoLocationError('fresh');
123
+ if ([401, 403].includes(reason.status)) throw new GeoLocationError('denied');
124
+ if ([404, 405].includes(reason.status)) throw new GeoLocationError('unsupported');
125
+ if (reason.status === 409 || reason.status === 400 && /changed; reload before publishing/i.test(reason.message)) throw new GeoLocationError('conflict');
126
+ if (reason.status === 400) throw new GeoLocationError('validation', 'The server rejected the provider settings or credential. Check the MojoVerify key. Pending service configuration may need Fleet Configuration applied first, or a replacement key.');
127
+ }
128
+ throw new GeoLocationError(body?.action === 'configure_providers' ? 'uncertain' : 'unavailable');
129
+ } finally {
130
+ // The only raw-bearing object has a deliberately short lifetime, including rejected calls.
131
+ if (body) delete ((body.providers as { geoip: Record<string, unknown> }).geoip).GEOIP_API_KEY_MOJO;
132
+ }
133
+ }
134
+ export const readGeoLocation = async (scope?: PrivateScope): Promise<GeoLocationState> => parseGeoLocation(await geoCall(null, scope));
135
+ export const testGeoLocation = async (revision: string | null, draft: GeoDraft, edit?: GeoKeyEdit, scope?: PrivateScope): Promise<GeoVerdict> => parseGeoTest(await geoCall(buildGeoPayload('test_providers', revision, draft, edit), scope));
136
+ export const saveGeoLocation = async (revision: string | null, draft: GeoDraft, edit?: GeoKeyEdit, scope?: PrivateScope): Promise<GeoPublication> => parseGeoPublication(await geoCall(buildGeoPayload('configure_providers', revision, draft, edit), scope));
137
+ /** Mount-relative sibling destination, including an embedded Admin prefix. */
138
+ export const geoFleetRoute = (pathname: string): string => pathname.replace(/\/geolocation\/?$/, '/fleet-configuration');
@@ -0,0 +1,3 @@
1
+ export { GeoLocationPage } from './GeoLocationPage';
2
+ export { GEOLOCATION_ENDPOINT, GEOLOCATION_KEYS, GeoLocationError, parseGeoLocation, geoDraft, geoProviderOptions, geoWriteBlock, geoFleetRoute, buildGeoPayload, parseGeoTest, parseGeoPublication, readGeoLocation, testGeoLocation, saveGeoLocation } from './data';
3
+ export type { GeoDraft, GeoField, GeoKeyEdit, GeoLocationState, GeoVerdict, GeoPublication, StoredGeoVerification } from './data';
@@ -1,3 +1,4 @@
1
+ import { ComboBox } from '../../../ui/ComboBox';
1
2
  import { useEffect, useState } from 'react';
2
3
  import { useQueryClient } from '@tanstack/react-query';
3
4
  import { useCan, type Params } from '../../../client/runtime';
@@ -135,14 +136,7 @@ export function GroupMembersPanel({
135
136
  className="input" type="search" placeholder="Search members…"
136
137
  value={search} onChange={(event) => setSearch(event.target.value)}
137
138
  />
138
- <select
139
- className="input input-compact" value={status} aria-label="Status filter"
140
- onChange={(event) => setStatus(event.target.value as StatusFilter)}
141
- >
142
- <option value="active">Active</option>
143
- <option value="inactive">Inactive</option>
144
- <option value="all">All</option>
145
- </select>
139
+ <ComboBox allowCustom={false} maxSuggestions={Number.MAX_SAFE_INTEGER} value={String(status)} aria-label="Status filter" onChange={(selectedValue) => setStatus(String(selectedValue) as StatusFilter)} options={[({ value: "active", label: "Active" }), ({ value: "inactive", label: "Inactive" }), ({ value: "all", label: "All" })]} />
146
140
  </div>
147
141
  {query.isPending && <div className="skel skel-block" />}
148
142
  {query.error && <p className="text-bad">{query.error.message}</p>}
@@ -26,3 +26,11 @@ export * from './assistant';
26
26
  export { ADMIN_SECTIONS } from './registry';
27
27
  export { FleetConfigurationPage, FleetNodeStatus } from './fleet-configuration/FleetConfigurationPage';
28
28
  export { FLEET_CONFIGURATION_ADMIN_SECTION } from './domains/operations';
29
+ export * from './fleet';
30
+ export * from './geolocation';
31
+ export { FLEET_ADMIN_SECTION } from './domains/infrastructure';
32
+ export { GEOLOCATION_ADMIN_SECTION } from './domains/operations';
33
+ export * from './maintenance';
34
+ export { MAINTENANCE_ADMIN_SECTION } from './domains/operations';
35
+ export * from './deployments';
36
+ export { DEPLOYMENTS_ADMIN_SECTION } from './domains/operations';
@@ -0,0 +1,161 @@
1
+ import { useEffect, useRef, useState } from 'react';
2
+ import { frameworkActionLabel, type Finding, type Framework } from './data';
3
+ import { datetime } from '../../ui/format';
4
+ import { FleetHelp as MaintenanceHelp } from '../fleet/FleetHelp';
5
+ import type { MaintenanceAttempt } from './operations';
6
+ export function MaintenanceDialog({
7
+ finding,
8
+ framework,
9
+ busy,
10
+ onConfirm,
11
+ onClose,
12
+ }: {
13
+ finding?: Finding;
14
+ framework?: Framework;
15
+ busy: boolean;
16
+ onConfirm: (immediate: boolean) => void;
17
+ onClose: () => void;
18
+ }) {
19
+ const dialog = useRef<HTMLDialogElement>(null);
20
+ const [typed, setTyped] = useState(''),
21
+ [windowChoice, setWindowChoice] = useState<'now' | 'window' | ''>('');
22
+ const expected = finding?.resource_id ?? framework?.latest ?? '';
23
+ useEffect(() => {
24
+ const d = dialog.current;
25
+ d?.showModal();
26
+ return () => d?.close();
27
+ }, []);
28
+ return (
29
+ <dialog
30
+ ref={dialog}
31
+ className="maintenance-dialog"
32
+ aria-labelledby="maintenance-confirm-title"
33
+ onCancel={(e) => {
34
+ e.preventDefault();
35
+ if (!busy) onClose();
36
+ }}
37
+ >
38
+ <div className="maintenance-dialog-body">
39
+ <h2 id="maintenance-confirm-title">
40
+ {finding
41
+ ? `Upgrade ${finding.resource_id}?`
42
+ : framework
43
+ ? frameworkActionLabel(framework)
44
+ : 'Review update'}
45
+ </h2>
46
+ <div className="maintenance-dialog-scroll">
47
+ <p>
48
+ {finding
49
+ ? `${finding.engine}: ${finding.current_version} → ${finding.available_major}`
50
+ : `Installed ${framework?.installed ?? 'unknown'} → ${framework?.latest ?? 'unknown'}`}
51
+ </p>
52
+ <p className="maintenance-warning">
53
+ {finding
54
+ ? finding.kind === 'elasticache'
55
+ ? 'This restarts cache nodes. Clients reconnect and cached data may be lost. A major upgrade cannot be undone; recovery means restoring a snapshot.'
56
+ : 'The database goes offline during the engine upgrade. Multi-AZ may fail over; a single instance is unavailable. A major upgrade cannot be undone; recovery means restoring a snapshot.'
57
+ : 'This clears the current pin, restores automatic latest-version updates, and redeploys the last converged application SHA. Services restart as the canary migrates and the fleet rolls. Acceptance does not prove fleet completion.'}
58
+ </p>
59
+ {finding && (
60
+ <fieldset disabled={busy}>
61
+ <legend>Choose the outage window</legend>
62
+ <label>
63
+ <input
64
+ type="radio"
65
+ name="maintenance-window"
66
+ checked={windowChoice === 'window'}
67
+ onChange={() => setWindowChoice('window')}
68
+ />{' '}
69
+ Next maintenance window
70
+ </label>
71
+ <label>
72
+ <input
73
+ type="radio"
74
+ name="maintenance-window"
75
+ checked={windowChoice === 'now'}
76
+ onChange={() => setWindowChoice('now')}
77
+ />{' '}
78
+ Immediately
79
+ </label>
80
+ </fieldset>
81
+ )}
82
+ <label className="maintenance-confirm-label">
83
+ Type {expected} to confirm
84
+ <input
85
+ autoFocus
86
+ autoComplete="off"
87
+ value={typed}
88
+ disabled={busy}
89
+ onChange={(e) => setTyped(e.target.value)}
90
+ />
91
+ </label>
92
+ </div>
93
+ <div className="maintenance-actions">
94
+ <button className="btn" disabled={busy} onClick={onClose}>
95
+ Cancel
96
+ </button>
97
+ <button
98
+ className="btn btn-danger"
99
+ disabled={busy || typed !== expected || !expected || (!!finding && !windowChoice)}
100
+ onClick={() => onConfirm(windowChoice === 'now')}
101
+ >
102
+ {busy ? 'Requesting…' : finding ? 'Apply upgrade' : 'Confirm framework update'}
103
+ </button>
104
+ </div>
105
+ </div>
106
+ </dialog>
107
+ );
108
+ }
109
+
110
+ export function MaintenancePolicyDialog({ framework, busy, error, onSave, onClose }: {
111
+ framework: Framework; busy: boolean; error: string; onSave: (value: string) => void; onClose: () => void;
112
+ }) {
113
+ const dialog = useRef<HTMLDialogElement>(null);
114
+ const [mode, setMode] = useState(framework.pin.mode);
115
+ const [version, setVersion] = useState(framework.pin.mode === 'pinned' ? framework.pin.value ?? '' : framework.installed ?? '');
116
+ const value = mode === 'latest' ? '' : mode === 'hold' ? 'hold' : version.trim().toLowerCase();
117
+ const valid = mode !== 'pinned' || /^[0-9][a-z0-9._!+-]{0,63}$/.test(value);
118
+ const unchanged = mode === framework.pin.mode && (mode !== 'pinned' || value === framework.pin.value);
119
+ useEffect(() => { const d = dialog.current; d?.showModal(); return () => d?.close(); }, []);
120
+ return <dialog ref={dialog} className="maintenance-dialog" aria-labelledby="maintenance-policy-title" onCancel={e => { e.preventDefault(); if (!busy) onClose(); }}>
121
+ <div className="maintenance-dialog-body"><h2 id="maintenance-policy-title">Framework update policy</h2>
122
+ <div className="maintenance-dialog-scroll">
123
+ <p>This saves policy only. Nodes change on a later deployment.</p>
124
+ <fieldset disabled={busy}><legend>Version policy</legend>
125
+ <label><input type="radio" name="framework-policy" checked={mode === 'latest'} onChange={() => setMode('latest')} /> Automatic</label>
126
+ <label><input type="radio" name="framework-policy" checked={mode === 'hold'} onChange={() => setMode('hold')} /> Hold</label>
127
+ <label><input type="radio" name="framework-policy" checked={mode === 'pinned'} onChange={() => setMode('pinned')} /> Exact version</label>
128
+ </fieldset>
129
+ {mode === 'pinned' && <label className="field"><span className="field-label">Version</span><input className="input" disabled={busy} value={version} onChange={e => setVersion(e.target.value)} autoComplete="off" placeholder="1.39.6" /></label>}
130
+ <p className="dim">{mode === 'latest' ? 'Each deployment installs the newest published release.' : mode === 'hold' ? 'Keep the framework version from the last converged deployment.' : 'Each deployment installs this exact version.'} <MaintenanceHelp label="framework policy"><p>Hold requires a last converged framework version. Exact version checks syntax here; release availability is checked during deployment.</p><p>The separate framework Update action clears Hold or Exact and deploys the latest published version.</p></MaintenanceHelp></p>
131
+ {error && <p role="alert" className="form-alert">{error}</p>}
132
+ </div>
133
+ <div className="maintenance-actions"><button className="btn" disabled={busy} onClick={onClose}>Cancel</button><button className="btn btn-primary" disabled={busy || !valid || unchanged} onClick={() => onSave(value)}>{busy ? 'Saving…' : 'Save policy'}</button></div>
134
+ </div>
135
+ </dialog>;
136
+ }
137
+ export function MaintenanceProgressDialog({ attempt, busy, onObserve, onClose, deploymentsHref }: {
138
+ attempt: MaintenanceAttempt; busy: boolean; onObserve: () => void; onClose: () => void; deploymentsHref?: string;
139
+ }) {
140
+ const dialog = useRef<HTMLDialogElement>(null);
141
+ useEffect(() => { const d = dialog.current; d?.showModal(); return () => d?.close(); }, []);
142
+ const resource = 'kind' in attempt.input ? attempt.input : null;
143
+ const target = resource?.target_version ?? ('version' in attempt.input ? attempt.input.version : 'policy' in attempt.input ? attempt.input.policy || 'Automatic' : '');
144
+ const observation = attempt.observation;
145
+ return <dialog ref={dialog} className="maintenance-dialog" aria-labelledby="maintenance-progress-title" onCancel={e => { e.preventDefault(); if (!busy) onClose(); }}>
146
+ <div className="maintenance-dialog-body"><h2 id="maintenance-progress-title">Update progress</h2>
147
+ <div className="maintenance-dialog-scroll">
148
+ <strong>{resource?.resource ?? ('policy' in attempt.input ? 'Framework policy' : 'Django-MOJO')}</strong>
149
+ <p role="status">{attempt.message}</p>
150
+ <dl className="maintenance-progress-facts"><dt>Target</dt><dd>{target}</dd>{resource && <><dt>When</dt><dd>{resource.apply_immediately ? 'Immediately' : 'Next configured AWS window'}</dd></>}<dt>Last observation</dt><dd>{attempt.observedAt ? datetime(attempt.observedAt) : 'Not observed yet'}</dd></dl>
151
+ {observation && <><p>{observation.found ? `${observation.status ?? 'Status unknown'} · ${observation.engine_version ?? 'Version unknown'}` : 'Resource not found — outcome remains unconfirmed.'}</p>{observation.pending_version && <p>Pending version: {observation.pending_version}</p>}<p className="dim">Observed versions: {observation.engine_versions.join(', ') || 'Unknown'} · {observation.settled ? 'Provider settled' : 'Still changing'}</p></>}
152
+ {attempt.deployment && <p className="dim">Deployment {attempt.deployment.id}</p>}
153
+ {attempt.frameworkObservation?.nodes.map(node => <p key={node.runner}>{node.runner} · {node.state} · {node.version ?? 'Version unreported'}</p>)}
154
+ {'version' in attempt.input && !attempt.deployment && <p className="maintenance-warning">Deployment acceptance is unknown. Reconcile server deployment logs; a matching version or latest deployment cannot identify this request.</p>}
155
+ <p className="dim">Closing this dialog does not cancel submitted work. <MaintenanceHelp label="update recovery"><p>Unknown outcomes stay guarded. Refresh only reads evidence and never resends a write. Receipts survive navigation in this document, but a full browser reload loses them.</p>{resource?.kind === 'rds-cluster' && <p>Aurora reports cluster-level settlement; individual database member proof is unavailable here.</p>}</MaintenanceHelp></p>
156
+ {deploymentsHref && 'version' in attempt.input && <a href={deploymentsHref}>Open Deployments</a>}
157
+ </div>
158
+ <div className="maintenance-actions"><button className="btn" disabled={busy} onClick={onClose}>Close</button>{attempt.state !== 'complete' && attempt.state !== 'refused' && (!('version' in attempt.input) || !!attempt.deployment) && <button className="btn btn-primary" disabled={busy || attempt.state === 'sending'} onClick={onObserve}>{busy ? 'Observing…' : 'Refresh status'}</button>}</div>
159
+ </div>
160
+ </dialog>;
161
+ }