portal-mojo 0.2.19 → 0.2.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/README.md +7 -1
- package/docs/admin-credentials.md +27 -0
- package/docs/admin-deployments.md +201 -0
- package/docs/admin-dns-certificates.md +160 -0
- package/docs/admin-dns-domains.md +92 -0
- package/docs/admin-dns.md +18 -0
- package/docs/admin-edge.md +200 -196
- package/docs/admin-fleet-configuration.md +98 -2
- package/docs/admin-geolocation.md +100 -0
- package/docs/admin-identity-directories.md +12 -0
- package/docs/admin-maintenance.md +133 -0
- package/docs/admin-modals.md +45 -2
- package/docs/admin-operational-fleet.md +206 -0
- package/docs/admin-presentation.md +51 -0
- package/docs/admin-signin.md +12 -2
- package/docs/auth-pages.md +18 -6
- package/docs/auth.md +16 -2
- package/docs/client.md +7 -0
- package/docs/collection-select.md +14 -0
- package/docs/combobox.md +18 -0
- package/docs/popover.md +5 -4
- package/package.json +1 -1
- package/src/admin/core/OperationsHelp.tsx +10 -0
- package/src/admin/core/index.ts +6 -4
- package/src/admin/core/route-context.ts +6 -0
- package/src/admin/credentials/AcmeConsumerKeyDialog.tsx +88 -0
- package/src/admin/credentials/acme-consumer-key.ts +34 -0
- package/src/admin/credentials/group-api-keys.tsx +5 -3
- package/src/admin/deployments/ApiDeploymentDetail.tsx +58 -0
- package/src/admin/deployments/DeploymentsPage.tsx +73 -0
- package/src/admin/deployments/WebAppDeploySetup.tsx +49 -0
- package/src/admin/deployments/WebAppDeploymentDetail.tsx +59 -0
- package/src/admin/deployments/WebAppDetail.tsx +104 -0
- package/src/admin/deployments/WebAppEditDialog.tsx +70 -0
- package/src/admin/deployments/WebAppKeyDialog.tsx +25 -0
- package/src/admin/deployments/WebAppOnboardingDialog.tsx +217 -0
- package/src/admin/deployments/WebAppRollbackDialog.tsx +82 -0
- package/src/admin/deployments/WebAppUploadPanel.tsx +132 -0
- package/src/admin/deployments/api.ts +158 -0
- package/src/admin/deployments/authority-state.ts +9 -0
- package/src/admin/deployments/deployment-state.ts +13 -0
- package/src/admin/deployments/index.ts +4 -0
- package/src/admin/deployments/onboarding-state.ts +11 -0
- package/src/admin/deployments/permissions.ts +37 -0
- package/src/admin/deployments/receipts.ts +78 -0
- package/src/admin/deployments/sanitize.ts +73 -0
- package/src/admin/deployments/shared.tsx +39 -0
- package/src/admin/deployments/types.ts +238 -0
- package/src/admin/deployments/upload.ts +53 -0
- package/src/admin/deployments/useDeploymentObservation.ts +104 -0
- package/src/admin/dns/CertificateDetail.tsx +8 -3
- package/src/admin/dns/CertificateRequestDialog.tsx +45 -7
- package/src/admin/dns/DomainDetail.tsx +14 -4
- package/src/admin/dns/DomainOnboardingContent.tsx +44 -0
- package/src/admin/dns/DomainOnboardingDialog.tsx +5 -24
- package/src/admin/dns/DomainPurchaseWizard.tsx +1 -1
- package/src/admin/dns/DomainRegisterExisting.tsx +63 -0
- package/src/admin/dns/DomainRegistrarSettings.tsx +52 -0
- package/src/admin/dns/DomainsPage.tsx +26 -13
- package/src/admin/dns/SharedValidationDialog.tsx +298 -0
- package/src/admin/dns/SharedValidationPanel.tsx +76 -0
- package/src/admin/dns/api.ts +4 -2
- package/src/admin/dns/domain-navigation.ts +16 -0
- package/src/admin/dns/registrar-api.ts +13 -0
- package/src/admin/dns/shared-validation-api.ts +90 -0
- package/src/admin/dns/shared-validation-data.ts +106 -0
- package/src/admin/domains/assistant.ts +5 -4
- package/src/admin/domains/communications.ts +10 -9
- package/src/admin/domains/identity.ts +8 -7
- package/src/admin/domains/infrastructure.ts +19 -13
- package/src/admin/domains/lazy-page.ts +6 -0
- package/src/admin/domains/observability.ts +6 -5
- package/src/admin/domains/operations.ts +27 -10
- package/src/admin/domains/security.ts +16 -15
- package/src/admin/edge/EdgeHelp.tsx +10 -0
- package/src/admin/edge/ServingReadiness.tsx +54 -0
- package/src/admin/edge/VhostDetail.tsx +145 -139
- package/src/admin/edge/VhostWizard.tsx +204 -115
- package/src/admin/edge/VhostsPage.tsx +27 -28
- package/src/admin/edge/api.ts +24 -10
- package/src/admin/edge/models.ts +4 -4
- package/src/admin/edge/serving-data.ts +78 -0
- package/src/admin/fleet/CapacityEditor.tsx +81 -0
- package/src/admin/fleet/CapacityInspector.tsx +19 -0
- package/src/admin/fleet/CapacityProgress.tsx +9 -0
- package/src/admin/fleet/FleetHelp.tsx +7 -0
- package/src/admin/fleet/FleetOverview.tsx +150 -0
- package/src/admin/fleet/FleetPage.tsx +11 -0
- package/src/admin/fleet/FleetWorkspace.tsx +82 -0
- package/src/admin/fleet/ScaleFleetDialog.tsx +169 -0
- package/src/admin/fleet/access.ts +22 -0
- package/src/admin/fleet/api.ts +77 -0
- package/src/admin/fleet/data.ts +148 -0
- package/src/admin/fleet/index.ts +8 -0
- package/src/admin/fleet/operations.ts +179 -0
- package/src/admin/fleet/standalone-data.ts +87 -0
- package/src/admin/fleet/types.ts +208 -0
- package/src/admin/fleet-configuration/FleetConfigurationPage.tsx +14 -8
- package/src/admin/fleet-configuration/SharedValidationConnectionPanel.tsx +166 -0
- package/src/admin/fleet-configuration/data.ts +4 -0
- package/src/admin/fleet-configuration/operator-destinations.ts +20 -0
- package/src/admin/fleet-configuration/shared-validation-data.ts +143 -0
- package/src/admin/geolocation/GeoLocationPage.tsx +171 -0
- package/src/admin/geolocation/data.ts +138 -0
- package/src/admin/geolocation/index.ts +3 -0
- package/src/admin/identity/groups/group-sections/SubGroupsSection.tsx +73 -2
- package/src/admin/identity/groups/models.ts +2 -0
- package/src/admin/identity/members/GroupMembersPanel.tsx +2 -8
- package/src/admin/index.ts +8 -0
- package/src/admin/maintenance/MaintenanceDialogs.tsx +161 -0
- package/src/admin/maintenance/MaintenancePage.tsx +442 -0
- package/src/admin/maintenance/data.ts +486 -0
- package/src/admin/maintenance/index.ts +3 -0
- package/src/admin/maintenance/operations.ts +208 -0
- package/src/admin/public/infrastructure.ts +1 -0
- package/src/admin/public/operations.ts +3 -0
- package/src/admin/registry.ts +6 -2
- package/src/admin/settings/SettingDetail.tsx +7 -2
- package/src/admin/settings/SettingEditor.tsx +2 -1
- package/src/admin/settings/SettingsPage.tsx +3 -1
- package/src/admin/settings/model.ts +1 -0
- package/src/admin/signin/SigninPage.tsx +17 -3
- package/src/admin/signin/api.ts +2 -0
- package/src/charts/MetricsChartView.tsx +4 -11
- package/src/charts/MetricsMiniWidget.tsx +3 -15
- package/src/client/auth.ts +77 -25
- package/src/client/client.ts +6 -0
- package/src/client/deployments-mock.ts +375 -0
- package/src/client/domain-operations-mock.ts +37 -0
- package/src/client/fleet-mock.ts +73 -1
- package/src/client/geo-provider-mock.ts +82 -0
- package/src/client/index.ts +2 -2
- package/src/client/maintenance-mock.ts +285 -0
- package/src/client/mock.ts +280 -11
- package/src/client/operational-fleet-mock.ts +279 -0
- package/src/client/operations-mock.ts +85 -0
- package/src/client/serving-mock.ts +22 -0
- package/src/styles/components/admin-deployments.css +35 -0
- package/src/styles/components/admin-dns-certificates.css +33 -0
- package/src/styles/components/admin-domains.css +12 -0
- package/src/styles/components/admin-edge.css +125 -1
- package/src/styles/components/admin-geolocation.css +40 -0
- package/src/styles/components/admin-maintenance.css +127 -0
- package/src/styles/components/admin-operational-fleet.css +38 -0
- package/src/styles/components/admin-operations.css +9 -0
- package/src/styles/components/admin-signin.css +2 -0
- package/src/styles/components/combobox.css +12 -2
- package/src/styles/components/fleet-page.css +9 -0
- package/src/styles/index.css +7 -0
- package/src/ui/CollectionSelect.tsx +30 -8
- package/src/ui/ComboBox.tsx +39 -8
- package/src/ui/FilterBar.tsx +3 -5
- package/src/ui/ModelTable.tsx +2 -8
- package/src/ui/Popover.tsx +15 -7
- package/src/ui/date/TimePicker.tsx +4 -14
- package/src/ui/image-editor/ImageEditor.tsx +4 -5
- package/src/ui/schema-form-core.tsx +3 -5
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { useEffect, useRef, useState, useSyncExternalStore } from 'react';
|
|
2
|
+
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
|
3
|
+
import { Link, useLocation } from 'react-router-dom';
|
|
4
|
+
import { decodeJwt, getAccessToken, getAuthSessionRevision, subscribeAuth, useAuthSnapshot, useMe } from '../../client/runtime';
|
|
5
|
+
import { modal } from '../../ui/modal';
|
|
6
|
+
import { Tabs } from '../../ui/Tabs';
|
|
7
|
+
import { datetime } from '../../ui/format';
|
|
8
|
+
import { useAdminRouteContext } from '../core/route-context';
|
|
9
|
+
import { backendGlobalCan, FLEET_CAPACITY_GRANTS, FLEET_DASHBOARD_GRANTS } from './access';
|
|
10
|
+
import { readCapacity, readFleetDashboard } from './api';
|
|
11
|
+
import { fleetOperations, fleetSessionScope } from './operations';
|
|
12
|
+
import { FleetHelp } from './FleetHelp';
|
|
13
|
+
import type { FleetLinks, FleetOverviewProps } from './FleetOverview';
|
|
14
|
+
import type { CapacityReport, FleetFacts, FleetSource } from './types';
|
|
15
|
+
const defaultLinks: FleetLinks = { last_deployment: { to: 'deployments', grants: ['view_platform', 'manage_platform', 'admin', 'view_dns', 'manage_dns', 'security'] }, framework: { to: 'maintenance', grants: ['manage_aws', 'view_platform', 'manage_platform', 'admin'] }, certificates: { to: 'dns/certificates', grants: ['view_dns', 'manage_dns', 'security'] }, incidents: { to: 'security/incidents?status=open', grants: ['view_security', 'manage_security', 'security'] }, tickets: { to: 'security/tickets', grants: ['view_security', 'manage_security', 'security'] }, jobs: { to: 'jobs/list', grants: ['view_jobs', 'manage_jobs', 'jobs'] }, email: { to: 'email/domains', grants: ['manage_aws', 'comms'] }, sms: { to: 'phonehub', grants: ['view_phone_config', 'manage_phone_config', 'view_sms', 'manage_sms', 'comms'] } };
|
|
16
|
+
const labels: Record<string, string> = { load_balancer: 'Load balancer', compute: 'App nodes', database: 'Database', cache: 'Redis', certificates: 'Certificates', public_api: 'Public API', framework: 'Django-MOJO', last_deployment: 'Last deployment', jobs: 'Jobs', sanity: 'Node checks', incidents: 'Incidents', tickets: 'Tickets', sms: 'SMS', email: 'Email' };
|
|
17
|
+
const fact = (v: unknown, fallback = 'Unknown') => typeof v === 'string' || typeof v === 'number' ? String(v) : fallback;
|
|
18
|
+
const object = (v: unknown): FleetFacts => v && typeof v === 'object' && !Array.isArray(v) ? v as FleetFacts : {};
|
|
19
|
+
const list = (v: unknown): unknown[] => Array.isArray(v) ? v : [];
|
|
20
|
+
function age(value: string | null, now: number): string { const stamp = value ? Date.parse(value) : NaN; if (!Number.isFinite(stamp)) return 'Observation time unavailable'; const sec = Math.max(0, Math.floor((now - stamp) / 1000)); return sec < 60 ? `Observed ${sec}s ago` : sec < 3600 ? `Observed ${Math.floor(sec / 60)}m ago` : `Observed ${Math.floor(sec / 3600)}h ago`; }
|
|
21
|
+
function concise(name: string, d: FleetFacts): string {
|
|
22
|
+
switch (name) {
|
|
23
|
+
case 'compute': return `${fact(d.up)} / ${fact(d.total)} up`;
|
|
24
|
+
case 'load_balancer': return d.configured === false ? 'Not configured' : `${fact(d.healthy)} / ${fact(d.registered)} healthy targets`;
|
|
25
|
+
case 'database': case 'cache': return `${d.reachable === true ? 'Reachable' : d.reachable === false ? 'Unreachable' : 'Reachability unknown'} · ${fact(d.engine)}`;
|
|
26
|
+
case 'certificates': return `${fact(d.active)} active · ${fact(d.expiring_within_30_days)} expiring soon`;
|
|
27
|
+
case 'public_api': return d.configured === false ? 'Not configured' : `HTTP ${fact(object(d.probe).http_status)} · ${object(d.probe).ok === true ? 'Passed' : object(d.probe).ok === false ? 'Failed' : 'Unknown'}`;
|
|
28
|
+
case 'framework': return `${fact(d.installed)}${d.update_available === true ? ' · update available' : ''}`;
|
|
29
|
+
case 'last_deployment': return `${fact(object(list(d.items)[0]).sha, 'No deployment').slice(0, 12)} · ${fact(object(list(d.items)[0]).status)}`;
|
|
30
|
+
case 'jobs': return `${fact(object(d.jobs).pending)} pending · ${fact(object(d.jobs).running)} running`;
|
|
31
|
+
case 'sanity': return list(d.checks).length ? `${list(d.checks).filter(v => object(v).ok === true).length} / ${list(d.checks).length} checks passed` : 'Check evidence unavailable';
|
|
32
|
+
case 'incidents': case 'tickets': return `${fact(d.open)} open`;
|
|
33
|
+
case 'sms': return `${fact(d.provider)} · ${d.test_mode === true ? 'Test mode' : 'Live mode'}`;
|
|
34
|
+
case 'email': return `${fact(d.domains)} domains`;
|
|
35
|
+
default: return 'Evidence available';
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
function SourceRow({ name, source, link, now }: { name: string; source: FleetSource; link?: string; now: number }) {
|
|
39
|
+
const restricted = source.status === 'permission_denied', stale = source.stale_after && Date.parse(source.stale_after) <= now;
|
|
40
|
+
return <article className="fp-source"><div className="fp-row"><strong>{link && !restricted ? <Link to={link}>{labels[name] ?? name}</Link> : labels[name] ?? name}</strong>{!restricted && <FleetHelp label={labels[name] ?? name}><div className="fp-stack"><strong>{labels[name] ?? name}</strong><p>{concise(name, source.data)}</p>{source.reason && <p>{source.reason.replaceAll('_', ' ')}</p>}<p>Observed {source.observed_at ? datetime(source.observed_at) : 'time unavailable'}{source.stale_after ? ` · stale after ${datetime(source.stale_after)}` : ''}</p>{name === 'compute' && <p>{source.data.source === 'target_group' ? 'Load-balancer target evidence' : source.data.source === 'ec2' ? 'EC2 inventory; serving health unknown' : 'Evidence source unknown'}</p>}{name === 'database' && source.data.drift != null && <p className="text-warn">Engine update: {fact(object(source.data.drift).available_major)} · {fact(object(source.data.drift).note)} · {fact(object(source.data.drift).deadline)}</p>}{name === 'certificates' && <p>{fact(source.data.failing)} failing · next expiry {fact(source.data.soonest_expiry)}</p>}{name === 'jobs' && <p>Scheduler: {source.data.scheduler_active === true ? 'Active' : source.data.scheduler_active === false ? 'Inactive' : 'Unknown'} · {fact(source.data.failed_recent)} recent failures</p>}</div></FleetHelp>}</div><p>{restricted ? 'Restricted' : concise(name, source.data)}</p><div className="fp-row fp-source-meta"><span className={`fp-state ${stale ? 'text-warn' : ''}`}>{restricted ? 'Restricted' : source.status.replaceAll('_', ' ')}{stale ? ' · stale' : ''}</span>{stale && <time className="text-warn" dateTime={source.observed_at ?? undefined} title={source.observed_at ?? undefined}>{age(source.observed_at, now)}</time>}</div></article>;
|
|
41
|
+
}
|
|
42
|
+
export function FleetWorkspace({ mount: explicitMount, links: customLinks, showConfigurationLink = true }: FleetOverviewProps = {}) {
|
|
43
|
+
const auth = useAuthSnapshot(), { data: me } = useMe(), queryClient = useQueryClient(), location = useLocation(), route = useAdminRouteContext();
|
|
44
|
+
const scope = auth.authenticated && auth.uid ? `${auth.uid}:${getAuthSessionRevision()}` : null, mount = (explicitMount ?? route?.mount ?? location.pathname.replace(/\/fleet\/?$/, '').replace(/\/$/, '')).replace(/\/$/, '');
|
|
45
|
+
const canDashboard = !!scope && backendGlobalCan(me, FLEET_DASHBOARD_GRANTS), canCapacity = !!scope && backendGlobalCan(me, FLEET_CAPACITY_GRANTS);
|
|
46
|
+
const fresh = useRef(false), owner = useRef<AbortController | null>(null), openKind = useRef<'inspect' | 'edit' | 'progress' | null>(null), dismissBusy = useRef(false), [refreshing, setRefreshing] = useState(false), [opening, setOpening] = useState(false), [error, setError] = useState(''), [now, setNow] = useState(Date.now());
|
|
47
|
+
const currentAuthority = useRef({ scope, canDashboard, canCapacity, eligible: false }); currentAuthority.current = { scope, canDashboard, canCapacity, eligible: false };
|
|
48
|
+
const assertRead = (kind: 'dashboard' | 'capacity') => { const current = currentAuthority.current; if (!scope || fleetSessionScope() !== scope || current.scope !== scope || !(kind === 'dashboard' ? current.canDashboard : current.canCapacity)) throw new Error('Fleet read authority changed. Refresh with your current session.'); };
|
|
49
|
+
const dashboard = useQuery({ queryKey: ['operational-fleet', 'dashboard', scope], queryFn: ({ signal }) => readFleetDashboard(fresh.current, { signal, beforeSend: () => assertRead('dashboard') }), enabled: canDashboard, retry: false, refetchOnWindowFocus: false });
|
|
50
|
+
const capacity = useQuery({ queryKey: ['operational-fleet', 'capacity', scope], queryFn: ({ signal }) => readCapacity(fresh.current, { signal, beforeSend: () => assertRead('capacity') }), enabled: canCapacity, retry: false, refetchOnWindowFocus: false });
|
|
51
|
+
const operation = useSyncExternalStore(fn => scope ? fleetOperations.subscribe(scope, fn) : () => {}, () => scope ? fleetOperations.get(scope) : null, () => null);
|
|
52
|
+
const jwt = decodeJwt(getAccessToken()), interactive = !!jwt && !['api_key', 'group_scoped_token'].includes(String(jwt.credential_kind ?? ''));
|
|
53
|
+
const eligible = canCapacity && me?.is_superuser === true && interactive && capacity.data?.mode === 'managed', blocked = !!operation && ['submitting', 'running', 'unknown'].includes(operation.status);
|
|
54
|
+
currentAuthority.current.eligible = eligible;
|
|
55
|
+
const assertActive = (mutate = false) => { const current = currentAuthority.current; if (!scope || fleetSessionScope() !== scope || current.scope !== scope || !current.canCapacity || mutate && !current.eligible) throw new Error('Capacity authority changed. Reopen with your current session.'); };
|
|
56
|
+
useEffect(() => { const expected = scope; const unsubscribe = subscribeAuth(() => { if (fleetSessionScope() !== expected) owner.current?.abort(); }); return () => { owner.current?.abort(); unsubscribe(); }; }, [scope]);
|
|
57
|
+
useEffect(() => { if (!canCapacity || openKind.current === 'edit' && !eligible) owner.current?.abort(); }, [canCapacity, eligible]);
|
|
58
|
+
useEffect(() => { const timer = setInterval(() => setNow(Date.now()), 10_000); return () => clearInterval(timer); }, []);
|
|
59
|
+
useEffect(() => { if (!scope || !canCapacity || !operation || !['done', 'failed', 'unknown'].includes(operation.status)) return; const expected = scope; const controller = new AbortController(); void readCapacity(true, { signal: controller.signal, beforeSend: () => assertActive() }).then(next => { if (fleetSessionScope() === expected && currentAuthority.current.canCapacity) queryClient.setQueryData(['operational-fleet', 'capacity', expected], next); }).catch(() => {}); return () => controller.abort(); }, [scope, canCapacity, operation?.status, operation?.planId, queryClient]);
|
|
60
|
+
const refresh = async () => { if (refreshing) return; setRefreshing(true); fresh.current = true; try { await Promise.all([...(canDashboard ? [dashboard.refetch()] : []), ...(canCapacity ? [capacity.refetch()] : [])]); } finally { fresh.current = false; setRefreshing(false); } };
|
|
61
|
+
const launch = async (kind: 'inspect' | 'edit' | 'progress') => {
|
|
62
|
+
if (opening || dismissBusy.current) return;
|
|
63
|
+
try { assertActive(kind === 'edit'); if (kind === 'edit' && (blocked || !capacity.data) || kind === 'inspect' && !capacity.data) return; owner.current?.abort(); const controller = new AbortController(); owner.current = controller; openKind.current = kind; setOpening(true); setError('');
|
|
64
|
+
const setBusy = (value: boolean) => { dismissBusy.current = value; };
|
|
65
|
+
if (kind === 'edit') { const { CapacityEditor } = await import('./CapacityEditor'); assertActive(true); if (controller.signal.aborted) return; setOpening(false); await modal.open(done => <CapacityEditor scope={scope!} initialReport={capacity.data!} close={() => done(null)} setBusy={setBusy} assertActive={() => assertActive(true)} signal={controller.signal} onRefresh={(next: CapacityReport) => queryClient.setQueryData(['operational-fleet', 'capacity', scope], next)} onProgress={() => { void launch('progress'); }}/>, { size: 'lg', signal: controller.signal, canDismiss: () => !dismissBusy.current && fleetOperations.get(scope!).status !== 'submitting' }); }
|
|
66
|
+
else if (kind === 'inspect') { const { CapacityInspector } = await import('./CapacityInspector'); assertActive(); if (controller.signal.aborted) return; setOpening(false); await modal.open(done => <CapacityInspector report={capacity.data!} ingress={dashboard.data?.sources.load_balancer} close={() => done(null)}/>, { size: 'lg', signal: controller.signal }); }
|
|
67
|
+
else { const { CapacityProgress } = await import('./CapacityProgress'); assertActive(); if (controller.signal.aborted) return; setOpening(false); await modal.open(done => <CapacityProgress scope={scope!} close={() => done(null)} setBusy={setBusy} assertActive={() => assertActive()}/>, { size: 'lg', signal: controller.signal, canDismiss: () => !dismissBusy.current && fleetOperations.get(scope!).status !== 'submitting' }); }
|
|
68
|
+
} catch (reason) { setError(reason instanceof Error ? reason.message : 'Fleet dialog could not open.'); } finally { setOpening(false); dismissBusy.current = false; }
|
|
69
|
+
};
|
|
70
|
+
const links = { ...defaultLinks, ...customLinks }, data = canDashboard ? dashboard.data : undefined;
|
|
71
|
+
const sections = [{ title: 'Infrastructure', names: ['load_balancer', 'compute', 'database', 'cache', 'public_api', 'certificates'] }, { title: 'Operations', names: ['framework', 'last_deployment', 'jobs', 'sanity', 'incidents', 'tickets', 'sms', 'email'] }];
|
|
72
|
+
const configMounted = !route || route.paths.includes(`${mount}/fleet-configuration`);
|
|
73
|
+
return <section className="fp-workspace" aria-label="Fleet workspace"><header className="fp-header"><div><div className="eyebrow">Operations</div><h1>Fleet</h1><p className="dim">Health and capacity</p></div><div className="fp-actions">{canCapacity && (blocked ? <button className="btn btn-primary" disabled={opening} onClick={() => void launch('progress')}>View capacity progress</button> : eligible ? <button className="btn btn-primary" disabled={opening} onClick={() => void launch('edit')}>Change capacity</button> : null)}<button className="btn" disabled={refreshing || dashboard.isFetching || capacity.isFetching || operation?.status === 'submitting'} onClick={() => void refresh()}>Refresh fleet</button>{canCapacity && <button className="btn" disabled={!capacity.data || opening} onClick={() => void launch('inspect')}>Inspect capacity</button>}{showConfigurationLink && configMounted && me?.is_superuser === true && <Link className="btn" to={`${mount}/fleet-configuration`}>Configuration</Link>}</div></header>
|
|
74
|
+
{data && <div className={`panel fp-health fp-health-${data.availability.state}`}><div><strong>{data.availability.message}</strong><p className="dim"><time dateTime={data.observed_at} title={datetime(data.observed_at)}>{age(data.observed_at, now)}</time></p></div><span className="fp-state">{data.availability.state === 'ok' ? 'Available' : data.availability.state === 'down' ? 'Unavailable' : 'Unknown'}</span></div>}
|
|
75
|
+
{canDashboard && !data && <div className="panel panel-pad" role={dashboard.error ? 'alert' : 'status'}>{dashboard.error?.message ?? 'Loading fleet health…'}</div>}{data && dashboard.error && <p className="form-alert" role="alert">Latest health observation failed: {dashboard.error.message}. Prior evidence retains its original age.</p>}
|
|
76
|
+
{canCapacity && capacity.data && <div className="fp-capacity-strip panel"><div><span className="dim">App nodes</span><strong>{capacity.data.nodes.inventory_available ? capacity.data.nodes.instances.length : 'Unknown'}</strong></div><div><span className="dim">Database</span><strong>{capacity.data.databases_available ? capacity.data.databases.length : 'Unknown'}</strong></div><div><span className="dim">Redis</span><strong>{capacity.data.caches_available ? capacity.data.caches.length : 'Unknown'}</strong></div><div className="fp-capacity-context"><span>{capacity.data.region} · {capacity.data.mode}</span><small className="dim">{age(capacity.data.generated_at, now)}</small></div></div>}
|
|
77
|
+
{canCapacity && capacity.error && <p className="form-alert" role="alert">Capacity observation unavailable: {capacity.error.message}</p>}
|
|
78
|
+
{operation && operation.status !== 'idle' && canCapacity && <div className="panel fp-operation"><div><strong>{operation.status === 'done' ? 'Capacity batch completed' : operation.status === 'failed' ? 'Capacity batch failed' : operation.status === 'unknown' ? 'Capacity progress unknown' : 'Capacity change in progress'}</strong><p className="dim">{operation.message}</p></div>{!blocked && <button className="btn" onClick={() => void launch('progress')}>View capacity progress</button>}</div>}
|
|
79
|
+
{data && <section className="panel fp-section"><Tabs ariaLabel="Fleet health" items={sections.map(section => { const names = section.names.filter(name => { const source = data.sources[name]; return source && !(['sms', 'email'].includes(name) && (source.status === 'unconfigured' || source.data.configured === false)) && !(name === 'tickets' && source.data.open === 0); }); return { key: section.title.toLowerCase(), label: section.title, panel: <div className="fp-evidence-grid">{names.every(name => data.sources[name]?.status === 'permission_denied') ? <p className="fp-restricted">Restricted — your account cannot read these sources.</p> : names.map(name => { const link = links[name], destination = link && (!link.superuser || me?.is_superuser === true) && backendGlobalCan(me, link.grants) && (!route || route.paths.includes(`${mount}/${link.to.replace(/^\//, '').split('?')[0]}`)) ? `${mount}/${link.to.replace(/^\//, '')}` : undefined; return <SourceRow key={name} name={name} source={data.sources[name]!} link={destination} now={now}/>; })}</div> }; })}/></section>}
|
|
80
|
+
{data?.attention.message && <p className="text-warn">{data.attention.message}</p>}{!eligible && <p className="dim fp-access-note">{!canCapacity ? 'Capacity details require global manage_aws access.' : me?.is_superuser !== true ? 'Capacity changes require a superuser account.' : !interactive ? 'Capacity changes require an interactive user session.' : capacity.data?.mode === 'external' ? 'Infrastructure is managed externally. Capacity is read-only.' : 'Capacity changes require an available managed inventory.'}</p>}{error && <p className="form-alert" role="alert">{error}</p>}
|
|
81
|
+
</section>;
|
|
82
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
|
|
2
|
+
import { decodeJwt, getAccessToken, getAuthSessionRevision, getAuthSnapshot, isReauthRequired, requestFreshAuth, subscribeAuth, useMe } from '../../client/runtime';
|
|
3
|
+
import { backendGlobalCan } from './access';
|
|
4
|
+
import { Tabs } from '../../ui/Tabs';
|
|
5
|
+
import { datetime } from '../../ui/format';
|
|
6
|
+
import { OperationsHelp } from '../core/OperationsHelp';
|
|
7
|
+
import { readCapacity, requestCapacityPlan } from './api';
|
|
8
|
+
import { actionReason, compileFleetSteps, drainedNodeReason, monthlyPrice, newFleetDraft, nodeRemovalReason, offered, type FleetDraft } from './data';
|
|
9
|
+
import { fleetOperations, fleetSessionScope } from './operations';
|
|
10
|
+
import type { CapacityPlan, CapacityReport, CapacitySize } from './types';
|
|
11
|
+
export interface ScaleFleetDialogProps {
|
|
12
|
+
scope: string;
|
|
13
|
+
initialReport: CapacityReport;
|
|
14
|
+
close: () => void;
|
|
15
|
+
onRefresh?: () => void;
|
|
16
|
+
debounceMs?: number;
|
|
17
|
+
}
|
|
18
|
+
function SizeChoice({ label, value, onChange, sizes, disabled }: {
|
|
19
|
+
label: string;
|
|
20
|
+
value: string;
|
|
21
|
+
onChange: (v: string) => void;
|
|
22
|
+
sizes: CapacitySize[];
|
|
23
|
+
disabled?: boolean;
|
|
24
|
+
}) {
|
|
25
|
+
return <label className="field"><span className="field-label">{label}</span><select className="input" value={value} disabled={disabled} onChange={e => onChange(e.target.value)}><option value="">Keep current sizes</option>{sizes.map(s => <option key={s.size} value={s.size}>{s.label} · {s.monthly_usd == null ? 'price unknown' : `$${s.monthly_usd.toFixed(2)}/mo`}</option>)}</select></label>;
|
|
26
|
+
}
|
|
27
|
+
export function ScaleFleetDialog({ scope, initialReport, close, onRefresh, debounceMs = 400 }: ScaleFleetDialogProps) {
|
|
28
|
+
const { data: me } = useMe();
|
|
29
|
+
const [report, setReport] = useState(initialReport), [draft, setDraft] = useState(() => newFleetDraft(initialReport)), [plan, setPlan] = useState<CapacityPlan | null>(null), [consent, setConsent] = useState<string | null>(null), [error, setError] = useState(''), [planning, setPlanning] = useState(false), [refreshing, setRefreshing] = useState(false), [needsRefresh, setNeedsRefresh] = useState(false), [batchInput, setBatchInput] = useState(''), [tick, setTick] = useState(Date.now());
|
|
30
|
+
const generation = useRef(0), alive = useRef(true), planBusy = useRef(false), applyBusy = useRef(false), readGeneration = useRef(0), readBusy = useRef(false), planAbort = useRef<AbortController | null>(null);
|
|
31
|
+
const operation = useSyncExternalStore(fn => fleetOperations.subscribe(scope, fn), () => fleetOperations.get(scope), () => fleetOperations.get(scope));
|
|
32
|
+
const blocked = ['submitting', 'running', 'unknown'].includes(operation.status);
|
|
33
|
+
const invalidate = () => { generation.current++; planAbort.current?.abort(); setPlan(null); setConsent(null); setPlanning(false); setError(''); };
|
|
34
|
+
const edit = (change: (d: FleetDraft) => FleetDraft) => { invalidate(); setDraft(change); };
|
|
35
|
+
const authorized = () => { const jwt = decodeJwt(getAccessToken()); if (!jwt || ['api_key', 'group_scoped_token'].includes(String(jwt.credential_kind ?? '')))
|
|
36
|
+
throw new Error('Scaling requires an interactive user session.'); if (fleetSessionScope() !== scope || !backendGlobalCan(me, 'manage_aws') || me?.is_superuser !== true || report.mode !== 'managed')
|
|
37
|
+
throw new Error('Capacity authority changed. Refresh and review again.'); };
|
|
38
|
+
const refresh = async () => { if (readBusy.current)
|
|
39
|
+
return; readBusy.current = true; invalidate(); setRefreshing(true); setNeedsRefresh(true); const gen = ++readGeneration.current; try {
|
|
40
|
+
authorized();
|
|
41
|
+
const next = await readCapacity(true, { beforeSend: authorized });
|
|
42
|
+
if (!alive.current || gen !== readGeneration.current || fleetSessionScope() !== scope)
|
|
43
|
+
return;
|
|
44
|
+
setReport(next);
|
|
45
|
+
setDraft(newFleetDraft(next));
|
|
46
|
+
setNeedsRefresh(false);
|
|
47
|
+
onRefresh?.();
|
|
48
|
+
}
|
|
49
|
+
catch (e) {
|
|
50
|
+
if (alive.current && fleetSessionScope() === scope)
|
|
51
|
+
setError(e instanceof Error ? e.message : 'Inventory observation unavailable.');
|
|
52
|
+
}
|
|
53
|
+
finally {
|
|
54
|
+
readBusy.current = false;
|
|
55
|
+
if (alive.current)
|
|
56
|
+
setRefreshing(false);
|
|
57
|
+
} };
|
|
58
|
+
useEffect(() => { alive.current = true; const startRevision = getAuthSessionRevision(); const unsubscribe = subscribeAuth(() => { if (!getAuthSnapshot().authenticated || getAuthSessionRevision() !== startRevision || fleetSessionScope() !== scope) {
|
|
59
|
+
generation.current++;
|
|
60
|
+
readGeneration.current++;
|
|
61
|
+
planAbort.current?.abort();
|
|
62
|
+
close();
|
|
63
|
+
} }); return () => { alive.current = false; generation.current++; readGeneration.current++; planAbort.current?.abort(); unsubscribe(); }; }, [scope, close]);
|
|
64
|
+
useEffect(() => { const timer = setTimeout(() => void refresh(), 0); return () => clearTimeout(timer); }, []);
|
|
65
|
+
useEffect(() => { if (operation.status === 'done' || operation.status === 'failed')
|
|
66
|
+
void refresh(); }, [operation.status]);
|
|
67
|
+
useEffect(() => { const timer = setInterval(() => setTick(Date.now()), 1000); return () => clearInterval(timer); }, []);
|
|
68
|
+
useEffect(() => { if (!backendGlobalCan(me, 'manage_aws') || me?.is_superuser !== true) {
|
|
69
|
+
invalidate();
|
|
70
|
+
close();
|
|
71
|
+
} }, [me, close]);
|
|
72
|
+
const compiled = useMemo(() => { try {
|
|
73
|
+
return { steps: compileFleetSteps(report, draft), error: '' };
|
|
74
|
+
}
|
|
75
|
+
catch (e) {
|
|
76
|
+
return { steps: [], error: e instanceof Error ? e.message : 'Invalid capacity proposal.' };
|
|
77
|
+
} }, [report, draft]);
|
|
78
|
+
useEffect(() => {
|
|
79
|
+
if (!compiled.steps.length || compiled.error || needsRefresh || refreshing || blocked)
|
|
80
|
+
return;
|
|
81
|
+
const gen = ++generation.current;
|
|
82
|
+
let timer: ReturnType<typeof setTimeout> | null = null, disposed = false;
|
|
83
|
+
const run = async () => {
|
|
84
|
+
if (disposed || gen !== generation.current)
|
|
85
|
+
return;
|
|
86
|
+
if (planBusy.current) {
|
|
87
|
+
timer = setTimeout(() => void run(), 50);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
planBusy.current = true;
|
|
91
|
+
setPlanning(true);
|
|
92
|
+
const abort = new AbortController();
|
|
93
|
+
planAbort.current = abort;
|
|
94
|
+
try {
|
|
95
|
+
authorized();
|
|
96
|
+
const next = await requestCapacityPlan(compiled.steps, { signal: abort.signal, beforeSend: () => { authorized(); if (disposed || gen !== generation.current)
|
|
97
|
+
throw new Error('Proposal changed.'); } });
|
|
98
|
+
if (!alive.current || disposed || gen !== generation.current || fleetSessionScope() !== scope)
|
|
99
|
+
return;
|
|
100
|
+
if (next.actor !== me?.id)
|
|
101
|
+
throw new Error('Plan actor does not match your account.');
|
|
102
|
+
setPlan(next);
|
|
103
|
+
setConsent(null);
|
|
104
|
+
setError('');
|
|
105
|
+
}
|
|
106
|
+
catch (e) {
|
|
107
|
+
if (!alive.current || disposed || gen !== generation.current)
|
|
108
|
+
return;
|
|
109
|
+
setPlan(null);
|
|
110
|
+
setConsent(null);
|
|
111
|
+
setNeedsRefresh(true);
|
|
112
|
+
setError(isReauthRequired(e) ? 'Authenticate again, refresh inventory, and review a new plan.' : e instanceof Error ? e.message : 'The plan could not be prepared.');
|
|
113
|
+
if (isReauthRequired(e))
|
|
114
|
+
await requestFreshAuth();
|
|
115
|
+
}
|
|
116
|
+
finally {
|
|
117
|
+
planBusy.current = false;
|
|
118
|
+
if (alive.current && !disposed)
|
|
119
|
+
setPlanning(false);
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
timer = setTimeout(() => void run(), debounceMs);
|
|
123
|
+
return () => { disposed = true; if (timer)
|
|
124
|
+
clearTimeout(timer); planAbort.current?.abort(); };
|
|
125
|
+
}, [compiled, needsRefresh, refreshing, blocked, scope, debounceMs, me]);
|
|
126
|
+
const apply = async () => { if (applyBusy.current || !plan || consent !== plan.id || blocked || needsRefresh || refreshing || Date.parse(plan.expires_at) <= Date.now())
|
|
127
|
+
return; applyBusy.current = true; const reviewed = plan; setConsent(null); generation.current++; setError(''); try {
|
|
128
|
+
authorized();
|
|
129
|
+
if (reviewed.actor !== me?.id)
|
|
130
|
+
throw new Error('Plan actor changed.');
|
|
131
|
+
await fleetOperations.submit(scope, reviewed);
|
|
132
|
+
}
|
|
133
|
+
catch (e) {
|
|
134
|
+
if (alive.current && fleetSessionScope() === scope)
|
|
135
|
+
setError(e instanceof Error ? e.message : 'Apply was refused.');
|
|
136
|
+
}
|
|
137
|
+
finally {
|
|
138
|
+
applyBusy.current = false;
|
|
139
|
+
if (alive.current && fleetSessionScope() === scope) {
|
|
140
|
+
setPlan(null);
|
|
141
|
+
setNeedsRefresh(true);
|
|
142
|
+
void refresh();
|
|
143
|
+
}
|
|
144
|
+
onRefresh?.();
|
|
145
|
+
} };
|
|
146
|
+
const expired = plan ? Date.parse(plan.expires_at) <= tick : false;
|
|
147
|
+
const setDb = (id: string, patch: Partial<FleetDraft['databases'][string]>) => edit(d => ({ ...d, databases: { ...d.databases, [id]: { ...d.databases[id]!, ...patch } } }));
|
|
148
|
+
const setCache = (id: string, patch: Partial<FleetDraft['caches'][string]>) => edit(d => ({ ...d, caches: { ...d.caches, [id]: { ...d.caches[id]!, ...patch } } }));
|
|
149
|
+
const toggle = (key: 'removeNodes' | 'terminateNodes', id: string) => edit(d => ({ ...d, [key]: d[key].includes(id) ? d[key].filter(v => v !== id) : [...d[key], id] }));
|
|
150
|
+
const sources = report.nodes.instances.filter(n => n.healthy && n.registered && n.instance_state === 'running');
|
|
151
|
+
return <div className="modal-pad fleet-scale"><h2 className="modal-title">Scale fleet</h2><p className="modal-message">Choose capacity, then review cost and interruptions before applying.</p><div className="fleet-scale-body">
|
|
152
|
+
<div className="fleet-buttons"><p className="dim">Inventory checked {datetime(report.generated_at)}</p><button className="btn" disabled={refreshing || operation.status === 'submitting'} onClick={() => void refresh()}>{refreshing ? 'Refreshing…' : 'Refresh inventory'}</button></div>
|
|
153
|
+
{blocked && <section className="panel panel-pad" aria-label="Capacity progress"><h3>{operation.status === 'unknown' ? 'Progress unknown' : 'Capacity change in progress'}</h3><p role="status">{operation.message}</p><p>Plan: {operation.planId}<br />Batch: {operation.batchId ?? 'Not acknowledged'}</p>{operation.batch?.steps.map(s => <div className="fleet-progress-step" key={s.index}><strong>{s.index + 1}. {s.description} · {s.state}</strong><p>{s.phase}{s.message ? ` · ${s.message}` : ''}{s.error_code ? ` · ${s.error_code}` : ''}</p>{s.operation && <small>Operation {s.operation}</small>}</div>)}{operation.status === 'unknown' && !operation.batchId && <label className="field"><span className="field-label">Batch ID from server logs for this plan</span><input className="input" value={batchInput} onChange={e => setBatchInput(e.target.value)}/><span className="field-help">Inspect capacity batch logs and AWS state. Only a matching batch’s terminal evidence resolves this block; refreshing inventory or closing this dialog does not.</span></label>}<button className="btn" disabled={operation.status === 'submitting' || (!operation.batchId && !batchInput.trim())} onClick={() => void fleetOperations.observe(scope, batchInput)}>Refresh observation</button></section>}
|
|
154
|
+
{!blocked && operation.status !== 'idle' && <section className="panel panel-pad" role="status"><h3>{operation.status === 'done' ? 'Batch completed' : 'Batch failed'}</h3><p>{operation.message}</p>{operation.status === 'failed' && <p>Completed steps remain applied. Nothing rolled back; refresh the actual inventory before planning recovery.</p>}{operation.batch?.steps.map(s => <p key={s.index}>{s.description} · {s.state}{s.error_code ? ` · ${s.error_code}` : ''}{s.operation ? ` · operation ${s.operation}` : ''}</p>)}</section>}
|
|
155
|
+
<fieldset disabled={blocked || refreshing || needsRefresh}><Tabs ariaLabel="Capacity type" items={[{ key: 'nodes', label: 'App nodes', panel: <div className="fleet-capacity-tab"><h3>App nodes <OperationsHelp label="app nodes">Node size is inherited from the source. Removal drains before terminating. At least one currently healthy target must remain.</OperationsHelp></h3>
|
|
156
|
+
<div className="fleet-scale-fields"><label className="field"><span className="field-label">Add app nodes</span><input className="input" type="number" min={0} max={20} step={1} value={draft.addNodes} disabled={!offered(report, 'add_node')} onChange={e => edit(d => ({ ...d, addNodes: Number(e.target.value) }))}/>{!offered(report, 'add_node') && <span className="field-help">{actionReason(report, 'add_node')}</span>}</label>
|
|
157
|
+
</div><details className="fleet-scale-disclosure"><summary>Advanced node placement</summary><div className="fleet-scale-fields"><label className="field"><span className="field-label">Clone source (optional)</span><select className="input" value={draft.source} onChange={e => edit(d => ({ ...d, source: e.target.value }))}><option value="">Automatic healthy source</option>{sources.map(n => <option key={n.id} value={n.id}>{n.name} · {n.instance_type} · {n.zone}</option>)}</select></label>
|
|
158
|
+
<label className="field"><span className="field-label">Subnet (optional)</span><input className="input" list="fleet-subnets" value={draft.subnet} placeholder={sources.find(n => n.id === draft.source)?.subnet_id ?? 'subnet-…'} onChange={e => edit(d => ({ ...d, subnet: e.target.value }))}/><datalist id="fleet-subnets">{[...new Set(sources.filter(n => !draft.source || n.zone === sources.find(s => s.id === draft.source)?.zone).map(n => n.subnet_id).filter(Boolean))].map(id => <option key={id} value={id!}/>)}</datalist><span className="field-help">AWS verifies VPC, same availability zone and address suitability. Empty fields are omitted.</span></label></div></details>
|
|
159
|
+
<details className="fleet-scale-disclosure"><summary>Remove nodes</summary>
|
|
160
|
+
{report.nodes.self_check !== 'matched' && <p className="text-warn">This serving node cannot identify itself. Node removal is unavailable until a fresh report identifies it.</p>}{report.nodes.instances.map(n => { const selected = draft.removeNodes.includes(n.id), reason = nodeRemovalReason(report, n, draft.removeNodes.filter(id => id !== n.id)); const recovery = drainedNodeReason(report, n); return <div key={n.id} className="fleet-node"><div><b>{n.name}</b><small>{n.id} · {n.instance_type ?? 'size unknown'} · {n.state} / {n.lifecycle_state}{n.self ? ' · serving this request' : ''}</small><small>{reason}</small></div>{!n.registered ? <label><input type="checkbox" checked={draft.terminateNodes.includes(n.id)} disabled={!!recovery} onChange={() => toggle('terminateNodes', n.id)}/> Terminate drained node<small>{recovery ?? 'Drain completed; server will re-prove ownership and drain before termination.'}</small></label> : <label><input type="checkbox" checked={selected} disabled={!!reason && !selected} onChange={() => toggle('removeNodes', n.id)}/> Remove node</label>}</div>; })}
|
|
161
|
+
</details></div> }, { key: 'database', label: 'Database', panel: <div className="fleet-capacity-tab"><h3>Database capacity</h3>{!report.databases_available && <p>Database inventory unavailable.</p>}{report.databases.map(row => { const d = draft.databases[row.identifier]!; const writer = row.kind === 'aurora' ? row.writer : row.identifier; return <section key={row.identifier} className="panel panel-pad"><h4>{row.identifier}</h4><p>{row.engine} · {row.status}{row.blocked_reason ? ` · ${row.blocked_reason}` : ''}</p><div className="fleet-scale-fields"><label className="field"><span className="field-label">Reader count for {row.identifier}</span><input className="input" type="number" min={0} max={20} step={1} value={d.readers} disabled={!!row.blocked_reason || (!offered(report, 'add_reader') && !offered(report, 'remove_reader'))} onChange={e => setDb(row.identifier, { readers: Number(e.target.value) })}/></label><SizeChoice label={`Writer size for ${row.identifier}`} value={d.writerSize} sizes={report.sizes.database} disabled={!!row.blocked_reason || !offered(report, 'resize_database') || !row.members.find(m => m.id === writer)?.can_resize} onChange={writerSize => setDb(row.identifier, { writerSize })}/><SizeChoice label={`Existing-reader size for ${row.identifier}`} value={d.readerSize} sizes={report.sizes.database} disabled={!!row.blocked_reason || !row.readers.length || !offered(report, 'resize_database') || row.members.some(m => row.readers.includes(m.id) && !m.can_resize)} onChange={readerSize => setDb(row.identifier, { readerSize })}/></div><details className="ops-details"><summary>Size and routing details</summary><p className="dim">Writer {writer ?? 'unknown'} · {row.writer_instance_class ?? row.instance_class ?? 'class unknown'}. Resizing the writer causes minutes offline. Existing-reader sizing changes each surviving reader independently; other readers and the writer keep serving.</p><p className="dim">New readers inherit the current writer/source class ({row.writer_instance_class ?? row.instance_class ?? 'unknown'}), even when the writer resizes in this plan. After creation, refresh their concrete IDs and review a separate resize.</p>{row.members.map(m => <small className="fleet-member" key={m.id}>{m.id} · {m.role} · {m.instance_class} · {m.status ?? 'status unknown'} / {m.lifecycle_state}</small>)}</details></section>; })}
|
|
162
|
+
</div> }, { key: 'redis', label: 'Redis', panel: <div className="fleet-capacity-tab"><h3>Redis capacity</h3>{!report.caches_available && <p>Redis inventory unavailable.</p>}{report.caches.map(row => { const d = draft.caches[row.identifier]!; return <section className="panel panel-pad" key={row.identifier}><h4>{row.identifier}</h4><p>{row.node_type} · {row.status}{row.blocked_reason ? ` · ${row.blocked_reason}` : ''}</p><div className="fleet-scale-fields"><label className="field"><span className="field-label">Replicas for {row.identifier}</span><input className="input" type="number" min={row.min_replicas} max={100} step={1} value={d.replicas} disabled={!!row.blocked_reason || !offered(report, 'set_cache_replicas')} onChange={e => setCache(row.identifier, { replicas: Number(e.target.value) })}/><span className="field-help">Server minimum: {row.min_replicas}. Replicas provide failover capacity; routing is a separate process setting.</span></label><SizeChoice label={`Group size for ${row.identifier}`} value={d.size} sizes={report.sizes.cache} disabled={!!row.blocked_reason || !offered(report, 'resize_cache')} onChange={size => setCache(row.identifier, { size })}/></div><p className="text-warn">{row.resize_impact === 'rolling' ? 'Rolling resize: replicas change first, followed by a brief primary failover interruption.' : 'Downtime resize: Redis is unavailable while its node is replaced.'} Size applies to the entire group. Downsizing risks eviction when the working set no longer fits.</p></section>; })}
|
|
163
|
+
</div> }]} />
|
|
164
|
+
</fieldset>
|
|
165
|
+
{compiled.error && <p className="form-alert" role="alert">{compiled.error}</p>}{error && <p className="form-alert" role="alert">{error}</p>}{needsRefresh && !blocked && <p className="text-warn">Refresh inventory before staging or reviewing another plan.</p>}
|
|
166
|
+
{planning && !blocked && <p role="status">Preparing the server plan…</p>}{!compiled.steps.length && !blocked && !compiled.error && <p className="dim">Choose a change to prepare a server plan.</p>}
|
|
167
|
+
{plan && !blocked && <section className="panel panel-pad" aria-label="Review capacity plan"><h3>Review server plan · {plan.steps.length} steps</h3><p className="text-warn">Creates billable resources. Removals cannot be undone; completed steps remain applied if a later step fails.</p><p>{plan.order_note}</p><ol className="fleet-plan-steps">{plan.steps.map(s => <li key={s.index}><strong>{s.description}</strong><p>{monthlyPrice(s.monthly_delta_usd)}</p>{s.warnings.map((w, i) => <p className="text-warn" key={i}>{w}</p>)}</li>)}</ol><p><b>{plan.estimate_complete ? 'Estimated monthly change' : 'Partial monthly estimate'}: {monthlyPrice(plan.total_monthly_delta_usd)}</b>{!plan.estimate_complete && ' · some prices are unknown'}</p><p className="dim">Plan {plan.id} · expires {datetime(plan.expires_at)}</p>{expired ? <p className="text-warn">This plan expired. Refresh inventory to prepare another review.</p> : <label><input type="checkbox" checked={consent === plan.id} onChange={e => setConsent(e.target.checked ? plan.id : null)}/> I reviewed this exact plan, its cost and interruptions, and understand removals cannot be undone.</label>}</section>}
|
|
168
|
+
</div><div className="modal-actions"><button className="btn" disabled={operation.status === 'submitting'} onClick={close}>{blocked ? 'Close progress' : 'Cancel'}</button><button className="btn btn-primary" disabled={!plan || consent !== plan.id || expired || blocked || refreshing || needsRefresh || planning} onClick={() => void apply()}>Apply reviewed plan</button></div></div>;
|
|
169
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/** Exact global User.has_permission implication rule (django-mojo helpers/perms.py). */
|
|
2
|
+
export interface BackendGlobalUser { is_superuser?: boolean; permissions?: Record<string, unknown> | null }
|
|
3
|
+
const categories = new Set(['users', 'groups', 'security', 'comms', 'jobs', 'metrics', 'files']);
|
|
4
|
+
export const FLEET_DASHBOARD_GRANTS = ['view_admin', 'manage_users', 'manage_settings', 'admin'] as const;
|
|
5
|
+
export const FLEET_CAPACITY_GRANTS = ['manage_aws'] as const;
|
|
6
|
+
export function backendGlobalCan(user: BackendGlobalUser | null | undefined, grants: string | readonly string[]): boolean {
|
|
7
|
+
if (!user) return false;
|
|
8
|
+
if (user.is_superuser === true) return true;
|
|
9
|
+
// Python bool(JSON value), including false for empty containers.
|
|
10
|
+
const granted = (key: string) => {
|
|
11
|
+
if (!user.permissions || !Object.hasOwn(user.permissions, key)) return false;
|
|
12
|
+
const value = user.permissions[key];
|
|
13
|
+
if (Array.isArray(value)) return value.length > 0;
|
|
14
|
+
if (value && typeof value === 'object') return Object.keys(value).length > 0;
|
|
15
|
+
return Boolean(value);
|
|
16
|
+
};
|
|
17
|
+
return (typeof grants === 'string' ? [grants] : grants).some((key) => {
|
|
18
|
+
if (granted(key)) return true;
|
|
19
|
+
const match = /^(?:view_|manage_)(.+)$/.exec(key);
|
|
20
|
+
return !!match && categories.has(match[1]!) && granted(match[1]!);
|
|
21
|
+
});
|
|
22
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { mojoCall, type FetchOpts } from '../../client/runtime';
|
|
2
|
+
import type { FleetDashboard, CapacityReport, CapacityPlan, CapacityBatch, CapacityAction, FleetFacts } from './types';
|
|
3
|
+
export const FLEET_DASHBOARD_ENDPOINT = '/api/account/admin/dashboard';
|
|
4
|
+
export const CAPACITY_ENDPOINT = '/api/aws/capacity';
|
|
5
|
+
const actions = new Set(['add_node', 'drain_node', 'terminate_node', 'add_reader', 'remove_reader', 'set_cache_replicas', 'resize_cache', 'resize_database']);
|
|
6
|
+
export function object(value: unknown): FleetFacts { if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
7
|
+
throw new Error('Fleet response is not an object.'); return value as FleetFacts; }
|
|
8
|
+
const text = (v: unknown): string => { if (typeof v !== 'string' || !v.trim())
|
|
9
|
+
throw new Error('Fleet response is missing required text.'); return v; };
|
|
10
|
+
const nullable = (v: unknown): string | null => typeof v === 'string' ? v : null;
|
|
11
|
+
const bool = (v: unknown) => v === true;
|
|
12
|
+
const number = (v: unknown): number => { if (typeof v !== 'number' || !Number.isFinite(v) || v < 0)
|
|
13
|
+
throw new Error('Fleet response has an invalid number.'); return v; };
|
|
14
|
+
const price = (v: unknown): number | null => v === null ? null : typeof v === 'number' && Number.isFinite(v) ? v : (() => { throw new Error('Fleet response has an invalid price.'); })();
|
|
15
|
+
const array = (v: unknown): unknown[] => { if (!Array.isArray(v))
|
|
16
|
+
throw new Error('Fleet response is missing a list.'); return v; };
|
|
17
|
+
const strings = (v: unknown) => array(v).map(text);
|
|
18
|
+
function schema(v: unknown): FleetFacts { const o = object(v); if (o.schema_version !== 2)
|
|
19
|
+
throw new Error('This fleet API requires schema version 2. Upgrade the backend and refresh.'); return o; }
|
|
20
|
+
function action(v: unknown): CapacityAction { const a = text(v); if (!actions.has(a))
|
|
21
|
+
throw new Error('Fleet response contains an unsupported action.'); return a as CapacityAction; }
|
|
22
|
+
const dashboardFields: Record<string, readonly string[]> = { load_balancer: ['configured', 'registered', 'healthy', 'balancer', 'elastic_ip_missing'], compute: ['total', 'up', 'source', 'instances'], database: ['reachable', 'vendor', 'engine', 'version', 'drift'], cache: ['reachable', 'engine', 'version', 'memory_used'], certificates: ['total', 'active', 'failing', 'expiring_within_30_days', 'soonest_renew', 'soonest_expiry'], public_api: ['configured', 'node_version', 'probe'], framework: ['installed', 'latest', 'update_available', 'pin'], last_deployment: ['items'], jobs: ['scheduler_active', 'jobs', 'failed_recent'], sanity: ['checks'], incidents: ['open', 'oldest_created', 'oldest_age_days'], tickets: ['open', 'oldest_created', 'oldest_age_days'], sms: ['configured', 'provider', 'test_mode', 'verified'], email: ['configured', 'domains', 'sendable_domains', 'default_sender', 'problems'] };
|
|
23
|
+
const nestedFields = new Set(['name', 'type', 'scheme', 'state', 'elastic_ips', 'addresses', 'ip', 'address', 'zone', 'id', 'healthy', 'available_major', 'deadline', 'note', 'ok', 'status', 'http_status', 'version', 'mode', 'sha', 'created', 'finished', 'actor', 'pending', 'running', 'failed', 'checked_at', 'message']);
|
|
24
|
+
function projectFacts(raw: FleetFacts, fields: readonly string[]): FleetFacts {
|
|
25
|
+
const safe = (v: unknown, depth: number): unknown => { if (v == null || typeof v === 'string' || typeof v === 'boolean' || typeof v === 'number' && Number.isFinite(v))
|
|
26
|
+
return v; if (depth > 4)
|
|
27
|
+
return null; if (Array.isArray(v))
|
|
28
|
+
return v.slice(0, 200).map(x => safe(x, depth + 1)); if (v && typeof v === 'object')
|
|
29
|
+
return Object.fromEntries(Object.entries(v).filter(([k]) => nestedFields.has(k)).map(([k, x]) => [k, safe(x, depth + 1)])); return null; };
|
|
30
|
+
return Object.fromEntries(Object.entries(raw).filter(([key]) => fields.includes(key)).map(([key, value]) => [key, safe(value, 0)]));
|
|
31
|
+
}
|
|
32
|
+
export function parseFleetDashboard(v: unknown): FleetDashboard {
|
|
33
|
+
const o = schema(v), availability = object(o.availability), sources = { ...object(o.sources) };
|
|
34
|
+
for (const name of Object.keys(dashboardFields))
|
|
35
|
+
if (!Object.hasOwn(sources, name))
|
|
36
|
+
sources[name] = { status: 'unknown', observed_at: null, stale_after: null, reason: 'source_missing', data: {} };
|
|
37
|
+
if (!['ok', 'down', 'unknown'].includes(String(availability.state)))
|
|
38
|
+
throw new Error('Fleet availability is unknown to this client.');
|
|
39
|
+
return { schema_version: 2, observed_at: text(o.observed_at), availability: { state: availability.state as 'ok' | 'down' | 'unknown', message: text(availability.message), down: strings(availability.down) }, attention: { message: nullable(object(o.attention).message) }, sources: Object.fromEntries(Object.entries(sources).map(([key, value]) => { const row = object(value); return [key, { status: text(row.status), observed_at: nullable(row.observed_at), stale_after: nullable(row.stale_after), reason: nullable(row.reason), data: projectFacts(object(row.data), dashboardFields[key] ?? []) }]; })) };
|
|
40
|
+
}
|
|
41
|
+
export function parseCapacity(v: unknown): CapacityReport {
|
|
42
|
+
const o = schema(v), n = object(o.nodes), sizes = object(o.sizes), e = object(o.egress), routing = object(o.reader_routing), dbRouting = object(routing.database), cacheRouting = object(routing.redis);
|
|
43
|
+
const ladder = (v: unknown) => array(v).map(value => { const r = object(value); return { size: text(r.size), label: text(r.label), type: text(r.type), monthly_usd: price(r.monthly_usd) }; });
|
|
44
|
+
const addresses = (v: unknown) => array(v).map(value => { const r = object(value); return { instance: text(r.instance), public_ip: text(r.public_ip), managed: bool(r.managed) }; });
|
|
45
|
+
return { schema_version: 2, mode: text(o.mode), region: text(o.region), generated_at: text(o.generated_at), identity_available: bool(o.identity_available), identity: o.identity == null ? null : { project: text(object(o.identity).project), environment: text(object(o.identity).environment) }, node_id_pinned: bool(o.node_id_pinned),
|
|
46
|
+
nodes: { self: nullable(n.self), self_check: text(n.self_check), serving_available: bool(n.serving_available), inventory_available: bool(n.inventory_available), instances: array(n.instances).map(value => { const r = object(value); return { id: text(r.id), name: text(r.name), state: text(r.state), instance_state: nullable(r.instance_state), lifecycle_state: text(r.lifecycle_state), instance_type: nullable(r.instance_type), zone: nullable(r.zone), subnet_id: nullable(r.subnet_id), public_ip: nullable(r.public_ip), healthy: bool(r.healthy), registered: bool(r.registered), can_drain: bool(r.can_drain), self: bool(r.self), primary: bool(r.primary), added_by_capacity: bool(r.added_by_capacity), stable_ip: bool(r.stable_ip), groups: strings(r.groups) }; }) },
|
|
47
|
+
databases_available: bool(o.databases_available), caches_available: bool(o.caches_available),
|
|
48
|
+
databases: array(o.databases).map(value => { const r = object(value); if (!['aurora', 'standalone'].includes(String(r.kind)))
|
|
49
|
+
throw new Error('Unsupported database kind.'); return { identifier: text(r.identifier), kind: r.kind as 'aurora' | 'standalone', engine: text(r.engine), status: text(r.status), writer: nullable(r.writer), readers: strings(r.readers), instance_class: nullable(r.instance_class), writer_instance_class: nullable(r.writer_instance_class), reader_instance_classes: Object.fromEntries(Object.entries(object(r.reader_instance_classes)).map(([k, v]) => [k, v === null ? null : text(v)])), blocked_reason: nullable(r.blocked_reason), members: array(r.members).map(value => { const m = object(value); return { id: text(m.id), role: text(m.role), status: m.status === null ? null : text(m.status), lifecycle_state: text(m.lifecycle_state), instance_class: nullable(m.instance_class), can_resize: bool(m.can_resize), can_remove: bool(m.can_remove) }; }) }; }),
|
|
50
|
+
caches: array(o.caches).map(value => { const r = object(value); if (!['rolling', 'downtime'].includes(String(r.resize_impact)))
|
|
51
|
+
throw new Error('Cache resize impact is unavailable.'); return { identifier: text(r.identifier), status: text(r.status), node_type: text(r.node_type), replica_count: number(r.replica_count), min_replicas: number(r.min_replicas), resize_impact: r.resize_impact as 'rolling' | 'downtime', blocked_reason: nullable(r.blocked_reason), members: array(r.members).map(value => { const m = object(value); return { id: text(m.id), role: text(m.role), status: text(m.status), lifecycle_state: text(m.lifecycle_state) }; }) }; }),
|
|
52
|
+
sizes: { database: ladder(sizes.database), cache: ladder(sizes.cache) }, actions: Object.fromEntries(Object.entries(object(o.actions)).map(([key, value]) => { const r = object(value); return [key, { offered: bool(r.offered), blocked_reason: nullable(r.blocked_reason) }]; })), warnings: array(o.warnings).map(value => { const r = object(value); return { code: text(r.code), message: text(r.message), iam_action: nullable(r.iam_action) }; }),
|
|
53
|
+
reader_routing: { database: { active: bool(dbRouting.active), host: nullable(dbRouting.host), skip_reason: nullable(dbRouting.skip_reason), matches_reader_endpoint: typeof dbRouting.matches_reader_endpoint === 'boolean' ? dbRouting.matches_reader_endpoint : null }, redis: { active: bool(cacheRouting.active) } },
|
|
54
|
+
egress: { available: bool(e.available), fleet_available: bool(e.fleet_available), addresses_available: bool(e.addresses_available), policy_available: bool(e.policy_available), enabled: bool(e.enabled), addresses: strings(e.addresses), attached: addresses(e.attached), fallback_attached: addresses(e.fallback_attached), reserved: array(e.reserved).map(value => ({ public_ip: text(object(value).public_ip) })), pending_nodes: strings(e.pending_nodes) } };
|
|
55
|
+
}
|
|
56
|
+
export function parseCapacityPlan(v: unknown): CapacityPlan {
|
|
57
|
+
const o = schema(v);
|
|
58
|
+
const expires_at = text(o.expires_at);
|
|
59
|
+
if (!Number.isFinite(Date.parse(expires_at)))
|
|
60
|
+
throw new Error('Plan expiry is unavailable.');
|
|
61
|
+
const steps = array(o.steps).map(value => { const r = object(value); return { index: number(r.index), action: action(r.action), resource: typeof r.resource === 'string' ? r.resource : '', description: text(r.description), kind: text(r.kind), params: object(r.params), warnings: strings(r.warnings), monthly_delta_usd: price(r.monthly_delta_usd) }; });
|
|
62
|
+
if (!steps.length || steps.length > 20)
|
|
63
|
+
throw new Error('Plan exceeds the supported 20 steps.');
|
|
64
|
+
return { schema_version: 2, id: text(o.id), actor: number(o.actor), created: typeof o.created === 'number' ? o.created : text(o.created), expires_at, expires_in: number(o.expires_in), steps, total_monthly_delta_usd: price(o.total_monthly_delta_usd), estimate_complete: bool(o.estimate_complete), order_note: text(o.order_note) };
|
|
65
|
+
}
|
|
66
|
+
export function parseCapacityBatch(v: unknown): CapacityBatch {
|
|
67
|
+
const o = schema(v);
|
|
68
|
+
const steps = array(o.steps).map(value => { const r = object(value); return { index: number(r.index), action: action(r.action), resource: typeof r.resource === 'string' ? r.resource : '', description: text(r.description), state: text(r.state), operation: nullable(r.operation), phase: nullable(r.phase), message: nullable(r.message), error_code: nullable(r.error_code) }; });
|
|
69
|
+
if (!steps.length || steps.length > 20)
|
|
70
|
+
throw new Error('Batch step count is invalid.');
|
|
71
|
+
return { schema_version: 2, id: text(o.id), plan_id: text(o.plan_id), actor: number(o.actor), state: text(o.state), started: typeof o.started === 'number' ? o.started : text(o.started), current_index: number(o.current_index), message: nullable(o.message), error_code: nullable(o.error_code), stalled: bool(o.stalled), steps };
|
|
72
|
+
}
|
|
73
|
+
export async function readFleetDashboard(refresh = false, opts: FetchOpts = {}) { return parseFleetDashboard((await mojoCall(FLEET_DASHBOARD_ENDPOINT, { ...opts, params: { refresh: refresh ? 1 : undefined } })).data); }
|
|
74
|
+
export async function readCapacity(refresh = false, opts: FetchOpts = {}) { return parseCapacity((await mojoCall(CAPACITY_ENDPOINT, { ...opts, params: { refresh: refresh ? 1 : undefined } })).data); }
|
|
75
|
+
export async function requestCapacityPlan(steps: unknown[], opts: FetchOpts = {}) { return parseCapacityPlan((await mojoCall(`${CAPACITY_ENDPOINT}/plan`, { ...opts, method: 'POST', body: { steps } })).data); }
|
|
76
|
+
export async function applyCapacityPlan(planId: string, opts: FetchOpts = {}) { return parseCapacityBatch((await mojoCall(`${CAPACITY_ENDPOINT}/plan/apply`, { ...opts, method: 'POST', body: { plan_id: planId } })).data); }
|
|
77
|
+
export async function readCapacityBatch(id: string, opts: FetchOpts = {}) { return parseCapacityBatch((await mojoCall(`${CAPACITY_ENDPOINT}/status`, { ...opts, params: { batch: id } })).data); }
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import type { CapacityAction, CapacityDatabase, CapacityNode, CapacityReport, CapacityStep } from './types';
|
|
2
|
+
export interface FleetDraft {
|
|
3
|
+
addNodes: number;
|
|
4
|
+
source: string;
|
|
5
|
+
subnet: string;
|
|
6
|
+
removeNodes: string[];
|
|
7
|
+
terminateNodes: string[];
|
|
8
|
+
databases: Record<string, {
|
|
9
|
+
readers: number;
|
|
10
|
+
writerSize: string;
|
|
11
|
+
readerSize: string;
|
|
12
|
+
}>;
|
|
13
|
+
caches: Record<string, {
|
|
14
|
+
replicas: number;
|
|
15
|
+
size: string;
|
|
16
|
+
}>;
|
|
17
|
+
}
|
|
18
|
+
export function newFleetDraft(report: CapacityReport): FleetDraft { return { addNodes: 0, source: '', subnet: '', removeNodes: [], terminateNodes: [], databases: Object.fromEntries(report.databases.map(r => [r.identifier, { readers: r.readers.length, writerSize: '', readerSize: '' }])), caches: Object.fromEntries(report.caches.map(r => [r.identifier, { replicas: r.replica_count, size: '' }])) }; }
|
|
19
|
+
export const offered = (r: CapacityReport, action: CapacityAction) => r.actions[action]?.offered === true;
|
|
20
|
+
export const actionReason = (r: CapacityReport, action: CapacityAction) => r.actions[action]?.blocked_reason ?? 'Unavailable';
|
|
21
|
+
export function nodeRemovalReason(report: CapacityReport, node: CapacityNode, selected: readonly string[] = []): string | null {
|
|
22
|
+
if (report.mode !== 'managed')
|
|
23
|
+
return 'Infrastructure is managed externally.';
|
|
24
|
+
if (!report.nodes.serving_available || !report.nodes.inventory_available)
|
|
25
|
+
return 'Node evidence is unavailable.';
|
|
26
|
+
if (report.nodes.self_check !== 'matched')
|
|
27
|
+
return 'The serving node could not identify itself. Refresh before removal.';
|
|
28
|
+
if (node.self || node.id === report.nodes.self)
|
|
29
|
+
return 'This node is answering your request.';
|
|
30
|
+
if (!offered(report, 'drain_node') || !offered(report, 'terminate_node'))
|
|
31
|
+
return actionReason(report, !offered(report, 'drain_node') ? 'drain_node' : 'terminate_node');
|
|
32
|
+
if (!node.can_drain)
|
|
33
|
+
return 'This node cannot currently be drained.';
|
|
34
|
+
const healthy = report.nodes.instances.filter(r => r.healthy);
|
|
35
|
+
if (node.healthy && healthy.filter(r => !selected.includes(r.id) && r.id !== node.id).length < 1)
|
|
36
|
+
return 'Keep at least one currently healthy target. New additions do not count yet.';
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
export function drainedNodeReason(report: CapacityReport, node: CapacityNode): string | null {
|
|
40
|
+
if (report.mode !== 'managed' || !offered(report, 'terminate_node'))
|
|
41
|
+
return actionReason(report, 'terminate_node');
|
|
42
|
+
if (!report.identity_available || !report.identity || !report.nodes.serving_available || !report.nodes.inventory_available)
|
|
43
|
+
return 'Fresh owned-node inventory is required.';
|
|
44
|
+
if (report.nodes.self_check !== 'matched' || node.self || node.id === report.nodes.self)
|
|
45
|
+
return 'The serving node must be identified and retained.';
|
|
46
|
+
if (node.registered || node.healthy || node.state !== 'unregistered' || node.lifecycle_state !== 'available' || node.instance_state !== 'running')
|
|
47
|
+
return 'Drain completion is not yet proved. Refresh and inspect.';
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
function integer(value: number, label: string, min = 0) { if (!Number.isSafeInteger(value) || value < min)
|
|
51
|
+
throw new Error(`${label} must be a whole number of at least ${min}.`); }
|
|
52
|
+
export function databaseRemovals(row: CapacityDatabase, target: number): string[] {
|
|
53
|
+
integer(target, 'Reader count');
|
|
54
|
+
const needed = Math.max(0, row.readers.length - target);
|
|
55
|
+
const removable = row.members.filter(m => row.readers.includes(m.id) && m.can_remove).map(m => m.id).reverse();
|
|
56
|
+
if (removable.length < needed)
|
|
57
|
+
throw new Error(`${row.identifier}: a reader is still transitioning and cannot be removed.`);
|
|
58
|
+
return removable.slice(0, needed);
|
|
59
|
+
}
|
|
60
|
+
export function compileFleetSteps(report: CapacityReport, draft: FleetDraft): CapacityStep[] {
|
|
61
|
+
if (report.mode !== 'managed')
|
|
62
|
+
throw new Error('External infrastructure is read-only.');
|
|
63
|
+
const steps: CapacityStep[] = [];
|
|
64
|
+
const add = (step: CapacityStep) => { if (!offered(report, step.action))
|
|
65
|
+
throw new Error(`${step.action}: ${actionReason(report, step.action)}`); steps.push(step); };
|
|
66
|
+
integer(draft.addNodes, 'App additions');
|
|
67
|
+
if (draft.addNodes > 20)
|
|
68
|
+
throw new Error('A plan holds at most 20 steps.');
|
|
69
|
+
const source = draft.source.trim(), subnet = draft.subnet.trim();
|
|
70
|
+
if (draft.addNodes && subnet && !/^subnet-[A-Za-z0-9]+$/.test(subnet))
|
|
71
|
+
throw new Error('Subnet must be a subnet-… identifier.');
|
|
72
|
+
if (draft.addNodes && source && !report.nodes.instances.some(n => n.id === source && n.healthy && n.registered && n.instance_state === 'running'))
|
|
73
|
+
throw new Error('Choose a healthy serving source node.');
|
|
74
|
+
for (let i = 0; i < draft.addNodes; i++)
|
|
75
|
+
add({ action: 'add_node', ...(source ? { source_instance: source } : {}), ...(subnet ? { subnet_id: subnet } : {}) });
|
|
76
|
+
for (const id of draft.removeNodes) {
|
|
77
|
+
const node = report.nodes.instances.find(n => n.id === id);
|
|
78
|
+
if (!node)
|
|
79
|
+
throw new Error('Node inventory changed. Refresh.');
|
|
80
|
+
const reason = nodeRemovalReason(report, node, draft.removeNodes.filter(n => n !== id));
|
|
81
|
+
if (reason)
|
|
82
|
+
throw new Error(reason);
|
|
83
|
+
add({ action: 'drain_node', resource: id });
|
|
84
|
+
add({ action: 'terminate_node', resource: id });
|
|
85
|
+
}
|
|
86
|
+
for (const id of draft.terminateNodes) {
|
|
87
|
+
const node = report.nodes.instances.find(n => n.id === id);
|
|
88
|
+
if (!node)
|
|
89
|
+
throw new Error('Drained node is no longer in the inventory.');
|
|
90
|
+
const reason = drainedNodeReason(report, node);
|
|
91
|
+
if (reason)
|
|
92
|
+
throw new Error(reason);
|
|
93
|
+
add({ action: 'terminate_node', resource: id });
|
|
94
|
+
}
|
|
95
|
+
const resize = (resource: string, size: string, current: string | null, ladder: CapacityReport['sizes']['database'], action: 'resize_database' | 'resize_cache') => { if (!size)
|
|
96
|
+
return; const target = ladder.find(s => s.size === size); if (!target)
|
|
97
|
+
throw new Error('Choose a server-provided curated size.'); if (target.type !== current)
|
|
98
|
+
add({ action, resource, size, apply_immediately: true }); };
|
|
99
|
+
for (const row of report.databases) {
|
|
100
|
+
const d = draft.databases[row.identifier];
|
|
101
|
+
if (!d)
|
|
102
|
+
continue;
|
|
103
|
+
integer(d.readers, 'Reader count');
|
|
104
|
+
if (d.readers > 20)
|
|
105
|
+
throw new Error('A plan holds at most 20 steps.');
|
|
106
|
+
const changing = d.readers !== row.readers.length || !!d.writerSize || !!d.readerSize;
|
|
107
|
+
if (changing && (!report.databases_available || row.blocked_reason))
|
|
108
|
+
throw new Error(`${row.identifier}: ${row.blocked_reason ?? 'Database inventory unavailable'}`);
|
|
109
|
+
const removes = databaseRemovals(row, d.readers);
|
|
110
|
+
for (const id of removes)
|
|
111
|
+
add({ action: 'remove_reader', resource: id });
|
|
112
|
+
for (let i = row.readers.length; i < d.readers; i++)
|
|
113
|
+
add({ action: 'add_reader', resource: row.identifier });
|
|
114
|
+
const writer = row.kind === 'aurora' ? row.writer : row.identifier;
|
|
115
|
+
if (d.writerSize) {
|
|
116
|
+
if (!writer || !row.members.find(m => m.id === writer)?.can_resize)
|
|
117
|
+
throw new Error('Writer resizing is unavailable.');
|
|
118
|
+
resize(writer, d.writerSize, row.writer_instance_class ?? row.instance_class, report.sizes.database, 'resize_database');
|
|
119
|
+
}
|
|
120
|
+
if (d.readerSize) {
|
|
121
|
+
if (!row.readers.length)
|
|
122
|
+
throw new Error('Existing-reader sizing requires existing readers.');
|
|
123
|
+
for (const id of row.readers.filter(id => !removes.includes(id))) {
|
|
124
|
+
const member = row.members.find(m => m.id === id);
|
|
125
|
+
if (!member?.can_resize)
|
|
126
|
+
throw new Error(`${id} cannot currently resize.`);
|
|
127
|
+
resize(id, d.readerSize, row.reader_instance_classes[id] ?? null, report.sizes.database, 'resize_database');
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
for (const row of report.caches) {
|
|
132
|
+
const d = draft.caches[row.identifier];
|
|
133
|
+
if (!d)
|
|
134
|
+
continue;
|
|
135
|
+
integer(d.replicas, 'Redis replica count', row.min_replicas);
|
|
136
|
+
if (d.replicas > 100)
|
|
137
|
+
throw new Error('Redis replica count exceeds the supported staging range.');
|
|
138
|
+
if ((d.replicas !== row.replica_count || d.size) && (!report.caches_available || row.blocked_reason))
|
|
139
|
+
throw new Error(`${row.identifier}: ${row.blocked_reason ?? 'Cache inventory unavailable'}`);
|
|
140
|
+
if (d.replicas !== row.replica_count)
|
|
141
|
+
add({ action: 'set_cache_replicas', resource: row.identifier, count: d.replicas, apply_immediately: true });
|
|
142
|
+
resize(row.identifier, d.size, row.node_type, report.sizes.cache, 'resize_cache');
|
|
143
|
+
}
|
|
144
|
+
if (steps.length > 20)
|
|
145
|
+
throw new Error(`A plan holds at most 20 steps; this proposal has ${steps.length}. Reduce the changes.`);
|
|
146
|
+
return steps;
|
|
147
|
+
}
|
|
148
|
+
export const monthlyPrice = (value: number | null) => value == null ? 'Price unknown' : `${value < 0 ? '−' : value > 0 ? '+' : ''}$${Math.abs(value).toFixed(2)}/month`;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export * from './access';
|
|
2
|
+
export * from './types';
|
|
3
|
+
export * from './api';
|
|
4
|
+
export * from './data';
|
|
5
|
+
export * from './operations';
|
|
6
|
+
export * from './FleetOverview';
|
|
7
|
+
export * from './FleetPage';
|
|
8
|
+
// The scale dialog is dynamically imported by FleetOverview to keep dashboard loading small.
|