portal-mojo 0.2.20 → 0.2.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (131) hide show
  1. package/docs/README.md +7 -1
  2. package/docs/admin-deployments.md +201 -0
  3. package/docs/admin-dns-certificates.md +3 -3
  4. package/docs/admin-dns-domains.md +92 -0
  5. package/docs/admin-dns.md +4 -2
  6. package/docs/admin-edge.md +200 -196
  7. package/docs/admin-geolocation.md +100 -0
  8. package/docs/admin-maintenance.md +133 -0
  9. package/docs/admin-modals.md +17 -2
  10. package/docs/admin-operational-fleet.md +206 -0
  11. package/docs/admin-presentation.md +51 -0
  12. package/docs/admin-signin.md +12 -2
  13. package/docs/client.md +7 -0
  14. package/docs/combobox.md +18 -0
  15. package/docs/popover.md +5 -4
  16. package/package.json +1 -1
  17. package/src/admin/core/OperationsHelp.tsx +10 -0
  18. package/src/admin/core/index.ts +6 -4
  19. package/src/admin/core/route-context.ts +6 -0
  20. package/src/admin/deployments/ApiDeploymentDetail.tsx +58 -0
  21. package/src/admin/deployments/DeploymentsPage.tsx +73 -0
  22. package/src/admin/deployments/WebAppDeploySetup.tsx +49 -0
  23. package/src/admin/deployments/WebAppDeploymentDetail.tsx +59 -0
  24. package/src/admin/deployments/WebAppDetail.tsx +104 -0
  25. package/src/admin/deployments/WebAppEditDialog.tsx +70 -0
  26. package/src/admin/deployments/WebAppKeyDialog.tsx +25 -0
  27. package/src/admin/deployments/WebAppOnboardingDialog.tsx +217 -0
  28. package/src/admin/deployments/WebAppRollbackDialog.tsx +82 -0
  29. package/src/admin/deployments/WebAppUploadPanel.tsx +132 -0
  30. package/src/admin/deployments/api.ts +158 -0
  31. package/src/admin/deployments/authority-state.ts +9 -0
  32. package/src/admin/deployments/deployment-state.ts +13 -0
  33. package/src/admin/deployments/index.ts +4 -0
  34. package/src/admin/deployments/onboarding-state.ts +11 -0
  35. package/src/admin/deployments/permissions.ts +37 -0
  36. package/src/admin/deployments/receipts.ts +78 -0
  37. package/src/admin/deployments/sanitize.ts +73 -0
  38. package/src/admin/deployments/shared.tsx +39 -0
  39. package/src/admin/deployments/types.ts +238 -0
  40. package/src/admin/deployments/upload.ts +53 -0
  41. package/src/admin/deployments/useDeploymentObservation.ts +104 -0
  42. package/src/admin/dns/DomainDetail.tsx +10 -6
  43. package/src/admin/dns/DomainOnboardingContent.tsx +44 -0
  44. package/src/admin/dns/DomainOnboardingDialog.tsx +5 -24
  45. package/src/admin/dns/DomainPurchaseWizard.tsx +1 -1
  46. package/src/admin/dns/DomainRegisterExisting.tsx +63 -0
  47. package/src/admin/dns/DomainRegistrarSettings.tsx +52 -0
  48. package/src/admin/dns/DomainsPage.tsx +22 -13
  49. package/src/admin/dns/SharedValidationDialog.tsx +1 -1
  50. package/src/admin/dns/api.ts +4 -2
  51. package/src/admin/dns/domain-navigation.ts +16 -0
  52. package/src/admin/dns/registrar-api.ts +13 -0
  53. package/src/admin/domains/assistant.ts +5 -4
  54. package/src/admin/domains/communications.ts +10 -9
  55. package/src/admin/domains/identity.ts +8 -7
  56. package/src/admin/domains/infrastructure.ts +19 -13
  57. package/src/admin/domains/lazy-page.ts +6 -0
  58. package/src/admin/domains/observability.ts +6 -5
  59. package/src/admin/domains/operations.ts +22 -7
  60. package/src/admin/domains/security.ts +16 -15
  61. package/src/admin/edge/EdgeHelp.tsx +10 -0
  62. package/src/admin/edge/ServingReadiness.tsx +54 -0
  63. package/src/admin/edge/VhostDetail.tsx +145 -139
  64. package/src/admin/edge/VhostWizard.tsx +204 -115
  65. package/src/admin/edge/VhostsPage.tsx +27 -28
  66. package/src/admin/edge/api.ts +24 -10
  67. package/src/admin/edge/models.ts +4 -4
  68. package/src/admin/edge/serving-data.ts +78 -0
  69. package/src/admin/fleet/CapacityEditor.tsx +81 -0
  70. package/src/admin/fleet/CapacityInspector.tsx +19 -0
  71. package/src/admin/fleet/CapacityProgress.tsx +9 -0
  72. package/src/admin/fleet/FleetHelp.tsx +7 -0
  73. package/src/admin/fleet/FleetOverview.tsx +150 -0
  74. package/src/admin/fleet/FleetPage.tsx +11 -0
  75. package/src/admin/fleet/FleetWorkspace.tsx +82 -0
  76. package/src/admin/fleet/ScaleFleetDialog.tsx +169 -0
  77. package/src/admin/fleet/access.ts +22 -0
  78. package/src/admin/fleet/api.ts +77 -0
  79. package/src/admin/fleet/data.ts +148 -0
  80. package/src/admin/fleet/index.ts +8 -0
  81. package/src/admin/fleet/operations.ts +179 -0
  82. package/src/admin/fleet/standalone-data.ts +87 -0
  83. package/src/admin/fleet/types.ts +208 -0
  84. package/src/admin/geolocation/GeoLocationPage.tsx +171 -0
  85. package/src/admin/geolocation/data.ts +138 -0
  86. package/src/admin/geolocation/index.ts +3 -0
  87. package/src/admin/identity/members/GroupMembersPanel.tsx +2 -8
  88. package/src/admin/index.ts +8 -0
  89. package/src/admin/maintenance/MaintenanceDialogs.tsx +161 -0
  90. package/src/admin/maintenance/MaintenancePage.tsx +442 -0
  91. package/src/admin/maintenance/data.ts +486 -0
  92. package/src/admin/maintenance/index.ts +3 -0
  93. package/src/admin/maintenance/operations.ts +208 -0
  94. package/src/admin/public/infrastructure.ts +1 -0
  95. package/src/admin/public/operations.ts +3 -0
  96. package/src/admin/registry.ts +6 -2
  97. package/src/admin/settings/SettingDetail.tsx +7 -2
  98. package/src/admin/settings/SettingEditor.tsx +2 -1
  99. package/src/admin/settings/SettingsPage.tsx +3 -1
  100. package/src/admin/settings/model.ts +1 -0
  101. package/src/admin/signin/SigninPage.tsx +17 -3
  102. package/src/admin/signin/api.ts +2 -0
  103. package/src/charts/MetricsChartView.tsx +4 -11
  104. package/src/charts/MetricsMiniWidget.tsx +3 -15
  105. package/src/client/client.ts +6 -0
  106. package/src/client/deployments-mock.ts +375 -0
  107. package/src/client/domain-operations-mock.ts +37 -0
  108. package/src/client/geo-provider-mock.ts +82 -0
  109. package/src/client/maintenance-mock.ts +285 -0
  110. package/src/client/mock.ts +52 -10
  111. package/src/client/operational-fleet-mock.ts +279 -0
  112. package/src/client/operations-mock.ts +85 -0
  113. package/src/client/serving-mock.ts +22 -0
  114. package/src/styles/components/admin-deployments.css +35 -0
  115. package/src/styles/components/admin-domains.css +12 -0
  116. package/src/styles/components/admin-edge.css +125 -1
  117. package/src/styles/components/admin-geolocation.css +40 -0
  118. package/src/styles/components/admin-maintenance.css +127 -0
  119. package/src/styles/components/admin-operational-fleet.css +38 -0
  120. package/src/styles/components/admin-operations.css +9 -0
  121. package/src/styles/components/admin-signin.css +2 -0
  122. package/src/styles/components/combobox.css +12 -2
  123. package/src/styles/components/fleet-page.css +9 -0
  124. package/src/styles/index.css +7 -0
  125. package/src/ui/ComboBox.tsx +39 -8
  126. package/src/ui/FilterBar.tsx +3 -5
  127. package/src/ui/ModelTable.tsx +2 -8
  128. package/src/ui/Popover.tsx +15 -7
  129. package/src/ui/date/TimePicker.tsx +4 -14
  130. package/src/ui/image-editor/ImageEditor.tsx +4 -5
  131. package/src/ui/schema-form-core.tsx +3 -5
