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.
Files changed (157) hide show
  1. package/docs/README.md +7 -1
  2. package/docs/admin-credentials.md +27 -0
  3. package/docs/admin-deployments.md +201 -0
  4. package/docs/admin-dns-certificates.md +160 -0
  5. package/docs/admin-dns-domains.md +92 -0
  6. package/docs/admin-dns.md +18 -0
  7. package/docs/admin-edge.md +200 -196
  8. package/docs/admin-fleet-configuration.md +98 -2
  9. package/docs/admin-geolocation.md +100 -0
  10. package/docs/admin-identity-directories.md +12 -0
  11. package/docs/admin-maintenance.md +133 -0
  12. package/docs/admin-modals.md +45 -2
  13. package/docs/admin-operational-fleet.md +206 -0
  14. package/docs/admin-presentation.md +51 -0
  15. package/docs/admin-signin.md +12 -2
  16. package/docs/auth-pages.md +18 -6
  17. package/docs/auth.md +16 -2
  18. package/docs/client.md +7 -0
  19. package/docs/collection-select.md +14 -0
  20. package/docs/combobox.md +18 -0
  21. package/docs/popover.md +5 -4
  22. package/package.json +1 -1
  23. package/src/admin/core/OperationsHelp.tsx +10 -0
  24. package/src/admin/core/index.ts +6 -4
  25. package/src/admin/core/route-context.ts +6 -0
  26. package/src/admin/credentials/AcmeConsumerKeyDialog.tsx +88 -0
  27. package/src/admin/credentials/acme-consumer-key.ts +34 -0
  28. package/src/admin/credentials/group-api-keys.tsx +5 -3
  29. package/src/admin/deployments/ApiDeploymentDetail.tsx +58 -0
  30. package/src/admin/deployments/DeploymentsPage.tsx +73 -0
  31. package/src/admin/deployments/WebAppDeploySetup.tsx +49 -0
  32. package/src/admin/deployments/WebAppDeploymentDetail.tsx +59 -0
  33. package/src/admin/deployments/WebAppDetail.tsx +104 -0
  34. package/src/admin/deployments/WebAppEditDialog.tsx +70 -0
  35. package/src/admin/deployments/WebAppKeyDialog.tsx +25 -0
  36. package/src/admin/deployments/WebAppOnboardingDialog.tsx +217 -0
  37. package/src/admin/deployments/WebAppRollbackDialog.tsx +82 -0
  38. package/src/admin/deployments/WebAppUploadPanel.tsx +132 -0
  39. package/src/admin/deployments/api.ts +158 -0
  40. package/src/admin/deployments/authority-state.ts +9 -0
  41. package/src/admin/deployments/deployment-state.ts +13 -0
  42. package/src/admin/deployments/index.ts +4 -0
  43. package/src/admin/deployments/onboarding-state.ts +11 -0
  44. package/src/admin/deployments/permissions.ts +37 -0
  45. package/src/admin/deployments/receipts.ts +78 -0
  46. package/src/admin/deployments/sanitize.ts +73 -0
  47. package/src/admin/deployments/shared.tsx +39 -0
  48. package/src/admin/deployments/types.ts +238 -0
  49. package/src/admin/deployments/upload.ts +53 -0
  50. package/src/admin/deployments/useDeploymentObservation.ts +104 -0
  51. package/src/admin/dns/CertificateDetail.tsx +8 -3
  52. package/src/admin/dns/CertificateRequestDialog.tsx +45 -7
  53. package/src/admin/dns/DomainDetail.tsx +14 -4
  54. package/src/admin/dns/DomainOnboardingContent.tsx +44 -0
  55. package/src/admin/dns/DomainOnboardingDialog.tsx +5 -24
  56. package/src/admin/dns/DomainPurchaseWizard.tsx +1 -1
  57. package/src/admin/dns/DomainRegisterExisting.tsx +63 -0
  58. package/src/admin/dns/DomainRegistrarSettings.tsx +52 -0
  59. package/src/admin/dns/DomainsPage.tsx +26 -13
  60. package/src/admin/dns/SharedValidationDialog.tsx +298 -0
  61. package/src/admin/dns/SharedValidationPanel.tsx +76 -0
  62. package/src/admin/dns/api.ts +4 -2
  63. package/src/admin/dns/domain-navigation.ts +16 -0
  64. package/src/admin/dns/registrar-api.ts +13 -0
  65. package/src/admin/dns/shared-validation-api.ts +90 -0
  66. package/src/admin/dns/shared-validation-data.ts +106 -0
  67. package/src/admin/domains/assistant.ts +5 -4
  68. package/src/admin/domains/communications.ts +10 -9
  69. package/src/admin/domains/identity.ts +8 -7
  70. package/src/admin/domains/infrastructure.ts +19 -13
  71. package/src/admin/domains/lazy-page.ts +6 -0
  72. package/src/admin/domains/observability.ts +6 -5
  73. package/src/admin/domains/operations.ts +27 -10
  74. package/src/admin/domains/security.ts +16 -15
  75. package/src/admin/edge/EdgeHelp.tsx +10 -0
  76. package/src/admin/edge/ServingReadiness.tsx +54 -0
  77. package/src/admin/edge/VhostDetail.tsx +145 -139
  78. package/src/admin/edge/VhostWizard.tsx +204 -115
  79. package/src/admin/edge/VhostsPage.tsx +27 -28
  80. package/src/admin/edge/api.ts +24 -10
  81. package/src/admin/edge/models.ts +4 -4
  82. package/src/admin/edge/serving-data.ts +78 -0
  83. package/src/admin/fleet/CapacityEditor.tsx +81 -0
  84. package/src/admin/fleet/CapacityInspector.tsx +19 -0
  85. package/src/admin/fleet/CapacityProgress.tsx +9 -0
  86. package/src/admin/fleet/FleetHelp.tsx +7 -0
  87. package/src/admin/fleet/FleetOverview.tsx +150 -0
  88. package/src/admin/fleet/FleetPage.tsx +11 -0
  89. package/src/admin/fleet/FleetWorkspace.tsx +82 -0
  90. package/src/admin/fleet/ScaleFleetDialog.tsx +169 -0
  91. package/src/admin/fleet/access.ts +22 -0
  92. package/src/admin/fleet/api.ts +77 -0
  93. package/src/admin/fleet/data.ts +148 -0
  94. package/src/admin/fleet/index.ts +8 -0
  95. package/src/admin/fleet/operations.ts +179 -0
  96. package/src/admin/fleet/standalone-data.ts +87 -0
  97. package/src/admin/fleet/types.ts +208 -0
  98. package/src/admin/fleet-configuration/FleetConfigurationPage.tsx +14 -8
  99. package/src/admin/fleet-configuration/SharedValidationConnectionPanel.tsx +166 -0
  100. package/src/admin/fleet-configuration/data.ts +4 -0
  101. package/src/admin/fleet-configuration/operator-destinations.ts +20 -0
  102. package/src/admin/fleet-configuration/shared-validation-data.ts +143 -0
  103. package/src/admin/geolocation/GeoLocationPage.tsx +171 -0
  104. package/src/admin/geolocation/data.ts +138 -0
  105. package/src/admin/geolocation/index.ts +3 -0
  106. package/src/admin/identity/groups/group-sections/SubGroupsSection.tsx +73 -2
  107. package/src/admin/identity/groups/models.ts +2 -0
  108. package/src/admin/identity/members/GroupMembersPanel.tsx +2 -8
  109. package/src/admin/index.ts +8 -0
  110. package/src/admin/maintenance/MaintenanceDialogs.tsx +161 -0
  111. package/src/admin/maintenance/MaintenancePage.tsx +442 -0
  112. package/src/admin/maintenance/data.ts +486 -0
  113. package/src/admin/maintenance/index.ts +3 -0
  114. package/src/admin/maintenance/operations.ts +208 -0
  115. package/src/admin/public/infrastructure.ts +1 -0
  116. package/src/admin/public/operations.ts +3 -0
  117. package/src/admin/registry.ts +6 -2
  118. package/src/admin/settings/SettingDetail.tsx +7 -2
  119. package/src/admin/settings/SettingEditor.tsx +2 -1
  120. package/src/admin/settings/SettingsPage.tsx +3 -1
  121. package/src/admin/settings/model.ts +1 -0
  122. package/src/admin/signin/SigninPage.tsx +17 -3
  123. package/src/admin/signin/api.ts +2 -0
  124. package/src/charts/MetricsChartView.tsx +4 -11
  125. package/src/charts/MetricsMiniWidget.tsx +3 -15
  126. package/src/client/auth.ts +77 -25
  127. package/src/client/client.ts +6 -0
  128. package/src/client/deployments-mock.ts +375 -0
  129. package/src/client/domain-operations-mock.ts +37 -0
  130. package/src/client/fleet-mock.ts +73 -1
  131. package/src/client/geo-provider-mock.ts +82 -0
  132. package/src/client/index.ts +2 -2
  133. package/src/client/maintenance-mock.ts +285 -0
  134. package/src/client/mock.ts +280 -11
  135. package/src/client/operational-fleet-mock.ts +279 -0
  136. package/src/client/operations-mock.ts +85 -0
  137. package/src/client/serving-mock.ts +22 -0
  138. package/src/styles/components/admin-deployments.css +35 -0
  139. package/src/styles/components/admin-dns-certificates.css +33 -0
  140. package/src/styles/components/admin-domains.css +12 -0
  141. package/src/styles/components/admin-edge.css +125 -1
  142. package/src/styles/components/admin-geolocation.css +40 -0
  143. package/src/styles/components/admin-maintenance.css +127 -0
  144. package/src/styles/components/admin-operational-fleet.css +38 -0
  145. package/src/styles/components/admin-operations.css +9 -0
  146. package/src/styles/components/admin-signin.css +2 -0
  147. package/src/styles/components/combobox.css +12 -2
  148. package/src/styles/components/fleet-page.css +9 -0
  149. package/src/styles/index.css +7 -0
  150. package/src/ui/CollectionSelect.tsx +30 -8
  151. package/src/ui/ComboBox.tsx +39 -8
  152. package/src/ui/FilterBar.tsx +3 -5
  153. package/src/ui/ModelTable.tsx +2 -8
  154. package/src/ui/Popover.tsx +15 -7
  155. package/src/ui/date/TimePicker.tsx +4 -14
  156. package/src/ui/image-editor/ImageEditor.tsx +4 -5
  157. package/src/ui/schema-form-core.tsx +3 -5
