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,179 @@
|
|
|
1
|
+
import { apiOrigin, getAuthSessionRevision, getAuthSnapshot, isReauthRequired, MojoError, requestFreshAuth } from '../../client/runtime';
|
|
2
|
+
import { applyCapacityPlan, readCapacityBatch } from './api';
|
|
3
|
+
import type { CapacityBatch, CapacityPlan } from './types';
|
|
4
|
+
|
|
5
|
+
export interface FleetOperationState {
|
|
6
|
+
status: 'idle' | 'submitting' | 'running' | 'unknown' | 'done' | 'failed';
|
|
7
|
+
planId: string | null;
|
|
8
|
+
batchId: string | null;
|
|
9
|
+
batch: CapacityBatch | null;
|
|
10
|
+
message: string;
|
|
11
|
+
since: number;
|
|
12
|
+
}
|
|
13
|
+
interface OperationRecord {
|
|
14
|
+
value: FleetOperationState;
|
|
15
|
+
listeners: Set<() => void>;
|
|
16
|
+
busy: boolean;
|
|
17
|
+
reading: boolean;
|
|
18
|
+
deadline: number;
|
|
19
|
+
actor: number | null;
|
|
20
|
+
timerScope: string | null;
|
|
21
|
+
timer: ReturnType<typeof setTimeout> | null;
|
|
22
|
+
}
|
|
23
|
+
export function fleetSessionScope(): string | null {
|
|
24
|
+
const auth = getAuthSnapshot();
|
|
25
|
+
return auth.authenticated && auth.uid ? `${auth.uid}:${getAuthSessionRevision()}` : null;
|
|
26
|
+
}
|
|
27
|
+
export function batchOutcome(batch: CapacityBatch): 'running' | 'unknown' | 'done' | 'failed' {
|
|
28
|
+
const uncertain = (code: string | null) => code === 'mutation_state_unknown' || code === 'operation_failed';
|
|
29
|
+
if (uncertain(batch.error_code) || batch.steps.some(step => uncertain(step.error_code))) return 'unknown';
|
|
30
|
+
if (batch.state === 'done' && !batch.error_code && batch.steps.every(step => step.state === 'done' && !step.error_code)) return 'done';
|
|
31
|
+
if (batch.state === 'failed') return 'failed';
|
|
32
|
+
return batch.state === 'running' ? 'running' : 'unknown';
|
|
33
|
+
}
|
|
34
|
+
const errorMessage = (error: unknown) => error instanceof Error ? error.message : 'Capacity outcome could not be confirmed.';
|
|
35
|
+
|
|
36
|
+
/** Safe unresolved IDs survive same-actor reauthentication; exact sessions fence every send/reply.
|
|
37
|
+
* Lives outside dialog/route components. Stores no credentials and never replays a mutation. */
|
|
38
|
+
export function createFleetOperationCoordinator(deps: {
|
|
39
|
+
apply?: typeof applyCapacityPlan;
|
|
40
|
+
read?: typeof readCapacityBatch;
|
|
41
|
+
scope?: () => string | null;
|
|
42
|
+
now?: () => number;
|
|
43
|
+
interval?: number;
|
|
44
|
+
} = {}) {
|
|
45
|
+
const apply = deps.apply ?? applyCapacityPlan;
|
|
46
|
+
const read = deps.read ?? readCapacityBatch;
|
|
47
|
+
const current = deps.scope ?? fleetSessionScope;
|
|
48
|
+
const now = deps.now ?? Date.now;
|
|
49
|
+
const records = new Map<string, OperationRecord>();
|
|
50
|
+
function record(scope: string): OperationRecord {
|
|
51
|
+
const actorKey = scope.slice(0, scope.lastIndexOf(':'));
|
|
52
|
+
const key = `${apiOrigin() || 'same-origin'}:${actorKey}`;
|
|
53
|
+
let entry = records.get(key);
|
|
54
|
+
if (!entry) {
|
|
55
|
+
entry = {
|
|
56
|
+
value: { status: 'idle', planId: null, batchId: null, batch: null, message: '', since: 0 },
|
|
57
|
+
listeners: new Set(), busy: false, reading: false, deadline: 0, actor: null, timerScope: null, timer: null,
|
|
58
|
+
};
|
|
59
|
+
records.set(key, entry);
|
|
60
|
+
}
|
|
61
|
+
return entry;
|
|
62
|
+
}
|
|
63
|
+
function update(scope: string, patch: Partial<FleetOperationState>): void {
|
|
64
|
+
const entry = record(scope);
|
|
65
|
+
entry.value = { ...entry.value, ...patch };
|
|
66
|
+
entry.listeners.forEach(listener => listener());
|
|
67
|
+
}
|
|
68
|
+
function active(scope: string): void {
|
|
69
|
+
if (current() !== scope) throw new Error('The signed-in session changed. Review again.');
|
|
70
|
+
}
|
|
71
|
+
function schedule(scope: string): void {
|
|
72
|
+
const entry = record(scope);
|
|
73
|
+
if (entry.timer) clearTimeout(entry.timer);
|
|
74
|
+
if (entry.value.status !== 'running') return;
|
|
75
|
+
entry.timerScope = scope;
|
|
76
|
+
entry.timer = setTimeout(() => {
|
|
77
|
+
entry.timer = null;
|
|
78
|
+
if (current() !== scope) return;
|
|
79
|
+
if (now() > entry.deadline) {
|
|
80
|
+
update(scope, { status: 'unknown', message: 'Automatic observation reached its budget. Progress is unknown; refresh this exact batch, never replay it.' });
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
void observe(scope);
|
|
84
|
+
}, deps.interval ?? 10_000);
|
|
85
|
+
}
|
|
86
|
+
function accept(scope: string, batch: CapacityBatch): void {
|
|
87
|
+
const entry = record(scope);
|
|
88
|
+
if (batch.actor !== entry.actor || batch.plan_id !== entry.value.planId || entry.value.batchId && batch.id !== entry.value.batchId) {
|
|
89
|
+
throw new Error('Batch identity does not match the submitted plan.');
|
|
90
|
+
}
|
|
91
|
+
const status = batchOutcome(batch);
|
|
92
|
+
update(scope, { batchId: batch.id, batch, status, message: batch.stalled
|
|
93
|
+
? 'Progress stalled. Check the jobs runner and cleanup channel, then refresh observation.' : batch.message ?? status });
|
|
94
|
+
schedule(scope);
|
|
95
|
+
}
|
|
96
|
+
async function observe(scope: string, batchId?: string): Promise<void> {
|
|
97
|
+
const entry = record(scope);
|
|
98
|
+
if (entry.reading || entry.busy) return;
|
|
99
|
+
const id = batchId?.trim() || entry.value.batchId;
|
|
100
|
+
if (!id || !entry.value.planId) return;
|
|
101
|
+
entry.reading = true;
|
|
102
|
+
try {
|
|
103
|
+
active(scope);
|
|
104
|
+
const batch = await read(id, { beforeSend: () => active(scope) });
|
|
105
|
+
active(scope);
|
|
106
|
+
accept(scope, batch);
|
|
107
|
+
} catch (error) {
|
|
108
|
+
if (current() === scope) update(scope, { status: 'unknown', message: `Observation unavailable: ${errorMessage(error)}. Keep the plan and batch IDs; no new change is safe yet.` });
|
|
109
|
+
} finally {
|
|
110
|
+
entry.reading = false;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return {
|
|
114
|
+
get: (scope: string) => record(scope).value,
|
|
115
|
+
subscribe(scope: string, listener: () => void) {
|
|
116
|
+
const entry = record(scope);
|
|
117
|
+
entry.listeners.add(listener);
|
|
118
|
+
// Resubscriptions on render must not keep postponing the next observation.
|
|
119
|
+
if (current() === scope && entry.value.status === 'running' && (!entry.timer || entry.timerScope !== scope)) schedule(scope);
|
|
120
|
+
return () => entry.listeners.delete(listener);
|
|
121
|
+
},
|
|
122
|
+
blocked: (scope: string) => ['submitting', 'running', 'unknown'].includes(record(scope).value.status),
|
|
123
|
+
observe,
|
|
124
|
+
async submit(scope: string, plan: CapacityPlan): Promise<void> {
|
|
125
|
+
const entry = record(scope);
|
|
126
|
+
if (entry.busy || ['submitting', 'running', 'unknown'].includes(entry.value.status)) return;
|
|
127
|
+
active(scope);
|
|
128
|
+
if (Date.parse(plan.expires_at) <= now()) throw new Error('This plan expired. Review a new plan.');
|
|
129
|
+
entry.busy = true;
|
|
130
|
+
entry.actor = plan.actor;
|
|
131
|
+
entry.deadline = now() + Math.max(1, plan.steps.length) * 90 * 60_000;
|
|
132
|
+
update(scope, { status: 'submitting', planId: plan.id, batchId: null, batch: null, message: 'Submitting the reviewed stored plan…', since: now() });
|
|
133
|
+
let beforeSendPassed = false, localRefusal = false;
|
|
134
|
+
try {
|
|
135
|
+
const batch = await apply(plan.id, { beforeSend: () => {
|
|
136
|
+
try {
|
|
137
|
+
active(scope);
|
|
138
|
+
if (Date.parse(plan.expires_at) <= now()) throw new Error('Plan expired before submission.');
|
|
139
|
+
beforeSendPassed = true;
|
|
140
|
+
} catch (error) { localRefusal = true; throw error; }
|
|
141
|
+
} });
|
|
142
|
+
if (current() !== scope) {
|
|
143
|
+
update(scope, { batchId: batch.id, status: 'unknown', message: 'The session changed during submission. Its batch ID is retained for reconciliation; observe it deliberately from your current session.' });
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
accept(scope, batch);
|
|
147
|
+
} catch (error) {
|
|
148
|
+
// The production transport always completes beforeSend before dispatch.
|
|
149
|
+
// An auth gate or our final fence can refuse without any request.
|
|
150
|
+
if (localRefusal || !deps.apply && !beforeSendPassed) {
|
|
151
|
+
update(scope, { status: 'idle', planId: null, batchId: null, batch: null, message: `${errorMessage(error)} Refresh inventory and review a new plan.` });
|
|
152
|
+
throw error;
|
|
153
|
+
}
|
|
154
|
+
const duplicate = error instanceof MojoError && error.errorCode === 'plan_already_applied';
|
|
155
|
+
const duplicateBatch = duplicate && error.data && typeof error.data === 'object'
|
|
156
|
+
? (error.data as { batch?: unknown }).batch : null;
|
|
157
|
+
if (typeof duplicateBatch === 'string' && duplicateBatch.trim()) {
|
|
158
|
+
update(scope, { batchId: duplicateBatch, status: 'running', message: 'Already submitted. Observing the existing batch.' });
|
|
159
|
+
schedule(scope);
|
|
160
|
+
} else if (isReauthRequired(error)) {
|
|
161
|
+
update(scope, { status: 'idle', message: 'Authenticate again, refresh the fleet, and deliberately review a new plan.' });
|
|
162
|
+
await requestFreshAuth();
|
|
163
|
+
throw error;
|
|
164
|
+
} else if (!duplicate && error instanceof MojoError && [400, 401, 403, 404, 405, 409, 422].includes(error.status)) {
|
|
165
|
+
update(scope, { status: 'idle', message: errorMessage(error) });
|
|
166
|
+
throw error;
|
|
167
|
+
} else {
|
|
168
|
+
update(scope, { status: 'unknown', message: `Apply outcome unknown: ${errorMessage(error)}. Inspect server capacity logs for plan ${plan.id}; enter its batch ID below. Inventory refresh or closing this dialog cannot resolve an unacknowledged submission. Do not replay it.` });
|
|
169
|
+
}
|
|
170
|
+
} finally {
|
|
171
|
+
entry.busy = false;
|
|
172
|
+
}
|
|
173
|
+
},
|
|
174
|
+
dispose() {
|
|
175
|
+
records.forEach(entry => { if (entry.timer) clearTimeout(entry.timer); });
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
export const fleetOperations = createFleetOperationCoordinator();
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { actionReason, drainedNodeReason, nodeRemovalReason, offered } from './data';
|
|
2
|
+
import type { CapacityReport, CapacityStep } from './types';
|
|
3
|
+
|
|
4
|
+
/** Standalone intent identifies every existing member explicitly; the Dashboard compiler is unchanged. */
|
|
5
|
+
export interface CapacityDraft {
|
|
6
|
+
addNodes: number; source: string; subnet: string; removeNodes: string[]; terminateNodes: string[];
|
|
7
|
+
databases: Record<string, { addReaders: number; sizes: Record<string, string>; removeReaders: string[] }>;
|
|
8
|
+
caches: Record<string, { replicas: number; size: string }>;
|
|
9
|
+
}
|
|
10
|
+
export function newCapacityDraft(report: CapacityReport): CapacityDraft {
|
|
11
|
+
return { addNodes: 0, source: '', subnet: '', removeNodes: [], terminateNodes: [], databases: Object.fromEntries(report.databases.map(row => [row.identifier, { addReaders: 0, sizes: {}, removeReaders: [] }])), caches: Object.fromEntries(report.caches.map(row => [row.identifier, { replicas: row.replica_count, size: '' }])) };
|
|
12
|
+
}
|
|
13
|
+
export function compileCapacityDraft(report: CapacityReport, draft: CapacityDraft): CapacityStep[] {
|
|
14
|
+
if (report.mode !== 'managed') throw new Error('External infrastructure is read-only.');
|
|
15
|
+
const steps: CapacityStep[] = [];
|
|
16
|
+
const add = (step: CapacityStep) => { if (!offered(report, step.action)) throw new Error(`${step.action}: ${actionReason(report, step.action)}`); steps.push(step); };
|
|
17
|
+
const count = (value: number, label: string, min = 0, max = 20) => { if (!Number.isSafeInteger(value) || value < min || value > max) throw new Error(`${label} must be a whole number from ${min} to ${max}.`); };
|
|
18
|
+
const unique = (ids: string[], label: string) => { if (new Set(ids).size !== ids.length) throw new Error(`${label} contains a duplicate target.`); };
|
|
19
|
+
count(draft.addNodes, 'App additions');
|
|
20
|
+
unique(draft.removeNodes, 'Node removals'); unique(draft.terminateNodes, 'Node terminations');
|
|
21
|
+
if (draft.addNodes) {
|
|
22
|
+
if (!report.nodes.inventory_available || !report.nodes.serving_available) throw new Error('Fresh node inventory and serving evidence are required.');
|
|
23
|
+
const source = report.nodes.instances.find(n => n.id === draft.source && n.healthy && n.registered && n.instance_state === 'running' && n.lifecycle_state === 'available');
|
|
24
|
+
if (!source) throw new Error('Choose an explicit healthy source node.');
|
|
25
|
+
if (draft.removeNodes.includes(source.id) || draft.terminateNodes.includes(source.id)) throw new Error('Keep the selected source node while adding nodes.');
|
|
26
|
+
if (draft.subnet && !/^subnet-[A-Za-z0-9]+$/.test(draft.subnet)) throw new Error('Subnet must be a subnet-… identifier.');
|
|
27
|
+
for (let i = 0; i < draft.addNodes; i++) add({ action: 'add_node', source_instance: source.id, ...(draft.subnet ? { subnet_id: draft.subnet } : {}) });
|
|
28
|
+
}
|
|
29
|
+
for (const id of draft.removeNodes) {
|
|
30
|
+
if (draft.terminateNodes.includes(id)) throw new Error('Choose one removal task per node.');
|
|
31
|
+
const node = report.nodes.instances.find(n => n.id === id);
|
|
32
|
+
if (!node) throw new Error('Selected node is absent from the fresh inventory.');
|
|
33
|
+
const reason = nodeRemovalReason(report, node, draft.removeNodes.filter(other => other !== id));
|
|
34
|
+
if (reason) throw new Error(reason);
|
|
35
|
+
add({ action: 'drain_node', resource: id }); add({ action: 'terminate_node', resource: id });
|
|
36
|
+
}
|
|
37
|
+
for (const id of draft.terminateNodes) {
|
|
38
|
+
const node = report.nodes.instances.find(n => n.id === id);
|
|
39
|
+
if (!node) throw new Error('Selected drained node is absent from the fresh inventory.');
|
|
40
|
+
const reason = drainedNodeReason(report, node); if (reason) throw new Error(reason);
|
|
41
|
+
add({ action: 'terminate_node', resource: id });
|
|
42
|
+
}
|
|
43
|
+
for (const [id, d] of Object.entries(draft.databases)) {
|
|
44
|
+
if (!d.addReaders && !d.removeReaders.length && !Object.values(d.sizes).some(Boolean)) continue;
|
|
45
|
+
const row = report.databases.find(r => r.identifier === id);
|
|
46
|
+
if (!row || !report.databases_available || row.blocked_reason) throw new Error(`${id}: ${row?.blocked_reason ?? 'Database inventory unavailable.'}`);
|
|
47
|
+
count(d.addReaders, 'Reader additions'); unique(d.removeReaders, 'Reader removals');
|
|
48
|
+
for (const target of d.removeReaders) {
|
|
49
|
+
const member = row.members.find(m => m.id === target);
|
|
50
|
+
if (!row.readers.includes(target) || !member?.can_remove) throw new Error(`${target}: reader removal is unavailable.`);
|
|
51
|
+
if (d.sizes[target]) throw new Error(`${target}: choose resize or removal.`);
|
|
52
|
+
add({ action: 'remove_reader', resource: target });
|
|
53
|
+
}
|
|
54
|
+
for (let i = 0; i < d.addReaders; i++) add({ action: 'add_reader', resource: id });
|
|
55
|
+
for (const [target, size] of Object.entries(d.sizes)) {
|
|
56
|
+
if (!size) continue;
|
|
57
|
+
const member = row.members.find(m => m.id === target), choice = report.sizes.database.find(s => s.size === size);
|
|
58
|
+
if (!member?.can_resize || !choice) throw new Error(`${target}: choose an available member and curated database size.`);
|
|
59
|
+
if (choice.type !== member.instance_class) add({ action: 'resize_database', resource: target, size, apply_immediately: true });
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
for (const [id, d] of Object.entries(draft.caches)) {
|
|
63
|
+
const row = report.caches.find(r => r.identifier === id);
|
|
64
|
+
if (!row) throw new Error(`${id}: Redis group is absent from the fresh inventory.`);
|
|
65
|
+
const choice = d.size ? report.sizes.cache.find(s => s.size === d.size) : null;
|
|
66
|
+
const resizing = !!choice && choice.type !== row.node_type;
|
|
67
|
+
if (d.size && !choice) throw new Error(`${id}: choose a curated Redis size.`);
|
|
68
|
+
count(d.replicas, 'Redis replicas', row.min_replicas, 100);
|
|
69
|
+
if (d.replicas === row.replica_count && !resizing) continue;
|
|
70
|
+
if (!report.caches_available || row.blocked_reason) throw new Error(`${id}: ${row.blocked_reason ?? 'Redis inventory unavailable.'}`);
|
|
71
|
+
if (d.replicas !== row.replica_count && resizing) throw new Error(`${id}: change size or replica count in one plan. Apply, refresh, then review the other change separately.`);
|
|
72
|
+
if (resizing) add({ action: 'resize_cache', resource: id, size: d.size, apply_immediately: true });
|
|
73
|
+
if (d.replicas !== row.replica_count) add({ action: 'set_cache_replicas', resource: id, count: d.replicas, apply_immediately: true });
|
|
74
|
+
}
|
|
75
|
+
if (steps.length > 20) throw new Error(`A plan supports 20 steps; this draft has ${steps.length}. Reduce the changes.`);
|
|
76
|
+
return steps;
|
|
77
|
+
}
|
|
78
|
+
export function observationAge(value: string | null, now = Date.now()): string {
|
|
79
|
+
const time = value ? Date.parse(value) : NaN;
|
|
80
|
+
if (!Number.isFinite(time)) return 'Observation time unavailable';
|
|
81
|
+
const seconds = Math.max(0, Math.floor((now - time) / 1000));
|
|
82
|
+
return seconds < 60 ? `Observed ${seconds}s ago` : seconds < 3600 ? `Observed ${Math.floor(seconds / 60)}m ago` : `Observed ${Math.floor(seconds / 3600)}h ago`;
|
|
83
|
+
}
|
|
84
|
+
export function completeAllowlist(report: CapacityReport): boolean {
|
|
85
|
+
const e = report.egress;
|
|
86
|
+
return e.available && e.fleet_available && e.addresses_available && e.policy_available && e.enabled && !e.pending_nodes.length;
|
|
87
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
export type FleetFacts = Record<string, unknown>;
|
|
2
|
+
export interface FleetSource {
|
|
3
|
+
status: string;
|
|
4
|
+
observed_at: string | null;
|
|
5
|
+
stale_after: string | null;
|
|
6
|
+
reason: string | null;
|
|
7
|
+
data: FleetFacts;
|
|
8
|
+
}
|
|
9
|
+
export interface FleetDashboard {
|
|
10
|
+
schema_version: 2;
|
|
11
|
+
observed_at: string;
|
|
12
|
+
availability: {
|
|
13
|
+
state: 'ok' | 'down' | 'unknown';
|
|
14
|
+
message: string;
|
|
15
|
+
down: string[];
|
|
16
|
+
};
|
|
17
|
+
attention: {
|
|
18
|
+
message: string | null;
|
|
19
|
+
};
|
|
20
|
+
sources: Record<string, FleetSource>;
|
|
21
|
+
}
|
|
22
|
+
export type CapacityAction = 'add_node' | 'drain_node' | 'terminate_node' | 'add_reader' | 'remove_reader' | 'set_cache_replicas' | 'resize_cache' | 'resize_database';
|
|
23
|
+
export interface CapacityStep {
|
|
24
|
+
action: CapacityAction;
|
|
25
|
+
resource?: string;
|
|
26
|
+
size?: string;
|
|
27
|
+
count?: number;
|
|
28
|
+
apply_immediately?: true;
|
|
29
|
+
source_instance?: string;
|
|
30
|
+
subnet_id?: string;
|
|
31
|
+
}
|
|
32
|
+
export interface CapacityNode {
|
|
33
|
+
id: string;
|
|
34
|
+
name: string;
|
|
35
|
+
state: string;
|
|
36
|
+
instance_state: string | null;
|
|
37
|
+
lifecycle_state: string;
|
|
38
|
+
instance_type: string | null;
|
|
39
|
+
zone: string | null;
|
|
40
|
+
subnet_id: string | null;
|
|
41
|
+
public_ip: string | null;
|
|
42
|
+
healthy: boolean;
|
|
43
|
+
registered: boolean;
|
|
44
|
+
can_drain: boolean;
|
|
45
|
+
self: boolean;
|
|
46
|
+
primary: boolean;
|
|
47
|
+
added_by_capacity: boolean;
|
|
48
|
+
stable_ip: boolean;
|
|
49
|
+
groups: string[];
|
|
50
|
+
}
|
|
51
|
+
export interface DatabaseMember {
|
|
52
|
+
id: string;
|
|
53
|
+
role: string;
|
|
54
|
+
status: string | null;
|
|
55
|
+
lifecycle_state: string;
|
|
56
|
+
instance_class: string | null;
|
|
57
|
+
can_resize: boolean;
|
|
58
|
+
can_remove: boolean;
|
|
59
|
+
}
|
|
60
|
+
export interface CapacityDatabase {
|
|
61
|
+
identifier: string;
|
|
62
|
+
kind: 'aurora' | 'standalone';
|
|
63
|
+
engine: string;
|
|
64
|
+
status: string;
|
|
65
|
+
writer: string | null;
|
|
66
|
+
readers: string[];
|
|
67
|
+
instance_class: string | null;
|
|
68
|
+
writer_instance_class: string | null;
|
|
69
|
+
reader_instance_classes: Record<string, string | null>;
|
|
70
|
+
members: DatabaseMember[];
|
|
71
|
+
blocked_reason: string | null;
|
|
72
|
+
}
|
|
73
|
+
export interface CapacityCache {
|
|
74
|
+
identifier: string;
|
|
75
|
+
status: string;
|
|
76
|
+
node_type: string;
|
|
77
|
+
replica_count: number;
|
|
78
|
+
min_replicas: number;
|
|
79
|
+
resize_impact: 'rolling' | 'downtime';
|
|
80
|
+
blocked_reason: string | null;
|
|
81
|
+
members: {
|
|
82
|
+
id: string;
|
|
83
|
+
role: string;
|
|
84
|
+
status: string;
|
|
85
|
+
lifecycle_state: string;
|
|
86
|
+
}[];
|
|
87
|
+
}
|
|
88
|
+
export interface CapacitySize {
|
|
89
|
+
size: string;
|
|
90
|
+
label: string;
|
|
91
|
+
type: string;
|
|
92
|
+
monthly_usd: number | null;
|
|
93
|
+
}
|
|
94
|
+
export interface CapacityReport {
|
|
95
|
+
schema_version: 2;
|
|
96
|
+
mode: string;
|
|
97
|
+
region: string;
|
|
98
|
+
generated_at: string;
|
|
99
|
+
identity_available: boolean;
|
|
100
|
+
identity: {
|
|
101
|
+
project: string;
|
|
102
|
+
environment: string;
|
|
103
|
+
} | null;
|
|
104
|
+
node_id_pinned: boolean;
|
|
105
|
+
nodes: {
|
|
106
|
+
self: string | null;
|
|
107
|
+
self_check: string;
|
|
108
|
+
serving_available: boolean;
|
|
109
|
+
inventory_available: boolean;
|
|
110
|
+
instances: CapacityNode[];
|
|
111
|
+
};
|
|
112
|
+
databases_available: boolean;
|
|
113
|
+
caches_available: boolean;
|
|
114
|
+
databases: CapacityDatabase[];
|
|
115
|
+
caches: CapacityCache[];
|
|
116
|
+
sizes: {
|
|
117
|
+
database: CapacitySize[];
|
|
118
|
+
cache: CapacitySize[];
|
|
119
|
+
};
|
|
120
|
+
actions: Record<string, {
|
|
121
|
+
offered: boolean;
|
|
122
|
+
blocked_reason: string | null;
|
|
123
|
+
}>;
|
|
124
|
+
warnings: {
|
|
125
|
+
code: string;
|
|
126
|
+
message: string;
|
|
127
|
+
iam_action: string | null;
|
|
128
|
+
}[];
|
|
129
|
+
reader_routing: {
|
|
130
|
+
database: {
|
|
131
|
+
active: boolean;
|
|
132
|
+
host: string | null;
|
|
133
|
+
skip_reason: string | null;
|
|
134
|
+
matches_reader_endpoint: boolean | null;
|
|
135
|
+
};
|
|
136
|
+
redis: {
|
|
137
|
+
active: boolean;
|
|
138
|
+
};
|
|
139
|
+
};
|
|
140
|
+
egress: {
|
|
141
|
+
available: boolean;
|
|
142
|
+
fleet_available: boolean;
|
|
143
|
+
addresses_available: boolean;
|
|
144
|
+
policy_available: boolean;
|
|
145
|
+
enabled: boolean;
|
|
146
|
+
addresses: string[];
|
|
147
|
+
attached: {
|
|
148
|
+
instance: string;
|
|
149
|
+
public_ip: string;
|
|
150
|
+
managed: boolean;
|
|
151
|
+
}[];
|
|
152
|
+
fallback_attached: {
|
|
153
|
+
instance: string;
|
|
154
|
+
public_ip: string;
|
|
155
|
+
managed: boolean;
|
|
156
|
+
}[];
|
|
157
|
+
reserved: {
|
|
158
|
+
public_ip: string;
|
|
159
|
+
}[];
|
|
160
|
+
pending_nodes: string[];
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
export interface CapacityPlanStep {
|
|
164
|
+
index: number;
|
|
165
|
+
action: CapacityAction;
|
|
166
|
+
resource: string;
|
|
167
|
+
description: string;
|
|
168
|
+
kind: string;
|
|
169
|
+
params: FleetFacts;
|
|
170
|
+
warnings: string[];
|
|
171
|
+
monthly_delta_usd: number | null;
|
|
172
|
+
}
|
|
173
|
+
export interface CapacityPlan {
|
|
174
|
+
schema_version: 2;
|
|
175
|
+
id: string;
|
|
176
|
+
actor: number;
|
|
177
|
+
created: number | string;
|
|
178
|
+
expires_at: string;
|
|
179
|
+
expires_in: number;
|
|
180
|
+
steps: CapacityPlanStep[];
|
|
181
|
+
total_monthly_delta_usd: number | null;
|
|
182
|
+
estimate_complete: boolean;
|
|
183
|
+
order_note: string;
|
|
184
|
+
}
|
|
185
|
+
export interface CapacityBatchStep {
|
|
186
|
+
index: number;
|
|
187
|
+
action: CapacityAction;
|
|
188
|
+
resource: string;
|
|
189
|
+
description: string;
|
|
190
|
+
state: string;
|
|
191
|
+
operation: string | null;
|
|
192
|
+
phase: string | null;
|
|
193
|
+
message: string | null;
|
|
194
|
+
error_code: string | null;
|
|
195
|
+
}
|
|
196
|
+
export interface CapacityBatch {
|
|
197
|
+
schema_version: 2;
|
|
198
|
+
id: string;
|
|
199
|
+
plan_id: string;
|
|
200
|
+
actor: number;
|
|
201
|
+
state: string;
|
|
202
|
+
started: number | string;
|
|
203
|
+
current_index: number;
|
|
204
|
+
message: string | null;
|
|
205
|
+
error_code: string | null;
|
|
206
|
+
stalled: boolean;
|
|
207
|
+
steps: CapacityBatchStep[];
|
|
208
|
+
}
|
|
@@ -1,12 +1,14 @@
|
|
|
1
|
-
import { useRef, useState } from 'react';
|
|
1
|
+
import { lazy, Suspense, useRef, useState } from 'react';
|
|
2
2
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
|
3
|
+
import { Link, useLocation } from 'react-router-dom';
|
|
3
4
|
import { useMe } from '../../client/runtime';
|
|
4
5
|
import { modal } from '../../ui';
|
|
5
6
|
import {
|
|
6
|
-
FLEET_ENDPOINT, FLEET_HEALTH_SCOPE, displayFleetValue, fleetChange, fleetIsHealthy, operationFinished,
|
|
7
|
+
FLEET_ENDPOINT, FLEET_HEALTH_SCOPE, isManagedConnectionKey, displayFleetValue, fleetChange, fleetIsHealthy, operationFinished,
|
|
7
8
|
readFleet, readFleetHistory, readFleetOperation, writeFleet,
|
|
8
9
|
type FleetChanges, type FleetEntry, type FleetOperation, type FleetPublication, type FleetReport, type FleetState,
|
|
9
10
|
} from './data';
|
|
11
|
+
const ConnectionPage = lazy(() => import('./SharedValidationConnectionPanel').then(({ SharedValidationConnectionPage }) => ({ default: SharedValidationConnectionPage })));
|
|
10
12
|
|
|
11
13
|
function SettingInput({ entry, disabled }: { entry: FleetEntry; disabled: boolean }) {
|
|
12
14
|
const [mode, setMode] = useState('keep');
|
|
@@ -41,13 +43,14 @@ function SettingInput({ entry, disabled }: { entry: FleetEntry; disabled: boolea
|
|
|
41
43
|
function FleetEditor({ state, busy, onPublish }: { state: FleetState; busy: boolean; onPublish: (changes: FleetChanges) => Promise<void> }) {
|
|
42
44
|
const form = useRef<HTMLFormElement>(null);
|
|
43
45
|
const [error, setError] = useState('');
|
|
44
|
-
const
|
|
46
|
+
const editable = state.entries.filter((entry) => !isManagedConnectionKey(entry.key));
|
|
47
|
+
const sections = [...new Set(editable.map((entry) => entry.section))];
|
|
45
48
|
const submit = async (event: React.FormEvent) => {
|
|
46
49
|
event.preventDefault(); setError('');
|
|
47
50
|
try {
|
|
48
51
|
const fields = new FormData(form.current!);
|
|
49
52
|
const changes: FleetChanges = {};
|
|
50
|
-
for (const entry of
|
|
53
|
+
for (const entry of editable) {
|
|
51
54
|
const change = fleetChange(entry, String(fields.get(`${entry.key}:mode`) ?? 'keep'), String(fields.get(`${entry.key}:value`) ?? ''));
|
|
52
55
|
if (change) changes[entry.key] = change;
|
|
53
56
|
}
|
|
@@ -59,11 +62,11 @@ function FleetEditor({ state, busy, onPublish }: { state: FleetState; busy: bool
|
|
|
59
62
|
<fieldset disabled={busy} style={{ border: 0, padding: 0, margin: 0, minWidth: 0 }}>
|
|
60
63
|
{sections.map((section) => <section key={section} style={{ marginBottom: 20 }}><h2>{section}</h2>
|
|
61
64
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(min(100%, 320px), 1fr))', gap: 12 }}>
|
|
62
|
-
{
|
|
65
|
+
{editable.filter((entry) => entry.section === section).map((entry) => <SettingInput key={entry.key} entry={entry} disabled={busy} />)}
|
|
63
66
|
</div></section>)}
|
|
64
|
-
{!
|
|
67
|
+
{!editable.length && <p>No ordinary settings have been registered and delegated for this application.</p>}
|
|
65
68
|
{error && <p className="form-alert" role="alert">{error}</p>}
|
|
66
|
-
<button className="btn btn-primary" type="submit" disabled={busy || !state.publish_configured || !
|
|
69
|
+
<button className="btn btn-primary" type="submit" disabled={busy || !state.publish_configured || !editable.length}>Publish</button>
|
|
67
70
|
</fieldset>
|
|
68
71
|
</form>;
|
|
69
72
|
}
|
|
@@ -132,7 +135,7 @@ function FleetContent({ userId }: { userId: number }) {
|
|
|
132
135
|
setOperationId(null); setEditorEpoch((value) => value + 1); setNotice('Previous version published as a new revision. Node application and health are still pending.'); await refresh();
|
|
133
136
|
});
|
|
134
137
|
};
|
|
135
|
-
if (!state) return <div className="panel panel-pad"><h1>Fleet Configuration</h1><p role="alert">{stateQuery.error?.message ?? 'Loading configuration…'}</p>
|
|
138
|
+
if (!state) return <div className="panel panel-pad"><h1>Fleet Configuration</h1><p><Link className="btn" to="shared-validation">Shared validation connection</Link></p><p role="alert">{stateQuery.error?.message ?? 'Loading configuration…'}</p>
|
|
136
139
|
{stateQuery.error && <button className="btn" onClick={() => void refresh()}>Retry</button>}</div>;
|
|
137
140
|
const tracked = operationId && operation.data?.revision === state.revision ? operation.data : null;
|
|
138
141
|
const evidence = tracked && (!operationFinished(tracked) || Date.parse(tracked.observed_at ?? '') > Date.parse(state.fleet.observed_at ?? '')) ? tracked : state.fleet;
|
|
@@ -140,6 +143,7 @@ function FleetContent({ userId }: { userId: number }) {
|
|
|
140
143
|
nodes: evidence.nodes.map((node) => ({ ...node, healthy: false })) } : evidence;
|
|
141
144
|
return <div className="page" style={{ minWidth: 0, overflowWrap: 'anywhere' }}><header className="page-header"><div><h1>Fleet Configuration</h1>
|
|
142
145
|
<p className="dim">Publish registered application settings and track their application across your nodes.</p></div></header>
|
|
146
|
+
<section className="panel panel-pad" style={{ marginBottom: 20 }}><h2>Shared validation connection</h2><p>The approved hub profile, origin and protected key form one managed bundle. Use its dedicated identity preview and publication flow.</p><Link className="btn" to="shared-validation">Open shared validation connection</Link></section>
|
|
143
147
|
<div className="panel panel-pad" style={{ marginBottom: 20 }}><p style={{ overflowWrap: 'anywhere' }}><b>Published to S3:</b> {state.published ? state.revision : 'Not yet published'}</p>
|
|
144
148
|
<p className="dim">This request’s node loaded revision: {state.loaded_revision ?? 'Not reported'}</p>
|
|
145
149
|
{!state.publish_configured && <p className="form-alert">Publishing is unavailable until the fleet configuration location, encryption and delegated settings are configured.</p>}
|
|
@@ -164,8 +168,10 @@ function FleetContent({ userId }: { userId: number }) {
|
|
|
164
168
|
}
|
|
165
169
|
|
|
166
170
|
export function FleetConfigurationPage() {
|
|
171
|
+
const location = useLocation();
|
|
167
172
|
const { data: me, isLoading } = useMe();
|
|
168
173
|
if (isLoading) return <p>Checking access…</p>;
|
|
169
174
|
if (me?.is_superuser !== true) return <div className="panel panel-pad">Fleet Configuration requires a superuser account.</div>;
|
|
175
|
+
if (location.pathname.endsWith('/shared-validation')) return <Suspense fallback={<p>Loading protected connection…</p>}><ConnectionPage /></Suspense>;
|
|
170
176
|
return <FleetContent key={me.id} userId={me.id} />;
|
|
171
177
|
}
|