@@ -0,0 +1,133 @@
1
+ # Maintenance
2
+
3
+ ```tsx
4
+ import { MaintenancePage } from 'portal-mojo/admin/operations';
5
+ ```
6
+
7
+ `MaintenancePage` supplies one global Operations destination at `maintenance`.
8
+ It renders Database, Cache and Framework as independent evidence sections.
9
+ Page admission is AWS read (`manage_aws`) **or** framework read
10
+ (`view_platform`, `manage_platform`, `admin`), with literal superuser bypass.
11
+ Each read uses the Fleet exact backend permission helper; group membership,
12
+ the frontend admin wildcard and `comms` do not grant AWS authority.
13
+
14
+ A scan reports offered major upgrades, region, scan time, scheduled-scanning
15
+ state and each warning's exact IAM action. It supplies no complete inventory
16
+ counts. Empty findings mean **No offered major upgrades found in this scan**.
17
+ Partial or unavailable scans never establish support coverage. Standard and
18
+ extended deadlines are separate; an unpublished deadline remains unknown.
19
+ Targets and resource identifiers retain their server spelling. Release notes
20
+ accept credential-free HTTPS links only.
21
+
22
+ Writes additionally require platform management authority and current managed
23
+ infrastructure proof. Bootstrap has its own independent dashboard permission:
24
+ without that grant the page retains authorized inventory but cannot establish
25
+ mode or offer writes. External infrastructure remains readable and explains
26
+ that IaC owns changes. Missing, denied or malformed mode proof blocks writes.
27
+ The backend remains authoritative for interactive, non-key-backed access and
28
+ 600-second fresh authentication.
29
+
30
+ The native confirmation names current and target versions, outage/restart
31
+ impact, and snapshot-based recovery. Type the exact case-sensitive resource
32
+ identifier and explicitly choose Immediately or Next maintenance window.
33
+ `apply_immediately` is a real boolean, including `false`. Changing evidence or
34
+ session closes the old confirmation. Confirmation is single-flight; transport
35
+ uses `mojoCall`, AbortSignals and a final session/authority fence without retry,
36
+ `withFreshAuth`, Query mutations or raw fetch.
37
+
38
+ After acceptance the exact resource and target are observed every ten seconds,
39
+ at most 180 times. Completion requires both `settled === true` and
40
+ `upgraded === true`. A target version before settlement, old available version,
41
+ missing resource, denied/malformed read, or mixed cache member versions remains
42
+ unconfirmed. Timeout retains the receipt and offers explicit status refresh.
43
+
44
+ Attempts are scoped by installation and actor in a module registry, surviving
45
+ dialog closure, navigation/remount, timeout, ordinary token renewal and a new
46
+ session for the same actor. Unmount/session changes abort local observations;
47
+ provider work can continue. An attempted/unknown provider outcome never unlocks
48
+ another apply merely because a refreshed report still offers the old version.
49
+ A completed receipt permits a new, different offered target with new consent;
50
+ switching only the timing never bypasses the same-target guard.
51
+ Reconcile the provider operation and maintenance window. A definitive refusal
52
+ before provider work can be cleared only by a deliberate successful Refresh
53
+ Maintenance and a new confirmation. A 440 requests authentication once and
54
+ never replays the write.
55
+
56
+ Framework shows installed/latest versions, check time/source, pin policy,
57
+ update availability and the backend blocker. Held/pinned versions use
58
+ “Resume and update”. The operation clears the pin and
59
+ redeploys the last converged application SHA. Policy clearing and deployment
60
+ acceptance are separate facts: a 503 or lost response can leave automatic policy
61
+ active with unknown deployment acceptance. That attempt remains guarded across
62
+ remount and reauthentication. Refresh Framework and inspect Deployments even
63
+ when no deployment ID was returned. Acceptance points to Deployments and never
64
+ claims fleet completion. The separate Update policy dialog saves Automatic (`""`), Hold (`"hold"`) or an
65
+ exact version through `POST /api/account/admin/advanced/settings` with only
66
+ `framework_pin`. It requires an active literal superuser and fresh authentication;
67
+ this page also keeps external infrastructure read-only. Syntax validation does
68
+ not verify that an exact version is published. Hold resolves the last converged
69
+ framework version. Saving policy never submits a deployment. The framework
70
+ Update action explicitly clears that policy and requests latest; it cannot
71
+ deploy an exact selection while retaining the pin.
72
+
73
+ A successful policy response followed by matching live policy readback completes
74
+ that policy attempt. A lost/uncertain response remains guarded even if the
75
+ desired value is later observed: current value does not prove request ownership
76
+ or that a delayed writer finished. There is no policy revision or durable receipt
77
+ lookup. Reconcile server audit before further policy writes; there is no reset
78
+ button. Generic Settings directs `EDGE_FRAMEWORK_VERSION` to the mounted
79
+ Maintenance owner, rather than submitting through the rejected generic writer.
80
+
81
+ Framework submissions with an acknowledged UUID observe that exact deployment
82
+ and SHA through the bounded platform deployments collector. Only `converged`,
83
+ `failed`, and `superseded` release its submission guard for a different target;
84
+ `verified`, `partial`, `unknown`, absence, and errors do not. Deployment terminal
85
+ status is distinct from requested framework-version proof. That requires a
86
+ nonempty unique frozen runner roster, unique matching proof for each runner,
87
+ exact UUID/full SHA/version, and fresh valid observation timestamps. Missing
88
+ version proof remains explicit even when deployment converges. Lost acceptance
89
+ without a UUID cannot be reconciled from the latest same-SHA row.
90
+
91
+ Submitted operations remain compact rows across categories; View progress opens
92
+ a focused dialog with target, timing, last successful observation, current/pending
93
+ and member versions, or correlated deployment evidence. Manual refresh only reads.
94
+ Aurora settlement proves cluster-level state, not individual database members.
95
+ Module-memory receipts survive navigation and same-actor renewal; a full browser
96
+ reload loses them. Durable reload-safe reconciliation needs backend support.
97
+
98
+ `maintenanceDeploymentsHref(pathname)` preserves an embedded Admin prefix.
99
+ `MaintenancePage` also accepts an explicit `deploymentsHref`, `pollInterval`
100
+ and `pollLimit`; production defaults are ten seconds and 180 observations.
101
+
102
+ Mock integration: call `maintenanceMock(path, method, body, params, context)`
103
+ only for its owned paths. It returns `undefined` for every sibling path.
104
+ `context` contains authenticated user, claim-derived `keyBacked` and `fresh`.
105
+ Use `readMaintenanceBootstrapFacts()` in the **single central bootstrap
106
+ composer**; Maintenance does not claim that endpoint. Preserve its explicit
107
+ `bootstrap` denied/malformed scenario and derive mode and managed capability
108
+ from the same facts. `MAINTENANCE_SCENARIOS` and `setMockMaintenanceScenario`
109
+ exercise partial scans, waiting/settlement, provider uncertainty and Framework
110
+ policy/deployment uncertainty through the actual page.
111
+
112
+ Integration manifest: lazy-load `admin/maintenance/MaintenancePage.tsx`; global
113
+ section id/path `maintenance`, label Maintenance, navigationGroup operations,
114
+ nav union `sys.manage_aws | sys.view_platform | sys.manage_platform | sys.admin`.
115
+ Import `styles/components/admin-maintenance.css`; register `AdminMaintenanceDemo`
116
+ from `apps/showcase/src/pages/components/demos-admin-maintenance.tsx` lazily.
117
+ Register `verify-admin-maintenance.mjs` and `verify-admin-maintenance-mounted.mjs`
118
+ once each; the harness is an imported test adapter, not a command. Public index
119
+ exports are MaintenancePage, maintenanceDeploymentsHref, maintenanceAccess,
120
+ frameworkActionLabel, MAINTENANCE_ENDPOINTS and maintenanceCompleted.
121
+
122
+ Resource rows keep the offered version, provider state and upgrade action visible. Support deadlines, member evidence and recovery notes remain under each row’s disclosure. Dates use compact local presentation and preserve the original wire value in their title. Framework policy uses readable labels; missing or refused evidence remains explicit.
123
+
124
+ ### KISS presentation
125
+
126
+ Database, Cache and Django-MOJO use tabs when multiple categories are authorized.
127
+ A reader with only one authorized category sees its panel directly without a tab strip. Scan metadata is collapsed; scan errors, incomplete evidence and scanning-off warnings stay above the tabs. Submitted operations remain visible independently of the selected category. See [visual QA](admin-presentation.md).
128
+
129
+ Standard support deadlines within 30 days or overdue remain visible on resource rows;
130
+ standard and extended support stay separate. Missing support dates are not
131
+ interpreted as supported. Framework status distinguishes current, policy-paused
132
+ published versions, and unavailable/latest-unknown evidence, with source details
133
+ in accessible info popovers.
@@ -11,6 +11,20 @@ or rename an action pane to disguise it. Genuine autosave fields remain content.
11
11
  A nested child may have its own contextual actions. Evidence such as a firewall