@@ -0,0 +1,106 @@
1
+ /** Internal public-metadata boundary. Raw responses never become component/cache state. */
2
+ export class SharedValidationError extends Error {
3
+ constructor(public kind: 'unsupported' | 'access' | 'unavailable' | 'invalid' | 'reauth' | 'prerequisite', public readiness: Readiness | null = null, public code: string | null = null) {
4
+ super({ unsupported: 'Shared validation needs a compatible backend. Ordinary DNS and legacy certificates remain available.', access: 'Shared validation is unavailable with this session or allocation.', unavailable: 'Shared validation could not be refreshed. Check the deployment and try Refresh.', invalid: 'Shared validation returned an incompatible response. Refresh after checking the backend.', reauth: 'Authenticate again, then Refresh and deliberately repeat the action.', prerequisite: 'The backend refused this action. Review the refreshed validation state.' }[kind]);
5
+ }
6
+ }
7
+ export type SetupTarget = { domain: number } | { group: number; name: string };
8
+ export interface ConnectionRequest { version: 1; consumer_project_uuid: string; client_ref: string; domain: string; allocation_ref: string }
9
+ export interface Readiness {
10
+ version: 1; checked_at: string;
11
+ domain: { id: number | null; name: string; provider: string | null; group: { id: number; name: string } | null };
12
+ profile: { name: 'apex_wildcard'; names: string[] };
13
+ hub: { state: string; protocol_version: number | null; consumer_project_uuid: string | null };
14
+ connection: { id: number; mode: 'shared' | 'allocated'; state: string; client_ref: string; allocation_ref: string | null; participant_ref: string | null; verified_at: string | null } | null;
15
+ trust: { state: string; can_publish: boolean; can_withdraw: boolean };
16
+ alias: { state: string; source: string; target: string | null; observed_target: string | null; one_hop: boolean | null; checked_at: string | null };
17
+ certificate: { id: number; state: string; names: string[]; not_after: string | null; renew_after: string | null; serving_valid: boolean | null; material_state: string } | null;
18
+ renewal: { state: string; next_attempt_at: string | null; attempts: number; error_code: string | null; cleanup_pending: boolean; job: { id: string; state: string } | null; worker_version: string | null };
19
+ capabilities: { group_choices: boolean; can_connect: boolean; can_restart: boolean; can_check: boolean; can_request: boolean };
20
+ next_action: { code: string; actor: string };
21
+ connection_request: ConnectionRequest | null;
22
+ operation: { action: string; outcome: string; changed: boolean };
23
+ }
24
+ export interface HubCapabilities { version: 2; shared_validation: boolean; consumer_project_uuid: string | null; can_consume: boolean; can_manage_trust: boolean; trust_scope: 'apex_and_wildcard' }
25
+ export interface Allocation { allocation_ref: string; domain: string; source: string; target: string; owner_project_uuid: string; can_manage_trust: boolean }
26
+ export interface Participant { allocation_ref: string; participant_ref: string; client_ref: string; domain: string; source: string; target: string; role: 'owner' | 'trusted'; trust_state: 'active' | 'revoked'; scope: string[]; can_publish: boolean; can_withdraw: boolean; revoked_at: string | null; consumer_project_uuid: string; consumer_label: string }
27
+ export interface Recipient { consumer_project_uuid: string; label: string; active: boolean }
28
+ export interface Page<T> { items: T[]; next_cursor: string | null }
29
+ const invalid = (): never => { throw new SharedValidationError('invalid'); };
30
+ export function record(value: unknown): Record<string, unknown> { return value != null && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : invalid(); }
31
+ export function text(value: unknown, max = 200): string { return typeof value === 'string' && value.length <= max && !/[\x00-\x1f]|-----BEGIN|Bearer\s|:\/\//i.test(value) ? value : invalid(); }
32
+ export function positive(value: unknown): number { return Number.isSafeInteger(value) && Number(value) > 0 ? Number(value) : invalid(); }
33
+ const integer = (value: unknown) => Number.isSafeInteger(value) && Number(value) >= 0 ? Number(value) : invalid();
34
+ const bool = (value: unknown): boolean => typeof value === 'boolean' ? value : invalid();
35
+ const nullable = <T>(value: unknown, parse: (v: unknown) => T): T | null => value === null ? null : parse(value);
36
+ const choice = <T extends string>(value: unknown, values: readonly T[]): T => values.includes(value as T) ? value as T : invalid();
37
+ export function hostname(value: unknown): string {
38
+ const name = text(value, 253);
39
+ return name === name.toLowerCase() && name.includes('.') && name.split('.').every((label) => /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(label)) ? name : invalid();
40
+ }
41
+ export function normalizeHostname(value: string): string { return hostname(value.trim().toLowerCase().replace(/\.+$/, '')); }
42
+ export function uuid(value: unknown): string { const v = text(value, 36); return /^(?:[a-f0-9]{32}|[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12})$/.test(v) ? v : invalid(); }
43
+ /** Group project identities may retain the existing 16-character format. */
44
+ export function projectIdentity(value: unknown): string { return typeof value === 'string' && /^[a-f0-9]{16}$/.test(value) ? value : uuid(value); }
45
+ const date = (v: unknown) => { const s = text(v, 40); return Number.isFinite(Date.parse(s)) ? s : invalid(); };
46
+ const token = (v: unknown) => { const s = text(v, 64); return /^[a-z0-9_.-]+$/.test(s) ? s : invalid(); };
47
+ function scope(v: unknown, domain: string): string[] { if (!Array.isArray(v) || v.length !== 2 || v[0] !== domain || v[1] !== `*.${domain}`) invalid(); return [domain, `*.${domain}`]; }
48
+ function names(v: unknown): string[] { if (!Array.isArray(v) || v.length < 1 || v.length > 100) return invalid(); return v.map((n) => typeof n === 'string' && n.startsWith('*.') ? `*.${hostname(n.slice(2))}` : hostname(n)); }
49
+ export function parseConnectionRequest(value: unknown): ConnectionRequest {
50
+ const r = record(value);
51
+ if (Object.keys(r).sort().join(',') !== 'allocation_ref,client_ref,consumer_project_uuid,domain,version' || r.version !== 1) invalid();
52
+ return { version: 1, consumer_project_uuid: projectIdentity(r.consumer_project_uuid), client_ref: uuid(r.client_ref), domain: hostname(r.domain), allocation_ref: hostname(r.allocation_ref) };
53
+ }
54
+ export const CONNECTION_REQUEST_MAX_BYTES = 2048;
55
+ export function parseConnectionRequestText(value: string): ConnectionRequest {
56
+ if (!value || new TextEncoder().encode(value).byteLength > CONNECTION_REQUEST_MAX_BYTES) return invalid();
57
+ try { return parseConnectionRequest(JSON.parse(value)); } catch { return invalid(); }
58
+ }
59
+ export function serializeConnectionRequest(value: ConnectionRequest): string {
60
+ const result = JSON.stringify(parseConnectionRequest(value));
61
+ parseConnectionRequestText(result);
62
+ return result;
63
+ }
64
+ export function parseReadiness(value: unknown, target?: SetupTarget, allocation?: string): Readiness {
65
+ const r = record(value); if (r.version !== 1) throw new SharedValidationError('unsupported');
66
+ const d = record(r.domain), h = record(r.hub), t = record(r.trust), a = record(r.alias), n = record(r.renewal), c = record(r.capabilities), next = record(r.next_action), op = record(r.operation), p = record(r.profile);
67
+ const domain = { id: nullable(d.id, positive), name: hostname(d.name), provider: nullable(d.provider, token), group: d.group === null ? null : { id: positive(record(d.group).id), name: text(record(d.group).name) } };
68
+ if (target && ('domain' in target ? domain.id !== target.domain : domain.group?.id !== target.group || domain.name !== target.name)) invalid();
69
+ const connection = r.connection === null ? null : (() => { const v = record(r.connection); return { id: positive(v.id), mode: choice(v.mode, ['shared', 'allocated'] as const), state: choice(v.state, ['pending', 'verified', 'broken', 'retired']), client_ref: uuid(v.client_ref), allocation_ref: nullable(v.allocation_ref, hostname), participant_ref: nullable(v.participant_ref, uuid), verified_at: nullable(v.verified_at, date) }; })();
70
+ const result: Readiness = {
71
+ version: 1, checked_at: date(r.checked_at), domain,
72
+ profile: { name: choice(p.name, ['apex_wildcard']), names: scope(p.names, domain.name) },
73
+ hub: { state: choice(h.state, ['unconfigured', 'misconfigured', 'unavailable', 'unauthorized', 'unsupported', 'ready']), protocol_version: nullable(h.protocol_version, positive), consumer_project_uuid: nullable(h.consumer_project_uuid, projectIdentity) },
74
+ connection, trust: { state: choice(t.state, ['unknown', 'not_requested', 'missing', 'active', 'revoked', 'identity_changed']), can_publish: bool(t.can_publish), can_withdraw: bool(t.can_withdraw) },
75
+ alias: { state: choice(a.state, ['unknown', 'match', 'mismatch', 'lookup_failed']), source: text(a.source, 270), target: nullable(a.target, hostname), observed_target: nullable(a.observed_target, hostname), one_hop: nullable(a.one_hop, bool), checked_at: nullable(a.checked_at, date) },
76
+ certificate: r.certificate === null ? null : (() => { const v = record(r.certificate); return { id: positive(v.id), state: choice(v.state, ['pending', 'issuing', 'active', 'failed', 'revoked', 'expired', 'unknown']), names: names(v.names), not_after: nullable(v.not_after, date), renew_after: nullable(v.renew_after, date), serving_valid: nullable(v.serving_valid, bool), material_state: choice(v.material_state, ['available', 'missing', 'unavailable']) }; })(),
77
+ renewal: { state: choice(n.state, ['not_requested', 'scheduled', 'queued', 'issuing', 'retrying', 'blocked', 'failed', 'expired', 'unknown']), next_attempt_at: nullable(n.next_attempt_at, date), attempts: integer(n.attempts), error_code: nullable(n.error_code, token), cleanup_pending: bool(n.cleanup_pending), job: n.job === null ? null : { id: text(record(n.job).id, 128), state: choice(record(n.job).state, ['pending', 'running', 'completed', 'failed', 'canceled', 'expired', 'unknown']) }, worker_version: nullable(n.worker_version, (v) => text(v, 64)) },
78
+ capabilities: { group_choices: bool(c.group_choices), can_connect: bool(c.can_connect), can_restart: bool(c.can_restart), can_check: bool(c.can_check), can_request: bool(c.can_request) },
79
+ next_action: { code: choice(next.code, ['configure_hub', 'upgrade_hub', 'repair_hub_access', 'retry_hub', 'choose_allocation', 'prepare_connection', 'grant_trust', 'restart_connection', 'connect', 'repair_alias', 'check', 'request', 'wait_for_job', 'repair_worker', 'restore_custody', 'inspect_failure', 'unsupported_domain_scope', 'none']), actor: choice(next.actor, ['deployment_operator', 'allocation_controller', 'domain_owner', 'project_operator', 'none']) },
80
+ connection_request: r.connection_request === null ? null : parseConnectionRequest(r.connection_request),
81
+ operation: { action: choice(op.action, ['plan', 'connect', 'check', 'request']), outcome: choice(op.outcome, ['observed', 'awaiting_trust', 'connected', 'verified', 'queued', 'already_current', 'already_queued', 'custody_unavailable', 'job_held']), changed: bool(op.changed) },
82
+ };
83
+ if (result.alias.source !== `_acme-challenge.${domain.name}` || (allocation && result.alias.target !== allocation) || (connection?.allocation_ref && result.alias.target !== connection.allocation_ref)) invalid();
84
+ if (result.certificate && [...result.certificate.names].sort().join(',') !== [...result.profile.names].sort().join(',')) invalid();
85
+ if (result.trust.state === 'identity_changed' || result.hub.state !== 'ready' || !result.hub.consumer_project_uuid) result.connection_request = null;
86
+ const descriptor = result.connection_request;
87
+ if (descriptor && (descriptor.domain !== domain.name || descriptor.allocation_ref !== result.alias.target || descriptor.client_ref !== connection?.client_ref || (result.hub.consumer_project_uuid && descriptor.consumer_project_uuid !== result.hub.consumer_project_uuid))) invalid();
88
+ if (!domain.group && (result.next_action.code !== 'unsupported_domain_scope' || Object.values(result.capabilities).some(Boolean) || descriptor)) invalid();
89
+ if (result.capabilities.can_request && (result.hub.state !== 'ready' || !result.hub.consumer_project_uuid || connection?.mode !== 'shared' || connection.state !== 'verified' || result.trust.state !== 'active' || result.alias.state !== 'match' || !result.alias.one_hop)) invalid();
90
+ return result;
91
+ }
92
+ export function parseHubCapabilities(value: unknown): HubCapabilities {
93
+ const r = record(value); if (r.version !== 2) throw new SharedValidationError('unsupported');
94
+ return { version: 2, shared_validation: bool(r.shared_validation), consumer_project_uuid: nullable(r.consumer_project_uuid, projectIdentity), can_consume: bool(r.can_consume), can_manage_trust: bool(r.can_manage_trust), trust_scope: choice(r.trust_scope, ['apex_and_wildcard']) };
95
+ }
96
+ export function parseAllocation(value: unknown, domain: string): Allocation {
97
+ const r = record(value); const out = { allocation_ref: hostname(r.allocation_ref), domain: hostname(r.domain), source: text(r.source, 270), target: hostname(r.target), owner_project_uuid: projectIdentity(r.owner_project_uuid), can_manage_trust: bool(r.can_manage_trust) };
98
+ if (out.domain !== domain || out.source !== `_acme-challenge.${domain}` || out.target !== out.allocation_ref) invalid(); return out;
99
+ }
100
+ export function parseParticipant(value: unknown, allocation: Allocation): Participant {
101
+ const r = record(value); const base = parseAllocation({ ...r, owner_project_uuid: allocation.owner_project_uuid, can_manage_trust: allocation.can_manage_trust }, allocation.domain); const out: Participant = { allocation_ref: base.allocation_ref, domain: base.domain, source: base.source, target: base.target, participant_ref: uuid(r.participant_ref), client_ref: uuid(r.client_ref), role: choice(r.role, ['owner', 'trusted']), trust_state: choice(r.trust_state, ['active', 'revoked']), scope: scope(r.scope, allocation.domain), can_publish: bool(r.can_publish), can_withdraw: bool(r.can_withdraw), revoked_at: nullable(r.revoked_at, date), consumer_project_uuid: projectIdentity(r.consumer_project_uuid), consumer_label: text(r.consumer_label) };
102
+ if (out.allocation_ref !== allocation.allocation_ref || (out.trust_state === 'revoked') !== (out.revoked_at !== null)) invalid(); return out;
103
+ }
104
+ export function parsePage<T>(value: unknown, parse: (v: unknown) => T): Page<T> { const r = record(value); if (!Array.isArray(r.items) || r.items.length > 20) invalid(); return { items: (r.items as unknown[]).map(parse), next_cursor: nullable(r.next_cursor, (v) => { const s = text(v, 20); return /^[1-9]\d*$/.test(s) ? s : invalid(); }) }; }
105
+ export function parseRecipient(value: unknown, expected: string): Recipient { const r = record(value); const out = { consumer_project_uuid: projectIdentity(r.consumer_project_uuid), label: text(r.label), active: bool(r.active) }; if (out.consumer_project_uuid !== expected || !out.active) invalid(); return out; }
106
+ export const pendingReadiness = (r: Readiness) => ['queued', 'issuing'].includes(r.renewal.state) || ['pending', 'issuing'].includes(r.certificate?.state ?? '');
@@ -1,3 +1,4 @@
1
+ import { namedPage } from './lazy-page';
1
2
  import type { AdminSection } from '../core';
2
3
  import { ASSISTANT_PERMISSIONS } from '../assistant/permissions';
3
4
 
@@ -5,10 +6,10 @@ export { ASSISTANT_PERMISSIONS } from '../assistant/permissions';
5
6
  export const ASSISTANT_ADMIN_SECTION: AdminSection = {
6
7
  id: 'assistant', basePath: 'assistant', title: 'Assistant', icon: 'bi-stars', navigationGroup: 'assistant', permissions: ASSISTANT_PERMISSIONS,
7
8
  routes: [
8
- { path: 'setup', label: 'Setup', requireSuperuser: true, loadComponent: () => import('../assistant/AssistantSetupPage').then(({ AssistantSetupPage }) => ({ default: AssistantSetupPage })) },
9
- { path: 'conversations', label: 'Conversations', loadComponent: () => import('../assistant/pages').then(({ ConversationsPage }) => ({ default: ConversationsPage })), permissions: ASSISTANT_PERMISSIONS },
10
- { path: 'skills', label: 'Skills', loadComponent: () => import('../assistant/pages').then(({ SkillsPage }) => ({ default: SkillsPage })), permissions: ASSISTANT_PERMISSIONS },
11
- { path: 'memories', label: 'Memory', loadComponent: () => import('../assistant/pages').then(({ MemoriesPage }) => ({ default: MemoriesPage })), permissions: ['sys.assistant'] },
9
+ { path: 'setup', label: 'Setup', requireSuperuser: true, loadComponent: () => import('../assistant/AssistantSetupPage').then(namedPage('AssistantSetupPage')) },
10
+ { path: 'conversations', label: 'Conversations', loadComponent: () => import('../assistant/pages').then(namedPage('ConversationsPage')), permissions: ASSISTANT_PERMISSIONS },
11
+ { path: 'skills', label: 'Skills', loadComponent: () => import('../assistant/pages').then(namedPage('SkillsPage')), permissions: ASSISTANT_PERMISSIONS },
12
+ { path: 'memories', label: 'Memory', loadComponent: () => import('../assistant/pages').then(namedPage('MemoriesPage')), permissions: ['sys.assistant'] },
12
13
  ],
13
14
  };
14
15
  export const ASSISTANT_ADMIN_SECTIONS = [ASSISTANT_ADMIN_SECTION] as const;
@@ -1,3 +1,4 @@
1
+ import { namedPage } from './lazy-page';
1
2
  import type { AdminSection } from '../core';
2
3
  import { EMAIL_ADMIN_PERMISSIONS, PUBLIC_MESSAGE_VIEW_PERMISSIONS } from '../messaging/models';
3
4
  import { PUSH_ADMIN_PERMISSIONS } from '../messaging/push/models';
@@ -7,29 +8,29 @@ import { SHORTLINK_MANAGE_PERMISSIONS } from '../shortlinks/models';
7
8
  export const SHORTLINKS_ADMIN_SECTION: AdminSection = {
8
9
  id: 'shortlinks', basePath: 'shortlinks', title: 'Shortlinks', icon: 'bi-link-45deg', navigationGroup: 'communications', permissions: SHORTLINK_MANAGE_PERMISSIONS,
9
10
  routes: [
10
- { path: 'links', label: 'Links', loadComponent: () => import('../shortlinks/ShortlinksPage').then(({ ShortlinksPage }) => ({ default: ShortlinksPage })), permissions: SHORTLINK_MANAGE_PERMISSIONS },
11
- { path: 'history', label: 'Click history', loadComponent: () => import('../shortlinks/ShortlinkHistoryPage').then(({ ShortlinkHistoryPage }) => ({ default: ShortlinkHistoryPage })), permissions: SHORTLINK_MANAGE_PERMISSIONS },
11
+ { path: 'links', label: 'Links', loadComponent: () => import('../shortlinks/ShortlinksPage').then(namedPage('ShortlinksPage')), permissions: SHORTLINK_MANAGE_PERMISSIONS },
12
+ { path: 'history', label: 'Click history', loadComponent: () => import('../shortlinks/ShortlinkHistoryPage').then(namedPage('ShortlinkHistoryPage')), permissions: SHORTLINK_MANAGE_PERMISSIONS },
12
13
  ],
13
14
  };
14
15
  export const EMAIL_ADMIN_SECTION: AdminSection = {
15
16
  id: 'email', basePath: 'email', title: 'Email', icon: 'bi-envelope-at', navigationGroup: 'communications', permissions: EMAIL_ADMIN_PERMISSIONS,
16
17
  routes: [
17
- { path: 'domains', label: 'Email Domains', loadComponent: () => import('../messaging/EmailDomainsPage').then(({ EmailDomainsPage }) => ({ default: EmailDomainsPage })), permissions: EMAIL_ADMIN_PERMISSIONS },
18
- { path: 'mailboxes', label: 'Mailboxes', loadComponent: () => import('../messaging/MailboxesPage').then(({ MailboxesPage }) => ({ default: MailboxesPage })), permissions: EMAIL_ADMIN_PERMISSIONS },
19
- { path: 'sent', label: 'Sent Messages', loadComponent: () => import('../messaging/SentMessagesPage').then(({ SentMessagesPage }) => ({ default: SentMessagesPage })), permissions: EMAIL_ADMIN_PERMISSIONS },
20
- { path: 'templates', label: 'Email Templates', loadComponent: () => import('../messaging/EmailTemplatesPage').then(({ EmailTemplatesPage }) => ({ default: EmailTemplatesPage })), permissions: EMAIL_ADMIN_PERMISSIONS },
18
+ { path: 'domains', label: 'Email Domains', loadComponent: () => import('../messaging/EmailDomainsPage').then(namedPage('EmailDomainsPage')), permissions: EMAIL_ADMIN_PERMISSIONS },
19
+ { path: 'mailboxes', label: 'Mailboxes', loadComponent: () => import('../messaging/MailboxesPage').then(namedPage('MailboxesPage')), permissions: EMAIL_ADMIN_PERMISSIONS },
20
+ { path: 'sent', label: 'Sent Messages', loadComponent: () => import('../messaging/SentMessagesPage').then(namedPage('SentMessagesPage')), permissions: EMAIL_ADMIN_PERMISSIONS },
21
+ { path: 'templates', label: 'Email Templates', loadComponent: () => import('../messaging/EmailTemplatesPage').then(namedPage('EmailTemplatesPage')), permissions: EMAIL_ADMIN_PERMISSIONS },
21
22
  ],
22
23
  };
23
24
  export const PUBLIC_MESSAGES_ADMIN_SECTION: AdminSection = {
24
25
  id: 'public-messages', basePath: 'messaging', title: 'Contact Messages', icon: 'bi-chat-left-text', navigationGroup: 'communications', permissions: PUBLIC_MESSAGE_VIEW_PERMISSIONS,
25
- routes: [{ path: 'public-messages', label: 'Contact Messages', loadComponent: () => import('../messaging/PublicMessagesPage').then(({ PublicMessagesPage }) => ({ default: PublicMessagesPage })), permissions: PUBLIC_MESSAGE_VIEW_PERMISSIONS }],
26
+ routes: [{ path: 'public-messages', label: 'Contact Messages', loadComponent: () => import('../messaging/PublicMessagesPage').then(namedPage('PublicMessagesPage')), permissions: PUBLIC_MESSAGE_VIEW_PERMISSIONS }],
26
27
  };
27
28
  export const PUSH_ADMIN_SECTION: AdminSection = {
28
29
  id: 'push', basePath: 'push', title: 'Push Notifications', icon: 'bi-bell', navigationGroup: 'communications', permissions: PUSH_ADMIN_PERMISSIONS,
29
- routes: [{ path: '', label: 'Push Notifications', loadComponent: () => import('../messaging/push/PushPage').then(({ PushPage }) => ({ default: PushPage })), permissions: PUSH_ADMIN_PERMISSIONS }],
30
+ routes: [{ path: '', label: 'Push Notifications', loadComponent: () => import('../messaging/push/PushPage').then(namedPage('PushPage')), permissions: PUSH_ADMIN_PERMISSIONS }],
30
31
  };
31
32
  export const PHONE_HUB_ADMIN_SECTION: AdminSection = {
32
33
  id: 'phonehub', basePath: 'phonehub', title: 'Phone Hub', icon: 'bi-phone', navigationGroup: 'communications', permissions: PHONE_HUB_ADMIN_PERMISSIONS,
33
- routes: [{ path: '', label: 'Phone Hub', loadComponent: () => import('../phonehub/PhoneHubPage').then(({ PhoneHubPage }) => ({ default: PhoneHubPage })), permissions: PHONE_HUB_ADMIN_PERMISSIONS }],
34
+ routes: [{ path: '', label: 'Phone Hub', loadComponent: () => import('../phonehub/PhoneHubPage').then(namedPage('PhoneHubPage')), permissions: PHONE_HUB_ADMIN_PERMISSIONS }],
34
35
  };
35
36
  export const COMMUNICATIONS_ADMIN_SECTIONS = [SHORTLINKS_ADMIN_SECTION, EMAIL_ADMIN_SECTION, PUBLIC_MESSAGES_ADMIN_SECTION, PUSH_ADMIN_SECTION, PHONE_HUB_ADMIN_SECTION] as const;
@@ -1,3 +1,4 @@
1
+ import { namedPage } from './lazy-page';
1
2
  import type { AdminSection } from '../core';
2
3
  import { GLOBAL_CREDENTIAL_PERMS } from '../credentials/models';
3
4
  import { MEMBER_READ_PERMISSIONS } from '../identity/members/models';
@@ -7,35 +8,35 @@ import { GROUP_VIEW_PERMS } from '../identity/groups/group-permissions';
7
8
 
8
9
  export const USERS_ADMIN_SECTION: AdminSection = {
9
10
  id: 'users', basePath: '', title: 'Users', icon: 'bi-people', navigationGroup: 'identity-access', permissions: USER_VIEW_PERMISSIONS,
10
- routes: [{ path: 'users', label: 'Users', loadComponent: () => import('../identity/users/UsersPage').then(({ UsersPage }) => ({ default: UsersPage })), permissions: USER_VIEW_PERMISSIONS }],
11
+ routes: [{ path: 'users', label: 'Users', loadComponent: () => import('../identity/users/UsersPage').then(namedPage('UsersPage')), permissions: USER_VIEW_PERMISSIONS }],
11
12
  };
12
13
 
13
14
  export const MEMBERS_ADMIN_SECTION: AdminSection = {
14
15
  id: 'members', basePath: '', title: 'Members', icon: 'bi-person-badge', navigationGroup: 'identity-access', permissions: MEMBER_READ_PERMISSIONS,
15
- routes: [{ path: 'members', label: 'Members', loadComponent: () => import('../identity/members/MembersPage').then(({ MembersPage }) => ({ default: MembersPage })), permissions: MEMBER_READ_PERMISSIONS }],
16
+ routes: [{ path: 'members', label: 'Members', loadComponent: () => import('../identity/members/MembersPage').then(namedPage('MembersPage')), permissions: MEMBER_READ_PERMISSIONS }],
16
17
  };
17
18
 
18
19
  export const CREDENTIALS_ADMIN_SECTION: AdminSection = {
19
20
  id: 'credentials', basePath: '', title: 'Credentials', icon: 'bi-key', navigationGroup: 'identity-access', permissions: GLOBAL_CREDENTIAL_PERMS,
20
21
  routes: [
21
- { path: 'api-keys', label: 'Group API Keys', loadComponent: () => import('../credentials/group-api-keys').then(({ GroupApiKeysPage }) => ({ default: GroupApiKeysPage })), permissions: GLOBAL_CREDENTIAL_PERMS },
22
- { path: 'webhook-subscriptions', label: 'Webhooks', loadComponent: () => import('../credentials/webhook-subscriptions').then(({ WebhookSubscriptionsPage }) => ({ default: WebhookSubscriptionsPage })), permissions: GLOBAL_CREDENTIAL_PERMS },
22
+ { path: 'api-keys', label: 'Group API Keys', loadComponent: () => import('../credentials/group-api-keys').then(namedPage('GroupApiKeysPage')), permissions: GLOBAL_CREDENTIAL_PERMS },
23
+ { path: 'webhook-subscriptions', label: 'Webhooks', loadComponent: () => import('../credentials/webhook-subscriptions').then(namedPage('WebhookSubscriptionsPage')), permissions: GLOBAL_CREDENTIAL_PERMS },
23
24
  ],
24
25
  };
25
26
 
26
27
  export const SIGNIN_ADMIN_SECTION: AdminSection = {
27
28
  id: 'signin', basePath: 'system/sign-in', title: 'Sign-in', icon: 'bi-box-arrow-in-right', navigationGroup: 'identity-access', permissions: SIGNIN_ADMIN_PERMISSIONS,
28
- routes: [{ path: '', loadComponent: () => import('../signin/SigninPage').then(({ SigninPage }) => ({ default: SigninPage })), permissions: SIGNIN_ADMIN_PERMISSIONS }],
29
+ routes: [{ path: '', loadComponent: () => import('../signin/SigninPage').then(namedPage('SigninPage')), permissions: SIGNIN_ADMIN_PERMISSIONS }],
29
30
  };
30
31
 
31
32
  export const GROUPS_ADMIN_SECTION: AdminSection = {
32
33
  id: 'groups', basePath: '', title: 'Groups', icon: 'bi-diagram-3', navigationGroup: 'identity-access', permissions: GROUP_VIEW_PERMS,
33
- routes: [{ path: 'groups', label: 'Groups', loadComponent: () => import('../identity/groups/GroupsPage').then(({ GroupsPage }) => ({ default: GroupsPage })), permissions: GROUP_VIEW_PERMS }],
34
+ routes: [{ path: 'groups', label: 'Groups', loadComponent: () => import('../identity/groups/GroupsPage').then(namedPage('GroupsPage')), permissions: GROUP_VIEW_PERMS }],
34
35
  };
35
36
 
36
37
  export const PERSONAL_API_KEYS_ADMIN_SECTION: AdminSection = {
37
38
  id: 'personal-api-keys', basePath: '', title: 'Personal API Keys', icon: 'bi-key', navigationGroup: 'identity-access', permissions: USER_VIEW_PERMISSIONS,
38
- routes: [{ path: 'apikeys', label: 'Personal API Keys', loadComponent: () => import('../identity/users/PersonalApiKeysPage').then(({ PersonalApiKeysPage }) => ({ default: PersonalApiKeysPage })) }],
39
+ routes: [{ path: 'apikeys', label: 'Personal API Keys', loadComponent: () => import('../identity/users/PersonalApiKeysPage').then(namedPage('PersonalApiKeysPage')) }],
39
40
  };
40
41
 
41
42
  export const IDENTITY_ADMIN_SECTIONS = [
@@ -1,3 +1,4 @@
1
+ import { namedPage } from './lazy-page';
1
2
  import type { AdminSection } from '../core';
2
3
  import { DNS_MANAGE_PERMISSIONS, DNS_VIEW_PERMISSIONS } from '../dns/models';
3
4
  import { BUCKET_MANAGE_PERMS, STORAGE_VIEW_PERMS } from '../storage/permissions';
@@ -6,29 +7,34 @@ import { SETTINGS_PERMISSIONS } from '../settings/model';
6
7
  export const DNS_ADMIN_SECTION: AdminSection = {
7
8
  id: 'dns', title: 'DNS', icon: 'bi-globe2', navigationGroup: 'infrastructure', basePath: 'dns', permissions: DNS_VIEW_PERMISSIONS,
8
9
  routes: [
9
- { path: 'domains', label: 'Domains', loadComponent: () => import('../dns/DomainsPage').then(({ DomainsPage }) => ({ default: DomainsPage })), permissions: DNS_VIEW_PERMISSIONS },
10
- { path: 'records', label: 'DNS Records', loadComponent: () => import('../dns/DnsRecordsPage').then(({ DnsRecordsPage }) => ({ default: DnsRecordsPage })), permissions: DNS_VIEW_PERMISSIONS },
11
- { path: 'certificates', label: 'Certificates', loadComponent: () => import('../dns/CertificatesPage').then(({ CertificatesPage }) => ({ default: CertificatesPage })), permissions: DNS_VIEW_PERMISSIONS },
12
- { path: 'purchases', label: 'Domain Purchases', loadComponent: () => import('../dns/DomainPurchasesPage').then(({ DomainPurchasesPage }) => ({ default: DomainPurchasesPage })), permissions: DNS_VIEW_PERMISSIONS },
13
- { path: 'registrant', label: 'Registrant Contact', loadComponent: () => import('../dns/RegistrantContactPage').then(({ RegistrantContactPage }) => ({ default: RegistrantContactPage })), permissions: DNS_MANAGE_PERMISSIONS },
14
- { path: 'credentials', label: 'Provider Credentials', loadComponent: () => import('../dns/ProviderCredentialsPage').then(({ ProviderCredentialsPage }) => ({ default: ProviderCredentialsPage })), permissions: DNS_VIEW_PERMISSIONS },
10
+ { path: 'domains', label: 'Domains', loadComponent: () => import('../dns/DomainsPage').then(namedPage('DomainsPage')), permissions: DNS_VIEW_PERMISSIONS },
11
+ { path: 'records', label: 'DNS Records', loadComponent: () => import('../dns/DnsRecordsPage').then(namedPage('DnsRecordsPage')), permissions: DNS_VIEW_PERMISSIONS },
12
+ { path: 'certificates', label: 'Certificates', loadComponent: () => import('../dns/CertificatesPage').then(namedPage('CertificatesPage')), permissions: DNS_VIEW_PERMISSIONS },
13
+ { path: 'purchases', label: 'Domain Purchases', loadComponent: () => import('../dns/DomainPurchasesPage').then(namedPage('DomainPurchasesPage')), permissions: DNS_VIEW_PERMISSIONS },
14
+ { path: 'registrant', label: 'Registrant Contact', loadComponent: () => import('../dns/RegistrantContactPage').then(namedPage('RegistrantContactPage')), permissions: DNS_MANAGE_PERMISSIONS },
15
+ { path: 'credentials', label: 'Provider Credentials', loadComponent: () => import('../dns/ProviderCredentialsPage').then(namedPage('ProviderCredentialsPage')), permissions: DNS_VIEW_PERMISSIONS },
15
16
  ],
16
17
  };
17
18
  export const STORAGE_ADMIN_SECTION: AdminSection = {
18
19
  id: 'storage', basePath: 'storage', title: 'Storage', icon: 'bi-hdd-stack', navigationGroup: 'infrastructure', permissions: ['sys.view_fileman', 'sys.manage_files', 'sys.manage_aws', 'sys.files', ...SETTINGS_PERMISSIONS],
19
20
  routes: [
20
- { path: 'buckets', label: 'Buckets', loadComponent: () => import('../storage/BucketsPage').then(({ BucketsPage }) => ({ default: BucketsPage })), permissions: BUCKET_MANAGE_PERMS },
21
- { path: 'backends', label: 'Backends', loadComponent: () => import('../storage/BackendsPage').then(({ BackendsPage }) => ({ default: BackendsPage })), permissions: STORAGE_VIEW_PERMS },
22
- { path: 'files', label: 'Files', loadComponent: () => import('../storage/FilesPage').then(({ FilesPage }) => ({ default: FilesPage })), permissions: STORAGE_VIEW_PERMS },
21
+ { path: 'buckets', label: 'Buckets', loadComponent: () => import('../storage/BucketsPage').then(namedPage('BucketsPage')), permissions: BUCKET_MANAGE_PERMS },
22
+ { path: 'backends', label: 'Backends', loadComponent: () => import('../storage/BackendsPage').then(namedPage('BackendsPage')), permissions: STORAGE_VIEW_PERMS },
23
+ { path: 'files', label: 'Files', loadComponent: () => import('../storage/FilesPage').then(namedPage('FilesPage')), permissions: STORAGE_VIEW_PERMS },
23
24
  // Media rendering edits three Setting rows, so it carries the Settings page's gate, not the storage one.
24
- { path: 'rendering', label: 'Media rendering', loadComponent: () => import('../storage/MediaRenderingPage').then(({ MediaRenderingPage }) => ({ default: MediaRenderingPage })), permissions: SETTINGS_PERMISSIONS },
25
+ { path: 'rendering', label: 'Media rendering', loadComponent: () => import('../storage/MediaRenderingPage').then(namedPage('MediaRenderingPage')), permissions: SETTINGS_PERMISSIONS },
25
26
  ],
26
27
  };
27
28
  export const EDGE_ADMIN_SECTION: AdminSection = {
28
29
  id: 'edge', basePath: 'edge', title: 'Edge', icon: 'bi-hdd-network', navigationGroup: 'infrastructure', permissions: DNS_VIEW_PERMISSIONS,
29
30
  routes: [
30
- { path: 'vhosts', label: 'Vhosts', loadComponent: () => import('../edge/VhostsPage').then(({ VhostsPage }) => ({ default: VhostsPage })), permissions: DNS_VIEW_PERMISSIONS },
31
- { path: 'upstreams', label: 'Upstreams', loadComponent: () => import('../edge/UpstreamsPage').then(({ UpstreamsPage }) => ({ default: UpstreamsPage })), permissions: DNS_VIEW_PERMISSIONS },
31
+ { path: 'vhosts', label: 'Serving', loadComponent: () => import('../edge/VhostsPage').then(namedPage('VhostsPage')), permissions: DNS_VIEW_PERMISSIONS },
32
+ { path: 'upstreams', label: 'Upstreams', loadComponent: () => import('../edge/UpstreamsPage').then(namedPage('UpstreamsPage')), permissions: DNS_VIEW_PERMISSIONS },
32
33
  ],
33
34
  };
34
- export const INFRASTRUCTURE_ADMIN_SECTIONS = [DNS_ADMIN_SECTION, STORAGE_ADMIN_SECTION, EDGE_ADMIN_SECTION] as const;
35
+ export const FLEET_ADMIN_SECTION: AdminSection = {
36
+ id: 'fleet', title: 'Fleet', icon: 'bi-cloud', navigationGroup: 'operations',
37
+ permissions: ['sys.view_admin', 'sys.manage_users', 'sys.manage_settings', 'sys.admin', 'sys.manage_aws'],
38
+ routes: [{ path: '', label: 'Fleet', loadComponent: () => import('../fleet/FleetPage').then(namedPage('FleetPage')) }],
39
+ };
40
+ export const INFRASTRUCTURE_ADMIN_SECTIONS = [FLEET_ADMIN_SECTION, DNS_ADMIN_SECTION, STORAGE_ADMIN_SECTION, EDGE_ADMIN_SECTION] as const;
@@ -0,0 +1,6 @@
1
+ import type { ComponentType } from 'react';
2
+
3
+ /** Adapt an explicit named page export; every route keeps its literal lazy import. */
4
+ export function namedPage<TName extends string>(name: TName) {
5
+ return <TModule extends Record<TName, ComponentType>>(module: TModule) => ({ default: module[name] });
6
+ }
@@ -1,3 +1,4 @@
1
+ import { namedPage } from './lazy-page';
1
2
  import type { AdminSection } from '../core';
2
3
  import { CLOUDWATCH_PERMISSIONS } from '../cloudwatch/data';
3
4
  import { DASHBOARD_METRIC_PERMISSIONS } from '../dashboard/data';
@@ -10,19 +11,19 @@ import { SECURITY_VIEW_PERMS } from '../security-permissions';
10
11
  export const ADMIN_DASHBOARD_PERMISSIONS = [...new Set([...DASHBOARD_METRIC_PERMISSIONS, ...SECURITY_VIEW_PERMS, ...JOBS_VIEW_PERMS, ...EMAIL_ADMIN_PERMISSIONS, ...LOGIN_SUMMARY_PERMS])];
11
12
  export const DASHBOARD_ADMIN_SECTION: AdminSection = {
12
13
  id: 'dashboard', basePath: '', title: 'Dashboard', icon: 'bi-grid-1x2', navigationGroup: null, permissions: ADMIN_DASHBOARD_PERMISSIONS,
13
- routes: [{ path: '', label: 'Dashboard', loadComponent: () => import('../dashboard/AdminDashboardPage').then(({ AdminDashboardPage }) => ({ default: AdminDashboardPage })), permissions: ADMIN_DASHBOARD_PERMISSIONS, fallbackToFirstVisible: true }],
14
+ routes: [{ path: '', label: 'Dashboard', loadComponent: () => import('../dashboard/AdminDashboardPage').then(namedPage('AdminDashboardPage')), permissions: ADMIN_DASHBOARD_PERMISSIONS, fallbackToFirstVisible: true }],
14
15
  };
15
16
  export const MONITORING_ADMIN_SECTION: AdminSection = {
16
17
  id: 'monitoring', basePath: '', title: 'Monitoring', icon: 'bi-activity', navigationGroup: 'observability',
17
18
  permissions: [...new Set([...LOGS_ADMIN_PERMISSIONS, ...METRICS_EXPLORER_PERMISSIONS, ...METRICS_PERMISSIONS_ADMIN_PERMISSIONS])],
18
19
  routes: [
19
- { path: 'logs', label: 'Logs', loadComponent: () => import('../monitoring/LogsPage').then(({ LogsPage }) => ({ default: LogsPage })), permissions: LOGS_ADMIN_PERMISSIONS },
20
- { path: 'metrics/explorer', label: 'Metrics Explorer', loadComponent: () => import('../monitoring/MetricsExplorerPage').then(({ MetricsExplorerPage }) => ({ default: MetricsExplorerPage })), permissions: METRICS_EXPLORER_PERMISSIONS },
21
- { path: 'metrics/permissions', label: 'Metrics Permissions', loadComponent: () => import('../monitoring/MetricsPermissionsPage').then(({ MetricsPermissionsPage }) => ({ default: MetricsPermissionsPage })), permissions: METRICS_PERMISSIONS_ADMIN_PERMISSIONS },
20
+ { path: 'logs', label: 'Logs', loadComponent: () => import('../monitoring/LogsPage').then(namedPage('LogsPage')), permissions: LOGS_ADMIN_PERMISSIONS },
21
+ { path: 'metrics/explorer', label: 'Metrics Explorer', loadComponent: () => import('../monitoring/MetricsExplorerPage').then(namedPage('MetricsExplorerPage')), permissions: METRICS_EXPLORER_PERMISSIONS },
22
+ { path: 'metrics/permissions', label: 'Metrics Permissions', loadComponent: () => import('../monitoring/MetricsPermissionsPage').then(namedPage('MetricsPermissionsPage')), permissions: METRICS_PERMISSIONS_ADMIN_PERMISSIONS },
22
23
  ],
23
24
  };
24
25
  export const CLOUDWATCH_ADMIN_SECTION: AdminSection = {
25
26
  id: 'cloudwatch', title: 'CloudWatch', icon: 'bi-clouds', navigationGroup: 'observability', permissions: CLOUDWATCH_PERMISSIONS,
26
- routes: [{ path: '', label: 'CloudWatch', loadComponent: () => import('../cloudwatch/CloudWatchDashboardPage').then(({ CloudWatchDashboardPage }) => ({ default: CloudWatchDashboardPage })), permissions: CLOUDWATCH_PERMISSIONS }],
27
+ routes: [{ path: '', label: 'CloudWatch', loadComponent: () => import('../cloudwatch/CloudWatchDashboardPage').then(namedPage('CloudWatchDashboardPage')), permissions: CLOUDWATCH_PERMISSIONS }],
27
28
  };
28
29
  export const OBSERVABILITY_ADMIN_SECTIONS = [DASHBOARD_ADMIN_SECTION, MONITORING_ADMIN_SECTION, CLOUDWATCH_ADMIN_SECTION] as const;
@@ -1,25 +1,42 @@
1
+ import { namedPage } from './lazy-page';
1
2
  import type { AdminSection } from '../core';
2
3
  import { JOBS_VIEW_PERMS, SCHEDULED_TASK_VIEW_PERMS } from '../jobs/models';
3
4
  import { SETTINGS_PERMISSIONS } from '../settings/model';
4
5
 
6
+ const navigationGroup = 'operations';
7
+
5
8
  export const SETTINGS_ADMIN_SECTION: AdminSection = {
6
- id: 'settings', title: 'Settings', icon: 'bi-gear', navigationGroup: 'operations', permissions: SETTINGS_PERMISSIONS,
7
- routes: [{ path: '', loadComponent: () => import('../settings/SettingsPage').then(({ SettingsPage }) => ({ default: SettingsPage })), permissions: SETTINGS_PERMISSIONS }],
9
+ id: 'settings', title: 'Settings', icon: 'bi-gear', navigationGroup, permissions: SETTINGS_PERMISSIONS,
10
+ routes: [{ path: '', loadComponent: () => import('../settings/SettingsPage').then(namedPage('SettingsPage')), permissions: SETTINGS_PERMISSIONS }],
8
11
  };
9
12
  // Empty any-of permissions admit only the existing literal-superuser bypass.
10
13
  // A global admin permission alone must not expose this configuration surface.
11
14
  export const FLEET_CONFIGURATION_ADMIN_SECTION: AdminSection = {
12
15
  id: 'fleet-configuration', title: 'Fleet Configuration', icon: 'bi-hdd-network',
13
- navigationGroup: 'operations', permissions: [],
14
- routes: [{ path: '', loadComponent: () => import('../fleet-configuration/FleetConfigurationPage').then(({ FleetConfigurationPage }) => ({ default: FleetConfigurationPage })) }],
16
+ navigationGroup, permissions: [],
17
+ routes: ['', 'shared-validation'].map((path) => ({ path, requireSuperuser: true, loadComponent: () => import('../fleet-configuration/FleetConfigurationPage').then(namedPage('FleetConfigurationPage')) })),
15
18
  };
16
19
  export const JOBS_ADMIN_SECTION: AdminSection = {
17
- id: 'jobs', title: 'Jobs', icon: 'bi-cpu', navigationGroup: 'operations', permissions: [...new Set([...JOBS_VIEW_PERMS, ...SCHEDULED_TASK_VIEW_PERMS])],
20
+ id: 'jobs', title: 'Jobs', icon: 'bi-cpu', navigationGroup, permissions: [...new Set([...JOBS_VIEW_PERMS, ...SCHEDULED_TASK_VIEW_PERMS])],
18
21
  routes: [
19
- { path: '', label: 'Dashboard', loadComponent: () => import('../jobs/JobDashboardPage').then(({ JobDashboardPage }) => ({ default: JobDashboardPage })), permissions: JOBS_VIEW_PERMS },
20
- { path: 'runners', label: 'Runners', loadComponent: () => import('../jobs/JobRunnersPage').then(({ JobRunnersPage }) => ({ default: JobRunnersPage })), permissions: JOBS_VIEW_PERMS },
21
- { path: 'list', label: 'Jobs', loadComponent: () => import('../jobs/JobsTablePage').then(({ JobsTablePage }) => ({ default: JobsTablePage })), permissions: JOBS_VIEW_PERMS },
22
- { path: 'scheduled-tasks', label: 'Scheduled Tasks', loadComponent: () => import('../jobs/ScheduledTasksPage').then(({ ScheduledTasksPage }) => ({ default: ScheduledTasksPage })), permissions: SCHEDULED_TASK_VIEW_PERMS },
22
+ { path: '', label: 'Dashboard', loadComponent: () => import('../jobs/JobDashboardPage').then(namedPage('JobDashboardPage')), permissions: JOBS_VIEW_PERMS },
23
+ { path: 'runners', label: 'Runners', loadComponent: () => import('../jobs/JobRunnersPage').then(namedPage('JobRunnersPage')), permissions: JOBS_VIEW_PERMS },
24
+ { path: 'list', label: 'Jobs', loadComponent: () => import('../jobs/JobsTablePage').then(namedPage('JobsTablePage')), permissions: JOBS_VIEW_PERMS },
25
+ { path: 'scheduled-tasks', label: 'Scheduled Tasks', loadComponent: () => import('../jobs/ScheduledTasksPage').then(namedPage('ScheduledTasksPage')), permissions: SCHEDULED_TASK_VIEW_PERMS },
23
26
  ],
24
27
  };
25
- export const OPERATIONS_ADMIN_SECTIONS = [SETTINGS_ADMIN_SECTION, FLEET_CONFIGURATION_ADMIN_SECTION, JOBS_ADMIN_SECTION] as const;
28
+ export const DEPLOYMENTS_ADMIN_SECTION: AdminSection = {
29
+ id: 'deployments', title: 'Deployments', icon: 'bi-rocket', navigationGroup,
30
+ permissions: ['sys.view_platform', 'sys.manage_platform', 'sys.admin', 'sys.view_dns', 'sys.manage_dns', 'sys.security'],
31
+ routes: [{ path: '', label: 'Deployments', loadComponent: () => import('../deployments/DeploymentsPage').then(namedPage('DeploymentsPage')) }],
32
+ };
33
+ export const MAINTENANCE_ADMIN_SECTION: AdminSection = {
34
+ id: 'maintenance', title: 'Maintenance', icon: 'bi-tools', navigationGroup,
35
+ permissions: ['sys.manage_aws', 'sys.view_platform', 'sys.manage_platform', 'sys.admin'],
36
+ routes: [{ path: '', label: 'Maintenance', loadComponent: () => import('../maintenance/MaintenancePage').then(namedPage('MaintenancePage')) }],
37
+ };
38
+ export const GEOLOCATION_ADMIN_SECTION: AdminSection = {
39
+ id: 'geolocation', title: 'GeoLocation', icon: 'bi-geo-alt', navigationGroup, permissions: [],
40
+ routes: [{ path: '', label: 'GeoLocation', requireSuperuser: true, loadComponent: () => import('../geolocation/GeoLocationPage').then(namedPage('GeoLocationPage')) }],
41
+ };
42
+ export const OPERATIONS_ADMIN_SECTIONS = [SETTINGS_ADMIN_SECTION, FLEET_CONFIGURATION_ADMIN_SECTION, JOBS_ADMIN_SECTION, MAINTENANCE_ADMIN_SECTION, DEPLOYMENTS_ADMIN_SECTION, GEOLOCATION_ADMIN_SECTION] as const;
@@ -1,3 +1,4 @@
1
+ import { namedPage } from './lazy-page';
1
2
  import type { AdminRoute, AdminSection } from '../core';
2
3
  import { BOUNCER_VIEW_PERMS } from '../bouncer/models';
3
4
  import { EDGE_BLOCKLIST_VIEW_PERMS } from '../edge/permissions';
@@ -9,48 +10,48 @@ import { GEOFENCE_VIEW_PERMS } from '../network/geofence/permissions';
9
10
  import { IPSET_VIEW_PERMS } from '../network/permissions';
10
11
 
11
12
  export const INCIDENTS_ADMIN_ROUTES: AdminRoute[] = [
12
- { path: 'incidents', label: 'Incidents', loadComponent: () => import('../incidents/IncidentsPage').then(({ IncidentsPage }) => ({ default: IncidentsPage })), permissions: SECURITY_VIEW_PERMS },
13
- { path: 'events', label: 'Events', loadComponent: () => import('../incidents/EventsPage').then(({ EventsPage }) => ({ default: EventsPage })), permissions: SECURITY_VIEW_PERMS },
13
+ { path: 'incidents', label: 'Incidents', loadComponent: () => import('../incidents/IncidentsPage').then(namedPage('IncidentsPage')), permissions: SECURITY_VIEW_PERMS },
14
+ { path: 'events', label: 'Events', loadComponent: () => import('../incidents/EventsPage').then(namedPage('EventsPage')), permissions: SECURITY_VIEW_PERMS },
14
15
  ];
15
16
  export const RULES_ADMIN_ROUTES: AdminRoute[] = [
16
- { path: 'rules', label: 'Rule Engine', loadComponent: () => import('../rules/RuleSetsPage').then(({ RuleSetsPage }) => ({ default: RuleSetsPage })), permissions: SECURITY_VIEW_PERMS },
17
+ { path: 'rules', label: 'Rule Engine', loadComponent: () => import('../rules/RuleSetsPage').then(namedPage('RuleSetsPage')), permissions: SECURITY_VIEW_PERMS },
17
18
  ];
18
19
 
19
20
  export const SECURITY_OPERATIONS_ADMIN_SECTION: AdminSection = {
20
21
  id: 'security-operations', basePath: 'security', title: 'Security Operations', icon: 'bi-shield-check', navigationGroup: 'security', permissions: SECURITY_VIEW_PERMS,
21
22
  routes: [
22
- { path: 'tickets', label: 'Tickets', loadComponent: () => import('../security/tickets').then(({ TicketsPage }) => ({ default: TicketsPage })), permissions: SECURITY_VIEW_PERMS },
23
+ { path: 'tickets', label: 'Tickets', loadComponent: () => import('../security/tickets').then(namedPage('TicketsPage')), permissions: SECURITY_VIEW_PERMS },
23
24
  ...INCIDENTS_ADMIN_ROUTES, ...RULES_ADMIN_ROUTES,
24
25
  ],
25
26
  };
26
27
  export const BOUNCER_ADMIN_SECTION: AdminSection = {
27
28
  id: 'bouncer', basePath: 'security/bouncer', title: 'Bouncer', icon: 'bi-shield-check', navigationGroup: 'security', permissions: BOUNCER_VIEW_PERMS,
28
29
  routes: [
29
- { path: 'signals', label: 'Signal Decisions', loadComponent: () => import('../bouncer/signals').then(({ BouncerSignalsPage }) => ({ default: BouncerSignalsPage })), permissions: BOUNCER_VIEW_PERMS },
30
- { path: 'devices', label: 'Device Reputation', loadComponent: () => import('../bouncer/devices').then(({ BouncerDevicesPage }) => ({ default: BouncerDevicesPage })), permissions: BOUNCER_VIEW_PERMS },
31
- { path: 'signatures', label: 'Bot Signatures', loadComponent: () => import('../bouncer/signatures').then(({ BotSignaturesPage }) => ({ default: BotSignaturesPage })), permissions: BOUNCER_VIEW_PERMS },
30
+ { path: 'signals', label: 'Signal Decisions', loadComponent: () => import('../bouncer/signals').then(namedPage('BouncerSignalsPage')), permissions: BOUNCER_VIEW_PERMS },
31
+ { path: 'devices', label: 'Device Reputation', loadComponent: () => import('../bouncer/devices').then(namedPage('BouncerDevicesPage')), permissions: BOUNCER_VIEW_PERMS },
32
+ { path: 'signatures', label: 'Bot Signatures', loadComponent: () => import('../bouncer/signatures').then(namedPage('BotSignaturesPage')), permissions: BOUNCER_VIEW_PERMS },
32
33
  ],
33
34
  };
34
35
  export const DEVICE_INTEL_ADMIN_SECTION: AdminSection = {
35
36
  id: 'device-intel', basePath: 'security/devices', title: 'Devices & Logins', icon: 'bi-laptop', navigationGroup: 'security', permissions: [...new Set([...USER_DEVICE_VIEW_PERMS, ...LOGIN_EVENT_VIEW_PERMS])],
36
37
  routes: [
37
- { path: 'user-devices', label: 'User Devices', loadComponent: () => import('../security/devices/UserDevicesPage').then(({ UserDevicesPage }) => ({ default: UserDevicesPage })), permissions: USER_DEVICE_VIEW_PERMS },
38
- { path: 'login-locations', label: 'Login Locations', loadComponent: () => import('../security/devices/LoginLocationsPage').then(({ LoginLocationsPage }) => ({ default: LoginLocationsPage })), permissions: LOGIN_EVENT_VIEW_PERMS },
38
+ { path: 'user-devices', label: 'User Devices', loadComponent: () => import('../security/devices/UserDevicesPage').then(namedPage('UserDevicesPage')), permissions: USER_DEVICE_VIEW_PERMS },
39
+ { path: 'login-locations', label: 'Login Locations', loadComponent: () => import('../security/devices/LoginLocationsPage').then(namedPage('LoginLocationsPage')), permissions: LOGIN_EVENT_VIEW_PERMS },
39
40
  ],
40
41
  };
41
42
  export const GEOIP_ADMIN_SECTION: AdminSection = {
42
43
  id: 'geoip', basePath: 'security/geoip', title: 'IP Intelligence', icon: 'bi-globe2', navigationGroup: 'security', permissions: GEOIP_VIEW_PERMS,
43
- routes: [{ path: 'cache', label: 'GeoIP Cache', loadComponent: () => import('../security/geoip/GeoIpCachePage').then(({ GeoIpCachePage }) => ({ default: GeoIpCachePage })), permissions: GEOIP_VIEW_PERMS }],
44
+ routes: [{ path: 'cache', label: 'GeoIP Cache', loadComponent: () => import('../security/geoip/GeoIpCachePage').then(namedPage('GeoIpCachePage')), permissions: GEOIP_VIEW_PERMS }],
44
45
  };
45
46
  export const NETWORK_SECURITY_ADMIN_SECTION: AdminSection = {
46
47
  id: 'network-security', basePath: 'security/network', title: 'Network Security', icon: 'bi-hdd-network', navigationGroup: 'security',
47
48
  permissions: [...new Set([...GEOIP_VIEW_PERMS, ...LOGS_ADMIN_PERMISSIONS, ...IPSET_VIEW_PERMS, ...GEOFENCE_VIEW_PERMS, ...EDGE_BLOCKLIST_VIEW_PERMS])],
48
49
  routes: [
49
- { path: 'blocked-ips', label: 'Blocked IPs', loadComponent: () => import('../network/BlockedIPsPage').then(({ BlockedIPsPage }) => ({ default: BlockedIPsPage })), permissions: GEOIP_VIEW_PERMS },
50
- { path: 'firewall-log', label: 'Firewall Log', loadComponent: () => import('../network/FirewallLogPage').then(({ FirewallLogPage }) => ({ default: FirewallLogPage })), permissions: LOGS_ADMIN_PERMISSIONS },
51
- { path: 'ip-sets', label: 'IP Sets', loadComponent: () => import('../network/IPSetsPage').then(({ IPSetsPage }) => ({ default: IPSetsPage })), permissions: IPSET_VIEW_PERMS },
52
- { path: 'geofencing', label: 'Geofencing', loadComponent: () => import('../network/geofence/GeofencingPage').then(({ GeofencingPage }) => ({ default: GeofencingPage })), permissions: GEOFENCE_VIEW_PERMS },
53
- { path: 'edge-blocklist', label: 'Edge Blocklist', loadComponent: () => import('../edge/BlocklistPage').then(({ BlocklistPage }) => ({ default: BlocklistPage })), permissions: EDGE_BLOCKLIST_VIEW_PERMS },
50
+ { path: 'blocked-ips', label: 'Blocked IPs', loadComponent: () => import('../network/BlockedIPsPage').then(namedPage('BlockedIPsPage')), permissions: GEOIP_VIEW_PERMS },
51
+ { path: 'firewall-log', label: 'Firewall Log', loadComponent: () => import('../network/FirewallLogPage').then(namedPage('FirewallLogPage')), permissions: LOGS_ADMIN_PERMISSIONS },
52
+ { path: 'ip-sets', label: 'IP Sets', loadComponent: () => import('../network/IPSetsPage').then(namedPage('IPSetsPage')), permissions: IPSET_VIEW_PERMS },
53
+ { path: 'geofencing', label: 'Geofencing', loadComponent: () => import('../network/geofence/GeofencingPage').then(namedPage('GeofencingPage')), permissions: GEOFENCE_VIEW_PERMS },
54
+ { path: 'edge-blocklist', label: 'Edge Blocklist', loadComponent: () => import('../edge/BlocklistPage').then(namedPage('BlocklistPage')), permissions: EDGE_BLOCKLIST_VIEW_PERMS },
54
55
  ],
55
56
  };
56
57
 
@@ -0,0 +1,10 @@
1
+ import { useId, useRef, useState, type ReactNode } from 'react';
2
+ import { Popover } from '../../ui';
3
+
4
+ /** Short, keyboard-accessible help, including inside native dialogs. */
5
+ export function EdgeHelp({ label, children }: { label: string; children: ReactNode }) {
6
+ const anchor = useRef<HTMLButtonElement>(null);
7
+ const [open, setOpen] = useState(false);
8
+ const id = useId();
9
+ return <span className="edge-help"><button ref={anchor} type="button" className="btn-icon" aria-label={label} aria-expanded={open} aria-controls={open ? id : undefined} onClick={() => setOpen(value => !value)}><i className="bi bi-info-circle" aria-hidden="true" /></button><Popover id={id} anchorRef={anchor} open={open} onClose={() => setOpen(false)} className="edge-help-popover" aria-label={label}>{children}</Popover></span>;
10
+ }
@@ -0,0 +1,54 @@
1
+ import { useEffect, useRef, useState } from 'react';
2
+ import { getAuthSessionRevision, getAuthSnapshot, useAuthSnapshot, useMe } from '../../client/runtime';
3
+ import { Badge, FlatRow, modal } from '../../ui';
4
+ import { EdgeHelp } from './EdgeHelp';
5
+ import { datetime } from '../../ui/format';
6
+ import { backendGlobalCan, FLEET_DASHBOARD_GRANTS } from '../fleet/access';
7
+ import { readHostingReadiness, readServingBootstrap, type HostingReadiness, type ServingBootstrap } from './serving-data';
8
+
9
+ /** Read-only evidence, deliberately outside Query: raw bootstrap never enters a cache. */
10
+ export function ServingReadiness({ refreshRevision = 0 }: { refreshRevision?: number }) {
11
+ const auth = useAuthSnapshot(), { data: me } = useMe();
12
+ const scope = auth.authenticated && auth.uid && me?.id === Number(auth.uid) ? `${auth.uid}:${getAuthSessionRevision()}` : null;
13
+ const admitted = !!scope && backendGlobalCan(me, ['view_dns', 'manage_dns', 'security']);
14
+ const bootstrapAllowed = admitted && backendGlobalCan(me, FLEET_DASHBOARD_GRANTS);
15
+ const [refresh, setRefresh] = useState(0);
16
+ const [state, setState] = useState<{ scope: string | null; busy: boolean; bootstrap?: ServingBootstrap; report?: HostingReadiness; error?: string; reportError?: string }>({ scope: null, busy: false });
17
+ const flight = useRef(0);
18
+ const owner = useRef<AbortController | null>(null);
19
+ const evidenceAuthority = `${scope}:${admitted}:${bootstrapAllowed}:${me?.is_superuser === true}:${state.scope === scope && state.bootstrap?.user.is_superuser === true}:${state.scope === scope && state.bootstrap?.capabilities.setup === true}`;
20
+ useEffect(() => { const controller = new AbortController(); owner.current = controller; return () => controller.abort(); }, [evidenceAuthority]);
21
+ useEffect(() => {
22
+ const serial = ++flight.current, controller = new AbortController();
23
+ const current = () => !controller.signal.aborted && flight.current === serial && getAuthSnapshot().authenticated && `${getAuthSnapshot().uid}:${getAuthSessionRevision()}` === scope;
24
+ setState({ scope, busy: bootstrapAllowed });
25
+ if (!bootstrapAllowed || !me || !scope) return () => controller.abort();
26
+ const userId = me.id, superuser = me.is_superuser === true;
27
+ void (async () => {
28
+ try {
29
+ const bootstrap = await readServingBootstrap(userId, controller.signal);
30
+ if (!current()) return;
31
+ setState({ scope, busy: superuser && bootstrap.user.is_superuser && bootstrap.capabilities.setup, bootstrap });
32
+ if (superuser && bootstrap.user.is_superuser && bootstrap.capabilities.setup === true) {
33
+ try {
34
+ const report = await readHostingReadiness(controller.signal);
35
+ if (current()) setState({ scope, busy: false, bootstrap, report });
36
+ } catch (error) {
37
+ if (current()) setState({ scope, busy: false, bootstrap, reportError: error instanceof Error ? error.message : 'Readiness observation failed.' });
38
+ }
39
+ }
40
+ } catch (error) {
41
+ if (current()) setState({ scope, busy: false, error: error instanceof Error ? error.message : 'Node posture observation failed.' });
42
+ }
43
+ })();
44
+ return () => controller.abort();
45
+ }, [scope, bootstrapAllowed, me?.id, me?.is_superuser, refresh, refreshRevision]);
46
+ if (!bootstrapAllowed) return null;
47
+ const visible: typeof state = state.scope === scope ? state : { scope, busy: false };
48
+ const edge = visible.bootstrap?.edge, report = visible.report, selected = report?.sections.find(section => section.code === 'hosting_vhosts');
49
+ const unavailable = visible.error;
50
+ const showDetails = () => modal.open(close => <div className="modal-pad edge-check-dialog"><h2 className="modal-title">Serving configuration checks</h2><div className="edge-check-body"><FlatRow label="Public HTTP">{edge?.available && edge.http_enabled !== null ? edge.http_enabled ? 'Enabled' : 'Disabled' : 'Unknown'}</FlatRow><FlatRow label="Certificate issuance">{edge?.available && edge.dnsman_issuance === 'dns-01' ? 'DNS-01' : 'Unknown'}</FlatRow>{unavailable && <p role="status">Unavailable: {unavailable}</p>}
51
+ {selected && report ? <><FlatRow label="Generated"><time dateTime={report.generated_at}>{datetime(report.generated_at)}</time></FlatRow><FlatRow label="Coverage">{selected.coverage.returned} of {selected.coverage.total} checks · {selected.coverage.omitted} omitted</FlatRow><FlatRow label="Report status">{report.overall} · {report.summary.pass} pass · {report.summary.warn} warn · {report.summary.fail} fail · {report.summary.pending} pending</FlatRow>{(report.truncated || selected.coverage.omitted > 0 || report.coverage.checks.omitted > 0 || report.coverage.sections.omitted > 0) && <p className="text-warn">Partial report · some checks are omitted.</p>}{selected.checks.map((check, index) => <div key={`${check.code}:${index}`} className="edge-serving-check"><b>{check.status} · {check.code}</b><p>{check.explanation}</p>{check.remediation && <p>{check.remediation}</p>}{check.details && Object.entries(check.details).map(([key, value]) => <p key={key}>{key}: {String(value)}</p>)}</div>)}</> : <p role="status">{visible.busy ? 'Loading observation…' : `Unavailable: ${visible.reportError ?? unavailable ?? 'Requires a literal superuser and successful same-identity bootstrap with setup capability.'}`}</p>}
52
+ <p className="dim">These checks inspect domain and certificate configuration. They do not prove certificate expiry, coverage, installed material, fleet convergence, expected nodes or the running generation. HTTP is a file rendering setting; DNS-01 identifies the issuance method. A hostname absent from this bounded report has no individual verdict.</p></div><div className="modal-actions"><button type="button" className="btn" onClick={() => close(null)}>Close</button></div></div>, { size: 'md', signal: owner.current?.signal });
53
+ return <section className="edge-serving-evidence" aria-label="Serving evidence"><div className="edge-check-summary"><div className="edge-check-status"><i className="bi bi-shield-check" aria-hidden="true" /><strong>Configuration checks</strong><Badge tone={selected?.status === 'fail' ? 'danger' : 'muted'}>{visible.busy ? 'Checking…' : selected?.status ?? 'Unavailable'}</Badge><EdgeHelp label="About configuration checks">Checks saved domain and certificate configuration. Fleet application and live serving remain unverified.</EdgeHelp></div><div className="edge-row-actions"><button type="button" className="btn btn-compact" onClick={() => void showDetails()}>Check details</button><button type="button" className="btn-icon" aria-label="Refresh serving evidence" title="Refresh" disabled={visible.busy} onClick={() => setRefresh(value => value + 1)}><i className={`bi bi-arrow-repeat${visible.busy ? ' spin' : ''}`} /></button></div></div>{report && <small className="dim">As of <time dateTime={report.generated_at}>{datetime(report.generated_at)}</time>{report.truncated || selected?.coverage.omitted || report.coverage.checks.omitted || report.coverage.sections.omitted ? ' · Partial report' : ''}</small>}{(visible.error || visible.reportError) && <p className="text-warn" role="status">Configuration check unavailable. Open Check details for the reason.</p>}</section>;
54
+ }