portal-mojo 0.2.20 → 0.2.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/README.md +7 -1
- package/docs/admin-deployments.md +201 -0
- package/docs/admin-dns-certificates.md +3 -3
- package/docs/admin-dns-domains.md +92 -0
- package/docs/admin-dns.md +4 -2
- package/docs/admin-edge.md +200 -196
- package/docs/admin-geolocation.md +100 -0
- package/docs/admin-maintenance.md +133 -0
- package/docs/admin-modals.md +17 -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/client.md +7 -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/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/DomainDetail.tsx +10 -6
- 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 +22 -13
- package/src/admin/dns/SharedValidationDialog.tsx +1 -1
- 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/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 +22 -7
- 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/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/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/client.ts +6 -0
- package/src/client/deployments-mock.ts +375 -0
- package/src/client/domain-operations-mock.ts +37 -0
- package/src/client/geo-provider-mock.ts +82 -0
- package/src/client/maintenance-mock.ts +285 -0
- package/src/client/mock.ts +52 -10
- 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-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/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
|
+
}
|