12
12
  log's Action **column** describes the event and is not an operation section.
13
13
 
14
+ Serving hostname inspection uses a compact `modal.open` with horizontal task
15
+ tabs instead of a side rail: Overview, conditional Routes and Settings.
16
+ Certificate details and a direct certificate editor are available in Overview.
17
+ Its Hostname/Certificate/Settings editor and Hostname → Certificate → Review
18
+ create flow keep one draft. The launcher guards native dismissal while any write or
19
+ route preflight is pending; focused child editors retain the same owner lifetime.
20
+ This is scoped to Serving and does not change the shared DetailView contract.
21
+
22
+ Deployments likewise uses compact `modal.open` task dialogs: API Overview/Nodes/
23
+ Activity; WebApp Overview/Deployments/Releases/Deploy/Settings. Read-only actors
24
+ omit deployment controls, and attempt viewers get a direct overview when there
25
+ is no admitted node evidence. Workflow, integration, setup identity and profile
26
+ editing use focused child dialogs. No shared DetailView default is changed.
27
+
14
28
  Use `modal-pad`, `modal-title`, `modal-message`, grouped fields and `modal-actions`.
15
29
  Cancel precedes the primary button. Pending operations disable repeat submission;
16
30
  `canDismiss` also protects native Escape/backdrop dismissal during a write.
