portal-mojo 0.2.19 → 0.2.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/README.md +7 -1
- package/docs/admin-credentials.md +27 -0
- package/docs/admin-deployments.md +201 -0
- package/docs/admin-dns-certificates.md +160 -0
- package/docs/admin-dns-domains.md +92 -0
- package/docs/admin-dns.md +18 -0
- package/docs/admin-edge.md +200 -196
- package/docs/admin-fleet-configuration.md +98 -2
- package/docs/admin-geolocation.md +100 -0
- package/docs/admin-identity-directories.md +12 -0
- package/docs/admin-maintenance.md +133 -0
- package/docs/admin-modals.md +45 -2
- package/docs/admin-operational-fleet.md +206 -0
- package/docs/admin-presentation.md +51 -0
- package/docs/admin-signin.md +12 -2
- package/docs/auth-pages.md +18 -6
- package/docs/auth.md +16 -2
- package/docs/client.md +7 -0
- package/docs/collection-select.md +14 -0
- package/docs/combobox.md +18 -0
- package/docs/popover.md +5 -4
- package/package.json +1 -1
- package/src/admin/core/OperationsHelp.tsx +10 -0
- package/src/admin/core/index.ts +6 -4
- package/src/admin/core/route-context.ts +6 -0
- package/src/admin/credentials/AcmeConsumerKeyDialog.tsx +88 -0
- package/src/admin/credentials/acme-consumer-key.ts +34 -0
- package/src/admin/credentials/group-api-keys.tsx +5 -3
- package/src/admin/deployments/ApiDeploymentDetail.tsx +58 -0
- package/src/admin/deployments/DeploymentsPage.tsx +73 -0
- package/src/admin/deployments/WebAppDeploySetup.tsx +49 -0
- package/src/admin/deployments/WebAppDeploymentDetail.tsx +59 -0
- package/src/admin/deployments/WebAppDetail.tsx +104 -0
- package/src/admin/deployments/WebAppEditDialog.tsx +70 -0
- package/src/admin/deployments/WebAppKeyDialog.tsx +25 -0
- package/src/admin/deployments/WebAppOnboardingDialog.tsx +217 -0
- package/src/admin/deployments/WebAppRollbackDialog.tsx +82 -0
- package/src/admin/deployments/WebAppUploadPanel.tsx +132 -0
- package/src/admin/deployments/api.ts +158 -0
- package/src/admin/deployments/authority-state.ts +9 -0
- package/src/admin/deployments/deployment-state.ts +13 -0
- package/src/admin/deployments/index.ts +4 -0
- package/src/admin/deployments/onboarding-state.ts +11 -0
- package/src/admin/deployments/permissions.ts +37 -0
- package/src/admin/deployments/receipts.ts +78 -0
- package/src/admin/deployments/sanitize.ts +73 -0
- package/src/admin/deployments/shared.tsx +39 -0
- package/src/admin/deployments/types.ts +238 -0
- package/src/admin/deployments/upload.ts +53 -0
- package/src/admin/deployments/useDeploymentObservation.ts +104 -0
- package/src/admin/dns/CertificateDetail.tsx +8 -3
- package/src/admin/dns/CertificateRequestDialog.tsx +45 -7
- package/src/admin/dns/DomainDetail.tsx +14 -4
- package/src/admin/dns/DomainOnboardingContent.tsx +44 -0
- package/src/admin/dns/DomainOnboardingDialog.tsx +5 -24
- package/src/admin/dns/DomainPurchaseWizard.tsx +1 -1
- package/src/admin/dns/DomainRegisterExisting.tsx +63 -0
- package/src/admin/dns/DomainRegistrarSettings.tsx +52 -0
- package/src/admin/dns/DomainsPage.tsx +26 -13
- package/src/admin/dns/SharedValidationDialog.tsx +298 -0
- package/src/admin/dns/SharedValidationPanel.tsx +76 -0
- package/src/admin/dns/api.ts +4 -2
- package/src/admin/dns/domain-navigation.ts +16 -0
- package/src/admin/dns/registrar-api.ts +13 -0
- package/src/admin/dns/shared-validation-api.ts +90 -0
- package/src/admin/dns/shared-validation-data.ts +106 -0
- package/src/admin/domains/assistant.ts +5 -4
- package/src/admin/domains/communications.ts +10 -9
- package/src/admin/domains/identity.ts +8 -7
- package/src/admin/domains/infrastructure.ts +19 -13
- package/src/admin/domains/lazy-page.ts +6 -0
- package/src/admin/domains/observability.ts +6 -5
- package/src/admin/domains/operations.ts +27 -10
- package/src/admin/domains/security.ts +16 -15
- package/src/admin/edge/EdgeHelp.tsx +10 -0
- package/src/admin/edge/ServingReadiness.tsx +54 -0
- package/src/admin/edge/VhostDetail.tsx +145 -139
- package/src/admin/edge/VhostWizard.tsx +204 -115
- package/src/admin/edge/VhostsPage.tsx +27 -28
- package/src/admin/edge/api.ts +24 -10
- package/src/admin/edge/models.ts +4 -4
- package/src/admin/edge/serving-data.ts +78 -0
- package/src/admin/fleet/CapacityEditor.tsx +81 -0
- package/src/admin/fleet/CapacityInspector.tsx +19 -0
- package/src/admin/fleet/CapacityProgress.tsx +9 -0
- package/src/admin/fleet/FleetHelp.tsx +7 -0
- package/src/admin/fleet/FleetOverview.tsx +150 -0
- package/src/admin/fleet/FleetPage.tsx +11 -0
- package/src/admin/fleet/FleetWorkspace.tsx +82 -0
- package/src/admin/fleet/ScaleFleetDialog.tsx +169 -0
- package/src/admin/fleet/access.ts +22 -0
- package/src/admin/fleet/api.ts +77 -0
- package/src/admin/fleet/data.ts +148 -0
- package/src/admin/fleet/index.ts +8 -0
- package/src/admin/fleet/operations.ts +179 -0
- package/src/admin/fleet/standalone-data.ts +87 -0
- package/src/admin/fleet/types.ts +208 -0
- package/src/admin/fleet-configuration/FleetConfigurationPage.tsx +14 -8
- package/src/admin/fleet-configuration/SharedValidationConnectionPanel.tsx +166 -0
- package/src/admin/fleet-configuration/data.ts +4 -0
- package/src/admin/fleet-configuration/operator-destinations.ts +20 -0
- package/src/admin/fleet-configuration/shared-validation-data.ts +143 -0
- package/src/admin/geolocation/GeoLocationPage.tsx +171 -0
- package/src/admin/geolocation/data.ts +138 -0
- package/src/admin/geolocation/index.ts +3 -0
- package/src/admin/identity/groups/group-sections/SubGroupsSection.tsx +73 -2
- package/src/admin/identity/groups/models.ts +2 -0
- package/src/admin/identity/members/GroupMembersPanel.tsx +2 -8
- package/src/admin/index.ts +8 -0
- package/src/admin/maintenance/MaintenanceDialogs.tsx +161 -0
- package/src/admin/maintenance/MaintenancePage.tsx +442 -0
- package/src/admin/maintenance/data.ts +486 -0
- package/src/admin/maintenance/index.ts +3 -0
- package/src/admin/maintenance/operations.ts +208 -0
- package/src/admin/public/infrastructure.ts +1 -0
- package/src/admin/public/operations.ts +3 -0
- package/src/admin/registry.ts +6 -2
- package/src/admin/settings/SettingDetail.tsx +7 -2
- package/src/admin/settings/SettingEditor.tsx +2 -1
- package/src/admin/settings/SettingsPage.tsx +3 -1
- package/src/admin/settings/model.ts +1 -0
- package/src/admin/signin/SigninPage.tsx +17 -3
- package/src/admin/signin/api.ts +2 -0
- package/src/charts/MetricsChartView.tsx +4 -11
- package/src/charts/MetricsMiniWidget.tsx +3 -15
- package/src/client/auth.ts +77 -25
- package/src/client/client.ts +6 -0
- package/src/client/deployments-mock.ts +375 -0
- package/src/client/domain-operations-mock.ts +37 -0
- package/src/client/fleet-mock.ts +73 -1
- package/src/client/geo-provider-mock.ts +82 -0
- package/src/client/index.ts +2 -2
- package/src/client/maintenance-mock.ts +285 -0
- package/src/client/mock.ts +280 -11
- package/src/client/operational-fleet-mock.ts +279 -0
- package/src/client/operations-mock.ts +85 -0
- package/src/client/serving-mock.ts +22 -0
- package/src/styles/components/admin-deployments.css +35 -0
- package/src/styles/components/admin-dns-certificates.css +33 -0
- package/src/styles/components/admin-domains.css +12 -0
- package/src/styles/components/admin-edge.css +125 -1
- package/src/styles/components/admin-geolocation.css +40 -0
- package/src/styles/components/admin-maintenance.css +127 -0
- package/src/styles/components/admin-operational-fleet.css +38 -0
- package/src/styles/components/admin-operations.css +9 -0
- package/src/styles/components/admin-signin.css +2 -0
- package/src/styles/components/combobox.css +12 -2
- package/src/styles/components/fleet-page.css +9 -0
- package/src/styles/index.css +7 -0
- package/src/ui/CollectionSelect.tsx +30 -8
- package/src/ui/ComboBox.tsx +39 -8
- package/src/ui/FilterBar.tsx +3 -5
- package/src/ui/ModelTable.tsx +2 -8
- package/src/ui/Popover.tsx +15 -7
- package/src/ui/date/TimePicker.tsx +4 -14
- package/src/ui/image-editor/ImageEditor.tsx +4 -5
- package/src/ui/schema-form-core.tsx +3 -5
package/docs/auth-pages.md
CHANGED
|
@@ -124,12 +124,18 @@ Client surface (`portal-mojo/client`):
|
|
|
124
124
|
- `withFreshAuth(fn)` — run fn; on a 440 prompt, then retry **once** after a
|
|
125
125
|
successful re-login; a dismissed prompt rejects with the ORIGINAL error.
|
|
126
126
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
127
|
+
In hosted mode (including packaged Admin), the native dialog offers Cancel
|
|
128
|
+
and Continue to sign-in. Continue opens the existing configured sign-in page
|
|
129
|
+
with `force_reauth=1`, so Google-only deployments can use Google. It preserves
|
|
130
|
+
the exact return route and original storage choice. The pending challenge
|
|
131
|
+
resolves false before unloading: after returning, re-read and deliberately
|
|
132
|
+
retry the action. Picking another account starts a new session and screen;
|
|
133
|
+
the former account's operation is never carried over.
|
|
134
|
+
|
|
135
|
+
In explicit in-app/mock mode the modal re-prompts credentials over the current
|
|
136
|
+
screen, pins the current email when available, and keeps the existing
|
|
137
|
+
password/passkey/MFA and storage behavior. Dev affordance:
|
|
138
|
+
`__mojo.requestFreshAuth()` exercises the modal without a 440.
|
|
133
139
|
|
|
134
140
|
Call-site pattern:
|
|
135
141
|
|
|
@@ -172,6 +178,12 @@ bouncer-enforcing deployments.
|
|
|
172
178
|
|
|
173
179
|
## Mock affordances
|
|
174
180
|
|
|
181
|
+
`npm run verify:hosted-reauth` covers the mounted hosted confirmation and
|
|
182
|
+
return/storage paths. For native dialog, responsive and theme checks, run
|
|
183
|
+
`node scripts/serve-hosted-reauth.mjs` and open its printed local URL. The
|
|
184
|
+
fixture uses the actual Portal host/bridge and a simulated Google-only sign-in
|
|
185
|
+
page; it does not prove a live OAuth provider exchange.
|
|
186
|
+
|
|
175
187
|
Under the mock transport the pages show inline dev hints: password `mojo`
|
|
176
188
|
for any seeded user (e.g. `ian@mojoverify.com`); Maya (seeded user 2) requires
|
|
177
189
|
MFA; TOTP `123456`, recovery `mock-recovery`, and SMS `654321`; reset code
|
package/docs/auth.md
CHANGED
|
@@ -102,11 +102,25 @@ its bot-check interstitial). Flow:
|
|
|
102
102
|
`<origin>/auth?redirect=<HASH-FREE current url>`. Hash-free is
|
|
103
103
|
load-bearing: the page string-appends `?auth_code=<code>` to the
|
|
104
104
|
redirect, and a `#` in it would swallow the code.
|
|
105
|
-
2. User signs in
|
|
105
|
+
2. User signs in using that page's configured methods (Google, password,
|
|
106
|
+
passkey, etc.). Same-origin return uses its existing stored session;
|
|
107
|
+
a cross-origin return mints a single-use 60s handoff code
|
|
106
108
|
(`POST /api/auth/handoff`) and navigates back with `?auth_code=`.
|
|
107
109
|
3. `handleAuthCodeFromURL()` (boot) exchanges it (`POST /api/auth/exchange`),
|
|
108
110
|
scrubs the code from the URL synchronously, restores the stashed route.
|
|
109
|
-
|
|
111
|
+
It also restores same-origin returns without a code. Handles a
|
|
112
|
+
hash-embedded `#/route?auth_code=` landing too. Await this at boot before
|
|
113
|
+
creating the router.
|
|
114
|
+
|
|
115
|
+
`redirectToHostedAuth({forceReauth: true})` uses the same page with
|
|
116
|
+
`force_reauth=1`, bypassing its automatic existing-session check. It retains
|
|
117
|
+
the initiating persistent/session-only choice, including when the hosted
|
|
118
|
+
page writes localStorage. `remember` may explicitly supply the choice captured
|
|
119
|
+
when the challenge opened. Tab-local return metadata contains only the route,
|
|
120
|
+
destination, time and storage choice; it is consumed once, expires after 30
|
|
121
|
+
minutes, and applies only at the matching origin/path/query. It never carries
|
|
122
|
+
an operation or establishes freshness. The server still enforces `auth_time`.
|
|
123
|
+
When storage cannot retain the choice, step-up navigation returns false.
|
|
110
124
|
|
|
111
125
|
`hostedAuthUrl()` returns null under the mock transport — there is no page
|
|
112
126
|
to host; the UI should fall back to a dev affordance (`__mojo.login(...)`).
|
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.
|
|
@@ -130,3 +130,17 @@ too — there is no parallel fetch path.
|
|
|
130
130
|
handed a picker that says to pick a group they can't pick there.
|
|
131
131
|
- Don't wire `onChange` to a per-keystroke autosave expectation — it fires
|
|
132
132
|
on commit (that is the point; see the B3 autosave contract).
|
|
133
|
+
|
|
134
|
+
### Optional ingress projection
|
|
135
|
+
|
|
136
|
+
`sanitizeRow={(row) => publicProjection(row)}` projects list and one-record
|
|
137
|
+
responses before Query cache storage. Such pickers use a separate cache keyed
|
|
138
|
+
by the sanitizer identity, auth UID and session revision; they never scan or
|
|
139
|
+
reuse raw endpoint caches. Keep the callback stable. Default consumers retain
|
|
140
|
+
the existing shared model keys and behavior. The callback must return only the
|
|
141
|
+
approved fields or reject malformed input. Full selected objects also need to
|
|
142
|
+
be projected by the caller before passing them in.
|
|
143
|
+
|
|
144
|
+
An endpoint with query-based hydration should hydrate through its own safe
|
|
145
|
+
`?id=` helper and pass the full selected object; do not pass a bare id that
|
|
146
|
+
would invent a `/<id>` record route.
|
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.
|
|
99
|
-
|
|
100
|
-
|
|
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,
|
|
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.
|
|
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
|
+
}
|
package/src/admin/core/index.ts
CHANGED
|
@@ -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(
|
|
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);
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { useQueryClient } from '@tanstack/react-query';
|
|
3
|
+
import { requestFreshAuth } from '../../client/runtime';
|
|
4
|
+
import { CollectionSelect, FlatRow, modal } from '../../ui';
|
|
5
|
+
import { SecretBox } from './secret-dialog';
|
|
6
|
+
import { OperatorError, usePrivateOperator, projectIdentity } from '../fleet-configuration/shared-validation-data';
|
|
7
|
+
import { issueAcmeKey, projectKeyGroup, readAcmeRecipient, readKeyGroup, readRecipientKeyInventory, revealRecipientKey, type AcmeRecipient, type KeyGroup, type KeySummary } from './acme-consumer-key';
|
|
8
|
+
import { GroupApiKeyModel, type CredentialGroup } from './models';
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
export function AcmeConsumerKeyButton({ group }: { group?: CredentialGroup }) {
|
|
12
|
+
const owner = usePrivateOperator();
|
|
13
|
+
return owner.allowed ? <button className="btn btn-compact" onClick={() => void openAcmeConsumerKey(group)}>Create ACME consumer key</button> : null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function openAcmeConsumerKey(group?: CredentialGroup) {
|
|
17
|
+
let pending = false;
|
|
18
|
+
return modal.open((close) => <AcmeConsumerKeyDialog group={group} close={() => close(null)} onBusy={(busy) => { pending = busy; }} />, { size: 'lg', canDismiss: () => !pending });
|
|
19
|
+
}
|
|
20
|
+
export function AcmeConsumerKeyDialog({ group: fixedGroup, close, onBusy }: { group?: CredentialGroup; close: () => void; onBusy?: (busy: boolean) => void }) {
|
|
21
|
+
const owner = usePrivateOperator(), qc = useQueryClient();
|
|
22
|
+
const [group, setGroup] = useState<KeyGroup | null>(null), [recipient, setRecipient] = useState<AcmeRecipient | null>(null);
|
|
23
|
+
const [inventory, setInventory] = useState<KeySummary[] | null>(null), [blocked, setBlocked] = useState(false), [busy, setBusy] = useState(false), [error, setError] = useState(''), [notice, setNotice] = useState('');
|
|
24
|
+
const name = useRef<HTMLInputElement>(null), flight = useRef(false), generation = useRef(0), recipientOwner = useRef('');
|
|
25
|
+
const selected = useRef(group?.id); selected.current = group?.id;
|
|
26
|
+
const opener = useRef<HTMLElement | null>(null);
|
|
27
|
+
const pending = (value: boolean) => {
|
|
28
|
+
if (value) opener.current = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
|
29
|
+
flight.current = value; setBusy(value); onBusy?.(value);
|
|
30
|
+
if (!value) { const fence = owner.fence(), target = opener.current; requestAnimationFrame(() => { try { fence.beforeSend(); if (target?.isConnected) target.focus(); } catch { /* Former owner cannot regain focus. */ } }); }
|
|
31
|
+
};
|
|
32
|
+
useEffect(() => () => { generation.current++; onBusy?.(false); }, []);
|
|
33
|
+
useEffect(() => { setRecipient(null); setInventory(null); setBlocked(true); if (name.current) name.current.value = ''; }, [owner.key]);
|
|
34
|
+
const failure = async (reason: unknown) => { const safe = reason instanceof OperatorError ? reason : new OperatorError('unavailable'); setError(safe.message); setBlocked(true); if (safe.kind === 'fresh') await requestFreshAuth(); };
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
if (!fixedGroup || !owner.allowed) return;
|
|
37
|
+
const fence = owner.fence(); void readKeyGroup(fixedGroup.id, fence).then((g) => { fence.beforeSend(); setGroup(g); }).catch((e) => { if (!fence.signal?.aborted) void failure(e); });
|
|
38
|
+
}, [fixedGroup?.id, owner.key]);
|
|
39
|
+
useEffect(() => { setRecipient(null); setInventory(null); setError(''); setBlocked(false); generation.current++; }, [group?.id]);
|
|
40
|
+
const refresh = async () => {
|
|
41
|
+
if (flight.current || !group || !owner.allowed) return;
|
|
42
|
+
const fence = owner.fence(), id = group.id, run = ++generation.current; pending(true); setError(''); setRecipient(null);
|
|
43
|
+
try {
|
|
44
|
+
const current = await readKeyGroup(id, fence); fence.beforeSend();
|
|
45
|
+
if (selected.current !== id || generation.current !== run) return;
|
|
46
|
+
if (!current.uuid) throw new OperatorError('invalid'); projectIdentity(current.uuid);
|
|
47
|
+
const [person, keys] = await Promise.all([readAcmeRecipient(current.uuid, fence), readRecipientKeyInventory(id, fence)]); fence.beforeSend();
|
|
48
|
+
if (selected.current !== id || generation.current !== run) return;
|
|
49
|
+
recipientOwner.current = owner.key; setGroup(current); setRecipient(person); setInventory(keys); setBlocked(false);
|
|
50
|
+
} catch (e) { if (!fence.signal?.aborted && generation.current === run) await failure(e); }
|
|
51
|
+
finally { if (!fence.signal?.aborted) pending(false); }
|
|
52
|
+
};
|
|
53
|
+
const displaySecret = async (value: string, title: string) => {
|
|
54
|
+
const fence = owner.fence(); let secret: string | null = value;
|
|
55
|
+
try { fence.beforeSend(); await modal.open((done) => <div className="modal-pad"><h2 className="modal-title">{title}</h2><p>This transient display is not stored in Portal. A later explicit Token reveal in the key inventory is audited and remains available.</p><SecretBox secret={secret ?? ''} ariaLabel="ACME consumer token" /><p>Enter the replacement normally in the receiving deployment’s protected connection form. Publish, Apply and verify every expected node before explicitly deactivating the old hub key. Issuance alone grants no allocation trust or hub activation.</p><div className="modal-actions"><button className="btn" onClick={() => done(null)}>Close token</button></div></div>, { size: 'lg', signal: fence.signal }); }
|
|
56
|
+
finally { secret = null; value = ''; }
|
|
57
|
+
};
|
|
58
|
+
const issue = async () => {
|
|
59
|
+
if (flight.current || !owner.allowed || recipientOwner.current !== owner.key || !recipient?.can_issue || !group || blocked) return;
|
|
60
|
+
const fence = owner.fence(), person = recipient, id = group.id, keyName = name.current?.value.trim() ?? ''; if (!keyName) { setError('Name this replacement key.'); return; }
|
|
61
|
+
const run = generation.current; pending(true); setError(''); let attempted = false;
|
|
62
|
+
try {
|
|
63
|
+
const confirmed = await modal.open<boolean>((done) => <div className="modal-pad"><h2 className="modal-title">Issue protected ACME consumer key?</h2><div className="modal-message"><p>{person.consumer_label} · <code>{person.consumer_project_uuid}</code></p><p>Key: {keyName}</p><p>Grant this exact original hub project the protected ACME validation consumer purpose. The fixed grant is dnsman_acme_federation, with no linked user or override. This does not grant allocation trust or enable shared validation. The existing key stays active.</p></div><div className="modal-actions"><button className="btn" onClick={() => done(false)}>Cancel</button><button className="btn btn-primary" onClick={() => done(true)}>Issue replacement key</button></div></div>, { size: 'md', signal: fence.signal });
|
|
64
|
+
if (!confirmed) return; fence.beforeSend(); if (selected.current !== id || generation.current !== run) throw new OperatorError('conflict');
|
|
65
|
+
attempted = true; const result = await issueAcmeKey(person, keyName, fence); fence.beforeSend();
|
|
66
|
+
if (selected.current !== id || generation.current !== run) { result.token = ''; return; }
|
|
67
|
+
setNotice(`Key ${result.key.name} issued. The old key was not retired.`);
|
|
68
|
+
try { await displaySecret(result.token, 'ACME consumer key issued'); } finally { result.token = ''; }
|
|
69
|
+
} catch (e) { if (!fence.signal?.aborted) await failure(e); }
|
|
70
|
+
finally {
|
|
71
|
+
if (attempted) { if (name.current) name.current.value = ''; await GroupApiKeyModel.invalidate(qc); if (!fence.signal?.aborted && selected.current === id) { try { const keys = await readRecipientKeyInventory(id, fence); fence.beforeSend(); setInventory(keys); } catch { setBlocked(true); setError('Issuance may have persisted. Refresh the Group key inventory before issuing another key.'); } } }
|
|
72
|
+
if (!fence.signal?.aborted) pending(false);
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
const reveal = async (row: KeySummary) => {
|
|
76
|
+
if (flight.current || !owner.allowed || recipientOwner.current !== owner.key) return; const fence = owner.fence(); pending(true);
|
|
77
|
+
try { const value = await revealRecipientKey(row.id, fence); fence.beforeSend(); await displaySecret(value, `Audited token — ${row.name}`); }
|
|
78
|
+
catch (e) { if (!fence.signal?.aborted) await failure(e); } finally { if (!fence.signal?.aborted) pending(false); }
|
|
79
|
+
};
|
|
80
|
+
if (!owner.allowed) return <div className="modal-pad"><h2>Protected key access unavailable</h2><p>A current interactive literal superuser is required.</p><button className="btn" onClick={close}>Close</button></div>;
|
|
81
|
+
return <div className="modal-pad shared-validation-operator" style={{ minWidth: 0, overflowWrap: 'anywhere' }}><h2 className="modal-title">Create ACME consumer key</h2><p>Run this form on the approved hub. Choose its original recipient Group by name; a downstream tenant or acting user is not that identity.</p>
|
|
82
|
+
{fixedGroup ? <FlatRow label="Selected hub Group">{group?.name ?? fixedGroup.name}</FlatRow> : <CollectionSelect<KeyGroup> endpoint="/api/group" sanitizeRow={projectKeyGroup} value={group} label="Original hub recipient Group" disabled={busy} onChange={(_id, row) => setGroup(row ?? null)} />}
|
|
83
|
+
{group && <><FlatRow label="Group UUID"><code>{group.uuid ?? 'No valid UUID reported'}</code></FlatRow><button className="btn" disabled={busy} onClick={() => void refresh()}>Resolve recipient and refresh keys</button></>}
|
|
84
|
+
{error && <p className="form-alert" role="alert">{error}</p>}{notice && <p role="status">{notice}</p>}
|
|
85
|
+
{recipient && <div className="dns-shared-request"><FlatRow label="Actual active hub project">{recipient.consumer_label}</FlatRow><FlatRow label="Exact UUID"><code>{recipient.consumer_project_uuid}</code></FlatRow><FlatRow label="Purpose">ACME validation consumer</FlatRow><FlatRow label="Fixed protected grant"><code>{recipient.permission}</code></FlatRow><p>{recipient.grant_explanation}</p><p>Issuing a key does not grant allocation trust or activate the hub.</p><label className="field"><span className="field-label">Replacement key name</span><input className="input" ref={name} maxLength={200} disabled={busy || blocked || !recipient.can_issue} placeholder="Project shared validation" /></label><button className="btn btn-primary" disabled={busy || blocked || !recipient.can_issue} onClick={() => void issue()}>Review protected key issuance</button>{!recipient.can_issue && <p>Protected issuance is unavailable for this operator.</p>}</div>}
|
|
86
|
+
<h3>Recent Group key inventory</h3><p>After an uncertain response, Refresh this inventory and use audited reveal before creating another successor. Up to 100 recent keys are shown; generic key management remains in the existing inventory.</p>{inventory?.map((row) => <div className="dns-shared-request" key={row.id}><strong>{row.name} · #{row.id}</strong><p>{row.is_active ? 'Active' : 'Inactive'}</p><button className="btn" disabled={busy} onClick={() => void reveal(row)}>Reveal audited token</button></div>)}
|
|
87
|
+
<p>Replacement → protected connection Preview/Publish → Apply → verify every expected node → explicitly review/deactivate the old hub key. No key is rotated or retired by this form.</p><div className="modal-actions"><button className="btn" disabled={busy} onClick={close}>{busy ? 'Operation pending…' : 'Close'}</button></div></div>;
|
|
88
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { choice, flag, label, nullable, object, OperatorError, positive, privateCall, projectIdentity, type PrivateScope } from '../fleet-configuration/shared-validation-data';
|
|
2
|
+
import { GroupApiKeyModel } from './models';
|
|
3
|
+
export const ACME_RECIPIENT_ENDPOINT = '/api/account/admin/fleet/shared-validation/recipient';
|
|
4
|
+
export type KeyGroup = { id: number; name: string; uuid: string | null };
|
|
5
|
+
export interface AcmeRecipient { purpose: 'acme_validation_consumer'; consumer_project_uuid: string; consumer_label: string; permission: 'dnsman_acme_federation'; can_issue: boolean; grant_explanation: string }
|
|
6
|
+
export interface KeySummary { id: number; name: string; is_active: boolean }
|
|
7
|
+
export function projectKeyGroup(v: unknown): KeyGroup { const r = object(v); return { id: positive(r.id), name: label(r.name), uuid: r.uuid == null ? null : label(r.uuid, 36) }; }
|
|
8
|
+
export async function readKeyGroup(id: number, scope?: PrivateScope) { const g = projectKeyGroup(await privateCall(`/api/group/${positive(id)}`, null, scope)); if (g.id !== id) throw new OperatorError('invalid'); return g; }
|
|
9
|
+
export function parseAcmeRecipient(v: unknown, expected: string): AcmeRecipient {
|
|
10
|
+
const r = object(v); const out = { purpose: choice(r.purpose, ['acme_validation_consumer']), consumer_project_uuid: projectIdentity(r.consumer_project_uuid), consumer_label: label(r.consumer_label), permission: choice(r.permission, ['dnsman_acme_federation']), can_issue: flag(r.can_issue), grant_explanation: label(r.grant_explanation, 300) };
|
|
11
|
+
if (out.consumer_project_uuid !== expected) throw new OperatorError('invalid'); return out;
|
|
12
|
+
}
|
|
13
|
+
export const readAcmeRecipient = async (consumer: string, scope?: PrivateScope) => parseAcmeRecipient(await privateCall(ACME_RECIPIENT_ENDPOINT, null, scope, { consumer_project_uuid: projectIdentity(consumer) }), consumer);
|
|
14
|
+
export async function issueAcmeKey(recipient: AcmeRecipient, name: string, scope?: PrivateScope): Promise<{ key: KeySummary; token: string }> {
|
|
15
|
+
if (!recipient.can_issue || !name.trim()) throw new OperatorError('denied');
|
|
16
|
+
const r = object(await privateCall(ACME_RECIPIENT_ENDPOINT, { consumer_project_uuid: projectIdentity(recipient.consumer_project_uuid), expected_consumer_label: recipient.consumer_label, label: label(name.trim()), purpose: 'acme_validation_consumer' }, scope));
|
|
17
|
+
let secret = r.token; delete r.token;
|
|
18
|
+
try {
|
|
19
|
+
if (r.purpose !== 'acme_validation_consumer' || r.consumer_project_uuid !== recipient.consumer_project_uuid || r.consumer_label !== recipient.consumer_label) throw new OperatorError('invalid');
|
|
20
|
+
const key = object(r.api_key); if (key.permission !== 'dnsman_acme_federation' || key.is_active !== true || typeof secret !== 'string' || !secret || secret.length > 4096 || /\s/.test(secret)) throw new OperatorError('invalid');
|
|
21
|
+
return { key: { id: positive(key.id), name: label(key.name), is_active: true }, token: secret };
|
|
22
|
+
} finally { secret = null; }
|
|
23
|
+
}
|
|
24
|
+
export async function readRecipientKeyInventory(group: number, scope?: PrivateScope): Promise<KeySummary[]> {
|
|
25
|
+
const rows = await privateCall(GroupApiKeyModel.endpoint, null, scope, { group: positive(group), size: 100, sort: '-created', graph: 'default' });
|
|
26
|
+
if (!Array.isArray(rows) || rows.length > 100) throw new OperatorError('invalid');
|
|
27
|
+
return rows.map((v) => { const r = object(v), g = r.group && typeof r.group === 'object' ? object(r.group).id : r.group; if (g !== group) throw new OperatorError('invalid'); return { id: positive(r.id), name: label(r.name), is_active: flag(r.is_active) }; });
|
|
28
|
+
}
|
|
29
|
+
/** Same explicit audited graph as the existing Token control; no Query cache. */
|
|
30
|
+
export async function revealRecipientKey(id: number, scope?: PrivateScope): Promise<string> {
|
|
31
|
+
const r = object(await privateCall(`${GroupApiKeyModel.endpoint}/${positive(id)}`, null, scope, { graph: 'token' }));
|
|
32
|
+
const value = nullable(r.token, (v) => label(v, 4096)); delete r.token;
|
|
33
|
+
if (!value) throw new OperatorError('unavailable'); return value;
|
|
34
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useQueryClient } from '@tanstack/react-query';
|
|
2
|
-
import { useContext, useSyncExternalStore } from 'react';
|
|
2
|
+
import { lazy, Suspense, useContext, useSyncExternalStore } from 'react';
|
|
3
3
|
import {
|
|
4
4
|
GroupContext,
|
|
5
5
|
hasPermission,
|
|
@@ -42,6 +42,7 @@ import {
|
|
|
42
42
|
type GroupApiKeyRow,
|
|
43
43
|
} from './models';
|
|
44
44
|
import { showSecretDialog } from './secret-dialog';
|
|
45
|
+
const ProtectedKeyButton = lazy(() => import('./AcmeConsumerKeyDialog').then((m) => ({ default: m.AcmeConsumerKeyButton })));
|
|
45
46
|
|
|
46
47
|
function groupLabel(group: GroupApiKeyRow['group']): string {
|
|
47
48
|
if (group && typeof group === 'object') return group.name;
|
|
@@ -362,6 +363,7 @@ function GroupApiKeysContent({ group, permission }: { group: CredentialGroup; pe
|
|
|
362
363
|
<button className="btn btn-primary btn-compact" onClick={() => void actions.createKey(group)}>
|
|
363
364
|
<i className="bi bi-key" /> Create Key
|
|
364
365
|
</button>
|
|
366
|
+
<Suspense fallback={null}><ProtectedKeyButton group={group} /></Suspense>
|
|
365
367
|
</div>
|
|
366
368
|
)}
|
|
367
369
|
{!isPending && rows.length === 0 && <p className="dim-italic">No API keys yet.</p>}
|
|
@@ -489,7 +491,7 @@ export function GroupApiKeysPage() {
|
|
|
489
491
|
void modal.detail((close) => <GroupApiKeyDetail id={row.id} onClose={() => close(null)} />);
|
|
490
492
|
};
|
|
491
493
|
return (
|
|
492
|
-
|
|
494
|
+
<><Suspense fallback={null}><ProtectedKeyButton /></Suspense><ModelTable<GroupApiKeyRow>
|
|
493
495
|
model={GroupApiKeyModel}
|
|
494
496
|
eyebrow="Account"
|
|
495
497
|
title="Group API Keys"
|
|
@@ -507,6 +509,6 @@ export function GroupApiKeysPage() {
|
|
|
507
509
|
onRowClick={openDetail}
|
|
508
510
|
addLabel="New API Key"
|
|
509
511
|
onAdd={actions.canManage ? () => void actions.createKey() : undefined}
|
|
510
|
-
|
|
512
|
+
/></>
|
|
511
513
|
);
|
|
512
514
|
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
|
3
|
+
import { useMe } from '../../client/runtime';
|
|
4
|
+
import { Tabs } from '../../ui/Tabs';
|
|
5
|
+
import { datetime } from '../../ui/format';
|
|
6
|
+
import { modal } from '../../ui/modal';
|
|
7
|
+
import { canReadApi, canRecoverApi, isInteractive, assertOwner } from './permissions';
|
|
8
|
+
import { getPlatform, recoverApi } from './api';
|
|
9
|
+
import { apiActions, platformProof } from './deployment-state';
|
|
10
|
+
import { makeReceipt, settleReceipt } from './receipts';
|
|
11
|
+
import { useDeploymentObservation, useDeploymentOwner } from './useDeploymentObservation';
|
|
12
|
+
import { DeploymentHelp, ErrorNotice, Facts, Status, useCloseOnOwnerChange, useWrite } from './shared';
|
|
13
|
+
import type { ApiAction } from './types';
|
|
14
|
+
export function ApiDeploymentDetail({ id, onClose }: {
|
|
15
|
+
id: string;
|
|
16
|
+
onClose: () => void;
|
|
17
|
+
}) {
|
|
18
|
+
const me = useMe(), s = useDeploymentOwner(), queryClient = useQueryClient();
|
|
19
|
+
const [tracking, setTracking] = useState<string | null>(null);
|
|
20
|
+
useCloseOnOwnerChange(s.key, onClose);
|
|
21
|
+
const q = useQuery({
|
|
22
|
+
queryKey: ['deployments', s.key, 'api-detail', id], queryFn: ({ signal }) => getPlatform(s.owner, signal), enabled: canReadApi(me.data) && isInteractive(), retry: false
|
|
23
|
+
});
|
|
24
|
+
const selected = q.data?.deployments.data?.items.find(x => x.id === id);
|
|
25
|
+
const identity = tracking ?? (selected && ['requested', 'queued', 'canary', 'fleet', 'deploying'].includes(selected.status) ? id : null);
|
|
26
|
+
const observed = useDeploymentObservation(s.owner, identity, async (signal) => { const p = await getPlatform(s.owner, signal); const a = p.deployments.data?.items.find(x => x.id === identity); if (!a)
|
|
27
|
+
throw new Error('Tracked deployment not observed'); return a; }, a => ['converged', 'failed', 'superseded', 'verified', 'partial', 'unknown'].includes(a.status));
|
|
28
|
+
const row = observed.value ?? selected, latest = q.data?.deployments.data?.items[0]?.id === row?.id, can = canRecoverApi(me.data) && isInteractive();
|
|
29
|
+
useEffect(() => { if (observed.state === 'terminal') void queryClient.invalidateQueries({ queryKey: ['deployments', s.key] }); }, [observed.state, observed.value?.id, observed.value?.status, queryClient, s.key]);
|
|
30
|
+
const openRecovery = async (action: ApiAction) => { let busy = false; await modal.open(close => <ApiRecoveryDialog id={row!.id} action={action} onBusy={value => { busy = value; }} onClose={() => close(null)} onResult={result => { setTracking(result); void q.refetch(); }}/>, { signal: s.signal, canDismiss: () => !busy }); };
|
|
31
|
+
if (!canReadApi(me.data) || !isInteractive())
|
|
32
|
+
return <div className="modal-pad"><p>API authority is no longer available.</p><button className="btn" onClick={onClose}>Close</button></div>;
|
|
33
|
+
return <div className="modal-pad admin-deployments deployment-dialog"><header className="deployment-dialog-header"><div><h2 className="modal-title">API deployment {row && <DeploymentHelp label="API attempt information"><Facts rows={[["Attempt", row.id], ["Source", row.source], ["Actor", row.actor], ["Retry of", row.retry_of]]}/><p>Reported success and restart proof are separate. Recovery applies to the latest attempt and does not accept an arbitrary commit.</p></DeploymentHelp>}</h2><p className="dim mono">{row?.sha.slice(0, 12) || 'Attempt details'}</p></div><div className="deployment-actions"><button className="btn" disabled={q.isFetching} onClick={() => { void q.refetch(); observed.refresh(); }}>Refresh evidence</button><button className="btn" aria-label="Close API deployment" onClick={onClose}>Close</button></div></header>
|
|
34
|
+
{q.isError && <ErrorNotice>Deployment observation unavailable. {q.data ? 'Previous evidence is shown.' : 'Refresh to try again.'}</ErrorNotice>}
|
|
35
|
+
{row && <div className="deployment-toolbar"><Status value={row.status}/>{can && latest && <div className="deployment-actions">{apiActions(row.status).map(action => <button key={action} className="btn" onClick={() => void openRecovery(action)}>{action === 'retry' ? 'Retry same commit' : action === 'verify' ? 'Verify nodes' : 'Converge fleet'}</button>)}</div>}</div>}
|
|
36
|
+
<Tabs ariaLabel="API deployment details" items={[
|
|
37
|
+
{key:'overview',label:'Overview',panel:<div className="deployment-tab-content">{row ? <><Facts rows={[["Commit", <code>{row.sha}</code>], ['Evidence', platformProof(row)], ['Started', row.started ? datetime(row.started) : row.created ? datetime(row.created) : 'Time unavailable'], ['Finished', row.finished ? datetime(row.finished) : 'Not finished']]}/>{row.detail && <p>{row.detail}</p>}</> : <p>{q.isPending ? 'Loading attempt…' : q.data?.deployments.data ? 'Attempt unavailable in the latest 50 records.' : 'The collector did not return deployment evidence.'}</p>}</div>},
|
|
38
|
+
{key:'nodes',label:'Nodes',panel:<div className="deployment-tab-content">{row?.node_evidence.length ? row.node_evidence.map((n,i)=><article className="deployment-evidence" key={i}><strong>{n.runner}</strong><Status value={n.status}/>{n.sha && <p className="mono">{n.sha}</p>}{n.detail && <p>{n.detail}</p>}</article>) : <p>No node proof observed.</p>}</div>},
|
|
39
|
+
{key:'activity',label:'Activity',panel:<div className="deployment-tab-content">{row?.diagnosis.length ? <><h3>Failure diagnosis</h3>{row.diagnosis.map((d,i)=><article className="deployment-evidence" key={i}><strong>{d.runner}</strong><Status value={d.status}/><p>{d.detail}</p>{d.stderr_tail && <pre className="deployment-code">{d.stderr_tail}</pre>}</article>)}</> : <p>No failure diagnosis recorded.</p>}{!!row?.transitions.length && <><h3>Activity</h3>{row.transitions.map((t,i)=><p key={i}>{t.runner} · {t.status}{t.detail && ` · ${t.detail}`}</p>)}</>}</div>}
|
|
40
|
+
]}/>
|
|
41
|
+
{identity && <p className="dim" role="status">{observed.state === 'observing' ? 'Observing this attempt…' : observed.state === 'terminal' ? 'Observation complete.' : 'Observation stopped; outcome may be unknown. Refresh to observe again.'}</p>}
|
|
42
|
+
</div>;
|
|
43
|
+
}
|
|
44
|
+
function ApiRecoveryDialog({ id, action, onClose, onBusy, onResult }: {
|
|
45
|
+
id: string;
|
|
46
|
+
action: ApiAction;
|
|
47
|
+
onClose: () => void;
|
|
48
|
+
onBusy: (v: boolean) => void;
|
|
49
|
+
onResult: (id: string) => void;
|
|
50
|
+
}) {
|
|
51
|
+
const me = useMe(), s = useDeploymentOwner(), w = useWrite(s.owner, s.signal, onBusy);
|
|
52
|
+
const execute = () => w.run(async () => { if (!canRecoverApi(me.data) || !isInteractive())
|
|
53
|
+
throw new Error('API recovery authority unavailable'); const fresh = await getPlatform(s.owner, s.signal), source = fresh.deployments.data?.items.find(x => x.id === id); if (!source || !apiActions(source.status).includes(action) || fresh.deployments.data?.items[0]?.id !== id)
|
|
54
|
+
throw new Error('Attempt was replaced or changed. Refresh and review again.'); assertOwner(s.owner, s.signal); const receipt = makeReceipt(s.owner, 'api', `${id}:${action}`, 0, id); const result = await recoverApi(s.owner, receipt, id, action, s.signal); if (action === 'retry' && result.sha !== source.sha)
|
|
55
|
+
throw new Error('Retry did not preserve source SHA'); settleReceipt(receipt); onResult(result.id); onClose(); });
|
|
56
|
+
const label = { retry: 'Retry same commit', verify: 'Verify nodes', converge: 'Converge fleet' }[action];
|
|
57
|
+
return <div className="modal-pad admin-deployments"><h2 className="modal-title">{label} <DeploymentHelp label="API recovery target">Attempt <code>{id}</code>. The latest attempt and authority are checked before sending. A retry preserves its commit and tracks a new attempt.</DeploymentHelp></h2><p className="modal-message">{action === 'retry' ? 'Deploy the same commit as a new attempt.' : action === 'verify' ? 'Refresh runtime proof from the nodes in this attempt.' : 'Apply the current desired serving configuration across the fleet, then verify it.'}</p>{w.error && <ErrorNotice>{w.error}</ErrorNotice>}<div className="modal-actions"><button className="btn" disabled={w.busy} onClick={onClose}>Cancel</button><button className="btn btn-primary" disabled={w.busy} onClick={() => void execute()}>{label}</button></div></div>;
|
|
58
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
|
2
|
+
import { useSearchParams } from 'react-router-dom';
|
|
3
|
+
import { registerNonFilterParams, useMe } from '../../client/runtime';
|
|
4
|
+
import { Tabs } from '../../ui/Tabs';
|
|
5
|
+
import { datetime } from '../../ui/format';
|
|
6
|
+
import { modal } from '../../ui/modal';
|
|
7
|
+
import { backendGlobalCan } from '../fleet/access';
|
|
8
|
+
import { useAdminRouteContext } from '../core/route-context';
|
|
9
|
+
import { canOnboard, canReadApi, canReadBootstrap, canReadWebApps, isInteractive, assertOwner } from './permissions';
|
|
10
|
+
import { getBootstrap, getPlatform, getWebApps } from './api';
|
|
11
|
+
import { useDeploymentOwner } from './useDeploymentObservation';
|
|
12
|
+
import { DeploymentHelp, ErrorNotice, Facts, Status } from './shared';
|
|
13
|
+
import type { DeploymentsDestinations } from './types';
|
|
14
|
+
import { platformProof } from './deployment-state';
|
|
15
|
+
registerNonFilterParams('deployment_lane', 'deployment_app', 'deployment_tab');
|
|
16
|
+
export function DeploymentsPage({ destinations }: {
|
|
17
|
+
destinations?: DeploymentsDestinations;
|
|
18
|
+
} = {}) {
|
|
19
|
+
const me = useMe(), scope = useDeploymentOwner(), queryClient = useQueryClient(), [params, setParams] = useSearchParams();
|
|
20
|
+
const registered = useAdminRouteContext();
|
|
21
|
+
const mount = registered?.mount === '/' ? '' : registered?.mount;
|
|
22
|
+
const candidates = destinations ?? (mount !== undefined ? { domains: `${mount}/dns/domains`, serving: `${mount}/edge/vhosts` } : {});
|
|
23
|
+
const admittedDestinations: DeploymentsDestinations = {};
|
|
24
|
+
if (backendGlobalCan(me.data, ['view_dns', 'manage_dns', 'security'])) for (const key of ['domains', 'serving'] as const) {
|
|
25
|
+
const path = candidates[key];
|
|
26
|
+
if (path && (!registered || registered.paths.includes(path))) admittedDestinations[key] = path;
|
|
27
|
+
}
|
|
28
|
+
const interactive = isInteractive(), api = interactive && canReadApi(me.data), web = interactive && canReadWebApps(me.data);
|
|
29
|
+
const lane = params.get('deployment_lane') === 'webapps' && web ? 'webapps' : api ? 'api' : 'webapps';
|
|
30
|
+
const bootstrap = useQuery({
|
|
31
|
+
queryKey: ['deployments', scope.key, 'bootstrap'], queryFn: ({ signal }) => getBootstrap(scope.owner, signal), enabled: web && lane === 'webapps' && canReadBootstrap(me.data), retry: false
|
|
32
|
+
});
|
|
33
|
+
const select = (lane: string) => { setParams({ ...Object.fromEntries([...params].filter(([name]) => name === 'demo')), deployment_lane: lane }, { replace: true }); };
|
|
34
|
+
const newApp = async () => { const { WebAppOnboardingDialog } = await import('./WebAppOnboardingDialog'); assertOwner(scope.owner, scope.signal); let busy = false; await modal.open(close => <WebAppOnboardingDialog destinations={admittedDestinations} bootstrap={bootstrap.data!} onChanged={() => { void queryClient.invalidateQueries({ queryKey: ['deployments', scope.key] }); }} onClose={() => close(null)} onBusy={value => { busy = value; }}/>, {
|
|
35
|
+
signal: scope.signal, canDismiss: () => !busy, size: 'lg'
|
|
36
|
+
}); await queryClient.invalidateQueries({ queryKey: ['deployments', scope.key] }); };
|
|
37
|
+
if (!api && !web)
|
|
38
|
+
return <div className="panel panel-pad"><h1>Deployments</h1><p>API or WebApp read authority in this global Admin workspace is required.</p></div>;
|
|
39
|
+
const items = [...(api ? [{
|
|
40
|
+
key: 'api', label: 'API', panel: <ApiLane key={scope.key}/>
|
|
41
|
+
}] : []), ...(web ? [{
|
|
42
|
+
key: 'webapps', label: 'WebApps', panel: <WebAppsLane key={scope.key} destinations={admittedDestinations}/>
|
|
43
|
+
}] : [])];
|
|
44
|
+
return <div className="admin-deployments"><header className="page-header"><div><h1>Deployments</h1><p className="dim">Review releases and deployment progress.</p></div>{lane === 'webapps' && web && canOnboard(bootstrap.data) && <button className="btn btn-primary" onClick={() => void newApp()}>New WebApp</button>}</header>{items.length === 1 ? items[0]!.panel : <Tabs ariaLabel="Deployment type" items={items} activeKey={lane} onActiveKeyChange={select}/>}</div>;
|
|
45
|
+
}
|
|
46
|
+
function ApiLane() {
|
|
47
|
+
const me = useMe(), s = useDeploymentOwner();
|
|
48
|
+
const q = useQuery({
|
|
49
|
+
queryKey: ['deployments', s.key, 'platform', 'deployments,api'], queryFn: ({ signal }) => getPlatform(s.owner, signal), enabled: canReadApi(me.data) && isInteractive(), retry: false
|
|
50
|
+
});
|
|
51
|
+
const inspect = async (id: string) => { const { ApiDeploymentDetail } = await import('./ApiDeploymentDetail'); assertOwner(s.owner, s.signal); await modal.open(close => <ApiDeploymentDetail id={id} onClose={() => close(null)}/>, { signal: s.signal, size: 'lg' }); };
|
|
52
|
+
const d = q.data?.deployments, serving = d?.data?.currently_serving;
|
|
53
|
+
const stale = d?.status === 'stale' || !!d?.stale_after && Date.parse(d.stale_after) <= Date.now();
|
|
54
|
+
return <section><div className="deployment-toolbar"><h2>API</h2><button className="btn" disabled={q.isFetching} onClick={() => void q.refetch()}>Refresh API</button></div>
|
|
55
|
+
{q.isError && <ErrorNotice>API observation unavailable. {q.data ? 'Previous evidence is shown below.' : 'Refresh to try again.'}</ErrorNotice>}
|
|
56
|
+
{q.isPending ? <p role="status">Loading API evidence…</p> : !d?.data ? <div className="panel panel-pad"><h3>Deployment observation unavailable</h3><p>{d?.reason || 'The collector did not return deployment evidence. Refresh to try again.'}</p><Status value={d?.status ?? 'unknown'}/></div> : <>
|
|
57
|
+
{stale && <p className="text-warn" role="status">This deployment observation is stale. Refresh before acting.</p>}
|
|
58
|
+
<div className="deployment-current"><div className="deployment-toolbar"><div className="eyebrow">Last convergence</div><DeploymentHelp label="API convergence"><Facts rows={[["Collector", <Status value={d.status}/>], ["Observed", d.observed_at ? datetime(d.observed_at) : 'Time unavailable'], ['Framework', serving?.framework_version], ['Framework mode', d.data.framework_pin.mode], ['Coordination', d.data.coordination.state]]}/><p>Last convergence is dated evidence, not fresh proof for every current node. Framework changes belong to Maintenance.</p></DeploymentHelp></div><strong className="mono" title={serving?.sha}>{serving?.sha.slice(0, 12) ?? 'No convergence recorded'}</strong><p className="dim">{serving?.converged_at ? datetime(serving.converged_at) : 'Convergence time unknown'}</p></div>
|
|
59
|
+
<h3>Latest attempts</h3>{d.data.items.map((row, i) => <button className="deployment-record" key={row.id} onClick={() => void inspect(row.id)}><span><strong>{i === 0 ? 'Latest · ' : ''}{row.sha.slice(0, 12)}</strong><small>{platformProof(row)}</small></span><Status value={row.status}/></button>)}{!d.data.items.length && <p>No deployment attempts recorded.</p>}
|
|
60
|
+
{d.data.items.length >= d.data.limit && d.data.limit > 0 && <p className="dim">Showing the latest {d.data.limit} attempts.</p>}
|
|
61
|
+
</>}
|
|
62
|
+
</section>;
|
|
63
|
+
}
|
|
64
|
+
function WebAppsLane({ destinations }: {
|
|
65
|
+
destinations?: DeploymentsDestinations;
|
|
66
|
+
}) {
|
|
67
|
+
const me = useMe(), s = useDeploymentOwner();
|
|
68
|
+
const q = useQuery({
|
|
69
|
+
queryKey: ['deployments', s.key, 'summaries'], queryFn: ({ signal }) => getWebApps(s.owner, signal), enabled: canReadWebApps(me.data) && isInteractive(), retry: false
|
|
70
|
+
});
|
|
71
|
+
const inspect = async (id: number) => { const { WebAppDetail } = await import('./WebAppDetail'); assertOwner(s.owner, s.signal); await modal.open(close => <WebAppDetail id={id} destinations={destinations} onClose={() => close(null)}/>, { signal: s.signal, size: 'lg' }); };
|
|
72
|
+
return <section><div className="deployment-toolbar"><h2>WebApps</h2><button className="btn" disabled={q.isFetching} onClick={() => void q.refetch()}>Refresh WebApps</button></div>{q.isError ? <ErrorNotice>WebApp summary unavailable. Refresh to try again.</ErrorNotice> : q.isPending ? <p>Loading WebApps…</p> : <>{q.data?.truncated && <p className="text-warn">More apps exist; this list is truncated.</p>}{q.data?.items.map(row => <button className="deployment-record" key={row.webapp.id} onClick={() => void inspect(row.webapp.id)}><span><strong>{row.webapp.display_name || row.webapp.slug}</strong><small>{row.address?.hostname || 'No public address'}</small><small>Desired version: {row.current_release?.version ?? 'No release'}</small></span><span className="deployment-attempt-summary"><small>Latest attempt{row.latest_deployment?.release?.version && row.latest_deployment.release.version !== row.current_release?.version ? ` · ${row.latest_deployment.release.version}` : ''}</small><Status value={row.latest_deployment?.status ?? 'not started'}/></span></button>)}{!q.data?.items.length && <p>No WebApps yet.</p>}</>}</section>;
|
|
73
|
+
}
|