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,279 @@
|
|
|
1
|
+
/** Mock-only dashboard/capacity contract. Runtime modules never import this file. */
|
|
2
|
+
import { backendGlobalCan, FLEET_DASHBOARD_GRANTS, type BackendGlobalUser } from '../admin/fleet/access';
|
|
3
|
+
import type { CapacityAction, CapacityBatch, CapacityPlan, CapacityReport, CapacityStep, FleetDashboard, FleetFacts } from '../admin/fleet/types';
|
|
4
|
+
export const mockOperationalFleetScenarios = ['healthy', 'down', 'external', 'partial', 'restricted', 'missing-evidence', 'aged-observations', 'inventory-unavailable', 'transitional', 'drained', 'unknown-price', 'stale-plan', 'expired-plan', 'duplicate', 'failed', 'stalled', 'uncertain-apply', 'status-unavailable', 'reauth'] as const;
|
|
5
|
+
export type MockOperationalFleetScenario = typeof mockOperationalFleetScenarios[number];
|
|
6
|
+
export interface MockFleetActor extends BackendGlobalUser {
|
|
7
|
+
id: number;
|
|
8
|
+
credential_kind?: string;
|
|
9
|
+
}
|
|
10
|
+
let scenario: MockOperationalFleetScenario = 'healthy', sequence = 0, version = 0;
|
|
11
|
+
const plans = new Map<string, {
|
|
12
|
+
plan: CapacityPlan;
|
|
13
|
+
version: number;
|
|
14
|
+
}>(), batches = new Map<string, {
|
|
15
|
+
batch: CapacityBatch;
|
|
16
|
+
reads: number;
|
|
17
|
+
applied: boolean;
|
|
18
|
+
}>(), claimed = new Map<string, string>();
|
|
19
|
+
const copy = <T,>(v: T): T => JSON.parse(JSON.stringify(v)) as T;
|
|
20
|
+
const timestamp = () => new Date().toISOString();
|
|
21
|
+
const fail = (error_code: string, code: number, error = error_code, data?: unknown) => ({ status: false, code, error_code, error, ...(data ? { data } : {}) });
|
|
22
|
+
const ok = (data: unknown) => ({ status: true, data: copy(data) });
|
|
23
|
+
let state: CapacityReport;
|
|
24
|
+
export function setMockOperationalFleetScenario(value: MockOperationalFleetScenario) { if (!mockOperationalFleetScenarios.includes(value))
|
|
25
|
+
throw new Error('Unknown operational fleet scenario.'); scenario = value; version++; plans.clear(); batches.clear(); claimed.clear(); state = fixture(); }
|
|
26
|
+
export const getMockOperationalFleetScenario = () => scenario;
|
|
27
|
+
function fixture(): CapacityReport {
|
|
28
|
+
const instances = ['i-mocka', 'i-mockb', 'i-mockc'].map((id, i) => ({ id, name: `app-${i + 1}`, state: 'healthy', instance_state: 'running', lifecycle_state: 'available', instance_type: 'm6i.large', zone: 'us-east-1a', subnet_id: 'subnet-0abc', public_ip: `203.0.113.${10 + i}`, healthy: true, registered: true, can_drain: true, self: i === 0, primary: i === 0, added_by_capacity: i !== 0, stable_ip: true, groups: ['targetgroup/mock-api'] }));
|
|
29
|
+
if (scenario === 'drained') {
|
|
30
|
+
Object.assign(instances[2]!, { healthy: false, registered: false, can_drain: false, state: 'unregistered', groups: [] });
|
|
31
|
+
}
|
|
32
|
+
if (scenario === 'transitional') {
|
|
33
|
+
Object.assign(instances[2]!, { healthy: false, can_drain: false, state: 'draining', lifecycle_state: 'deleting' });
|
|
34
|
+
}
|
|
35
|
+
const classes = { 'db-reader-a': 'db.t4g.medium', 'db-reader-b': 'db.r6g.xlarge' };
|
|
36
|
+
const database = { identifier: 'mock-database', kind: 'aurora' as const, engine: 'aurora-postgresql', status: 'available', writer: 'db-writer', readers: ['db-reader-a', 'db-reader-b'], instance_class: null, writer_instance_class: 'db.r6g.large', reader_instance_classes: classes, blocked_reason: scenario === 'transitional' ? 'resource_transitioning' : null, members: [{ id: 'db-writer', role: 'writer', status: 'available', lifecycle_state: 'available', instance_class: 'db.r6g.large', can_resize: true, can_remove: false }, ...Object.entries(classes).map(([id, instance_class], i) => ({ id, instance_class, role: 'reader', status: scenario === 'transitional' && i === 1 ? 'creating' : 'available', lifecycle_state: scenario === 'transitional' && i === 1 ? 'creating' : 'available', can_resize: scenario !== 'transitional', can_remove: scenario !== 'transitional' }))] };
|
|
37
|
+
const actions = Object.fromEntries(['add_node', 'drain_node', 'terminate_node', 'add_reader', 'remove_reader', 'set_cache_replicas', 'resize_cache', 'resize_database'].map(a => [a, { offered: scenario !== 'external' && scenario !== 'partial', blocked_reason: scenario === 'external' ? 'infrastructure_external' : scenario === 'partial' ? 'identity_unavailable' : null }]));
|
|
38
|
+
return { schema_version: 2, mode: scenario === 'external' ? 'external' : 'managed', region: 'us-east-1', generated_at: timestamp(), identity_available: scenario !== 'partial', identity: scenario === 'partial' ? null : { project: 'mock', environment: 'production' }, node_id_pinned: false, nodes: { self: 'i-mocka', self_check: 'matched', serving_available: true, inventory_available: scenario !== 'partial', instances }, databases_available: scenario !== 'partial', caches_available: true, databases: scenario === 'partial' ? [] : [database], caches: [{ identifier: 'mock-redis', status: 'available', node_type: 'cache.t4g.micro', replica_count: 1, min_replicas: 1, resize_impact: 'rolling', blocked_reason: null, members: [{ id: 'redis-primary', role: 'primary', status: 'available', lifecycle_state: 'available' }, { id: 'redis-replica', role: 'replica', status: 'available', lifecycle_state: 'available' }] }], sizes: { database: [{ size: 'small', label: 'Small', type: 'db.t4g.medium', monthly_usd: 50 }, { size: 'medium', label: 'Medium', type: 'db.r6g.large', monthly_usd: 175 }, { size: 'large', label: 'Large', type: 'db.r6g.xlarge', monthly_usd: 350 }], cache: [{ size: 'small', label: 'Small', type: 'cache.t4g.micro', monthly_usd: 12 }, { size: 'medium', label: 'Medium', type: 'cache.t4g.medium', monthly_usd: scenario === 'unknown-price' ? null : 50 }, { size: 'large', label: 'Large', type: 'cache.r7g.large', monthly_usd: 120 }] }, actions, warnings: scenario === 'partial' ? [{ code: 'databases', iam_action: 'rds:DescribeDBClusters', message: 'Database inventory unavailable; do not infer an empty estate.' }] : [], reader_routing: { database: { active: true, host: 'mock-reader.example', skip_reason: null, matches_reader_endpoint: true }, redis: { active: false } }, egress: { available: scenario !== 'partial', fleet_available: true, addresses_available: scenario !== 'partial', policy_available: scenario !== 'partial', enabled: true, addresses: scenario === 'partial' ? [] : ['203.0.113.10', '203.0.113.11'], attached: scenario === 'partial' ? [] : [{ instance: 'i-mocka', public_ip: '203.0.113.10', managed: true }], fallback_attached: [], reserved: [{ public_ip: '203.0.113.20' }], pending_nodes: [] } };
|
|
39
|
+
}
|
|
40
|
+
setMockOperationalFleetScenario('healthy');
|
|
41
|
+
function dashboard(actor: MockFleetActor): FleetDashboard {
|
|
42
|
+
const observed_at = timestamp();
|
|
43
|
+
const source = (data: FleetFacts, status = 'healthy') => ({ status, observed_at, stale_after: new Date(Date.now() + 120000).toISOString(), reason: null, data });
|
|
44
|
+
const sources: FleetDashboard['sources'] = { load_balancer: source({ configured: true, registered: state.nodes.instances.filter(n=>n.registered).length, healthy: scenario === 'down' ? 0 : state.nodes.instances.filter(n=>n.healthy).length, balancer: { name: 'mock-api-nlb', type: 'network', scheme: 'internet-facing', state: 'active', elastic_ips: ['198.51.100.10'], addresses: [{ ip: '198.51.100.10' }] }, elastic_ip_missing: false }, scenario === 'down' ? 'unhealthy' : 'healthy'), compute: source({ total: state.nodes.instances.length, up: state.nodes.instances.filter(n=>n.healthy).length, source: 'target_group', instances: state.nodes.instances.map(n => ({ id: n.id, name: n.name, healthy: n.healthy })) }), database: source({ reachable: true, engine: 'PostgreSQL', version: '16.4', vendor: 'postgresql', drift: null }), cache: source({ reachable: true, engine: 'Redis', version: '7.1', memory_used: '42M' }), certificates: source({ total: 4, active: 4, failing: 0, expiring_within_30_days: 1, soonest_renew: observed_at, soonest_expiry: new Date(Date.now() + 25 * 86400000).toISOString() }), public_api: source({ configured: true, node_version: '2026.10.21', probe: { ok: true, http_status: 200, version: '2026.10.21' } }), framework: source({ installed: '1.2.100', latest: '1.2.101', update_available: true, pin: { mode: 'automatic' } }), last_deployment: source({ items: [{ id: 'mock-deployment', sha: '6bc254abcdef0123456789', status: 'converged', created: observed_at, finished: observed_at, actor: 'github-actions' }] }), jobs: source(scenario === 'missing-evidence' ? {} : { scheduler_active: true, jobs: { pending: 2, running: 1, failed: 3 }, failed_recent: 1 }, 'degraded'), sanity: source(scenario === 'missing-evidence' ? {} : { checks: [{ name: 'Database', ok: true }, { name: 'Redis', ok: true }] }), incidents: source({ open: 2, oldest_created: observed_at, oldest_age_days: 2 }, 'degraded'), tickets: source({ open: 0, oldest_created: null, oldest_age_days: null }), sms: source({ configured: false }, 'unconfigured'), email: source({ configured: true, domains: 2, sendable_domains: 2, default_sender: 'hello@example.com' }) };
|
|
45
|
+
for (const [key, row] of Object.entries(sources)) {
|
|
46
|
+
const grants = key === 'email' ? ['manage_aws', 'comms', 'admin'] : ['incidents', 'tickets'].includes(key) ? ['view_security', 'manage_security', 'security', 'admin'] : ['view_platform', 'manage_platform', 'admin'];
|
|
47
|
+
if (scenario === 'restricted' || !backendGlobalCan(actor, grants))
|
|
48
|
+
sources[key] = { ...row, status: 'permission_denied', observed_at: null, stale_after: null, reason: 'permission_required', data: {} };
|
|
49
|
+
}
|
|
50
|
+
if (scenario === 'partial')
|
|
51
|
+
sources.compute = { ...sources.compute!, status: 'unknown', reason: 'provider_denied', data: {} };
|
|
52
|
+
if (scenario === 'aged-observations')
|
|
53
|
+
for (const row of Object.values(sources)) {
|
|
54
|
+
if (row.observed_at) row.observed_at = new Date(Date.now() - 15 * 60_000).toISOString();
|
|
55
|
+
if (row.stale_after) row.stale_after = new Date(Date.now() - 13 * 60_000).toISOString();
|
|
56
|
+
}
|
|
57
|
+
return { schema_version: 2, observed_at, availability: { state: scenario === 'down' ? 'down' : scenario === 'restricted' || scenario === 'partial' ? 'unknown' : 'ok', message: scenario === 'down' ? 'Load balancer has no healthy targets' : scenario === 'restricted' || scenario === 'partial' ? 'Availability cannot be confirmed' : 'The serving fleet is available', down: scenario === 'down' ? ['load_balancer'] : [] }, attention: { message: 'Recent job failures and open incidents need review.' }, sources };
|
|
58
|
+
}
|
|
59
|
+
function validateSteps(steps: unknown): CapacityStep[] {
|
|
60
|
+
if (!Array.isArray(steps) || !steps.length || steps.length > 20)
|
|
61
|
+
throw new Error('A plan requires 1–20 steps.');
|
|
62
|
+
const normalized: CapacityStep[] = [];
|
|
63
|
+
const healthy = state.nodes.instances.filter(n => n.healthy);
|
|
64
|
+
const drained = new Set<string>();
|
|
65
|
+
for (const raw of steps) {
|
|
66
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw))
|
|
67
|
+
throw new Error('Invalid step.');
|
|
68
|
+
const s = raw as CapacityStep;
|
|
69
|
+
if (!state.actions[s.action]?.offered)
|
|
70
|
+
throw new Error('Action not offered.');
|
|
71
|
+
if (s.action !== 'add_node' && (typeof s.resource !== 'string' || !s.resource))
|
|
72
|
+
throw new Error('Resource required.');
|
|
73
|
+
if (s.action !== 'add_node' && (s.source_instance != null || s.subnet_id != null))
|
|
74
|
+
throw new Error('Placement only belongs to Add Node.');
|
|
75
|
+
if (s.action === 'add_node') {
|
|
76
|
+
if (s.source_instance && !state.nodes.instances.some(n => n.id === s.source_instance && n.healthy))
|
|
77
|
+
throw new Error('Source must be healthy.');
|
|
78
|
+
if (s.subnet_id && !/^subnet-[A-Za-z0-9]+$/.test(s.subnet_id))
|
|
79
|
+
throw new Error('Subnet identifier invalid.');
|
|
80
|
+
}
|
|
81
|
+
if (s.action === 'drain_node' || s.action === 'terminate_node') {
|
|
82
|
+
const n = state.nodes.instances.find(n => n.id === s.resource);
|
|
83
|
+
if (!n || n.self)
|
|
84
|
+
throw new Error('Node cannot be removed.');
|
|
85
|
+
if (s.action === 'drain_node') {
|
|
86
|
+
if (!n.can_drain)
|
|
87
|
+
throw new Error('Node cannot drain.');
|
|
88
|
+
drained.add(n.id);
|
|
89
|
+
if (healthy.filter(n => !drained.has(n.id)).length < 1)
|
|
90
|
+
throw new Error('Keep one healthy target.');
|
|
91
|
+
}
|
|
92
|
+
else if (!drained.has(n.id) && (n.registered || n.healthy || n.state !== 'unregistered'))
|
|
93
|
+
throw new Error('Drain completion required.');
|
|
94
|
+
}
|
|
95
|
+
if (s.action === 'resize_database' || s.action === 'resize_cache') {
|
|
96
|
+
const ladder = state.sizes[s.action === 'resize_cache' ? 'cache' : 'database'];
|
|
97
|
+
if (s.apply_immediately !== true || !ladder.some(r => r.size === s.size))
|
|
98
|
+
throw new Error('Curated size and apply_immediately required.');
|
|
99
|
+
if (s.action === 'resize_database' && !state.databases.some(d => !d.blocked_reason && d.members.some(m => m.id === s.resource && m.can_resize)))
|
|
100
|
+
throw new Error('Database member cannot resize.');
|
|
101
|
+
}
|
|
102
|
+
if (s.action === 'set_cache_replicas') {
|
|
103
|
+
const c = state.caches.find(c => c.identifier === s.resource);
|
|
104
|
+
if (!c || !Number.isSafeInteger(s.count) || s.count! < c.min_replicas || s.apply_immediately !== true)
|
|
105
|
+
throw new Error('Invalid replica count.');
|
|
106
|
+
}
|
|
107
|
+
if (s.action === 'add_reader' && !state.databases.some(d => d.identifier === s.resource && !d.blocked_reason))
|
|
108
|
+
throw new Error('Reader source unavailable.');
|
|
109
|
+
if (s.action === 'remove_reader' && !state.databases.some(d => !d.blocked_reason && d.members.some(m => m.id === s.resource && m.role === 'reader' && m.can_remove)))
|
|
110
|
+
throw new Error('Reader removal unavailable.');
|
|
111
|
+
if (normalized.some(p => p.action === s.action && p.resource === s.resource) && !['add_node', 'add_reader'].includes(s.action))
|
|
112
|
+
throw new Error('Duplicate step.');
|
|
113
|
+
normalized.push(copy(s));
|
|
114
|
+
}
|
|
115
|
+
return normalized;
|
|
116
|
+
}
|
|
117
|
+
function serverPlan(actor: MockFleetActor, raw: unknown): CapacityPlan {
|
|
118
|
+
const submitted = validateSteps(raw);
|
|
119
|
+
const rank: Record<CapacityAction, number> = { add_node: 0, add_reader: 1, set_cache_replicas: 2, resize_cache: 3, resize_database: 4, drain_node: 7, terminate_node: 8, remove_reader: 6 };
|
|
120
|
+
const steps = submitted.map(s => { const { action, resource = '', ...params } = s; return { index: 0, action, resource, params, kind: ['drain_node', 'terminate_node', 'remove_reader'].includes(action) || (action === 'set_cache_replicas' && Number(params.count) < (state.caches.find(c => c.identifier === resource)?.replica_count ?? 0)) ? 'remove' : 'add', description: action === 'add_node' ? 'Add and prove a new app node' : `${action.replaceAll('_', ' ')} ${resource}`, warnings: action === 'resize_database' ? ['Writer resize can cause minutes offline; reader resize pauses that reader.'] : action === 'add_reader' ? ['New readers inherit the current source class; refresh concrete IDs before resizing.'] : action === 'resize_cache' ? ['Group-wide resize interrupts the primary briefly; downsizing may evict data.'] : action === 'terminate_node' ? ['Node termination cannot be undone.'] : [], monthly_delta_usd: scenario === 'unknown-price' ? null : action === 'add_node' ? 70 : action === 'add_reader' ? 175 : action === 'terminate_node' ? -70 : 10 }; }).sort((a, b) => (a.action === 'set_cache_replicas' && a.kind === 'remove' ? 5 : rank[a.action]) - (b.action === 'set_cache_replicas' && b.kind === 'remove' ? 5 : rank[b.action]));
|
|
121
|
+
// Keep each termination immediately after its own drain, as the service does.
|
|
122
|
+
for (let i = 0; i < steps.length; i++) {
|
|
123
|
+
const s = steps[i]!;
|
|
124
|
+
if (s.action !== 'drain_node')
|
|
125
|
+
continue;
|
|
126
|
+
const pos = steps.findIndex(t => t.action === 'terminate_node' && t.resource === s.resource);
|
|
127
|
+
if (pos >= 0) {
|
|
128
|
+
const [term] = steps.splice(pos, 1);
|
|
129
|
+
steps.splice(i + 1, 0, term!);
|
|
130
|
+
i++;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
steps.forEach((s, i) => s.index = i);
|
|
134
|
+
return { schema_version: 2, id: `mock-plan-${++sequence}`, actor: actor.id, created: timestamp(), expires_at: new Date(Date.now() + (scenario === 'expired-plan' ? -1 : 300000)).toISOString(), expires_in: 300, steps, total_monthly_delta_usd: scenario === 'unknown-price' ? null : steps.reduce((n, s) => n + (s.monthly_delta_usd ?? 0), 0), estimate_complete: scenario !== 'unknown-price', order_note: 'Add and prove capacity before removing it; each node drains before termination.' };
|
|
135
|
+
}
|
|
136
|
+
function applyInventory(batch: CapacityBatch) { for (const s of batch.steps.filter(s => s.state === 'done')) {
|
|
137
|
+
const p = plans.get(batch.plan_id)?.plan.steps.find(p => p.index === s.index)?.params ?? {};
|
|
138
|
+
if (s.action === 'drain_node') {
|
|
139
|
+
const n = state.nodes.instances.find(n => n.id === s.resource);
|
|
140
|
+
if (n)
|
|
141
|
+
Object.assign(n, { state: 'unregistered', healthy: false, registered: false, can_drain: false, groups: [] });
|
|
142
|
+
}
|
|
143
|
+
if (s.action === 'terminate_node')
|
|
144
|
+
state.nodes.instances = state.nodes.instances.filter(n => n.id !== s.resource);
|
|
145
|
+
if (s.action === 'add_node') {
|
|
146
|
+
const source = state.nodes.instances.find(n => n.id === p.source_instance) ?? state.nodes.instances[0];
|
|
147
|
+
if (source)
|
|
148
|
+
state.nodes.instances.push({ ...copy(source), id: `i-added-${++sequence}`, name: 'New app node', self: false, primary: false, added_by_capacity: true });
|
|
149
|
+
}
|
|
150
|
+
if (s.action === 'add_reader') {
|
|
151
|
+
const d = state.databases.find(d => d.identifier === s.resource);
|
|
152
|
+
if (d) {
|
|
153
|
+
const id = `db-added-${++sequence}`, instance_class = d.writer_instance_class ?? d.instance_class ?? 'unknown';
|
|
154
|
+
d.readers.push(id);
|
|
155
|
+
d.reader_instance_classes[id] = instance_class;
|
|
156
|
+
d.members.push({ id, role: 'reader', status: 'available', lifecycle_state: 'available', instance_class, can_remove: true, can_resize: true });
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
if (s.action === 'remove_reader') {
|
|
160
|
+
for (const d of state.databases) {
|
|
161
|
+
d.readers = d.readers.filter(id => id !== s.resource);
|
|
162
|
+
d.members = d.members.filter(m => m.id !== s.resource);
|
|
163
|
+
delete d.reader_instance_classes[s.resource];
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
if (s.action === 'resize_database') {
|
|
167
|
+
const size = state.sizes.database.find(r => r.size === p.size);
|
|
168
|
+
if (size)
|
|
169
|
+
for (const d of state.databases) {
|
|
170
|
+
const m = d.members.find(m => m.id === s.resource);
|
|
171
|
+
if (m) {
|
|
172
|
+
m.instance_class = size.type;
|
|
173
|
+
if (m.role === 'writer')
|
|
174
|
+
d.writer_instance_class = size.type;
|
|
175
|
+
else
|
|
176
|
+
d.reader_instance_classes[m.id] = size.type;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
if (s.action === 'resize_cache' || s.action === 'set_cache_replicas') {
|
|
181
|
+
const c = state.caches.find(c => c.identifier === s.resource);
|
|
182
|
+
if (c) {
|
|
183
|
+
if (s.action === 'set_cache_replicas')
|
|
184
|
+
c.replica_count = Number(p.count);
|
|
185
|
+
else
|
|
186
|
+
c.node_type = state.sizes.cache.find(r => r.size === p.size)?.type ?? c.node_type;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
} state.generated_at = timestamp(); version++; }
|
|
190
|
+
/** Return undefined for unowned paths. Actor and auth_time must come from the central mock's validated bearer. */
|
|
191
|
+
export function handleOperationalFleetMock(path: string, method: string, body: Record<string, unknown> | undefined, params: Record<string, unknown> | undefined, actor: MockFleetActor | null | undefined, authTime?: number): unknown | undefined {
|
|
192
|
+
if (path !== '/api/account/admin/dashboard' && !path.startsWith('/api/aws/capacity'))
|
|
193
|
+
return undefined;
|
|
194
|
+
if (!actor)
|
|
195
|
+
return fail('authentication_required', 401);
|
|
196
|
+
if (actor.credential_kind && ['api_key', 'group_scoped_token'].includes(actor.credential_kind))
|
|
197
|
+
return fail('permission_denied', 403);
|
|
198
|
+
if (path === '/api/account/admin/dashboard')
|
|
199
|
+
return method === 'GET' ? (backendGlobalCan(actor, FLEET_DASHBOARD_GRANTS) ? ok(dashboard(actor)) : fail('permission_denied', 403)) : fail('not_found', 404);
|
|
200
|
+
if (!backendGlobalCan(actor, 'manage_aws'))
|
|
201
|
+
return fail('permission_denied', 403, 'Global manage_aws required.');
|
|
202
|
+
if (method === 'GET' && path === '/api/aws/capacity') {
|
|
203
|
+
if (scenario === 'inventory-unavailable')
|
|
204
|
+
return fail('provider_unavailable', 503, 'Capacity inventory is temporarily unavailable.');
|
|
205
|
+
state.generated_at = timestamp();
|
|
206
|
+
return ok(state);
|
|
207
|
+
}
|
|
208
|
+
if (method === 'GET' && path === '/api/aws/capacity/status') {
|
|
209
|
+
if (scenario === 'status-unavailable')
|
|
210
|
+
return fail('provider_unavailable', 503);
|
|
211
|
+
const row = batches.get(String(params?.batch ?? ''));
|
|
212
|
+
if (!row)
|
|
213
|
+
return fail('batch_not_found', 404);
|
|
214
|
+
if (row.batch.actor !== actor.id)
|
|
215
|
+
return fail('permission_denied', 403);
|
|
216
|
+
row.reads++;
|
|
217
|
+
if (scenario === 'stalled') {
|
|
218
|
+
row.batch.stalled = true;
|
|
219
|
+
row.batch.message = 'Batch stalled: inspect the jobs runner and cleanup channel.';
|
|
220
|
+
}
|
|
221
|
+
else if (row.reads >= 3 && !row.applied) {
|
|
222
|
+
row.batch.state = scenario === 'failed' ? 'failed' : 'done';
|
|
223
|
+
row.batch.message = scenario === 'failed' ? 'A step failed; completed changes remain applied.' : 'Every capacity step completed and was observed.';
|
|
224
|
+
row.batch.error_code = scenario === 'failed' ? 'terminate_failed' : null;
|
|
225
|
+
row.batch.steps.forEach((s, i) => { s.state = scenario === 'failed' ? (i === 0 ? 'done' : i === 1 ? 'failed' : 'not_attempted') : 'done'; s.phase = s.state === 'done' ? 'observed' : null; s.error_code = s.state === 'failed' ? 'terminate_failed' : null; s.operation = `mock-operation-${row.batch.id}-${i}`; });
|
|
226
|
+
applyInventory(row.batch);
|
|
227
|
+
row.applied = true;
|
|
228
|
+
}
|
|
229
|
+
else if (row.batch.state === 'running') {
|
|
230
|
+
row.batch.steps[0]!.state = 'running';
|
|
231
|
+
row.batch.steps[0]!.phase = 'settling';
|
|
232
|
+
row.batch.steps[0]!.operation = `mock-operation-${row.batch.id}-0`;
|
|
233
|
+
}
|
|
234
|
+
return ok(row.batch);
|
|
235
|
+
}
|
|
236
|
+
if (method !== 'POST')
|
|
237
|
+
return fail('not_found', 404);
|
|
238
|
+
if (state.mode === 'external')
|
|
239
|
+
return fail('infrastructure_external', 403);
|
|
240
|
+
if (actor.is_superuser !== true)
|
|
241
|
+
return fail('superuser_required', 403);
|
|
242
|
+
if (scenario === 'reauth' || typeof authTime !== 'number' || Date.now() / 1000 - authTime > 600)
|
|
243
|
+
return fail('reauth_required', 440, 'reauth_required');
|
|
244
|
+
if (path === '/api/aws/capacity/plan') {
|
|
245
|
+
try {
|
|
246
|
+
const plan = serverPlan(actor, body?.steps);
|
|
247
|
+
plans.set(plan.id, { plan, version });
|
|
248
|
+
return ok(plan);
|
|
249
|
+
}
|
|
250
|
+
catch (e) {
|
|
251
|
+
return fail('invalid_request', 400, e instanceof Error ? e.message : 'Invalid plan.');
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
if (path === '/api/aws/capacity/plan/apply') {
|
|
255
|
+
if (!body || Object.keys(body).length !== 1 || typeof body.plan_id !== 'string')
|
|
256
|
+
return fail('invalid_request', 400, 'Submit only plan_id.');
|
|
257
|
+
const stored = plans.get(body.plan_id);
|
|
258
|
+
if (!stored || Date.parse(stored.plan.expires_at) <= Date.now())
|
|
259
|
+
return fail('plan_not_found', 404);
|
|
260
|
+
if (stored.plan.actor !== actor.id)
|
|
261
|
+
return fail('permission_denied', 403);
|
|
262
|
+
if (scenario === 'stale-plan' || stored.version !== version)
|
|
263
|
+
return fail('plan_stale', 409);
|
|
264
|
+
const prior = claimed.get(body.plan_id);
|
|
265
|
+
if (prior)
|
|
266
|
+
return fail('plan_already_applied', 409, 'Plan already applied.', { batch: prior });
|
|
267
|
+
const batch: CapacityBatch = { schema_version: 2, id: `mock-batch-${++sequence}`, plan_id: body.plan_id, actor: actor.id, state: 'running', started: timestamp(), current_index: 0, message: 'Requested; waiting for provider observation.', error_code: null, stalled: false, steps: stored.plan.steps.map(s => ({ index: s.index, action: s.action, resource: s.resource, description: s.description, state: 'pending', operation: null, phase: null, message: null, error_code: null })) };
|
|
268
|
+
claimed.set(body.plan_id, batch.id);
|
|
269
|
+
batches.set(batch.id, { batch, reads: 0, applied: false });
|
|
270
|
+
if (scenario === 'duplicate')
|
|
271
|
+
return fail('plan_already_applied', 409, 'Plan already applied.', { batch: batch.id });
|
|
272
|
+
if (scenario === 'uncertain-apply')
|
|
273
|
+
throw new TypeError('Mock response lost after batch submission. Inspect mock server batch records.');
|
|
274
|
+
return ok(batch);
|
|
275
|
+
}
|
|
276
|
+
return fail('not_found', 404);
|
|
277
|
+
}
|
|
278
|
+
/** Test/debug observation only: IDs, never an alternate apply path. */
|
|
279
|
+
export const getMockOperationalFleetBatchIds = () => [...batches.keys()];
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/** Lazy central bridge for the operations contracts; never imported by runtime entrypoints. */
|
|
2
|
+
import { backendGlobalCan, FLEET_DASHBOARD_GRANTS } from '../admin/fleet/access';
|
|
3
|
+
import type { User } from './types';
|
|
4
|
+
interface OperationsMockContext {
|
|
5
|
+
user?: User;
|
|
6
|
+
credentialKind?: unknown;
|
|
7
|
+
authTime?: number;
|
|
8
|
+
origin?: string;
|
|
9
|
+
}
|
|
10
|
+
interface OperationsMockOptions { method?: string; body?: Record<string, unknown>; params?: Record<string, unknown>; headers?: Record<string, string> }
|
|
11
|
+
const denied = (code = 403) => ({ status: false, error: 'permission denied', error_code: code });
|
|
12
|
+
const confined = (kind: unknown) => typeof kind === 'string' && ['api_key', 'group_token', 'group_scoped_token'].includes(kind);
|
|
13
|
+
|
|
14
|
+
export async function operationsMock(path: string, options: OperationsMockOptions, context: OperationsMockContext): Promise<unknown | undefined> {
|
|
15
|
+
const method = (options.method ?? 'GET').toUpperCase(), user = context.user;
|
|
16
|
+
const deploymentOwned = path === '/api/account/admin/platform' && options.params?.sections === 'deployments,api'
|
|
17
|
+
|| path.startsWith('/api/account/admin/platform/deploy/') || path.startsWith('/api/edge/webapp/onboarding/')
|
|
18
|
+
|| ['/api/edge/webapp/summaries', '/api/edge/webapp/summary', '/api/edge/webapp/key_status', '/api/edge/webapp/link_key', '/api/edge/webapp/revoke_key', '/api/edge/webapp/rollback', '/api/edge/webapp/health'].includes(path)
|
|
19
|
+
|| /^\/api\/edge\/webapp\/\d+$/.test(path) || /^\/api\/edge\/release\/deployment\/\d+$/.test(path)
|
|
20
|
+
|| path === '/api/edge/release' && (method === 'POST' || options.params?.webapp !== undefined)
|
|
21
|
+
|| path === '/api/edge/release/complete' || path === '/api/edge/deployment' && options.params?.webapp !== undefined;
|
|
22
|
+
const maintenanceOwned = path === '/api/account/admin/advanced/settings' && Object.hasOwn(options.body ?? {}, 'framework_pin') || path === '/api/account/admin/platform' && options.params?.sections === 'deployments';
|
|
23
|
+
const owned = maintenanceOwned || deploymentOwned || ['/api/account/admin/bootstrap', '/api/account/admin/settings', '/api/account/admin/dashboard', '/api/account/admin/setup/readiness', '/api/aws/capacity', '/api/aws/capacity/status', '/api/aws/capacity/plan', '/api/aws/capacity/plan/apply', '/api/aws/maintenance/versions', '/api/aws/maintenance/status', '/api/aws/maintenance/apply', '/api/account/admin/platform/framework', '/api/account/admin/platform/framework/update'].includes(path);
|
|
24
|
+
if (!owned) return undefined;
|
|
25
|
+
if (!user || user.is_active !== true) return denied(401);
|
|
26
|
+
// Mock attribution comes from the validated mock bearer. Per-user UserAPIKey
|
|
27
|
+
// is deliberately allowed by these ordinary operator decorators.
|
|
28
|
+
const keyBacked = confined(context.credentialKind);
|
|
29
|
+
if (keyBacked) return denied();
|
|
30
|
+
if (path === '/api/account/admin/bootstrap') {
|
|
31
|
+
if (method !== 'GET') return { status: false, error: 'Method not allowed', error_code: 405 };
|
|
32
|
+
if (!backendGlobalCan(user, FLEET_DASHBOARD_GRANTS)) return denied();
|
|
33
|
+
const { getMockOperationalFleetScenario } = await import('./operational-fleet-mock');
|
|
34
|
+
const { readMaintenanceBootstrapFacts } = await import('./maintenance-mock');
|
|
35
|
+
const maintenance = readMaintenanceBootstrapFacts();
|
|
36
|
+
if (maintenance.bootstrap === 'denied') return denied();
|
|
37
|
+
const malformed = maintenance.bootstrap === 'malformed';
|
|
38
|
+
const { readDeploymentBootstrapFacts } = await import('./deployments-mock');
|
|
39
|
+
const deployment = readDeploymentBootstrapFacts(user);
|
|
40
|
+
const { readServingBootstrapFacts, recordServingBootstrap } = await import('./serving-mock');
|
|
41
|
+
const serving = readServingBootstrapFacts();
|
|
42
|
+
if (serving.bootstrap === 'denied') return denied();
|
|
43
|
+
recordServingBootstrap(user.id, context.authTime);
|
|
44
|
+
const mode = maintenance.mode === 'external' || getMockOperationalFleetScenario() === 'external' ? 'external' : 'managed';
|
|
45
|
+
// One composer owns this response. Later domain facts are assigned by
|
|
46
|
+
// explicit nested field ownership rather than whole-object overwrites.
|
|
47
|
+
return { status: true, data: {
|
|
48
|
+
admin_path: '/admin/', user: { id: serving.mismatch ? user.id + 1 : user.id, is_superuser: user.is_superuser === true },
|
|
49
|
+
capabilities: { setup: user.is_superuser === true, manage_webapps: deployment.capabilities.manage_webapps }, webapp_groups: deployment.webapp_groups, can_create_webapp_group: deployment.can_create_webapp_group, ...(serving.edge !== undefined ? { edge: serving.edge } : {}), infrastructure: malformed ? {} : { mode },
|
|
50
|
+
features: {
|
|
51
|
+
platform: { enabled: true, capabilities: { setup: user.is_superuser === true, view: backendGlobalCan(user, ['view_platform', 'manage_platform', 'admin']), capacity: backendGlobalCan(user, 'manage_aws'), ...(!malformed ? { infrastructure_managed: mode === 'managed' } : {}) } },
|
|
52
|
+
webapps: { enabled: deployment.features.webapps.enabled },
|
|
53
|
+
settings: { enabled: true, capabilities: { owner_edit: user.is_superuser === true } },
|
|
54
|
+
},
|
|
55
|
+
} };
|
|
56
|
+
}
|
|
57
|
+
if (path === '/api/account/admin/setup/readiness') {
|
|
58
|
+
const { servingReadinessMock } = await import('./serving-mock');
|
|
59
|
+
return servingReadinessMock(method, options.params, user.id, user.is_superuser === true && backendGlobalCan(user, FLEET_DASHBOARD_GRANTS), context.authTime);
|
|
60
|
+
}
|
|
61
|
+
if (path === '/api/account/admin/settings') {
|
|
62
|
+
if (user.is_superuser !== true) return denied();
|
|
63
|
+
if (method === 'POST') {
|
|
64
|
+
if (context.origin && context.origin !== globalThis.location?.origin) return denied();
|
|
65
|
+
if (typeof context.authTime !== 'number' || !Number.isFinite(context.authTime) || context.authTime > Date.now() / 1000 || Date.now() / 1000 - context.authTime > 600) return { status: false, error: 'reauth_required', error_code: 440 };
|
|
66
|
+
}
|
|
67
|
+
const { geoProviderMock } = await import('./geo-provider-mock');
|
|
68
|
+
return geoProviderMock(path, method, options.body, true) ?? { status: false, error: 'Unsupported provider topic', error_code: 400 };
|
|
69
|
+
}
|
|
70
|
+
if (maintenanceOwned || path.startsWith('/api/aws/maintenance/') || path === '/api/account/admin/platform/framework' || path === '/api/account/admin/platform/framework/update') {
|
|
71
|
+
const { maintenanceMock } = await import('./maintenance-mock');
|
|
72
|
+
const age = typeof context.authTime === 'number' ? Date.now() / 1000 - context.authTime : NaN;
|
|
73
|
+
return maintenanceMock(path, method, options.body, options.params, { user, keyBacked, fresh: Number.isFinite(age) && age >= 0 && age <= 600 });
|
|
74
|
+
}
|
|
75
|
+
if (path === '/api/account/admin/dashboard' || path.startsWith('/api/aws/capacity')) {
|
|
76
|
+
const { handleOperationalFleetMock } = await import('./operational-fleet-mock');
|
|
77
|
+
return handleOperationalFleetMock(path, method, options.body, options.params, user, context.authTime);
|
|
78
|
+
}
|
|
79
|
+
if (path === '/api/account/admin/platform' || path.startsWith('/api/account/admin/platform/deploy/') || path.startsWith('/api/edge/webapp') || path.startsWith('/api/edge/release') || path === '/api/edge/deployment') {
|
|
80
|
+
const { deploymentsMock } = await import('./deployments-mock');
|
|
81
|
+
const group = options.params?.group ?? options.body?.group;
|
|
82
|
+
return deploymentsMock(path, options, { user, keyBacked, authTime: context.authTime ?? null, group: typeof group === 'number' ? group : typeof group === 'string' && /^\d+$/.test(group) ? Number(group) : null, interactive: !context.origin || context.origin === globalThis.location?.origin });
|
|
83
|
+
}
|
|
84
|
+
return undefined;
|
|
85
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/** Read-only Serving fixtures. Controls import this module directly, never through runtime. */
|
|
2
|
+
export const SERVING_SCENARIOS = ['ready', 'http-disabled', 'unavailable', 'missing-edge', 'unexpected-issuance', 'bootstrap-denied', 'bootstrap-identity', 'bootstrap-malformed', 'old-backend', 'readiness-denied', 'readiness-malformed', 'reordered', 'missing-section', 'truncated', 'stale-refresh'] as const;
|
|
3
|
+
export type ServingMockScenario = typeof SERVING_SCENARIOS[number];
|
|
4
|
+
let scenario: ServingMockScenario = 'ready', reads = 0;
|
|
5
|
+
const admitted = new Set<string>();
|
|
6
|
+
export function setMockServingScenario(value: ServingMockScenario): void { scenario = value; reads = 0; admitted.clear(); }
|
|
7
|
+
const identity = (id: number, authTime?: number) => `${id}:${authTime ?? 'no-interactive-time'}`;
|
|
8
|
+
export function readServingBootstrapFacts() {
|
|
9
|
+
return { bootstrap: scenario === 'bootstrap-denied' ? 'denied' : 'ok', mismatch: scenario === 'bootstrap-identity', edge: scenario === 'missing-edge' ? undefined : scenario === 'bootstrap-malformed' ? { available: 'unknown' } : scenario === 'unavailable' ? { available: false, http_enabled: null, dnsman_issuance: null } : { available: true, http_enabled: scenario !== 'http-disabled', dnsman_issuance: scenario === 'unexpected-issuance' ? 'other' : 'dns-01' } };
|
|
10
|
+
}
|
|
11
|
+
export function recordServingBootstrap(id: number, authTime?: number): void { if (!['bootstrap-denied', 'bootstrap-identity', 'bootstrap-malformed', 'missing-edge'].includes(scenario)) admitted.add(identity(id, authTime)); }
|
|
12
|
+
export function servingReadinessMock(method: string, params: Record<string, unknown> | undefined, id: number, superuser: boolean, authTime?: number): unknown {
|
|
13
|
+
const fail = (error: string, error_code: number) => ({ status: false, error, error_code });
|
|
14
|
+
if (method !== 'GET') return fail('Method not allowed', 405);
|
|
15
|
+
if (!superuser || !admitted.has(identity(id, authTime)) || scenario === 'readiness-denied') return fail('permission denied', 403);
|
|
16
|
+
if (params?.section !== 'hosting_vhosts' || scenario === 'old-backend') return fail('Not found', 404);
|
|
17
|
+
if (scenario === 'stale-refresh' && reads++ > 0) return fail('Latest hosting observation unavailable', 503);
|
|
18
|
+
const coverage = (total: number, returned = total) => ({ total, returned, omitted: total - returned });
|
|
19
|
+
const checks = [{ code: 'hosting.vhost.1', status: 'pass', explanation: 'Enabled hostname has active domain and certificate configuration.', remediation: 'Inspect Serving for configuration changes.', details: { configuration_only: true } }];
|
|
20
|
+
const section = { code: 'hosting_vhosts', label: 'Hosting readiness', status: 'pass', checks, coverage: coverage(scenario === 'truncated' ? 3 : 1, 1) };
|
|
21
|
+
return { status: true, data: { schema_version: scenario === 'readiness-malformed' ? 99 : 1, generated_at: new Date().toISOString(), overall: 'pass', summary: { pass: 1, warn: 0, fail: 0, pending: 0 }, sections: scenario === 'missing-section' ? [] : scenario === 'reordered' ? [{ code: 'unrelated', label: 'Unrelated readiness', status: 'pending', checks: [], coverage: coverage(0) }, section] : [section], coverage: { sections: coverage(1), checks: coverage(scenario === 'truncated' ? 3 : 1, 1) }, truncated: scenario === 'truncated' } };
|
|
22
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
.admin-deployments{min-width:0;container-type:inline-size}.deployment-toolbar{display:flex;align-items:center;gap:.65rem;flex-wrap:wrap;justify-content:space-between;margin-block:1rem}.deployment-record{width:100%;display:flex;justify-content:space-between;align-items:center;gap:1rem;padding:1rem;text-align:left;background:var(--surface);color:var(--ink);border:1px solid var(--line);border-radius:var(--radius,.5rem);margin-block:.5rem;cursor:pointer}.deployment-record small{display:block;margin-top:.35rem;color:var(--mute);overflow-wrap:anywhere}.deployment-record:hover{border-color:var(--accent)}.deployment-status{white-space:nowrap}.deployment-facts{display:grid;gap:.7rem;margin-block:1rem}.deployment-facts>div{display:grid;grid-template-columns:11rem minmax(0,1fr);gap:1rem}.deployment-facts dt{color:var(--mute)}.deployment-facts dd{margin:0;overflow-wrap:anywhere}.deployment-form{display:grid;gap:1rem;grid-template-columns:repeat(2,minmax(0,1fr));margin-block:1rem}.admin-deployments label{display:grid;gap:.4rem;margin-block:.75rem}.admin-deployments input,.admin-deployments select{max-width:100%;min-width:0}.deployment-code{max-height:18rem;overflow:auto;white-space:pre-wrap;overflow-wrap:anywhere;padding:1rem;background:var(--surface2);border:1px solid var(--line)}.deployment-confirm{padding:1rem;border:1px solid var(--line);margin-block:1rem}.deployment-confirm button+button{margin-left:.5rem}@container(max-width:480px){.deployment-form{grid-template-columns:minmax(0,1fr)}.deployment-facts>div{grid-template-columns:minmax(0,1fr);gap:.2rem}.deployment-record{align-items:flex-start;flex-direction:column}.admin-deployments .modal-actions{flex-wrap:wrap}}
|
|
2
|
+
|
|
3
|
+
.admin-deployments input:not([type="file"]),.admin-deployments select{padding:.55rem .7rem;border:1px solid var(--field-line);border-radius:6px;background:var(--field-bg);color:var(--ink);font:inherit;width:100%;box-sizing:border-box}.admin-deployments .page-header{display:flex;justify-content:space-between;align-items:start;gap:1rem;flex-wrap:wrap}
|
|
4
|
+
.admin-deployments h1{font-size:26px;font-weight:750;line-height:1.2;letter-spacing:-.025em;color:var(--ink-strong);margin:0 0 .5rem}.admin-deployments h2{font-size:19px;font-weight:700;line-height:1.35;color:var(--ink-strong)}.admin-deployments h3{font-size:16px;font-weight:650;color:var(--ink-strong);margin-block:1rem .5rem}.admin-deployments .page-header{margin-bottom:1.25rem}.admin-deployments .page-header p{max-width:60ch}
|
|
5
|
+
|
|
6
|
+
.deployment-current { padding: 20px; border: 1px solid var(--line); border-radius: 10px; background: var(--surface); }
|
|
7
|
+
.deployment-current > strong { display: block; margin-top: 8px; overflow-wrap: anywhere; font-size: 18px; }
|
|
8
|
+
.deployment-current > p { margin: 6px 0; }
|
|
9
|
+
.admin-deployments { max-width: 1120px; margin-inline: auto; }
|
|
10
|
+
.admin-deployments .deployment-record { padding: 16px 20px; }
|
|
11
|
+
|
|
12
|
+
.deployment-attempt-summary { flex-shrink: 0; text-align: right; }
|
|
13
|
+
|
|
14
|
+
/* Deployment tasks share compact, native dialogs; secondary evidence stays on request. */
|
|
15
|
+
.deployment-dialog-header{display:flex;justify-content:space-between;align-items:flex-start;gap:1rem;margin-bottom:1.25rem;min-width:0}
|
|
16
|
+
.deployment-dialog-heading{min-width:0}.deployment-dialog-header .modal-title{margin:0}.deployment-dialog-header p{margin:.4rem 0 0;overflow-wrap:anywhere}
|
|
17
|
+
.deployment-actions,.deployment-row-actions{display:flex;align-items:center;gap:.65rem;flex-wrap:wrap}
|
|
18
|
+
.deployment-stack,.deployment-tab-content{display:grid;gap:1rem;min-width:0}.deployment-tab-content{padding-top:1rem}
|
|
19
|
+
.deployment-dialog .deployment-toolbar{margin-block:.75rem}.deployment-dialog .deployment-facts{margin-block:.5rem}
|
|
20
|
+
.deployment-dialog .panel-title{font-size:19px;line-height:1.35;letter-spacing:normal;margin:0}
|
|
21
|
+
.deployment-summary-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:1rem;margin-block:1rem}
|
|
22
|
+
.deployment-summary-grid>div,.deployment-summary-grid>section,.deployment-evidence{padding:1rem;border:1px solid var(--line);border-radius:8px;background:var(--surface);min-width:0;overflow-wrap:anywhere}
|
|
23
|
+
.deployment-evidence>strong{margin-right:.75rem}.deployment-evidence p{margin-block:.5rem 0}.deployment-empty{padding:1.25rem;color:var(--mute)}
|
|
24
|
+
.deployment-fields{border:0;padding:0;margin:0;min-width:0}.deployment-fields legend{padding:0;font-weight:650}.deployment-instructions{padding-left:1.4rem;display:grid;gap:.75rem;list-style:decimal}
|
|
25
|
+
.deployment-key-setup{border-top:1px solid var(--line);padding-top:1rem;margin-top:1rem}.deployment-key-setup p{margin-block:.5rem 1rem}
|
|
26
|
+
.deployment-setup-progress{display:flex;gap:.5rem;flex-wrap:wrap;padding:0;list-style:none;margin:1rem 0 1.5rem}.deployment-setup-progress li{display:flex;align-items:center;gap:.35rem;color:var(--mute);padding:.5rem .65rem;border-radius:6px;border:1px solid var(--line)}.deployment-setup-progress [aria-current="step"]{color:var(--accent);border-color:var(--accent);font-weight:650}
|
|
27
|
+
.deployment-pagination{display:flex;align-items:center;justify-content:space-between;gap:.75rem;flex-wrap:wrap;margin-block:1rem}
|
|
28
|
+
.admin-deployments input[type="checkbox"],.admin-deployments input[type="radio"]{width:auto;padding:0;accent-color:var(--accent)}.admin-deployments label:has(>input[type="checkbox"]),.admin-deployments label:has(>input[type="radio"]){display:flex;align-items:flex-start;gap:.65rem}
|
|
29
|
+
@container(max-width:560px){.deployment-dialog-header{flex-direction:column}.deployment-summary-grid{grid-template-columns:minmax(0,1fr)}.deployment-attempt-summary{text-align:left}.deployment-dialog .modal-actions{display:flex;flex-wrap:wrap;gap:.65rem}.deployment-dialog .modal-actions>*{margin:0}.deployment-dialog .deployment-facts>div{grid-template-columns:minmax(0,1fr);gap:.25rem}}
|
|
30
|
+
|
|
31
|
+
.admin-deployments .deployment-form>label{margin:0}
|
|
32
|
+
.deployment-dialog-header:has(>.modal-title){flex-direction:row}.deployment-dialog-header>.btn-icon{flex-shrink:0}
|
|
33
|
+
@container(max-width:480px){.deployment-dialog [role="tablist"][aria-label="WebApp tasks"]{flex-wrap:wrap;overflow:visible}}
|
|
34
|
+
|
|
35
|
+
@container(max-width:480px){.deployment-release-history th:nth-child(3),.deployment-release-history td:nth-child(3){display:none}.deployment-release-history th,.deployment-release-history td{padding-inline:.5rem}.deployment-release-history td .btn{white-space:nowrap;padding-inline:.65rem;font-size:12px}.deployment-release-history th,.deployment-release-history th button{font-size:10px;letter-spacing:.04em}.deployment-release-history table{table-layout:fixed;width:100%}.deployment-release-history th:first-child{width:25%}.deployment-release-history th:nth-child(2){width:42%}.deployment-release-history th:last-child{width:33%}}
|
|
@@ -26,3 +26,36 @@
|
|
|
26
26
|
.dns-cert-card .badge { align-self: flex-start; }
|
|
27
27
|
}
|
|
28
28
|
@media (prefers-reduced-motion: reduce) { .dns-cert-readiness .spin { animation: none; } }
|
|
29
|
+
|
|
30
|
+
.dns-shared-dialog, .dns-shared-facts, .dns-shared-panel, .dns-shared-showcase { display: grid; gap: 14px; min-width: 0; }
|
|
31
|
+
.dns-shared-dialog code, .dns-shared-facts code, .dns-shared-confirm code, .dns-shared-request code, .dns-shared-showcase code { overflow-wrap: anywhere; white-space: normal; }
|
|
32
|
+
.dns-shared-showcase { grid-template-columns: minmax(0, 1fr); }
|
|
33
|
+
.dns-shared-showcase > * { min-width: 0; max-width: 100%; }
|
|
34
|
+
.dns-shared-actions, .dns-shared-demo-controls { display: flex; flex-wrap: wrap; gap: 10px; align-items: end; }
|
|
35
|
+
.dns-shared-actions .btn { white-space: normal; }
|
|
36
|
+
.dns-shared-request { display: grid; gap: 10px; border: 1px solid var(--line); border-radius: 8px; padding: 14px; min-width: 0; overflow-wrap: anywhere; }
|
|
37
|
+
.dns-shared-request dl { margin: 0; display: grid; gap: 8px; }
|
|
38
|
+
.dns-shared-request dt { color: var(--mute); font-size: 12px; }
|
|
39
|
+
.dns-shared-request dd { margin: 0; }
|
|
40
|
+
.dns-shared-dialog .seg { flex-wrap: wrap; }
|
|
41
|
+
.dns-shared-dialog .field { min-width: 0; }
|
|
42
|
+
.dns-shared-dialog .modal-actions { position: static; }
|
|
43
|
+
.dns-shared-dialog .dns-shared-facts { border-top: 1px solid var(--line); padding-top: 14px; }
|
|
44
|
+
|
|
45
|
+
.dns-shared-allocations { min-width: 0; border: 1px solid var(--line); border-radius: 8px; padding: 14px; display: grid; gap: 12px; }
|
|
46
|
+
.dns-shared-allocation { display: flex; align-items: flex-start; gap: 10px; cursor: pointer; min-width: 0; }
|
|
47
|
+
.dns-shared-allocation > span { display: grid; gap: 4px; min-width: 0; overflow-wrap: anywhere; }
|
|
48
|
+
.dns-shared-allocation input { flex: 0 0 auto; margin-top: 4px; }
|
|
49
|
+
.dns-shared-allocation small { color: var(--mute); }
|
|
50
|
+
.dns-shared-dialog input[type="file"] { max-width: 100%; min-width: 0; color: var(--ink); }
|
|
51
|
+
.dns-shared-dialog input[type="file"]::file-selector-button { color: var(--ink); background: var(--surface2); border: 1px solid var(--line); border-radius: 6px; padding: 7px 10px; margin-right: 8px; cursor: pointer; }
|
|
52
|
+
.dns-shared-confirm { min-width: 0; overflow-wrap: anywhere; }
|
|
53
|
+
|
|
54
|
+
/* Private connection/key facts also fit narrow embedded Admin content. */
|
|
55
|
+
.shared-validation-operator { container-type: inline-size; }
|
|
56
|
+
.shared-validation-operator .flat-value { min-width: 0; overflow-wrap: anywhere; }
|
|
57
|
+
.shared-validation-operator code { overflow-wrap: anywhere; white-space: normal; }
|
|
58
|
+
@container (max-width: 420px) {
|
|
59
|
+
.shared-validation-operator .flat-row { grid-template-columns: minmax(0, 1fr); gap: 4px; }
|
|
60
|
+
.shared-validation-operator .flat-action:empty { display: none; }
|
|
61
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
.domains-header,.domains-actions,.domains-overview-actions{display:flex;align-items:center;justify-content:space-between;gap:10px;flex-wrap:wrap}.domains-header{margin-bottom:16px}.domains-actions,.domains-overview-actions{justify-content:flex-start}.domains-header p{margin:0;max-width:60ch}.domains-scope{overflow-wrap:anywhere}.domains-advanced{margin-top:16px;padding:16px;border:1px solid var(--line);border-radius:8px;background:var(--surface)}.domains-advanced summary{font-weight:650;cursor:pointer}.domains-overview-actions{margin-bottom:18px}.domains-registrar-settings{display:grid;gap:14px}.domains-registrar-settings label{display:flex;gap:10px;align-items:center}.domains-demo{display:grid;gap:16px;min-width:0}.domains-demo .seg{flex-wrap:wrap}@media(max-width:560px){.domains-header{align-items:flex-start;flex-direction:column}.domains-actions{width:100%}.domains-actions .btn{flex:1 1 auto;white-space:normal}}
|
|
2
|
+
|
|
3
|
+
.domains-header h1 { margin: 4px 0 8px; font-size: 26px; line-height: 1.2; font-weight: 750; letter-spacing: -.025em; color: var(--ink-strong); }
|
|
4
|
+
.domains-header { align-items: center; margin-bottom: 24px; }
|
|
5
|
+
|
|
6
|
+
.domains-page, .domains-page > * { min-width: 0; }
|
|
7
|
+
.domains-demo { grid-template-columns: minmax(0, 1fr); }
|
|
8
|
+
.domains-demo > * { min-width: 0; }
|
|
9
|
+
@media (max-width: 560px) { .domains-header { align-items: flex-start; } }
|
|
10
|
+
|
|
11
|
+
.domain-onboarding > .dim { margin: 4px 0 20px; }
|
|
12
|
+
.domain-onboarding .dns-registrar-grid { gap: 20px; }
|