@@ -70,7 +84,8 @@ the inventory using `node scripts/verify-admin-modals.mjs --write-inventory`.
70
84
  | Storage/shortlinks | Bucket, FileManager, File, shortlink; owner/backend/test/CORS/upload/rendition/share | FileManager/shortlink/share active state; no File disable promise; bucket empty only |
71
85
  | Email/support | Domain, mailbox, sent message, template, contact; credentials/onboarding/audit/reconcile/test/template/status | Preserve evidence; separate mailbox directions and support status |
72
86
  | Phone/Push | Number, SMS, config, device, delivery, template; lookup/test/config editors | Config/template lifecycle; number/SMS/device/delivery evidence retained |
73
- | Edge | Vhost with nested routes; shape-first wizard, route editor, upstream declare/retire, blocklist entry editor | Vhost and upstream enabled state; upstreams retire and are never deleted; vhost, route and blocklist deletion are retained operations |
87
+ | Operations | Fleet capacity planning, Maintenance exact resource/window confirmation, GeoLocation local key-removal confirmation, API/WebApp detail and focused onboarding/recovery/upload/credential flows | No generic deployment/release edits or deletion; staged intent, observed receipts and transient secrets remain distinct |
88
+ | Edge | Vhost with nested routes; Hostname → Certificate → Review creation, route editor, upstream declare/retire, blocklist entry editor | Vhost and upstream enabled state; upstreams retire and are never deleted; vhost, route and blocklist deletion are retained operations |
74
89
  | Assistant/settings | Conversation, Skill, Setting; feed continuation, scoped memory, typed settings editor | Owner-only continuation; Skill active selection; memory is scoped editing |
75
90
 
76
91
  ## Resource lifecycle matrix
@@ -93,7 +108,7 @@ the inventory using `node scripts/verify-admin-modals.mjs --write-inventory`.
93
108
  | Push config / template | Existing active selection with separate manage/test grants |
94
109
  | Assistant conversation | Retain history; no arbitrary REST save or invented archive |
95
110
  | Assistant Skill | Existing `is_active` save requires `sys.view_admin`; authoritative reload after mutation |
96
- | Edge vhost | Enable/disable from the header; Edit and Delete in the header menu. Disabling keeps the row and is how a replacement is staged |
111
+ | Edge vhost | Enable/disable and Edit in the header; Delete in the header menu. Disabling keeps the row and is how a replacement is staged |
97
112
  | Edge upstream | `is_enabled` only. Retire (platform administrators) disables and keeps the row; server `CAN_DELETE=False` |
98
113
  | Edge blocklist entry | Mode is the lifecycle: `off` parks an entry without losing it. Deletion is a retained operation |
99
114
  | Other logs/devices/history | Read-only by default, even where low-level REST supports DELETE |
