portal-mojo 0.2.4 → 0.2.5
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 +2 -0
- package/docs/admin-fleet-configuration.md +50 -0
- package/docs/admin-push.md +85 -6
- package/docs/admin-settings.md +10 -0
- package/docs/admin-signin.md +73 -0
- package/docs/realtime.md +5 -0
- package/package.json +1 -1
- package/src/admin/domains/identity.ts +7 -1
- package/src/admin/domains/operations.ts +8 -1
- package/src/admin/fleet-configuration/FleetConfigurationPage.tsx +171 -0
- package/src/admin/fleet-configuration/data.ts +89 -0
- package/src/admin/index.ts +3 -0
- package/src/admin/messaging/push/PushPage.tsx +61 -13
- package/src/admin/messaging/push/PushTestDialog.tsx +59 -0
- package/src/admin/messaging/push/api.ts +25 -1
- package/src/admin/messaging/push/models.ts +7 -6
- package/src/admin/public/identity.ts +1 -0
- package/src/admin/public/operations.ts +1 -0
- package/src/admin/registry.ts +4 -2
- package/src/admin/settings/SettingDetail.tsx +2 -1
- package/src/admin/settings/SettingValue.tsx +37 -0
- package/src/admin/settings/SettingsPage.tsx +2 -1
- package/src/admin/signin/SigninPage.tsx +397 -0
- package/src/admin/signin/api.ts +83 -0
- package/src/admin/signin/index.ts +4 -0
- package/src/client/fleet-mock.ts +61 -0
- package/src/client/mock.ts +239 -10
- package/src/client/realtime.ts +12 -0
package/docs/README.md
CHANGED
|
@@ -19,12 +19,14 @@ everything: run the showcase (`npm run dev:showcase`) → **Develop → Componen
|
|
|
19
19
|
| [admin-monitoring.md](admin-monitoring.md) | Monitoring Admin registration, logs, and exact Redis-backed metrics-permission mutations |
|
|
20
20
|
| [admin-metrics-explorer.md](admin-metrics-explorer.md) | Read-only recorded-metrics discovery, full-slug adapters, KPI/fan-out/scalar contracts, and cache identity |
|
|
21
21
|
| [admin-settings.md](admin-settings.md) | Runtime settings admin — direction-aware atomic writes, write-only secrets, and global/group scope |
|
|
22
|
+
| [admin-fleet-configuration.md](admin-fleet-configuration.md) | Superuser structured configuration publishing, version restore and per-node application evidence |
|
|
22
23
|
| [admin-bouncer.md](admin-bouncer.md) | Bouncer decisions, device investigations, and bot-signature administration with token-safe caching |
|
|
23
24
|
| [admin-security-tickets.md](admin-security-tickets.md) | Ticket queue, KISS detail modal, notes, approvals, and Maestro sync contract |
|
|
24
25
|
| [admin-incidents.md](admin-incidents.md) | Incident/event triage, forensic detail, merge semantics, and sanitized export boundary |
|
|
25
26
|
| [admin-rules.md](admin-rules.md) | Always-inactive RuleSet authoring and the lossless, runtime-effective handler-chain builder |
|
|
26
27
|
| [admin-members.md](admin-members.md) | Global membership table/detail, no-oracle admission, extensible grants, Group-detail composition, and unsupported-operation boundaries |
|
|
27
28
|
| [admin-identity-users.md](admin-identity-users.md) | Global Users table and 14-section detail, system gates, action contracts, and one-time credential invariants |
|
|
29
|
+
| [admin-signin.md](admin-signin.md) | System sign-in page look & feel plus Google/Apple/GitHub provider credentials — one endpoint, changed-paths saves, write-only secrets |
|
|
28
30
|
| [admin-devices-geoip.md](admin-devices-geoip.md) | Fleet device triage, the login-location world map with country drill-down, and the GeoIP dossier — expiry-aware enforcement, the never-cached raw provider record, and the backend traps the port encodes |
|
|
29
31
|
| [admin-jobs.md](admin-jobs.md) | Jobs engine — runner fleet, per-channel queue depth, the segmented job table with armed cancel and republishing retry, the dry-run-first control plane, scheduled tasks with a global-only gate, and the thirteen backend corrections the port encodes |
|
|
30
32
|
| [admin-network-security.md](admin-network-security.md) | Perimeter control — blocked IPs, the firewall log, kernel IP sets and the geofencing page (posture, rules, simulator, evidence, exemptions), with the DSL projection and every backend correction the port encodes |
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# Fleet Configuration
|
|
2
|
+
|
|
3
|
+
The Operations → Fleet Configuration page uses `/api/account/admin/fleet`.
|
|
4
|
+
Only literal superusers see the section. An empty permission any-of array uses
|
|
5
|
+
the existing superuser bypass and refuses even the global `admin` grant; the
|
|
6
|
+
page checks `is_superuser === true` before fetching. Backend authority remains
|
|
7
|
+
mandatory, including rejection of diagnostic/key-backed sessions and fresh auth.
|
|
8
|
+
|
|
9
|
+
Applications register their schema in django-mojo. The page renders the returned
|
|
10
|
+
section, label, description, default, type and restart requirement; no application
|
|
11
|
+
setting names are hardcoded. Each field defaults to Keep current value. Set value
|
|
12
|
+
and Remove published override are explicit actions. Clearing falls back to base
|
|
13
|
+
configuration/default; it does not promise erasure of a bootstrap secret.
|
|
14
|
+
|
|
15
|
+
Secret inputs start blank, never hold masked placeholders, and blank replacement
|
|
16
|
+
preserves the existing secret. Values stay in the form until an imperative POST;
|
|
17
|
+
secret-bearing writes bypass TanStack MutationCache. Secret current/default fields
|
|
18
|
+
are removed before Query cache storage. The revision-bound publish/restore/apply
|
|
19
|
+
POSTs use `withFreshAuth` and show failures without claiming success. Schema/type
|
|
20
|
+
validation is repeated by the server; browser validation is only feedback.
|
|
21
|
+
|
|
22
|
+
Publication means published to S3, never applied. Apply now starts a fixed
|
|
23
|
+
asynchronous sync operation and polls its ID until terminal operation status. The
|
|
24
|
+
short job completes after dispatching sync; completed job status does not stop
|
|
25
|
+
polling. Each operation read freshly observes activation until healthy or timed
|
|
26
|
+
out; failed, expired or canceled jobs also stop polling. The expected
|
|
27
|
+
node roster includes missing nodes. Installed, restart requested, restarted and
|
|
28
|
+
healthy remain separate evidence. Only positive installed + restarted + health
|
|
29
|
+
results from every expected node with the explicit
|
|
30
|
+
`request_service_jobs_and_dependencies` health scope justify fleet completion.
|
|
31
|
+
Missing or older health scopes remain unconfirmed, including per-node health and
|
|
32
|
+
restart confirmation, and prompt a backend upgrade. Health covers the
|
|
33
|
+
request service and dependencies where enabled, plus the job engine and scheduler
|
|
34
|
+
with their startup-loaded revision. Worker-only nodes need no request service.
|
|
35
|
+
Draining an old engine is not proof that its replacement has loaded the revision.
|
|
36
|
+
Timed-out, failed, superseded and unknown results stay visible. Refresh status observes the
|
|
37
|
+
normal timer workflow even when Apply now was not used.
|
|
38
|
+
|
|
39
|
+
History contains only version IDs and timestamps. Restore publishes the selected
|
|
40
|
+
version as a new revision using the current expected revision. Secrets are neither
|
|
41
|
+
shown nor compared. Concurrent publication rejects the stale revision; refresh
|
|
42
|
+
before retrying. Settings edits are tied to their loaded publication revision.
|
|
43
|
+
|
|
44
|
+
Verify with `npm run verify:admin-fleet`, `npm run typecheck`, `npm run build`, and
|
|
45
|
+
browser checks of both themes. The Settings showcase includes a partial-convergence
|
|
46
|
+
example. Mock Portal uses representative registered settings and an offline node.
|
|
47
|
+
|
|
48
|
+
Django receives this page only after a canonical Portal artifact is built and
|
|
49
|
+
vendored; follow `docs/admin-artifact.md` and the Django vendor tool. Ordinary
|
|
50
|
+
Vite build output is not the canonical artifact.
|
package/docs/admin-push.md
CHANGED
|
@@ -6,38 +6,117 @@ Import `PushPage`, its individual panels/pages, the canonical push models, API h
|
|
|
6
6
|
|
|
7
7
|
The page has six possible tabs: Your stats, Global metrics, Devices, Deliveries, Templates, and Config. Stats are authenticated caller data. Every other tab is independently filtered by its exact permission clause, and only the active authorized panel mounts, so hidden/inactive surfaces issue no requests.
|
|
8
8
|
|
|
9
|
-
`push_surface` is registered with `registerNonFilterParams` at module load. It survives table writes but never becomes a Django lookup or filter pill. Changing tabs
|
|
9
|
+
`push_surface` is registered with `registerNonFilterParams` at module load. It survives table writes but never becomes a Django lookup or filter pill. Changing tabs retains only `push_surface` and the showcase host's `demo` selector, clearing search, sort, paging, and filters belonging to the prior model. Showcase registers `demo` as a non-filter param at boot, so table writes keep the selected demo without sending it as a model lookup.
|
|
10
10
|
|
|
11
11
|
## Exact permissions
|
|
12
12
|
|
|
13
|
-
All UI clauses carry the `sys.` namespace
|
|
13
|
+
All UI clauses carry the `sys.` namespace. Alternatives within a clause are ANY-of:
|
|
14
14
|
|
|
15
15
|
- Devices view: `view_devices | manage_devices | comms | owner | manage_users`; save: `manage_devices | comms | owner`.
|
|
16
16
|
- Deliveries view: `view_notifications | manage_notifications | comms | owner | manage_users`; save: `manage_notifications | comms`.
|
|
17
17
|
- Templates view: `manage_notifications | manage_groups | comms | owner | manage_users`; save: `manage_notifications | manage_groups | comms`.
|
|
18
18
|
- Config view/save: `manage_push_config | manage_groups | comms`.
|
|
19
19
|
- Config connection test: `manage_push_config | comms`.
|
|
20
|
+
- Device test: `send_notifications | comms` **AND** the Devices view clause above.
|
|
20
21
|
- Global metrics: `view_metrics | metrics`, with `account="global"`.
|
|
21
22
|
|
|
22
23
|
`sys.users` is deliberately absent: it is not in `RegisteredDevice.RestMeta.VIEW_PERMS`. The Stats endpoint requires authentication only and always scopes all five keys to `request.user`.
|
|
23
24
|
|
|
25
|
+
Device inspection or config management alone never enables a test send. The backend separately enforces global `send_notifications | comms` and visibility of the selected registration; invisible and missing devices both return 404. Config checks require global `manage_push_config | comms`, independently of config editing. Group/member grants, API keys, and key-backed sessions cannot satisfy these test-action gates.
|
|
26
|
+
|
|
24
27
|
## Projection and ownership boundaries
|
|
25
28
|
|
|
26
29
|
Every model pins a `basic` or `default` graph, allowlists list params, and positively projects rows before Query or Mutation cache reconciliation. RegisteredDevice never retains `device_token`; Delivery never retains `platform_data`, and its arbitrary `data_payload` is not retained by the UI projection. Related users are reduced to id plus a short display label—never email, phone, metadata, or an expanded user graph. Template JSON objects accept only a bounded shallow scalar projection on reads.
|
|
27
30
|
|
|
31
|
+
The additional evidence retained by the UI is:
|
|
32
|
+
|
|
33
|
+
| Model / result | Retained fields and fallback |
|
|
34
|
+
|---|---|
|
|
35
|
+
| `PushDeviceRow` | `is_active` is boolean or null; an older backend with no field shows “Activity unknown”. |
|
|
36
|
+
| `PushConfigRow` | `fcm_project_id` (max 200 characters), `fcm_client_email` (max 254), and boolean/null `has_fcm_credentials`. Missing metadata is “Not reported” / “Presence not reported”; credential presence is not verification. |
|
|
37
|
+
| `PushDeliveryRow` | `push_outcome` allowlists `accepted`, `delivered`, `simulated`, `rejected`, `blocked`, `unknown`, `pending`, `sent`, and `failed`; otherwise null, with display falling back to stored `status`. |
|
|
38
|
+
| `PushTestConfig` | Positive safe-integer `id`, `name` (max 100), `group` (safe-integer id/name up to 120 characters, or explicit null for system default), `fcm_project_id` (max 200), `test_mode`, `is_active`, `has_fcm_credentials`. No service-account email or secret material. |
|
|
39
|
+
| `PushTestResult` | `success`, `outcome`, local `message`, allowlisted/null `error_code`, success-only `message_id` (max 500), positive safe-integer/null `delivery_id` and `device_id`, and projected/null `config`. |
|
|
40
|
+
|
|
41
|
+
Test results live in the open dialog, outside Query and Mutation caches. Provider messages and raw error bodies are replaced by local copy; unknown error codes are discarded. `PushReadiness` retains only `ready`, local `message`, `device_id`, and projected `config`.
|
|
42
|
+
|
|
28
43
|
`PushDeviceModel` now lives canonically in `admin/messaging/push`. The former `admin/identity/users/models.ts` definition is a compatibility re-export of the same object, so User Detail and the global Push page share one endpoint definition and Query key.
|
|
29
44
|
|
|
30
45
|
## FCM credentials and actions
|
|
31
46
|
|
|
32
47
|
FCM writes are imperative and cache-free. A service account textarea is an uncontrolled DOM ref and never prefills. Blank means untouched and is omitted. Replacement must parse to an object with `type="service_account"`, `project_id`, `client_email`, and a PEM `private_key`. Clear sends `null` only after explicit confirmation. Empty replacement is impossible, and server responses are sanitized before cache reconciliation.
|
|
33
48
|
|
|
34
|
-
|
|
49
|
+
### Saved configuration check
|
|
50
|
+
|
|
51
|
+
`testPushConfigConnection(id)` calls `POST /api/account/devices/push/config/<id>/test` with `{}`. The backend authenticates with FCM and submits a validation-only message, including when `test_mode=true` or the selected config is inactive. It sends no device notification and does not activate the config. The portal never accepts a raw device token.
|
|
52
|
+
|
|
53
|
+
The adapter accepts success only when the envelope has not failed, `success === true`, `outcome === "validated"`, `validation_only === true`, and `message_id` matches `projects/<project>/messages/<reference>`. Missing/malformed verdicts, simulated legacy responses, and provider rejections cannot display a verified connection. Backend HTTP 400 failures retain safe diagnostic data; the adapter returns a failed `PushTestResult` for the dialog to display.
|
|
54
|
+
|
|
55
|
+
“Check FCM connection” lives in the config header menu. The result and check time remain visible in Overview and Connection for that saved record/revision. Checks lock duplicate checks, editing, and active-state changes while pending. Editing or changing active state clears the result; record revision, permission, caller, or mount changes invalidate late completions. This is evidence from the current view, not a persisted credential-verification flag.
|
|
56
|
+
|
|
57
|
+
### One registered device
|
|
58
|
+
|
|
59
|
+
The device header's “Send test push” opens a focused dialog. The API helpers use the existing test endpoint:
|
|
60
|
+
|
|
61
|
+
| Helper | Request | Purpose |
|
|
62
|
+
|---|---|---|
|
|
63
|
+
| `fetchPushDeviceReadiness(42)` | `GET /api/account/devices/push/test?device_id=42` | Local readiness; no FCM traffic. |
|
|
64
|
+
| `sendPushDeviceTest(42, title, message)` | `POST /api/account/devices/push/test` with `{"device_id":42,"title":"Push test","message":"This is a test notification."}` | One real notification to the selected registration. |
|
|
65
|
+
|
|
66
|
+
`device_id` here is the numeric database `id`, not the app-provided string `device_id` used for registration. The portal never calls the caller-wide POST variant without a selector. The backend resolves the device owner's active org config, falling back to the active system default; the dialog shows the device, user, config ID/name, scope, and FCM project actually reported by readiness or the send result.
|
|
67
|
+
|
|
68
|
+
Readiness must explicitly match the requested device and include an active config with credentials present and simulation disabled. Inactive registration, push disabled, an opted-out `test` category, missing token/config/credentials, or simulation prevents sending. Readiness does not verify provider credentials or device reachability. Unknown/malformed readiness disables Send rather than guessing.
|
|
69
|
+
|
|
70
|
+
The dialog defaults to title `Push test` and message `This is a test notification.`; inputs must be nonblank and fit 200/1000 characters. A single pending guard prevents duplicate submissions and locks fields, Cancel, Escape, and backdrop dismissal during sending. Each dialog permits one completed attempt; reopen it to obtain fresh readiness before another. Permission loss hides stale results; settled requests still unlock dismissal. Every attempt invalidates device, delivery, and caller-stat queries in `finally`.
|
|
71
|
+
|
|
72
|
+
Device results require explicit `success === true`, `outcome === "accepted"`, a provider message reference, and the requested `device_id`. Handled backend failures may be HTTP 200 with `data.success=false`; inspect the projected result. A failed envelope cannot establish success. Uncertain transport/malformed responses become `unknown`, and known HTTP 400/401/403/404 refusals without structured evidence become blocked. There is no automatic resend.
|
|
73
|
+
|
|
74
|
+
The portal exposes no general `/send`, caller-wide test, delivery retry, device registration/unregistration, or delete action.
|
|
75
|
+
|
|
76
|
+
All four REST models have live `CAN_DELETE = false`; the mock returns 403 without removing or cascading anything. Templates/configs can be created and edited under their save clauses. Device record editing and delivery mutation remain absent from this page.
|
|
77
|
+
|
|
78
|
+
## Outcomes and delivery evidence
|
|
79
|
+
|
|
80
|
+
Test dialogs keep a visible result label, local explanation, check time, and delivery record ID when available:
|
|
81
|
+
|
|
82
|
+
| Outcome | Meaning |
|
|
83
|
+
|---|---|
|
|
84
|
+
| `validated` | FCM connection verified; no notification delivered. |
|
|
85
|
+
| `accepted` | FCM accepted the notification; the operator must check the selected device for receipt. |
|
|
86
|
+
| `rejected` | FCM refused the request; show the safe diagnostic. |
|
|
87
|
+
| `blocked` | Readiness, credentials, authentication, or request refusal prevented a successful test. |
|
|
88
|
+
| `unknown` | Acceptance is uncertain. Check the device and delivery history before another test; do not label it failed or retry automatically. |
|
|
35
89
|
|
|
36
|
-
|
|
90
|
+
Delivery lists/details prefer `push_outcome` over `status`. `simulated` is explicitly shown as no FCM send, even though the stored status is `sent`. `unknown` remains pending; provider acceptance alone does not mark a record delivered. Older `sent` records are described as lacking acceptance/receipt evidence. Status filtering and sorting still use the stored status field, not the derived outcome. The caller-stat tile is “Recorded sent” because its count may include simulations.
|
|
37
91
|
|
|
38
92
|
## Modal lifecycle convention
|
|
39
93
|
|
|
40
94
|
See [Admin modal policy](admin-modals.md) for header actions, content-only
|
|
41
95
|
sections, supported lifecycle fields and retained irreversible exceptions.
|
|
42
|
-
Config/template active-state controls live in the detail header.
|
|
43
|
-
|
|
96
|
+
Config/template active-state controls live in the detail header. Device details
|
|
97
|
+
use Overview/Preferences with active and push-state chips; delivery details use
|
|
98
|
+
Notification/Delivery with outcome explanations. Templates use
|
|
99
|
+
Overview/Notification/Variables; configs use Overview/Connection with scope,
|
|
100
|
+
send-mode, credential-presence, and service-account metadata.
|
|
101
|
+
|
|
102
|
+
Config/template editors keep one pending save guard and protect Escape/backdrop
|
|
103
|
+
dismissal as well as Cancel. Config saves retain write-only credential handling;
|
|
104
|
+
device test sends use their separate permission AND and dismissal lock.
|
|
105
|
+
|
|
106
|
+
## Showcase and verification
|
|
107
|
+
|
|
108
|
+
Open `#/?demo=admin-push` in Showcase. Its banner explicitly identifies
|
|
109
|
+
local fixtures: connection checks and test pushes there never contact FCM or
|
|
110
|
+
deliver notifications. Fixtures exercise system/org config selection, disabled
|
|
111
|
+
push, simulation blockers, safe metadata, and a selected-device accepted result
|
|
112
|
+
with a delivery row and no delivered timestamp. Switching Push tabs preserves
|
|
113
|
+
`demo=admin-push`; model filtering never consumes that host selector.
|
|
114
|
+
|
|
115
|
+
`npm run verify:admin-push` runs both `scripts/verify-admin-push.mjs` (mock wire,
|
|
116
|
+
permissions, projections, credentials, and target selection) and
|
|
117
|
+
`scripts/verify-push-tests.mjs` (mounted dialog/adapters). The latter covers strict
|
|
118
|
+
provider verdicts, malformed/mismatched responses, revision/permission ownership,
|
|
119
|
+
persistent results, duplicate-send prevention, pending dismissal, and cache
|
|
120
|
+
isolation. JSDOM assertions do not prove native dialog layout or stacking;
|
|
121
|
+
complete browser verification in both themes and a narrow viewport, including
|
|
122
|
+
the device test, configuration check, and nested editors.
|
package/docs/admin-settings.md
CHANGED
|
@@ -33,6 +33,16 @@ queries `/api/group`, and preserves that scope on save. New settings default to
|
|
|
33
33
|
Global. Delete is intentionally absent because live `Setting.RestMeta` does not
|
|
34
34
|
declare deletion.
|
|
35
35
|
|
|
36
|
+
## Viewing values
|
|
37
|
+
|
|
38
|
+
JSON objects and arrays show a compact type/count summary in the list. Long or
|
|
39
|
+
multiline text shows a single-line preview, capped at 80 characters. Select the
|
|
40
|
+
row to open the full value in the existing detail
|
|
41
|
+
modal. JSON uses the shared `JsonBlock` viewer, expanded initially, with syntax
|
|
42
|
+
highlighting, Show/Hide, Copy, and a scrollable body. Other text preserves line
|
|
43
|
+
breaks in a scrollable area; invalid JSON remains plain text. Secret values stay
|
|
44
|
+
masked in both views.
|
|
45
|
+
|
|
36
46
|
## Atomic write contract
|
|
37
47
|
|
|
38
48
|
Use `saveSettingAtomic()` / `buildSettingPayload()` rather than a generic
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# Sign-in admin (#5547)
|
|
2
|
+
|
|
3
|
+
Import `SigninPage`, `SIGNIN_ADMIN_SECTION`, the permission clause and the API
|
|
4
|
+
helpers from `portal-mojo/admin` (or `portal-mojo/admin/identity`). The section
|
|
5
|
+
registers one global route, `/system/sign-in`, under **Identity & Access**,
|
|
6
|
+
gated by `SIGNIN_ADMIN_PERMISSIONS` = `['sys.manage_settings', 'sys.admin']`
|
|
7
|
+
(superusers pass). Admin global scope masks any outer product group.
|
|
8
|
+
|
|
9
|
+
The page has two parts:
|
|
10
|
+
|
|
11
|
+
- **Look & feel** — the SYSTEM hosted login/registration page: branding,
|
|
12
|
+
layout/appearance/accent color, hero panel, sign-in heading/copy/methods,
|
|
13
|
+
registration, custom CSS. It edits the same keys a group admin edits in the
|
|
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
|
|
16
|
+
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
|
+
|
|
19
|
+
KISS by design: trusted admins, no step-up auth, no confirmation beyond a
|
|
20
|
+
browser `confirm()` before clearing a stored value.
|
|
21
|
+
|
|
22
|
+
## Wire contract
|
|
23
|
+
|
|
24
|
+
One endpoint, both methods, django-mojo envelope.
|
|
25
|
+
|
|
26
|
+
`GET /api/account/admin/signin` → `{schema_version, auth, editable, options, providers}`
|
|
27
|
+
|
|
28
|
+
- `auth` is `public_auth_config` of the resolved system config
|
|
29
|
+
(`{theme, login, registration}`).
|
|
30
|
+
- `editable` lists the flat dotted paths the POST accepts. The page renders
|
|
31
|
+
only fields whose path is listed, so the backend can narrow it.
|
|
32
|
+
- `options` carries the allowed tokens for methods, layouts, appearances,
|
|
33
|
+
hero image positions and passkey prompts. Legacy layouts `card` /
|
|
34
|
+
`fullscreen` display as `compact` / `branded-panel`; any other unknown select
|
|
35
|
+
value falls back to the first option with a `console.warn`.
|
|
36
|
+
- `providers[]`: `{name, label, enabled, ready, missing, callback_url,
|
|
37
|
+
console_url, help, fields[]}`; each field is `{key, label, secret,
|
|
38
|
+
multiline, configured, source, value, hint}`. `source` is `admin`,
|
|
39
|
+
`deployment` (shown as "Set in server config") or `none`. Secret fields
|
|
40
|
+
never carry `value`; `hint` (e.g. `…a1b2`) is shown when present.
|
|
41
|
+
|
|
42
|
+
`POST /api/account/admin/signin` returns the same full payload, which the page
|
|
43
|
+
writes straight into its query cache (`SIGNIN_QUERY_KEY`).
|
|
44
|
+
|
|
45
|
+
- Look & feel: `{auth: {"theme.app_title": "…", "login.methods": […]}}` —
|
|
46
|
+
only the paths whose value differs from the loaded payload, sent by the
|
|
47
|
+
explicit **Save** button. Password is locked on in the sign-in methods picker;
|
|
48
|
+
the server rejects a list without it (400).
|
|
49
|
+
- Provider: `{provider, values, enabled}`. In `values`, an omitted key or
|
|
50
|
+
`""` keeps the stored value and `null` clears it. The card's **Save** sends
|
|
51
|
+
only fields the admin typed into; **Clear…** (shown for `source: "admin"`)
|
|
52
|
+
confirms, then sends `null` at once. The **Enabled** switch saves
|
|
53
|
+
immediately with `{provider, enabled}`, which adds/removes the provider from
|
|
54
|
+
both `login.methods` and `registration.methods`.
|
|
55
|
+
- Errors are `{status: false, error}` (HTTP 400). The client rejects them; the
|
|
56
|
+
page shows the message inline and as a toast and keeps the unsaved edits.
|
|
57
|
+
|
|
58
|
+
Secrets are entered in password inputs (the Apple `.p8` key in a textarea)
|
|
59
|
+
and cleared from local state after a successful save.
|
|
60
|
+
|
|
61
|
+
## Mock
|
|
62
|
+
|
|
63
|
+
`src/client/mock.ts` implements the endpoint: global `manage_settings` /
|
|
64
|
+
`admin` gate (401 anonymous, 403 otherwise), `editable`-only writes with the
|
|
65
|
+
option and password rules, and credential storage. Seed: Apple has Team ID and
|
|
66
|
+
Services ID stored in the Admin (missing Key ID and private key), Google has
|
|
67
|
+
nothing, GitHub is configured by the deployment. Saved look & feel lands in a
|
|
68
|
+
system override that `/api/auth/config` layers between the deployment config
|
|
69
|
+
and group overrides, so the public config reflects saves. An Admin-stored
|
|
70
|
+
credential wins over the deployment value; clearing it falls back to the
|
|
71
|
+
deployment value.
|
|
72
|
+
|
|
73
|
+
Verify with `npm run verify:admin-signin`.
|
package/docs/realtime.md
CHANGED
|
@@ -19,6 +19,11 @@ disable this shared provider.
|
|
|
19
19
|
`auth_success`. An auth error closes and suspends the connection until the
|
|
20
20
|
token value changes.
|
|
21
21
|
- Heartbeat is a `ping` every 20 seconds with a 10-second `pong` deadline.
|
|
22
|
+
A server-initiated `{type:'ping'}` (django-mojo 1.32+ sends one every 20
|
|
23
|
+
seconds and culls a socket silent for 90) is answered at once with
|
|
24
|
+
`{type:'pong'}` while ready, clears the pending `pong` deadline, and is never
|
|
25
|
+
dispatched to consumers. Answering from the message handler keeps the socket
|
|
26
|
+
alive while a background tab throttles the client's own ping timer.
|
|
22
27
|
Reconnect uses capped jittered backoff. Pre-accept close code 4429 enforces at
|
|
23
28
|
least 60 seconds, and focus/visibility cannot bypass that deadline.
|
|
24
29
|
- Desired topics are refcounted and replay after authentication. Operations are
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "portal-mojo",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.5",
|
|
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",
|
|
@@ -2,6 +2,7 @@ import type { AdminSection } from '../core';
|
|
|
2
2
|
import { GLOBAL_CREDENTIAL_PERMS } from '../credentials/models';
|
|
3
3
|
import { MEMBER_READ_PERMISSIONS } from '../identity/members/models';
|
|
4
4
|
import { USER_VIEW_PERMISSIONS } from '../identity/users/models';
|
|
5
|
+
import { SIGNIN_ADMIN_PERMISSIONS } from '../signin/api';
|
|
5
6
|
|
|
6
7
|
export const USERS_ADMIN_SECTION: AdminSection = {
|
|
7
8
|
id: 'users', basePath: '', title: 'Users', icon: 'bi-people', navigationGroup: 'identity-access', permissions: USER_VIEW_PERMISSIONS,
|
|
@@ -21,4 +22,9 @@ export const CREDENTIALS_ADMIN_SECTION: AdminSection = {
|
|
|
21
22
|
],
|
|
22
23
|
};
|
|
23
24
|
|
|
24
|
-
export const
|
|
25
|
+
export const SIGNIN_ADMIN_SECTION: AdminSection = {
|
|
26
|
+
id: 'signin', basePath: 'system/sign-in', title: 'Sign-in', icon: 'bi-box-arrow-in-right', navigationGroup: 'identity-access', permissions: SIGNIN_ADMIN_PERMISSIONS,
|
|
27
|
+
routes: [{ path: '', loadComponent: () => import('../signin/SigninPage').then(({ SigninPage }) => ({ default: SigninPage })), permissions: SIGNIN_ADMIN_PERMISSIONS }],
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export const IDENTITY_ADMIN_SECTIONS = [USERS_ADMIN_SECTION, MEMBERS_ADMIN_SECTION, CREDENTIALS_ADMIN_SECTION, SIGNIN_ADMIN_SECTION] as const;
|
|
@@ -6,6 +6,13 @@ export const SETTINGS_ADMIN_SECTION: AdminSection = {
|
|
|
6
6
|
id: 'settings', title: 'Settings', icon: 'bi-gear', navigationGroup: 'operations', permissions: SETTINGS_PERMISSIONS,
|
|
7
7
|
routes: [{ path: '', loadComponent: () => import('../settings/SettingsPage').then(({ SettingsPage }) => ({ default: SettingsPage })), permissions: SETTINGS_PERMISSIONS }],
|
|
8
8
|
};
|
|
9
|
+
// Empty any-of permissions admit only the existing literal-superuser bypass.
|
|
10
|
+
// A global admin permission alone must not expose this configuration surface.
|
|
11
|
+
export const FLEET_CONFIGURATION_ADMIN_SECTION: AdminSection = {
|
|
12
|
+
id: 'fleet-configuration', title: 'Fleet Configuration', icon: 'bi-hdd-network',
|
|
13
|
+
navigationGroup: 'operations', permissions: [],
|
|
14
|
+
routes: [{ path: '', loadComponent: () => import('../fleet-configuration/FleetConfigurationPage').then(({ FleetConfigurationPage }) => ({ default: FleetConfigurationPage })) }],
|
|
15
|
+
};
|
|
9
16
|
export const JOBS_ADMIN_SECTION: AdminSection = {
|
|
10
17
|
id: 'jobs', title: 'Jobs', icon: 'bi-cpu', navigationGroup: 'operations', permissions: [...new Set([...JOBS_VIEW_PERMS, ...SCHEDULED_TASK_VIEW_PERMS])],
|
|
11
18
|
routes: [
|
|
@@ -15,4 +22,4 @@ export const JOBS_ADMIN_SECTION: AdminSection = {
|
|
|
15
22
|
{ path: 'scheduled-tasks', label: 'Scheduled Tasks', loadComponent: () => import('../jobs/ScheduledTasksPage').then(({ ScheduledTasksPage }) => ({ default: ScheduledTasksPage })), permissions: SCHEDULED_TASK_VIEW_PERMS },
|
|
16
23
|
],
|
|
17
24
|
};
|
|
18
|
-
export const OPERATIONS_ADMIN_SECTIONS = [SETTINGS_ADMIN_SECTION, JOBS_ADMIN_SECTION] as const;
|
|
25
|
+
export const OPERATIONS_ADMIN_SECTIONS = [SETTINGS_ADMIN_SECTION, FLEET_CONFIGURATION_ADMIN_SECTION, JOBS_ADMIN_SECTION] as const;
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { useRef, useState } from 'react';
|
|
2
|
+
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
|
3
|
+
import { useMe } from '../../client/runtime';
|
|
4
|
+
import { modal } from '../../ui';
|
|
5
|
+
import {
|
|
6
|
+
FLEET_ENDPOINT, FLEET_HEALTH_SCOPE, displayFleetValue, fleetChange, fleetIsHealthy, operationFinished,
|
|
7
|
+
readFleet, readFleetHistory, readFleetOperation, writeFleet,
|
|
8
|
+
type FleetChanges, type FleetEntry, type FleetOperation, type FleetPublication, type FleetReport, type FleetState,
|
|
9
|
+
} from './data';
|
|
10
|
+
|
|
11
|
+
function SettingInput({ entry, disabled }: { entry: FleetEntry; disabled: boolean }) {
|
|
12
|
+
const [mode, setMode] = useState('keep');
|
|
13
|
+
const current = entry.sensitive ? (entry.configured ? 'Configured (hidden)' : 'Not configured') : displayFleetValue(entry.current);
|
|
14
|
+
const initial = entry.sensitive ? '' : entry.value_type === 'string' ? String(entry.current ?? '')
|
|
15
|
+
: entry.value_type === 'boolean' ? String(entry.current === true)
|
|
16
|
+
: entry.current == null ? '' : JSON.stringify(entry.current);
|
|
17
|
+
const control = { name: `${entry.key}:value`, className: 'input', disabled, defaultValue: initial };
|
|
18
|
+
return <div className="panel panel-pad" style={{ minWidth: 0 }}>
|
|
19
|
+
<h3>{entry.label}</h3><p className="dim">{entry.description}</p>
|
|
20
|
+
<p style={{ overflowWrap: 'anywhere' }}><b>Current:</b> {current}</p>
|
|
21
|
+
{!entry.sensitive && <p className="dim">Default: {displayFleetValue(entry.default)}</p>}
|
|
22
|
+
<p><span className="badge">{entry.restart_required ? 'Restart required' : 'No restart required'}</span></p>
|
|
23
|
+
<label className="field"><span className="field-label">Proposed change for {entry.label}</span>
|
|
24
|
+
<select className="input" name={`${entry.key}:mode`} value={mode} disabled={disabled} onChange={(event) => setMode(event.target.value)}>
|
|
25
|
+
<option value="keep">Keep current value</option><option value="set">{entry.sensitive ? 'Replace secret' : 'Set value'}</option>
|
|
26
|
+
{entry.overridden && <option value="clear">Remove published override</option>}
|
|
27
|
+
</select>
|
|
28
|
+
</label>
|
|
29
|
+
{mode === 'set' && <label className="field"><span className="field-label">{entry.sensitive ? 'Secret replacement' : 'Proposed value'}</span>
|
|
30
|
+
{entry.value_type === 'boolean' ? <select {...control}><option value="true">True</option><option value="false">False</option></select>
|
|
31
|
+
: entry.value_type === 'list' || entry.value_type === 'object' ? <textarea {...control} rows={4} autoComplete="off" spellCheck={false} />
|
|
32
|
+
: <input {...control} type={entry.sensitive ? 'password' : entry.value_type === 'integer' ? 'number' : 'text'}
|
|
33
|
+
autoComplete="off" spellCheck={false} min={entry.min_value ?? undefined} max={entry.max_value ?? undefined} maxLength={entry.max_length} step={1} />}
|
|
34
|
+
<span className="field-help">{entry.sensitive ? 'Leave blank to preserve the existing secret. Secrets are never shown in history.'
|
|
35
|
+
: entry.value_type === 'list' || entry.value_type === 'object' ? `Enter a JSON ${entry.value_type}; at most ${entry.max_items ?? 100} entries.` : 'Validated again by the server before publication.'}</span>
|
|
36
|
+
</label>}
|
|
37
|
+
{mode === 'clear' && <p className="text-warn">Removes this published override; the node’s base configuration or application default will apply.</p>}
|
|
38
|
+
</div>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function FleetEditor({ state, busy, onPublish }: { state: FleetState; busy: boolean; onPublish: (changes: FleetChanges) => Promise<void> }) {
|
|
42
|
+
const form = useRef<HTMLFormElement>(null);
|
|
43
|
+
const [error, setError] = useState('');
|
|
44
|
+
const sections = [...new Set(state.entries.map((entry) => entry.section))];
|
|
45
|
+
const submit = async (event: React.FormEvent) => {
|
|
46
|
+
event.preventDefault(); setError('');
|
|
47
|
+
try {
|
|
48
|
+
const fields = new FormData(form.current!);
|
|
49
|
+
const changes: FleetChanges = {};
|
|
50
|
+
for (const entry of state.entries) {
|
|
51
|
+
const change = fleetChange(entry, String(fields.get(`${entry.key}:mode`) ?? 'keep'), String(fields.get(`${entry.key}:value`) ?? ''));
|
|
52
|
+
if (change) changes[entry.key] = change;
|
|
53
|
+
}
|
|
54
|
+
if (!Object.keys(changes).length) { setError('Choose at least one change before publishing.'); return; }
|
|
55
|
+
await onPublish(changes);
|
|
56
|
+
} catch (reason) { setError(reason instanceof Error ? reason.message : 'Could not publish configuration'); }
|
|
57
|
+
};
|
|
58
|
+
return <form ref={form} onSubmit={submit}>
|
|
59
|
+
<fieldset disabled={busy} style={{ border: 0, padding: 0, margin: 0, minWidth: 0 }}>
|
|
60
|
+
{sections.map((section) => <section key={section} style={{ marginBottom: 20 }}><h2>{section}</h2>
|
|
61
|
+
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(min(100%, 320px), 1fr))', gap: 12 }}>
|
|
62
|
+
{state.entries.filter((entry) => entry.section === section).map((entry) => <SettingInput key={entry.key} entry={entry} disabled={busy} />)}
|
|
63
|
+
</div></section>)}
|
|
64
|
+
{!state.entries.length && <p>No settings have been registered and delegated for this application.</p>}
|
|
65
|
+
{error && <p className="form-alert" role="alert">{error}</p>}
|
|
66
|
+
<button className="btn btn-primary" type="submit" disabled={busy || !state.publish_configured || !state.entries.length}>Publish</button>
|
|
67
|
+
</fieldset>
|
|
68
|
+
</form>;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function FleetNodeStatus({ report, revision }: { report: FleetReport; revision: string | null }) {
|
|
72
|
+
const healthy = fleetIsHealthy(report);
|
|
73
|
+
const jobsCovered = report.health_scope === FLEET_HEALTH_SCOPE;
|
|
74
|
+
return <section className="panel panel-pad" aria-label="Fleet convergence" style={{ marginTop: 20 }}>
|
|
75
|
+
<h2>Fleet convergence</h2><p role="status" className={healthy ? 'text-ok' : 'text-warn'}>
|
|
76
|
+
{healthy ? 'Applied and healthy on every expected node' : `Not confirmed healthy everywhere — ${report.status}`}</p>
|
|
77
|
+
<p className="dim" style={{ overflowWrap: 'anywhere' }}>Target revision: {revision ?? 'Not published'}{report.observed_at ? ` · Observed ${report.observed_at}` : ''}</p>
|
|
78
|
+
{report.error_code && <p className="form-alert">{report.error_code}</p>}
|
|
79
|
+
{!report.nodes.length ? <p>No node results are available. Publication alone does not establish fleet health.</p>
|
|
80
|
+
: <div className="tbl-scroll"><table className="tbl"><thead><tr><th>Node</th><th>Published</th><th>Downloaded / installed</th><th>Restart</th><th>Healthy</th><th>Result</th></tr></thead>
|
|
81
|
+
<tbody>{report.nodes.map((node) => <tr key={node.hostname}><td>{node.hostname}</td><td>{node.published ? 'Yes' : 'Unconfirmed'}</td>
|
|
82
|
+
<td>{node.installed ? 'Yes' : 'Unconfirmed'}</td><td>{jobsCovered && node.restarted ? 'Confirmed' : node.restart_requested ? 'Requested' : 'Unconfirmed'}</td>
|
|
83
|
+
<td>{jobsCovered && node.healthy && node.installed && node.restarted && !node.error_code ? 'Yes' : 'Unconfirmed'}</td><td>{node.error_code ?? (!jobsCovered && node.status === 'healthy' ? 'Scope unverified' : node.status)}</td></tr>)}</tbody></table></div>}
|
|
84
|
+
{!jobsCovered && <p className="text-warn">This report does not verify job engine and scheduler activation. Refresh after upgrading the backend to confirm fleet health.</p>}
|
|
85
|
+
<p className="dim">Health verifies the request service where enabled, plus the job engine and scheduler. Worker-only nodes do not require a request service. Offline or missing nodes remain unconfirmed.</p>
|
|
86
|
+
</section>;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function FleetContent({ userId }: { userId: number }) {
|
|
90
|
+
const queryClient = useQueryClient();
|
|
91
|
+
const [busy, setBusy] = useState(false);
|
|
92
|
+
const [error, setError] = useState('');
|
|
93
|
+
const [notice, setNotice] = useState('');
|
|
94
|
+
const [editorEpoch, setEditorEpoch] = useState(0);
|
|
95
|
+
const [operationId, setOperationId] = useState<string | null>(null);
|
|
96
|
+
const queryKey = [FLEET_ENDPOINT, userId];
|
|
97
|
+
const stateQuery = useQuery({ queryKey, queryFn: readFleet, refetchOnWindowFocus: false, retry: false });
|
|
98
|
+
const history = useQuery({ queryKey: [...queryKey, 'history'], queryFn: readFleetHistory, retry: false });
|
|
99
|
+
const operation = useQuery({ queryKey: [...queryKey, 'operation', operationId], enabled: operationId != null,
|
|
100
|
+
queryFn: () => readFleetOperation(operationId!), retry: false,
|
|
101
|
+
refetchInterval: (query) => query.state.error || (query.state.data && operationFinished(query.state.data)) ? false : 3000 });
|
|
102
|
+
const state = stateQuery.data;
|
|
103
|
+
const refresh = async () => { await Promise.all([stateQuery.refetch(), history.refetch(), ...(operationId ? [operation.refetch()] : [])]); };
|
|
104
|
+
const run = async (task: () => Promise<void>) => {
|
|
105
|
+
setBusy(true); setError(''); setNotice('');
|
|
106
|
+
try { await task(); } catch (reason) { setError(reason instanceof Error ? reason.message : 'Fleet operation failed'); }
|
|
107
|
+
finally { setBusy(false); }
|
|
108
|
+
};
|
|
109
|
+
const publish = async (changes: FleetChanges) => {
|
|
110
|
+
if (!state || !await modal.confirm({ title: 'Publish fleet configuration?',
|
|
111
|
+
message: `Publish changes to ${Object.keys(changes).join(', ')}? Config-sync timers will download the new configuration and may restart services. Publication does not confirm that nodes are healthy.`, confirmText: 'Publish' })) return;
|
|
112
|
+
await run(async () => {
|
|
113
|
+
const result = await writeFleet<FleetPublication>({ action: 'publish', expected_revision: state.revision, changes });
|
|
114
|
+
if (result.published !== true) throw new Error('Publication was not confirmed. Reload to check the current revision.');
|
|
115
|
+
setOperationId(null); setEditorEpoch((value) => value + 1); setNotice('Published to S3. Node application and health are still pending.'); await refresh();
|
|
116
|
+
});
|
|
117
|
+
};
|
|
118
|
+
const apply = async () => {
|
|
119
|
+
if (!state || !await modal.confirm({ title: 'Apply configuration now?', message: 'Ask every expected node to run its configured sync service now. Services may restart. Results will appear below.', confirmText: 'Apply now' })) return;
|
|
120
|
+
await run(async () => {
|
|
121
|
+
const result = await writeFleet<FleetOperation>({ action: 'apply', expected_revision: state.revision });
|
|
122
|
+
if (!result.operation_id) throw new Error('No apply operation was returned. Refresh node status before retrying.');
|
|
123
|
+
queryClient.setQueryData([...queryKey, 'operation', result.operation_id], result);
|
|
124
|
+
setOperationId(result.operation_id); setNotice('Apply requested. Waiting for each node to confirm its result.');
|
|
125
|
+
});
|
|
126
|
+
};
|
|
127
|
+
const restore = async (versionId: string) => {
|
|
128
|
+
if (!state || !await modal.confirm({ title: 'Restore previous version?', message: 'Publish this previous configuration as a new revision, including its stored secret values. Config-sync timers may restart services. Node health must be checked separately.', confirmText: 'Restore previous version' })) return;
|
|
129
|
+
await run(async () => {
|
|
130
|
+
const result = await writeFleet<FleetPublication>({ action: 'restore', expected_revision: state.revision, version_id: versionId });
|
|
131
|
+
if (result.published !== true) throw new Error('Restore was not confirmed. Reload to check the current revision.');
|
|
132
|
+
setOperationId(null); setEditorEpoch((value) => value + 1); setNotice('Previous version published as a new revision. Node application and health are still pending.'); await refresh();
|
|
133
|
+
});
|
|
134
|
+
};
|
|
135
|
+
if (!state) return <div className="panel panel-pad"><h1>Fleet Configuration</h1><p role="alert">{stateQuery.error?.message ?? 'Loading configuration…'}</p>
|
|
136
|
+
{stateQuery.error && <button className="btn" onClick={() => void refresh()}>Retry</button>}</div>;
|
|
137
|
+
const tracked = operationId && operation.data?.revision === state.revision ? operation.data : null;
|
|
138
|
+
const evidence = tracked && (!operationFinished(tracked) || Date.parse(tracked.observed_at ?? '') > Date.parse(state.fleet.observed_at ?? '')) ? tracked : state.fleet;
|
|
139
|
+
const report = stateQuery.error || operation.error ? { ...evidence, status: 'observation_unavailable', healthy_everywhere: false,
|
|
140
|
+
nodes: evidence.nodes.map((node) => ({ ...node, healthy: false })) } : evidence;
|
|
141
|
+
return <div className="page" style={{ minWidth: 0, overflowWrap: 'anywhere' }}><header className="page-header"><div><h1>Fleet Configuration</h1>
|
|
142
|
+
<p className="dim">Publish registered application settings and track their application across your nodes.</p></div></header>
|
|
143
|
+
<div className="panel panel-pad" style={{ marginBottom: 20 }}><p style={{ overflowWrap: 'anywhere' }}><b>Published to S3:</b> {state.published ? state.revision : 'Not yet published'}</p>
|
|
144
|
+
<p className="dim">This request’s node loaded revision: {state.loaded_revision ?? 'Not reported'}</p>
|
|
145
|
+
{!state.publish_configured && <p className="form-alert">Publishing is unavailable until the fleet configuration location, encryption and delegated settings are configured.</p>}
|
|
146
|
+
<button className="btn" disabled={busy || stateQuery.isFetching} onClick={() => void refresh()}>Refresh status</button>{' '}
|
|
147
|
+
<button className="btn" disabled={busy || !state.revision || (operation.data != null && !operationFinished(operation.data))} onClick={() => void apply()}>Apply now</button>
|
|
148
|
+
{notice && <p role="status">{notice}</p>}{(error || stateQuery.error) && <p className="form-alert" role="alert">{error || stateQuery.error?.message}</p>}
|
|
149
|
+
</div>
|
|
150
|
+
<FleetEditor key={`${state.revision ?? 'unpublished'}:${editorEpoch}`} state={state} busy={busy} onPublish={publish} />
|
|
151
|
+
{operation.error && <p className="form-alert" role="alert">Apply status could not be refreshed: {operation.error.message}. Refresh status to check nodes; prior results are not current confirmation.</p>}
|
|
152
|
+
{tracked && <p role="status">Apply operation: {tracked.status}</p>}
|
|
153
|
+
<FleetNodeStatus report={report} revision={state.revision} />
|
|
154
|
+
<section className="panel panel-pad" style={{ marginTop: 20 }}><h2>Version history</h2>
|
|
155
|
+
{history.error && <p className="form-alert">{history.error.message}</p>}
|
|
156
|
+
{history.isPending && <p>Loading versions…</p>}
|
|
157
|
+
{history.data?.versions.map((version) => <div key={version.version_id} style={{ display: 'flex', flexWrap: 'wrap', gap: 12, marginBottom: 12, alignItems: 'center' }}>
|
|
158
|
+
<span style={{ overflowWrap: 'anywhere', minWidth: 0 }}>{version.published_at ?? 'Time unavailable'} · {version.version_id}{version.current ? ' (current)' : ''}</span>
|
|
159
|
+
{!version.current && <button className="btn" disabled={busy || !state.publish_configured} onClick={() => void restore(version.version_id)}>Restore previous version</button>}
|
|
160
|
+
</div>)}
|
|
161
|
+
{history.data?.versions.length === 0 && <p>No previous versions are available.</p>}
|
|
162
|
+
{history.data?.truncated && <p className="dim">Only the most recent available versions are shown.</p>}
|
|
163
|
+
</section></div>;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function FleetConfigurationPage() {
|
|
167
|
+
const { data: me, isLoading } = useMe();
|
|
168
|
+
if (isLoading) return <p>Checking access…</p>;
|
|
169
|
+
if (me?.is_superuser !== true) return <div className="panel panel-pad">Fleet Configuration requires a superuser account.</div>;
|
|
170
|
+
return <FleetContent key={me.id} userId={me.id} />;
|
|
171
|
+
}
|