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
package/src/admin/edge/api.ts
CHANGED
|
@@ -8,25 +8,39 @@ import {
|
|
|
8
8
|
// Every call goes through the one client boundary; a refused write rejects
|
|
9
9
|
// with the server's own message.
|
|
10
10
|
|
|
11
|
-
export async function saveVhost(id: number | null, changes: Record<string, unknown
|
|
12
|
-
return sanitizeEdgeVhostRow(await withFreshAuth(() => mojoSave<EdgeVhostRow>(EdgeVhostModel.endpoint, id, changes)));
|
|
11
|
+
export async function saveVhost(id: number | null, changes: Record<string, unknown>, beforeWrite?: () => void): Promise<EdgeVhostRow> {
|
|
12
|
+
return sanitizeEdgeVhostRow(await withFreshAuth(() => { beforeWrite?.(); return mojoSave<EdgeVhostRow>(EdgeVhostModel.endpoint, id, changes); }));
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
export async function deleteVhost(id: number): Promise<void> {
|
|
16
|
-
await withFreshAuth(() => mojoDelete(EdgeVhostModel.endpoint, id));
|
|
15
|
+
export async function deleteVhost(id: number, beforeWrite?: () => void): Promise<void> {
|
|
16
|
+
await withFreshAuth(() => { beforeWrite?.(); return mojoDelete(EdgeVhostModel.endpoint, id); });
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
export async function listVhostRoutes(vhost: number): Promise<EdgeRouteRow[]> {
|
|
20
|
-
const
|
|
21
|
-
|
|
20
|
+
const rows: EdgeRouteRow[] = [];
|
|
21
|
+
const seen = new Set<number>();
|
|
22
|
+
let total: number | null = null;
|
|
23
|
+
while (rows.length < 2000) {
|
|
24
|
+
const page = await mojoList<EdgeRouteRow>(EdgeRouteModel.endpoint, normalizeRouteListParams({ vhost, sort: 'path_prefix', start: rows.length, size: 200 }));
|
|
25
|
+
if (!Number.isSafeInteger(page.count) || page.count < rows.length + page.rows.length || total != null && total !== page.count) throw new Error('The route inventory changed or was incomplete. Refresh before changing routes.');
|
|
26
|
+
total = page.count;
|
|
27
|
+
for (const raw of page.rows) {
|
|
28
|
+
const row = sanitizeEdgeRouteRow(raw);
|
|
29
|
+
if (!Number.isSafeInteger(row.id) || row.id <= 0 || seen.has(row.id)) throw new Error('The route list changed or was incomplete. Refresh before changing routes.');
|
|
30
|
+
seen.add(row.id); rows.push(row);
|
|
31
|
+
}
|
|
32
|
+
if (rows.length === total) return rows;
|
|
33
|
+
if (!page.rows.length) throw new Error('The route inventory was incomplete. No route was changed.');
|
|
34
|
+
}
|
|
35
|
+
throw new Error('The route list exceeds the safe inspection limit. No route was changed.');
|
|
22
36
|
}
|
|
23
37
|
|
|
24
|
-
export async function saveVhostRoute(id: number | null, changes: { vhost?: number; path_prefix?: string; upstream?: number }): Promise<EdgeRouteRow> {
|
|
25
|
-
return sanitizeEdgeRouteRow(await withFreshAuth(() => mojoSave<EdgeRouteRow>(EdgeRouteModel.endpoint, id, changes)));
|
|
38
|
+
export async function saveVhostRoute(id: number | null, changes: { vhost?: number; path_prefix?: string; upstream?: number }, beforeWrite?: () => void): Promise<EdgeRouteRow> {
|
|
39
|
+
return sanitizeEdgeRouteRow(await withFreshAuth(() => { beforeWrite?.(); return mojoSave<EdgeRouteRow>(EdgeRouteModel.endpoint, id, changes); }));
|
|
26
40
|
}
|
|
27
41
|
|
|
28
|
-
export async function deleteVhostRoute(id: number): Promise<void> {
|
|
29
|
-
await withFreshAuth(() => mojoDelete(EdgeRouteModel.endpoint, id));
|
|
42
|
+
export async function deleteVhostRoute(id: number, beforeWrite?: () => void): Promise<void> {
|
|
43
|
+
await withFreshAuth(() => { beforeWrite?.(); return mojoDelete(EdgeRouteModel.endpoint, id); });
|
|
30
44
|
}
|
|
31
45
|
|
|
32
46
|
/** The only field a `manage_dns` holder can write on an upstream. */
|
package/src/admin/edge/models.ts
CHANGED
|
@@ -16,9 +16,9 @@ export const VHOST_KINDS: readonly VhostKind[] = ['api', 'site', 'site_api', 're
|
|
|
16
16
|
export interface VhostKindCard { value: VhostKind; title: string; description: string; icon: string }
|
|
17
17
|
/** The wizard's first step: a product shape in plain words, never a raw enum. */
|
|
18
18
|
export const VHOST_KIND_CARDS: readonly VhostKindCard[] = [
|
|
19
|
-
{ value: 'api', title: 'API
|
|
20
|
-
{ value: 'site', title: '
|
|
21
|
-
{ value: 'site_api', title: 'Site + API
|
|
19
|
+
{ value: 'api', title: 'API', description: 'Every request on this name goes to one declared upstream.', icon: 'bi-hdd-rack' },
|
|
20
|
+
{ value: 'site', title: 'Site', description: 'Serves a published site. Nothing is proxied.', icon: 'bi-window' },
|
|
21
|
+
{ value: 'site_api', title: 'Site + API', description: 'Serves a site, and sends chosen path prefixes to upstreams.', icon: 'bi-diagram-3' },
|
|
22
22
|
{ value: 'redirect', title: 'Redirect', description: 'Sends every request to another host, permanently.', icon: 'bi-arrow-return-right' },
|
|
23
23
|
];
|
|
24
24
|
|
|
@@ -198,7 +198,7 @@ export function normalizeRouteListParams(params: Params): Params {
|
|
|
198
198
|
}
|
|
199
199
|
|
|
200
200
|
export function normalizeUpstreamListParams(params: Params): Params {
|
|
201
|
-
return normalizeListParams(params, new Set(['is_enabled', 'kind']), new Set(['name', 'kind', 'created', 'is_enabled']), 'default');
|
|
201
|
+
return normalizeListParams(params, new Set(['is_enabled', 'kind', 'group', 'group__isnull']), new Set(['name', 'kind', 'created', 'is_enabled']), 'default');
|
|
202
202
|
}
|
|
203
203
|
|
|
204
204
|
export function normalizeBlocklistListParams(params: Params): Params {
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { mojoCall } from '../../client/runtime';
|
|
2
|
+
|
|
3
|
+
export type ServingStatus = 'pass' | 'warn' | 'fail' | 'pending';
|
|
4
|
+
export interface ServingBootstrap {
|
|
5
|
+
user: { id: number; is_superuser: boolean };
|
|
6
|
+
capabilities: { setup: boolean };
|
|
7
|
+
edge: { available: boolean; http_enabled: boolean | null; dnsman_issuance: 'dns-01' | null };
|
|
8
|
+
}
|
|
9
|
+
export interface ServingCheck {
|
|
10
|
+
code: string; status: ServingStatus; explanation: string; remediation: string;
|
|
11
|
+
details?: Record<string, string | number | boolean | null>;
|
|
12
|
+
}
|
|
13
|
+
export interface ServingCoverage { total: number; returned: number; omitted: number }
|
|
14
|
+
export interface HostingReadiness {
|
|
15
|
+
schema_version: 1; generated_at: string; overall: ServingStatus;
|
|
16
|
+
summary: Record<ServingStatus, number>;
|
|
17
|
+
sections: Array<{ code: 'hosting_vhosts'; label: string; status: ServingStatus; checks: ServingCheck[]; coverage: ServingCoverage }>;
|
|
18
|
+
coverage: { sections: ServingCoverage; checks: ServingCoverage }; truncated?: boolean;
|
|
19
|
+
}
|
|
20
|
+
const invalid = (): never => { throw new Error('Serving evidence unavailable: incompatible or missing response fields.'); };
|
|
21
|
+
function object(value: unknown): Record<string, unknown> {
|
|
22
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : invalid();
|
|
23
|
+
}
|
|
24
|
+
function bool(value: unknown): boolean { return typeof value === 'boolean' ? value : invalid(); }
|
|
25
|
+
function text(value: unknown, limit = 4096): string { return typeof value === 'string' && value.length <= limit ? value : invalid(); }
|
|
26
|
+
function count(value: unknown): number { return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : invalid(); }
|
|
27
|
+
function status(value: unknown): ServingStatus { return typeof value === 'string' && ['pass', 'warn', 'fail', 'pending'].includes(value) ? value as ServingStatus : invalid(); }
|
|
28
|
+
function coverage(value: unknown): ServingCoverage {
|
|
29
|
+
const row = object(value), total = count(row.total), returned = count(row.returned), omitted = count(row.omitted);
|
|
30
|
+
if (returned > total || omitted !== total - returned) invalid();
|
|
31
|
+
return { total, returned, omitted };
|
|
32
|
+
}
|
|
33
|
+
/** Positive projection only. Bootstrap carries unrelated private fields. */
|
|
34
|
+
export function parseServingBootstrap(value: unknown, userId: number): ServingBootstrap {
|
|
35
|
+
const row = object(value), user = object(row.user), capabilities = object(row.capabilities), edge = object(row.edge);
|
|
36
|
+
if (!Number.isSafeInteger(user.id) || Number(user.id) <= 0 || user.id !== userId) invalid();
|
|
37
|
+
if (!Object.hasOwn(edge, 'dnsman_issuance') || !(edge.dnsman_issuance === null || typeof edge.dnsman_issuance === 'string')) invalid();
|
|
38
|
+
const available = bool(edge.available);
|
|
39
|
+
const http = edge.http_enabled === null && !available ? null : bool(edge.http_enabled);
|
|
40
|
+
return { user: { id: userId, is_superuser: bool(user.is_superuser) }, capabilities: { setup: bool(capabilities.setup) }, edge: {
|
|
41
|
+
available, http_enabled: http, dnsman_issuance: edge.dnsman_issuance === 'dns-01' ? 'dns-01' : null,
|
|
42
|
+
} };
|
|
43
|
+
}
|
|
44
|
+
export function parseHostingReadiness(value: unknown): HostingReadiness {
|
|
45
|
+
const row = object(value);
|
|
46
|
+
if (row.schema_version !== 1) invalid();
|
|
47
|
+
const generated_at = text(row.generated_at, 64);
|
|
48
|
+
if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/.test(generated_at) || !Number.isFinite(Date.parse(generated_at))) invalid();
|
|
49
|
+
const sections: unknown[] = Array.isArray(row.sections) && row.sections.length <= 64 ? row.sections : invalid();
|
|
50
|
+
const selected = sections.map(object).filter(section => section.code === 'hosting_vhosts');
|
|
51
|
+
if (selected.length !== 1) invalid();
|
|
52
|
+
const section = selected[0]!;
|
|
53
|
+
const wireChecks: unknown[] = Array.isArray(section.checks) && section.checks.length <= 64 ? section.checks : invalid();
|
|
54
|
+
const checks = wireChecks.map(value => {
|
|
55
|
+
const check = object(value);
|
|
56
|
+
const out: ServingCheck = { code: text(check.code, 256), status: status(check.status), explanation: text(check.explanation), remediation: text(check.remediation) };
|
|
57
|
+
if (check.details != null) {
|
|
58
|
+
const entries = Object.entries(object(check.details));
|
|
59
|
+
out.details = {};
|
|
60
|
+
for (const [key, value] of entries.slice(0, 16)) {
|
|
61
|
+
if (key.length <= 128 && (value === null || typeof value === 'boolean' || (typeof value === 'number' && Number.isFinite(value)) || (typeof value === 'string' && value.length <= 1024))) out.details[key] = value;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return out;
|
|
65
|
+
});
|
|
66
|
+
const summary = object(row.summary), wireCoverage = object(row.coverage);
|
|
67
|
+
const result: HostingReadiness = { schema_version: 1, generated_at, overall: status(row.overall), summary: {
|
|
68
|
+
pass: count(summary.pass), warn: count(summary.warn), fail: count(summary.fail), pending: count(summary.pending),
|
|
69
|
+
}, sections: [{ code: 'hosting_vhosts', label: text(section.label, 256), status: status(section.status), checks, coverage: coverage(section.coverage) }], coverage: { sections: coverage(wireCoverage.sections), checks: coverage(wireCoverage.checks) } };
|
|
70
|
+
if (row.truncated !== undefined) result.truncated = bool(row.truncated);
|
|
71
|
+
return result;
|
|
72
|
+
}
|
|
73
|
+
export async function readServingBootstrap(userId: number, signal?: AbortSignal): Promise<ServingBootstrap> {
|
|
74
|
+
return parseServingBootstrap((await mojoCall('/api/account/admin/bootstrap', { signal })).data, userId);
|
|
75
|
+
}
|
|
76
|
+
export async function readHostingReadiness(signal?: AbortSignal): Promise<HostingReadiness> {
|
|
77
|
+
return parseHostingReadiness((await mojoCall('/api/account/admin/setup/readiness', { params: { section: 'hosting_vhosts' }, signal })).data);
|
|
78
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
|
|
2
|
+
import { isReauthRequired, requestFreshAuth, useMe } from '../../client/runtime';
|
|
3
|
+
import { Tabs } from '../../ui/Tabs';
|
|
4
|
+
import { modal } from '../../ui/modal';
|
|
5
|
+
import { datetime } from '../../ui/format';
|
|
6
|
+
import { readCapacity, requestCapacityPlan } from './api';
|
|
7
|
+
import { actionReason, drainedNodeReason, monthlyPrice, nodeRemovalReason, offered } from './data';
|
|
8
|
+
import { fleetOperations } from './operations';
|
|
9
|
+
import { FleetHelp } from './FleetHelp';
|
|
10
|
+
import { compileCapacityDraft, newCapacityDraft, type CapacityDraft } from './standalone-data';
|
|
11
|
+
import type { CapacityPlan, CapacityReport, CapacitySize } from './types';
|
|
12
|
+
/** Review only the stored server plan. Never substitute draft intent or arbitrary provider facts. */
|
|
13
|
+
function reviewedParameters(params: CapacityPlan['steps'][number]['params']): string[] {
|
|
14
|
+
const fields = [['source_instance', 'Source node'], ['subnet_id', 'Subnet'], ['size', 'Target size'], ['count', 'Count'], ['apply_immediately', 'Apply immediately']] as const;
|
|
15
|
+
return fields.flatMap(([key, label]) => {
|
|
16
|
+
const value = params[key];
|
|
17
|
+
if (typeof value !== 'string' && !(typeof value === 'number' && Number.isFinite(value))) return [];
|
|
18
|
+
const text = String(value).trim();
|
|
19
|
+
return text ? [`${label}: ${text.length > 240 ? `${text.slice(0, 240)}…` : text}`] : [];
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
function SizeChoice({ id, current, value, sizes, disabled, onChange }: { id: string; current: string | null; value: string; sizes: CapacitySize[]; disabled?: boolean; onChange: (v: string) => void }) {
|
|
23
|
+
return <label className="field"><span className="field-label">Size for {id}</span><select className="input" value={value} disabled={disabled} onChange={e => onChange(e.target.value)}><option value="">Keep {current ?? 'current class'}</option>{sizes.map(s => <option key={s.size} value={s.size}>{s.label} · {s.type} · {s.monthly_usd == null ? 'price unknown' : `$${s.monthly_usd.toFixed(2)}/mo`}</option>)}</select></label>;
|
|
24
|
+
}
|
|
25
|
+
function Placement({ report, source, initial, close, assertActive }: { report: CapacityReport; source: string; initial: string; close: (v: string | null) => void; assertActive: () => void }) {
|
|
26
|
+
const [subnet, setSubnet] = useState(initial), [error, setError] = useState('');
|
|
27
|
+
const node = report.nodes.instances.find(n => n.id === source);
|
|
28
|
+
return <div className="modal-pad fleet-standalone-dialog"><h2 className="modal-title">Change subnet</h2><p className="modal-message">{node?.name ?? 'Choose a healthy source first'} · {node?.zone ?? 'Zone unknown'}</p><label className="field"><span className="field-label">Subnet override</span><input className="input" value={subnet} placeholder={node?.subnet_id ?? 'subnet-…'} onChange={e => setSubnet(e.target.value)}/><span className="field-help">Leave empty to inherit the source subnet. AWS verifies VPC and availability zone.</span></label>{error && <p role="alert" className="form-alert">{error}</p>}<div className="modal-actions"><button className="btn" onClick={() => close(null)}>Cancel</button><button className="btn btn-primary" onClick={() => { try { assertActive(); if (subnet && !/^subnet-[A-Za-z0-9]+$/.test(subnet)) throw new Error('Enter a subnet-… identifier.'); close(subnet); } catch (reason) { setError(reason instanceof Error ? reason.message : 'Placement unavailable.'); } }}>Use subnet</button></div></div>;
|
|
29
|
+
}
|
|
30
|
+
export function CapacityEditor({ scope, initialReport, close, setBusy, assertActive, signal, onRefresh, onProgress }: { scope: string; initialReport: CapacityReport; close: () => void; setBusy: (v: boolean) => void; assertActive: () => void; signal: AbortSignal; onRefresh: (report: CapacityReport) => void; onProgress: () => void }) {
|
|
31
|
+
const { data: me } = useMe();
|
|
32
|
+
const [report, setReport] = useState(initialReport), [draft, setDraft] = useState(() => newCapacityDraft(initialReport)), [tab, setTab] = useState('nodes'), [nodeTask, setNodeTask] = useState('add');
|
|
33
|
+
const [plan, setPlan] = useState<CapacityPlan | null>(null), [consent, setConsent] = useState(false), [error, setError] = useState(''), [busy, busyState] = useState(false), [fresh, setFresh] = useState(false), [tick, setTick] = useState(Date.now());
|
|
34
|
+
const lock = useRef(false), generation = useRef(0), alive = useRef(false), request = useRef<AbortController | null>(null), needsRefresh = useRef(false);
|
|
35
|
+
const operation = useSyncExternalStore(fn => fleetOperations.subscribe(scope, fn), () => fleetOperations.get(scope), () => fleetOperations.get(scope));
|
|
36
|
+
const blocked = ['submitting', 'running', 'unknown'].includes(operation.status);
|
|
37
|
+
const pending = (value: boolean) => { lock.current = value; busyState(value); setBusy(value); };
|
|
38
|
+
const invalidate = () => { generation.current++; request.current?.abort(); setPlan(null); setConsent(false); setError(''); };
|
|
39
|
+
const edit = (change: (d: CapacityDraft) => CapacityDraft) => { if (lock.current || blocked) return; invalidate(); setDraft(change); };
|
|
40
|
+
const refresh = async () => {
|
|
41
|
+
if (lock.current || blocked) return;
|
|
42
|
+
invalidate(); setDraft(newCapacityDraft(report)); pending(true); setFresh(false); const gen = generation.current, controller = new AbortController(); request.current = controller;
|
|
43
|
+
try { assertActive(); const next = await readCapacity(true, { signal: controller.signal, beforeSend: assertActive }); assertActive(); if (!alive.current || gen !== generation.current) return; if (next.mode !== 'managed') throw new Error('Infrastructure is managed externally. Close this editor and inspect capacity.'); setReport(next); setDraft(newCapacityDraft(next)); needsRefresh.current = false; setFresh(true); onRefresh(next); }
|
|
44
|
+
catch (reason) { if (alive.current && gen === generation.current) setError(reason instanceof Error ? reason.message : 'Inventory unavailable.'); }
|
|
45
|
+
finally { if (alive.current && gen === generation.current) pending(false); }
|
|
46
|
+
};
|
|
47
|
+
useEffect(() => { alive.current = true; const timer = setTimeout(() => void refresh(), 0); const clock = setInterval(() => setTick(Date.now()), 1000); return () => { alive.current = false; generation.current++; request.current?.abort(); setBusy(false); clearTimeout(timer); clearInterval(clock); }; }, []);
|
|
48
|
+
const compiled = useMemo(() => { try { return { steps: compileCapacityDraft(report, draft), error: '' }; } catch (reason) { return { steps: [], error: reason instanceof Error ? reason.message : 'Invalid draft.' }; } }, [report, draft]);
|
|
49
|
+
const review = async () => {
|
|
50
|
+
if (lock.current || blocked || !fresh || needsRefresh.current || compiled.error || !compiled.steps.length) return;
|
|
51
|
+
invalidate(); pending(true); const gen = generation.current, controller = new AbortController(); request.current = controller;
|
|
52
|
+
try { assertActive(); const next = await requestCapacityPlan(compiled.steps, { signal: controller.signal, beforeSend: () => { assertActive(); if (gen !== generation.current) throw new Error('Draft changed.'); } }); assertActive(); if (!alive.current || gen !== generation.current) return; if (next.actor !== me?.id) throw new Error('Plan actor does not match your account.'); setPlan(next); setConsent(false); }
|
|
53
|
+
catch (reason) { if (alive.current && gen === generation.current) { setError(isReauthRequired(reason) ? 'Authenticate again, refresh inventory and review a new plan.' : reason instanceof Error ? reason.message : 'Plan unavailable.'); setFresh(false); if (isReauthRequired(reason)) await requestFreshAuth(); } }
|
|
54
|
+
finally { if (alive.current && gen === generation.current) pending(false); }
|
|
55
|
+
};
|
|
56
|
+
const apply = async () => {
|
|
57
|
+
if (lock.current || blocked || !plan || !consent || Date.parse(plan.expires_at) <= Date.now()) return;
|
|
58
|
+
const reviewed = plan; setConsent(false); pending(true); needsRefresh.current = true;
|
|
59
|
+
try { assertActive(); if (reviewed.actor !== me?.id) throw new Error('Plan actor changed.'); await fleetOperations.submit(scope, reviewed); assertActive(); if (alive.current) { setPlan(null); setFresh(false); pending(false); if (fleetOperations.get(scope).status !== 'idle') { close(); onProgress(); } } }
|
|
60
|
+
catch (reason) { if (alive.current) { setPlan(null); setFresh(false); setError(reason instanceof Error ? reason.message : 'Apply refused. Refresh and review again.'); } }
|
|
61
|
+
finally { if (alive.current) pending(false); }
|
|
62
|
+
};
|
|
63
|
+
const placement = async () => { if (lock.current || blocked) return; assertActive(); const value = await modal.open<string | null>(done => <Placement report={report} source={draft.source} initial={draft.subnet} close={done} assertActive={assertActive}/>, { size: 'sm', signal }); if (value !== null && alive.current) { assertActive(); edit(d => ({ ...d, subnet: value })); } };
|
|
64
|
+
const db = (id: string, change: (d: CapacityDraft['databases'][string]) => CapacityDraft['databases'][string]) => edit(d => ({ ...d, databases: { ...d.databases, [id]: change(d.databases[id]!) } }));
|
|
65
|
+
const cache = (id: string, patch: Partial<CapacityDraft['caches'][string]>) => edit(d => ({ ...d, caches: { ...d.caches, [id]: { ...d.caches[id]!, ...patch } } }));
|
|
66
|
+
const toggle = (key: 'removeNodes' | 'terminateNodes', id: string) => edit(d => ({ ...d, [key]: d[key].includes(id) ? d[key].filter(v => v !== id) : [...d[key], id] }));
|
|
67
|
+
const sources = report.nodes.instances.filter(n => n.healthy && n.registered && n.instance_state === 'running' && n.lifecycle_state === 'available'), source = sources.find(n => n.id === draft.source);
|
|
68
|
+
const expired = !!plan && Date.parse(plan.expires_at) <= tick;
|
|
69
|
+
useEffect(() => { if (expired) setConsent(false); }, [expired]);
|
|
70
|
+
return <div className="modal-pad fleet-standalone-dialog" aria-label={plan ? 'Review capacity plan' : 'Change capacity'}><h2 className="modal-title">{plan ? 'Review capacity changes' : 'Change capacity'}</h2><p className="modal-message">{plan ? 'Authoritative server plan. Apply this exact plan once.' : `${report.region} · ${datetime(report.generated_at)}`}</p><div className="fleet-standalone-body">
|
|
71
|
+
{plan ? <section className="fp-stack" aria-label="Review capacity plan"><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="fp-plan">{plan.steps.map(step => <li key={step.index}><strong>{step.description}</strong><p className="dim">{step.resource || step.action.replaceAll('_', ' ')} · {monthlyPrice(step.monthly_delta_usd)}</p>{reviewedParameters(step.params).map(value => <p className="dim" key={value}>{value}</p>)}{step.warnings.map((warning, index) => <p className="text-warn" key={index}>{warning}</p>)}</li>)}</ol><p><strong>{plan.estimate_complete ? 'Estimated monthly change' : 'Partial monthly estimate'}: {monthlyPrice(plan.total_monthly_delta_usd)}</strong>{!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" role="alert">Plan expired. Go back, refresh and review again.</p> : <label className="fp-check"><input type="checkbox" checked={consent} disabled={busy || blocked} onChange={e => setConsent(e.target.checked)}/>I reviewed this exact plan, its cost and interruptions, and understand removals cannot be undone.</label>}</section> : <><div className="fp-row fp-toolbar"><span className="dim">{busy ? 'Observing or preparing plan…' : fresh ? 'Fresh inventory' : 'Fresh observation required'}</span><button className="btn" disabled={busy || blocked} onClick={() => void refresh()}>Refresh and clear changes</button></div><fieldset disabled={busy || blocked || !fresh}><Tabs ariaLabel="Capacity tasks" activeKey={tab} onActiveKeyChange={setTab} items={[
|
|
72
|
+
{ key: 'nodes', label: 'App nodes', panel: <div className="fp-stack"><Tabs ariaLabel="Node tasks" variant="segmented" activeKey={nodeTask} onActiveKeyChange={setNodeTask} items={[
|
|
73
|
+
{ key: 'add', label: 'Add nodes', panel: <div className="fp-stack"><div className="fp-fields"><label className="field"><span className="field-label">Add app nodes</span><input className="input" type="number" min={0} max={20} value={draft.addNodes} disabled={!offered(report, 'add_node')} onChange={e => edit(d => ({ ...d, addNodes: Number(e.target.value) }))}/></label><label className="field"><span className="field-label">Healthy source</span><select className="input" value={draft.source} onChange={e => edit(d => ({ ...d, source: e.target.value, subnet: '' }))}><option value="">Choose source node</option>{sources.map(n => <option key={n.id} value={n.id}>{n.name} · {n.id} · {n.instance_type} · {n.zone}</option>)}</select></label></div>{!offered(report, 'add_node') && <p className="text-warn">{actionReason(report, 'add_node')}</p>}{source && <div className="fp-resource"><strong>Inherit {source.instance_type ?? 'unknown size'}</strong><p className="dim">Target groups: {source.groups.join(', ') || 'None reported'}</p><div className="fp-row"><span>{draft.subnet || source.subnet_id || 'Subnet unknown'} · {source.zone ?? 'Zone unknown'}</span><button className="btn" onClick={() => void placement()}>Change subnet</button></div></div>}<p className="dim">New nodes clone the selected healthy source. <FleetHelp label="app additions">Source selection is explicit because automatic selection can differ between quote and execution. Size, target groups and default subnet are inherited. AWS revalidates ownership and placement.</FleetHelp></p></div> },
|
|
74
|
+
{ key: 'remove', label: 'Remove nodes', panel: <div className="fp-stack">{report.nodes.instances.map(n => { const registered = n.registered, key = registered ? 'removeNodes' : 'terminateNodes', selected = draft[key].includes(n.id), reason = registered ? nodeRemovalReason(report, n, draft.removeNodes.filter(id => id !== n.id)) : drainedNodeReason(report, n); return <article className="fp-resource" key={n.id}><div className="fp-row"><div><strong>{n.name}</strong><p className="dim">{n.id} · {n.instance_type ?? 'Size unknown'}{n.self ? ' · serving this request' : ''}</p></div><label className="fp-check"><input type="checkbox" checked={selected} disabled={!!reason && !selected} onChange={() => toggle(key, n.id)}/>{registered ? `Remove node ${n.id}` : `Terminate drained node ${n.id}`}</label></div>{reason && <small className="dim">{reason}</small>}</article>})}<p className="dim">Removal drains, then terminates. One currently healthy target must remain. <FleetHelp label="node removal">The serving node is protected. New additions cannot count toward the healthy floor before fresh observation. A proven drained node can be terminated separately. The server also revalidates the healthy floor for each target group before applying.</FleetHelp></p></div> },
|
|
75
|
+
]}/></div> },
|
|
76
|
+
{ key: 'database', label: 'Database', panel: <div className="fp-stack">{!report.databases_available && <p>Database inventory unavailable.</p>}{report.databases_available && !report.databases.length && <p>No database resources reported.</p>}{report.databases.map(row => { const d = draft.databases[row.identifier]!; return <section className="fp-resource fp-stack" key={row.identifier}><div className="fp-row"><h3>{row.identifier}</h3><FleetHelp label={`database ${row.identifier}`}>Writer resizing causes minutes offline. Each existing member is selected by its exact ID and current class. New readers inherit the current source class; refresh their new IDs and resize separately.</FleetHelp></div>{row.blocked_reason && <p className="text-warn">{row.blocked_reason}</p>}{row.members.map(member => <div className="fp-member" key={member.id}><strong>{member.id} · {member.role}</strong><small className="dim">{member.instance_class ?? 'Class unknown'} · {member.status ?? 'Status unknown'} / {member.lifecycle_state}</small><div className="fp-fields"><SizeChoice id={member.id} current={member.instance_class} value={d.sizes[member.id] ?? ''} sizes={report.sizes.database} disabled={!report.databases_available || !!row.blocked_reason || !member.can_resize || !offered(report, 'resize_database') || d.removeReaders.includes(member.id)} onChange={size => db(row.identifier, value => ({ ...value, sizes: { ...value.sizes, [member.id]: size } }))}/>{row.readers.includes(member.id) && <label className="fp-check"><input type="checkbox" checked={d.removeReaders.includes(member.id)} disabled={!report.databases_available || !!row.blocked_reason || !member.can_remove || !offered(report, 'remove_reader') || !!d.sizes[member.id]} onChange={() => db(row.identifier, value => ({ ...value, removeReaders: value.removeReaders.includes(member.id) ? value.removeReaders.filter(id => id !== member.id) : [...value.removeReaders, member.id] }))}/>Remove reader {member.id}</label>}</div></div>)}<label className="field"><span className="field-label">Add readers for {row.identifier}</span><input className="input" type="number" min={0} max={20} value={d.addReaders} disabled={!report.databases_available || !!row.blocked_reason || !offered(report, 'add_reader')} onChange={e => db(row.identifier, value => ({ ...value, addReaders: Number(e.target.value) }))}/><span className="field-help">New readers inherit current source class {row.writer_instance_class ?? row.instance_class ?? 'unknown'}. Refresh after creation, then resize their exact IDs separately.</span></label></section>; })}</div> },
|
|
77
|
+
{ key: 'redis', label: 'Redis', panel: <div className="fp-stack">{!report.caches_available && <p>Redis inventory unavailable.</p>}{report.caches_available && !report.caches.length && <p>No Redis groups reported.</p>}{report.caches.map(row => { const d = draft.caches[row.identifier]!; const sizeChanging = !!d.size && report.sizes.cache.find(s => s.size === d.size)?.type !== row.node_type, countChanging = d.replicas !== row.replica_count; return <section className="fp-resource fp-stack" key={row.identifier}><div className="fp-row"><h3>{row.identifier}</h3><FleetHelp label={`Redis ${row.identifier}`}>Change the group size or replica count in one plan. Apply and refresh before changing the other; the current server cannot cumulatively quote both for one group. Downsizing can evict data when the working set no longer fits.</FleetHelp></div><p>{row.node_type} · {row.replica_count} replicas · {row.status}</p>{row.blocked_reason && <p className="text-warn">{row.blocked_reason}</p>}<div className="fp-fields"><label className="field"><span className="field-label">Replicas for {row.identifier}</span><input className="input" type="number" min={row.min_replicas} max={100} value={d.replicas} disabled={!report.caches_available || !!row.blocked_reason || !offered(report, 'set_cache_replicas') || sizeChanging} onChange={e => cache(row.identifier, { replicas: Number(e.target.value) })}/><span className="field-help">Minimum {row.min_replicas}{sizeChanging ? ' · clear the size change to edit replicas' : ''}</span></label><SizeChoice id={row.identifier} current={row.node_type} value={d.size} sizes={report.sizes.cache} disabled={!report.caches_available || !!row.blocked_reason || !offered(report, 'resize_cache') || countChanging} onChange={size => cache(row.identifier, { size })}/></div><p className="dim">{countChanging ? 'Restore the current replica count to edit group size.' : 'Change one dimension per group, then apply and refresh.'}</p><p className="text-warn">{row.resize_impact === 'rolling' ? 'Rolling resize with a brief primary failover interruption.' : 'Downtime: Redis is unavailable while its node is replaced.'}</p></section>; })}</div> },
|
|
78
|
+
]}/></fieldset>{compiled.error && <p className="form-alert" role="alert">{compiled.error}</p>}{!compiled.steps.length && !compiled.error && <p className="dim fp-empty">Choose a capacity change to review.</p>}</>}
|
|
79
|
+
{blocked && <p className="text-warn">A capacity operation is unresolved. View progress before starting another change.</p>}{error && <p className="form-alert" role="alert">{error}</p>}
|
|
80
|
+
</div><div className="modal-actions"><button className="btn" disabled={busy} onClick={plan ? () => { invalidate(); } : close}>{plan ? 'Back to changes' : 'Cancel'}</button>{plan ? <button className="btn btn-primary" disabled={busy || blocked || !consent || expired} onClick={() => void apply()}>Apply reviewed plan</button> : <button className="btn btn-primary" disabled={busy || blocked || !fresh || !!compiled.error || !compiled.steps.length} onClick={() => void review()}>Review changes ({compiled.steps.length})</button>}</div></div>;
|
|
81
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { Tabs } from '../../ui/Tabs';
|
|
3
|
+
import { datetime } from '../../ui/format';
|
|
4
|
+
import { FleetHelp } from './FleetHelp';
|
|
5
|
+
import { completeAllowlist } from './standalone-data';
|
|
6
|
+
import type { CapacityReport, FleetSource } from './types';
|
|
7
|
+
export function CapacityInspector({ report: r, ingress, close }: { report: CapacityReport; ingress?: FleetSource; close: () => void }) {
|
|
8
|
+
const [copied, setCopied] = useState('');
|
|
9
|
+
const e = r.egress, proof = completeAllowlist(r);
|
|
10
|
+
const balancer = ingress?.data.balancer;
|
|
11
|
+
const ingressIPs = balancer && typeof balancer === 'object' && 'elastic_ips' in balancer && Array.isArray(balancer.elastic_ips) ? balancer.elastic_ips.filter((ip): ip is string => typeof ip === 'string') : [];
|
|
12
|
+
const copy = async () => { try { await navigator.clipboard.writeText(e.addresses.join('\n')); setCopied('Allowlist copied'); } catch { setCopied('Copy unavailable. Select the addresses below.'); } };
|
|
13
|
+
return <div className="modal-pad fleet-standalone-dialog" aria-label="Capacity and addressing"><h2 className="modal-title">Capacity and addressing</h2><p className="modal-message">{r.region} · {r.mode} · {datetime(r.generated_at)}</p><div className="fleet-standalone-body"><Tabs ariaLabel="Capacity inspection" items={[
|
|
14
|
+
{ key: 'nodes', label: 'App nodes', panel: <div className="fp-stack"><p className="dim">{r.nodes.inventory_available ? `${r.nodes.instances.length} owned nodes` : 'Owned inventory unavailable'} · {r.nodes.serving_available ? 'Serving evidence available' : 'Serving evidence unavailable'} <FleetHelp label="node evidence">Serving identity: {r.nodes.self_check}. Inventory counts do not prove healthy load-balancer targets.</FleetHelp></p>{r.nodes.instances.map(n => <article className="fp-resource" key={n.id}><div className="fp-row"><strong>{n.name}</strong><span className="fp-state">{n.healthy ? 'Healthy target' : n.state}</span></div><p>{n.instance_type ?? 'Size unknown'} · {n.zone ?? 'Zone unknown'}{n.self ? ' · serving this request' : ''}</p><small className="dim">{n.id} · {n.instance_state ?? 'Provider state unknown'} / {n.lifecycle_state}</small><p className="dim">Target groups: {n.groups.join(', ') || 'None reported'}</p></article>)}</div> },
|
|
15
|
+
{ key: 'database', label: 'Database', panel: <div className="fp-stack">{!r.databases_available && <p>Database inventory unavailable.</p>}{r.databases_available && !r.databases.length && <p>No database resources reported.</p>}{r.databases.map(d => <article className="fp-resource" key={d.identifier}><h3>{d.identifier}</h3><p>{d.engine} · {d.status}</p>{d.blocked_reason && <p className="text-warn">{d.blocked_reason}</p>}{d.members.map(m => <div className="fp-member" key={m.id}><strong>{m.id}</strong><span>{m.role} · {m.instance_class ?? 'Class unknown'}</span><small className="dim">{m.status ?? 'Status unknown'} / {m.lifecycle_state}</small></div>)}</article>)}<p className="dim">This process’s reader routing: {r.reader_routing.database.active ? 'Active' : 'Inactive'} · {r.reader_routing.database.host ?? 'Host not reported'} <FleetHelp label="database routing">{r.reader_routing.database.skip_reason ?? 'No skip reason reported.'} Reader endpoint match: {r.reader_routing.database.matches_reader_endpoint == null ? 'Unknown' : r.reader_routing.database.matches_reader_endpoint ? 'Yes' : 'No'}. This describes the serving process, not every node.</FleetHelp></p></div> },
|
|
16
|
+
{ key: 'redis', label: 'Redis', panel: <div className="fp-stack">{!r.caches_available && <p>Redis inventory unavailable.</p>}{r.caches_available && !r.caches.length && <p>No Redis groups reported.</p>}{r.caches.map(c => <article className="fp-resource" key={c.identifier}><h3>{c.identifier}</h3><p>{c.node_type} · {c.replica_count} replicas · {c.status}</p>{c.blocked_reason && <p className="text-warn">{c.blocked_reason}</p>}{c.members.map(m => <div className="fp-member" key={m.id}><strong>{m.id}</strong><span>{m.role} · {m.status} / {m.lifecycle_state}</span></div>)}</article>)}<p className="dim">This process’s reader routing: {r.reader_routing.redis.active ? 'Active' : 'Inactive'} <FleetHelp label="Redis routing">Routing is a serving-process observation. Replicas alone do not prove that every app node uses a reader.</FleetHelp></p></div> },
|
|
17
|
+
{ key: 'addresses', label: 'Addresses', panel: <div className="fp-stack"><section className="fp-resource"><div className="fp-row"><h3>Fleet outbound allowlist</h3><FleetHelp label="outbound allowlist">Only a complete, enabled policy observation with no pending nodes can be copied as an allowlist. Fallback attachments and reservations are separate evidence.</FleetHelp></div><p>{proof ? 'Complete observation' : 'Incomplete observation — do not use as a complete allowlist'}</p><p className="dim">Policy: {e.policy_available ? e.enabled ? 'Enabled' : 'Disabled' : 'Unavailable'}</p>{e.fleet_available && e.addresses_available ? <pre>{e.addresses.join('\n') || 'No attached fleet addresses reported'}</pre> : <p>Fleet addresses unavailable.</p>}<button className="btn" disabled={!proof || !e.addresses.length} onClick={() => void copy()}>Copy allowlist</button>{copied && <p role="status">{copied}</p>}{e.attached.map(a => <p key={`${a.instance}:${a.public_ip}`}>{a.instance} · {a.public_ip} · {a.managed ? 'Portal managed' : 'Externally managed'}</p>)}<p className="dim">Pending nodes: {e.fleet_available ? e.pending_nodes.join(', ') || 'None' : 'Unavailable'}</p></section><section className="fp-resource"><h3>Fallback outbound attachments</h3>{e.fleet_available && e.addresses_available ? e.fallback_attached.length ? e.fallback_attached.map(a => <p key={`${a.instance}:${a.public_ip}`}>{a.instance} · {a.public_ip} · outside the fleet allowlist</p>) : <p>None reported</p> : <p>Unavailable</p>}</section><section className="fp-resource"><h3>Reserved addresses</h3><p>{e.addresses_available ? e.reserved.map(a => a.public_ip).join(', ') || 'None' : 'Unavailable'}</p></section><section className="fp-resource"><h3>Fixed ingress addresses</h3><p>{ingressIPs.join(', ') || (ingress?.data.elastic_ip_missing === true ? 'No fixed ingress allocation reported' : 'Unavailable')}</p><p className="dim">Load-balancer ingress is separate from outbound node addresses.</p></section></div> },
|
|
18
|
+
]}/>{r.warnings.map((w, index) => <p className="text-warn" key={index}>{w.message}{w.iam_action ? ` (${w.iam_action})` : ''}</p>)}</div><div className="modal-actions"><button className="btn" onClick={close}>Close</button></div></div>;
|
|
19
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { useRef, useState, useSyncExternalStore } from 'react';
|
|
2
|
+
import { fleetOperations } from './operations';
|
|
3
|
+
export function CapacityProgress({ scope, close, setBusy, assertActive }: { scope: string; close: () => void; setBusy: (v: boolean) => void; assertActive: () => void }) {
|
|
4
|
+
const operation = useSyncExternalStore(fn => fleetOperations.subscribe(scope, fn), () => fleetOperations.get(scope), () => fleetOperations.get(scope));
|
|
5
|
+
const [batch, setBatch] = useState(''), [error, setError] = useState(''), [reading, setReading] = useState(false), lock = useRef(false);
|
|
6
|
+
const observe = async () => { if (lock.current) return; lock.current = true; setBusy(true); setReading(true); setError(''); try { assertActive(); await fleetOperations.observe(scope, batch); assertActive(); } catch (reason) { setError(reason instanceof Error ? reason.message : 'Observation unavailable.'); } finally { lock.current = false; setBusy(false); setReading(false); } };
|
|
7
|
+
const pending = reading || operation.status === 'submitting';
|
|
8
|
+
return <div className="modal-pad fleet-standalone-dialog" aria-label="Capacity progress"><h2 className="modal-title">Capacity progress</h2><div className="fleet-standalone-body fp-stack"><p className="fp-state">{operation.status === 'unknown' ? 'Progress unknown' : operation.status === 'done' ? 'Batch completed' : operation.status === 'failed' ? 'Batch failed' : operation.status === 'idle' ? 'No capacity batch in this session' : 'Capacity change in progress'}</p><p role="status">{operation.message}</p>{operation.planId && <p className="dim">Plan {operation.planId}<br/>Batch {operation.batchId ?? 'Not acknowledged'}</p>}{operation.status !== 'idle' && <p className="dim">Completed steps remain applied. Closing this window does not cancel the batch.</p>}{operation.batch?.steps.map(step => <article className="fp-resource" key={step.index}><strong>{step.index + 1}. {step.description}</strong><p>{step.state}{step.phase ? ` · ${step.phase}` : ''}</p>{step.message && <p>{step.message}</p>}{step.error_code && <p className="text-warn">{step.error_code}</p>}{step.operation && <small className="dim">Operation {step.operation}</small>}</article>)}{operation.status === 'unknown' && !operation.batchId && <label className="field"><span className="field-label">Batch ID from server logs</span><input className="input" value={batch} onChange={e => setBatch(e.target.value)}/><span className="field-help">Use the exact batch for plan {operation.planId}. Only matching terminal evidence resolves this guard. Never replay an unacknowledged apply.</span></label>}{error && <p className="form-alert" role="alert">{error}</p>}</div><div className="modal-actions"><button className="btn" disabled={pending} onClick={close}>Close progress</button>{operation.planId && <button className="btn btn-primary" disabled={pending || !operation.batchId && !batch.trim()} onClick={() => void observe()}>{reading ? 'Observing…' : 'Refresh observation'}</button>}</div></div>;
|
|
9
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { useId, useRef, useState, type ReactNode } from 'react';
|
|
2
|
+
import { Popover } from '../../ui/Popover';
|
|
3
|
+
export function FleetHelp({ label, children }: { label: string; children: ReactNode }) {
|
|
4
|
+
const anchor = useRef<HTMLButtonElement>(null), id = useId();
|
|
5
|
+
const [open, setOpen] = useState(false);
|
|
6
|
+
return <><button ref={anchor} type="button" className="ops-help" aria-label={`About ${label}`} aria-expanded={open} aria-controls={open ? id : undefined} onClick={() => setOpen(value => !value)}><i className="bi bi-info-circle" aria-hidden="true" /></button><Popover anchorRef={anchor} open={open} onClose={() => setOpen(false)} id={id} aria-label={`About ${label}`} className="ops-help-content">{children}</Popover></>;
|
|
7
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
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, useAuthSnapshot, useMe } from '../../client/runtime';
|
|
5
|
+
import { modal } from '../../ui/modal';
|
|
6
|
+
import { datetime } from '../../ui/format';
|
|
7
|
+
import { useAdminRouteContext } from '../core/route-context';
|
|
8
|
+
import { backendGlobalCan, FLEET_CAPACITY_GRANTS, FLEET_DASHBOARD_GRANTS, type BackendGlobalUser } from './access';
|
|
9
|
+
import { readCapacity, readFleetDashboard } from './api';
|
|
10
|
+
import { fleetOperations, fleetSessionScope } from './operations';
|
|
11
|
+
import type { CapacityReport, FleetFacts, FleetSource } from './types';
|
|
12
|
+
export type FleetLinks = Record<string, {
|
|
13
|
+
to: string;
|
|
14
|
+
grants: readonly string[];
|
|
15
|
+
superuser?: boolean;
|
|
16
|
+
} | null>;
|
|
17
|
+
export interface FleetOverviewProps {
|
|
18
|
+
mount?: string;
|
|
19
|
+
links?: FleetLinks;
|
|
20
|
+
showConfigurationLink?: boolean;
|
|
21
|
+
headingLevel?: 1 | 2;
|
|
22
|
+
capacityDetails?: 'inline' | 'modal';
|
|
23
|
+
}
|
|
24
|
+
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'] } };
|
|
25
|
+
export function fleetMount(pathname: string, explicit?: string): string { return (explicit ?? pathname.replace(/\/fleet\/?$/, '').replace(/\/$/, '')).replace(/\/$/, ''); }
|
|
26
|
+
const fact = (v: unknown, fallback = 'Unknown') => typeof v === 'string' || typeof v === 'number' ? String(v) : fallback;
|
|
27
|
+
const obj = (v: unknown): FleetFacts => v && typeof v === 'object' && !Array.isArray(v) ? v as FleetFacts : {};
|
|
28
|
+
const list = (v: unknown): unknown[] => Array.isArray(v) ? v : [];
|
|
29
|
+
const count = (v: unknown) => typeof v === 'number' && Number.isFinite(v) ? String(v) : 'Unknown';
|
|
30
|
+
function summary(name: string, d: FleetFacts): string {
|
|
31
|
+
switch (name) {
|
|
32
|
+
case 'load_balancer': return d.configured === false ? 'No load balancer configured' : `${count(d.healthy)} / ${count(d.registered)} healthy targets · ${fact(obj(d.balancer).name)}`;
|
|
33
|
+
case 'compute': return `${count(d.up)} / ${count(d.total)} up · ${d.source === 'target_group' ? 'Load-balancer target evidence' : d.source === 'ec2' ? 'EC2 inventory (serving health unknown)' : 'Evidence source unknown'}`;
|
|
34
|
+
case 'database':
|
|
35
|
+
case 'cache': return `${d.reachable === true ? 'Reachable' : d.reachable === false ? 'Unreachable' : 'Reachability unknown'} · ${fact(d.engine)} ${fact(d.version, 'version unknown')}${name === 'cache' ? ` · memory ${fact(d.memory_used)}` : ''}`;
|
|
36
|
+
case 'certificates': return `${count(d.active)} active · ${count(d.failing)} failing · ${count(d.expiring_within_30_days)} expiring within 30 days${d.soonest_expiry ? ` · next expiry ${fact(d.soonest_expiry)}` : ''}`;
|
|
37
|
+
case 'public_api': return d.configured === false ? 'Public proof not configured' : `Node ${fact(d.node_version)} · public proof ${fact(obj(d.probe).version, 'version unknown')} · HTTP ${fact(obj(d.probe).http_status)} · ${obj(d.probe).ok === true ? 'Passed' : obj(d.probe).ok === false ? 'Failed' : 'Unknown'}`;
|
|
38
|
+
case 'framework': return `${fact(d.installed)} installed · ${fact(d.latest)} latest${d.update_available === true ? ' · update available' : ''} · pin ${fact(obj(d.pin).mode)}`;
|
|
39
|
+
case 'last_deployment': {
|
|
40
|
+
const r = obj(list(d.items)[0]);
|
|
41
|
+
return `${fact(r.sha, 'No deployment').slice(0, 12)} · ${fact(r.status)} · ${fact(r.finished ?? r.created)}`;
|
|
42
|
+
}
|
|
43
|
+
case 'jobs': return `Scheduler ${d.scheduler_active === true ? 'active' : d.scheduler_active === false ? 'inactive' : 'unknown'} · ${count(obj(d.jobs).pending)} pending · ${count(obj(d.jobs).running)} running · ${count(d.failed_recent)} recent failures`;
|
|
44
|
+
case 'sanity': {
|
|
45
|
+
const checks = list(d.checks);
|
|
46
|
+
return checks.length ? checks.map(r => `${fact(obj(r).name)}: ${obj(r).ok === true ? 'passed' : obj(r).ok === false ? 'failed' : 'unknown'}`).join(' · ') : 'Check evidence unavailable';
|
|
47
|
+
}
|
|
48
|
+
case 'incidents':
|
|
49
|
+
case 'tickets': return `${count(d.open)} open${d.oldest_age_days != null ? ` · oldest ${count(d.oldest_age_days)} days` : ''}`;
|
|
50
|
+
case 'sms': return `${fact(d.provider)} · ${d.test_mode === true ? 'test mode' : 'live mode'} · verification ${obj(d.verified).ok === true ? 'passed' : obj(d.verified).ok === false ? 'failed' : 'unknown'}`;
|
|
51
|
+
case 'email': return `${count(d.domains)} domains · ${fact(d.message, 'Inspect email configuration')}`;
|
|
52
|
+
default: return 'Evidence available';
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
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' };
|
|
56
|
+
function EvidenceRow({ name, source, link }: {
|
|
57
|
+
name: string;
|
|
58
|
+
source: FleetSource;
|
|
59
|
+
link?: string;
|
|
60
|
+
}) {
|
|
61
|
+
const restricted = source.status === 'permission_denied';
|
|
62
|
+
const details = () => modal.open(close => <div className="modal-pad fleet-source-detail"><h2 className="modal-title">{labels[name] ?? name}</h2><p>{restricted ? 'Your account cannot read this source.' : summary(name, source.data)}</p>{source.reason && <p>{source.reason.replaceAll('_', ' ')}</p>}{name === 'load_balancer' && <p>Fixed ingress addresses: {list(obj(source.data.balancer).elastic_ips).map(v => fact(v)).join(', ') || (source.data.elastic_ip_missing === true ? 'No fixed ingress allocation reported' : 'Unavailable')}. These are separate from node outbound addresses.</p>}{name === 'database' && !!source.data.drift && <p className="text-warn">Engine update {fact(obj(source.data.drift).available_major)} · {fact(obj(source.data.drift).note)} · deadline {fact(obj(source.data.drift).deadline)}</p>}<p className="dim">Observed {source.observed_at ? <time dateTime={source.observed_at}>{datetime(source.observed_at)}</time> : 'time unavailable'}{source.stale_after ? ` · stale after ${datetime(source.stale_after)}` : ''}</p>{link && <p><Link to={link} onClick={() => close(null)}>Open {labels[name]?.toLowerCase() ?? name} →</Link></p>}<div className="modal-actions"><button className="btn" onClick={() => close(null)}>Close</button></div></div>, {size: 'md'});
|
|
63
|
+
const d = source.data;
|
|
64
|
+
const concise = restricted ? 'Restricted' : name === 'compute' ? `${count(d.up)} / ${count(d.total)} up` : name === 'load_balancer' ? d.configured === false ? 'Not configured' : `${count(d.healthy)} / ${count(d.registered)} targets healthy` : name === 'database' || name === 'cache' ? `${d.reachable === true ? 'Reachable' : d.reachable === false ? 'Unreachable' : 'Unknown'} · ${fact(d.engine)} ${fact(d.version, '')}` : name === 'framework' ? `${fact(d.installed)}${d.update_available === true ? ' · update available' : ''}` : name === 'public_api' ? d.configured === false ? 'Not configured' : `HTTP ${fact(obj(d.probe).http_status)} · ${obj(d.probe).ok === true ? 'passed' : obj(d.probe).ok === false ? 'failed' : 'unknown'}` : name === 'certificates' ? `${count(d.active)} active · ${count(d.expiring_within_30_days)} expiring soon${d.failing ? ` · ${count(d.failing)} failing` : ''}` : name === 'jobs' && d.scheduler_active == null ? summary(name, d) : name === 'jobs' ? `${count(obj(d.jobs).pending)} pending · ${count(obj(d.jobs).running)} running` : name === 'last_deployment' ? `${fact(obj(list(d.items)[0]).sha, 'No deployment').slice(0, 12)} · ${fact(obj(list(d.items)[0]).status)}` : name === 'email' ? `${count(d.domains)} domains` : name === 'sanity' && !list(d.checks).length ? 'Check evidence unavailable' : name === 'sanity' ? `${list(d.checks).filter(v => obj(v).ok === true).length} / ${list(d.checks).length} checks passed` : summary(name, d);
|
|
65
|
+
return <div className="fleet-evidence-row"><strong>{labels[name] ?? name}</strong><span className="fleet-row-value">{concise}</span><span className={`fleet-source-status fleet-status-${source.status}`}>{restricted ? 'Restricted' : source.status.replaceAll('_', ' ')}</span>{!restricted && <button type="button" className="fleet-detail-button" aria-label={`Details for ${labels[name] ?? name}`} onClick={() => void details()}>Details</button>}</div>;
|
|
66
|
+
}
|
|
67
|
+
function EvidenceSection({ title, names, sources, links, mount, user }: {
|
|
68
|
+
title: string;
|
|
69
|
+
names: string[];
|
|
70
|
+
sources: Record<string, FleetSource>;
|
|
71
|
+
links: FleetLinks;
|
|
72
|
+
mount: string;
|
|
73
|
+
user: BackendGlobalUser;
|
|
74
|
+
}) {
|
|
75
|
+
const visible = names.filter(name => { const s = sources[name]; return s && !(['sms', 'email'].includes(name) && (s.status === 'unconfigured' || s.data.configured === false)) && !(name === 'tickets' && s.data.open === 0); });
|
|
76
|
+
if (!visible.length)
|
|
77
|
+
return null;
|
|
78
|
+
return <section className="panel fleet-evidence"><h3>{title}</h3>{visible.every(name => sources[name]?.status === 'permission_denied') ? <div className="fleet-evidence-row"><strong>Restricted</strong><p>Your account cannot read these sources.</p></div> : visible.map(name => { const link = links[name]; const can = link && (!link.superuser || user.is_superuser === true) && backendGlobalCan(user, link.grants); return <EvidenceRow key={name} name={name} source={sources[name]!} link={can ? `${mount}/${link.to.replace(/^\//, '')}` : undefined}/>; })}</section>;
|
|
79
|
+
}
|
|
80
|
+
function CapacityFactsContent({ report }: {
|
|
81
|
+
report: CapacityReport;
|
|
82
|
+
}) {
|
|
83
|
+
const e = report.egress, r = report.reader_routing;
|
|
84
|
+
return <><p className="dim">Observed {report.generated_at} · {report.identity ? `${report.identity.project} / ${report.identity.environment}` : 'Ownership identity unavailable'}</p>
|
|
85
|
+
<div className="fleet-fact-grid"><section><h4>App nodes</h4><p>{report.nodes.inventory_available ? `${report.nodes.instances.length} owned inventory rows` : 'Instance inventory unavailable'} · {report.nodes.serving_available ? 'Serving evidence available' : 'Serving evidence unavailable'}</p><p>Serving-node identity: {report.nodes.self_check}</p>{report.nodes.instances.map(n => <p key={n.id}><b>{n.name}</b> · {n.id} · {n.instance_type ?? 'size unknown'} · {n.instance_state ?? 'provider state unknown'} / {n.state} · {n.lifecycle_state}{n.self ? ' · serving this request' : ''}</p>)}</section>
|
|
86
|
+
<section><h4>Stable outbound addresses</h4><p>Policy: {e.policy_available ? (e.enabled ? 'Enabled' : 'Disabled') : 'Unavailable'}</p>{e.available && e.fleet_available && e.addresses_available ? <><p>Fleet allowlist: {e.addresses.length ? e.addresses.join(', ') : 'No attached fleet addresses'}</p>{e.attached.map(a => <p key={`${a.instance}:${a.public_ip}`}>{a.instance} · {a.public_ip} · {a.managed ? 'portal managed' : 'managed outside this portal'}</p>)}</> : <p>Fleet allowlist unavailable. An empty list would not prove there are no addresses.</p>}{e.fallback_attached.map(a => <p key={`${a.instance}:${a.public_ip}`}>{a.instance} · {a.public_ip} · managed outside this portal; not part of the fleet allowlist.</p>)}<p>Reserved addresses: {e.addresses_available ? e.reserved.map(a => a.public_ip).join(', ') || 'None' : 'Unavailable'}</p><p>Pending node addresses: {e.fleet_available ? e.pending_nodes.join(', ') || 'None' : 'Unavailable'}</p></section>
|
|
87
|
+
<section><h4>Data services</h4><p>Database inventory: {report.databases_available ? `${report.databases.length} resources` : 'Unavailable'}</p>{report.databases.map(d => <p key={d.identifier}>{d.identifier} · {d.engine} · {d.status}{d.blocked_reason ? ` · ${d.blocked_reason}` : ''}{d.members.map(m => <small key={m.id} className="fleet-member">{m.id} · {m.role} · {m.status ?? 'status unknown'} / {m.lifecycle_state} · {m.instance_class ?? 'class unknown'}</small>)}</p>)}<p>Redis inventory: {report.caches_available ? `${report.caches.length} groups` : 'Unavailable'}</p>{report.caches.map(c => <p key={c.identifier}>{c.identifier} · {c.node_type} · {c.replica_count} replicas · {c.status}{c.blocked_reason ? ` · ${c.blocked_reason}` : ''}{c.members.map(m => <small key={m.id} className="fleet-member">{m.id} · {m.role} · {m.status ?? 'status unknown'} / {m.lifecycle_state}</small>)}</p>)}</section>
|
|
88
|
+
<section><h4>This serving process’s reader routing</h4><p>Database: {r.database.active ? 'Active' : 'Inactive'} · {r.database.host ?? 'Host not reported'}</p>{r.database.skip_reason && <p>{r.database.skip_reason}</p>}<p>Matches reader endpoint: {r.database.matches_reader_endpoint === null ? 'Unknown' : r.database.matches_reader_endpoint ? 'Yes' : 'No'}</p><p>Redis: {r.redis.active ? 'Active' : 'Inactive'}. These observations describe this process, not every fleet node.</p></section></div>{report.warnings.map((w, i) => <p className="text-warn" key={i}>{w.code}: {w.message}{w.iam_action ? ` (${w.iam_action})` : ''}</p>)}</>;
|
|
89
|
+
}
|
|
90
|
+
export function CapacityFacts({ report }: { report: CapacityReport }) {
|
|
91
|
+
return <details className="panel panel-pad fleet-capacity-facts"><summary>Capacity and addressing · {report.region} · {report.mode}</summary><CapacityFactsContent report={report}/></details>;
|
|
92
|
+
}
|
|
93
|
+
export function FleetOverview({ mount: explicitMount, links: customLinks, showConfigurationLink = true, headingLevel = 2, capacityDetails = 'inline' }: FleetOverviewProps = {}) {
|
|
94
|
+
const Heading = headingLevel === 1 ? 'h1' : 'h2';
|
|
95
|
+
const queryClient = useQueryClient(), fresh = useRef(false);
|
|
96
|
+
const routeContext = useAdminRouteContext();
|
|
97
|
+
const location = useLocation(), mount = fleetMount(location.pathname, explicitMount ?? routeContext?.mount), auth = useAuthSnapshot(), { data: me } = useMe();
|
|
98
|
+
const scope = auth.authenticated && auth.uid ? `${auth.uid}:${getAuthSessionRevision()}` : null;
|
|
99
|
+
const canDashboard = !!scope && backendGlobalCan(me, FLEET_DASHBOARD_GRANTS), canCapacity = !!scope && backendGlobalCan(me, FLEET_CAPACITY_GRANTS);
|
|
100
|
+
const dashboard = useQuery({ queryKey: ['operational-fleet', 'dashboard', scope], queryFn: ({ signal }) => readFleetDashboard(fresh.current, { signal }), enabled: canDashboard, retry: false, refetchOnWindowFocus: false });
|
|
101
|
+
const capacity = useQuery({ queryKey: ['operational-fleet', 'capacity', scope], queryFn: ({ signal }) => readCapacity(fresh.current, { signal }), enabled: canCapacity, retry: false, refetchOnWindowFocus: false });
|
|
102
|
+
const operation = useSyncExternalStore(fn => scope ? fleetOperations.subscribe(scope, fn) : () => { }, () => scope ? fleetOperations.get(scope) : null, () => null);
|
|
103
|
+
const owner = useRef<AbortController | null>(null), [refreshing, setRefreshing] = useState(false), [error, setError] = useState('');
|
|
104
|
+
const links = { ...defaultLinks, ...customLinks };
|
|
105
|
+
if (routeContext) for (const [name, link] of Object.entries(links)) {
|
|
106
|
+
if (link && !routeContext.paths.includes(`${mount}/${link.to.replace(/^\//, '').split('?')[0]}`)) links[name] = null;
|
|
107
|
+
}
|
|
108
|
+
const configurationMounted = !routeContext || routeContext.paths.includes(`${mount}/fleet-configuration`);
|
|
109
|
+
useEffect(() => () => { owner.current?.abort(); }, [scope]);
|
|
110
|
+
useEffect(() => { if (!scope || !canCapacity || !operation || !['done', 'failed', 'unknown'].includes(operation.status))
|
|
111
|
+
return; const expected = scope; void readCapacity(true).then(next => { if (fleetSessionScope() === expected)
|
|
112
|
+
queryClient.setQueryData(['operational-fleet', 'capacity', expected], next); }).catch(() => { }); }, [scope, canCapacity, operation?.status, operation?.planId, queryClient]);
|
|
113
|
+
const jwt = decodeJwt(getAccessToken());
|
|
114
|
+
const interactive = !!jwt && !['api_key', 'group_scoped_token'].includes(String(jwt.credential_kind ?? ''));
|
|
115
|
+
const eligible = canCapacity && me?.is_superuser === true && interactive && capacity.data?.mode === 'managed';
|
|
116
|
+
const refresh = async () => { owner.current?.abort(); setRefreshing(true); setError(''); fresh.current = true; try {
|
|
117
|
+
await Promise.all([...(canDashboard ? [dashboard.refetch()] : []), ...(canCapacity ? [capacity.refetch()] : [])]);
|
|
118
|
+
}
|
|
119
|
+
finally {
|
|
120
|
+
fresh.current = false;
|
|
121
|
+
setRefreshing(false);
|
|
122
|
+
} };
|
|
123
|
+
const openCapacity = () => {
|
|
124
|
+
if (!scope || fleetSessionScope() !== scope || !canCapacity || !capacity.data) return;
|
|
125
|
+
owner.current?.abort(); const controller = new AbortController(); owner.current = controller;
|
|
126
|
+
const report = capacity.data;
|
|
127
|
+
return modal.open(close => <div className="modal-pad fleet-capacity-dialog"><h2 className="modal-title">Capacity and addressing</h2><p className="dim">{report.region} · {report.mode}</p><div className="fleet-capacity-body"><CapacityFactsContent report={report}/></div><div className="modal-actions"><button className="btn" onClick={() => close(null)}>Close</button></div></div>, { size: 'lg', signal: controller.signal });
|
|
128
|
+
};
|
|
129
|
+
useEffect(() => { if (capacityDetails === 'modal' && !canCapacity) owner.current?.abort(); }, [capacityDetails, canCapacity]);
|
|
130
|
+
const openScale = async () => { if (!scope || fleetSessionScope() !== scope || !eligible || !capacity.data)
|
|
131
|
+
return; owner.current?.abort(); const controller = new AbortController(); owner.current = controller; try {
|
|
132
|
+
const { ScaleFleetDialog } = await import('./ScaleFleetDialog');
|
|
133
|
+
if (controller.signal.aborted || fleetSessionScope() !== scope)
|
|
134
|
+
return;
|
|
135
|
+
await modal.open(close => <ScaleFleetDialog scope={scope} initialReport={capacity.data!} close={() => close(null)} onRefresh={() => void capacity.refetch()}/>, { size: 'lg', signal: controller.signal, canDismiss: () => fleetOperations.get(scope).status !== 'submitting' });
|
|
136
|
+
}
|
|
137
|
+
catch (reason) {
|
|
138
|
+
setError(reason instanceof Error ? reason.message : 'Capacity editor could not load.');
|
|
139
|
+
} };
|
|
140
|
+
if (!canDashboard && !canCapacity)
|
|
141
|
+
return null;
|
|
142
|
+
const data = dashboard.data;
|
|
143
|
+
return <section className="operational-fleet" aria-label="Fleet overview"><header className="fleet-heading"><div><div className="eyebrow">Operations</div><Heading>Fleet overview</Heading></div><div className="fleet-buttons"><button className="btn" disabled={refreshing || dashboard.isFetching || capacity.isFetching || operation?.status === 'submitting'} onClick={() => void refresh()}>Refresh fleet</button>{eligible && <button className="btn btn-primary" onClick={() => void openScale()}>{operation && ['submitting', 'running', 'unknown'].includes(operation.status) ? 'View capacity progress' : 'Scale fleet'}</button>}{showConfigurationLink && configurationMounted && me?.is_superuser === true && <Link className="btn" to={`${mount}/fleet-configuration`}>Configuration</Link>}</div></header>
|
|
144
|
+
{canDashboard && !data && <div className="panel panel-pad" role={dashboard.error ? 'alert' : 'status'}>{dashboard.error?.message ?? 'Loading fleet health…'}</div>}
|
|
145
|
+
{data && <><div className={`panel panel-pad fleet-availability fleet-availability-${data.availability.state}`}><strong>{data.availability.message}</strong><p className="dim">Observed <time dateTime={data.observed_at} title={data.observed_at}>{datetime(data.observed_at)}</time></p>{dashboard.error && <p className="text-warn">Latest observation failed: {dashboard.error.message}. The prior report is retained with its original timestamps.</p>}</div><div className="fleet-evidence-grid"><EvidenceSection title="Infrastructure" names={['load_balancer', 'compute', 'database', 'cache', 'certificates', 'public_api']} sources={data.sources} links={links} mount={mount} user={me!}/><EvidenceSection title="Software" names={['framework', 'last_deployment', 'sms', 'email']} sources={data.sources} links={links} mount={mount} user={me!}/><EvidenceSection title="Needs attention" names={['jobs', 'sanity', 'incidents', 'tickets']} sources={data.sources} links={links} mount={mount} user={me!}/></div>{data.attention.message && <p>{data.attention.message}</p>}</>}
|
|
146
|
+
{canCapacity && capacity.error && <p className="form-alert" role="alert">Capacity observation unavailable: {capacity.error.message}</p>}{canCapacity && capacity.data && (capacityDetails === 'modal' ? <div className="fleet-buttons"><button className="btn" onClick={() => void openCapacity()}>Capacity and addressing</button><span className="dim">{capacity.data.region} · {capacity.data.mode}</span></div> : <CapacityFacts report={capacity.data}/>)}
|
|
147
|
+
{!eligible && <p className="dim">{!canCapacity ? 'Capacity details require the global manage_aws grant.' : me?.is_superuser !== true ? 'Scaling requires a literal superuser account.' : !interactive ? 'Scaling requires an interactive user session.' : capacity.data?.mode === 'external' ? 'Infrastructure is managed externally. Capacity is read-only here.' : 'Loading capacity authority…'}</p>}
|
|
148
|
+
{operation && operation.status !== 'idle' && <div className="panel panel-pad" role="status"><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>{operation.message}</p><p className="dim">Plan {operation.planId} · batch {operation.batchId ?? 'not acknowledged'}</p>{operation.status === 'failed' && <p>Completed steps remain applied. Nothing rolls back.</p>}</div>}{error && <p className="form-alert" role="alert">{error}</p>}
|
|
149
|
+
</section>;
|
|
150
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { lazy, Suspense } from 'react';
|
|
2
|
+
import { useAuthSnapshot, useMe } from '../../client/runtime';
|
|
3
|
+
import { backendGlobalCan, FLEET_CAPACITY_GRANTS, FLEET_DASHBOARD_GRANTS } from './access';
|
|
4
|
+
import type { FleetOverviewProps } from './FleetOverview';
|
|
5
|
+
const Workspace = lazy(() => import('./FleetWorkspace').then(module => ({ default: module.FleetWorkspace })));
|
|
6
|
+
export function FleetPage(props: FleetOverviewProps = {}) {
|
|
7
|
+
const auth = useAuthSnapshot(), { data: me, isLoading } = useMe();
|
|
8
|
+
if (isLoading) return <p>Checking fleet access…</p>;
|
|
9
|
+
if (!auth.authenticated || !backendGlobalCan(me, [...FLEET_DASHBOARD_GRANTS, ...FLEET_CAPACITY_GRANTS])) return <div className="panel panel-pad">Fleet requires dashboard or capacity access.</div>;
|
|
10
|
+
return <div className="page fleet-page"><Suspense fallback={<p role="status">Loading fleet…</p>}><Workspace {...props}/></Suspense></div>;
|
|
11
|
+
}
|