@@ -0,0 +1,206 @@
1
+ # Operational Fleet
2
+
3
+ `FleetOverview` is the reusable health matrix at the top of the global Dashboard.
4
+ `FleetPage` presents a separate standalone workspace at the mount-relative `/fleet` destination. Its task dialogs and styles are isolated from `FleetOverview`; the Admin Dashboard keeps its existing overview, disclosure, editor and behavior.
5
+ Import both, `backendGlobalCan`, contracts and pure staging helpers from
6
+ `portal-mojo/admin` (or `portal-mojo/admin/infrastructure`). The scale editor loads
7
+ only when opened. Existing Dashboard directory, activity and login panels remain
8
+ below this component.
9
+
10
+ ```tsx
11
+ <FleetOverview mount="/product/control" />
12
+ <FleetPage mount="/product/control" />
13
+ ```
14
+
15
+ Both accept optional `mount`, `links` and `showConfigurationLink` props. The mount
16
+ is inferred from the current dashboard pathname, removing a final `/fleet` on
17
+ that page; pass it explicitly in a custom host. `links` maps source names to
18
+ `{ to: 'relative/registered/route', grants: ['exact_backend_key'], superuser?: true }`
19
+ or `null` to suppress a destination. Hosts with a partial registry must suppress
20
+ or replace unavailable destinations. Built-in links target existing Admin pages
21
+ only; Fleet Configuration remains its separate advanced superuser destination.
22
+ Use `showConfigurationLink={false}` when that destination is not mounted.
23
+ `FleetOverview` also accepts `headingLevel` (`1 | 2`, default `2`); use `1` when
24
+ rendering it as a standalone page. `FleetPage` supplies its own level-one Fleet heading.
25
+ `capacityDetails="modal"` replaces the capacity disclosure with a button and a
26
+ read-only modal. This option applies to `FleetOverview`. The reusable overview defaults to `"inline"` to preserve the current Admin Dashboard. `FleetPage` always uses the standalone tabbed capacity inspector.
27
+
28
+ ## Authority and evidence
29
+
30
+ `backendGlobalCan(user, grant | grants)` checks global permissions only. It mirrors
31
+ Python truthiness of JSON values and the finite `helpers/perms.py` categories:
32
+ `users`, `groups`, `security`, `comms`, `jobs`, `metrics`, `files` imply only their
33
+ own `view_`/`manage_` checks. It has no group membership or implicit `admin`
34
+ wildcard. In particular `comms` and `admin` do not imply `manage_aws`.
35
+
36
+ The dashboard request needs `view_admin | manage_users | manage_settings | admin`
37
+ or literal superuser. Capacity reads separately require `manage_aws` or literal
38
+ superuser. Every request and query key is bound to actor plus session revision;
39
+ ordinary token renewal preserves that revision. Each denied read is suppressed.
40
+ Source statuses and server timestamps survive independently. The availability
41
+ headline is the server verdict; attention counts never recompute it. Missing
42
+ jobs/check observations stay unknown. Wholly restricted sections collapse to
43
+ one Restricted row. Unconfigured SMS/email and zero-ticket rows are absent.
44
+ Dashboard data is positively projected before Query storage; arbitrary nested
45
+ provider output and deployment stderr never enter this cache.
46
+
47
+ Load-balancer target health, fixed ingress addresses, and stable node outbound
48
+ addresses are distinct observations. Unknown egress reads say unavailable, never
49
+ an empty allowlist. Serving-process database/Redis reader routing is explicitly
50
+ per process. Database and cache members retain their own lifecycle/status. An Aurora reader absent from filtered instance facts may carry null class/status with unknown lifecycle. That database remains transition-blocked while node/cache evidence and independently supported changes remain available.
51
+
52
+ ## Standalone Fleet workspace
53
+
54
+ `FleetPage` lazy-loads `FleetWorkspace`, and each inspection, editing and progress
55
+ body loads only when opened. Its scoped `.fleet-page` and
56
+ `.fleet-standalone-dialog` styles do not change the Dashboard. Infrastructure and Operations task tabs keep
57
+ the backend availability verdict and each source status. Overall observation age
58
+ is visible; each source timestamp stays in its info popover, while stale sources
59
+ also show their age inline. Stale thresholds remain in info popovers; refreshing never relabels
60
+ old evidence as current. Missing sources stay unknown, denied sections stay
61
+ restricted. Authorized, mounted related destinations are direct links on source names;
62
+ unauthorized destinations are omitted. Info popovers contain evidence, not navigation.
63
+
64
+ The primary action is **Change capacity**. **Inspect capacity** opens App nodes,
65
+ Database, Redis and Addresses tabs. Fleet outbound allowlist, fallback attachments,
66
+ reserved addresses and fixed load-balancer ingress appear separately. **Copy
67
+ allowlist** requires every availability proof, an enabled observed policy and no
68
+ pending nodes. Missing evidence never becomes an empty allowlist. Database and
69
+ Redis reader routing explicitly describe the serving process.
70
+
71
+ The standalone editor uses one `CapacityDraft` from `standalone-data.ts` across
72
+ App nodes, Database and Redis tabs. App nodes has Add nodes and Remove nodes tasks.
73
+ Additions require an explicit healthy, registered, running, settled source; its
74
+ size and target groups are shown, and Change subnet opens a focused override dialog.
75
+ Removal retains the existing self-node and healthy-floor checks; the server also
76
+ re-proves each target group's floor and ownership.
77
+
78
+ Every database member shows its exact ID, role and **current** class. Its size
79
+ select and remove checkbox target that ID; there is no automatic reader selection
80
+ or shared existing-reader sizing fan-out. Newly added readers inherit the current
81
+ source class, even when the writer also resizes. Observe their concrete IDs after
82
+ creation and resize separately. For a Redis group, stage size **or** replica
83
+ count in one plan. The other control is locked until the first returns to current
84
+ values; apply, refresh and separately review the second change. The pure compiler
85
+ rejects mixed-dimension intents independently of those controls because the
86
+ current backend cannot quote them cumulatively.
87
+
88
+ The explicit **Review changes (N)** button is the only plan request. It replaces
89
+ the editor with the authoritative server plan, including step order, warnings,
90
+ unknown prices, expiry and exact-plan consent. Review shows only allowlisted
91
+ server parameters for source node, subnet, target size, count and immediate-apply
92
+ mode when the server reports supported scalar values. Draft intent never fills
93
+ in missing plan facts. Source choices include the exact node ID. **Back to changes**
94
+ preserves the draft and invalidates consent. No background/debounced quote is
95
+ requested by the standalone editor. **Refresh and clear changes** explicitly discards the draft
96
+ before reading fresh inventory; there is no silent reset. Initial open also
97
+ requires a fresh observation before staging. Apply uses only `fleetOperations`.
98
+ Both standalone and Dashboard changes require a literal superuser, an interactive
99
+ user credential and backend fresh authentication. A 440 opens fresh authentication,
100
+ spends consent and requires an explicit refreshed review; planning and applying
101
+ are never automatically replayed.
102
+
103
+ **View capacity progress** is independently available when an in-session operation
104
+ record is retained with a non-idle status and the account has capacity-read
105
+ authority, including external infrastructure, unavailable inventories and non-superuser
106
+ readers. It uses the same in-session operation record as Dashboard. It never
107
+ hydrates an operation from storage or creates another coordinator. When an apply
108
+ reply is lost, the exact batch ID can be entered for read-only reconciliation of
109
+ the retained plan. Matching terminal evidence is required before another change.
110
+ Keep the current document open while reconciling: a full document reload loses
111
+ the in-memory operation record. Batch-ID entry reconciles a retained plan and
112
+ does not import an arbitrary operation after reload. Actor/session changes and
113
+ permission loss fence requests and close owned dialogs;
114
+ Escape/backdrop and explicit close are locked while requests are pending.
115
+
116
+ The Showcase demo uses `FleetPage` and a focused Demo controls dialog. Scenario
117
+ switching is disabled during unresolved operations. Read-only mock batch IDs are
118
+ available through Refresh mock evidence so lost replies can be reconciled without
119
+ clearing the browser's guard.
120
+
121
+ ## Dashboard reviewed scaling
122
+
123
+ Scaling requires a literal superuser with capacity authority and a browser user
124
+ session. External infrastructure remains read-only. The backend enforces its
125
+ credential provenance and 600-second freshness requirement. A 440 opens the
126
+ existing fresh-auth handler, spends consent, and requires explicit refreshed
127
+ review. Neither planning nor applying is automatically replayed.
128
+
129
+ The existing Dashboard focused native dialog refreshes inventory before staging. Local intent is
130
+ compiled into at most 20 steps, then debounced about 400ms into
131
+ `POST /api/aws/capacity/plan {steps}`. Displayed order, descriptions, warnings,
132
+ prices and expiry come from that stored plan. Null prices are unknown. Editing,
133
+ refreshing, expiry, permission loss or session replacement invalidates consent.
134
+ A synchronous guard protects submission, including duplicate activation.
135
+ `POST /api/aws/capacity/plan/apply` sends only `{plan_id}`.
136
+
137
+ Node removal stages drain then terminate, excludes this serving node, requires
138
+ identified self and complete inventory, and retains one currently healthy target.
139
+ Unproven additions never satisfy the healthy floor. A freshly observed owned,
140
+ unregistered, settled node gets a separate Terminate drained node recovery path;
141
+ AWS ownership and drain completion are re-proved by the server. App size remains
142
+ informational. Add-node placement omits empty fields and locally validates subnet
143
+ shape; AWS proves the source, VPC, availability zone and address suitability.
144
+
145
+ Database writer and existing-reader sizes are independent curated keys. Reader
146
+ resizing fans out over surviving concrete reader IDs with different current
147
+ classes; staged removals are excluded. New readers inherit the **current** source
148
+ class, even when the writer also resizes in the plan. Refresh their concrete IDs
149
+ after creation and separately review resizing them. Writer resizing causes
150
+ minutes offline. Redis sizing applies to the whole group; replica counts retain
151
+ the server minimum and the editor explains rolling or downtime impact before
152
+ review. All count/cache/database resize steps explicitly send
153
+ `apply_immediately: true`.
154
+
155
+ ## Observation and uncertain outcomes
156
+
157
+ `fleetOperations` lives outside component/dialog state. Its records contain safe
158
+ IDs and observed batch facts, keyed by installation and actor. Unresolved state
159
+ survives dialog closure, Dashboard/Fleet navigation, and same-actor reauthentication;
160
+ it does not survive a full document reload. Keep the current document open when
161
+ reconciling an uncertain submission. New sends and read replies are still fenced
162
+ to the exact session revision. A
163
+ different actor sees no previous actor record. No credentials or mutation drafts
164
+ are stored locally. The coordinator never replays a mutation. Expiry or session refusal at the final transport boundary is provably unsent: refresh inventory and review again. A transport failure after that boundary remains unknown and retains the reconciliation guard.
165
+
166
+ Running batches are observed about every ten seconds, with a backstop covering
167
+ 90 minutes per step. Completed steps remain visible on failure; nothing rolls
168
+ back. `stalled` directs the operator to the jobs runner/cleanup channel. Poll
169
+ failures and exhausted budgets say progress unknown and offer explicit observation
170
+ refresh. A duplicate apply receipt follows its existing batch. Stale/expired plans
171
+ require another review.
172
+
173
+ A lost apply reply retains the submitted plan ID and blocks new mutations, even
174
+ when no batch ID was acknowledged. Closing, refreshing inventory, signing in
175
+ again as the same actor, and selecting a mock scenario do not clear this block.
176
+ Inspect capacity server logs and AWS state, obtain the exact plan's batch ID, and
177
+ enter it for read-only observation. Only matching terminal evidence resolves the
178
+ block; an unrelated batch is refused. Ambiguous provider outcomes remain unknown.
179
+
180
+ ## Mock and executable verification
181
+
182
+ `client/operational-fleet-mock.ts` is mock-only and exports:
183
+
184
+ - `handleOperationalFleetMock(path, method, body, params, actor, authTime)` → an
185
+ envelope, `undefined` for unowned routes, or an intentional lost-response error.
186
+ The central mock supplies its validated actor/global grants and bearer auth time;
187
+ optional `actor.credential_kind` identifies a confirmed confined credential.
188
+ - `mockOperationalFleetScenarios`, `setMockOperationalFleetScenario`,
189
+ `getMockOperationalFleetScenario`, and read-only `getMockOperationalFleetBatchIds`.
190
+
191
+ The default fixture includes asymmetric reader classes. Scenarios cover external,
192
+ restricted, partial, transitioning/drained nodes, missing evidence, unknown prices,
193
+ stale/expired/duplicate plans, partial failures, stalled progress and lost replies.
194
+ Changing scenario resets the simulated server, not the browser operation record.
195
+
196
+ Run `npm run verify:admin-fleet-workspace` for standalone compiler and mounted coverage, plus `node scripts/verify-admin-operational-fleet.mjs` and
197
+ `node scripts/verify-admin-operational-fleet-mounted.mjs`. The mounted verifier uses
198
+ an isolated injected transport and rejects every real host. It exercises actual
199
+ React components in StrictMode, custom embedded mounts, denied request suppression,
200
+ cancellation, stale responses, single-flight, fresh-auth without replay, cross-page
201
+ progress and unknown-outcome reconciliation. Browser light/dark, narrow layout,
202
+ native focus and console proof belong to the integrated closing run.
203
+
204
+ ### KISS presentation
205
+
206
+ The Dashboard overview uses its existing compact service/status rows and modal Details. The separate Fleet workspace uses compact evidence cards and info popovers. Both editors retain a single draft across App nodes, Database and Redis tabs; the standalone editor enters review only through Review changes and discards consent on Back. All staged changes appear in one exact server-plan review. See [visual QA](admin-presentation.md).
@@ -0,0 +1,51 @@
1
+ # Admin presentation and visual QA
2
+
3
+ Use intuitive, simple screens with clean spacing. Keep only important data and
4
+ short action-oriented copy on the primary surface. Use tabs, modals and help
5
+ popovers only when they improve the task. Tabs separate
6
+ distinct jobs; do not split a few related fields across tiny panels or render
7
+ a one-item tab strip. Use focused modals for substantial inspection/confirmation
8
+ and keyboard-accessible info popovers for secondary explanations. Keep navigation
9
+ directly discoverable rather than hiding destination links inside help. On pages
10
+ that already scroll, use modals/popovers rather
11
+ than collapsible detail sections. Compact disclosures such as Maintenance are
12
+ fine; this is contextual. Preserve Admin Dashboard as-is unless Ian explicitly
13
+ requests a change; separate Fleet work must not alter it. Keep action-specific errors, unknown outcomes and concrete
14
+ interruptions visible where the operator needs them. Permission/consent/session
15
+ and no-replay checks are behavioral requirements, not prose to remove.
16
+
17
+ Both applications import `portal-mojo/styles.css`. Never add app-local copies of
18
+ package component CSS: unlayered stale copies override the canonical layer.
19
+ Showcase owns only tokens, chrome and demo-only styling. Test fixture controls
20
+ are demo-only, separate from the product's UI.
21
+
22
+ ## Required acceptance
23
+
24
+ 1. Run `npm run verify:admin-presentation`. It compiles each app's real theme
25
+ through Vite and checks the six operations layouts, plus the shared style
26
+ entrypoint. A CSS file existing on disk does not prove it is loaded.
27
+ 2. Wait for actual loaded data and controls, not just a heading, spinner or
28
+ skeleton. Inspect initial-viewport screenshots of **all six** screens
29
+ (GeoLocation, Fleet, Maintenance, Deployments, Domains, Serving) in light and
30
+ dark at desktop and 390px. Revisit each image; producing one is not review.
31
+ 3. Check computed page/row display, padding/gap and heading size. Check field
32
+ separation, alignment, readable contrast, wrapping and no page overflow.
33
+ Tables may scroll inside their own container. Inspect internal scroll areas
34
+ and sticky actions, not just document width.
35
+ 4. Open help, tabs, details and dialogs. Verify popovers inside a native dialog,
36
+ Escape/outside dismissal, readable fields and reachable actions at narrow
37
+ width. Inspect every changed surface, including errors and unknown outcomes.
38
+ 5. Exercise mock workflows: provider test/save, a combined scaling draft retained
39
+ across tabs and exact plan review, maintenance outage/resource confirmation,
40
+ API/WebApp tabs and recovery/setup, Domain actions/details, Serving wizard.
41
+ Cancel billable/destructive actions unless a test explicitly owns the mock.
42
+ 6. Repeat a smoke check in the actual Admin shell and built Showcase output.
43
+ Check the browser console and run focused behavior contracts plus typecheck.
44
+ DOM-only tests verify behavior; they cannot certify layout or visual quality.
45
+ At mobile width, verify navigation starts collapsed, expands over the page,
46
+ closes with Escape/outside activation or navigation, and preserves the saved
47
+ desktop preference when returning to a wider viewport.
48
+
49
+ Keep screenshot evidence and the screen/theme/viewport/interaction results on
50
+ the Maestro item. State whether evidence is mock or live. A preview link must
51
+ work when handed over; the development server's lifetime is part of that check.
@@ -12,9 +12,11 @@ The page has two parts:
12
12
  layout/appearance/accent color, hero panel, sign-in heading/copy/methods,
