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,442 @@
1
+ import { Tabs } from '../../ui/Tabs';
2
+ import { useQueryClient } from '@tanstack/react-query';
3
+ import { SettingModel } from '../settings/model';
4
+ import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from 'react';
5
+ import { useLocation } from 'react-router-dom';
6
+ import { getAuthSessionRevision, requestFreshAuth, useAuthSnapshot, useMe } from '../../client/runtime';
7
+ import { date, datetime } from '../../ui/format';
8
+ import { MaintenanceDialog, MaintenancePolicyDialog, MaintenanceProgressDialog } from './MaintenanceDialogs';
9
+ import { FleetHelp as MaintenanceHelp } from '../fleet/FleetHelp';
10
+ import { useAdminRouteContext } from '../core/route-context';
11
+ import {
12
+ frameworkActionLabel, frameworkStatusLabel, supportUrgency,
13
+ maintenanceAccess,
14
+ maintenanceFence,
15
+ MaintenanceError,
16
+ readFramework,
17
+ readInfrastructure,
18
+ readMaintenance,
19
+ type Finding,
20
+ type Framework,
21
+ type Report,
22
+ } from './data';
23
+ import {
24
+ getMaintenanceAttempt,
25
+ getMaintenanceAttempts,
26
+ maintenanceAttemptsRevision,
27
+ maintenanceObservationLimit,
28
+ observeMaintenance,
29
+ refreshRefusedMaintenanceAttempt,
30
+ requestFramework,
31
+ requestMaintenance,
32
+ resourceKey,
33
+ subscribeMaintenanceAttempts, canRequestMaintenance, requestPolicy, reconcilePolicy, observeFramework, type MaintenanceAttempt,
34
+ } from './operations';
35
+ function ObservedTime({ value, dateOnly = false }: { value: string | number | null | undefined; dateOnly?: boolean }) {
36
+ return value != null ? <time dateTime={String(value)} title={String(value)}>{dateOnly ? date(value) : datetime(value)}</time> : <>unknown / unpublished</>;
37
+ }
38
+ interface Evidence<T> {
39
+ value?: T;
40
+ error?: string;
41
+ loading?: boolean;
42
+ }
43
+ type Review = { finding: Finding; fingerprint: string } | { framework: Framework; fingerprint: string };
44
+ export function maintenanceDeploymentsHref(pathname: string) {
45
+ const base = pathname.replace(/\/maintenance\/?$/, '');
46
+ return `#${base}/deployments`;
47
+ }
48
+ export function MaintenancePage({
49
+ pollInterval = 10_000,
50
+ pollLimit = 180,
51
+ deploymentsHref,
52
+ }: { pollInterval?: number; pollLimit?: number; deploymentsHref?: string } = {}) {
53
+ const { data: me } = useMe(),
54
+ auth = useAuthSnapshot(),
55
+ revision = getAuthSessionRevision();
56
+ const access = maintenanceAccess(auth.authenticated && String(me?.id) === auth.uid ? me : null);
57
+ const location = useLocation(), route = useAdminRouteContext();
58
+ const queryClient = useQueryClient();
59
+ const deploymentLink = deploymentsHref ?? (route ? route.paths.includes(`${route.mount.replace(/\/$/, '')}/deployments`) ? `#${route.mount.replace(/\/$/, '')}/deployments` : undefined : maintenanceDeploymentsHref(location.pathname));
60
+ const [policy, setPolicy] = useState<Framework | null>(null), [progress, setProgress] = useState<string | null>(null);
61
+ const [aws, setAWS] = useState<Evidence<Report>>({}),
62
+ [framework, setFramework] = useState<Evidence<Framework>>({}),
63
+ [mode, setMode] = useState<Evidence<'managed' | 'external'>>({});
64
+ const [review, setReview] = useState<Review | null>(null),
65
+ [busy, setBusy] = useState(false),
66
+ [error, setError] = useState('');
67
+ const controller = useRef<AbortController | null>(null),
68
+ inFlight = useRef(false),
69
+ readGeneration = useRef(0);
70
+ const currentUser = useRef(me); currentUser.current = me;
71
+ const current = useRef({ access, mode, aws, framework, review });
72
+ current.current = { access, mode, aws, framework, review };
73
+ const attemptRevision = useSyncExternalStore(
74
+ subscribeMaintenanceAttempts,
75
+ maintenanceAttemptsRevision,
76
+ maintenanceAttemptsRevision,
77
+ );
78
+ const attempts = getMaintenanceAttempts().filter(a => 'kind' in a.input ? access.aws : access.framework);
79
+ const readScope = (a: MaintenanceAttempt, signal?: AbortSignal) => maintenanceFence({ signal, beforeSend: () => { const grants = current.current.access; if ('kind' in a.input ? !grants.aws : !grants.framework) throw new MaintenanceError('denied'); } });
80
+ const load = useCallback(
81
+ async (refresh = false) => {
82
+ const c = controller.current;
83
+ if (!c || c.signal.aborted) return;
84
+ const generation = ++readGeneration.current,
85
+ fence = maintenanceFence({ signal: c.signal });
86
+ setReview(null);
87
+ setError('');
88
+ const active = () =>
89
+ !c.signal.aborted &&
90
+ generation === readGeneration.current &&
91
+ revision === getAuthSessionRevision();
92
+ const permissions = current.current.access;
93
+ await Promise.all([
94
+ permissions.aws
95
+ ? (async () => {
96
+ setAWS({ loading: true });
97
+ try {
98
+ const value = await readMaintenance(fence, refresh);
99
+ if (active()) {
100
+ setAWS({ value });
101
+ if (refresh)
102
+ for (const a of getMaintenanceAttempts())
103
+ if ('kind' in a.input) refreshRefusedMaintenanceAttempt(a.key);
104
+ }
105
+ } catch (e) {
106
+ if (active()) setAWS({ error: message(e) });
107
+ }
108
+ })()
109
+ : Promise.resolve(),
110
+ permissions.framework
111
+ ? (async () => {
112
+ setFramework({ loading: true });
113
+ try {
114
+ const value = await readFramework(fence, refresh);
115
+ if (active()) {
116
+ setFramework({ value });
117
+ reconcilePolicy(value);
118
+ if (refresh) { refreshRefusedMaintenanceAttempt('framework'); refreshRefusedMaintenanceAttempt('framework-policy'); }
119
+ }
120
+ } catch (e) {
121
+ if (active()) setFramework({ error: message(e) });
122
+ }
123
+ })()
124
+ : Promise.resolve(),
125
+ permissions.bootstrap
126
+ ? (async () => {
127
+ setMode({ loading: true });
128
+ try {
129
+ const value = await readInfrastructure(fence);
130
+ if (active()) setMode({ value });
131
+ } catch (e) {
132
+ if (active()) setMode({ error: message(e) });
133
+ }
134
+ })()
135
+ : Promise.resolve(
136
+ setMode({ error: 'Mode proof requires independent bootstrap permission.' }),
137
+ ),
138
+ ]);
139
+ },
140
+ [revision],
141
+ );
142
+ useEffect(() => {
143
+ const c = new AbortController();
144
+ controller.current = c;
145
+ setReview(null); setPolicy(null); setProgress(null);
146
+ setAWS({});
147
+ setFramework({});
148
+ setMode({});
149
+ setBusy(false);
150
+ inFlight.current = false;
151
+ void load();
152
+ return () => {
153
+ c.abort();
154
+ readGeneration.current++;
155
+ if (controller.current === c) controller.current = null;
156
+ };
157
+ }, [load, access.aws, access.framework, access.bootstrap]);
158
+ useEffect(() => {
159
+ const c = controller.current;
160
+ if (!c || c.signal.aborted) return;
161
+ const pending = attempts.filter(
162
+ (a) => ('kind' in a.input || 'version' in a.input && !!a.deployment) && ['waiting', 'uncertain', 'accepted'].includes(a.state),
163
+ );
164
+ if (!pending.length) return;
165
+ const timer = setTimeout(() => {
166
+ for (const a of pending) {
167
+ if (a.observations >= pollLimit) maintenanceObservationLimit(a);
168
+ else if ('kind' in a.input) void observeMaintenance(a, readScope(a, c.signal));
169
+ else void observeFramework(a, readScope(a, c.signal));
170
+ }
171
+ }, pollInterval);
172
+ return () => clearTimeout(timer);
173
+ // Registry revision schedules the next bounded observation after the previous response.
174
+ }, [attemptRevision, access.aws, pollInterval, pollLimit, revision]);
175
+ const canWrite = mode.value === 'managed' && !mode.loading && access.manage;
176
+ const canPolicy = canWrite && me?.is_active === true && me?.is_superuser === true && access.framework;
177
+ useEffect(() => { if (!canPolicy) setPolicy(null); }, [canPolicy]);
178
+ const submitPolicy = async (value: string) => {
179
+ const c = controller.current; if (!c || inFlight.current || !canPolicy) return;
180
+ inFlight.current = true; setBusy(true); setError('');
181
+ const fence = maintenanceFence({ signal: c.signal, beforeSend: () => { const st = current.current; if (!st.access.manage || !st.access.framework || st.mode.value !== 'managed' || st.mode.loading || (currentUser.current?.is_superuser !== true || currentUser.current?.is_active !== true)) throw new MaintenanceError('denied'); } });
182
+ try {
183
+ await requestPolicy(value, fence);
184
+ const fresh = await readFramework(fence, true); fence.beforeSend?.(); setFramework({ value: fresh }); reconcilePolicy(fresh);
185
+ } catch (reason) {
186
+ if (!c.signal.aborted) { setError(message(reason)); if (reason instanceof MaintenanceError && reason.code === 'fresh_auth_required') await requestFreshAuth(); else if (getMaintenanceAttempt('framework-policy')?.state !== 'refused') setProgress('framework-policy'); }
187
+ } finally { void queryClient.invalidateQueries({queryKey:SettingModel.keys.root}); if (!c.signal.aborted) { setPolicy(null); setBusy(false); inFlight.current = false; } }
188
+ };
189
+ const submit = async (immediate: boolean) => {
190
+ const consent = current.current.review,
191
+ c = controller.current;
192
+ if (!consent || inFlight.current || !c || c.signal.aborted) return;
193
+ const ensure = () => {
194
+ const state = current.current;
195
+ if (
196
+ state.mode.value !== 'managed' ||
197
+ state.mode.loading ||
198
+ !state.access.manage ||
199
+ ('finding' in consent
200
+ ? !state.access.aws ||
201
+ !state.aws.value?.findings.some((f) => JSON.stringify(f) === consent.fingerprint)
202
+ : !state.access.framework ||
203
+ consent.framework.pin.mode !== 'latest' && (currentUser.current?.is_superuser !== true || currentUser.current?.is_active !== true) ||
204
+ JSON.stringify(state.framework.value) !== consent.fingerprint)
205
+ )
206
+ throw new MaintenanceError('denied');
207
+ };
208
+ try {
209
+ ensure();
210
+ } catch (e) {
211
+ setReview(null);
212
+ setError(message(e));
213
+ return;
214
+ }
215
+ inFlight.current = true;
216
+ setBusy(true);
217
+ setError('');
218
+ const fence = maintenanceFence({ signal: c.signal, beforeSend: ensure });
219
+ try {
220
+ if ('finding' in consent) {
221
+ const f = consent.finding;
222
+ await requestMaintenance(
223
+ {
224
+ kind: f.kind,
225
+ resource: f.resource_id,
226
+ target_version: f.available_major,
227
+ confirm_resource: f.resource_id,
228
+ apply_immediately: immediate,
229
+ },
230
+ fence,
231
+ );
232
+ } else if (consent.framework.latest) await requestFramework(consent.framework.latest, fence);
233
+ } catch (e) {
234
+ if (!c.signal.aborted) {
235
+ setError(message(e));
236
+ if (e instanceof MaintenanceError && e.code === 'fresh_auth_required') {
237
+ setReview(null);
238
+ await requestFreshAuth();
239
+ }
240
+ }
241
+ } finally {
242
+ if (!c.signal.aborted) {
243
+ setReview(null);
244
+ setBusy(false);
245
+ inFlight.current = false;
246
+ }
247
+ }
248
+ };
249
+ const observeOne = async (a: MaintenanceAttempt) => {
250
+ const c = controller.current; if (!c || inFlight.current) return;
251
+ inFlight.current = true; setBusy(true); setReview(null);
252
+ try {
253
+ const fence = readScope(a, c.signal);
254
+ if ('kind' in a.input) await observeMaintenance(a, fence);
255
+ else if ('version' in a.input) await observeFramework(a, fence);
256
+ else { const value = await readFramework(fence, true); fence.beforeSend?.(); setFramework({ value }); reconcilePolicy(value); void queryClient.invalidateQueries({queryKey:SettingModel.keys.root}); }
257
+ } catch (reason) { if (!c.signal.aborted) setError(message(reason)); }
258
+ finally { if (!c.signal.aborted) { inFlight.current = false; setBusy(false); } }
259
+ };
260
+ const refreshStatus = async () => {
261
+ for (const a of attempts) if (!['sending','refused','complete'].includes(a.state)) await observeOne(a);
262
+ };
263
+ const row = (f: Finding) => {
264
+ const attempt = getMaintenanceAttempt(resourceKey({ kind: f.kind, resource: f.resource_id }));
265
+ const live = attempt?.observation, currentVersion = live?.engine_version ?? f.current_version, urgency = supportUrgency(f);
266
+ return (
267
+ <article className="maintenance-resource" key={`${f.kind}:${f.resource_id}`}>
268
+ <div>
269
+ <h3>{f.resource_id}</h3>
270
+ <p>
271
+ {f.engine} ·{' '}
272
+ <span className="mono">
273
+ {currentVersion}{currentVersion !== f.available_major ? ` → ${f.available_major}` : ''}
274
+ </span>
275
+ </p>
276
+ <p className="maintenance-resource-status">{live?.status ?? f.status ?? 'Provider status unknown'}{(live?.pending_version ?? f.pending_version) ? ` · pending ${live?.pending_version ?? f.pending_version}` : ''}</p>
277
+ {urgency && <p className={f.days_remaining! <= 0 ? 'text-bad' : 'maintenance-warning'}>{urgency}</p>}
278
+ <details className="maintenance-details"><summary>Support &amp; upgrade details</summary>
279
+ <p>Resource: {f.kind} · Live: {f.status ?? 'unknown'} · Pending: {f.pending_version ?? 'not reported'} · {f.settled === null ? 'Settlement unknown' : f.settled ? 'Settled' : 'Still changing'}</p>
280
+ {f.engine_versions && <p>Member versions: {f.engine_versions.join(', ') || 'unknown'}{f.members === undefined ? '' : ` · ${f.members} members`}</p>}
281
+ <p>Standard support deadline: <ObservedTime value={f.deadline} dateOnly /></p>
282
+ <p>Extended support deadline: <ObservedTime value={f.extended_deadline} dateOnly /></p>
283
+ {f.note && <p>{f.note}</p>}
284
+ <p>Review compatibility and snapshot-based recovery before a major upgrade. The confirmation explains restart and outage impact.</p>
285
+ {f.release_notes_url && <a href={f.release_notes_url} target="_blank" rel="noopener noreferrer">Release notes</a>}
286
+ </details>
287
+ {attempt && <p role="status">{attempt.state === 'complete' ? 'Upgrade observed' : 'Update submitted · '+attempt.state}</p>}
288
+ </div>
289
+ {canWrite && access.aws && !busy && canRequestMaintenance(resourceKey({ kind: f.kind, resource: f.resource_id }), { kind: f.kind, resource: f.resource_id, target_version: f.available_major, confirm_resource: f.resource_id, apply_immediately: true }) && aws.value?.status === 'ok' && (
290
+ <button
291
+ className="btn" aria-label={`Upgrade ${f.resource_id}`}
292
+ onClick={() => setReview({ finding: f, fingerprint: JSON.stringify(f) })}
293
+ >
294
+ Upgrade…
295
+ </button>
296
+ )}
297
+ </article>
298
+ );
299
+ };
300
+ if (!me && auth.authenticated) return <p>Checking Maintenance permissions…</p>;
301
+ if (!access.aws && !access.framework)
302
+ return <p role="alert">Maintenance requires global AWS or framework read permission.</p>;
303
+ const fw = framework.value;
304
+ const tabs = [...(access.aws ? [{ key: 'database', label: 'Database', panel: ( <section aria-label="Database">
305
+ <h2>Database</h2>
306
+ {aws.value?.findings.filter((f) => f.kind !== 'elasticache').map(row)}
307
+ {aws.value?.status === 'ok' &&
308
+ !aws.value.findings.some((f) => f.kind !== 'elasticache') && (
309
+ <p>
310
+ No offered major upgrades found in this scan. Complete inventory and
311
+ support coverage are unknown.
312
+ </p>
313
+ )}
314
+ </section>) }, { key: 'cache', label: 'Cache', panel: ( <section aria-label="Cache">
315
+ <h2>Cache</h2>
316
+ {aws.value?.findings.filter((f) => f.kind === 'elasticache').map(row)}
317
+ {aws.value?.status === 'ok' &&
318
+ !aws.value.findings.some((f) => f.kind === 'elasticache') && (
319
+ <p>
320
+ No offered major upgrades found in this scan. Complete inventory and
321
+ support coverage are unknown.
322
+ </p>
323
+ )}
324
+ </section>) }] : []), ...(access.framework ? [{ key: 'framework', label: 'Django-MOJO', panel: ( <section aria-label="Framework">
325
+ <h2>Django-MOJO</h2>
326
+ {framework.loading && <p>Reading Framework evidence…</p>}
327
+ {framework.error && (
328
+ <p role="alert">Framework evidence is unavailable: {framework.error}</p>
329
+ )}
330
+ {fw && (
331
+ <>
332
+ <dl className="maintenance-framework-summary">
333
+ <div><dt>Installed</dt><dd>{fw.installed ?? 'unknown'}</dd></div>
334
+ <div><dt>Latest published</dt><dd>{fw.latest ?? 'unknown'}</dd></div>
335
+ <div><dt>Update policy <MaintenanceHelp label="policy permissions"><p>Policy changes require an active superuser and recent authentication. External infrastructure stays read-only here. Saving policy does not deploy it.</p></MaintenanceHelp></dt><dd>{fw.pin.mode === 'latest' ? 'Automatic updates' : fw.pin.mode === 'hold' ? 'Updates paused' : `Pinned to ${fw.pin.value ?? 'unknown version'}`}</dd></div>
336
+ </dl>
337
+ <p className={fw.source === 'unavailable' || fw.pin.mode !== 'latest' ? 'maintenance-warning' : 'dim'}>{frameworkStatusLabel(fw)} <MaintenanceHelp label="framework versions"><p>Installed is this responding node. Latest published comes from {fw.source === 'cache' ? 'cached PyPI evidence' : fw.source === 'pypi' ? 'PyPI' : 'an unavailable source'}. A paused policy can suppress the update-available flag.</p><p>Use deployment node evidence to verify the fleet.</p></MaintenanceHelp></p>
338
+ {fw.blocked_reason && <p className="maintenance-warning">{fw.blocked_reason.replaceAll('_', ' ')}</p>}
339
+ <details className="maintenance-details"><summary>Framework evidence</summary><p>Checked: <ObservedTime value={fw.checked_at} /> · Source: {fw.source}</p><p>Pin policy: {fw.pin.mode} {fw.pin.value}</p><p>An accepted framework update clears the pin and requests redeployment. Follow Deployments for fleet evidence.</p></details>
340
+ <div className="maintenance-actions">{canPolicy && canRequestMaintenance('framework-policy') && <button className="btn" disabled={busy} onClick={() => setPolicy(fw)}>Update policy</button>}
341
+ {canWrite && fw.can_update && canRequestMaintenance('framework', { version: fw.latest ?? '' }) && (
342
+ <button
343
+ className="btn btn-primary" disabled={busy}
344
+ onClick={() =>
345
+ setReview({ framework: fw, fingerprint: JSON.stringify(fw) })
346
+ }
347
+ >
348
+ {frameworkActionLabel(fw)}
349
+ </button>
350
+ )}</div>
351
+ </>
352
+ )}
353
+ </section>) }] : [])];
354
+ return (
355
+ <main className="admin-maintenance">
356
+ <header>
357
+ <div>
358
+ <p className="eyebrow">Operations</p>
359
+ <h1>Maintenance</h1>
360
+ <p>Keep your database, cache and Django-MOJO up to date.</p>
361
+ </div>
362
+ <button className="btn" disabled={busy} onClick={() => void load(true)}>
363
+ Refresh Maintenance
364
+ </button>
365
+ </header>
366
+ {mode.value !== 'managed' && <p className="maintenance-mode">
367
+ {mode.value === 'external'
368
+ ? 'External infrastructure: your infrastructure team applies upgrades through IaC. Evidence remains available here.'
369
+ : mode.value === 'managed'
370
+ ? 'Managed infrastructure confirmed.'
371
+ : `Infrastructure mode is unconfirmed. Upgrade controls require current managed-mode proof. ${mode.error ?? ''}`}
372
+ </p>}
373
+ {error && <p role="alert">{error}</p>}
374
+ {access.aws && (
375
+ <>
376
+ <section aria-label="AWS scan evidence" className="maintenance-scan">
377
+ {aws.loading && <p>Reading AWS evidence…</p>}
378
+ {aws.error && <p role="alert">AWS inventory is unavailable: {aws.error}</p>}
379
+ {aws.value && (
380
+ <>
381
+ <details className="ops-details"><summary>Scan details · <ObservedTime value={aws.value.generated_at} /></summary><p>
382
+ Region: {aws.value.region ?? 'unknown'} · Scan time:{' '}
383
+ <ObservedTime value={aws.value.generated_at} />
384
+ {' · '}Scheduled scanning:{' '}
385
+ {aws.value.scheduled
386
+ ? 'on'
387
+ : 'off — this page only reflects the current scan'}
388
+ </p></details>{!aws.value.scheduled && <p className="maintenance-warning">Scheduled scanning is off; results reflect this scan.</p>}
389
+ {aws.value.status === 'unavailable' && (
390
+ <p role="alert">
391
+ AWS is unavailable. Offered upgrades are unknown. {aws.value.reason}
392
+ </p>
393
+ )}
394
+ {aws.value.warnings.map((w, i) => (
395
+ <p className="maintenance-warning" key={i}>
396
+ Incomplete evidence: {w.iam_action}
397
+ {w.aws_code ? ` (${w.aws_code})` : ''}. These resources or live states
398
+ were not fully read.
399
+ </p>
400
+ ))}
401
+ </>
402
+ )}
403
+ </section>
404
+
405
+
406
+ </>
407
+ )}
408
+ {tabs.length === 1 ? tabs[0]!.panel : <Tabs items={tabs} defaultActiveKey={new URLSearchParams(location.search).get('tab')} ariaLabel="Maintenance category" />}
409
+ {attempts.length > 0 && (
410
+ <section aria-label="Submitted operations">
411
+ <h2>Submitted operations</h2>
412
+ {attempts.map(a => <article key={a.key} className="maintenance-attempt-row"><div><strong>{'kind' in a.input ? a.input.resource : 'policy' in a.input ? 'Framework policy' : 'Django-MOJO'}</strong><p role="status">{a.state === 'complete' ? a.frameworkObservation ? `Deployment ${a.frameworkObservation.status}` : 'policy' in a.input ? 'Policy observed' : 'Upgrade observed' : a.state === 'uncertain' ? 'Outcome unknown' : a.state === 'accepted' ? 'Deployment submitted' : a.state === 'timeout' ? 'Observation paused' : a.state === 'refused' ? 'Request refused' : 'Update in progress'}</p></div><button className="btn" disabled={busy} onClick={() => setProgress(a.key)}>View progress</button></article>)}
413
+ {attempts.some(a => !['sending','refused','complete'].includes(a.state) && (!('version' in a.input) || !!a.deployment)) && (
414
+ <button className="btn" disabled={busy} onClick={() => void refreshStatus()}>
415
+ Refresh progress
416
+ </button>
417
+ )}
418
+ {access.framework && deploymentLink && (
419
+ <a href={deploymentLink}>
420
+ Open Deployments
421
+ </a>
422
+ )}
423
+ </section>
424
+ )}
425
+ {policy && <MaintenancePolicyDialog framework={policy} busy={busy} error={error} onSave={value => void submitPolicy(value)} onClose={() => { if (!inFlight.current) setPolicy(null); }} />}
426
+ {progress && attempts.find(a => a.key === progress) && <MaintenanceProgressDialog attempt={attempts.find(a => a.key === progress)!} busy={busy} deploymentsHref={deploymentLink} onClose={() => { if (!inFlight.current) setProgress(null); }} onObserve={() => { const a = attempts.find(a => a.key === progress); if (a) void observeOne(a); }} />}
427
+ {review && (
428
+ <MaintenanceDialog
429
+ {...('finding' in review ? { finding: review.finding } : { framework: review.framework })}
430
+ busy={busy}
431
+ onConfirm={(immediate) => void submit(immediate)}
432
+ onClose={() => {
433
+ if (!inFlight.current) setReview(null);
434
+ }}
435
+ />
436
+ )}
437
+ </main>
438
+ );
439
+ }
440
+ function message(e: unknown) {
441
+ return e instanceof MaintenanceError ? e.message : 'Evidence is unavailable; refresh before continuing.';
442
+ }