diff --git a/Makefile b/Makefile index 05603caa..22634105 100644 --- a/Makefile +++ b/Makefile @@ -2,7 +2,7 @@ IMAGE_NAME ?= openshell-dashboard TAG ?= latest PLATFORMS ?= linux/amd64,linux/arm64 -.PHONY: help setup gateway dev dev-full dev-backend dev-frontend dev-proxy dev-logs dev-down dev-reset build build-frontend build-backend test lint lint-go typecheck format format-check clean buildx +.PHONY: help setup gateway dev dev-full dev-mock dev-mock-empty dev-backend dev-frontend dev-proxy dev-logs dev-down dev-reset build build-frontend build-backend test lint lint-go typecheck format format-check clean buildx help: ## List the targets @awk -F ':.*## ' '/^[a-zA-Z0-9_-]+:.*## / {printf " %-14s %s\n", $$1, $$2}' $(MAKEFILE_LIST) @@ -24,6 +24,18 @@ dev-full: ## The same behind sign-in: starts the gateway, Keycloak and the auth scripts/dev-env.sh up --sign-in @eval "$$(scripts/dev-env.sh env --sign-in)" && $(MAKE) -j3 dev-backend dev-frontend dev-proxy +# UI work against fixtures instead of a gateway. A Vite middleware plugin +# answers /api/v1 itself, so neither the BFF nor a gateway has to be running. +# See docs/mock-api.md for what the fixtures cover. +dev-mock: ## Start the frontend only (:3000), served from mock API data + cd frontend && MOCK_API=true npm start + +# The same fixtures, emptied: the one `default` workspace a gateway starts +# with and nothing in it. This is how the Gateway page's get-started +# walkthrough, which stands in for the counts, is reached. +dev-mock-empty: ## Start the frontend only (:3000), as a gateway nobody has used yet + cd frontend && MOCK_API=true MOCK_EMPTY=true npm start + # Sign-in is off unless the caller says otherwise: AUTH_DISABLED=false make dev. # make dev-full does, and adds what the BFF needs behind the auth proxy. # diff --git a/backend/pkg/handlers/drafts_handler.go b/backend/pkg/handlers/drafts_handler.go index 3c17981d..7189cb19 100644 --- a/backend/pkg/handlers/drafts_handler.go +++ b/backend/pkg/handlers/drafts_handler.go @@ -88,12 +88,24 @@ func (h *DraftsHandler) GetWorkspaceDraftSummary(w http.ResponseWriter, r *http. } // GetDraftSummary is the summary outside a workspace route. With ?workspace= -// it is that workspace's, the same answer as GetWorkspaceDraftSummary. +// it is that workspace's, the same answer as GetWorkspaceDraftSummary. With +// ?allWorkspaces=true it is every workspace's, which is what a platform +// admin's shell (the Proposals count in the navigation), Gateway page and +// Proposals page ask for: the gateway answers the all-workspaces sandbox list +// for platform admins only and refuses everyone else, and that refusal is +// relayed as it is (ADR 0002). // -// Without one it answers an empty summary. A summary of every workspace would -// be one draft read per sandbox on the gateway, and a 403 for everyone who is -// not a platform admin. +// With neither it answers an empty summary. Frontends older than the +// workspace route poll this URL without a workspace, every few seconds and +// for every user; a summary of every workspace would be one draft read per +// sandbox on the gateway per poll, and a 403 for everyone who is not a +// platform admin. So the cross-workspace summary is asked for by name, +// never inferred. func (h *DraftsHandler) GetDraftSummary(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("allWorkspaces") == "true" { + h.writeDraftSummary(w, r, "", everyWorkspace("")) + return + } workspace := r.URL.Query().Get("workspace") if workspace == "" { apiutils.WriteJSON(w, http.StatusOK, models.DraftSummary{Sandboxes: []models.DraftSandboxSummary{}}) @@ -102,13 +114,18 @@ func (h *DraftsHandler) GetDraftSummary(w http.ResponseWriter, r *http.Request) h.writeDraftSummary(w, r, workspace) } -func (h *DraftsHandler) writeDraftSummary(w http.ResponseWriter, r *http.Request, workspace string) { +// writeDraftSummary summarizes the sandboxes the given list options select. +// Across workspaces the workspace argument is empty and the SDK ignores it; +// each sandbox names its own, which summarizeDrafts prefers. A sandbox that +// still resolves to no workspace is reported unavailable, never read with an +// empty selector (see summarizeDrafts). +func (h *DraftsHandler) writeDraftSummary(w http.ResponseWriter, r *http.Request, workspace string, opts ...openshell.ListOptions) { if h.sandboxes == nil { slog.Warn("draft summary requested, but the drafts handler was given no sandbox service") apiutils.WriteJSON(w, http.StatusOK, models.DraftSummary{Sandboxes: []models.DraftSandboxSummary{}}) return } - sandboxes, err := h.sandboxes.ListAll(r.Context(), workspace) + sandboxes, err := h.sandboxes.ListAll(r.Context(), workspace, opts...) if err != nil { apiutils.WriteSDKError(w, err) return @@ -123,13 +140,19 @@ func (h *DraftsHandler) writeDraftSummary(w http.ResponseWriter, r *http.Request // unavailable, which a client must not read as none pending, and so does the // entry of a sandbox the summary ran out of time for. A sandbox that is gone // by the time it is read is left out: there is nothing to report on. -// workspace is the fallback for a sandbox that names none itself. +// workspace is the fallback for a sandbox that names none itself. Across +// workspaces there is no fallback, and a sandbox that names no workspace is +// reported unavailable without a read: the gateway refuses a selector with +// an empty name (hard fact 12), and a client could not lead anywhere from an +// entry without one. func (h *DraftsHandler) summarizeDrafts(ctx context.Context, workspace string, sandboxes []*openshell.Sandbox) models.DraftSummary { ctx, cancel := context.WithTimeout(ctx, draftSummaryTimeout) defer cancel() entries := make([]*models.DraftSandboxSummary, len(sandboxes)) read := make([]bool, len(sandboxes)) + // Decided before any read starts, so the workers never see these. + markUnscoped(workspace, sandboxes, entries, read) indexes := make(chan int) var wg sync.WaitGroup for range min(draftSummaryConcurrency, len(sandboxes)) { @@ -147,6 +170,9 @@ func (h *DraftsHandler) summarizeDrafts(ctx context.Context, workspace string, s } // Nothing more is started once the client has gone or the time is up. for i := range sandboxes { + if read[i] { + continue + } if ctx.Err() != nil { break } @@ -177,6 +203,23 @@ func (h *DraftsHandler) summarizeDrafts(ctx context.Context, workspace string, s return summary } +// markUnscoped reports every sandbox that resolves to no workspace as +// unavailable and marks it read, so it is never read with an empty selector +// (hard fact 12). It logs them once. +func markUnscoped(workspace string, sandboxes []*openshell.Sandbox, entries []*models.DraftSandboxSummary, read []bool) { + var unscoped []string + for i, sandbox := range sandboxes { + if sandbox != nil && sandboxWorkspaceOr(sandbox, workspace) == "" { + entries[i] = &models.DraftSandboxSummary{SandboxName: sandbox.Name, Unavailable: true} + read[i] = true + unscoped = append(unscoped, sandbox.Name) + } + } + if len(unscoped) > 0 { + slog.Warn("pending draft chunks not read for sandboxes listed without a workspace", "sandboxes", unscoped) + } +} + // sandboxWorkspaceOr is the workspace a sandbox names, or the one it was // listed in when it names none. func sandboxWorkspaceOr(sandbox *openshell.Sandbox, workspace string) string { @@ -216,8 +259,17 @@ func (h *DraftsHandler) pendingDrafts(ctx context.Context, workspace, name strin if chunk.SecurityNotes != "" { entry.HasSecurityFlags = true } - if !chunk.CreatedAt.IsZero() && chunk.CreatedAt.UnixMilli() > entry.LatestDraftMs { - entry.LatestDraftMs = chunk.CreatedAt.UnixMilli() + if chunk.CreatedAt.IsZero() { + // No creation time to order by: keep the first rationale we see. + if entry.LatestRationale == "" { + entry.LatestRationale = chunk.Rationale + } + continue + } + createdMs := chunk.CreatedAt.UnixMilli() + if createdMs > entry.LatestDraftMs { + entry.LatestDraftMs = createdMs + entry.LatestRationale = chunk.Rationale } } return entry diff --git a/backend/pkg/handlers/drafts_summary_handler_test.go b/backend/pkg/handlers/drafts_summary_handler_test.go index a124b6d1..c1ffe612 100644 --- a/backend/pkg/handlers/drafts_summary_handler_test.go +++ b/backend/pkg/handlers/drafts_summary_handler_test.go @@ -107,18 +107,24 @@ func TestWorkspaceDraftSummary(t *testing.T) { sandboxes: sandboxesNamed("team-a", "busy", "quiet", "flagged"), chunks: map[string][]openshell.PolicyChunk{ "team-a/busy": { - {ID: "c1", Status: "pending", CreatedAt: newer}, - {ID: "c2", Status: "pending", CreatedAt: older}, + {ID: "c1", Status: "pending", CreatedAt: newer, Rationale: "allow pypi.org for pip"}, + {ID: "c2", Status: "pending", CreatedAt: older, Rationale: "older proposal"}, }, "team-a/flagged": { - {ID: "c3", Status: "pending", CreatedAt: older, SecurityNotes: "wildcard host"}, + {ID: "c3", Status: "pending", CreatedAt: older, SecurityNotes: "wildcard host", Rationale: "broad egress"}, }, }, want: models.DraftSummary{ TotalPending: 3, Sandboxes: []models.DraftSandboxSummary{ - {Workspace: "team-a", SandboxName: "busy", PendingCount: 2, LatestDraftMs: newer.UnixMilli()}, - {Workspace: "team-a", SandboxName: "flagged", PendingCount: 1, LatestDraftMs: older.UnixMilli(), HasSecurityFlags: true}, + { + Workspace: "team-a", SandboxName: "busy", PendingCount: 2, + LatestDraftMs: newer.UnixMilli(), LatestRationale: "allow pypi.org for pip", + }, + { + Workspace: "team-a", SandboxName: "flagged", PendingCount: 1, + LatestDraftMs: older.UnixMilli(), HasSecurityFlags: true, LatestRationale: "broad egress", + }, }, }, wantReads: []draftRead{ @@ -333,7 +339,9 @@ func TestDraftSummaryListFails(t *testing.T) { } } -// The route outside a workspace summarizes the workspace it names. +// The route outside a workspace summarizes the scope it is asked for, and +// nothing wider: a workspace by name, every workspace by name, and without +// either, nothing at all. func TestDraftSummaryScope(t *testing.T) { everywhere := []*openshell.Sandbox{ {Name: "agent", Workspace: "team-a"}, @@ -345,45 +353,69 @@ func TestDraftSummaryScope(t *testing.T) { "team-a/agent": {{ID: "c1", Status: "pending"}}, "team-b/agent": {{ID: "c2", Status: "pending"}, {ID: "c3", Status: "pending", SecurityNotes: "broad"}}, } + teamB := models.DraftSummary{ + TotalPending: 2, + Sandboxes: []models.DraftSandboxSummary{ + {Workspace: "team-b", SandboxName: "agent", PendingCount: 2, HasSecurityFlags: true}, + }, + } tests := []struct { name string path string wantWorkspace string listed []*openshell.Sandbox + wantOpts []openshell.ListOptions want models.DraftSummary + wantNoList bool }{ { name: "?workspace= is that workspace's summary", path: "/draft-summary?workspace=team-b", wantWorkspace: "team-b", listed: everywhere[1:], - want: models.DraftSummary{ - TotalPending: 2, - Sandboxes: []models.DraftSandboxSummary{ - {Workspace: "team-b", SandboxName: "agent", PendingCount: 2, HasSecurityFlags: true}, - }, - }, + want: teamB, }, { name: "the workspace route", path: "/workspaces/team-b/draft-summary", wantWorkspace: "team-b", listed: everywhere[1:], + want: teamB, + }, + { + // Each sandbox names its own workspace, which is what the entries + // report: the empty workspace argument is only a fallback. + name: "?allWorkspaces=true is every workspace's summary", + path: "/draft-summary?allWorkspaces=true", + listed: everywhere, + wantOpts: []openshell.ListOptions{{AllWorkspaces: true}}, want: models.DraftSummary{ - TotalPending: 2, + TotalPending: 3, Sandboxes: []models.DraftSandboxSummary{ + {Workspace: "team-a", SandboxName: "agent", PendingCount: 1}, {Workspace: "team-b", SandboxName: "agent", PendingCount: 2, HasSecurityFlags: true}, }, }, }, + { + // Neither is the old answer: empty, without a call to the gateway. + name: "neither names a scope, so nothing is summarized", + path: "/draft-summary", + wantNoList: true, + want: models.DraftSummary{Sandboxes: []models.DraftSandboxSummary{}}, + }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { sdk := &mockSDK{} sdk.sandboxes.listFn = func(_ context.Context, workspace string, opts ...openshell.ListOptions) ([]*openshell.Sandbox, error) { - if len(opts) != 0 || workspace != tc.wantWorkspace { - t.Errorf("sandboxes listed in %q with %+v, want workspace %q and no options", - workspace, opts, tc.wantWorkspace) + if tc.wantNoList { + t.Errorf("sandboxes listed (workspace %q, %+v) for a summary that names no scope", workspace, opts) + return nil, nil + } + if workspace != tc.wantWorkspace || !reflect.DeepEqual(opts, tc.wantOpts) { + t.Errorf("sandboxes listed in %q with %+v, want workspace %q with %+v", + workspace, opts, tc.wantWorkspace, tc.wantOpts) } return tc.listed, nil } @@ -403,6 +435,23 @@ func TestDraftSummaryScope(t *testing.T) { } } +// The gateway answers an all-workspaces list for platform admins only. Its +// refusal is the answer, relayed as it is: the BFF does not authorize, and a +// 403 is not an empty summary. +func TestDraftSummaryAllWorkspacesRefused(t *testing.T) { + sdk := &mockSDK{} + sdk.sandboxes.listFn = func(context.Context, string, ...openshell.ListOptions) ([]*openshell.Sandbox, error) { + return nil, &openshell.StatusError{Code: openshell.ErrorPermissionDenied, Message: "platform admin required"} + } + sdk.policy.getDraftFn = func(context.Context, string, string, ...openshell.GetDraftOption) (*openshell.DraftPolicy, error) { + t.Error("an inbox was read although the sandboxes could not be listed") + return nil, nil + } + wantErrorResponse(t, + serve(t, draftSummaryRoutes(sdk), http.MethodGet, "/draft-summary?allWorkspaces=true", ""), + http.StatusForbidden, apiutils.PermissionDenied) +} + // However many sandboxes a workspace has, only so many inboxes are read at // once. func TestDraftSummaryBoundsConcurrentReads(t *testing.T) { @@ -579,3 +628,73 @@ func TestDraftSummaryStopsWhenNobodyIsWaiting(t *testing.T) { t.Errorf("%d sandboxes reported as unavailable, want the %d that were never read", unavailable, want) } } + +// Across workspaces there is no workspace to fall back on. A sandbox the +// gateway lists without one is reported unavailable and never read: the +// gateway refuses a selector with an empty name (hard fact 12). Any other +// sandbox in the same list is read as usual. +func TestDraftSummaryAllWorkspacesSandboxWithoutAWorkspace(t *testing.T) { + tests := []struct { + name string + listed []*openshell.Sandbox + wantReads []draftRead + want models.DraftSummary + }{ + { + name: "the only sandbox names no workspace", + listed: []*openshell.Sandbox{{Name: "orphan"}}, + want: models.DraftSummary{ + Sandboxes: []models.DraftSandboxSummary{{SandboxName: "orphan", Unavailable: true}}, + }, + wantReads: []draftRead{}, + }, + { + name: "one of several names no workspace", + listed: []*openshell.Sandbox{ + {Name: "agent", Workspace: "team-a"}, + {Name: "orphan"}, + {Name: "quiet", Workspace: "team-b"}, + }, + want: models.DraftSummary{ + TotalPending: 1, + Sandboxes: []models.DraftSandboxSummary{ + {Workspace: "team-a", SandboxName: "agent", PendingCount: 1}, + {SandboxName: "orphan", Unavailable: true}, + }, + }, + wantReads: []draftRead{ + {workspace: "team-a", sandbox: "agent", filter: "pending"}, + {workspace: "team-b", sandbox: "quiet", filter: "pending"}, + }, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + sdk := &mockSDK{} + sdk.sandboxes.listFn = func(context.Context, string, ...openshell.ListOptions) ([]*openshell.Sandbox, error) { + return tc.listed, nil + } + inbox := &draftInbox{chunks: map[string][]openshell.PolicyChunk{ + "team-a/agent": {{ID: "c1", Status: "pending"}}, + }} + sdk.policy.getDraftFn = inbox.get + + w := serve(t, draftSummaryRoutes(sdk), http.MethodGet, "/draft-summary?allWorkspaces=true", "") + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body: %s", w.Code, w.Body.String()) + } + var got models.DraftSummary + decodeInto(t, w, &got) + if !reflect.DeepEqual(got, tc.want) { + t.Errorf("summary differs.\ngot: %+v\nwant: %+v", got, tc.want) + } + want := map[draftRead]int{} + for _, read := range tc.wantReads { + want[read]++ + } + if reads := inbox.readSet(); !reflect.DeepEqual(reads, want) { + t.Errorf("draft reads = %v, want %v", reads, want) + } + }) + } +} diff --git a/backend/pkg/models/observability.go b/backend/pkg/models/observability.go index 0d761971..6bb0f040 100644 --- a/backend/pkg/models/observability.go +++ b/backend/pkg/models/observability.go @@ -128,15 +128,18 @@ type DraftHistoryEntry struct { // DraftSandboxSummary counts the pending draft chunks of one sandbox, as read // with GetDraftPolicy and the status filter "pending". HasSecurityFlags says -// whether any of them carries security notes, and LatestDraftMs is when the -// newest was created. +// whether any of them carries security notes, LatestDraftMs is when the newest +// was created, and LatestRationale is that chunk's rationale — what the Needs +// attention row shows under the sandbox name. // // Unavailable marks a sandbox whose inbox could not be read. Its count is not // known, which is not the same as none pending, so PendingCount is 0 and // means nothing. type DraftSandboxSummary struct { - Workspace string `json:"workspace"` - SandboxName string `json:"sandboxName"` + Workspace string `json:"workspace"` + SandboxName string `json:"sandboxName"` + // The rationale of the newest pending chunk, when it has one. + LatestRationale string `json:"latestRationale,omitempty"` LatestDraftMs int64 `json:"latestDraftMs"` PendingCount int `json:"pendingCount"` HasSecurityFlags bool `json:"hasSecurityFlags"` diff --git a/docs/development.md b/docs/development.md index 4eaa959e..4f793a5a 100644 --- a/docs/development.md +++ b/docs/development.md @@ -29,6 +29,7 @@ Open `http://localhost:4180`, not the port 3000 the dev server prints. The comma - **Stop.** Ctrl-C, then `make dev-down`. `make dev-reset` also deletes the gateway's data; run it before you delete the checkout. - **Logs.** `make dev-logs` follows the gateway and Keycloak. - **Docker.** The gateway runs on the daemon `docker` talks to (`docker context ls`). Tested on colima on macOS only; there, keep the checkout under your home directory. +- **No gateway at all.** `make dev-mock` serves the UI from fixtures, with no BFF or gateway running; `make dev-mock-empty` is a gateway nobody has used yet. See [The mock API](mock-api.md). - **A gateway of your own.** For `make dev`, skip `make gateway` and export its address and certificates first ([Connecting to a gateway outside the chart](configuration.md#connecting-to-a-gateway-outside-the-chart)). ## Make targets @@ -37,6 +38,7 @@ Open `http://localhost:4180`, not the port 3000 the dev server prints. The comma |---|---| | `make setup` | `npm install` in `frontend/`, `go mod download` in `backend/` | | `make gateway`, `dev`, `dev-full`, `dev-logs`, `dev-down`, `dev-reset` | [Run it locally](#run-it-locally) | +| `make dev-mock`, `dev-mock-empty` | The UI alone, on [the mock API](mock-api.md) | | `make test` | `go test ./...` and Jest | | `make lint` | ESLint, the Prettier check and golangci-lint | | `make typecheck`, `make format` | `tsc --noEmit`; Prettier, writing the files | diff --git a/docs/mock-api.md b/docs/mock-api.md new file mode 100644 index 00000000..a8a2be67 --- /dev/null +++ b/docs/mock-api.md @@ -0,0 +1,56 @@ +# The mock API + +For UI work without a gateway. A **Vite dev-server plugin** intercepts all `/api/v1/*` requests and returns realistic mock data, allowing the full dashboard UI to run locally without a real OpenShell gateway, BFF, or Keycloak. + +## Files + +| File | Purpose | +|---|---| +| `frontend/mock/data.ts` | All mock data — workspaces, providers, sandboxes, profiles, settings, policies, drafts, logs, services, templates, members, gateway info, auth config | +| `frontend/mock/mockPlugin.ts` | Vite plugin with a compiled route matcher that intercepts GET requests with pattern-matched routes (`:param` syntax), and stubs POST/PUT/DELETE generically | +| `frontend/vite.config.ts` | Imports `mockApiPlugin` and conditionally adds it to the Vite plugin array when `MOCK_API=true` | + +## How to run + +```bash +cd frontend && MOCK_API=true npm start +``` + +Then open **http://localhost:3000** and click "Continue as developer." + +No BFF or gateway needed. The mock plugin runs as Vite middleware *before* the existing `/api` proxy, so when `MOCK_API` is unset, the proxy to the real BFF on `:8080` still works exactly as before. + +Both are also `make` targets: `make dev-mock`, and `make dev-mock-empty` for the mode below. + +### Empty-gateway mode + +```bash +cd frontend && MOCK_API=true MOCK_EMPTY=true npm start +``` + +Every list answers empty and only the `default` workspace a gateway starts with is returned. That is the one state the full fixtures cannot reach, and it is what the Gateway page's get-started walkthrough — which stands in for the count tiles — is shown for. + +## Mock data coverage + +- **Auth**: dev-mode config + platform admin user with `['admin', 'user']` roles +- **Gateway**: healthy v0.1.2, 2 compute drivers (Podman + K8s), 3 extensions (including NVIDIA NIM interceptor), compatibility status "supported" +- **Workspaces** (4): `default`, `ml-training`, `dev-sandbox`, `staging` — each with members +- **Providers** (3–4 per workspace): OpenAI, Anthropic, GitHub, NVIDIA NIM — with credential expiry and refresh status +- **Provider Profiles** (5 platform-scope): OpenAI, Anthropic, GitHub, NVIDIA NIM, Slack — with full credential schemas and network endpoints +- **Sandboxes** (10 total): varied phases — READY, PROVISIONING, STOPPED, STOPPING, ERROR, COMPLETED — with attached providers, endpoint statuses, and conditions +- **Sandbox Logs**: 10 lines per sandbox with gateway + sandbox sources, levels (INFO/DEBUG/WARN), and structured decision fields +- **Sandbox Policy**: per-sandbox policy views with loaded revisions and network policy rules +- **Effective Policy**: sandbox-scoped with OpenAI endpoint rule +- **Draft Policy**: 2 pending proposals (PyPI access + Hugging Face Hub) with rationale, confidence scores, and hit counts +- **Draft Summary**: cross-workspace notification badges (`code-assistant`: 2 pending, `training-run-42`: 1 pending with security flag) +- **Global Policy**: empty (no global override) +- **Gateway Settings**: 5 entries (default image, max sandboxes, idle timeout, driver config flag, policy validation mode) +- **Sandbox Settings**: 3 entries mixing SANDBOX and GLOBAL scopes +- **Templates**: `python-dev`, `node-dev`, `pytorch-gpu`, `jupyter-notebook` across workspaces +- **Services**: 4 exposed endpoints with generated URLs + +## Architecture notes + +- The mock plugin only activates for `GET` requests via the compiled router; `POST`/`PUT`/`DELETE` return generic success stubs so UI actions (create, delete, etc.) don't error out, but they don't mutate the in-memory data. +- Timestamps use `Date.now()` offsets so data always looks fresh. +- The plugin has zero effect when `MOCK_API` is not set to `true`. diff --git a/frontend/cypress/e2e/gateway-overview.cy.ts b/frontend/cypress/e2e/gateway-overview.cy.ts index 96e56fca..8b76387d 100644 --- a/frontend/cypress/e2e/gateway-overview.cy.ts +++ b/frontend/cypress/e2e/gateway-overview.cy.ts @@ -14,18 +14,25 @@ describe('Gateway Overview', () => { cy.contains('h1', 'Gateway').should('be.visible'); }); - it('shows gateway status as HEALTHY', () => { - cy.get('[data-testid="gateway-status-card"]').within(() => { - cy.contains('Status').should('be.visible'); - cy.contains('HEALTHY').should('be.visible'); + // The health check's answer is the masthead's, on every page. + it('shows the gateway as healthy in the masthead', () => { + cy.get('[data-testid="gateway-status-indicator"]').should( + 'contain.text', + 'Healthy', + ); + }); + + // The status the gateway reports for itself sits with its version. + it('shows the status the gateway reports', () => { + cy.get('[data-testid="gateway-facts"]').within(() => { + cy.get('[data-testid="gateway-status"]').should('contain.text', 'HEALTHY'); }); }); it('shows gateway version', () => { - cy.get('[data-testid="gateway-version-card"]').within(() => { - cy.contains('Gateway version').should('be.visible'); - cy.contains('0.0.92').should('be.visible'); - }); + cy.get('[data-testid="gateway-version"]') + .should('be.visible') + .and('contain.text', 'Gateway version: 0.0.92'); }); it('shows compute drivers table', () => { diff --git a/frontend/cypress/e2e/sandbox-lifecycle.cy.ts b/frontend/cypress/e2e/sandbox-lifecycle.cy.ts index 2c27c337..0a1660fa 100644 --- a/frontend/cypress/e2e/sandbox-lifecycle.cy.ts +++ b/frontend/cypress/e2e/sandbox-lifecycle.cy.ts @@ -31,6 +31,10 @@ describe('Sandbox Lifecycle', () => { cy.get('[data-testid="create-sandbox"]').click(); cy.get('.pf-v6-c-modal-box').should('be.visible'); + // The name is generated by default and sits behind its section. + cy.get('[data-testid="sandbox-generated-expand"]') + .contains('button', 'Name, labels and resources') + .click(); cy.get('[data-testid="sandbox-name-input"]').type('new-sandbox'); cy.get('[data-testid="sandbox-image-input"]').clear().type( 'ghcr.io/nvidia/openshell-community/sandboxes/base:latest', diff --git a/frontend/cypress/support/intercepts.ts b/frontend/cypress/support/intercepts.ts index 6e46e5aa..ec78c14e 100644 --- a/frontend/cypress/support/intercepts.ts +++ b/frontend/cypress/support/intercepts.ts @@ -14,20 +14,40 @@ export const interceptGateway = () => { }).as('gateway'); // The shell asks for the verdict on every page. This stub answers - // "unknown", which shows no notice. + // "unknown", which shows no notice, and a healthy gateway, which the + // masthead's status reads from the same answer. cy.intercept('GET', '/api/v1/gateway/compatibility', { statusCode: 200, - body: { gatewayVersion: '0.0.92', compatibility: { status: 'unknown' } }, + body: { + gatewayVersion: '0.0.92', + healthy: true, + compatibility: { status: 'unknown' }, + }, }).as('gatewayCompatibility'); }; +// The list fixtures are written as one page, { items, nextPageToken }. The +// BFF answers a list that way only when it is asked for pageSize; without it, +// the answer is the whole array. The shell's counts and the Gateway page ask +// without, the paged lists with, so a stub answers each as the BFF would. +const replyAsListOrPage = + (page: { items: unknown[] }) => + (req: { + query: Record; + reply: (body: object) => void; + }) => + req.reply({ + statusCode: 200, + body: req.query.pageSize ? page : page.items, + }); + export const interceptWorkspaces = () => { - cy.intercept( - { method: 'GET', pathname: '/api/v1/workspaces' }, - { - fixture: 'workspaces.json', - }, - ).as('listWorkspaces'); + cy.fixture('workspaces.json').then((page) => { + cy.intercept( + { method: 'GET', pathname: '/api/v1/workspaces' }, + replyAsListOrPage(page), + ).as('listWorkspaces'); + }); cy.intercept('GET', '/api/v1/workspaces/default', { statusCode: 200, @@ -204,9 +224,7 @@ export const interceptProviders = (workspace = 'default') => { cy.intercept( 'DELETE', - new RegExp( - `/api/v1/workspaces/${workspace}/provider-profiles/[^/]+$`, - ), + new RegExp(`/api/v1/workspaces/${workspace}/provider-profiles/[^/]+$`), { statusCode: 200, body: { deleted: true } }, ).as('deleteProviderProfile'); @@ -312,6 +330,23 @@ export const interceptMembers = (workspace = 'default') => { }; export const interceptMisc = () => { + // The Gateway page and the shell read these across every workspace. + cy.intercept('GET', '/api/v1/draft-summary?allWorkspaces=true', { + statusCode: 200, + body: { sandboxes: [], totalPending: 0 }, + }).as('allWorkspacesDraftSummary'); + + cy.fixture('sandboxes.json').then((page) => { + cy.intercept( + { method: 'GET', pathname: '/api/v1/sandboxes' }, + replyAsListOrPage(page), + ).as('listAllSandboxes'); + }); + + cy.intercept('GET', '/api/v1/providers', { + fixture: 'providers.json', + }).as('listAllProviders'); + cy.intercept('GET', '/api/v1/draft-summary', { statusCode: 200, body: { sandboxes: [], totalPending: 0 }, diff --git a/frontend/mock/data.ts b/frontend/mock/data.ts new file mode 100644 index 00000000..e240b332 --- /dev/null +++ b/frontend/mock/data.ts @@ -0,0 +1,1052 @@ +// Mock data for every API surface the dashboard reads. +// Timestamps are relative to "now" so the UI always looks fresh. + +// Typed against the dashboard's own types, so a fixture that drifts from +// what the BFF answers fails `npm run typecheck`. +import type { + AuthConfig, + CredentialRefreshStatus, + CurrentUser, + DraftPolicy, + DraftSummary, + EffectivePolicy, + GatewayCompatibilityInfo, + GatewayInfo, + GatewaySettings, + LogLine, + Provider, + ProviderProfile, + Sandbox, + SandboxLogs, + SandboxPhase, + SandboxPolicyView, + SandboxTemplate, + ServiceEndpoint, + Workspace, + WorkspaceMember, +} from '../src/types'; + +const now = Date.now(); +const minutes = (n: number) => n * 60_000; +const hours = (n: number) => n * 60 * 60_000; +const days = (n: number) => n * 24 * 60 * 60_000; + +// --------------------------------------------------------------------------- +// Auth +// --------------------------------------------------------------------------- + +export const authConfig: AuthConfig = { + authDisabled: true, + adminRole: 'admin', + features: { + terminal: true, + fileTransfer: true, + settings: true, + globalPolicy: true, + credentialRefresh: true, + services: true, + draftPolicy: true, + }, +}; + +export const currentUser: CurrentUser = { + subject: 'dev-user', + displayName: 'Platform Admin', + roles: ['admin', 'user'], + scopes: ['openid', 'profile', 'email'], +}; + +// --------------------------------------------------------------------------- +// Gateway +// --------------------------------------------------------------------------- + +export const gatewayInfo: GatewayInfo = { + status: 'HEALTHY', + gatewayVersion: '0.1.2', + computeDrivers: [ + { name: 'podman', driverName: 'podman', driverVersion: '5.4.0' }, + { name: 'kubernetes', driverName: 'kubernetes', driverVersion: '1.31.2' }, + ], + extensions: [ + { + kind: 'COMPUTE_DRIVER', + configuredName: 'podman', + implementationName: 'podman-driver', + implementationVersion: '0.1.2', + protocolMajor: 1, + protocolMinor: 0, + }, + { + kind: 'COMPUTE_DRIVER', + configuredName: 'kubernetes', + implementationName: 'kubernetes-driver', + implementationVersion: '0.1.2', + protocolMajor: 1, + protocolMinor: 0, + }, + { + kind: 'GATEWAY_INTERCEPTOR', + configuredName: 'nvidia-nim', + implementationName: 'nvidia-nim-interceptor', + implementationVersion: '0.1.0', + protocolMajor: 1, + protocolMinor: 0, + supportedCapabilities: ['credential_injection', 'profile_vending'], + }, + ], + compatibility: { + status: 'supported', + supportedLine: '0.1', + }, +}; + +export const gatewayCompatibility: GatewayCompatibilityInfo = { + gatewayVersion: '0.1.2', + healthy: true, + compatibility: { + status: 'supported', + supportedLine: '0.1', + }, +}; + +// --------------------------------------------------------------------------- +// Workspaces +// --------------------------------------------------------------------------- + +const mkMeta = ( + name: string, + workspace?: string, + opts: { + id?: string; + labels?: Record; + createdAgo?: number; + resourceVersion?: number; + } = {}, +) => ({ + id: opts.id ?? `${workspace ? workspace + '/' : ''}${name}`, + name, + ...(workspace ? { workspace } : {}), + labels: opts.labels ?? {}, + annotations: {}, + createdAtMs: now - (opts.createdAgo ?? days(7)), + resourceVersion: opts.resourceVersion ?? 1, +}); + +export const workspaces: Workspace[] = [ + { + metadata: mkMeta('default', undefined, { createdAgo: days(30) }), + phase: 'ACTIVE', + }, + { + metadata: mkMeta('ml-training', undefined, { + createdAgo: days(14), + labels: { team: 'ml-platform' }, + }), + phase: 'ACTIVE', + }, + { + metadata: mkMeta('dev-sandbox', undefined, { + createdAgo: days(5), + labels: { team: 'devtools' }, + }), + phase: 'ACTIVE', + }, + { + metadata: mkMeta('staging', undefined, { + createdAgo: days(2), + labels: { env: 'staging' }, + }), + phase: 'ACTIVE', + }, +]; + +export const workspaceDetail: Record = {}; +workspaces.forEach((ws) => { + workspaceDetail[ws.metadata.name] = ws; +}); + +// --------------------------------------------------------------------------- +// Members (per workspace) +// --------------------------------------------------------------------------- + +export const members: Record = { + default: [ + { + metadata: mkMeta('admin@test', 'default', { createdAgo: days(30) }), + principalSubject: 'admin@test', + role: 'ADMIN', + }, + { + metadata: mkMeta('alice@dev', 'default', { createdAgo: days(20) }), + principalSubject: 'alice@dev', + role: 'USER', + }, + { + metadata: mkMeta('bob@dev', 'default', { createdAgo: days(15) }), + principalSubject: 'bob@dev', + role: 'USER', + }, + ], + 'ml-training': [ + { + metadata: mkMeta('admin@test', 'ml-training', { createdAgo: days(14) }), + principalSubject: 'admin@test', + role: 'ADMIN', + }, + { + metadata: mkMeta('carol@ml', 'ml-training', { createdAgo: days(10) }), + principalSubject: 'carol@ml', + role: 'ADMIN', + }, + { + metadata: mkMeta('dave@ml', 'ml-training', { createdAgo: days(8) }), + principalSubject: 'dave@ml', + role: 'USER', + }, + ], + 'dev-sandbox': [ + { + metadata: mkMeta('admin@test', 'dev-sandbox', { createdAgo: days(5) }), + principalSubject: 'admin@test', + role: 'ADMIN', + }, + { + metadata: mkMeta('eve@devtools', 'dev-sandbox', { createdAgo: days(3) }), + principalSubject: 'eve@devtools', + role: 'USER', + }, + ], + staging: [ + { + metadata: mkMeta('admin@test', 'staging', { createdAgo: days(2) }), + principalSubject: 'admin@test', + role: 'ADMIN', + }, + ], +}; + +// --------------------------------------------------------------------------- +// Providers +// --------------------------------------------------------------------------- + +const mkProviders = (workspace: string): Provider[] => [ + { + metadata: mkMeta('openai', workspace, { + createdAgo: days(25), + labels: { tier: 'production' }, + }), + type: 'openai', + config: { model: 'gpt-4o' }, + credentialNames: ['OPENAI_API_KEY'], + credentialExpiresAtMs: { OPENAI_API_KEY: now + days(60) }, + profileWorkspace: '', + }, + { + metadata: mkMeta('anthropic', workspace, { + createdAgo: days(20), + labels: { tier: 'production' }, + }), + type: 'anthropic', + config: {}, + credentialNames: ['ANTHROPIC_API_KEY'], + credentialExpiresAtMs: { ANTHROPIC_API_KEY: now + days(45) }, + profileWorkspace: '', + }, + { + metadata: mkMeta('github', workspace, { + createdAgo: days(15), + labels: { category: 'source-control' }, + }), + type: 'github', + config: {}, + credentialNames: ['GITHUB_TOKEN'], + credentialExpiresAtMs: { GITHUB_TOKEN: now + days(30) }, + profileWorkspace: '', + }, +]; + +export const providers: Record = { + default: mkProviders('default'), + 'ml-training': [ + ...mkProviders('ml-training'), + { + metadata: mkMeta('nvidia-nim', 'ml-training', { + createdAgo: days(7), + labels: { tier: 'production', category: 'inference' }, + }), + type: 'nvidia-nim', + config: { endpoint: 'https://integrate.api.nvidia.com' }, + credentialNames: ['NVIDIA_API_KEY'], + credentialExpiresAtMs: { NVIDIA_API_KEY: now + days(90) }, + profileWorkspace: '', + }, + ], + 'dev-sandbox': mkProviders('dev-sandbox').slice(0, 2), + staging: mkProviders('staging').slice(0, 1), +}; + +export const allProviders = Object.values(providers).flat(); + +// --------------------------------------------------------------------------- +// Provider profiles (platform scope) +// --------------------------------------------------------------------------- + +export const platformProfiles: ProviderProfile[] = [ + { + id: 'openai', + displayName: 'OpenAI', + description: 'OpenAI API — GPT-4o, GPT-4o-mini, o1', + category: 'INFERENCE', + credentials: [ + { + name: 'api_key', + description: 'OpenAI API key', + envVars: ['OPENAI_API_KEY'], + required: true, + authStyle: 'header', + headerName: 'Authorization', + }, + ], + endpoints: ['api.openai.com:443'], + networkEndpoints: [ + { host: 'api.openai.com', port: 443, protocol: 'HTTPS' }, + ], + inferenceCapable: true, + source: 'interceptor/openai', + scope: 'platform', + resourceVersion: 1, + }, + { + id: 'anthropic', + displayName: 'Anthropic', + description: 'Anthropic API — Claude 4 Opus, Claude 4 Sonnet', + category: 'INFERENCE', + credentials: [ + { + name: 'api_key', + description: 'Anthropic API key', + envVars: ['ANTHROPIC_API_KEY'], + required: true, + authStyle: 'header', + headerName: 'x-api-key', + }, + ], + endpoints: ['api.anthropic.com:443'], + networkEndpoints: [ + { host: 'api.anthropic.com', port: 443, protocol: 'HTTPS' }, + ], + inferenceCapable: true, + source: 'interceptor/anthropic', + scope: 'platform', + resourceVersion: 1, + }, + { + id: 'github', + displayName: 'GitHub', + description: 'GitHub API — source control and issue tracking', + category: 'SOURCE_CONTROL', + credentials: [ + { + name: 'token', + description: 'GitHub personal access token', + envVars: ['GITHUB_TOKEN'], + required: true, + authStyle: 'header', + headerName: 'Authorization', + }, + ], + endpoints: ['api.github.com:443'], + networkEndpoints: [ + { host: 'api.github.com', port: 443, protocol: 'HTTPS' }, + ], + inferenceCapable: false, + source: 'user', + scope: 'platform', + resourceVersion: 1, + }, + { + id: 'nvidia-nim', + displayName: 'NVIDIA NIM', + description: 'NVIDIA Inference Microservices — Llama, Mixtral, Nemotron', + category: 'INFERENCE', + credentials: [ + { + name: 'api_key', + description: 'NVIDIA API key', + envVars: ['NVIDIA_API_KEY'], + required: true, + authStyle: 'header', + headerName: 'Authorization', + }, + ], + endpoints: ['integrate.api.nvidia.com:443'], + networkEndpoints: [ + { host: 'integrate.api.nvidia.com', port: 443, protocol: 'HTTPS' }, + ], + inferenceCapable: true, + source: 'interceptor/nvidia-nim', + scope: 'platform', + resourceVersion: 1, + }, + { + id: 'slack', + displayName: 'Slack', + description: 'Slack API — messaging and notifications', + category: 'MESSAGING', + credentials: [ + { + name: 'bot_token', + description: 'Slack bot token', + envVars: ['SLACK_BOT_TOKEN'], + required: true, + authStyle: 'header', + headerName: 'Authorization', + }, + ], + endpoints: ['slack.com:443'], + networkEndpoints: [{ host: 'slack.com', port: 443, protocol: 'HTTPS' }], + inferenceCapable: false, + source: 'user', + scope: 'platform', + resourceVersion: 1, + }, +]; + +// --------------------------------------------------------------------------- +// Sandboxes +// --------------------------------------------------------------------------- + +const mkSandbox = ( + name: string, + workspace: string, + phase: SandboxPhase, + opts: { + image?: string; + labels?: Record; + createdAgo?: number; + providers?: string[]; + exitCode?: number; + policyVersion?: number; + } = {}, +): Sandbox => ({ + metadata: mkMeta(name, workspace, { + createdAgo: opts.createdAgo ?? days(1), + labels: opts.labels, + resourceVersion: 2, + }), + spec: { + image: + opts.image ?? + 'ghcr.io/nvidia/openshell-community/sandboxes/default:latest', + providers: opts.providers ?? ['openai'], + policy: { + version: opts.policyVersion ?? 1, + networkPolicies: { + openai: { + name: 'openai', + endpoints: [{ host: 'api.openai.com', port: 443, protocol: 'HTTPS' }], + }, + }, + }, + environment: {}, + }, + status: { + phase, + currentPolicyVersion: opts.policyVersion ?? 1, + ...(opts.exitCode !== undefined ? { exitCode: opts.exitCode } : {}), + conditions: + phase === 'ERROR' + ? [ + { + type: 'Ready', + status: 'False', + reason: 'CrashLoopBackOff', + message: 'Container exited with code 1', + }, + ] + : [], + endpointStatuses: + phase === 'READY' + ? [ + { + endpointId: 'openai-0', + host: 'api.openai.com', + ports: [443], + lastResult: 'HTTP_RESPONSE_RECEIVED', + lastReportedAt: new Date(now - minutes(2)).toISOString(), + }, + ] + : [], + }, +}); + +export const sandboxes: Record = { + default: [ + mkSandbox('code-assistant', 'default', 'READY', { + createdAgo: days(3), + providers: ['openai', 'github'], + labels: { purpose: 'coding' }, + policyVersion: 3, + }), + mkSandbox('data-analysis', 'default', 'READY', { + createdAgo: days(2), + providers: ['openai'], + labels: { purpose: 'analytics' }, + }), + mkSandbox('web-scraper', 'default', 'STOPPED', { + createdAgo: days(5), + providers: [], + labels: { purpose: 'data-collection' }, + }), + ], + 'ml-training': [ + mkSandbox('training-run-42', 'ml-training', 'READY', { + image: 'ghcr.io/nvidia/openshell-community/sandboxes/pytorch:latest', + createdAgo: hours(6), + providers: ['nvidia-nim', 'openai'], + labels: { experiment: 'v42', gpu: 'true' }, + policyVersion: 2, + }), + mkSandbox('eval-pipeline', 'ml-training', 'READY', { + createdAgo: hours(12), + providers: ['openai', 'anthropic'], + labels: { purpose: 'evaluation' }, + }), + mkSandbox('notebook-dev', 'ml-training', 'PROVISIONING', { + image: 'ghcr.io/nvidia/openshell-community/sandboxes/jupyter:latest', + createdAgo: minutes(5), + providers: ['nvidia-nim'], + labels: { purpose: 'exploration' }, + }), + mkSandbox('failed-experiment', 'ml-training', 'ERROR', { + createdAgo: days(1), + providers: ['openai'], + labels: { experiment: 'v41' }, + exitCode: 1, + }), + ], + 'dev-sandbox': [ + mkSandbox('my-sandbox', 'dev-sandbox', 'READY', { + createdAgo: hours(4), + providers: ['openai', 'anthropic'], + labels: { owner: 'eve' }, + }), + mkSandbox('test-env', 'dev-sandbox', 'STOPPING', { + createdAgo: hours(8), + providers: ['openai'], + }), + ], + staging: [ + mkSandbox('integration-test', 'staging', 'COMPLETED', { + createdAgo: hours(2), + providers: ['openai'], + exitCode: 0, + }), + ], +}; + +export const allSandboxes = Object.values(sandboxes).flat(); + +// --------------------------------------------------------------------------- +// Sandbox logs +// --------------------------------------------------------------------------- + +const mkLogLines = (sandbox: string): LogLine[] => [ + { + timestampMs: now - minutes(10), + level: 'INFO', + target: 'openshell::supervisor', + message: `Sandbox ${sandbox} started`, + source: 'gateway', + }, + { + timestampMs: now - minutes(9), + level: 'INFO', + target: 'openshell::policy', + message: 'Policy version 1 loaded successfully', + source: 'gateway', + }, + { + timestampMs: now - minutes(8), + level: 'INFO', + target: 'sandbox', + message: 'Initializing sandbox environment...', + source: 'sandbox', + }, + { + timestampMs: now - minutes(7), + level: 'INFO', + target: 'sandbox', + message: 'Installing dependencies from requirements.txt', + source: 'sandbox', + }, + { + timestampMs: now - minutes(6), + level: 'DEBUG', + target: 'openshell::network', + message: 'Endpoint api.openai.com:443 — HTTP_RESPONSE_RECEIVED', + source: 'gateway', + fields: { dst_host: 'api.openai.com', dst_port: '443', action: 'allow' }, + }, + { + timestampMs: now - minutes(5), + level: 'INFO', + target: 'sandbox', + message: 'Connected to OpenAI API successfully', + source: 'sandbox', + }, + { + timestampMs: now - minutes(4), + level: 'WARN', + target: 'openshell::network', + message: 'Rate limit approaching for api.openai.com', + source: 'gateway', + fields: { dst_host: 'api.openai.com', remaining: '12' }, + }, + { + timestampMs: now - minutes(3), + level: 'INFO', + target: 'sandbox', + message: 'Processing batch 1 of 5...', + source: 'sandbox', + }, + { + timestampMs: now - minutes(2), + level: 'INFO', + target: 'sandbox', + message: 'Batch 1 complete — 200 items processed', + source: 'sandbox', + }, + { + timestampMs: now - minutes(1), + level: 'INFO', + target: 'sandbox', + message: 'Processing batch 2 of 5...', + source: 'sandbox', + }, +]; + +export const sandboxLogs = (sandbox: string): SandboxLogs => ({ + logs: mkLogLines(sandbox), + bufferTotal: 10, +}); + +// --------------------------------------------------------------------------- +// Sandbox policy +// --------------------------------------------------------------------------- + +export const sandboxPolicy = (workspace: string): SandboxPolicyView => ({ + activeVersion: 1, + latest: { + version: 1, + policyHash: 'abc123def456', + status: 'LOADED', + createdAtMs: now - days(1), + loadedAtMs: now - days(1) + 2000, + policy: { + version: 1, + networkPolicies: { + openai: { + name: 'openai', + endpoints: [ + { + host: 'api.openai.com', + port: 443, + protocol: 'HTTPS', + enforcement: 'NETWORK_ENFORCEMENT_MODE_ENFORCE', + access: 'NETWORK_ACCESS_PRESET_FULL', + }, + ], + }, + ...(workspace === 'ml-training' + ? { + 'nvidia-nim': { + name: 'nvidia-nim', + endpoints: [ + { + host: 'integrate.api.nvidia.com', + port: 443, + protocol: 'HTTPS', + enforcement: 'NETWORK_ENFORCEMENT_MODE_ENFORCE', + access: 'NETWORK_ACCESS_PRESET_FULL', + }, + ], + }, + } + : {}), + }, + }, + }, + revisions: [ + { + version: 1, + policyHash: 'abc123def456', + status: 'LOADED', + createdAtMs: now - days(1), + loadedAtMs: now - days(1) + 2000, + }, + ], +}); + +// --------------------------------------------------------------------------- +// Effective policy +// --------------------------------------------------------------------------- + +export const effectivePolicy: EffectivePolicy = { + policy: { + version: 1, + networkPolicies: { + openai: { + name: 'openai', + endpoints: [ + { + host: 'api.openai.com', + port: 443, + protocol: 'HTTPS', + enforcement: 'NETWORK_ENFORCEMENT_MODE_ENFORCE', + access: 'NETWORK_ACCESS_PRESET_FULL', + }, + ], + }, + }, + }, + version: 1, + policyHash: 'abc123def456', + policySource: 'SANDBOX', +}; + +// --------------------------------------------------------------------------- +// Global policy +// --------------------------------------------------------------------------- + +export const globalPolicy: SandboxPolicyView = { + activeVersion: 0, + revisions: [], +}; + +// --------------------------------------------------------------------------- +// Draft policy +// --------------------------------------------------------------------------- + +export const draftPolicy: DraftPolicy = { + chunks: [ + { + id: 'draft-001', + // The gateway pins an approval to the candidate it evaluated with this + // token, and approve-all skips a chunk sent without one (hard fact 25). + reviewToken: 'mock-review-token-001', + status: 'pending', + ruleName: 'pypi-access', + proposedRule: { + name: 'pypi-access', + endpoints: [{ host: 'pypi.org', port: 443, protocol: 'HTTPS' }], + }, + rationale: + 'The sandbox attempted to install packages from PyPI. Allowing access to pypi.org enables pip install.', + securityNotes: + 'PyPI is a public package registry. Packages are user-uploaded and may contain malicious code.', + confidence: 0.85, + createdAtMs: now - minutes(30), + hitCount: 7, + validationResult: 'VALID', + firstSeenMs: now - minutes(45), + lastSeenMs: now - minutes(5), + stage: 'initial', + }, + { + id: 'draft-002', + reviewToken: 'mock-review-token-002', + status: 'pending', + ruleName: 'huggingface-hub', + proposedRule: { + name: 'huggingface-hub', + endpoints: [{ host: 'huggingface.co', port: 443, protocol: 'HTTPS' }], + }, + rationale: 'Model weights download from Hugging Face Hub.', + confidence: 0.92, + createdAtMs: now - minutes(20), + hitCount: 3, + validationResult: 'VALID', + firstSeenMs: now - minutes(25), + lastSeenMs: now - minutes(10), + stage: 'initial', + }, + ], + rollingSummary: + 'The sandbox is installing Python packages and downloading ML model weights.', + draftVersion: 2, + lastAnalyzedAtMs: now - minutes(5), +}; + +export const draftSummary: DraftSummary = { + sandboxes: [ + { + workspace: 'default', + sandboxName: 'code-assistant', + pendingCount: 2, + hasSecurityFlags: false, + latestDraftMs: now - minutes(20), + latestRationale: 'Model weights download from Hugging Face Hub.', + }, + { + workspace: 'ml-training', + sandboxName: 'training-run-42', + pendingCount: 2, + hasSecurityFlags: true, + latestDraftMs: now - minutes(10), + latestRationale: + 'The sandbox attempted to install packages from PyPI. Allowing access to pypi.org enables pip install.', + }, + ], + totalPending: 4, +}; + +export const workspaceDraftSummary: Record = { + default: { + sandboxes: [ + { + workspace: 'default', + sandboxName: 'code-assistant', + pendingCount: 2, + hasSecurityFlags: false, + latestDraftMs: now - minutes(20), + latestRationale: 'Model weights download from Hugging Face Hub.', + }, + ], + totalPending: 2, + }, + 'ml-training': { + sandboxes: [ + { + workspace: 'ml-training', + sandboxName: 'training-run-42', + pendingCount: 2, + hasSecurityFlags: true, + latestDraftMs: now - minutes(10), + latestRationale: + 'The sandbox attempted to install packages from PyPI. Allowing access to pypi.org enables pip install.', + }, + ], + totalPending: 2, + }, + 'dev-sandbox': { sandboxes: [], totalPending: 0 }, + staging: { sandboxes: [], totalPending: 0 }, +}; + +// --------------------------------------------------------------------------- +// Gateway settings +// --------------------------------------------------------------------------- + +export const globalSettings: GatewaySettings = { + settings: [ + { + key: 'default_sandbox_image', + value: 'ghcr.io/nvidia/openshell-community/sandboxes/default:latest', + }, + { key: 'max_sandboxes_per_workspace', value: 50 }, + { key: 'allow_driver_config', value: false }, + { key: 'sandbox_idle_timeout_seconds', value: 3600 }, + { key: 'policy_validation_failure_mode', value: 'fail_closed' }, + ], + settingsRevision: 3, +}; + +// --------------------------------------------------------------------------- +// Sandbox settings +// --------------------------------------------------------------------------- + +export const sandboxSettings = { + settings: [ + { key: 'proposal_approval_mode', value: 'manual', scope: 'SANDBOX' }, + { key: 'sandbox_idle_timeout_seconds', value: 3600, scope: 'GLOBAL' }, + { + key: 'policy_validation_failure_mode', + value: 'fail_closed', + scope: 'GLOBAL', + }, + ], + policySource: 'SANDBOX', + policyHash: 'abc123def456', + policyVersion: 1, + globalPolicyVersion: 0, + configRevision: '12345678901234', + providerEnvRevision: '98765432109876', +}; + +// --------------------------------------------------------------------------- +// Sandbox templates +// --------------------------------------------------------------------------- + +export const templates: Record = { + default: [ + { + metadata: mkMeta('python-dev', 'default', { + createdAgo: days(20), + labels: { language: 'python' }, + }), + spec: { + workload: { + image: 'ghcr.io/nvidia/openshell-community/sandboxes/python:latest', + environment: { PYTHON_VERSION: '3.12' }, + resources: { cpu: '2', memory: '4Gi' }, + }, + }, + }, + { + metadata: mkMeta('node-dev', 'default', { + createdAgo: days(18), + labels: { language: 'nodejs' }, + }), + spec: { + workload: { + image: 'ghcr.io/nvidia/openshell-community/sandboxes/node:latest', + environment: { NODE_VERSION: '22' }, + resources: { cpu: '2', memory: '2Gi' }, + }, + }, + }, + ], + 'ml-training': [ + { + metadata: mkMeta('pytorch-gpu', 'ml-training', { + createdAgo: days(10), + labels: { framework: 'pytorch', gpu: 'true' }, + }), + spec: { + workload: { + image: 'ghcr.io/nvidia/openshell-community/sandboxes/pytorch:latest', + resources: { cpu: '8', memory: '32Gi', gpu: { count: 1 } }, + }, + }, + }, + { + metadata: mkMeta('jupyter-notebook', 'ml-training', { + createdAgo: days(8), + labels: { purpose: 'exploration' }, + }), + spec: { + workload: { + image: 'ghcr.io/nvidia/openshell-community/sandboxes/jupyter:latest', + resources: { cpu: '4', memory: '8Gi' }, + }, + }, + }, + ], + 'dev-sandbox': [], + staging: [], +}; + +export const allTemplates = Object.values(templates).flat(); + +// --------------------------------------------------------------------------- +// Services +// --------------------------------------------------------------------------- + +export const services: Record = { + default: [ + { + id: 'svc-1', + workspace: 'default', + sandboxId: 'code-assistant', + sandboxName: 'code-assistant', + serviceName: '', + targetPort: 8080, + domain: true, + url: 'https://code-assistant.default.sandbox.openshell.dev', + }, + ], + 'ml-training': [ + { + id: 'svc-2', + workspace: 'ml-training', + sandboxId: 'training-run-42', + sandboxName: 'training-run-42', + serviceName: 'tensorboard', + targetPort: 6006, + domain: true, + url: 'https://training-run-42-tensorboard.ml-training.sandbox.openshell.dev', + }, + { + id: 'svc-3', + workspace: 'ml-training', + sandboxId: 'notebook-dev', + sandboxName: 'notebook-dev', + serviceName: 'jupyter', + targetPort: 8888, + domain: true, + url: 'https://notebook-dev-jupyter.ml-training.sandbox.openshell.dev', + }, + ], + 'dev-sandbox': [ + { + id: 'svc-4', + workspace: 'dev-sandbox', + sandboxId: 'my-sandbox', + sandboxName: 'my-sandbox', + serviceName: 'dev-server', + targetPort: 3000, + domain: true, + url: 'https://my-sandbox-dev-server.dev-sandbox.sandbox.openshell.dev', + }, + ], + staging: [], +}; + +export const allServices = Object.values(services).flat(); + +// --------------------------------------------------------------------------- +// Credential refresh status +// --------------------------------------------------------------------------- + +export const refreshStatus: Record = { + openai: [ + { + credentialKey: 'OPENAI_API_KEY', + strategy: 'static', + status: 'active', + expiresAtMs: now + days(60), + }, + ], + anthropic: [ + { + credentialKey: 'ANTHROPIC_API_KEY', + strategy: 'static', + status: 'active', + expiresAtMs: now + days(45), + }, + ], + github: [ + { + credentialKey: 'GITHUB_TOKEN', + strategy: 'static', + status: 'active', + expiresAtMs: now + days(30), + }, + ], + 'nvidia-nim': [ + { + credentialKey: 'NVIDIA_API_KEY', + strategy: 'static', + status: 'active', + expiresAtMs: now + days(90), + }, + ], +}; + +// --------------------------------------------------------------------------- +// Sandbox attached providers +// --------------------------------------------------------------------------- + +export const attachedProviders = ( + workspace: string, + sandbox: string, +): Provider[] => { + const sbx = (sandboxes[workspace] ?? []).find( + (s) => s.metadata.name === sandbox, + ); + if (!sbx) return []; + const attached = sbx.spec.providers ?? []; + return (providers[workspace] ?? []).filter((p) => + attached.includes(p.metadata.name), + ); +}; diff --git a/frontend/mock/mockPlugin.ts b/frontend/mock/mockPlugin.ts new file mode 100644 index 00000000..98b412f2 --- /dev/null +++ b/frontend/mock/mockPlugin.ts @@ -0,0 +1,326 @@ +// Vite dev-server plugin that intercepts /api calls and returns mock data. +// Activated when MOCK_API=true (set automatically by `make dev-mock`). + +import type { Plugin, ViteDevServer } from 'vite'; +import type { IncomingMessage, ServerResponse } from 'node:http'; +import * as data from './data'; + +type RouteHandler = ( + params: Record, + query: URLSearchParams, +) => unknown; + +// MOCK_EMPTY=true serves a gateway nobody has used yet: the one `default` +// workspace it starts with, and nothing in it. That is what the Gateway +// page's get-started walkthrough stands in for, and it cannot be reached +// from the ordinary fixtures, which are deliberately full. +const isEmptyGateway = process.env.MOCK_EMPTY === 'true'; + +// A list as the gateway would answer it in whichever mode is running. +const orEmpty = (items: T[]): T[] => (isEmptyGateway ? [] : items); + +const emptyDraftSummary = { sandboxes: [], totalPending: 0 }; + +const workspaceDraftSummary = (workspace: string) => + (isEmptyGateway ? undefined : data.workspaceDraftSummary[workspace]) ?? + emptyDraftSummary; + +// Route patterns: :param captures a path segment. +const routes: [string, RouteHandler][] = [ + // Auth + ['GET /api/v1/auth/config', () => data.authConfig], + ['GET /api/v1/auth/whoami', () => data.currentUser], + + // Gateway + ['GET /api/v1/gateway', () => data.gatewayInfo], + ['GET /api/v1/gateway/compatibility', () => data.gatewayCompatibility], + + // Workspaces + [ + 'GET /api/v1/workspaces', + () => (isEmptyGateway ? data.workspaces.slice(0, 1) : data.workspaces), + ], + [ + 'GET /api/v1/workspaces/:workspace', + ({ workspace }) => data.workspaceDetail[workspace] ?? null, + ], + + // Members + [ + 'GET /api/v1/workspaces/:workspace/members', + ({ workspace }) => orEmpty(data.members[workspace] ?? []), + ], + + // Providers (workspace scope) + [ + 'GET /api/v1/workspaces/:workspace/providers', + ({ workspace }) => orEmpty(data.providers[workspace] ?? []), + ], + [ + 'GET /api/v1/workspaces/:workspace/providers/:provider', + ({ workspace, provider }) => { + const list = data.providers[workspace] ?? []; + return list.find((p) => p.metadata.name === provider) ?? null; + }, + ], + [ + 'GET /api/v1/workspaces/:workspace/providers/:provider/refresh-status', + ({ provider }) => data.refreshStatus[provider] ?? [], + ], + + // Providers (all workspaces) + ['GET /api/v1/providers', () => orEmpty(data.allProviders)], + + // Provider profiles (platform) + ['GET /api/v1/provider-profiles', () => data.platformProfiles], + // Provider profiles (workspace — includes platform's) + [ + 'GET /api/v1/workspaces/:workspace/provider-profiles', + () => data.platformProfiles, + ], + + // Sandboxes (workspace scope) + [ + 'GET /api/v1/workspaces/:workspace/sandboxes', + ({ workspace }) => orEmpty(data.sandboxes[workspace] ?? []), + ], + [ + 'GET /api/v1/workspaces/:workspace/sandboxes/:sandbox', + ({ workspace, sandbox }) => { + const list = data.sandboxes[workspace] ?? []; + return list.find((s) => s.metadata.name === sandbox) ?? null; + }, + ], + + // Sandbox logs + [ + 'GET /api/v1/workspaces/:workspace/sandboxes/:sandbox/logs', + ({ sandbox }) => data.sandboxLogs(sandbox), + ], + + // Sandbox policy + [ + 'GET /api/v1/workspaces/:workspace/sandboxes/:sandbox/policy', + ({ workspace }) => data.sandboxPolicy(workspace), + ], + [ + 'GET /api/v1/workspaces/:workspace/sandboxes/:sandbox/policy/effective', + () => data.effectivePolicy, + ], + + // Sandbox settings + [ + 'GET /api/v1/workspaces/:workspace/sandboxes/:sandbox/settings', + () => data.sandboxSettings, + ], + + // Sandbox providers (attached) + [ + 'GET /api/v1/workspaces/:workspace/sandboxes/:sandbox/providers', + ({ workspace, sandbox }) => data.attachedProviders(workspace, sandbox), + ], + + // Sandbox services + [ + 'GET /api/v1/workspaces/:workspace/sandboxes/:sandbox/services', + ({ workspace, sandbox }) => + (data.services[workspace] ?? []).filter((s) => s.sandboxName === sandbox), + ], + + // Sandbox drafts, filtered by ?status= as the BFF passes it to the gateway. + [ + 'GET /api/v1/workspaces/:workspace/sandboxes/:sandbox/drafts', + (_params, query) => { + const status = query.get('status'); + return status + ? { + ...data.draftPolicy, + chunks: data.draftPolicy.chunks.filter((c) => c.status === status), + } + : data.draftPolicy; + }, + ], + [ + 'GET /api/v1/workspaces/:workspace/sandboxes/:sandbox/drafts/history', + () => [], + ], + + // Draft summaries. The scope is named, as the BFF reads it: ?workspace= is + // one workspace's, ?allWorkspaces=true is every workspace's, and neither is + // the empty answer the route has always given an unscoped poll. + [ + 'GET /api/v1/draft-summary', + (_params, query) => { + if (query.get('allWorkspaces') === 'true') { + return isEmptyGateway ? emptyDraftSummary : data.draftSummary; + } + const workspace = query.get('workspace'); + return workspace ? workspaceDraftSummary(workspace) : emptyDraftSummary; + }, + ], + [ + 'GET /api/v1/workspaces/:workspace/draft-summary', + ({ workspace }) => workspaceDraftSummary(workspace), + ], + + // Global policy + ['GET /api/v1/global-policy', () => data.globalPolicy], + + // Global settings + ['GET /api/v1/settings/global', () => data.globalSettings], + + // Templates (workspace scope) + [ + 'GET /api/v1/workspaces/:workspace/templates', + ({ workspace }) => orEmpty(data.templates[workspace] ?? []), + ], + [ + 'GET /api/v1/workspaces/:workspace/templates/:template', + ({ workspace, template }) => { + const list = data.templates[workspace] ?? []; + return list.find((t) => t.metadata.name === template) ?? null; + }, + ], + + // Templates (all workspaces) + ['GET /api/v1/templates', () => orEmpty(data.allTemplates)], + + // Services (workspace scope) + [ + 'GET /api/v1/workspaces/:workspace/services', + ({ workspace }) => orEmpty(data.services[workspace] ?? []), + ], + + // Services (all workspaces) + ['GET /api/v1/services', () => orEmpty(data.allServices)], + + // Sandboxes (all workspaces) + ['GET /api/v1/sandboxes', () => orEmpty(data.allSandboxes)], +]; + +// Build a compiled router: convert :param patterns to regex. +type CompiledRoute = { + method: string; + regex: RegExp; + paramNames: string[]; + handler: RouteHandler; +}; + +const compileRoutes = (): CompiledRoute[] => + routes.map(([pattern, handler]) => { + const [method, path] = pattern.split(' ', 2); + const paramNames: string[] = []; + const regexStr = path.replace(/:([a-zA-Z_]+)/g, (_match, name) => { + paramNames.push(name); + return '([^/]+)'; + }); + return { + method, + regex: new RegExp(`^${regexStr}$`), + paramNames, + handler, + }; + }); + +const matchRoute = ( + compiled: CompiledRoute[], + method: string, + pathname: string, +): { handler: RouteHandler; params: Record } | null => { + for (const route of compiled) { + if (route.method !== method) continue; + const match = pathname.match(route.regex); + if (!match) continue; + const params: Record = {}; + route.paramNames.forEach((name, i) => { + params[name] = decodeURIComponent(match[i + 1]); + }); + return { handler: route.handler, params }; + } + return null; +}; + +// A list asked for with pageSize is answered a page at a time, as the BFF +// answers it: { items, nextPageToken }, the token being the next offset. +// Without pageSize the whole array is the answer, as before pagination. +const pageOf = (body: unknown, query: URLSearchParams): unknown => { + const size = Number(query.get('pageSize')); + if (!Array.isArray(body) || !query.has('pageSize') || !(size > 0)) { + return body; + } + const offset = Number(query.get('pageToken') || '0'); + const next = offset + size; + return { + items: body.slice(offset, next), + nextPageToken: next < body.length ? String(next) : '', + }; +}; + +const json = (res: ServerResponse, body: unknown, status = 200) => { + const payload = JSON.stringify(body); + res.writeHead(status, { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(payload), + }); + res.end(payload); +}; + +export default function mockApiPlugin(): Plugin { + const compiled = compileRoutes(); + return { + name: 'mock-api', + configureServer(server: ViteDevServer) { + // Run before Vite's own middleware (including the proxy). + server.middlewares.use( + (req: IncomingMessage, res: ServerResponse, next: () => void) => { + const method = req.method ?? 'GET'; + const urlObj = new URL(req.url ?? '/', `http://${req.headers.host}`); + const pathname = urlObj.pathname; + + // Only intercept /api paths. + if (!pathname.startsWith('/api/')) { + next(); + return; + } + + // Handle write operations generically (POST/PUT/DELETE return a + // sensible stub so the UI does not break on actions). + if (method !== 'GET') { + if (method === 'POST' || method === 'PUT') { + json(res, { success: true, version: 1, policyHash: 'mock' }); + } else if (method === 'DELETE') { + json(res, { deleted: true, outcome: 'completed' }); + } else { + json(res, {}, 204); + } + return; + } + + const result = matchRoute(compiled, method, pathname); + if (!result) { + json( + res, + { + code: 'NOT_FOUND', + message: `Mock: no handler for ${method} ${pathname}`, + }, + 404, + ); + return; + } + + const body = result.handler(result.params, urlObj.searchParams); + if (body === null) { + json( + res, + { code: 'NOT_FOUND', message: 'Resource not found' }, + 404, + ); + return; + } + json(res, pageOf(body, urlObj.searchParams)); + }, + ); + }, + }; +} diff --git a/frontend/package.json b/frontend/package.json index ddb5902f..84ea929d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,10 +12,10 @@ "start": "vite", "build": "vite build", "preview": "vite preview", - "typecheck": "tsc --noEmit", - "lint": "eslint 'src/**/*.{ts,tsx}' vite.config.ts", - "format": "prettier --write 'src/**/*.{ts,tsx,css}' vite.config.ts", - "format:check": "prettier --check 'src/**/*.{ts,tsx,css}' vite.config.ts", + "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.mock.json", + "lint": "eslint 'src/**/*.{ts,tsx}' 'mock/**/*.ts' vite.config.ts", + "format": "prettier --write 'src/**/*.{ts,tsx,css}' 'mock/**/*.ts' vite.config.ts", + "format:check": "prettier --check 'src/**/*.{ts,tsx,css}' 'mock/**/*.ts' vite.config.ts", "test": "jest", "test:coverage": "jest --coverage", "e2e": "cypress run", diff --git a/frontend/src/api/__tests__/draft-decisions.spec.tsx b/frontend/src/api/__tests__/draft-decisions.spec.tsx new file mode 100644 index 00000000..69e2fb40 --- /dev/null +++ b/frontend/src/api/__tests__/draft-decisions.spec.tsx @@ -0,0 +1,147 @@ +import React from 'react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { act, renderHook } from '@testing-library/react'; + +import { post, put } from '../client'; +import { + useApproveAllDraftChunks, + useApproveDraftChunk, + useClearDraftChunks, + useEditDraftChunk, + useRejectDraftChunk, + useUndoDraftChunk, +} from '../policy'; +import { allWorkspacesKeys, policyKeys } from '../queryKeys'; + +// The hooks are the real ones, on a real query client; only the HTTP calls +// under them are stubbed. +jest.mock('../client', () => ({ + apiFetch: jest.fn(), + get: jest.fn(), + post: jest.fn(), + put: jest.fn(), + del: jest.fn(), +})); + +// Every query a page can be showing when a proposal in team-a/agent is +// decided: the sandbox's own inbox and policy, and each count a summary +// carries — the workspace's and the one across every workspace. +const KEYS = { + drafts: policyKeys.drafts('team-a', 'agent', 'pending'), + history: policyKeys.draftHistory('team-a', 'agent'), + policy: policyKeys.sandbox('team-a', 'agent'), + workspaceSummary: policyKeys.workspaceDraftSummary('team-a'), + otherWorkspaceSummary: policyKeys.workspaceDraftSummary('team-b'), + allWorkspacesSummary: allWorkspacesKeys.draftSummary, + otherDrafts: policyKeys.drafts('team-a', 'other', 'pending'), +}; + +const setup = () => { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + Object.values(KEYS).forEach((key) => client.setQueryData(key, 'cached')); + const wrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => ( + {children} + ); + const invalidated = (key: readonly unknown[]) => + client.getQueryState(key)?.isInvalidated; + return { wrapper, invalidated }; +}; + +beforeEach(() => { + jest.clearAllMocks(); + (post as jest.Mock).mockResolvedValue({}); + (put as jest.Mock).mockResolvedValue({ edited: true }); +}); + +type Wrapper = ReturnType['wrapper']; + +const decisions: [string, (wrapper: Wrapper) => Promise][] = [ + [ + 'approving a chunk', + async (wrapper) => { + const { result } = renderHook( + () => useApproveDraftChunk('team-a', 'agent'), + { wrapper }, + ); + await act(() => result.current.mutateAsync({ chunkId: 'c1' })); + }, + ], + [ + 'rejecting a chunk', + async (wrapper) => { + const { result } = renderHook( + () => useRejectDraftChunk('team-a', 'agent'), + { wrapper }, + ); + await act(() => result.current.mutateAsync({ chunkId: 'c1' })); + }, + ], + [ + 'approving every chunk', + async (wrapper) => { + const { result } = renderHook( + () => useApproveAllDraftChunks('team-a', 'agent'), + { wrapper }, + ); + await act(() => result.current.mutateAsync(false)); + }, + ], + [ + 'editing a chunk', + async (wrapper) => { + const { result } = renderHook( + () => useEditDraftChunk('team-a', 'agent'), + { wrapper }, + ); + await act(() => + result.current.mutateAsync({ + chunkId: 'c1', + proposedRule: { name: 'pypi', endpoints: [], binaries: [] }, + }), + ); + }, + ], + [ + 'undoing a chunk', + async (wrapper) => { + const { result } = renderHook( + () => useUndoDraftChunk('team-a', 'agent'), + { wrapper }, + ); + await act(() => result.current.mutateAsync('c1')); + }, + ], + [ + 'clearing the chunks', + async (wrapper) => { + const { result } = renderHook( + () => useClearDraftChunks('team-a', 'agent'), + { wrapper }, + ); + await act(() => result.current.mutateAsync(undefined)); + }, + ], +]; + +describe('a decision on a proposal', () => { + it.each(decisions)( + '%s re-reads the inbox, the policy and every pending count', + async (_label, run) => { + const { wrapper, invalidated } = setup(); + await run(wrapper); + + expect(invalidated(KEYS.drafts)).toBe(true); + expect(invalidated(KEYS.history)).toBe(true); + expect(invalidated(KEYS.policy)).toBe(true); + // The nav badge, the Gateway page and the Proposals page count from + // these; a decision must not leave them showing the chunk as waiting. + expect(invalidated(KEYS.workspaceSummary)).toBe(true); + expect(invalidated(KEYS.otherWorkspaceSummary)).toBe(true); + expect(invalidated(KEYS.allWorkspacesSummary)).toBe(true); + // Another sandbox's inbox is left alone. + expect(invalidated(KEYS.otherDrafts)).toBe(false); + }, + ); +}); diff --git a/frontend/src/api/__tests__/draftSummary.spec.tsx b/frontend/src/api/__tests__/draftSummary.spec.tsx index 4e61514b..0e2bb5f4 100644 --- a/frontend/src/api/__tests__/draftSummary.spec.tsx +++ b/frontend/src/api/__tests__/draftSummary.spec.tsx @@ -6,6 +6,7 @@ import { DRAFT_SUMMARY_POLL_MS } from '../../constants'; import { getWorkspaceDraftSummary, useWorkspaceDraftSummary, + useWorkspacesDraftSummaries, } from '../draftSummary'; import { policyKeys } from '../queryKeys'; import type { DraftSummary } from '../../types'; @@ -141,3 +142,100 @@ describe('workspace draft summary', () => { expect(observer?.options.refetchInterval).toBe(DRAFT_SUMMARY_POLL_MS); }); }); + +// A promise per workspace, settled when the test says so. +const deferredReads = () => { + const pending: Record< + string, + { resolve: (summary: DraftSummary) => void; reject: (e: Error) => void } + > = {}; + mockGet.mockImplementation( + (path: string) => + new Promise((resolve, reject) => { + const workspace = decodeURIComponent(path.split('/')[4]); + pending[workspace] = { resolve, reject }; + }), + ); + return pending; +}; + +const setupMany = (workspaces: string[], enabled = true) => { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + const wrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => ( + {children} + ); + return renderHook( + (props: { enabled: boolean }) => + useWorkspacesDraftSummaries(workspaces, props.enabled), + { wrapper, initialProps: { enabled } }, + ); +}; + +const teamSummary = (workspace: string, pendingCount: number) => ({ + totalPending: pendingCount, + sandboxes: [ + { + workspace, + sandboxName: 'agent', + pendingCount, + hasSecurityFlags: false, + latestDraftMs: 0, + }, + ], +}); + +describe('the summaries of several workspaces', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + // A list built from some of the workspaces would say nothing is waiting in + // the ones still being read. + it('is loading until every workspace has answered', async () => { + const reads = deferredReads(); + const { result } = setupMany(['team-a', 'team-b']); + await waitFor(() => expect(Object.keys(reads)).toHaveLength(2)); + expect(result.current.isLoading).toBe(true); + + reads['team-a'].resolve(teamSummary('team-a', 1)); + await waitFor(() => expect(result.current.items).toHaveLength(1)); + expect(result.current.isLoading).toBe(true); + + reads['team-b'].resolve(teamSummary('team-b', 2)); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.totalPending).toBe(3); + expect(result.current.items).toHaveLength(2); + }); + + it('counts a workspace that failed as answered, and names it', async () => { + const reads = deferredReads(); + const { result } = setupMany(['team-a', 'team-b']); + await waitFor(() => expect(Object.keys(reads)).toHaveLength(2)); + + reads['team-a'].resolve(teamSummary('team-a', 1)); + reads['team-b'].reject(new Error('workspace role required')); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.failedWorkspaces).toEqual(['team-b']); + expect(result.current.isError).toBe(false); + }); + + // The page renders once with the reads switched off (while the role is + // being read) and then switches them on. The render that switches them on + // must not be answered from the one before it. + it('is loading from the render that switches the reads on', async () => { + const reads = deferredReads(); + const { result, rerender } = setupMany(['team-a'], false); + expect(result.current.isLoading).toBe(false); + expect(mockGet).not.toHaveBeenCalled(); + + rerender({ enabled: true }); + expect(result.current.isLoading).toBe(true); + + await waitFor(() => expect(reads['team-a']).toBeDefined()); + reads['team-a'].resolve(teamSummary('team-a', 1)); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.totalPending).toBe(1); + }); +}); diff --git a/frontend/src/api/allWorkspaces.ts b/frontend/src/api/allWorkspaces.ts index 04c1f7e0..b63c15c1 100644 --- a/frontend/src/api/allWorkspaces.ts +++ b/frontend/src/api/allWorkspaces.ts @@ -60,19 +60,29 @@ export const listAllServices = (): Promise => // when someone changes them, in a workspace this page is not looking at, and // are read again at the slower pace of such lists. -export const useAllSandboxes = (labelSelector?: string) => +// `enabled` and `refetchInterval` are for the shell, which counts sandboxes on +// every page: it must not ask a user the gateway will refuse, and it reads at +// NAV_COUNT_POLL_MS. The Gateway page reads at GATEWAY_PAGE_POLL_MS. The +// Sandboxes page leaves both alone. +export const useAllSandboxes = ( + labelSelector?: string, + options: { enabled?: boolean; refetchInterval?: number } = {}, +) => useQuery({ queryKey: allWorkspacesKeys.sandboxes(labelSelector), queryFn: () => listAllSandboxes(labelSelector), - refetchInterval: SANDBOX_POLL_MS, + refetchInterval: options.refetchInterval ?? SANDBOX_POLL_MS, placeholderData: keepPreviousData, + enabled: options.enabled ?? true, }); -export const useAllProviders = () => +// `refetchInterval` is for the Gateway page, which only asks whether any +// provider exists yet; the Resources page leaves it alone. +export const useAllProviders = (options: { refetchInterval?: number } = {}) => useQuery({ queryKey: allWorkspacesKeys.providers, queryFn: listAllProviders, - refetchInterval: RESOURCE_POLL_MS, + refetchInterval: options.refetchInterval ?? RESOURCE_POLL_MS, }); export const useAllTemplates = (labelSelector?: string) => diff --git a/frontend/src/api/draftSummary.ts b/frontend/src/api/draftSummary.ts index f247b633..fbbc4801 100644 --- a/frontend/src/api/draftSummary.ts +++ b/frontend/src/api/draftSummary.ts @@ -1,9 +1,9 @@ import { useMemo } from 'react'; -import { useQuery } from '@tanstack/react-query'; +import { useQueries, useQuery } from '@tanstack/react-query'; import { DRAFT_SUMMARY_POLL_MS } from '../constants'; import { get } from './client'; -import { policyKeys } from './queryKeys'; +import { allWorkspacesKeys, policyKeys } from './queryKeys'; import type { DraftSandboxSummary, DraftSummary } from '../types'; // The pending draft chunks of every sandbox in a workspace, which is what the @@ -55,3 +55,96 @@ export const useWorkspaceDraftSummary = ( isError: query.isError, }; }; + +// The same summary across every workspace, in one request. The gateway +// answers an all-workspaces list for platform admins only and refuses +// everyone else, so this is for a caller that knows it is one; the Proposals +// page reads each workspace separately otherwise. The scope is named: the +// same route without it answers empty, for embedders that still poll it. +export const getAllWorkspacesDraftSummary = (): Promise => + get('/api/v1/draft-summary?allWorkspaces=true'); + +// `refetchInterval` is for the shell, which counts on every page and reads +// at NAV_COUNT_POLL_MS, and for the Gateway page (GATEWAY_PAGE_POLL_MS); the +// Proposals page leaves it alone. +export const useAllWorkspacesDraftSummary = ( + enabled = true, + options: { refetchInterval?: number } = {}, +) => { + const query = useQuery({ + queryKey: allWorkspacesKeys.draftSummary, + queryFn: getAllWorkspacesDraftSummary, + refetchInterval: options.refetchInterval ?? DRAFT_SUMMARY_POLL_MS, + enabled, + }); + + return { + items: query.data?.sandboxes ?? [], + totalPending: query.data?.totalPending ?? 0, + isLoading: query.isLoading, + isError: query.isError, + error: query.error, + refetch: query.refetch, + }; +}; + +// The summaries of the named workspaces, one request each, merged in the +// order the workspaces were given. This is how a user who is not a platform +// admin reads across the workspaces they are in: the gateway refuses them the +// all-workspaces list, but each workspace they are a member of is theirs to +// read. A workspace whose summary fails is counted as one that could not be +// read, not as one with nothing pending. +export const useWorkspacesDraftSummaries = ( + workspaces: string[], + enabled = true, +) => { + const queries = useQueries({ + queries: workspaces.map((workspace) => ({ + queryKey: policyKeys.workspaceDraftSummary(workspace), + queryFn: () => getWorkspaceDraftSummary(workspace), + refetchInterval: DRAFT_SUMMARY_POLL_MS, + enabled, + })), + }); + + // useQueries returns a new array every render, so the merge is keyed on + // what actually changed: each query's status (pending, error or success), + // when it last produced data, and when it last failed. The status is in + // the key so that the render on which the reads start, after a render + // with them disabled, is not answered from that disabled render. + const fingerprint = queries + .map( + (query) => + `${query.status}:${query.fetchStatus}:${query.dataUpdatedAt}:${query.errorUpdatedAt}`, + ) + .join(','); + + return useMemo(() => { + const items: DraftSandboxSummary[] = []; + const failed: string[] = []; + let totalPending = 0; + queries.forEach((query, i) => { + if (query.isError) { + failed.push(workspaces[i]); + return; + } + items.push(...(query.data?.sandboxes ?? [])); + totalPending += query.data?.totalPending ?? 0; + }); + return { + items, + totalPending, + // Loading until every workspace has answered, one way or the other: a + // list built from some of them would say "nothing waiting" about the + // ones still being read. A read that is switched off is not loading. + isLoading: enabled && queries.some((query) => query.isPending), + // Only a user with no readable workspace at all has no list; one + // workspace that failed is a gap in it, named by `failedWorkspaces`. + isError: queries.length > 0 && failed.length === queries.length, + error: queries.find((query) => query.isError)?.error, + failedWorkspaces: failed, + refetch: () => queries.forEach((query) => void query.refetch()), + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [fingerprint, enabled, workspaces.join(',')]); +}; diff --git a/frontend/src/api/policy.ts b/frontend/src/api/policy.ts index 2284664e..a5737014 100644 --- a/frontend/src/api/policy.ts +++ b/frontend/src/api/policy.ts @@ -14,7 +14,7 @@ import { POLICY_PENDING_POLL_MS, } from '../constants'; import { apiFetch, del, get, post, put } from './client'; -import { policyKeys, sandboxKeys } from './queryKeys'; +import { allWorkspacesKeys, policyKeys, sandboxKeys } from './queryKeys'; import type { ApproveAllResult, DraftChunkApproval, @@ -259,12 +259,14 @@ export const useMergeSandboxPolicy = (workspace: string, name: string) => { }; // Polled: another platform admin, or the CLI, may set or delete the global -// policy while the page is open. -export const useGlobalPolicy = () => +// policy while the page is open. Pass enabled=false where the global policy +// feature is switched off: nothing is requested. +export const useGlobalPolicy = (enabled = true) => useQuery({ queryKey: policyKeys.global, queryFn: getGlobalPolicy, refetchInterval: CONFIG_POLL_MS, + enabled, }); // The global policy replaces the policy of every sandbox, so setting or @@ -319,7 +321,11 @@ export const useDraftPolicy = ( }); // Draft decisions invalidate both the inbox and the policy view (approvals -// bump the policy version). +// bump the policy version), and every pending count read from a summary: each +// workspace's (the sandbox list's badges, a member's Proposals page and nav +// count) and the all-workspaces one (a platform admin's nav count, Gateway +// page and Proposals page), so none of them shows the decided chunk as still +// waiting until its next poll. const useDraftMutation = ( workspace: string, name: string, @@ -338,6 +344,10 @@ const useDraftMutation = ( queryClient.invalidateQueries({ queryKey: policyKeys.sandbox(workspace, name), }); + queryClient.invalidateQueries({ queryKey: policyKeys.draftSummary }); + queryClient.invalidateQueries({ + queryKey: allWorkspacesKeys.draftSummary, + }); }, }); }; diff --git a/frontend/src/api/queryKeys.ts b/frontend/src/api/queryKeys.ts index 91228cc1..ac71773a 100644 --- a/frontend/src/api/queryKeys.ts +++ b/frontend/src/api/queryKeys.ts @@ -96,6 +96,10 @@ export const allWorkspacesKeys = { templates: (labelSelector = '') => ['all-workspaces', 'templates', labelSelector] as const, services: ['all-workspaces', 'services'] as const, + // The pending draft chunks of every workspace's sandboxes, in one request. + // Here and not under policyKeys: there a second segment is a workspace + // name, and a workspace may be called anything. + draftSummary: ['all-workspaces', 'draft-summary'] as const, }; export const providerKeys = { diff --git a/frontend/src/api/settings.ts b/frontend/src/api/settings.ts index 9fb95552..99a3f088 100644 --- a/frontend/src/api/settings.ts +++ b/frontend/src/api/settings.ts @@ -34,12 +34,14 @@ export const deleteGlobalSetting = ( ); // Polled: another platform admin, or the CLI, may change a setting while the -// page is open. -export const useGlobalSettings = () => +// page is open. Pass enabled=false where the settings feature is switched +// off: nothing is requested. +export const useGlobalSettings = (enabled = true) => useQuery({ queryKey: settingsKeys.global, queryFn: getGlobalSettings, refetchInterval: CONFIG_POLL_MS, + enabled, }); // A global setting overrides the same key on every sandbox, so each sandbox's diff --git a/frontend/src/api/workspaces.ts b/frontend/src/api/workspaces.ts index 003e9983..2915f002 100644 --- a/frontend/src/api/workspaces.ts +++ b/frontend/src/api/workspaces.ts @@ -88,12 +88,19 @@ export const listWorkspaceServices = ( // // Polled: a workspace somebody else creates or deletes, and the phase of one // that is being deleted, show up without a reload. -export const useWorkspaces = (labelSelector?: string) => +// `enabled` and `refetchInterval` are for the shell, which counts the +// workspaces on every page: not for a user the gateway gives no role, and at +// NAV_COUNT_POLL_MS. The Workspaces page leaves both alone. +export const useWorkspaces = ( + labelSelector?: string, + options: { enabled?: boolean; refetchInterval?: number } = {}, +) => useQuery({ queryKey: workspaceKeys.list(labelSelector), queryFn: () => listWorkspaces(labelSelector), placeholderData: keepPreviousData, - refetchInterval: RESOURCE_POLL_MS, + refetchInterval: options.refetchInterval ?? RESOURCE_POLL_MS, + enabled: options.enabled ?? true, }); type UseWorkspaceServicesOptions = { diff --git a/frontend/src/app/App.tsx b/frontend/src/app/App.tsx index 186b1535..669ec5ca 100644 --- a/frontend/src/app/App.tsx +++ b/frontend/src/app/App.tsx @@ -4,6 +4,7 @@ import { BrowserRouter } from 'react-router-dom'; import { SlotProvider } from '../slots'; import { AlertProvider } from './AlertContext'; import AppRoutes from './AppRoutes'; +import { ThemeProvider } from './theme'; const queryClient = new QueryClient({ defaultOptions: { @@ -14,13 +15,15 @@ const queryClient = new QueryClient({ const App: React.FC = () => ( - - - - - + + + + + + + ); diff --git a/frontend/src/app/AppLayout.tsx b/frontend/src/app/AppLayout.tsx index 9e7358a6..66b16b05 100644 --- a/frontend/src/app/AppLayout.tsx +++ b/frontend/src/app/AppLayout.tsx @@ -1,7 +1,8 @@ -import { useState } from 'react'; -import { BUILD_COMMIT } from '../constants'; +import { Fragment, useState } from 'react'; +import { BUILD_COMMIT, NAV_COUNT_POLL_MS } from '../constants'; import { AboutModal, + Badge, Button, Content, DescriptionList, @@ -21,6 +22,7 @@ import { MenuToggle, Nav, NavItem, + NavItemSeparator, NavList, Page, PageSection, @@ -41,9 +43,12 @@ import { Link, useLocation } from 'react-router-dom'; import openshellLogo from '~/assets/openshell-logo.svg'; import openshellLogoDark from '~/assets/openshell-logo-dark.svg'; -import { useGatewayCompatibility, useGatewayInfo } from '../api/gateway'; +import { useAllSandboxes } from '../api/allWorkspaces'; +import { useGatewayCompatibility } from '../api/gateway'; import { useCurrentUser, useFeatureFlags } from '../api/auth'; +import { useAllWorkspacesDraftSummary } from '../api/draftSummary'; import { useUserRole } from '../api/rbac'; +import { useWorkspaces } from '../api/workspaces'; import CurrentUserModal from '../components/CurrentUserModal'; import GatewayCompatibilityAlert from '../components/GatewayCompatibilityAlert'; import GatewayStatusIndicator from '../components/GatewayStatusIndicator'; @@ -57,37 +62,70 @@ type AppLayoutProps = { sessionAlert?: React.ReactNode; }; +type NavGroupId = 'workspaces' | 'platform'; + type NavEntry = { path: string; labelKey: | 'nav.gateway' | 'nav.workspaces' + | 'nav.proposals' + | 'nav.sandboxes' | 'nav.allWorkspaces' | 'nav.providerProfiles' | 'nav.globalPolicy' | 'nav.settings'; + // The section of the sidebar the entry belongs to. An entry with none sits + // above the sections, as Gateway does: the dashboard's landing page is not + // one of a set. + group?: NavGroupId; adminOnly?: boolean; featureKey?: keyof import('../types').FeatureFlags; }; +const navGroups: { id: NavGroupId }[] = [ + { id: 'workspaces' }, + { id: 'platform' }, +]; + const navEntries: NavEntry[] = [ { path: '/gateway', labelKey: 'nav.gateway', adminOnly: true }, - { path: '/workspaces', labelKey: 'nav.workspaces' }, - { path: '/all-workspaces', labelKey: 'nav.allWorkspaces', adminOnly: true }, + { path: '/workspaces', labelKey: 'nav.workspaces', group: 'workspaces' }, + { + path: '/sandboxes', + labelKey: 'nav.sandboxes', + group: 'workspaces', + adminOnly: true, + }, + { + path: '/proposals', + labelKey: 'nav.proposals', + group: 'workspaces', + featureKey: 'draftPolicy', + }, + { + path: '/all-workspaces', + labelKey: 'nav.allWorkspaces', + group: 'workspaces', + adminOnly: true, + }, { path: '/provider-profiles', labelKey: 'nav.providerProfiles', + group: 'platform', adminOnly: true, }, { path: '/global-policy', labelKey: 'nav.globalPolicy', + group: 'platform', adminOnly: true, featureKey: 'globalPolicy', }, { path: '/settings', labelKey: 'nav.settings', + group: 'platform', adminOnly: true, featureKey: 'settings', }, @@ -104,13 +142,14 @@ const AppLayout: React.FC = ({ children, sessionAlert }) => { isLoading: isRoleLoading, } = useUserRole(); const features = useFeatureFlags(); - const gateway = useGatewayInfo(); // The release line this build is for comes from the BFF, which has it // compiled in, so it is known in every image whatever built it. Read from // the route every signed-in user may call; the notice and the masthead keep - // the same query fresh. - const builtForLine = - useGatewayCompatibility().data?.compatibility?.supportedLine; + // the same query fresh. Gateway info is not read here: a gateway with roles + // refuses it to anyone who is not a platform admin, and the shell is + // mounted for everyone. + const compatibility = useGatewayCompatibility().data; + const builtForLine = compatibility?.compatibility?.supportedLine; const [isAboutOpen, setAboutOpen] = useState(false); const [isHelpOpen, setHelpOpen] = useState(false); const [isUserOpen, setUserOpen] = useState(false); @@ -118,6 +157,42 @@ const AppLayout: React.FC = ({ children, sessionAlert }) => { const { theme, toggleTheme } = useTheme(); const logoSrc = theme === 'dark' ? openshellLogoDark : openshellLogo; + // How many rules are waiting for a decision, on the Proposals entry. Read + // for a platform admin only, and not because proposals are theirs: this is + // the shell, mounted on every page, and the cross-workspace summary is the + // one shape of this read that is a single request to the BFF. Reading it + // the way a standard user has to — one request per workspace they are in — + // would put that behind every page they open. The page itself does that + // read, once, where someone is looking at it. The same query the Proposals + // page and the Gateway page use, so an admin on either is not polling it + // twice. The BFF answers it with one draft read per sandbox on the gateway, + // so the shell's own pace is NAV_COUNT_POLL_MS, not the pages'. + const { totalPending: pendingProposals } = useAllWorkspacesDraftSummary( + features.draftPolicy && isPlatformAdmin, + { refetchInterval: NAV_COUNT_POLL_MS }, + ); + // How many sandboxes the gateway has, on the Sandboxes entry. The unfiltered + // all-workspaces list — the same query the Sandboxes page reads when no + // label selector is set — so an admin looking at that list is not polling + // it twice. A platform admin only: the gateway refuses the list to anyone + // else, and the shell is mounted on every page. + const { data: allSandboxes } = useAllSandboxes(undefined, { + enabled: isPlatformAdmin, + refetchInterval: NAV_COUNT_POLL_MS, + }); + const sandboxCount = allSandboxes?.length ?? 0; + // How many workspaces the signed-in user can see, on the Workspaces entry. + // The same unfiltered list the Workspaces page reads, so looking at that + // list is not a second poll. Unlike the sandboxes count this is for every + // user with a role: the gateway answers each with the workspaces they are + // in. A user with no role is shown the entitlement page and no navigation, + // so nothing is asked for them. + const { data: workspaces } = useWorkspaces(undefined, { + enabled: !isRoleLoading && !isUnentitled, + refetchInterval: NAV_COUNT_POLL_MS, + }); + const workspaceCount = workspaces?.length ?? 0; + const masthead = ( = ({ children, sessionAlert }) => { toggle={(toggleRef) => ( setUserOpen(!isUserOpen)} isExpanded={isUserOpen} data-testid="current-user" @@ -267,28 +347,68 @@ const AppLayout: React.FC = ({ children, sessionAlert }) => { ); + const visibleEntries = navEntries.filter( + (e) => + (!e.adminOnly || isPlatformAdmin) && + (!e.featureKey || features[e.featureKey]), + ); + const groupedEntries = navGroups + .map((group) => ({ + ...group, + entries: visibleEntries.filter((entry) => entry.group === group.id), + })) + .filter((group) => group.entries.length > 0); + // A rule above the only section there is would divide the sidebar from + // nothing. Rules earn their place once a user can see more than one section + // — a standard user, who sees only their workspaces, gets the flat list + // they had. + const hasSections = groupedEntries.length > 1; + + const navItem = (entry: NavEntry) => ( + } + > + {t(entry.labelKey)} + {entry.path === '/proposals' && pendingProposals > 0 && ( + + {pendingProposals} + + )} + {entry.path === '/workspaces' && workspaceCount > 0 && ( + + {workspaceCount} + + )} + {entry.path === '/sandboxes' && sandboxCount > 0 && ( + + {sandboxCount} + + )} + + ); + const sidebar = ( @@ -361,25 +481,7 @@ const AppLayout: React.FC = ({ children, sessionAlert }) => { {t('about.gatewayVersion')} - {gateway.data?.gatewayVersion || t('about.unknown')} - - - - - {t('about.gatewayStatus')} - - - {gateway.data?.status || t('about.unknown')} - - - - - {t('about.computeDriver')} - - - {gateway.data?.computeDrivers?.[0] - ? `${gateway.data.computeDrivers[0].driverName} ${gateway.data.computeDrivers[0].driverVersion}` - : t('about.unknown')} + {compatibility?.gatewayVersion || t('about.unknown')} diff --git a/frontend/src/app/AuthenticatedRoutes.tsx b/frontend/src/app/AuthenticatedRoutes.tsx index 761b613b..b490e5fc 100644 --- a/frontend/src/app/AuthenticatedRoutes.tsx +++ b/frontend/src/app/AuthenticatedRoutes.tsx @@ -12,6 +12,7 @@ import { useParams, } from 'react-router-dom'; +import AllSandboxesPage from '../pages/AllSandboxesPage'; import AllWorkspacesPage from '../pages/AllWorkspacesPage'; import EntitlementPage from '../pages/EntitlementPage'; import GatewayOverviewPage from '../pages/GatewayOverviewPage'; @@ -21,6 +22,7 @@ import SandboxDetailPage from '../pages/SandboxDetailPage'; import ProviderDetailPage from '../pages/ProviderDetailPage'; import ProviderProfilesPage from '../pages/ProviderProfilesPage'; import GlobalPolicyPage from '../pages/GlobalPolicyPage'; +import ProposalsPage from '../pages/ProposalsPage'; import SettingsPage from '../pages/SettingsPage'; import { useUserRole } from '../api/rbac'; import { useI18n } from '../i18n'; @@ -38,6 +40,9 @@ const WorkspaceCrumbs: React.FC<{ workspace: string; leaf?: string }> = ({ leaf, }) => { const { t } = useI18n('common'); + // Flat children, never a fragment: Breadcrumb puts the divider on each + // child after the first with Children.map, which does not look inside a + // fragment, so grouping the deeper two left both of them without one. return ( @@ -48,20 +53,16 @@ const WorkspaceCrumbs: React.FC<{ workspace: string; leaf?: string }> = ({ )} /> - {leaf ? ( - <> - ( - - {workspace} - - )} - /> - {leaf} - - ) : ( - {workspace} + {leaf && ( + ( + + {workspace} + + )} + /> )} + {leaf ?? workspace} ); @@ -169,6 +170,14 @@ const AuthenticatedRoutes: React.FC<{ sessionAlert?: React.ReactNode }> = ({ } /> + + + + } + /> = ({ } /> + {/* Not behind AdminRoute: proposals are a sandbox's, and a standard + user reviews the ones in the workspaces they are a member of. Only + the cross-workspace rollup needs a platform admin, and the page + falls back to a request per workspace without one. */} + } /> } /> } /> ({ isUnentitled: false, }), })); +// The shell counts the rules waiting for a decision for its Proposals badge. +jest.mock('../../api/draftSummary', () => ({ + useAllWorkspacesDraftSummary: () => ({ totalPending: 0 }), +})); +// The shell counts sandboxes and workspaces for their nav badges. None here. +jest.mock('../../api/allWorkspaces', () => ({ + useAllSandboxes: () => ({ data: [] }), +})); +jest.mock('../../api/workspaces', () => ({ + useWorkspaces: () => ({ data: [] }), +})); jest.mock('../theme', () => ({ useTheme: () => ({ theme: 'light', toggleTheme: jest.fn() }), })); @@ -90,12 +101,13 @@ describe('AppLayout gateway health', () => { answerHealth(true); renderShell(); - // The gateway-info request was made and refused... - expect(mockUseGatewayInfo).toHaveBeenCalled(); + // Gateway info is the Gateway page's, and a gateway with roles refuses + // it to this user: the shell does not ask for it at all... + expect(mockUseGatewayInfo).not.toHaveBeenCalled(); // ...and the health is there all the same. expect( masthead().getByTestId('gateway-status-indicator'), - ).toHaveTextContent('Gateway healthy'); + ).toHaveTextContent('Healthy'); // The Gateway page is still not offered to this user. expect( screen.queryByRole('link', { name: 'Gateway' }), @@ -109,7 +121,7 @@ describe('AppLayout gateway health', () => { expect( masthead().getByTestId('gateway-status-indicator'), - ).toHaveTextContent('Gateway not healthy'); + ).toHaveTextContent('Not healthy'); }); it('says the gateway is unreachable when the health request fails for that reason', () => { @@ -131,7 +143,7 @@ describe('AppLayout gateway health', () => { expect( masthead().getByTestId('gateway-status-indicator'), - ).toHaveTextContent('Gateway unreachable'); + ).toHaveTextContent('Unreachable'); }); // The shell stays mounted while the routes change under it, so the status diff --git a/frontend/src/app/__tests__/AppLayout.navigation.spec.tsx b/frontend/src/app/__tests__/AppLayout.navigation.spec.tsx index 483105aa..58fa131d 100644 --- a/frontend/src/app/__tests__/AppLayout.navigation.spec.tsx +++ b/frontend/src/app/__tests__/AppLayout.navigation.spec.tsx @@ -8,6 +8,7 @@ let mockIsPlatformAdmin = false; let mockIsUser = true; let mockIsUnentitled = false; let mockUser: CurrentUser | undefined; +let mockPendingProposals = 0; jest.mock('../../api/gateway', () => ({ useGatewayInfo: () => ({ isLoading: false, isError: false, data: undefined }), @@ -19,7 +20,11 @@ jest.mock('../../api/gateway', () => ({ })); jest.mock('../../api/auth', () => ({ useCurrentUser: () => ({ data: mockUser }), - useFeatureFlags: () => ({ globalPolicy: true, settings: true }), + useFeatureFlags: () => ({ + globalPolicy: true, + settings: true, + draftPolicy: true, + }), })); jest.mock('../../api/rbac', () => ({ useUserRole: () => ({ @@ -28,6 +33,17 @@ jest.mock('../../api/rbac', () => ({ isUnentitled: mockIsUnentitled, }), })); +jest.mock('../../api/draftSummary', () => ({ + useAllWorkspacesDraftSummary: jest.fn(() => ({ + totalPending: mockPendingProposals, + })), +})); +jest.mock('../../api/allWorkspaces', () => ({ + useAllSandboxes: jest.fn(() => ({ data: [] })), +})); +jest.mock('../../api/workspaces', () => ({ + useWorkspaces: jest.fn(() => ({ data: [] })), +})); jest.mock('../theme', () => ({ useTheme: () => ({ theme: 'light', toggleTheme: jest.fn() }), })); @@ -42,8 +58,15 @@ jest.mock('~/assets/openshell-logo-dark.svg', () => ({ default: 'openshell-logo-dark.svg', })); +import { useAllSandboxes } from '../../api/allWorkspaces'; +import { useAllWorkspacesDraftSummary } from '../../api/draftSummary'; +import { NAV_COUNT_POLL_MS } from '../../constants'; +import { useWorkspaces } from '../../api/workspaces'; import AppLayout from '../AppLayout'; +const mockUseAllSandboxes = useAllSandboxes as jest.Mock; +const mockUseWorkspaces = useWorkspaces as jest.Mock; + const renderShell = (route: string) => render( { mockIsUser = true; mockIsUnentitled = false; mockUser = { subject: 'user-1', roles: [] }; + mockPendingProposals = 0; + mockUseAllSandboxes.mockReset(); + mockUseAllSandboxes.mockReturnValue({ data: [] }); + mockUseWorkspaces.mockReset(); + mockUseWorkspaces.mockReturnValue({ data: [] }); }); // The lists across workspaces are answered for platform admins only, so - // nobody else is shown the way to them. - it('offers All workspaces to a platform admin, after Workspaces', () => { + // nobody else is shown the way to them. Sandboxes sits with All resources: + // it is the list that used to be that page's first tab. + it('offers Sandboxes and All resources to a platform admin', () => { mockIsPlatformAdmin = true; renderShell('/workspaces'); expect(navLinks()).toEqual([ ['Gateway', '/gateway'], ['Workspaces', '/workspaces'], - ['All workspaces', '/all-workspaces'], + ['Sandboxes', '/sandboxes'], + ['Proposals', '/proposals'], + ['Resources', '/all-workspaces'], ['Provider profiles', '/provider-profiles'], ['Global policy', '/global-policy'], ['Settings', '/settings'], ]); + expect(mockUseAllSandboxes).toHaveBeenCalledWith(undefined, { + enabled: true, + refetchInterval: NAV_COUNT_POLL_MS, + }); + }); + + // Proposals are a sandbox's, not the platform's: a standard user reviews + // the ones in the workspaces they are a member of, so the entry is theirs + // too. Sandboxes and All resources are the cross-workspace lists, which + // the gateway answers for a platform admin only. + it('does not offer them to anyone else', () => { + renderShell('/workspaces'); + + expect(navLinks()).toEqual([ + ['Workspaces', '/workspaces'], + ['Proposals', '/proposals'], + ]); + expect(mockUseAllSandboxes).toHaveBeenCalledWith(undefined, { + enabled: false, + refetchInterval: NAV_COUNT_POLL_MS, + }); + }); + + // The badge is the whole point of the entry: how many rules are waiting for + // a decision, without opening the page. + it('counts the pending proposals on the entry', () => { + mockPendingProposals = 3; + renderShell('/workspaces'); + + expect(screen.getByTestId('proposals-badge')).toHaveTextContent('3'); + }); + + // The count costs the gateway one draft read per sandbox, so the shell + // reads it at the navigation's pace, not the Proposals page's. + it('reads the proposal count at the navigation pace', () => { + mockIsPlatformAdmin = true; + renderShell('/workspaces'); + + expect(useAllWorkspacesDraftSummary).toHaveBeenCalledWith(true, { + refetchInterval: NAV_COUNT_POLL_MS, + }); + }); + + it('badges nothing when nothing is waiting', () => { + renderShell('/workspaces'); + + expect(screen.queryByTestId('proposals-badge')).not.toBeInTheDocument(); + }); + + // The same badge the Proposals entry carries: how many there are, without + // opening the page. Zero is not badged. + it('counts the sandboxes on the entry', () => { + mockIsPlatformAdmin = true; + mockUseAllSandboxes.mockReturnValue({ data: [{}, {}, {}] }); + renderShell('/workspaces'); + + expect(screen.getByTestId('sandboxes-badge')).toHaveTextContent('3'); + }); + + it('badges nothing when the gateway has no sandboxes', () => { + mockIsPlatformAdmin = true; + renderShell('/workspaces'); + + expect(screen.queryByTestId('sandboxes-badge')).not.toBeInTheDocument(); + }); + + // Same badge again: how many workspaces the signed-in user can see. + it('counts the workspaces on the entry', () => { + mockUseWorkspaces.mockReturnValue({ data: [{}, {}] }); + renderShell('/workspaces'); + + expect(screen.getByTestId('workspaces-badge')).toHaveTextContent('2'); + }); + + // The shell reads it on every page, so at the navigation's pace, and not + // at all for a user the gateway gives no role. + it('reads the workspace count at the navigation pace', () => { + renderShell('/workspaces'); + + expect(mockUseWorkspaces).toHaveBeenCalledWith(undefined, { + enabled: true, + refetchInterval: NAV_COUNT_POLL_MS, + }); + }); + + it('does not ask for the workspaces of a user with no role', () => { + mockIsUser = false; + mockIsUnentitled = true; + renderShell('/workspaces'); + + expect(mockUseWorkspaces).toHaveBeenCalledWith(undefined, { + enabled: false, + refetchInterval: NAV_COUNT_POLL_MS, + }); + }); + + it('badges nothing when the user has no workspaces', () => { + renderShell('/workspaces'); + + expect(screen.queryByTestId('workspaces-badge')).not.toBeInTheDocument(); + }); + + // Gateway leads, ungrouped: the landing page is not one of a set. A rule + // starts each of the two sections below it, and nothing names them. + it('rules off the sections a platform admin sees', () => { + mockIsPlatformAdmin = true; + renderShell('/gateway'); + + expect(primaryNav().getAllByTestId('nav-section-rule')).toHaveLength(2); + expect( + primaryNav().queryByText('Workspaces', { selector: 'h2' }), + ).not.toBeInTheDocument(); + expect( + primaryNav().queryByText('Platform', { selector: 'h2' }), + ).not.toBeInTheDocument(); }); - it('does not offer it to anyone else', () => { + // A rule above the only section there is would divide the sidebar from + // nothing. A standard user keeps the flat list they had. + it('leaves the nav flat and unruled for a standard user', () => { renderShell('/workspaces'); - expect(navLinks()).toEqual([['Workspaces', '/workspaces']]); + expect(primaryNav().queryAllByTestId('nav-section-rule')).toHaveLength(0); }); - // The two entries share a word, not a path prefix: each is current only on - // its own pages. + // Workspaces and All resources share a word, not a path prefix. A workspace + // sandbox path shares a segment with Sandboxes the same way. Each entry is + // current only on its own pages. it.each([ - ['/all-workspaces', 'All workspaces'], + ['/sandboxes', 'Sandboxes'], + ['/all-workspaces', 'Resources'], ['/workspaces', 'Workspaces'], ['/workspaces/team-a/sandboxes/agent', 'Workspaces'], ])('marks the right entry as current on %s', (route, entry) => { @@ -165,6 +315,7 @@ describe('AppLayout identity', () => { mockIsPlatformAdmin = false; mockIsUser = true; mockIsUnentitled = false; + mockUseAllSandboxes.mockReturnValue({ data: [] }); mockUser = { subject: 'f3b1c2d4', displayName: 'Ada Lovelace', diff --git a/frontend/src/app/__tests__/AppLayout.spec.tsx b/frontend/src/app/__tests__/AppLayout.spec.tsx index 5059c08f..538f5a66 100644 --- a/frontend/src/app/__tests__/AppLayout.spec.tsx +++ b/frontend/src/app/__tests__/AppLayout.spec.tsx @@ -14,6 +14,18 @@ jest.mock('../../api/auth', () => ({ useFeatureFlags: () => ({ globalPolicy: true, settings: true }), })); // Real useUserRole — badge/nav cases set roles on useCurrentUser below. +// The shell counts the rules waiting for a decision for its Proposals badge. +// Nothing is pending here; AppLayout.navigation.spec covers the badge itself. +jest.mock('../../api/draftSummary', () => ({ + useAllWorkspacesDraftSummary: () => ({ totalPending: 0 }), +})); +// The shell counts sandboxes and workspaces for their nav badges. None here. +jest.mock('../../api/allWorkspaces', () => ({ + useAllSandboxes: () => ({ data: [] }), +})); +jest.mock('../../api/workspaces', () => ({ + useWorkspaces: () => ({ data: [] }), +})); jest.mock('../theme', () => ({ useTheme: () => ({ theme: 'light', toggleTheme: jest.fn() }), })); @@ -99,6 +111,22 @@ const renderShell = (route: string, page: React.ReactNode) => , ); +// An entry of the sidebar, by its label. A link and not its text: the page +// heading carries the same words as the entry that led to it ("Workspaces"), +// and jsdom has no width, so the collapsed sidebar is out of the +// accessibility tree. +const navLink = (name: string) => + within(screen.getByLabelText('Primary navigation')).getByRole('link', { + name, + hidden: true, + }); + +const missingNavLink = (name: string) => + within(screen.getByLabelText('Primary navigation')).queryByRole('link', { + name, + hidden: true, + }); + // A page as every routed page starts: its own level 1 heading. const page = (
@@ -118,6 +146,9 @@ describe('AppLayout role badge', () => { mockUser({ roles: ['user'] }); renderShell('/workspaces', page); + expect(screen.getByTestId('role-badge-region')).toHaveTextContent( + /Role:\s*Standard user/, + ); expect(screen.getByTestId('role-badge')).toHaveTextContent('Standard user'); }); @@ -125,6 +156,9 @@ describe('AppLayout role badge', () => { mockUser({ roles: ['admin'] }); renderShell('/workspaces', page); + expect(screen.getByTestId('role-badge-region')).toHaveTextContent( + /Role:\s*Platform admin/, + ); expect(screen.getByTestId('role-badge')).toHaveTextContent( 'Platform admin', ); @@ -171,22 +205,20 @@ describe('AppLayout platform-admin gating', () => { it('hides Gateway, Global policy, and Settings nav for a User', () => { renderShell('/workspaces', page); - const nav = screen.getByLabelText('Primary navigation'); - expect(within(nav).getByText('Workspaces')).toBeInTheDocument(); - expect(within(nav).queryByText('Gateway')).not.toBeInTheDocument(); - expect(within(nav).queryByText('Global policy')).not.toBeInTheDocument(); - expect(within(nav).queryByText('Settings')).not.toBeInTheDocument(); + expect(navLink('Workspaces')).toBeInTheDocument(); + expect(missingNavLink('Gateway')).not.toBeInTheDocument(); + expect(missingNavLink('Global policy')).not.toBeInTheDocument(); + expect(missingNavLink('Settings')).not.toBeInTheDocument(); }); it('shows Gateway, Global policy, and Settings nav for a Platform Admin', () => { mockUser({ roles: ['admin'] }); renderShell('/workspaces', page); - const nav = screen.getByLabelText('Primary navigation'); - expect(within(nav).getByText('Gateway')).toBeInTheDocument(); - expect(within(nav).getByText('Workspaces')).toBeInTheDocument(); - expect(within(nav).getByText('Global policy')).toBeInTheDocument(); - expect(within(nav).getByText('Settings')).toBeInTheDocument(); + expect(navLink('Gateway')).toBeInTheDocument(); + expect(navLink('Workspaces')).toBeInTheDocument(); + expect(navLink('Global policy')).toBeInTheDocument(); + expect(navLink('Settings')).toBeInTheDocument(); }); it('points the logo home at /workspaces for a User', () => { @@ -237,8 +269,9 @@ describe('AppLayout gateway compatibility notice', () => { mockVerdict('0.0.116', { status: 'unsupported', ...line }); renderShell('/workspaces', page); - // The shell did ask for gateway info, and got nothing... - expect(mockUseGatewayInfo).toHaveBeenCalled(); + // The verdict rides on its own request, which the gateway answers for + // every signed-in user: the shell never asks for gateway info... + expect(mockUseGatewayInfo).not.toHaveBeenCalled(); // ...and the notice is there anyway, naming the version. expect(screen.getByTestId('gateway-compatibility-alert')).toHaveTextContent( 'This gateway reports 0.0.116.', @@ -429,12 +462,13 @@ describe('AppLayout About dialog', () => { }); // The gateway refuses GET /gateway to anyone but a platform admin. The line - // is read from the route every signed-in user may call. + // is read from the route every signed-in user may call, and the shell never + // asks for gateway info at all. it('shows the line to a user whose gateway-info request is refused', async () => { renderShell('/workspaces', page); const about = await openAbout(); - expect(mockUseGatewayInfo).toHaveBeenCalled(); + expect(mockUseGatewayInfo).not.toHaveBeenCalled(); expect(within(about).getByTestId('about-gateway-line')).toHaveTextContent( '0.1.x', ); diff --git a/frontend/src/app/__tests__/AuthenticatedRoutes.spec.tsx b/frontend/src/app/__tests__/AuthenticatedRoutes.spec.tsx index cb4eae6b..6d0b81b6 100644 --- a/frontend/src/app/__tests__/AuthenticatedRoutes.spec.tsx +++ b/frontend/src/app/__tests__/AuthenticatedRoutes.spec.tsx @@ -31,6 +31,7 @@ const mockPage = (testId: string) => ({ __esModule: true, default: () =>
, }); +jest.mock('../../pages/AllSandboxesPage', () => mockPage('sandboxes')); jest.mock('../../pages/AllWorkspacesPage', () => mockPage('all-workspaces')); jest.mock('../../pages/EntitlementPage', () => mockPage('entitlement-screen')); jest.mock('../../pages/GatewayOverviewPage', () => mockPage('gateway')); @@ -75,6 +76,20 @@ describe('AuthenticatedRoutes', () => { expect(screen.getByTestId('workspace-list')).toBeInTheDocument(); }); + it('shows the sandboxes page to a platform admin', () => { + mockRole = { ...entitledUser(), isPlatformAdmin: true }; + renderAt('/sandboxes'); + + expect(screen.getByTestId('sandboxes')).toBeInTheDocument(); + }); + + it('sends anyone else away from sandboxes', () => { + renderAt('/sandboxes'); + + expect(screen.queryByTestId('sandboxes')).not.toBeInTheDocument(); + expect(screen.getByTestId('workspace-list')).toBeInTheDocument(); + }); + it('decides nothing while the role is still loading', () => { mockRole = { ...entitledUser(), isLoading: true }; renderAt('/all-workspaces'); @@ -83,6 +98,24 @@ describe('AuthenticatedRoutes', () => { expect(screen.queryByTestId('workspace-list')).not.toBeInTheDocument(); }); + // Breadcrumb puts the divider on each child after the first, and it counts + // its children without looking inside a fragment. Grouping the deeper two + // in one left a sandbox's trail reading "Workspaces default sb" with + // nothing between the names. + it('divides every crumb of a sandbox from the one before it', () => { + const { container } = renderAt('/workspaces/team-a/sandboxes/sb'); + + const crumbs = screen.getAllByRole('listitem'); + expect(crumbs.map((crumb) => crumb.textContent)).toEqual([ + 'Workspaces', + 'team-a', + 'sb', + ]); + expect( + container.querySelectorAll('.pf-v6-c-breadcrumb__item-divider'), + ).toHaveLength(2); + }); + // It sits beside /workspaces, not under it: a workspace named // "all-workspaces" would otherwise be shadowed, and the reverse. it('keeps the workspace routes where they were', () => { @@ -92,9 +125,23 @@ describe('AuthenticatedRoutes', () => { expect(screen.getByTestId('workspace-list')).toBeInTheDocument(); unmount(); - renderAt('/workspaces/all-workspaces'); + const { unmount: unmountAllWorkspaces } = renderAt( + '/workspaces/all-workspaces', + ); expect(screen.getByTestId('workspace')).toBeInTheDocument(); expect(screen.queryByTestId('all-workspaces')).not.toBeInTheDocument(); + unmountAllWorkspaces(); + + // Beside /workspaces, not under it: a workspace named "sandboxes" is + // still that workspace, and a sandbox detail path is still a sandbox. + const { unmount: unmountWorkspace } = renderAt('/workspaces/sandboxes'); + expect(screen.getByTestId('workspace')).toBeInTheDocument(); + expect(screen.queryByTestId('sandboxes')).not.toBeInTheDocument(); + unmountWorkspace(); + + renderAt('/workspaces/team-a/sandboxes/agent'); + expect(screen.getByTestId('sandbox')).toBeInTheDocument(); + expect(screen.queryByTestId('sandboxes')).not.toBeInTheDocument(); }); it('shows the entitlement screen instead of routes when unentitled', () => { diff --git a/frontend/src/app/__tests__/ProviderProfilesRoute.spec.tsx b/frontend/src/app/__tests__/ProviderProfilesRoute.spec.tsx index 984500ba..822db6bc 100644 --- a/frontend/src/app/__tests__/ProviderProfilesRoute.spec.tsx +++ b/frontend/src/app/__tests__/ProviderProfilesRoute.spec.tsx @@ -22,6 +22,20 @@ jest.mock('../../api/gateway', () => ({ useGatewayInfo: () => ({ data: undefined }), useGatewayCompatibility: () => ({ data: undefined, isLoading: true }), })); +// The shell counts the rules waiting for a decision for its Proposals badge. +jest.mock('../../api/draftSummary', () => ({ + useAllWorkspacesDraftSummary: () => ({ totalPending: 0 }), +})); +// The shell counts sandboxes and workspaces for their nav badges. The real +// allWorkspaces module stays for the pages this file loads; only the counts +// are stubbed. +jest.mock('../../api/allWorkspaces', () => ({ + ...jest.requireActual('../../api/allWorkspaces'), + useAllSandboxes: () => ({ data: [] }), +})); +jest.mock('../../api/workspaces', () => ({ + useWorkspaces: () => ({ data: [] }), +})); jest.mock('../theme', () => ({ useTheme: () => ({ theme: 'light', toggleTheme: jest.fn() }), })); @@ -49,6 +63,7 @@ jest.mock('../../pages/WorkspaceDetailPage', () => mockPage('workspace')); jest.mock('../../pages/SandboxDetailPage', () => mockPage('sandbox')); jest.mock('../../pages/ProviderDetailPage', () => mockPage('provider')); jest.mock('../../pages/GlobalPolicyPage', () => mockPage('global-policy')); +jest.mock('../../pages/ProposalsPage', () => mockPage('proposals')); jest.mock('../../pages/SettingsPage', () => mockPage('settings')); import AuthenticatedRoutes from '../AuthenticatedRoutes'; diff --git a/frontend/src/app/__tests__/theme.spec.tsx b/frontend/src/app/__tests__/theme.spec.tsx new file mode 100644 index 00000000..ac83bdec --- /dev/null +++ b/frontend/src/app/__tests__/theme.spec.tsx @@ -0,0 +1,111 @@ +import { fireEvent, render, screen } from '@testing-library/react'; + +import { ThemeProvider, useTheme } from '../theme'; + +const THEME_STORAGE_KEY = 'openshell-dashboard-theme'; +const DARK_THEME_CLASS = 'pf-v6-theme-dark'; + +// Two surfaces of the kind the provider exists for: the masthead toggle and +// one of the editors that only reads the theme. +const Toggle: React.FC = () => { + const { theme, toggleTheme } = useTheme(); + return ( + + ); +}; + +const Reader: React.FC = () => { + const { theme } = useTheme(); + return {theme}; +}; + +beforeEach(() => { + window.localStorage.clear(); + document.documentElement.classList.remove(DARK_THEME_CLASS); + // jsdom has no matchMedia, and the provider asks the system for a theme + // whenever none was stored. + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: () => ({ matches: false }), + }); +}); + +describe('ThemeProvider', () => { + it('starts from the stored theme', () => { + window.localStorage.setItem(THEME_STORAGE_KEY, 'dark'); + render( + + + , + ); + expect(screen.getByTestId('reader')).toHaveTextContent('dark'); + }); + + it('falls back to the system theme when none was stored', () => { + render( + + + , + ); + expect(screen.getByTestId('reader')).toHaveTextContent('light'); + }); + + it('takes every surface with it when the theme is toggled', () => { + render( + + + + , + ); + + fireEvent.click(screen.getByTestId('toggle')); + + // The reader is what a hook with state of its own would have left behind. + expect(screen.getByTestId('toggle')).toHaveTextContent('dark'); + expect(screen.getByTestId('reader')).toHaveTextContent('dark'); + expect(document.documentElement).toHaveClass(DARK_THEME_CLASS); + expect(window.localStorage.getItem(THEME_STORAGE_KEY)).toBe('dark'); + + fireEvent.click(screen.getByTestId('toggle')); + + expect(screen.getByTestId('reader')).toHaveTextContent('light'); + expect(document.documentElement).not.toHaveClass(DARK_THEME_CLASS); + expect(window.localStorage.getItem(THEME_STORAGE_KEY)).toBe('light'); + }); + + // Storage that throws — blocked site data, some private windows — must not + // stop the app from starting or the toggle from working. + it('starts and toggles when storage throws', () => { + const getItem = jest + .spyOn(Storage.prototype, 'getItem') + .mockImplementation(() => { + throw new Error('blocked'); + }); + const setItem = jest + .spyOn(Storage.prototype, 'setItem') + .mockImplementation(() => { + throw new Error('blocked'); + }); + try { + render( + + + + , + ); + fireEvent.click(screen.getByTestId('toggle')); + expect(screen.getByTestId('reader')).toHaveTextContent('dark'); + expect(document.documentElement).toHaveClass(DARK_THEME_CLASS); + } finally { + getItem.mockRestore(); + setItem.mockRestore(); + } + }); + + it('reads as light outside a provider', () => { + render(); + expect(screen.getByTestId('reader')).toHaveTextContent('light'); + }); +}); diff --git a/frontend/src/app/theme.ts b/frontend/src/app/theme.ts deleted file mode 100644 index 8f4e5790..00000000 --- a/frontend/src/app/theme.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { useState } from 'react'; - -export type Theme = 'light' | 'dark'; - -const THEME_STORAGE_KEY = 'openshell-dashboard-theme'; -const DARK_THEME_CLASS = 'pf-v6-theme-dark'; - -export const getStoredTheme = (): Theme | null => { - const stored = window.localStorage.getItem(THEME_STORAGE_KEY); - return stored === 'light' || stored === 'dark' ? stored : null; -}; - -export const getSystemTheme = (): Theme => - window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; - -export const applyTheme = (theme: Theme): void => { - document.documentElement.classList.toggle(DARK_THEME_CLASS, theme === 'dark'); -}; - -export const initTheme = (): Theme => { - const theme = getStoredTheme() ?? getSystemTheme(); - applyTheme(theme); - return theme; -}; - -export const useTheme = (): { theme: Theme; toggleTheme: () => void } => { - const [theme, setTheme] = useState( - () => getStoredTheme() ?? getSystemTheme(), - ); - - const toggleTheme = () => { - const next: Theme = theme === 'dark' ? 'light' : 'dark'; - applyTheme(next); - window.localStorage.setItem(THEME_STORAGE_KEY, next); - setTheme(next); - }; - - return { theme, toggleTheme }; -}; diff --git a/frontend/src/app/theme.tsx b/frontend/src/app/theme.tsx new file mode 100644 index 00000000..caf0075c --- /dev/null +++ b/frontend/src/app/theme.tsx @@ -0,0 +1,75 @@ +import { createContext, useContext, useState } from 'react'; + +export type Theme = 'light' | 'dark'; + +const THEME_STORAGE_KEY = 'openshell-dashboard-theme'; +const DARK_THEME_CLASS = 'pf-v6-theme-dark'; + +// Storage can throw — blocked site data, some private windows — and the theme +// is read at boot, so a failure to read or write it is no theme remembered, +// never an app that does not start. +export const getStoredTheme = (): Theme | null => { + try { + const stored = window.localStorage.getItem(THEME_STORAGE_KEY); + return stored === 'light' || stored === 'dark' ? stored : null; + } catch { + return null; + } +}; + +const storeTheme = (theme: Theme): void => { + try { + window.localStorage.setItem(THEME_STORAGE_KEY, theme); + } catch { + // Not remembered; the toggle still applies for this visit. + } +}; + +export const getSystemTheme = (): Theme => + window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; + +export const applyTheme = (theme: Theme): void => { + document.documentElement.classList.toggle(DARK_THEME_CLASS, theme === 'dark'); +}; + +export const initTheme = (): Theme => { + const theme = getStoredTheme() ?? getSystemTheme(); + applyTheme(theme); + return theme; +}; + +type ThemeContextValue = { theme: Theme; toggleTheme: () => void }; + +// Light by default, so a component rendered outside the provider, as in a +// unit test, is readable. +const ThemeContext = createContext({ + theme: 'light', + toggleTheme: () => undefined, +}); + +// The theme the whole app reads: the masthead toggle, the two policy document +// editors, the log viewer and the terminal. Context rather than a hook with +// state of its own, because those are several call sites and several copies +// of useState would not see each other's toggle. +export const useTheme = (): ThemeContextValue => useContext(ThemeContext); + +export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ + children, +}) => { + const [theme, setTheme] = useState( + () => getStoredTheme() ?? getSystemTheme(), + ); + + const toggleTheme = () => { + const next: Theme = theme === 'dark' ? 'light' : 'dark'; + applyTheme(next); + storeTheme(next); + setTheme(next); + }; + + return ( + + {children} + + ); +}; diff --git a/frontend/src/components/AllSandboxList.tsx b/frontend/src/components/AllSandboxList.tsx index 0f436461..319f159b 100644 --- a/frontend/src/components/AllSandboxList.tsx +++ b/frontend/src/components/AllSandboxList.tsx @@ -1,5 +1,5 @@ import { useState } from 'react'; -import { Button } from '@patternfly/react-core'; +import { Link } from 'react-router-dom'; import { useAllSandboxPage } from '../api/allWorkspaces'; import { @@ -10,23 +10,21 @@ import { import { combineSandboxLabelSelector } from '../utils/sandboxLabelSelector'; import { formatAge } from '../utils/formatters'; import type { Sandbox } from '../types'; -import { ImageReference, scopedName, workspaceOf } from './AllWorkspaceCells'; +import { + sandboxPath, + scopedName, + workspaceOf, + workspacePath, +} from '../utils/workspaceNames'; +import ImageReference from './sandbox/ImageReference'; import LabelsList from './LabelsList'; import PhaseLabel from './PhaseLabel'; import ResourceTable from './ResourceTable'; import type { ResourceTableColumn } from './ResourceTable'; -type AllSandboxListProps = { - onSelectWorkspace: (workspace: string) => void; - onSelectSandbox: (workspace: string, name: string) => void; -}; - -// The sandbox tab owns its Gateway cursor, type-label filter and columns. +// The Sandboxes page's list: it owns its gateway cursor, type-label filter and columns. // Other all-workspaces resources continue to use local table pagination. -const AllSandboxList: React.FC = ({ - onSelectWorkspace, - onSelectSandbox, -}) => { +const AllSandboxList: React.FC = () => { const [selector, setSelector] = useState(''); const [typeLabel, setTypeLabel] = useState(''); const [filterError, setFilterError] = useState(''); @@ -53,34 +51,31 @@ const AllSandboxList: React.FC = ({ setTypeLabel(type.trim()); }; + // Name leads so the sandbox is what the row is about; workspace follows. + // Both are links, so they open in a new tab and read as links. const columns: ResourceTableColumn[] = [ { - title: 'Workspace', + title: 'Name', modifier: 'nowrap', render: (sandbox) => ( - + {sandbox.metadata.name} + ), }, { - title: 'Name', + title: 'Workspace', modifier: 'nowrap', render: (sandbox) => ( - + {workspaceOf(sandbox)} + ), }, { diff --git a/frontend/src/components/AllWorkspaceCells.tsx b/frontend/src/components/AllWorkspaceCells.tsx index aab34145..e2df7396 100644 --- a/frontend/src/components/AllWorkspaceCells.tsx +++ b/frontend/src/components/AllWorkspaceCells.tsx @@ -1,17 +1,5 @@ -export const workspaceOf = (item: { - metadata: { workspace?: string }; -}): string => item.metadata.workspace ?? ''; - -// Names are unique only within a workspace. -export const scopedName = (item: { - metadata: { workspace?: string; name: string }; -}): string => `${workspaceOf(item)}/${item.metadata.name}`; - -export const ImageReference: React.FC<{ image?: string }> = ({ image }) => - image ? ( - - {image} - - ) : ( - <>- - ); +// The cells the lists across workspaces share. The helpers live with the +// rest of the workspace-scoped naming (utils/workspaceNames) and the image +// cell with the sandbox components; this keeps the one import those lists use. +export { scopedName, workspaceOf } from '../utils/workspaceNames'; +export { default as ImageReference } from './sandbox/ImageReference'; diff --git a/frontend/src/components/CreateSandboxFromTemplateModal.tsx b/frontend/src/components/CreateSandboxFromTemplateModal.tsx index f264db57..c7a5149e 100644 --- a/frontend/src/components/CreateSandboxFromTemplateModal.tsx +++ b/frontend/src/components/CreateSandboxFromTemplateModal.tsx @@ -23,6 +23,7 @@ import { useProviders } from '../api/providers'; import { useApplyApprovalMode } from '../api/sandboxSettings'; import { useCreateSandboxFromTemplate } from '../api/templates'; import { useAlerts } from '../app/AlertContext'; +import { useI18n } from '../i18n'; import { POLICY_REQUIRED, parseLabels } from '../hooks/useCreateSandboxForm'; import { usePolicyText } from '../hooks/usePolicyText'; import { useSandboxLaunchOptions } from '../hooks/useSandboxLaunchOptions'; @@ -43,6 +44,7 @@ type CreateSandboxFromTemplateModalProps = { const CreateSandboxFromTemplateModal: React.FC< CreateSandboxFromTemplateModalProps > = ({ workspace, templateName, isOpen, onClose }) => { + const { t } = useI18n('sandboxes'); const providers = useProviders(workspace); const createFromTemplate = useCreateSandboxFromTemplate(workspace); const { addSuccess } = useAlerts(); @@ -59,6 +61,7 @@ const CreateSandboxFromTemplateModal: React.FC< // labels and annotations, its main command, the services to expose and the // approval mode. The workload stays the template's. const [labelsText, setLabelsText] = useState(''); + const [isGeneratedExpanded, setGeneratedExpanded] = useState(false); const [annotationRows, setAnnotationRows] = useState([]); const [isAdvancedExpanded, setAdvancedExpanded] = useState(false); const launch = useSandboxLaunchOptions(); @@ -74,6 +77,9 @@ const CreateSandboxFromTemplateModal: React.FC< [policyTemplateId], ); const labels = parseLabels(labelsText); + // A field of the folded section that holds a value keeps the section + // open: whatever it holds is sent, so it is never sent out of sight. + const hasOptionalValues = name !== '' || labelsText !== ''; const { record: annotations, error: annotationsError } = rowsToRecord(annotationRows); // The section opens by itself around a field that needs correcting. @@ -105,6 +111,7 @@ const CreateSandboxFromTemplateModal: React.FC< setSelectedProviders([]); setPolicyExpanded(false); setLabelsText(''); + setGeneratedExpanded(false); setAnnotationRows([]); setAdvancedExpanded(false); launch.reset(); @@ -197,62 +204,6 @@ const CreateSandboxFromTemplateModal: React.FC< submit(); }} > - - setName(value)} - placeholder="Leave empty for a generated name" - /> - - - setLabelsText(value)} - placeholder="team=ml, kind=agent" - validated={labels === null ? 'error' : 'default'} - /> - - - - {labels === null - ? 'Labels must be comma-separated key=value pairs' - : 'Optional comma-separated key=value pairs, used for filtering'} - - - - - - {(providers.data ?? []).length === 0 ? ( - - - - No providers in this workspace yet - - - - ) : ( - (providers.data ?? []).map((provider) => ( - - toggleProvider(provider.metadata.name, checked) - } - /> - )) - )} - + + {(providers.data ?? []).length === 0 ? ( + + + + No providers in this workspace yet + + + + ) : ( + (providers.data ?? []).map((provider) => ( + + toggleProvider(provider.metadata.name, checked) + } + /> + )) + )} + + {/* Optional fields, folded away as in the Create sandbox form. + Only the name is generated when left empty. The section opens + by itself around a field that needs correcting, and stays open + while any of its fields holds a value. */} + setGeneratedExpanded(expanded)} + data-testid="from-template-generated-expand" + > + + + setName(value)} + placeholder="Leave empty for a generated name" + /> + + + {t('create.nameHelper')} + + + + + setLabelsText(value)} + placeholder="team=ml, kind=agent" + validated={labels === null ? 'error' : 'default'} + /> + + + + {labels === null + ? 'Labels must be comma-separated key=value pairs' + : 'Optional comma-separated key=value pairs, used for filtering'} + + + + + + = ({ isOpen, onClose, }) => { + const { t } = useI18n('sandboxes'); const form = useCreateSandboxForm(); + // A field of the folded section that holds a value keeps the section + // open: whatever it holds is sent, so it is never sent out of sight. + const hasOptionalValues = [ + form.name, + form.labelsText, + form.gpuCount, + form.cpu, + form.memory, + ].some((value) => value !== ''); const providers = useProviders(workspace); const createSandbox = useCreateSandbox(workspace); const approvalMode = useApplyApprovalMode(workspace); @@ -126,15 +137,49 @@ const CreateSandboxModal: React.FC = ({ submit(); }} > - - form.setName(value)} - placeholder="Leave empty for a generated name" - /> + + form.applyTemplate(value)} + > + {policyTemplates.map((template) => ( + + ))} + + {form.activeTemplate && ( + + + + {form.activeTemplate.description} + + + + )} + form.setPolicyExpanded(expanded)} + data-testid="sandbox-policy-expand" + > + + = ({ - - form.setLabelsText(value)} - placeholder="team=ml, kind=agent" - validated={form.labels === null ? 'error' : 'default'} - /> - - - - {form.labels === null - ? 'Labels must be comma-separated key=value pairs' - : 'Optional comma-separated key=value pairs, used for filtering'} - - - - {(providers.data ?? []).length === 0 ? ( @@ -200,92 +224,108 @@ const CreateSandboxModal: React.FC = ({ )) )} - - - - form.setGpuCount(value)} - placeholder="GPUs (e.g. 1)" - validated={form.gpuInvalid ? 'error' : 'default'} - aria-label="GPU count" - /> - - + {/* Optional fields, folded away so that the form opens on the + three things a sandbox is made of. Only the name is generated + when left empty. The section opens by itself around a field + that needs correcting, and stays open while any of its fields + holds a value. */} + form.setGeneratedExpanded(expanded)} + data-testid="sandbox-generated-expand" + > + + form.setCpu(value)} - placeholder="CPU limit (e.g. 2, 500m)" - aria-label="CPU limit" + id="sandbox-name" + data-testid="sandbox-name-input" + value={form.name} + onChange={(_event, value) => form.setName(value)} + placeholder="Leave empty for a generated name" /> - - + + + {t('create.nameHelper')} + + + + form.setMemory(value)} - placeholder="Memory limit (e.g. 4Gi)" - aria-label="Memory limit" + id="sandbox-labels" + data-testid="sandbox-labels-input" + value={form.labelsText} + onChange={(_event, value) => form.setLabelsText(value)} + placeholder="team=ml, kind=agent" + validated={form.labels === null ? 'error' : 'default'} /> - - - - - - {form.gpuInvalid - ? 'GPU count must be a whole number' - : 'All optional. CPU/memory use Kubernetes quantities and apply as limits.'} - - - - - - form.applyTemplate(value)} - > - {policyTemplates.map((template) => ( - - ))} - - {form.activeTemplate && ( - - - - {form.activeTemplate.description} - - - - )} - - form.setPolicyExpanded(expanded)} - data-testid="sandbox-policy-expand" - > - + + + + {form.labels === null + ? 'Labels must be comma-separated key=value pairs' + : 'Optional comma-separated key=value pairs, used for filtering'} + + + + + + + + form.setGpuCount(value)} + placeholder="GPUs (e.g. 1)" + validated={form.gpuInvalid ? 'error' : 'default'} + aria-label="GPU count" + /> + + + form.setCpu(value)} + placeholder="CPU limit (e.g. 2, 500m)" + aria-label="CPU limit" + /> + + + form.setMemory(value)} + placeholder="Memory limit (e.g. 4Gi)" + aria-label="Memory limit" + /> + + + + + + {form.gpuInvalid + ? 'GPU count must be a whole number' + : 'All optional. CPU/memory use Kubernetes quantities and apply as limits.'} + + + + + = { - healthy: 'success', - notHealthy: 'warning', - unreachable: 'danger', - unknown: undefined, +// The colour and the icon the Gateway page gives the same four states, so +// that the masthead and the page say one thing in one way. PatternFly's +// `status` shorthand is a different palette from the page's `color`, which +// is what had the two greens differing. +const STATE_COLOR: Record = { + healthy: 'green', + notHealthy: 'orange', + unreachable: 'red', + unknown: 'grey', +}; + +const STATE_ICON: Record = { + healthy: , + notHealthy: , + unreachable: , + unknown: , }; const STATE_LABEL_KEY = { @@ -78,19 +95,39 @@ const GatewayStatusIndicator: React.FC = () => { const state = gatewayHealthStateOf(useGatewayCompatibility()); return ( - + // The status region is the row itself. A full-height toolbar stretches + // its items and their children, so a plain wrapper around the row would + // be masthead-tall with the row sitting at its top — the label and the + // badge riding above the centreline every other item on the bar keeps. + // Centred here, there is nothing left to ride high in. + {state && ( - + // What the label is the value of, said once beside it rather than + // repeated inside every one of the four states. + <> + + {t('gatewayHealth.label')} + + + + + )} - + ); }; diff --git a/frontend/src/components/PolicyRuleEditor/PolicyDocumentEditor.tsx b/frontend/src/components/PolicyRuleEditor/PolicyDocumentEditor.tsx index e69847fb..0a327880 100644 --- a/frontend/src/components/PolicyRuleEditor/PolicyDocumentEditor.tsx +++ b/frontend/src/components/PolicyRuleEditor/PolicyDocumentEditor.tsx @@ -16,6 +16,7 @@ import { } from '@patternfly/react-core'; import { CodeEditor, Language } from '@patternfly/react-code-editor'; +import { useTheme } from '../../app/theme'; import { usePolicyText } from '../../hooks/usePolicyText'; import { hasGatewayMarks, policyToText } from '../../utils/policyFile'; import type { PolicyFileFormat } from '../../utils/policyFile'; @@ -94,6 +95,7 @@ const PolicyDocumentEditor: React.FC = ({ error, notSentReason, }) => { + const { theme } = useTheme(); const [view, setView] = useState('own'); const [format, setFormat] = useState('json'); const [ownDraft, setOwnDraft] = useState(null); @@ -246,6 +248,7 @@ const PolicyDocumentEditor: React.FC = ({ ) : ( = ({ } return ( - + // Same shape as Gateway status: a small name for the value, then the + // badge. A full-height toolbar stretches its items, so the row is + // centred the same way that indicator centres itself. + + + {t('header.roleLabel')} + + + + + ); }; diff --git a/frontend/src/components/SandboxDraftsTab/DraftChunkRow.tsx b/frontend/src/components/SandboxDraftsTab/DraftChunkRow.tsx index 5310bbd3..1afcc5fa 100644 --- a/frontend/src/components/SandboxDraftsTab/DraftChunkRow.tsx +++ b/frontend/src/components/SandboxDraftsTab/DraftChunkRow.tsx @@ -12,6 +12,7 @@ import { Flex, FlexItem, Label, + LabelGroup, Popover, Stack, StackItem, @@ -20,6 +21,7 @@ import { } from '@patternfly/react-core'; import { ExpandableRowContent, Tbody, Td, Tr } from '@patternfly/react-table'; +import { useI18n } from '../../i18n'; import { formatTimestamp } from '../../utils/formatters'; import { approvalAnnotation, chunkStatusColor, chunkStatusIcon } from './utils'; import type { useDraftActions } from './useDraftActions'; @@ -56,6 +58,7 @@ const DraftChunkRow: React.FC = ({ onSetRejecting, onSetRejectReason, }) => { + const { t } = useI18n('sandboxes'); const { approve, reject, undo } = actions; const annotation = approvalAnnotation(chunk); // The gateway takes an approval for a pending or a rejected chunk, and a @@ -88,26 +91,14 @@ const DraftChunkRow: React.FC = ({ {chunk.ruleName || chunk.id} + {/* The one label that stays in the table: it marks the chunks + the gateway skips on an approve-all, so it has to be + readable without opening the row. */} {chunk.securityNotes && ( )} - {annotation && ( - - )} - {chunk.stage && ( - - )} {chunk.rationale && ( @@ -159,7 +150,7 @@ const DraftChunkRow: React.FC = ({ {canApprove && ( ; ``` - Import `useI18n` from this directory by relative path (`'../i18n'`). Do not import `i18next` / `react-i18next` from pages or components; ESLint rejects it. -- Namespaces: `common` (shell + shared actions), `auth` (login), `workspaces` (workspace list), `sandboxes` (sandbox details). +- Namespaces: `common` (shell + shared actions), `auth` (login), `workspaces` (workspace list), `gateway` (the Gateway page), `crossWorkspace` (the Sandboxes and Proposals pages across workspaces), `sandboxes` (sandbox details, the create forms and the Proposals tab). - Prefer nested keys (`empty.title`), not English-as-key. - Interpolation: `t('delete.toast', { name })`. - **Do not translate:** product name “OpenShell”, opaque API IDs/names, technical codes, gateway/BFF `.message` strings. diff --git a/frontend/src/i18n/catalogs.ts b/frontend/src/i18n/catalogs.ts index 05b68a38..a6b2cdf4 100644 --- a/frontend/src/i18n/catalogs.ts +++ b/frontend/src/i18n/catalogs.ts @@ -1,5 +1,7 @@ import auth from './locales/en/auth'; import common from './locales/en/common'; +import crossWorkspace from './locales/en/crossWorkspace'; +import gateway from './locales/en/gateway'; import sandboxes from './locales/en/sandboxes'; import workspaces from './locales/en/workspaces'; @@ -8,6 +10,8 @@ export const catalogs = { common, auth, workspaces, + gateway, + crossWorkspace, sandboxes, }, } as const; diff --git a/frontend/src/i18n/init.ts b/frontend/src/i18n/init.ts index 97513e47..1765df1d 100644 --- a/frontend/src/i18n/init.ts +++ b/frontend/src/i18n/init.ts @@ -4,7 +4,14 @@ import { initReactI18next } from 'react-i18next'; import { catalogs } from './catalogs'; -const NAMESPACES = ['common', 'auth', 'workspaces', 'sandboxes'] as const; +const NAMESPACES = [ + 'common', + 'auth', + 'workspaces', + 'gateway', + 'crossWorkspace', + 'sandboxes', +] as const; export const defaultI18nOptions: InitOptions = { lng: 'en', diff --git a/frontend/src/i18n/locales/en/common.ts b/frontend/src/i18n/locales/en/common.ts index 5a2c109d..94f4a47a 100644 --- a/frontend/src/i18n/locales/en/common.ts +++ b/frontend/src/i18n/locales/en/common.ts @@ -2,7 +2,13 @@ export default { nav: { gateway: 'Gateway', workspaces: 'Workspaces', - allWorkspaces: 'All workspaces', + // Providers, templates and services across every workspace — resources, + // not workspaces, which is the question "what is the difference between + // Workspaces and All workspaces?" that the old label invited. Sandboxes + // are their own entry. + allWorkspaces: 'Resources', + proposals: 'Proposals', + sandboxes: 'Sandboxes', providerProfiles: 'Provider profiles', globalPolicy: 'Global policy', settings: 'Settings', @@ -15,6 +21,8 @@ export default { themeToDark: 'Switch to dark theme', help: 'Help', about: 'About', + // What the badge beside it is the value of — same form as gatewayHealth.label. + roleLabel: 'Role:', roleAdmin: 'Platform admin', roleUser: 'Standard user', userFallback: 'User', @@ -32,6 +40,8 @@ export default { none: 'None', close: 'Close', }, + // The About dialog, from the masthead's help menu, and the masthead logo's + // alt text. about: { productName: 'OpenShell Dashboard', trademark: 'Apache-2.0 license.', @@ -44,8 +54,6 @@ export default { gatewayLine: '{{line}}.x', dashboardCommit: 'Dashboard commit', gatewayVersion: 'Gateway version', - gatewayStatus: 'Gateway status', - computeDriver: 'Compute driver', unknown: 'Unknown', }, gatewayCompatibility: { @@ -58,10 +66,14 @@ export default { }, // What the gateway's own health check says, shown in the masthead. gatewayHealth: { - healthy: 'Gateway healthy', - notHealthy: 'Gateway not healthy', - unreachable: 'Gateway unreachable', - unknown: 'Gateway status unknown', + // What the label beside it is the value of. The states are in sentence + // case: the region they sit in is announced on every change, and some + // screen readers spell out text in capitals letter by letter. + label: 'Gateway status:', + healthy: 'Healthy', + notHealthy: 'Not healthy', + unreachable: 'Unreachable', + unknown: 'Unknown', }, logs: { autoRefresh: 'Auto-refresh', @@ -94,4 +106,10 @@ export default { workspaceResults: 'Workspace results', resourceResults: '{{title}} results', }, + // The platform's provider profiles page. + providerProfiles: { + title: 'Provider profiles', + description: + 'Platform-scoped profiles for connecting sandboxes to external services, such as LLM inference backends, code repositories, or enterprise APIs. Every workspace can create providers from them and attach those providers to its sandboxes; a profile a workspace keeps itself with the same ID takes precedence there.', + }, } as const; diff --git a/frontend/src/i18n/locales/en/crossWorkspace.ts b/frontend/src/i18n/locales/en/crossWorkspace.ts new file mode 100644 index 00000000..33977a75 --- /dev/null +++ b/frontend/src/i18n/locales/en/crossWorkspace.ts @@ -0,0 +1,57 @@ +// The crossWorkspace namespace. See src/i18n/catalogs.ts. +// +// The pages that read across workspaces: every sandbox on the gateway, and +// the policy proposals waiting in the workspaces the signed-in user can read. +export default { + sandboxes: { + title: 'Sandboxes', + description: + 'Secure execution environments, across every workspace on the gateway.', + }, + proposals: { + title: 'Proposals', + description: + 'Policy rules your sandboxes have proposed, waiting for a decision. Select a sandbox to review, edit, approve or reject what it has proposed.', + tableTitle: 'Proposals', + filterPlaceholder: 'Filter by workspace or sandbox', + empty: 'No sandbox you can see has a policy rule waiting for a decision.', + // Some workspaces could not be read, so "none you can see" would say too + // much about them. + emptyPartial: + 'No sandbox in the workspaces that could be read has a policy rule waiting for a decision.', + columns: { + pending: 'Pending', + workspace: 'Workspace', + sandbox: 'Sandbox', + security: 'Security', + latest: 'Latest', + }, + unavailableLabel: 'Could not be read', + flagged: 'Flagged', + // A link's name starts with what it shows (WCAG 2.5.3), then says which + // sandbox it leads to. + pendingLinkAria_one: + '{{count}} proposal waiting in {{workspace}}/{{sandbox}}', + pendingLinkAria_other: + '{{count}} proposals waiting in {{workspace}}/{{sandbox}}', + unavailableLinkAria: + 'Could not be read: the proposals of {{workspace}}/{{sandbox}}', + disabled: { + title: 'Policy proposals are switched off', + body: 'This dashboard was started with the draft-policy feature disabled, so no proposals are read.', + }, + partial: { + title_one: 'A workspace could not be read', + title_other: '{{count}} workspaces could not be read', + body: 'No proposals are shown for {{workspaces}}. There may be some waiting there.', + retry: 'Retry', + }, + // A sandbox whose inbox the summary could not read: listed after those + // with a known count, and counted here once rather than one alert each. + unavailable: { + title_one: 'The proposals of one sandbox could not be read', + title_other: 'The proposals of {{count}} sandboxes could not be read', + body: 'They are listed last, as "Could not be read", which is not the same as none waiting. The list is read again shortly; open a sandbox to see its proposals now.', + }, + }, +} as const; diff --git a/frontend/src/i18n/locales/en/gateway.ts b/frontend/src/i18n/locales/en/gateway.ts new file mode 100644 index 00000000..11b90a85 --- /dev/null +++ b/frontend/src/i18n/locales/en/gateway.ts @@ -0,0 +1,132 @@ +// The gateway namespace: the Gateway overview page. See src/i18n/catalogs.ts. +export default { + title: 'Gateway', + description: + "The gateway's status, what is waiting for a decision, and what it runs on.", + loading: 'Loading gateway info', + loadFailed: 'Cannot reach the OpenShell gateway', + retry: 'Retry', + // The strip at the top right: the gateway's own status, both versions and + // the verdict that relates them. + facts: { + gatewayVersion: 'Gateway version: {{version}}', + dashboard: 'Dashboard: {{build}}', + dashboardFor: 'for {{line}}.x', + compatibility: 'Compatibility: {{verdict}}', + notKnown: 'Not known', + }, + compatibility: { + supported: 'Supported', + supportedLine: 'Supported ({{line}})', + unsupported: 'Not supported', + unsupportedLine: 'Not supported (built for {{line}})', + notKnown: 'Not known', + }, + attention: { + title: 'Needs attention', + ariaLabel: 'Needs attention', + allClear: 'Nothing needs attention', + loading: 'Reading what needs attention', + loadingMore: 'Still reading the rest of the gateway', + filter: { + all: 'All types', + allCount: 'All types ({{count}})', + typeCount: '{{label}} ({{count}})', + }, + // Plural, because the filter picks out a set of them and counts it. + types: { + unread: 'Lists not read', + proposals: 'Proposals', + errors: 'Sandbox errors', + setup: 'Setup', + }, + // Singular, because a badge is on one row and says what that row is. + badges: { + unread: 'Not read', + proposals: 'Proposal', + errors: 'Sandbox error', + setup: 'Setup', + }, + unread: { + sandboxes: 'Sandboxes could not be read', + providers: 'Providers could not be read', + proposals: 'Proposals could not be read', + detail: + 'What is in this list is not known, so it is not counted as nothing to do.', + }, + proposals: { + unavailable: + 'Its proposal inbox could not be read, so what is waiting in it is not known.', + pending_one: '{{count}} policy rule waiting for a decision.', + pending_other: '{{count}} policy rules waiting for a decision.', + pendingFlagged_one: + '{{count}} policy rule waiting for a decision, flagged by the prover.', + pendingFlagged_other: + '{{count}} policy rules waiting for a decision, flagged by the prover.', + more_one: '{{count}} more sandbox with proposals', + more_other: '{{count}} more sandboxes with proposals', + moreDetail: 'Every sandbox with a rule waiting is on the Proposals page.', + total_one: '{{count}} policy rule waiting for a decision', + total_other: '{{count}} policy rules waiting for a decision', + totalDetail: + 'Proposed from connections the sandboxes were denied; each takes effect only once somebody approves it.', + }, + errors: { + detail: + 'Provisioning failed, or the main process exited non-zero; the log says which.', + more_one: '{{count}} more sandbox in error', + more_other: '{{count}} more sandboxes in error', + moreDetail: 'Every sandbox on the gateway is under Sandboxes.', + }, + setup: { + noProviders: 'No providers', + noProvidersDetail: + 'Sandboxes have no credentials to reach a model or a service until one is added.', + }, + }, + getStarted: { + title: 'Get started', + intro: + 'Nothing has been created on this gateway yet. Three steps make a sandbox to work in.', + ariaLabel: 'Getting started', + workspace: 'Create a workspace', + workspaceDetail: + "A workspace holds a team's sandboxes, providers and members. The gateway starts with a workspace called default.", + provider: 'Add a provider', + providerDetail: + 'A provider holds the credentials a sandbox uses to reach a model or a service. Its type comes from a provider profile.', + sandbox: 'Create a sandbox', + sandboxDetail: + 'A sandbox is a secure execution environment, governed by the security policy it is created with. Attach the providers it may use.', + }, + drivers: { + title: 'Compute drivers', + description: + 'Pluggable components that create, inspect, and delete sandbox workloads.', + ariaLabel: 'Compute drivers', + name: 'Name', + driver: 'Driver', + version: 'Version', + empty: 'No compute drivers reported', + }, + extensions: { + title: 'Extensions', + description: + 'What the gateway negotiated at start-up with each compute driver, credential driver, gateway interceptor and supervisor middleware.', + ariaLabel: 'Extensions', + name: 'Name', + kind: 'Kind', + implementation: 'Implementation', + protocol: 'Protocol', + capabilities: 'Capabilities', + requires: 'Requires from gateway', + empty: 'No extensions reported', + kinds: { + COMPUTE_DRIVER: 'Compute driver', + CREDENTIAL_DRIVER: 'Credential driver', + GATEWAY_INTERCEPTOR: 'Gateway interceptor', + SUPERVISOR_MIDDLEWARE: 'Supervisor middleware', + UNSPECIFIED: 'Unspecified', + }, + }, +}; diff --git a/frontend/src/i18n/locales/en/sandboxes.ts b/frontend/src/i18n/locales/en/sandboxes.ts index b5873b1f..1daec847 100644 --- a/frontend/src/i18n/locales/en/sandboxes.ts +++ b/frontend/src/i18n/locales/en/sandboxes.ts @@ -33,4 +33,31 @@ export default { destinationValue: '{{host}} (port {{port}})', help: "The latest token grant the sandbox refused, for any destination. It describes the attempted call, not the destination's current health, and clears once a grant to the same destination succeeds.", }, + create: { + // The folded section of optional fields in the Create sandbox form, and + // its counterpart in Create sandbox from template, which has no + // resources. Only the name is generated when it is left empty. + optionalSection: 'Name, labels and resources', + optionalSectionFromTemplate: 'Name and labels', + nameHelper: 'A name is generated when this is left empty.', + }, + drafts: { + status: { + // The dropdown's accessible name; it names the filter, not its value. + label: 'Proposal status', + all: 'All statuses', + pending: 'Pending', + approved: 'Approved', + rejected: 'Rejected', + }, + summary: { + source: + 'An LLM-generated analysis of the activity behind these proposals.', + sourceAriaLabel: 'Where the analysis summary comes from', + }, + chunk: { + labels: 'Labels', + securityNotes: 'Security notes', + }, + }, }; diff --git a/frontend/src/i18n/locales/en/workspaces.ts b/frontend/src/i18n/locales/en/workspaces.ts index b4bbfa7c..7b2db72e 100644 --- a/frontend/src/i18n/locales/en/workspaces.ts +++ b/frontend/src/i18n/locales/en/workspaces.ts @@ -1,10 +1,17 @@ export default { title: 'Workspaces', + description: + 'Workspaces are hard isolation boundaries for sandboxes, providers, and members.', loading: 'Loading workspaces', loadFailed: 'Failed to load workspaces', empty: { title: 'No workspaces', - body: 'Workspaces are hard isolation boundaries for sandboxes, providers, and members.', + // The sentence that was here is now the page description, which the empty + // state is shown under. This one says what to do instead of repeating it. + body: 'Create one to give a team sandboxes, providers and members of its own.', + // Only a platform admin can create a workspace; anyone else is added to + // one. + bodyMember: 'Ask a platform admin to add you to a workspace.', }, create: 'Create workspace', actionsToolbar: 'Workspace actions', diff --git a/frontend/src/pages/AllSandboxesPage.tsx b/frontend/src/pages/AllSandboxesPage.tsx new file mode 100644 index 00000000..958c88ba --- /dev/null +++ b/frontend/src/pages/AllSandboxesPage.tsx @@ -0,0 +1,30 @@ +import { Content, PageSection, Title } from '@patternfly/react-core'; + +import AllSandboxList from '../components/AllSandboxList'; +import { useI18n } from '../i18n'; + +// The sandboxes of every workspace on the gateway: `openshell sandbox list +// --all-workspaces`, a page at a time. The gateway answers this for platform +// admins only, and its refusal is shown as it is. Each row names its +// workspace and leads to the workspace-scoped sandbox, which is where it is +// acted on. The list is the one the gateway pages (AllSandboxList), with its +// label and type filters. +const AllSandboxesPage: React.FC = () => { + const { t } = useI18n('crossWorkspace'); + + return ( + <> + + + {t('sandboxes.title')} + + {t('sandboxes.description')} + + + + + + ); +}; + +export default AllSandboxesPage; diff --git a/frontend/src/pages/AllWorkspacesPage.tsx b/frontend/src/pages/AllWorkspacesPage.tsx index 07ecec89..cbd652d8 100644 --- a/frontend/src/pages/AllWorkspacesPage.tsx +++ b/frontend/src/pages/AllWorkspacesPage.tsx @@ -19,7 +19,6 @@ import { useAllTemplates, } from '../api/allWorkspaces'; import { useFeatureFlags } from '../api/auth'; -import AllSandboxList from '../components/AllSandboxList'; import { ImageReference, scopedName, @@ -43,11 +42,11 @@ const TabPanel: React.FC<{ children: React.ReactNode }> = ({ children }) => (
{children}
); -// The sandboxes, providers, templates and service endpoints of every -// workspace on the gateway: `--all-workspaces` on the CLI's four lists. The -// gateway answers these for platform admins only, and its refusal is shown as -// it is. Each row names its workspace and leads to the workspace-scoped page -// for the resource, which is where it is acted on. +// The providers, templates and service endpoints of every workspace on the +// gateway: `--all-workspaces` on those three CLI lists. Sandboxes are a page +// of their own. The gateway answers these for platform admins only, and its +// refusal is shown as it is. Each row names its workspace and leads to the +// workspace-scoped page for the resource, which is where it is acted on. const AllWorkspacesPage: React.FC = ({ onSelectWorkspace, onSelectSandbox, @@ -55,7 +54,7 @@ const AllWorkspacesPage: React.FC = ({ }) => { const navigate = useNavigate(); const features = useFeatureFlags(); - const [activeTab, setActiveTab] = useState('sandboxes'); + const [activeTab, setActiveTab] = useState('providers'); const [templateSelector, setTemplateSelector] = useState(''); const providers = useAllProviders(); const templates = useAllTemplates(templateSelector || undefined); @@ -170,10 +169,12 @@ const AllWorkspacesPage: React.FC = ({ return ( <> - All workspaces + + Resources + - Sandboxes, providers, templates and services across every workspace on - the gateway. Select a row to open it in its workspace. + Providers, templates and services across every workspace on the + gateway. @@ -182,18 +183,6 @@ const AllWorkspacesPage: React.FC = ({ onSelect={(_event, key) => setActiveTab(key)} aria-label="Resources in all workspaces" > - Sandboxes} - data-testid="all-tab-sandboxes" - > - - - - ['t']; + +// The BFF's verdict on the gateway, in a few words. The notice that explains +// what an unsupported gateway does to the rest of the dashboard is the one +// the app shell raises above every page; this only states where the gateway +// sits, beside the version it is a verdict on. +const compatibilityText = ( + t: Translate, + compatibility?: GatewayCompatibility, +): string => { + const line = compatibility?.supportedLine + ? `${compatibility.supportedLine}.x` + : ''; + switch (compatibility?.status) { + case 'supported': + return line + ? t('compatibility.supportedLine', { line }) + : t('compatibility.supported'); + case 'unsupported': + return line + ? t('compatibility.unsupportedLine', { line }) + : t('compatibility.unsupported'); + default: + return t('compatibility.notKnown'); + } +}; +// The gateway's own status from GetGatewayInfo. The masthead's indicator is +// the health check, which is a yes or a no; DEGRADED is only said here. const statusColor = ( status: ServiceStatus, ): 'green' | 'orange' | 'red' | 'grey' => { @@ -55,17 +116,17 @@ const statusIcon = (status: ServiceStatus) => { } }; -const EXTENSION_KIND_LABELS: Record = { - COMPUTE_DRIVER: 'Compute driver', - CREDENTIAL_DRIVER: 'Credential driver', - GATEWAY_INTERCEPTOR: 'Gateway interceptor', - SUPERVISOR_MIDDLEWARE: 'Supervisor middleware', - UNSPECIFIED: 'Unspecified', -}; +const EXTENSION_KINDS = new Set([ + 'COMPUTE_DRIVER', + 'CREDENTIAL_DRIVER', + 'GATEWAY_INTERCEPTOR', + 'SUPERVISOR_MIDDLEWARE', + 'UNSPECIFIED', +]); // A kind this build has no name for is shown as the gateway spelled it. -const extensionKindLabel = (kind: string): string => - (EXTENSION_KIND_LABELS as Record)[kind] ?? kind; +const extensionKindLabel = (t: Translate, kind: string): string => + EXTENSION_KINDS.has(kind) ? t(`extensions.kinds.${kind}`) : kind; const CapabilityList: React.FC<{ capabilities?: string[] }> = ({ capabilities, @@ -82,18 +143,357 @@ const CapabilityList: React.FC<{ capabilities?: string[] }> = ({ <>- ); -// Gateway overview. The API exposes exactly four things about the gateway: -// status, version, the compute driver list and the extensions it negotiated -// with (GetGatewayInfoResponse) — nothing else, so this page is intentionally -// small. It is what `openshell gateway info` prints. +// How many of something there are, or undefined when that cannot be said. +// The lists across workspaces are answered for platform admins only and the +// gateway refuses everyone else (403), so a refusal, and a list that has not +// arrived, must not be treated as a zero. +const countOf = (query: { + data?: unknown[]; + isError: boolean; +}): number | undefined => + query.isError || !query.data ? undefined : query.data.length; + +// The kinds of thing that can be waiting, which are also what the card's +// filter offers. A kind with nothing in it is not offered. A list that could +// not be read leads: until it is, nothing else on the card is the whole story. +const ATTENTION_TYPES = ['unread', 'proposals', 'errors', 'setup'] as const; + +type AttentionType = (typeof ATTENTION_TYPES)[number]; + +// What a kind means, told in the badge's colour. Danger is something that has +// already gone wrong (a sandbox in error, a list the page could not read); +// warning is something that will unless it is dealt with; info — PatternFly's +// purple — is a decision waiting on a person, which is a proposal exactly. +const ATTENTION_STATUS: Record = { + unread: 'danger', + proposals: 'info', + errors: 'danger', + setup: 'warning', +}; + +// PatternFly's own status icons are the filled variants. These are the +// outline ones the masthead's health indicator uses, so one shape means one +// thing across the page. +const ATTENTION_ICON: Record = { + unread: , + proposals: , + errors: , + setup: , +}; + +// One line of Needs attention: the thing it is about, named and badged with +// its kind, a sentence saying what is wrong with it, and when it happened. +type AttentionItem = { + key: string; + type: AttentionType; + // The name of the thing, which is the link. A sandbox is named as + // workspace/sandbox rather than counted: "3 sandboxes in error" is not + // something anybody can act on without first finding out which three. + text: string; + // What the title means — a proposed rule's rationale, or why the sandbox + // is in error, or what is missing. + detail: string; + to: string; + // When it was proposed or when the error landed, for the Timestamp at the + // end of the row. For proposals this is the newest pending chunk's + // createdAt — the same instant the sandbox Proposals tab shows in its + // Proposed column. Absent on rollup lines that are not about one sandbox. + atMs?: number; +}; + +// What to say under a proposal row: the newest chunk's own rationale when +// the summary carried one, otherwise how many rules are waiting. +const proposalDetail = (t: Translate, entry: DraftSandboxSummary): string => { + if (entry.unavailable) { + return t('attention.proposals.unavailable'); + } + if (entry.latestRationale) { + return entry.latestRationale; + } + return entry.hasSecurityFlags + ? t('attention.proposals.pendingFlagged', { count: entry.pendingCount }) + : t('attention.proposals.pending', { count: entry.pendingCount }); +}; + +// ERROR means one of two things — the gateway could not provision the +// sandbox, or its main process exited non-zero (hard fact 1). A condition +// message says which when the gateway sent one; otherwise the log is where +// to look. The Ready condition that is False is the one that says why; any +// other False condition is the next best. +const errorCondition = (sandbox: Sandbox) => { + const conditions = sandbox.status.conditions ?? []; + return ( + conditions.find((c) => c.type === 'Ready' && c.status === 'False') ?? + conditions.find((c) => c.status === 'False') + ); +}; + +const errorDetail = (t: Translate, sandbox: Sandbox): string => + errorCondition(sandbox)?.message || t('attention.errors.detail'); + +// When the error condition last changed. A sandbox's creation time is not +// when it went into error, so without a transition time there is no time. +const errorAtMs = (sandbox: Sandbox): number | undefined => { + const transitioned = errorCondition(sandbox)?.lastTransitionTime; + if (!transitioned) { + return undefined; + } + const ms = Date.parse(transitioned); + return Number.isNaN(ms) ? undefined : ms; +}; + +// Newest error first; one with no known time goes last. +const byErrorTime = (a: Sandbox, b: Sandbox) => + (errorAtMs(b) ?? -Infinity) - (errorAtMs(a) ?? -Infinity) || 0; + +// How many of one kind are named before the rest become a line of their own. +// A gateway with forty sandboxes in error would otherwise be a column forty +// rows long on the page that is meant to be read first. +const ATTENTION_PER_TYPE = 4; + +// Caps one kind of item, naming what it left out rather than dropping it +// quietly. `overflow` is the line that stands for the rest. +const cappedByType = ( + items: AttentionItem[], + overflow: (hidden: number) => AttentionItem, +): AttentionItem[] => + items.length > ATTENTION_PER_TYPE + ? [ + ...items.slice(0, ATTENTION_PER_TYPE), + overflow(items.length - ATTENTION_PER_TYPE), + ] + : items; + +// A sandbox whose proposal inbox could not be read comes first: nothing is +// known about it, which is worth more than a known count. Then the ones the +// prover flagged, then the most recently proposed. The same order the +// Proposals page puts them in. +const byProposalUrgency = (a: DraftSandboxSummary, b: DraftSandboxSummary) => { + if (!!a.unavailable !== !!b.unavailable) return a.unavailable ? -1 : 1; + if (a.hasSecurityFlags !== b.hasSecurityFlags) { + return a.hasSecurityFlags ? -1 : 1; + } + return b.latestDraftMs - a.latestDraftMs; +}; + +const sandboxPath = (workspace: string, name: string, tab: string) => + `/workspaces/${encodeURIComponent(workspace)}/sandboxes/${encodeURIComponent(name)}?tab=${tab}`; + +// One row of the card that is not an item: the all-clear, or that the lists +// are still being read. The same DataList the items are in, so the card does +// not change shape between the three. +const AttentionNote: React.FC<{ + id: string; + icon: React.ReactNode; + text: string; +}> = ({ id, icon, text }) => ( + + + + + {icon} + {text} + + , + ]} + /> + + +); + +// Gateway overview — the dashboard's landing page for a platform admin. It +// answers, in this order: is the gateway well, and is anything waiting for +// me. The gateway's status, versions and compatibility sit as one small strip +// beside the title; the gateway's compute drivers and extensions stay in the +// two tables below. The API exposes no time series, no utilisation and no +// event feed, so there is nothing to chart. const GatewayOverviewPage: React.FC = () => { + const { t } = useI18n('gateway'); const gateway = useGatewayInfo(); + const compatibility = useGatewayCompatibility(); + const features = useFeatureFlags(); + // This is the page a platform admin lands on and leaves open, so it reads + // the lists across workspaces at its own, slower pace (constants.ts). + const sandboxes = useAllSandboxes(undefined, { + refetchInterval: GATEWAY_PAGE_POLL_MS, + }); + const providers = useAllProviders({ + refetchInterval: GATEWAY_PAGE_PROVIDERS_POLL_MS, + }); + // The same query the shell's Proposals badge reads, so the badge rides on + // this page's reads while it is open. + const proposals = useAllWorkspacesDraftSummary(features.draftPolicy, { + refetchInterval: GATEWAY_PAGE_POLL_MS, + }); + // Which kind of thing Needs attention is showing. Every hook runs before + // the early returns below, so this one does too. + const [attentionType, setAttentionType] = useState( + 'all', + ); + const [isTypeOpen, setTypeOpen] = useState(false); + + const sandboxCount = countOf(sandboxes); + const providerCount = countOf(providers); + // Proposals are read only where draft policies are switched on; elsewhere + // there is nothing to wait for. + const proposalsRead = features.draftPolicy; + + // A list that has not answered yet. One that failed is not loading: it is + // a row of its own below. + const isLoadingLists = + (!sandboxes.isError && !sandboxes.data) || + (!providers.isError && !providers.data) || + (proposalsRead && proposals.isLoading); + + // Nothing has been made on this gateway yet. A workspace is not the test: + // a gateway starts with `default`, so one that has only workspaces has + // still not been used for anything. + const isEmpty = sandboxCount === 0 && providerCount === 0; + + // What is worth doing something about, each row leading to where it is + // done. The gateway's own version verdict is not repeated here: it is in + // the strip above, and the app shell raises a notice above every page when + // it is a problem. + // + // After PatternFly's own dashboard events card: a status badge and a short + // title that is the link, then a sentence beside it saying what the title + // means and what is to be done about it. + const attention: AttentionItem[] = []; + // First: a list that could not be read. Whatever it holds is not known, so + // the card must not read as having nothing to do. + const unread: [string, boolean, string, unknown][] = [ + ['sandboxes', sandboxes.isError, '/sandboxes', sandboxes.error], + ['providers', providers.isError, '/all-workspaces', providers.error], + [ + 'proposals', + proposalsRead && proposals.isError, + '/proposals', + proposals.error, + ], + ]; + for (const [list, failed, to, error] of unread) { + if (failed) { + attention.push({ + key: `unread-${list}`, + type: 'unread', + text: t(`attention.unread.${list}`), + detail: + (error instanceof Error && error.message) || + t('attention.unread.detail'), + to, + }); + } + } + // Then a rule proposed for a sandbox, from a connection it was denied: the + // sandbox is held up until somebody decides on it. One line per sandbox, + // named as workspace/sandbox, because approving is done in that sandbox's + // own inbox and nowhere else (hard fact 25) — a line that only counted + // them led to a page that counts them again. + const proposalItems = + proposalsRead && !proposals.isError ? proposals.items : []; + const totalPending = + proposalsRead && !proposals.isError ? proposals.totalPending : 0; + const pendingSandboxes = [...proposalItems] + .filter((entry) => entry.unavailable || entry.pendingCount > 0) + .sort(byProposalUrgency); + attention.push( + ...cappedByType( + pendingSandboxes.map((entry) => ({ + key: `proposals-${entry.workspace}/${entry.sandboxName}`, + type: 'proposals' as const, + text: `${entry.workspace}/${entry.sandboxName}`, + detail: proposalDetail(t, entry), + to: sandboxPath(entry.workspace, entry.sandboxName, 'proposals'), + atMs: + entry.unavailable || !entry.latestDraftMs + ? undefined + : entry.latestDraftMs, + })), + (hidden) => ({ + key: 'proposals-more', + type: 'proposals' as const, + text: t('attention.proposals.more', { count: hidden }), + detail: t('attention.proposals.moreDetail'), + to: '/proposals', + }), + ), + ); + // A summary that carries a total but no sandboxes is still worth saying; + // all it can do is lead to the page that lists them. + if (pendingSandboxes.length === 0 && totalPending > 0) { + attention.push({ + key: 'proposals', + type: 'proposals', + text: t('attention.proposals.total', { count: totalPending }), + detail: t('attention.proposals.totalDetail'), + to: '/proposals', + }); + } + // A list that failed names no sandboxes, even one it held before: a row + // above says it could not be read. + const erroredSandboxes = (sandboxes.isError ? [] : (sandboxes.data ?? [])) + .filter((sandbox) => sandbox.status.phase === 'ERROR') + .sort(byErrorTime); + attention.push( + ...cappedByType( + erroredSandboxes.map((sandbox) => ({ + key: `errored-${sandbox.metadata.workspace}/${sandbox.metadata.name}`, + type: 'errors' as const, + text: `${sandbox.metadata.workspace ?? ''}/${sandbox.metadata.name}`, + detail: errorDetail(t, sandbox), + // The row leads to the log, which is where ERROR is explained when + // the condition did not carry a message. + to: sandboxPath( + sandbox.metadata.workspace ?? '', + sandbox.metadata.name, + 'logs', + ), + atMs: errorAtMs(sandbox), + })), + (hidden) => ({ + key: 'errored-more', + type: 'errors' as const, + text: t('attention.errors.more', { count: hidden }), + detail: t('attention.errors.moreDetail'), + to: '/sandboxes', + }), + ), + ); + if (!isEmpty && providerCount === 0) { + attention.push({ + key: 'no-providers', + type: 'setup', + text: t('attention.setup.noProviders'), + detail: t('attention.setup.noProvidersDetail'), + to: '/all-workspaces', + }); + } + // The kinds that are actually waiting, in the order the card lists them. + const attentionTypes = ATTENTION_TYPES.filter((type) => + attention.some((item) => item.type === type), + ); + // A filter held across a refresh can outlive the last item of its kind. + // It is shown as All types at once, and dropped, so that it does not come + // back by itself when an item of that kind next appears. + const isFilterOffered = + attentionType === 'all' || attentionTypes.includes(attentionType); + useEffect(() => { + if (!isFilterOffered) { + setAttentionType('all'); + } + }, [isFilterOffered]); if (gateway.isLoading) { return ( - + ); @@ -104,10 +504,10 @@ const GatewayOverviewPage: React.FC = () => { gateway.refetch()}> - Retry + {t('retry')} } > @@ -118,125 +518,505 @@ const GatewayOverviewPage: React.FC = () => { } const info = gateway.data; + const status: ServiceStatus = info?.status ?? 'UNSPECIFIED'; // The type marks it optional; a missing list is shown as none. const extensions = info?.extensions ?? []; + const computeDrivers = info?.computeDrivers ?? []; + + const gatewayVersion = info?.gatewayVersion || '-'; + // A build has no version number, only the line it is for and, when the + // build was told, the commit it is made from (see constants.ts). + const builtForLine = compatibility.data?.compatibility?.supportedLine; + const dashboardBuild = + [ + builtForLine ? t('facts.dashboardFor', { line: builtForLine }) : '', + BUILD_COMMIT, + ] + .filter(Boolean) + .join(' · ') || t('facts.notKnown'); + const compatibilityLabel = compatibilityText( + t, + compatibility.data?.compatibility ?? info?.compatibility, + ); + + const activeType = isFilterOffered ? attentionType : 'all'; + const shownAttention = + activeType === 'all' + ? attention + : attention.filter((item) => item.type === activeType); + return ( <> - Gateway + {/* Heading and description left; the gateway's status and versions + right. The masthead's indicator is the health check only, so + the status the gateway reports for itself is said here. */} + + + + {t('title')} + + {t('description')} + + + + + + + + + {t('facts.gatewayVersion', { version: gatewayVersion })} + + + + + {t('facts.dashboard', { build: dashboardBuild })} + + + + + {t('facts.compatibility', { verdict: compatibilityLabel })} + + + + + - - - Status - - - - - - Version - - - - Gateway version - - {info?.gatewayVersion || '-'} - - - - - - - - - - Compute drivers - - - - - - - - - - - {(info?.computeDrivers ?? []).map((driver) => ( - - - - - - ))} - {(info?.computeDrivers ?? []).length === 0 && ( - - - - )} - -
NameDriverVersion
{driver.name}{driver.driverName || '-'}{driver.driverVersion || '-'}
No compute drivers reported
-
-
-
- - - Extensions - - - - - - - - - - - - - - {extensions.map((extension) => ( - - - - - - - - - ))} - {extensions.length === 0 && ( - - - - )} - -
NameKindImplementationProtocolCapabilitiesRequires from gateway
{extension.configuredName || '-'} - {extensionKindLabel(extension.kind)} - - {[ - extension.implementationName, - extension.implementationVersion, - ] - .filter(Boolean) - .join(' ') || '-'} - - {extension.protocolMajor}.{extension.protocolMinor} - - {t('attention.title')} + + + {isLoadingLists ? ( + + } + text={t('attention.loading')} /> - - + + + } + text={t('attention.allClear')} /> -
No extensions reported
-
-
+ )} + + + + )} + + {isEmpty && ( + + + {t('getStarted.title')} + + {t('getStarted.intro')} + + {/* Done already: the gateway starts with `default`. */} + + + {t('getStarted.workspace')} + + + {/* Providers are a tab of the workspace's own page. */} + + + {t('getStarted.provider')} + + + + + {t('getStarted.sandbox')} + + + + + + + )} + {/* What the gateway negotiated at start-up, each table under a + heading of its own rather than behind a fold. There are a + handful of rows in each on any real gateway, and a count in a + toggle that has to be opened to see what it counts asks for a + click to learn what the page could have said outright. + + Each is set off by the space the page puts between its + heading and the first row of cards: a page section's bottom + padding and the next one's top padding, both a medium spacer, + which is the grid's own gutter over again. So one more of the + same on top of the gutter, and a full-width card reads as the + start of a band rather than as another card of the row. */} + + + {/* The count badged rather than bracketed, the way a count + beside a heading is written everywhere else in the + dashboard, and a sentence under it saying what is being + counted — a table of names means nothing to a reader who + does not already know what a compute driver is. */} + + + {t('drivers.title')}{' '} + + {computeDrivers.length} + + + {t('drivers.description')} + + + + + + + + + + + + {computeDrivers.map((driver) => ( + + + + + + ))} + {computeDrivers.length === 0 && ( + + + + )} + +
{t('drivers.name')}{t('drivers.driver')}{t('drivers.version')}
{driver.name} + {driver.driverName || '-'} + + {driver.driverVersion || '-'} +
{t('drivers.empty')}
+
+
+
+ + + + + {t('extensions.title')}{' '} + + {extensions.length} + + + {t('extensions.description')} + + + + + + + + + + + + + + + {extensions.map((extension) => ( + + + + + + + + + ))} + {extensions.length === 0 && ( + + + + )} + +
{t('extensions.name')}{t('extensions.kind')}{t('extensions.implementation')}{t('extensions.protocol')}{t('extensions.capabilities')}{t('extensions.requires')}
+ {extension.configuredName || '-'} + + {extensionKindLabel(t, extension.kind)} + + {[ + extension.implementationName, + extension.implementationVersion, + ] + .filter(Boolean) + .join(' ') || '-'} + + {extension.protocolMajor}.{extension.protocolMinor} + + + + +
{t('extensions.empty')}
+
+
+
+
); diff --git a/frontend/src/pages/GlobalPolicyPage.tsx b/frontend/src/pages/GlobalPolicyPage.tsx index 224f6c78..9e5543b3 100644 --- a/frontend/src/pages/GlobalPolicyPage.tsx +++ b/frontend/src/pages/GlobalPolicyPage.tsx @@ -144,7 +144,9 @@ const GlobalPolicyPage: React.FC = () => { return ( <> - Global policy + + Global policy + A gateway-global policy applies to all sandboxes in full (no merge with per-sandbox policies). This is the platform ceiling mechanism — @@ -320,6 +322,8 @@ const GlobalPolicyPage: React.FC = () => { )} + {/* Danger: a global policy replaces every sandbox's own policy + (hard fact 23, the global one wins). */} + } + > + {t('proposals.partial.body', { + workspaces: partial.join(', '), + })} +
+ )} + {unavailableCount > 0 && ( + + {t('proposals.unavailable.body')} + + )} + `${entry.workspace}/${entry.sandboxName}`} + filterText={(entry) => `${entry.workspace}/${entry.sandboxName}`} + filterPlaceholder={t('proposals.filterPlaceholder')} + emptyBody={ + partial.length > 0 + ? t('proposals.emptyPartial') + : t('proposals.empty') + } + /> + + )} +
+ + ); +}; + +export default ProposalsPage; diff --git a/frontend/src/pages/ProviderProfilesPage.tsx b/frontend/src/pages/ProviderProfilesPage.tsx index 49549cb3..6f799175 100644 --- a/frontend/src/pages/ProviderProfilesPage.tsx +++ b/frontend/src/pages/ProviderProfilesPage.tsx @@ -3,6 +3,7 @@ import { Content, PageSection, Title } from '@patternfly/react-core'; import { PLATFORM_PROFILE_SCOPE } from '../api/providers'; import { useUserRole } from '../api/rbac'; import ProfilesPanel from '../components/provider/ProfilesPanel'; +import { useI18n } from '../i18n'; // The platform's provider profiles: the ones that belong to no workspace and // that every workspace sees and can create providers from. It is the CLI's @@ -10,16 +11,14 @@ import ProfilesPanel from '../components/provider/ProfilesPanel'; // platform admins only. const ProviderProfilesPage: React.FC = () => { const { isPlatformAdmin } = useUserRole(); + const { t } = useI18n('common'); return ( <> - Provider profiles - - Platform-scoped provider profiles. Every workspace sees them and can - create providers from them; a workspace keeps profiles of its own on - its Profiles tab, and one of those takes precedence there over a - platform profile with the same ID. Platform Admin only. - + + {t('providerProfiles.title')} + + {t('providerProfiles.description')} = (props) => { draftSummary={draftSummary} policyView={policyQuery.data} providerExpiry={providerExpiry} - onReviewDrafts={() => setActiveTab('proposals')} - onViewLogs={() => setActiveTab('logs')} + // The banner sits above the tabs, so on the Proposals tab the link + // leads to the page the reader is already on. No link then: the + // count is what the banner is for, and the proposals are below it. + onReviewDrafts={ + resolvedTab === 'proposals' + ? undefined + : () => setActiveTab('proposals') + } + // The same on the Logs tab: the link would lead where the reader is. + onViewLogs={ + resolvedTab === 'logs' ? undefined : () => setActiveTab('logs') + } mode="detail" + // No padding of its own: the banner then sits the heading's bottom + // padding below the heading and the tabs' top padding above the + // tabs, which are the same, so it is centred between the two. Its + // own padding on either side added to one of those and not the + // other, which is what put it against the heading. wrapper={(children) => ( - {children} + {children} )} /> diff --git a/frontend/src/pages/SandboxListPage.tsx b/frontend/src/pages/SandboxListPage.tsx index 5b98b747..dc64b501 100644 --- a/frontend/src/pages/SandboxListPage.tsx +++ b/frontend/src/pages/SandboxListPage.tsx @@ -306,18 +306,13 @@ const SandboxListPage: React.FC = ({ const list = ( <> + {/* PatternFly's toolbar order: filters, then the view toggle as an + icon group, then the buttons, then the overflow menu, and the + pagination last. createActionPosition chooses which end of the + action group the create button sits at; it never moves past the + pagination. */} - {createActionPosition === 'start' && ( - - - - )} {toolbarStart && {toolbarStart}} } breakpoint="lg"> @@ -368,6 +363,44 @@ const SandboxListPage: React.FC = ({ /> + {!compactToolbar && ( + + + } + aria-label="List view" + isSelected={viewMode === 'list'} + onChange={() => { + setViewMode('list'); + localStorage.setItem(VIEW_MODE_KEY, 'list'); + }} + data-testid="view-toggle-list" + /> + } + aria-label="Card view" + isSelected={viewMode === 'cards'} + onChange={() => { + setViewMode('cards'); + localStorage.setItem(VIEW_MODE_KEY, 'cards'); + }} + data-testid="view-toggle-cards" + /> + + + )} + {createActionPosition === 'start' && ( + + + + )} {!compactToolbar && ( = ({ )} - {!compactToolbar && ( - - - } - aria-label="List view" - isSelected={viewMode === 'list'} - onChange={() => { - setViewMode('list'); - localStorage.setItem(VIEW_MODE_KEY, 'list'); - }} - data-testid="view-toggle-list" - /> - } - aria-label="Card view" - isSelected={viewMode === 'cards'} - onChange={() => { - setViewMode('cards'); - localStorage.setItem(VIEW_MODE_KEY, 'cards'); - }} - data-testid="view-toggle-cards" - /> - - - )} {createActionPosition === 'end' && (