13
13
  registration, custom CSS. It edits the same keys a group admin edits in the
14
14
  group **Configure Auth** dialog; a group's own settings still override these.
15
- - **Providers** — one card each for Google, Apple and GitHub: an Enabled
15
+ - **Providers** — one card per provider the server lists (Google, Apple,
16
+ GitHub and, from django-mojo's Microsoft sign-in, Microsoft): an Enabled
16
17
  switch, a read-only callback URL with a Copy button, the credential fields,
17
- a Ready / Needs setup badge, and a link to the provider's console.
18
+ a Ready / Needs setup badge, any server notices, and a link to the
19
+ provider's console.
18
20
 
19
21
  KISS by design: trusted admins, no step-up auth, no confirmation beyond a
20
22
  browser `confirm()` before clearing a stored value.
@@ -38,6 +40,14 @@ One endpoint, both methods, django-mojo envelope.
38
40
  multiline, configured, source, value, hint}`. `source` is `admin`,
39
41
  `deployment` (shown as "Set in server config") or `none`. Secret fields
40
42
  never carry `value`; `hint` (e.g. `…a1b2`) is shown when present.
43
+ - Optional `notices[]` on a provider: `{tone, text}`, `tone` one of `info`,
44
+ `warning` or `danger` (for example a Microsoft client secret nearing or past
45
+ its recorded expiry). Each renders as its own `role="status"` note under
46
+ the Needs setup note, in order; any other tone renders as `info`. An absent
47
+ or empty list renders nothing, so older backends are unaffected.
48
+ - Provider `name` tokens with a built-in label and icon: `google`, `apple`,
49
+ `github`, `microsoft` (`bi-microsoft`). Any other name falls back to the
50
+ server's `label` and a key icon.
41
51
 
42
52
  `POST /api/account/admin/signin` returns the same full payload, which the page
43
53
  writes straight into its query cache (`SIGNIN_QUERY_KEY`).
package/docs/client.md CHANGED
@@ -458,3 +458,10 @@ now **rejects** a flat HTTP-200 refusal (top-level `success:false`) with
458
458
  - `err.errorCode` carries the refusal code and `err.data` the full reply, so
459
459
  the server's reason is no longer lost.
460
460
  - `defineModel` actions and `mojoAction` behave as before.
461
+
462
+ `mojoCall` accepts optional `idempotencyKey` for a reviewed operation receipt. The
463
+ transport validates 1–128 ASCII receipt characters (`A-Z`, `a-z`, `0-9`, `.`, `_`,
464
+ `:`, `-`) and emits only the `Idempotency-Key` header. It does not expose arbitrary
465
+ custom headers or record the key with sensitive bodies. Preserve the same receipt
466
+ for a deliberate retry of the same operation; observe uncertain outcomes before
467
+ creating another operation.
package/docs/combobox.md CHANGED
@@ -19,6 +19,7 @@ interface ComboOption {
19
19
  value: ComboValue;
20
20
  label: string; // what the input displays
21
21
  description?: string; // muted second line in the list
22
+ disabled?: boolean; // shown but never committed
22
23
  meta?: Record<string, unknown>; // opaque; returned via onSelect
23
24
  }
24
25
 
@@ -32,6 +33,8 @@ interface ComboOption {
32
33
  minChars={0} // typing auto-opens the list only at ≥ N chars
33
34
  maxSuggestions={10} // caps the rendered list AND keyboard wraparound
34
35
  placeholder disabled readOnly required id className
36
+ inputRef={fieldRef} // input focus target for form validation
37
+ aria-label="Output" aria-describedby="output-help" aria-invalid={invalid}
35
38
  />
36
39
  ```
37
40
 
@@ -103,3 +106,18 @@ strings.
103
106
  containers and native dialogs do not clip it.
104
107
  - Styles live in `packages/portal-mojo/src/styles/components/combobox.css` (tokens only, both
105
108
  themes) and ship in `portal-mojo/styles.css`.
109
+
110
+ ## Fixed-choice dropdowns
111
+
112
+ Use `allowCustom={false}` for enums and filters. Pass the same string/number
113
+ type to `value` and each option. Disabled options remain visible (including
114
+ a saved, unavailable selection); pointer, typed settlement and arrow navigation
115
+ cannot commit them. Permission loss closes the list and discards the draft.
116
+ Use `id` with a label or `aria-label`/`aria-labelledby`; description and validation
117
+ attributes reach the input. `inputRef` supports validation focus.
118
+
119
+ For short fixed lists that must show every choice, set `maxSuggestions` to the
120
+ list length; searchable catalogs may retain the default cap. Shared tables,
121
+ chart settings, filters and schema select fields use this same component.
122
+
123
+ The chevron is a pointer shortcut for the input. It is hidden from assistive technology so a wrapping field label does not include redundant button text; the combobox input owns the name and keyboard interaction.
package/docs/popover.md CHANGED
@@ -95,9 +95,10 @@ that here would close both layers on one keypress.)
95
95
  Outside-mousedown does NOT stop propagation: the click still activates
96
96
  whatever was under it (click a second trigger → this popover closes and
97
97
  that one opens). Clicks on the anchor are never "outside" — the anchor's
98
- own click handler owns its toggle. The listener attach is deferred one
99
- tick so the interaction that opened the popover can't immediately close
100
- it (source parity).
98
+ own click handler owns its toggle. Outside-click registration waits one tick so the opener cannot close its own
99
+ menu. Escape registration runs in a layout effect, before the opening frame
100
+ paints. Only the most recently opened popover handles Escape; a dialog opened
101
+ above its owner keeps its own dismissal behavior.
101
102
 
102
103
  ## Styling
103
104
 
@@ -125,7 +126,7 @@ baked in 20px padding + 260px min-width for its one tenant, the calendar
125
126
  to the consuming control.
126
127
  - The opening interaction can be `click` or `mousedown`-driven; both are
127
128
  safe. But if you open programmatically and simultaneously flip other
128
- document state, remember listeners attach a tick later.
129
+ document state, only outside-click registration waits a tick. Escape protection is immediate.
129
130
  - Placement flips only bottom→top (per source). If content can be taller
130
131
  than the viewport, cap and scroll it in YOUR content styles.
131
132
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "portal-mojo",
3
- "version": "0.2.20",
3
+ "version": "0.2.21",
4
4
  "description": "React portal toolkit for django-mojo — typed client + params store, mission-control UI, dependency-free SVG charts, admin section bundles.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -0,0 +1,10 @@
1
+ import { useId, useRef, useState, type ReactNode } from 'react';
2
+ import { Popover } from '../../ui/Popover';
3
+
4
+ /** Keep explanatory copy one deliberate action away from the working surface. */
5
+ export function OperationsHelp({ label, children }: { label: string; children: ReactNode }) {
6
+ const anchor = useRef<HTMLButtonElement>(null);
7
+ const [open, setOpen] = useState(false);
8
+ const id = useId();
9
+ return <><button ref={anchor} type="button" className="ops-help" aria-label={`About ${label}`} aria-expanded={open} aria-controls={open ? id : undefined} onClick={event => { event.preventDefault(); setOpen(value => !value); }}><i className="bi bi-question-circle" aria-hidden="true" /></button><Popover anchorRef={anchor} open={open} onClose={() => setOpen(false)} id={id} aria-label={`About ${label}`} className="ops-help-content">{children}</Popover></>;
10
+ }
@@ -7,6 +7,7 @@ import { isChunkLoadError } from '../../ui/route-error';
7
7
  import { GroupContext } from '../../client/group-context';
8
8
  import { hasPermission, useMe } from '../../client/me';
9
9
  import type { PermSpec } from '../../client/model';
10
+ import { AdminRouteContext, type AdminRouteContextValue } from './route-context';
10
11
  import { Guarded } from '../../ui/Guarded';
11
12
  import type { MenuConfig, MenuItem } from '../../ui/menu-registry';
12
13
 
@@ -127,7 +128,7 @@ function routeElement(route: AdminRoute): ReactNode {
127
128
  : createElement(route.component);
128
129
  }
129
130
 
130
- function guardedElement(section: AdminSection, route: AdminRoute, sections: readonly AdminSection[], mount: string): ReactNode {
131
+ function guardedElement(section: AdminSection, route: AdminRoute, sections: readonly AdminSection[], mount: string, context: AdminRouteContextValue): ReactNode {
131
132
  const fallback = route.fallbackToFirstVisible
132
133
  ? createElement(AdminMountLanding, { sections: sections.filter((candidate) => candidate !== section), mount })
133
134
  : createElement(AdminDenied);
@@ -146,9 +147,9 @@ function guardedElement(section: AdminSection, route: AdminRoute, sections: read
146
147
  // navigation between packaged admin pages silently stopped rendering
147
148
  // (reproduced in both the reference portal and a consumer). The key
148
149
  // forces a fresh mount, and a fresh Suspense, per route.
149
- return createElement(AdminGlobalScope, { key: relativePath(mount, section.basePath ?? section.id, route.path), children: createElement(Guarded, {
150
+ return createElement(AdminRouteContext.Provider, { key: relativePath(mount, section.basePath ?? section.id, route.path), value: context, children: createElement(AdminGlobalScope, { children: createElement(Guarded, {
150
151
  permission: section.permissions, fallback, children: routeGuarded,
151
- }) });
152
+ }) }) });
152
153
  }
153
154
 
154
155
  /** Admin is global even when embedded beneath a product portal's provider. */
@@ -182,6 +183,7 @@ function AdminMountLanding({ sections, mount }: { sections: readonly AdminSectio
182
183
  export function adminSectionRoutes(sections: readonly AdminSection[], opts: { mount?: string } = {}): RouteObject[] {
183
184
  const mount = relativePath(opts.mount ?? '');
184
185
  const routes: RouteObject[] = [];
186
+ const context: AdminRouteContextValue = { mount: absolutePath(mount), paths: sections.flatMap((section) => section.routes.map((route) => absolutePath(mount, section.basePath ?? section.id, route.path))) };
185
187
  const rootSections = sections.filter((section) => relativePath(section.basePath ?? section.id) === '');
186
188
  const rootHasIndex = rootSections.some((section) => section.routes.some((route) => relativePath(route.path) === ''));
187
189
  if (mount && rootSections.length > 0 && !rootHasIndex) routes.push({
@@ -198,7 +200,7 @@ export function adminSectionRoutes(sections: readonly AdminSection[], opts: { mo
198
200
  });
199
201
  for (const route of section.routes) routes.push({
200
202
  path: relativePath(mount, base, route.path),
201
- element: guardedElement(section, route, sections, mount),
203
+ element: guardedElement(section, route, sections, mount, context),
202
204
  });
203
205
  }
204
206
  return routes;
@@ -0,0 +1,6 @@
1
+ import { createContext, useContext } from 'react';
2
+
3
+ /** Internal registration facts for mount-aware links; never an authority decision. */
4
+ export interface AdminRouteContextValue { mount: string; paths: readonly string[] }
5
+ export const AdminRouteContext = createContext<AdminRouteContextValue | null>(null);
6
+ export const useAdminRouteContext = () => useContext(AdminRouteContext);