From 67fbe4e37b5345c243e27466209dbd326a82aeb4 Mon Sep 17 00:00:00 2001 From: Daniel-Warner-X Date: Mon, 19 Oct 2026 08:29:53 -0500 Subject: [PATCH 01/20] feat(ui): a cohesive redesign around the admin's core flows Daniel Warner's prototype (Daniel-Warner-X/dw-openshell-dashboard, feat/ui-cohesive-redesign at 5979b03), carried onto main as one commit: - Gateway becomes a dashboard: a Needs attention card (pending proposals and errored sandboxes, filterable by kind) leads, the gateway's facts sit in one line under the title, drivers and extensions below. - A Proposals page and a Sandboxes page across every workspace. - The sandbox Proposals tab is calmer: info banner, outline bulk actions, a status dropdown, the security flag kept on the collapsed row. - GET /draft-summary?allWorkspaces=true and the newest chunk's rationale in each sandbox's summary, for the Needs attention rows. - A mock API (make dev-mock) for UI work without a gateway. Left out: the prototyping toolchain (skill submodules and links, .mcp.json). Brought up to main: the build line and commit replace the old app version on the Gateway page, and the About dialog stays, read from the compatibility route so the shell still never asks for gateway info. Co-Authored-By: Claude Opus 5.5 --- Makefile | 14 +- backend/pkg/handlers/drafts_handler.go | 39 +- .../handlers/drafts_summary_handler_test.go | 81 +- backend/pkg/models/observability.go | 9 +- frontend/mock/data.ts | 668 +++++++++++++ frontend/mock/mockPlugin.ts | 239 +++++ frontend/src/api/allWorkspaces.ts | 8 +- frontend/src/api/draftSummary.ts | 85 +- frontend/src/api/policy.ts | 6 +- frontend/src/api/queryKeys.ts | 4 + frontend/src/api/settings.ts | 6 +- frontend/src/app/App.tsx | 17 +- frontend/src/app/AppLayout.tsx | 181 +++- frontend/src/app/AuthenticatedRoutes.tsx | 40 +- .../AppLayout.gatewayHealth.spec.tsx | 22 +- .../__tests__/AppLayout.navigation.spec.tsx | 132 ++- frontend/src/app/__tests__/AppLayout.spec.tsx | 62 +- .../__tests__/AuthenticatedRoutes.spec.tsx | 49 +- .../__tests__/ProviderProfilesRoute.spec.tsx | 19 +- frontend/src/app/__tests__/theme.spec.tsx | 82 ++ frontend/src/app/theme.ts | 39 - frontend/src/app/theme.tsx | 61 ++ .../CreateSandboxFromTemplateModal.tsx | 128 +-- .../src/components/CreateSandboxModal.tsx | 241 ++--- .../src/components/GatewayStatusIndicator.tsx | 71 +- .../PolicyRuleEditor/PolicyDocumentEditor.tsx | 3 + frontend/src/components/RoleBadge.tsx | 24 +- .../SandboxDraftsTab/DraftChunkRow.tsx | 85 +- .../__tests__/SandboxDraftsTab.spec.tsx | 89 +- .../src/components/SandboxDraftsTab/index.tsx | 146 ++- .../src/components/SandboxDraftsTab/utils.ts | 31 +- .../CreateSandboxFromTemplateModal.spec.tsx | 16 + .../__tests__/CreateSandboxModal.spec.tsx | 39 + .../__tests__/GatewayStatusIndicator.spec.tsx | 35 +- .../SandboxAttention.render.spec.tsx | 85 ++ .../__tests__/SandboxAttention.spec.ts | 7 +- .../__tests__/SandboxTerminalTab.spec.tsx | 3 + .../components/policy/PolicyDocumentInput.tsx | 3 + .../components/sandbox/SandboxAttention.tsx | 85 +- .../src/components/sandbox/SandboxLogsTab.tsx | 6 +- .../components/sandbox/SandboxTerminalTab.tsx | 40 +- .../__tests__/useCreateSandboxForm.spec.tsx | 64 ++ frontend/src/hooks/useCreateSandboxForm.ts | 15 + frontend/src/i18n/locales/en/common.ts | 38 +- frontend/src/i18n/locales/en/workspaces.ts | 6 +- frontend/src/pages/AllSandboxesPage.tsx | 145 +++ frontend/src/pages/AllWorkspacesPage.tsx | 33 +- frontend/src/pages/GatewayOverviewPage.tsx | 898 +++++++++++++++--- frontend/src/pages/GlobalPolicyPage.tsx | 6 +- frontend/src/pages/ProposalsPage.tsx | 207 ++++ frontend/src/pages/ProviderProfilesPage.tsx | 11 +- frontend/src/pages/SandboxDetailPage.tsx | 16 +- frontend/src/pages/SandboxListPage.tsx | 81 +- frontend/src/pages/SettingsPage.tsx | 4 +- frontend/src/pages/WorkspaceListPage.tsx | 36 +- .../pages/__tests__/AllSandboxesPage.spec.tsx | 273 ++++++ .../__tests__/AllWorkspacesPage.spec.tsx | 288 +----- .../__tests__/GatewayOverviewPage.spec.tsx | 388 +++++++- .../pages/__tests__/ProposalsPage.spec.tsx | 238 +++++ .../__tests__/ProviderProfilesPage.spec.tsx | 2 +- .../__tests__/SandboxDetailPage.spec.tsx | 27 +- frontend/src/pages/index.ts | 1 + frontend/src/types/index.ts | 4 +- frontend/vite.config.ts | 6 +- mock-api-setup.md | 63 ++ 65 files changed, 4797 insertions(+), 1053 deletions(-) create mode 100644 frontend/mock/data.ts create mode 100644 frontend/mock/mockPlugin.ts create mode 100644 frontend/src/app/__tests__/theme.spec.tsx delete mode 100644 frontend/src/app/theme.ts create mode 100644 frontend/src/app/theme.tsx create mode 100644 frontend/src/components/__tests__/SandboxAttention.render.spec.tsx create mode 100644 frontend/src/hooks/__tests__/useCreateSandboxForm.spec.tsx create mode 100644 frontend/src/pages/AllSandboxesPage.tsx create mode 100644 frontend/src/pages/ProposalsPage.tsx create mode 100644 frontend/src/pages/__tests__/AllSandboxesPage.spec.tsx create mode 100644 frontend/src/pages/__tests__/ProposalsPage.spec.tsx create mode 100644 mock-api-setup.md diff --git a/Makefile b/Makefile index 05603caa..61b89b60 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 mock-api-setup.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..b05cec73 100644 --- a/backend/pkg/handlers/drafts_handler.go +++ b/backend/pkg/handlers/drafts_handler.go @@ -88,12 +88,23 @@ 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 the Proposals +// page asks 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 +113,16 @@ 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. +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 @@ -216,8 +230,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..f952331f 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) { diff --git a/backend/pkg/models/observability.go b/backend/pkg/models/observability.go index 0d761971..a1562270 100644 --- a/backend/pkg/models/observability.go +++ b/backend/pkg/models/observability.go @@ -128,8 +128,9 @@ 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 @@ -140,7 +141,9 @@ type DraftSandboxSummary struct { LatestDraftMs int64 `json:"latestDraftMs"` PendingCount int `json:"pendingCount"` HasSecurityFlags bool `json:"hasSecurityFlags"` - Unavailable bool `json:"unavailable,omitempty"` + // The rationale of the newest pending chunk, when it has one. + LatestRationale string `json:"latestRationale,omitempty"` + Unavailable bool `json:"unavailable,omitempty"` } // DraftSummary is the pending draft chunks of several sandboxes at once. No diff --git a/frontend/mock/data.ts b/frontend/mock/data.ts new file mode 100644 index 00000000..7e1bf1ed --- /dev/null +++ b/frontend/mock/data.ts @@ -0,0 +1,668 @@ +// Mock data for every API surface the dashboard reads. +// Timestamps are relative to "now" so the UI always looks fresh. + +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 = { + authDisabled: true, + adminRole: 'admin', + features: { + terminal: true, + fileTransfer: true, + settings: true, + globalPolicy: true, + credentialRefresh: true, + services: true, + draftPolicy: true, + }, +}; + +export const currentUser = { + subject: 'dev-user', + displayName: 'Platform Admin', + email: 'admin@openshell.dev', + roles: ['admin', 'user'], + scopes: ['openid', 'profile', 'email'], +}; + +// --------------------------------------------------------------------------- +// Gateway +// --------------------------------------------------------------------------- + +export const 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', + supportedMin: '0.1.0', + supportedMax: '0.1.2', + }, +}; + +export const gatewayCompatibility = { + gatewayVersion: '0.1.2', + healthy: true, + compatibility: { + status: 'supported', + supportedMin: '0.1.0', + supportedMax: '0.1.2', + }, +}; + +// --------------------------------------------------------------------------- +// 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 = [ + { 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) => [ + { + 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 = [ + { + 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', tls: 'REQUIRE' }], + 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', tls: 'REQUIRE' }], + 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', tls: 'REQUIRE' }], + 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', tls: 'REQUIRE' }], + 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', tls: 'REQUIRE' }], + inferenceCapable: false, + source: 'user', + scope: 'platform', + resourceVersion: 1, + }, +]; + +// --------------------------------------------------------------------------- +// Sandboxes +// --------------------------------------------------------------------------- + +const mkSandbox = ( + name: string, + workspace: string, + phase: string, + opts: { + image?: string; + labels?: Record; + createdAgo?: number; + providers?: string[]; + exitCode?: number; + policyVersion?: number; + } = {}, +) => ({ + 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', tls: 'REQUIRE' }], + }, + }, + }, + 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) => [ + { 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) => ({ + logs: mkLogLines(sandbox), + bufferTotal: 10, +}); + +// --------------------------------------------------------------------------- +// Sandbox policy +// --------------------------------------------------------------------------- + +export const sandboxPolicy = (workspace: string, _name: string) => ({ + 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', tls: 'REQUIRE', enforcement: 'ENFORCE', access: 'ALLOW' }, + ], + }, + ...(workspace === 'ml-training' + ? { + 'nvidia-nim': { + name: 'nvidia-nim', + endpoints: [ + { host: 'integrate.api.nvidia.com', port: 443, protocol: 'HTTPS', tls: 'REQUIRE', enforcement: 'ENFORCE', access: 'ALLOW' }, + ], + }, + } + : {}), + }, + }, + }, + revisions: [ + { version: 1, policyHash: 'abc123def456', status: 'LOADED', createdAtMs: now - days(1), loadedAtMs: now - days(1) + 2000 }, + ], +}); + +// --------------------------------------------------------------------------- +// Effective policy +// --------------------------------------------------------------------------- + +export const effectivePolicy = { + policy: { + version: 1, + networkPolicies: { + openai: { + name: 'openai', + endpoints: [ + { host: 'api.openai.com', port: 443, protocol: 'HTTPS', tls: 'REQUIRE', enforcement: 'ENFORCE', access: 'ALLOW' }, + ], + }, + }, + }, + version: 1, + policyHash: 'abc123def456', + policySource: 'SANDBOX', +}; + +// --------------------------------------------------------------------------- +// Global policy +// --------------------------------------------------------------------------- + +export const globalPolicy = { + activeVersion: 0, + revisions: [], +}; + +// --------------------------------------------------------------------------- +// Draft policy +// --------------------------------------------------------------------------- + +export const draftPolicy = { + chunks: [ + { + id: 'draft-001', + status: 'pending', + ruleName: 'pypi-access', + proposedRule: { + name: 'pypi-access', + endpoints: [{ host: 'pypi.org', port: 443, protocol: 'HTTPS', tls: 'REQUIRE' }], + }, + 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', + status: 'pending', + ruleName: 'huggingface-hub', + proposedRule: { + name: 'huggingface-hub', + endpoints: [{ host: 'huggingface.co', port: 443, protocol: 'HTTPS', tls: 'REQUIRE' }], + }, + 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 = { + 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: 1, + hasSecurityFlags: true, + latestDraftMs: now - minutes(10), + latestRationale: + 'The sandbox attempted to install packages from PyPI. Allowing access to pypi.org enables pip install.', + }, + ], + totalPending: 3, +}; + +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: 1, + hasSecurityFlags: true, + latestDraftMs: now - minutes(10), + latestRationale: + 'The sandbox attempted to install packages from PyPI. Allowing access to pypi.org enables pip install.', + }, + ], + totalPending: 1, + }, + 'dev-sandbox': { sandboxes: [], totalPending: 0 }, + staging: { sandboxes: [], totalPending: 0 }, +}; + +// --------------------------------------------------------------------------- +// Gateway settings +// --------------------------------------------------------------------------- + +export const globalSettings = { + 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): object[] => { + const sbx = (sandboxes[workspace] ?? []).find( + (s: any) => s.metadata.name === sandbox, + ) as any; + if (!sbx) return []; + const ws = providers[workspace] ?? []; + return ws.filter((p: any) => (sbx.spec.providers ?? []).includes(p.metadata.name)); +}; diff --git a/frontend/mock/mockPlugin.ts b/frontend/mock/mockPlugin.ts new file mode 100644 index 00000000..6bbd322c --- /dev/null +++ b/frontend/mock/mockPlugin.ts @@ -0,0 +1,239 @@ +// 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 as any[]).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 as any[]).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, sandbox }) => + data.sandboxPolicy(workspace, sandbox)], + ['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: any) => s.sandboxName === sandbox)], + + // Sandbox drafts + ['GET /api/v1/workspaces/:workspace/sandboxes/:sandbox/drafts', () => + 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 as any[]).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; +}; + +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), + 'Access-Control-Allow-Origin': '*', + }); + 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, body); + }); + }, + }; +} diff --git a/frontend/src/api/allWorkspaces.ts b/frontend/src/api/allWorkspaces.ts index 04c1f7e0..282698cd 100644 --- a/frontend/src/api/allWorkspaces.ts +++ b/frontend/src/api/allWorkspaces.ts @@ -60,12 +60,18 @@ 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` is for the shell, which counts sandboxes on every page and must +// not ask a user the gateway will refuse. The Sandboxes page leaves it on. +export const useAllSandboxes = ( + labelSelector?: string, + options: { enabled?: boolean } = {}, +) => useQuery({ queryKey: allWorkspacesKeys.sandboxes(labelSelector), queryFn: () => listAllSandboxes(labelSelector), refetchInterval: SANDBOX_POLL_MS, placeholderData: keepPreviousData, + enabled: options.enabled ?? true, }); export const useAllProviders = () => diff --git a/frontend/src/api/draftSummary.ts b/frontend/src/api/draftSummary.ts index f247b633..b986fb8f 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,84 @@ 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'); + +export const useAllWorkspacesDraftSummary = (enabled = true) => { + const query = useQuery({ + queryKey: allWorkspacesKeys.draftSummary, + queryFn: getAllWorkspacesDraftSummary, + 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: when each query last produced data, and whether + // it is in error. + const fingerprint = queries + .map((query) => `${query.dataUpdatedAt}:${query.isError}`) + .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, + // Every workspace is read at once, so the list is worth showing once + // the first of them has answered. + isLoading: queries.length > 0 && queries.every((q) => q.isLoading), + // 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, workspaces.join(',')]); +}; diff --git a/frontend/src/api/policy.ts b/frontend/src/api/policy.ts index 2284664e..28d6c9b7 100644 --- a/frontend/src/api/policy.ts +++ b/frontend/src/api/policy.ts @@ -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 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/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..10b4516c 100644 --- a/frontend/src/app/AppLayout.tsx +++ b/frontend/src/app/AppLayout.tsx @@ -1,7 +1,8 @@ -import { useState } from 'react'; +import { Fragment, useState } from 'react'; import { BUILD_COMMIT } 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,33 @@ 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 gateway's cross-workspace + // summary is the one shape of this read that is a single request. 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 uses, so an admin who is on it is not polling it twice. + const { totalPending: pendingProposals } = useAllWorkspacesDraftSummary( + features.draftPolicy && isPlatformAdmin, + ); + // 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, + }); + 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 + // everyone: the gateway answers each user with the workspaces they are in. + const { data: workspaces } = useWorkspaces(); + const workspaceCount = workspaces?.length ?? 0; + const masthead = ( = ({ children, sessionAlert }) => { toggle={(toggleRef) => ( setUserOpen(!isUserOpen)} isExpanded={isUserOpen} data-testid="current-user" @@ -267,28 +338,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 +472,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..6171e15a 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,15 @@ jest.mock('../../api/rbac', () => ({ isUnentitled: mockIsUnentitled, }), })); +jest.mock('../../api/draftSummary', () => ({ + useAllWorkspacesDraftSummary: () => ({ 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 +56,13 @@ jest.mock('~/assets/openshell-logo-dark.svg', () => ({ default: 'openshell-logo-dark.svg', })); +import { useAllSandboxes } from '../../api/allWorkspaces'; +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'], - ['Provider profiles', '/provider-profiles'], + ['Sandboxes', '/sandboxes'], + ['Proposals', '/proposals'], + ['Resources', '/all-workspaces'], + ['Providers', '/provider-profiles'], ['Global policy', '/global-policy'], ['Settings', '/settings'], ]); + expect(mockUseAllSandboxes).toHaveBeenCalledWith(undefined, { + enabled: true, + }); + }); + + // 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, + }); + }); + + // 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'); + }); + + 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'); + }); + + 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 +276,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..6483cedc 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'; @@ -79,7 +94,7 @@ describe('the provider profiles page', () => { renderAt('/provider-profiles'); const link = within(nav()).getByRole('link', { - name: 'Provider profiles', + name: 'Providers', hidden: true, }); expect(link).toHaveAttribute('href', '/provider-profiles'); @@ -93,7 +108,7 @@ describe('the provider profiles page', () => { expect( within(nav()).queryByRole('link', { - name: 'Provider profiles', + name: 'Providers', hidden: true, }), ).not.toBeInTheDocument(); diff --git a/frontend/src/app/__tests__/theme.spec.tsx b/frontend/src/app/__tests__/theme.spec.tsx new file mode 100644 index 00000000..c09eb216 --- /dev/null +++ b/frontend/src/app/__tests__/theme.spec.tsx @@ -0,0 +1,82 @@ +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'); + }); + + 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..670e9c7b --- /dev/null +++ b/frontend/src/app/theme.tsx @@ -0,0 +1,61 @@ +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'; + +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; +}; + +type ThemeContextValue = { theme: Theme; toggleTheme: () => void }; + +// Light by default, so a surface rendered outside the provider — in a test, +// or embedded under ADR 0001 by a consumer that mounts one page on its own — +// is a readable page rather than a dark slab on a white one. +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); + window.localStorage.setItem(THEME_STORAGE_KEY, next); + setTheme(next); + }; + + return ( + + {children} + + ); +}; diff --git a/frontend/src/components/CreateSandboxFromTemplateModal.tsx b/frontend/src/components/CreateSandboxFromTemplateModal.tsx index f264db57..d67dba86 100644 --- a/frontend/src/components/CreateSandboxFromTemplateModal.tsx +++ b/frontend/src/components/CreateSandboxFromTemplateModal.tsx @@ -59,6 +59,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(); @@ -105,6 +106,7 @@ const CreateSandboxFromTemplateModal: React.FC< setSelectedProviders([]); setPolicyExpanded(false); setLabelsText(''); + setGeneratedExpanded(false); setAnnotationRows([]); setAdvancedExpanded(false); launch.reset(); @@ -197,62 +199,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) + } + /> + )) + )} + + {/* What the gateway fills in for a form that leaves it alone, as + in the Create sandbox form. It opens by itself around a field + that needs correcting. */} + setGeneratedExpanded(expanded)} + data-testid="from-template-generated-expand" + > + + + 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'} + + + + + + = ({ 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 +213,98 @@ const CreateSandboxModal: React.FC = ({ )) )} - - - - form.setGpuCount(value)} - placeholder="GPUs (e.g. 1)" - validated={form.gpuInvalid ? 'error' : 'default'} - aria-label="GPU count" - /> - - + {/* What the gateway fills in for a form that leaves it alone. It + is folded away so that the form opens on the three things a + sandbox is made of, and opens by itself around a field that + needs correcting. */} + 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" /> - - + + 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..482e4da1 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, @@ -21,7 +22,12 @@ import { import { ExpandableRowContent, Tbody, Td, Tr } from '@patternfly/react-table'; import { formatTimestamp } from '../../utils/formatters'; -import { approvalAnnotation, chunkStatusColor, chunkStatusIcon } from './utils'; +import { + approvalAnnotation, + approveOutlineStyle, + chunkStatusColor, + chunkStatusIcon, +} from './utils'; import type { useDraftActions } from './useDraftActions'; import type { PolicyChunk } from '../../types'; @@ -88,26 +94,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,8 +153,9 @@ const DraftChunkRow: React.FC = ({ {canApprove && ( + ), + }, + { + title: 'Workspace', + modifier: 'nowrap', + render: (sandbox) => ( + + ), + }, + { + title: 'Status', + render: (sandbox) => ( + + ), + }, + { + title: 'Image', + modifier: 'truncate', + render: (sandbox) => , + }, + { + title: 'Labels', + render: (sandbox) => ( + + ), + }, + { + title: 'Age', + render: (sandbox) => formatAge(sandbox.metadata.createdAtMs), + }, + ]; + + return ( + <> + + + Sandboxes + + + Secure execution environments for agents and tools, across every + workspace on the gateway. + + + + + + + ); +}; + +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" - > - - - - { - switch (status) { - case 'HEALTHY': - return 'green'; - case 'DEGRADED': - return 'orange'; - case 'UNHEALTHY': - return 'red'; +import { useAllProviders, useAllSandboxes } from '../api/allWorkspaces'; +import { useFeatureFlags } from '../api/auth'; +import { useAllWorkspacesDraftSummary } from '../api/draftSummary'; +import { useGatewayCompatibility, useGatewayInfo } from '../api/gateway'; +import { BUILD_COMMIT } from '../constants'; +import type { + DraftSandboxSummary, + GatewayCompatibility, + GatewayExtensionKind, + Sandbox, +} from '../types'; +// 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 = (compatibility?: GatewayCompatibility): string => { + const line = compatibility?.supportedLine + ? `${compatibility.supportedLine}.x` + : ''; + switch (compatibility?.status) { + case 'supported': + return line ? `Supported (${line})` : 'Supported'; + case 'unsupported': + return line ? `Not supported (built for ${line})` : 'Not supported'; default: - return 'grey'; - } -}; - -const statusIcon = (status: ServiceStatus) => { - switch (status) { - case 'HEALTHY': - return ; - case 'DEGRADED': - return ; - case 'UNHEALTHY': - return ; - default: - return ; + return 'Not known'; } }; @@ -82,12 +97,171 @@ 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 three 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. +const ATTENTION_TYPES = ['proposals', 'errors', 'setup'] as const; + +type AttentionType = (typeof ATTENTION_TYPES)[number]; + +// Plural, because the filter picks out a set of them and counts it. +const ATTENTION_TYPE_LABEL: Record = { + proposals: 'Proposals', + errors: 'Sandbox errors', + setup: 'Setup', +}; + +// Singular, because a badge is on one row and says what that row is. +const ATTENTION_BADGE_LABEL: Record = { + proposals: 'Proposal', + errors: 'Sandbox error', + setup: 'Setup', +}; + +// What a kind means, told in the badge's colour. Danger is something that has +// already gone wrong; 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. Nothing has gone wrong with a sandbox whose agent asked +// for a rule, and a card read before anything has is the wrong place to say +// otherwise. +const ATTENTION_STATUS: Record = { + 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 = { + 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 = (entry: DraftSandboxSummary): string => { + if (entry.unavailable) { + return 'Its proposal inbox could not be read, so what is waiting in it is not known.'; + } + if (entry.latestRationale) { + return entry.latestRationale; + } + return `${entry.pendingCount} policy ${ + entry.pendingCount === 1 ? 'rule' : 'rules' + } waiting for a decision${ + entry.hasSecurityFlags ? ', flagged by the prover' : '' + }.`; +}; + +// 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. +const errorCondition = (sandbox: Sandbox) => + sandbox.status.conditions?.find((c) => c.status === 'False' || c.reason); + +const errorDetail = (sandbox: Sandbox): string => + errorCondition(sandbox)?.message || + 'Provisioning failed, or the main process exited non-zero; the log says which.'; + +// Prefer when the error condition last changed; fall back to when the +// sandbox was created. +const errorAtMs = (sandbox: Sandbox): number | undefined => { + const transitioned = errorCondition(sandbox)?.lastTransitionTime; + if (transitioned) { + const ms = Date.parse(transitioned); + if (!Number.isNaN(ms)) { + return ms; + } + } + return sandbox.metadata.createdAtMs || undefined; +}; + +// 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}`; + +// 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. Versions and compatibility sit as one small line under the title; the +// gateway's compute drivers and extensions stay in the two tables a fold +// below. The API exposes no time series, no utilisation and no event feed, +// so there is nothing to chart. const GatewayOverviewPage: React.FC = () => { const gateway = useGatewayInfo(); + const compatibility = useGatewayCompatibility(); + const features = useFeatureFlags(); + const sandboxes = useAllSandboxes(); + const providers = useAllProviders(); + // The same query the shell's Proposals badge reads, so this costs the + // gateway nothing beyond what the badge already asks for. + const proposals = useAllWorkspacesDraftSummary(features.draftPolicy); + // 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); if (gateway.isLoading) { return ( @@ -120,123 +294,549 @@ const GatewayOverviewPage: React.FC = () => { const info = gateway.data; // The type marks it optional; a missing list is shown as none. const extensions = info?.extensions ?? []; + + const sandboxCount = countOf(sandboxes); + const providerCount = countOf(providers); + 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 ? `for ${builtForLine}.x` : '', BUILD_COMMIT] + .filter(Boolean) + .join(' · ') || 'Not known'; + const compatibilityLabel = compatibilityText( + compatibility.data?.compatibility ?? info?.compatibility, + ); + + // 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; + + const erroredSandboxes = (sandboxes.data ?? []).filter( + (sandbox) => sandbox.status.phase === 'ERROR', + ); + // 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 rule an agent proposed is a sandbox 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 pendingSandboxes = [...proposals.items] + .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(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: `${hidden} more ${hidden === 1 ? 'sandbox' : 'sandboxes'} with proposals`, + detail: 'Every sandbox with a rule waiting is on the Proposals page.', + 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 && proposals.totalPending > 0) { + attention.push({ + key: 'proposals', + type: 'proposals', + text: `${proposals.totalPending} policy ${ + proposals.totalPending === 1 ? 'rule' : 'rules' + } waiting for a decision`, + detail: `An agent proposed ${ + proposals.totalPending === 1 ? 'it' : 'them' + }. Each takes effect only once somebody approves it.`, + to: '/proposals', + }); + } + 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(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: `${hidden} more ${hidden === 1 ? 'sandbox' : 'sandboxes'} in error`, + detail: 'Every sandbox on the gateway is under Sandboxes.', + to: '/sandboxes', + }), + ), + ); + if (!isEmpty && providerCount === 0) { + attention.push({ + key: 'no-providers', + type: 'setup', + text: 'No providers', + detail: + 'Sandboxes have no credentials to reach a model or a service until one is added.', + 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. + const activeType = + attentionType !== 'all' && attentionTypes.includes(attentionType) + ? attentionType + : 'all'; + const shownAttention = + activeType === 'all' + ? attention + : attention.filter((item) => item.type === activeType); + return ( <> - Gateway - - - - - 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
-
-
+ {/* Heading and description left; versions right. The gateway's health + is in the masthead on every page, so these are only the facts + that are not. */} + + + + Gateway + + Admin view of status and alerts. + + + + + + Gateway version: {gatewayVersion} + + + + + Dashboard: {dashboardBuild} + + + + + Compatibility: {compatibilityLabel} + + + + +
- - 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} - - - - -
No extensions reported
-
-
+ {/* One grid for the whole page, so the blocks are separated by a + gutter rather than by a page section's padding each. Needs + attention leads: is anything waiting. */} + + {/* Either what is waiting, or that nothing is. */} + + {attention.length > 0 ? ( + // A card of its own rather than an inline alert. An alert's + // amber bar shouted the whole column at a page that is read + // before anything has gone wrong; the colour belongs on the + // one line it is about. + + {/* The filter sits across from the heading, where the + PatternFly dashboard card puts it: plain text with a + caret, not a bordered control competing with the + heading. It offers only the kinds that are waiting, so + choosing one can never empty the card. */} + { + setAttentionType(value as AttentionType | 'all'); + setTypeOpen(false); + }} + toggle={(toggleRef) => ( + setTypeOpen(!isTypeOpen)} + data-testid="gateway-attention-type" + > + {activeType === 'all' + ? 'All types' + : ATTENTION_TYPE_LABEL[activeType]} + + )} + popperProps={{ position: 'end' }} + > + + + {`All types (${attention.length})`} + + {attentionTypes.map((type) => ( + + {`${ATTENTION_TYPE_LABEL[type]} (${ + attention.filter((item) => item.type === type) + .length + })`} + + ))} + + + ), + }} + > + + Needs attention{' '} + {/* How many rows are under the heading, badged the way the + two full-width cards below badge theirs. It counts what + is shown rather than what is waiting, so that it agrees + with the list beneath it once a kind is picked. */} + + {shownAttention.length} + + + + {/* A row is one line: badge, the name as the link, the + sentence saying what is wrong with it, and how long ago + on the right — after PatternFly's own events card. */} + + + {shownAttention.map((item) => ( + + + + + + + + + + {item.text} + + + + + {item.detail} + + + + , + + {/* Same control the sandbox Proposals tab + uses for its Proposed column, so a row + here and a row there read as one clock. */} + {item.atMs ? ( + + ) : ( + + - + + )} + , + ]} + /> + + + ))} + + + + ) : ( + // The same card, built the same way, so that the row does + // not change shape when the last thing waiting is dealt with. + + Needs attention + + + + + + + + + + + + + Nothing needs attention + + + , + ]} + /> + + + + + + )} + + {isEmpty && ( + + + Get started + + + Nothing has been created on this gateway yet. Three steps + make a sandbox an agent can work in. + + + + Create a workspace + + + Add a provider + + + Create a 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. */} + + + Compute drivers{' '} + + {(info?.computeDrivers ?? []).length} + + + + Pluggable components that create, inspect, and delete sandbox + workloads. + + + + + + + + + + + + + {(info?.computeDrivers ?? []).map((driver) => ( + + + + + + ))} + {(info?.computeDrivers ?? []).length === 0 && ( + + + + )} + +
NameDriverVersion
{driver.name}{driver.driverName || '-'} + {driver.driverVersion || '-'} +
No compute drivers reported
+
+
+
+ + + + + Extensions{' '} + + {extensions.length} + + + + Specialized components designed to add deployment-specific + behavior to the runtime while preserving core security + boundaries and policy controls. + + + + + + + + + + + + + + + + {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} + + + + +
No extensions reported
+
+
+
+
); diff --git a/frontend/src/pages/GlobalPolicyPage.tsx b/frontend/src/pages/GlobalPolicyPage.tsx index 224f6c78..e160afc4 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 — @@ -321,7 +323,7 @@ const GlobalPolicyPage: React.FC = () => { + } + > + No proposals are shown for {partial.join(', ')}. There may be + some waiting there. + + )} + `${entry.workspace}/${entry.sandboxName}`} + filterText={(entry) => `${entry.workspace}/${entry.sandboxName}`} + filterPlaceholder="Filter by workspace or sandbox" + emptyBody="No sandbox you can see has a policy rule waiting for a decision." + /> + + )} + + + ); +}; + +export default ProposalsPage; diff --git a/frontend/src/pages/ProviderProfilesPage.tsx b/frontend/src/pages/ProviderProfilesPage.tsx index 49549cb3..ac790b07 100644 --- a/frontend/src/pages/ProviderProfilesPage.tsx +++ b/frontend/src/pages/ProviderProfilesPage.tsx @@ -13,12 +13,13 @@ const ProviderProfilesPage: React.FC = () => { return ( <> - Provider profiles + + Providers + - 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. + Platform-scoped profiles to connect an AI agent sandbox to external + services—such as LLM inference backends, code repositories, or + enterprise APIs. diff --git a/frontend/src/pages/SandboxDetailPage.tsx b/frontend/src/pages/SandboxDetailPage.tsx index 16c0be2f..86f38546 100644 --- a/frontend/src/pages/SandboxDetailPage.tsx +++ b/frontend/src/pages/SandboxDetailPage.tsx @@ -283,11 +283,23 @@ const SandboxDetailPage: React.FC = (props) => { draftSummary={draftSummary} policyView={policyQuery.data} providerExpiry={providerExpiry} - onReviewDrafts={() => setActiveTab('proposals')} + // 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') + } onViewLogs={() => 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..664cab04 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, as `.claude/rules/react.md` sets it out: + 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' && ( - } - > - {(gateway.error as Error).message} - - - ); - } - - const info = gateway.data; - // The type marks it optional; a missing list is shown as none. - const extensions = info?.extensions ?? []; - const sandboxCount = countOf(sandboxes); const providerCount = countOf(providers); - 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 ? `for ${builtForLine}.x` : '', BUILD_COMMIT] - .filter(Boolean) - .join(' · ') || 'Not known'; - const compatibilityLabel = compatibilityText( - compatibility.data?.compatibility ?? info?.compatibility, - ); + // 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; - const erroredSandboxes = (sandboxes.data ?? []).filter( - (sandbox) => sandbox.status.phase === 'ERROR', - ); // 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 @@ -338,12 +365,41 @@ const GatewayOverviewPage: React.FC = () => { // 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 rule an agent proposed is a sandbox 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 pendingSandboxes = [...proposals.items] + // 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( @@ -352,7 +408,7 @@ const GatewayOverviewPage: React.FC = () => { key: `proposals-${entry.workspace}/${entry.sandboxName}`, type: 'proposals' as const, text: `${entry.workspace}/${entry.sandboxName}`, - detail: proposalDetail(entry), + detail: proposalDetail(t, entry), to: sandboxPath(entry.workspace, entry.sandboxName, 'proposals'), atMs: entry.unavailable || !entry.latestDraftMs @@ -362,34 +418,35 @@ const GatewayOverviewPage: React.FC = () => { (hidden) => ({ key: 'proposals-more', type: 'proposals' as const, - text: `${hidden} more ${hidden === 1 ? 'sandbox' : 'sandboxes'} with proposals`, - detail: 'Every sandbox with a rule waiting is on the Proposals page.', + 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 && proposals.totalPending > 0) { + if (pendingSandboxes.length === 0 && totalPending > 0) { attention.push({ key: 'proposals', type: 'proposals', - text: `${proposals.totalPending} policy ${ - proposals.totalPending === 1 ? 'rule' : 'rules' - } waiting for a decision`, - detail: `An agent proposed ${ - proposals.totalPending === 1 ? 'it' : 'them' - }. Each takes effect only once somebody approves it.`, + 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(sandbox), + 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( @@ -402,8 +459,8 @@ const GatewayOverviewPage: React.FC = () => { (hidden) => ({ key: 'errored-more', type: 'errors' as const, - text: `${hidden} more ${hidden === 1 ? 'sandbox' : 'sandboxes'} in error`, - detail: 'Every sandbox on the gateway is under Sandboxes.', + text: t('attention.errors.more', { count: hidden }), + detail: t('attention.errors.moreDetail'), to: '/sandboxes', }), ), @@ -412,9 +469,8 @@ const GatewayOverviewPage: React.FC = () => { attention.push({ key: 'no-providers', type: 'setup', - text: 'No providers', - detail: - 'Sandboxes have no credentials to reach a model or a service until one is added.', + text: t('attention.setup.noProviders'), + detail: t('attention.setup.noProvidersDetail'), to: '/all-workspaces', }); } @@ -423,10 +479,67 @@ const GatewayOverviewPage: React.FC = () => { attention.some((item) => item.type === type), ); // A filter held across a refresh can outlive the last item of its kind. - const activeType = - attentionType !== 'all' && attentionTypes.includes(attentionType) - ? attentionType - : 'all'; + // 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 ( + + + + + + ); + } + + if (gateway.isError) { + return ( + + gateway.refetch()}> + {t('retry')} + + } + > + {(gateway.error as Error).message} + + + ); + } + + 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 @@ -435,9 +548,9 @@ const GatewayOverviewPage: React.FC = () => { return ( <> - {/* Heading and description left; versions right. The gateway's health - is in the masthead on every page, so these are only the facts - that are not. */} + {/* 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. */} { > - Gateway + {t('title')} - Admin view of status and alerts. + {t('description')} + + + - Gateway version: {gatewayVersion} + {t('facts.gatewayVersion', { version: gatewayVersion })} - Dashboard: {dashboardBuild} + {t('facts.dashboard', { build: dashboardBuild })} - Compatibility: {compatibilityLabel} + {t('facts.compatibility', { verdict: compatibilityLabel })} @@ -481,7 +603,8 @@ const GatewayOverviewPage: React.FC = () => { gutter rather than by a page section's padding each. Needs attention leads: is anything waiting. */} - {/* Either what is waiting, or that nothing is. */} + {/* What is waiting; or that the lists are still being read; or, + once every list has answered, that nothing is. */} {attention.length > 0 ? ( // A card of its own rather than an inline alert. An alert's @@ -515,8 +638,8 @@ const GatewayOverviewPage: React.FC = () => { data-testid="gateway-attention-type" > {activeType === 'all' - ? 'All types' - : ATTENTION_TYPE_LABEL[activeType]} + ? t('attention.filter.all') + : t(`attention.types.${activeType}`)} )} popperProps={{ position: 'end' }} @@ -526,19 +649,25 @@ const GatewayOverviewPage: React.FC = () => { key="all" value="all" isSelected={activeType === 'all'} + data-testid="gateway-attention-type-all" > - {`All types (${attention.length})`} + {t('attention.filter.allCount', { + count: attention.length, + })} {attentionTypes.map((type) => ( - {`${ATTENTION_TYPE_LABEL[type]} (${ - attention.filter((item) => item.type === type) - .length - })`} + {t('attention.filter.typeCount', { + label: t(`attention.types.${type}`), + count: attention.filter( + (item) => item.type === type, + ).length, + })} ))} @@ -547,7 +676,7 @@ const GatewayOverviewPage: React.FC = () => { }} > - Needs attention{' '} + {t('attention.title')}{' '} {/* How many rows are under the heading, badged the way the two full-width cards below badge theirs. It counts what is shown rather than what is waiting, so that it agrees @@ -558,11 +687,12 @@ const GatewayOverviewPage: React.FC = () => { {/* A row is one line: badge, the name as the link, the - sentence saying what is wrong with it, and how long ago - on the right — after PatternFly's own events card. */} + sentence saying what is wrong with it, and when it + happened (date and time) on the right — after + PatternFly's own events card. */} @@ -594,11 +724,15 @@ const GatewayOverviewPage: React.FC = () => { // the quiet. variant="outline" > - {ATTENTION_BADGE_LABEL[item.type]} + {t(`attention.badges.${item.type}`)} - + {item.text} @@ -636,44 +770,66 @@ const GatewayOverviewPage: React.FC = () => { ))} + {/* What is listed is not all there is while a list is + still being read. */} + {isLoadingLists && activeType === 'all' && ( + + } + text={t('attention.loadingMore')} + /> + )} ) : ( // The same card, built the same way, so that the row does // not change shape when the last thing waiting is dealt with. - - Needs attention + // It says nothing needs attention only once every list has + // answered: a list still being read could hold anything. + + {t('attention.title')} - - - - - - - - - - - Nothing needs attention - - - , - ]} - /> - - + {isLoadingLists ? ( + + } + text={t('attention.loading')} + /> + ) : ( + + + + } + text={t('attention.allClear')} + /> + )} @@ -682,37 +838,54 @@ const GatewayOverviewPage: React.FC = () => { {isEmpty && ( - Get started + {t('getStarted.title')} - - Nothing has been created on this gateway yet. Three steps - make a sandbox an agent can work in. - - + {t('getStarted.intro')} + + {/* Done already: the gateway starts with `default`. */} - Create a workspace + + {t('getStarted.workspace')} + + {/* Providers are a tab of the workspace's own page. */} - Add a provider + + {t('getStarted.provider')} + - Create a sandbox + + {t('getStarted.sandbox')} + @@ -740,38 +913,37 @@ const GatewayOverviewPage: React.FC = () => { does not already know what a compute driver is. */} - Compute drivers{' '} + {t('drivers.title')}{' '} - {(info?.computeDrivers ?? []).length} + {computeDrivers.length} - - Pluggable components that create, inspect, and delete sandbox - workloads. - + {t('drivers.description')} - +
- - - + + + - {(info?.computeDrivers ?? []).map((driver) => ( + {computeDrivers.map((driver) => ( - - - + + ))} - {(info?.computeDrivers ?? []).length === 0 && ( + {computeDrivers.length === 0 && ( - + )} @@ -783,39 +955,35 @@ const GatewayOverviewPage: React.FC = () => { - Extensions{' '} + {t('extensions.title')}{' '} {extensions.length} - - Specialized components designed to add deployment-specific - behavior to the runtime while preserving core security - boundaries and policy controls. - + {t('extensions.description')} -
NameDriverVersion{t('drivers.name')}{t('drivers.driver')}{t('drivers.version')}
{driver.name}{driver.driverName || '-'} + {driver.name} + {driver.driverName || '-'} + {driver.driverVersion || '-'}
No compute drivers reported{t('drivers.empty')}
+
- - - - - - + + + + + + {extensions.map((extension) => ( - - - - - - - + )} diff --git a/frontend/src/pages/__tests__/GatewayOverviewPage.spec.tsx b/frontend/src/pages/__tests__/GatewayOverviewPage.spec.tsx index b02c5f58..a65858f1 100644 --- a/frontend/src/pages/__tests__/GatewayOverviewPage.spec.tsx +++ b/frontend/src/pages/__tests__/GatewayOverviewPage.spec.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { fireEvent, render, screen, within } from '@testing-library/react'; import { MemoryRouter } from 'react-router-dom'; import GatewayOverviewPage from '../GatewayOverviewPage'; -import type { GatewayInfo, Sandbox } from '../../types'; +import type { GatewayInfo, Sandbox, SandboxCondition } from '../../types'; jest.mock('../../api/gateway', () => ({ useGatewayInfo: jest.fn(), @@ -12,17 +12,11 @@ jest.mock('../../api/allWorkspaces', () => ({ useAllSandboxes: jest.fn(), useAllProviders: jest.fn(), })); -jest.mock('../../api/policy', () => ({ useGlobalPolicy: jest.fn() })); -jest.mock('../../api/settings', () => ({ useGlobalSettings: jest.fn() })); jest.mock('../../api/draftSummary', () => ({ useAllWorkspacesDraftSummary: jest.fn(), })); jest.mock('../../api/auth', () => ({ - useFeatureFlags: () => ({ - globalPolicy: true, - settings: true, - draftPolicy: true, - }), + useFeatureFlags: () => ({ draftPolicy: true }), })); import { useAllProviders, useAllSandboxes } from '../../api/allWorkspaces'; @@ -32,15 +26,11 @@ import { GATEWAY_PAGE_POLL_MS, GATEWAY_PAGE_PROVIDERS_POLL_MS, } from '../../constants'; -import { useGlobalPolicy } from '../../api/policy'; -import { useGlobalSettings } from '../../api/settings'; const mockUseGatewayInfo = useGatewayInfo as jest.Mock; const mockUseGatewayCompatibility = useGatewayCompatibility as jest.Mock; const mockUseAllSandboxes = useAllSandboxes as jest.Mock; const mockUseAllProviders = useAllProviders as jest.Mock; -const mockUseGlobalPolicy = useGlobalPolicy as jest.Mock; -const mockUseGlobalSettings = useGlobalSettings as jest.Mock; const mockUseDraftSummary = useAllWorkspacesDraftSummary as jest.Mock; const answered = (data: unknown) => ({ @@ -49,12 +39,31 @@ const answered = (data: unknown) => ({ data, }); +const loading = () => ({ isLoading: true, isError: false, data: undefined }); + +const refused = (message: string) => ({ + isLoading: false, + isError: true, + data: undefined, + error: new Error(message), +}); + +const proposalsAnswered = ( + items: unknown[] = [], + totalPending = 0, +): Record => ({ + ...answered(undefined), + items, + totalPending, +}); + const minutesAgo = (minutes: number) => Date.now() - minutes * 60_000; const sandbox = ( name: string, phase: Sandbox['status']['phase'], - createdAtMs = minutesAgo(45), + conditions?: SandboxCondition[], + createdAtMs = minutesAgo(60 * 24 * 30), ): Sandbox => ({ metadata: { id: name, @@ -64,39 +73,53 @@ const sandbox = ( resourceVersion: 1, }, spec: { image: 'example.com/base:latest' }, - status: { phase, currentPolicyVersion: 1 }, + status: { phase, currentPolicyVersion: 1, conditions }, }); +// A sandbox in error whose Ready condition went False at `atMs`. +const erroredAt = (name: string, atMs: number, message?: string): Sandbox => + sandbox(name, 'ERROR', [ + { + type: 'Ready', + status: 'False', + reason: 'ProcessExited', + message, + lastTransitionTime: new Date(atMs).toISOString(), + }, + ]); + +const healthy = () => + mockUseGatewayInfo.mockReturnValue( + answered({ status: 'HEALTHY', gatewayVersion: '0.1.2' }), + ); + // Everything the page reads besides the gateway's own info, answered. Each // test overrides what it is about. const answerEverythingElse = () => { mockUseGatewayCompatibility.mockReturnValue(answered(undefined)); - mockUseAllSandboxes.mockReturnValue(answered([sandbox('agent', 'READY')])); + mockUseAllSandboxes.mockReturnValue(answered([sandbox('worker', 'READY')])); mockUseAllProviders.mockReturnValue(answered([{}])); - mockUseGlobalPolicy.mockReturnValue(answered({ activeVersion: 0 })); - mockUseGlobalSettings.mockReturnValue(answered({ settings: [] })); - mockUseDraftSummary.mockReturnValue({ - ...answered(undefined), - items: [], - totalPending: 0, - }); + mockUseDraftSummary.mockReturnValue(proposalsAnswered()); }; -const renderPage = () => - render( - - - , - ); +// A new element each time, so that a rerender renders the page again rather +// than bailing out on the same element. +const page = () => ( + + + +); + +const renderPage = () => render(page()); describe('GatewayOverviewPage', () => { // Fixed so Timestamp datetime assertions do not drift between the fixture // and the expectation. const quietProposedAt = minutesAgo(10); const flaggedProposedAt = minutesAgo(5); - const builderCreatedAt = minutesAgo(45); + const builderErroredAt = minutesAgo(45); beforeEach(() => { jest.clearAllMocks(); @@ -117,9 +140,7 @@ describe('GatewayOverviewPage', () => { // workspaces costs more the more sandboxes there are, so it reads at its // own pace, not the Sandboxes and Proposals pages'. it('reads the lists across workspaces at its own pace', () => { - mockUseGatewayInfo.mockReturnValue( - answered({ status: 'HEALTHY', gatewayVersion: '0.1.2' }), - ); + healthy(); renderPage(); expect(mockUseAllSandboxes).toHaveBeenCalledWith(undefined, { @@ -148,9 +169,10 @@ describe('GatewayOverviewPage', () => { expect(screen.getByText('Retry')).toBeInTheDocument(); }); - // One strip, not a card each: both versions and the verdict that relates - // them. The gateway's health is the masthead's, on every page. - it('states both versions and the verdict together, and not the health', () => { + // One strip: the status the gateway reports for itself, both versions and + // the verdict that relates them. The masthead's indicator is the health + // check only, so DEGRADED would be said nowhere else. + it('states the status, both versions and the verdict together', () => { const info: GatewayInfo = { status: 'HEALTHY', gatewayVersion: '0.1.2', @@ -170,10 +192,12 @@ describe('GatewayOverviewPage', () => { ); renderPage(); - const strip = screen.getByTestId('gateway-status-card'); - expect(within(strip).queryByText('HEALTHY')).not.toBeInTheDocument(); + const strip = screen.getByTestId('gateway-facts'); + expect(within(strip).getByTestId('gateway-status')).toHaveTextContent( + 'HEALTHY', + ); expect(strip).toHaveTextContent( - 'Gateway version: 0.1.2Dashboard: for 0.1.xCompatibility: Supported (0.1.x)', + 'HEALTHYGateway version: 0.1.2Dashboard: for 0.1.xCompatibility: Supported (0.1.x)', ); expect(within(strip).getByTestId('gateway-version')).toHaveTextContent( 'Gateway version: 0.1.2', @@ -186,115 +210,307 @@ describe('GatewayOverviewPage', () => { ).toHaveTextContent('Compatibility: Supported (0.1.x)'); }); - it('says so when there is no verdict to report', () => { + it('shows a degraded gateway as degraded', () => { mockUseGatewayInfo.mockReturnValue( - answered({ status: 'HEALTHY', gatewayVersion: '0.1.2' }), + answered({ status: 'DEGRADED', gatewayVersion: '0.1.2' }), ); renderPage(); + const status = screen.getByTestId('gateway-status'); + expect(status).toHaveTextContent('DEGRADED'); + expect(status).toHaveClass('pf-m-orange'); + }); + + it('says so when there is no verdict to report', () => { + healthy(); + renderPage(); + expect(screen.getByTestId('gateway-compatibility')).toHaveTextContent( 'Not known', ); }); // A gateway starts with a `default` workspace, so having workspaces is not - // having been used. + // having been used, and the first step is already done. it('walks through getting started on an empty gateway', () => { - mockUseGatewayInfo.mockReturnValue( - answered({ status: 'HEALTHY', gatewayVersion: '0.1.2' }), - ); + healthy(); mockUseAllSandboxes.mockReturnValue(answered([])); mockUseAllProviders.mockReturnValue(answered([])); renderPage(); const started = within(screen.getByTestId('gateway-get-started')); - expect(started.getByText('Create a workspace')).toBeInTheDocument(); - expect(started.getByText('Add a provider')).toBeInTheDocument(); - expect(started.getByText('Create a sandbox')).toBeInTheDocument(); + expect( + started.getByRole('heading', { name: 'Get started' }), + ).toBeInTheDocument(); + expect(started.getByTestId('get-started-workspace-link')).toHaveAttribute( + 'href', + '/workspaces', + ); + expect( + started.getByText(/starts with a workspace called default/), + ).toBeInTheDocument(); + expect(started.getByTestId('get-started-provider-link')).toHaveAttribute( + 'href', + '/workspaces/default', + ); + expect(started.getByTestId('get-started-sandbox-link')).toHaveAttribute( + 'href', + '/workspaces/default', + ); + // The workspace step is done; adding a provider is the current one. + expect( + started + .getByTestId('get-started-workspace-link') + .closest('.pf-v6-c-progress-stepper__step'), + ).toHaveClass('pf-m-success'); + expect( + started + .getByTestId('get-started-provider-link') + .closest('.pf-v6-c-progress-stepper__step'), + ).toHaveClass('pf-m-current'); + // Nothing about an agent: there is none. + expect(screen.getByTestId('gateway-get-started')).not.toHaveTextContent( + /agent/i, + ); }); - it('raises what is worth doing something about, and nothing when all is well', () => { - mockUseGatewayInfo.mockReturnValue( - answered({ status: 'HEALTHY', gatewayVersion: '0.1.2' }), - ); + // Counts that are not known are not zeros. + it('does not offer getting started while the counts are not known', () => { + healthy(); + mockUseAllSandboxes.mockReturnValue(loading()); + mockUseAllProviders.mockReturnValue(answered([])); renderPage(); + + expect(screen.queryByTestId('gateway-get-started')).not.toBeInTheDocument(); + }); + + it('says nothing needs attention once every list has answered', () => { + healthy(); + renderPage(); + expect(screen.queryByTestId('gateway-attention')).not.toBeInTheDocument(); - // The row under the versions says so rather than going blank. expect(screen.getByTestId('gateway-all-clear')).toHaveTextContent( 'Nothing needs attention', ); + }); + + // A list still being read could hold anything, so the card says it is + // reading rather than that nothing is waiting. + it.each([ + ['sandboxes', () => mockUseAllSandboxes.mockReturnValue(loading())], + ['providers', () => mockUseAllProviders.mockReturnValue(loading())], + [ + 'proposals', + () => + mockUseDraftSummary.mockReturnValue({ + ...proposalsAnswered(), + isLoading: true, + }), + ], + ])('shows a loading state while the %s are read', (_list, arrange) => { + healthy(); + arrange(); + renderPage(); + expect(screen.queryByTestId('gateway-all-clear')).not.toBeInTheDocument(); + expect( + screen.queryByText('Nothing needs attention'), + ).not.toBeInTheDocument(); + expect(screen.getByTestId('gateway-attention-loading')).toHaveAttribute( + 'aria-label', + 'Reading what needs attention', + ); + }); + + it('says the rest is still being read beside what is already known', () => { + healthy(); + mockUseAllSandboxes.mockReturnValue( + answered([erroredAt('builder', builderErroredAt)]), + ); + mockUseAllProviders.mockReturnValue(loading()); + renderPage(); + + const card = within(screen.getByTestId('gateway-attention')); + expect(card.getByText('team-a/builder')).toBeInTheDocument(); + expect(card.getByTestId('gateway-attention-loading')).toBeInTheDocument(); + expect(card.getByTestId('gateway-attention-count')).toHaveTextContent('1'); + }); + + // A refusal (403 for anybody but a platform admin) or an unavailable BFF + // is never shown as nothing to do. + it.each([ + ['sandboxes', 'Sandboxes could not be read', '/sandboxes'], + ['providers', 'Providers could not be read', '/all-workspaces'], + ['proposals', 'Proposals could not be read', '/proposals'], + ])('raises a row when the %s cannot be read', (list, text, href) => { + healthy(); + if (list === 'sandboxes') { + mockUseAllSandboxes.mockReturnValue(refused('forbidden')); + } else if (list === 'providers') { + mockUseAllProviders.mockReturnValue(refused('forbidden')); + } else { + mockUseDraftSummary.mockReturnValue({ + ...refused('forbidden'), + items: [], + totalPending: 0, + }); + } + renderPage(); + + expect(screen.queryByTestId('gateway-all-clear')).not.toBeInTheDocument(); + const card = within(screen.getByTestId('gateway-attention')); + expect(card.getByTestId(`unread-${list}-link`)).toHaveTextContent(text); + expect(card.getByTestId(`unread-${list}-link`)).toHaveAttribute( + 'href', + href, + ); + expect(card.getByText('forbidden')).toBeInTheDocument(); + expect(card.getByText('Not read')).toBeInTheDocument(); + }); + + it('raises a sandbox in error and a gateway with no providers', () => { + healthy(); mockUseAllSandboxes.mockReturnValue( answered([ - sandbox('agent', 'READY'), - sandbox('builder', 'ERROR', builderCreatedAt), + sandbox('worker', 'READY'), + erroredAt('builder', builderErroredAt), ]), ); mockUseAllProviders.mockReturnValue(answered([])); renderPage(); - const attention = within(screen.getAllByTestId('gateway-attention')[0]); + const attention = within(screen.getByTestId('gateway-attention')); // The sandbox in error is named as workspace/sandbox and leads to its // own log, which is the only thing that says why it is in error. A count // would have led to a list the reader then has to search. - expect(attention.getByText('team-a/builder')).toHaveAttribute( - 'href', - '/workspaces/team-a/sandboxes/builder?tab=logs', - ); + expect( + attention.getByTestId('errored-team-a/builder-link'), + ).toHaveAttribute('href', '/workspaces/team-a/sandboxes/builder?tab=logs'); expect(attention.getByText(/Provisioning failed/)).toBeInTheDocument(); + // When the error landed is when the condition last changed. expect( attention.getByTestId('errored-team-a/builder-age').querySelector('time'), - ).toHaveAttribute('datetime', new Date(builderCreatedAt).toISOString()); + ).toHaveAttribute('datetime', new Date(builderErroredAt).toISOString()); // The badge beside it says which of the card's kinds it is. expect(attention.getByText('Sandbox error')).toBeInTheDocument(); - expect( - attention.getByText(/^No providers/, { selector: 'a' }), - ).toBeInTheDocument(); + expect(attention.getByTestId('no-providers-link')).toHaveTextContent( + 'No providers', + ); expect( attention.getByText(/no credentials to reach a model/), ).toBeInTheDocument(); expect(attention.getByTestId('no-providers-age')).toHaveTextContent('-'); - // The second render is the one with something waiting; the all-clear - // panel belongs to the first, which is still mounted beside it. - expect(screen.getAllByTestId('gateway-all-clear')).toHaveLength(1); + expect(screen.queryByTestId('gateway-all-clear')).not.toBeInTheDocument(); }); - // A rule an agent proposed is a sandbox held up until somebody decides on - // it, so it leads the list, and each one leads to the inbox where that + // The sandbox's creation time is not when it went into error. + it('shows no time for an error without a transition time', () => { + healthy(); + mockUseAllSandboxes.mockReturnValue( + answered([sandbox('builder', 'ERROR')]), + ); + renderPage(); + + const age = screen.getByTestId('errored-team-a/builder-age'); + expect(age.querySelector('time')).toBeNull(); + expect(age).toHaveTextContent('-'); + }); + + // The Ready condition that is False says why; a True condition that merely + // carries a reason does not. + it('explains the error with the False Ready condition', () => { + healthy(); + mockUseAllSandboxes.mockReturnValue( + answered([ + sandbox('builder', 'ERROR', [ + { + type: 'Scheduled', + status: 'True', + reason: 'Scheduled', + message: 'Placed on a node', + lastTransitionTime: new Date(minutesAgo(90)).toISOString(), + }, + { + type: 'Ready', + status: 'False', + reason: 'ImagePullFailed', + message: 'image example.com/base:latest not found', + lastTransitionTime: new Date(builderErroredAt).toISOString(), + }, + ]), + ]), + ); + renderPage(); + + const card = within(screen.getByTestId('gateway-attention')); + expect( + card.getByText('image example.com/base:latest not found'), + ).toBeInTheDocument(); + expect(card.queryByText('Placed on a node')).not.toBeInTheDocument(); + expect( + card.getByTestId('errored-team-a/builder-age').querySelector('time'), + ).toHaveAttribute('datetime', new Date(builderErroredAt).toISOString()); + }); + + // The newest four errors are named; the rest are one line leading to the + // list of every sandbox. + it('names the newest errors first and counts the rest', () => { + healthy(); + const ages = [50, 10, 30, 5, 40, 20]; + mockUseAllSandboxes.mockReturnValue( + answered(ages.map((age) => erroredAt(`s-${age}`, minutesAgo(age)))), + ); + renderPage(); + + const card = within(screen.getByTestId('gateway-attention')); + expect(card.getAllByRole('link').map((link) => link.textContent)).toEqual([ + 'team-a/s-5', + 'team-a/s-10', + 'team-a/s-20', + 'team-a/s-30', + '2 more sandboxes in error', + ]); + expect(card.getByTestId('errored-more-link')).toHaveAttribute( + 'href', + '/sandboxes', + ); + }); + + // A rule proposed for a sandbox holds it up until somebody decides on it, + // so it leads the list, and each one leads to the inbox where that // decision is made — approving binds a chunk to the review token of the // reviewer's own last read of it (hard fact 25). it('names every sandbox with rules waiting, ahead of the errors', () => { - mockUseGatewayInfo.mockReturnValue( - answered({ status: 'HEALTHY', gatewayVersion: '0.1.2' }), + healthy(); + mockUseDraftSummary.mockReturnValue( + proposalsAnswered( + [ + { + workspace: 'team-a', + sandboxName: 'quiet', + pendingCount: 1, + hasSecurityFlags: false, + // Same instant the sandbox Proposals tab shows in Proposed. + latestDraftMs: quietProposedAt, + latestRationale: + 'The sandbox attempted to install packages from PyPI. Allowing access to pypi.org enables pip install.', + }, + { + workspace: 'team-b', + sandboxName: 'flagged', + pendingCount: 2, + hasSecurityFlags: true, + latestDraftMs: flaggedProposedAt, + latestRationale: 'Model weights download from Hugging Face Hub.', + }, + ], + 3, + ), ); - mockUseDraftSummary.mockReturnValue({ - ...answered(undefined), - items: [ - { - workspace: 'team-a', - sandboxName: 'quiet', - pendingCount: 1, - hasSecurityFlags: false, - // Same instant the sandbox Proposals tab shows in Proposed. - latestDraftMs: quietProposedAt, - latestRationale: - 'The sandbox attempted to install packages from PyPI. Allowing access to pypi.org enables pip install.', - }, - { - workspace: 'team-b', - sandboxName: 'flagged', - pendingCount: 2, - hasSecurityFlags: true, - latestDraftMs: flaggedProposedAt, - latestRationale: 'Model weights download from Hugging Face Hub.', - }, - ], - totalPending: 3, - }); mockUseAllSandboxes.mockReturnValue( answered([ - sandbox('agent', 'READY'), - sandbox('builder', 'ERROR', builderCreatedAt), + sandbox('worker', 'READY'), + erroredAt('builder', builderErroredAt), ]), ); renderPage(); @@ -325,42 +541,70 @@ describe('GatewayOverviewPage', () => { expect( card.getByTestId('proposals-team-a/quiet-age').querySelector('time'), ).toHaveAttribute('datetime', new Date(quietProposedAt).toISOString()); - expect( - card.getByTestId('errored-team-a/builder-age').querySelector('time'), - ).toHaveAttribute('datetime', new Date(builderCreatedAt).toISOString()); // Each row is badged with its kind: the two proposals, then the error. expect(card.getAllByText('Proposal')).toHaveLength(2); expect(card.getByText('Sandbox error')).toBeInTheDocument(); }); + // An inbox that could not be read leads: what is in it is not known. + it('raises a sandbox whose proposal inbox could not be read', () => { + healthy(); + mockUseDraftSummary.mockReturnValue( + proposalsAnswered([ + { + workspace: 'team-a', + sandboxName: 'counted', + pendingCount: 2, + hasSecurityFlags: false, + latestDraftMs: quietProposedAt, + }, + { + workspace: 'team-b', + sandboxName: 'unread', + pendingCount: 0, + hasSecurityFlags: false, + latestDraftMs: 0, + unavailable: true, + }, + ]), + ); + renderPage(); + + const card = within(screen.getByTestId('gateway-attention')); + const rows = card.getAllByRole('link'); + expect(rows[0]).toHaveTextContent('team-b/unread'); + expect( + card.getByText(/Its proposal inbox could not be read/), + ).toBeInTheDocument(); + expect(card.getByTestId('proposals-team-b/unread-age')).toHaveTextContent( + '-', + ); + expect(rows[1]).toHaveTextContent('team-a/counted'); + expect( + card.getByText('2 policy rules waiting for a decision.'), + ).toBeInTheDocument(); + }); + // A summary that carries a total but no sandboxes still says so, and all // it can do is lead to the page that lists them. it('falls back to the total when no sandbox is named', () => { - mockUseGatewayInfo.mockReturnValue( - answered({ status: 'HEALTHY', gatewayVersion: '0.1.2' }), - ); - mockUseDraftSummary.mockReturnValue({ - ...answered(undefined), - items: [], - totalPending: 3, - }); + healthy(); + mockUseDraftSummary.mockReturnValue(proposalsAnswered([], 3)); renderPage(); - const rows = within(screen.getByTestId('gateway-attention')).getAllByRole( - 'link', - ); + const card = screen.getByTestId('gateway-attention'); + const rows = within(card).getAllByRole('link'); expect(rows[0]).toHaveTextContent('3 policy rules waiting for a decision'); expect(rows[0]).toHaveAttribute('href', '/proposals'); + expect(card).not.toHaveTextContent(/agent/i); }); // Only the kinds that are waiting are offered, and choosing one leaves the // other kinds' lines out. it('filters the card by the kind of thing waiting', () => { - mockUseGatewayInfo.mockReturnValue( - answered({ status: 'HEALTHY', gatewayVersion: '0.1.2' }), - ); + healthy(); mockUseAllSandboxes.mockReturnValue( - answered([sandbox('agent', 'READY'), sandbox('builder', 'ERROR')]), + answered([sandbox('worker', 'READY'), sandbox('builder', 'ERROR')]), ); mockUseAllProviders.mockReturnValue(answered([])); renderPage(); @@ -375,8 +619,13 @@ describe('GatewayOverviewPage', () => { fireEvent.click(card.getByTestId('gateway-attention-type')); const menu = within(screen.getByRole('menu')); // Nothing is proposed, so Proposals is not offered. - expect(menu.queryByText(/^Proposals/)).not.toBeInTheDocument(); + expect( + screen.queryByTestId('gateway-attention-type-proposals'), + ).not.toBeInTheDocument(); expect(menu.getByText('All types (2)')).toBeInTheDocument(); + expect( + screen.getByTestId('gateway-attention-type-errors'), + ).toHaveTextContent('Sandbox errors (1)'); fireEvent.click(menu.getByText('Sandbox errors (1)')); expect(card.getByText('team-a/builder')).toBeInTheDocument(); @@ -384,6 +633,50 @@ describe('GatewayOverviewPage', () => { expect(card.getByTestId('gateway-attention-count')).toHaveTextContent('1'); }); + // A filter whose kind has gone is dropped, not kept to come back by itself + // the next time something of that kind turns up. + it('drops a filter whose kind is no longer waiting', () => { + healthy(); + mockUseAllSandboxes.mockReturnValue( + answered([sandbox('builder', 'ERROR')]), + ); + mockUseAllProviders.mockReturnValue(answered([])); + const { rerender } = renderPage(); + + fireEvent.click(screen.getByTestId('gateway-attention-type')); + // The test id is on the item; the menu item inside it is what is chosen. + fireEvent.click( + within(screen.getByTestId('gateway-attention-type-errors')).getByRole( + 'menuitem', + ), + ); + expect(screen.getByTestId('gateway-attention-type')).toHaveTextContent( + 'Sandbox errors', + ); + + // The error is dealt with: only the missing providers are left. + mockUseAllSandboxes.mockReturnValue( + answered([sandbox('builder', 'READY')]), + ); + rerender(page()); + expect(screen.getByTestId('gateway-attention-type')).toHaveTextContent( + 'All types', + ); + + // A new error does not bring the old filter back. + mockUseAllSandboxes.mockReturnValue( + answered([sandbox('builder', 'ERROR')]), + ); + rerender(page()); + expect(screen.getByTestId('gateway-attention-type')).toHaveTextContent( + 'All types', + ); + expect(screen.getByTestId('no-providers-link')).toBeInTheDocument(); + expect(screen.getByTestId('gateway-attention-count')).toHaveTextContent( + '2', + ); + }); + it('displays compute drivers table', () => { const info: GatewayInfo = { status: 'HEALTHY', From de671b0dd67c44a3dd919c5ba3d4f291910811e0 Mon Sep 17 00:00:00 2001 From: Gage Krumbach Date: Mon, 19 Oct 2026 12:51:18 -0500 Subject: [PATCH 11/20] fix(ui): refresh every proposal count after a decision Approving, rejecting or editing a proposal refreshed the sandbox's inbox but no summary, so the nav count, the Gateway page and the Proposals page went on showing it until their next poll. Decisions now invalidate every workspace's summary and the one across workspaces. Also from review of the prototype: - The Proposals page waits for every workspace's read before it says nothing is waiting, and counts the sandboxes it could not read in one alert after the ones it could. - The Sandboxes page links with Link and encoded paths instead of buttons that navigate, and shares the workspace-name helpers and ImageReference with All workspaces instead of copying them. - No agent in the copy; text through the crossWorkspace catalog. Co-Authored-By: Claude Opus 5.5 --- .../api/__tests__/draft-decisions.spec.tsx | 147 ++++++++++++++++++ .../src/api/__tests__/draftSummary.spec.tsx | 98 ++++++++++++ frontend/src/api/draftSummary.ts | 20 ++- frontend/src/api/policy.ts | 12 +- .../src/components/sandbox/ImageReference.tsx | 16 ++ frontend/src/pages/AllSandboxesPage.tsx | 96 ++++-------- frontend/src/pages/AllWorkspacesPage.tsx | 2 + frontend/src/pages/ProposalsPage.tsx | 135 ++++++++++------ .../pages/__tests__/AllSandboxesPage.spec.tsx | 46 +++--- .../pages/__tests__/ProposalsPage.spec.tsx | 137 +++++++++++++++- .../utils/__tests__/workspaceNames.spec.ts | 31 ++++ frontend/src/utils/workspaceNames.ts | 21 +++ 12 files changed, 615 insertions(+), 146 deletions(-) create mode 100644 frontend/src/api/__tests__/draft-decisions.spec.tsx create mode 100644 frontend/src/components/sandbox/ImageReference.tsx create mode 100644 frontend/src/utils/__tests__/workspaceNames.spec.ts create mode 100644 frontend/src/utils/workspaceNames.ts 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/draftSummary.ts b/frontend/src/api/draftSummary.ts index f3f8c221..fbbc4801 100644 --- a/frontend/src/api/draftSummary.ts +++ b/frontend/src/api/draftSummary.ts @@ -108,10 +108,15 @@ export const useWorkspacesDraftSummaries = ( }); // useQueries returns a new array every render, so the merge is keyed on - // what actually changed: when each query last produced data, and whether - // it is in error. + // 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.dataUpdatedAt}:${query.isError}`) + .map( + (query) => + `${query.status}:${query.fetchStatus}:${query.dataUpdatedAt}:${query.errorUpdatedAt}`, + ) .join(','); return useMemo(() => { @@ -129,9 +134,10 @@ export const useWorkspacesDraftSummaries = ( return { items, totalPending, - // Every workspace is read at once, so the list is worth showing once - // the first of them has answered. - isLoading: queries.length > 0 && queries.every((q) => q.isLoading), + // 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, @@ -140,5 +146,5 @@ export const useWorkspacesDraftSummaries = ( refetch: () => queries.forEach((query) => void query.refetch()), }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [fingerprint, workspaces.join(',')]); + }, [fingerprint, enabled, workspaces.join(',')]); }; diff --git a/frontend/src/api/policy.ts b/frontend/src/api/policy.ts index 28d6c9b7..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, @@ -321,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, @@ -340,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/components/sandbox/ImageReference.tsx b/frontend/src/components/sandbox/ImageReference.tsx new file mode 100644 index 00000000..d22ea038 --- /dev/null +++ b/frontend/src/components/sandbox/ImageReference.tsx @@ -0,0 +1,16 @@ +type ImageReferenceProps = { + image?: string; +}; + +// An image reference pinned by digest is far wider than its column, which +// truncates it. The title keeps the whole reference within reach. +const ImageReference: React.FC = ({ image }) => + image ? ( + + {image} + + ) : ( + <>- + ); + +export default ImageReference; diff --git a/frontend/src/pages/AllSandboxesPage.tsx b/frontend/src/pages/AllSandboxesPage.tsx index e77a369e..64ff4b25 100644 --- a/frontend/src/pages/AllSandboxesPage.tsx +++ b/frontend/src/pages/AllSandboxesPage.tsx @@ -1,92 +1,59 @@ import { useState } from 'react'; -import { Button, Content, PageSection, Title } from '@patternfly/react-core'; -import { useNavigate } from 'react-router-dom'; +import { Content, PageSection, Title } from '@patternfly/react-core'; +import { Link } from 'react-router-dom'; import { useAllSandboxes } from '../api/allWorkspaces'; +import { useI18n } from '../i18n'; import LabelsList from '../components/LabelsList'; import PhaseLabel from '../components/PhaseLabel'; import ResourceTable from '../components/ResourceTable'; import type { ResourceTableColumn } from '../components/ResourceTable'; +import ImageReference from '../components/sandbox/ImageReference'; import { formatAge } from '../utils/formatters'; +import { + sandboxPath, + scopedName, + workspaceOf, + workspacePath, +} from '../utils/workspaceNames'; import type { Sandbox } from '../types'; -type AllSandboxesPageProps = { - onSelectWorkspace?: (workspace: string) => void; - onSelectSandbox?: (workspace: string, name: string) => void; -}; - -const workspaceOf = (item: { metadata: { workspace?: string } }): string => - item.metadata.workspace ?? ''; - -// A name is only unique within its workspace, so rows are keyed, and -// filtered, on both. -const scopedName = (item: { - metadata: { workspace?: string; name: string }; -}): string => `${workspaceOf(item)}/${item.metadata.name}`; - -// An image reference pinned by digest is far wider than its column, which -// truncates it. The title keeps the whole reference within reach. -const ImageReference: React.FC<{ image?: string }> = ({ image }) => - image ? ( - - {image} - - ) : ( - <>- - ); - // The sandboxes of every workspace on the gateway: `openshell sandbox list // --all-workspaces`. 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. -const AllSandboxesPage: React.FC = ({ - onSelectWorkspace, - onSelectSandbox, -}) => { - const navigate = useNavigate(); +const AllSandboxesPage: React.FC = () => { + const { t } = useI18n('crossWorkspace'); const [sandboxSelector, setSandboxSelector] = useState(''); const sandboxes = useAllSandboxes(sandboxSelector || undefined); - const selectWorkspace = - onSelectWorkspace ?? - ((workspace: string) => navigate(`/workspaces/${workspace}`)); - const selectSandbox = - onSelectSandbox ?? - ((workspace: string, name: string) => - navigate(`/workspaces/${workspace}/sandboxes/${name}`)); - const columns: ResourceTableColumn[] = [ { - title: 'Name', + title: t('sandboxes.columns.name'), modifier: 'nowrap', render: (sandbox) => ( - + ), }, { - title: 'Workspace', + title: t('sandboxes.columns.workspace'), modifier: 'nowrap', render: (sandbox) => ( - + ), }, { - title: 'Status', + title: t('sandboxes.columns.status'), render: (sandbox) => ( = ({ ), }, { - title: 'Image', + title: t('sandboxes.columns.image'), modifier: 'truncate', render: (sandbox) => , }, { - title: 'Labels', + title: t('sandboxes.columns.labels'), render: (sandbox) => ( ), }, { - title: 'Age', + title: t('sandboxes.columns.age'), render: (sandbox) => formatAge(sandbox.metadata.createdAtMs), }, ]; @@ -115,23 +82,20 @@ const AllSandboxesPage: React.FC = ({ <> - Sandboxes + {t('sandboxes.title')} - - Secure execution environments for agents and tools, across every - workspace on the gateway. - + {t('sandboxes.description')} - `/workspaces/${encodeURIComponent(entry.workspace)}/sandboxes/${encodeURIComponent(entry.sandboxName)}?tab=proposals`; + `${sandboxPath(entry.workspace, entry.sandboxName)}?tab=proposals`; -// A sandbox whose inbox could not be read, first: nothing is known about it, -// which is worth more attention than a known count. +// Known counts first, flagged before the rest, then the most recently +// proposed. A sandbox whose inbox could not be read comes after them: the +// summary is read every DRAFT_SUMMARY_POLL_MS, one inbox read per sandbox, and +// on a busy gateway the ones the BFF runs out of time for would otherwise push +// every known proposal down the page. They are counted in one alert instead. const byUrgency = (a: DraftSandboxSummary, b: DraftSandboxSummary) => { - if (!!a.unavailable !== !!b.unavailable) return a.unavailable ? -1 : 1; + 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 columns: ResourceTableColumn[] = [ +type TFunction = ReturnType['t']; + +const proposalColumns = ( + t: TFunction, +): ResourceTableColumn[] => [ { - title: 'Pending', + title: t('proposals.columns.pending'), // The count is the row's one link: what is waiting, leading to where it // is decided. An inbox that could not be read is not an inbox with // nothing in it, and it leads to the same place — the tab is where the // reader finds out what the summary could not say. - render: (entry) => ( - - {entry.unavailable ? ( - - ) : ( - entry.pendingCount - )} - - ), + render: (entry) => { + const value = entry.unavailable ? ( + + ) : ( + entry.pendingCount + ); + // A sandbox the gateway listed without a workspace has no page to + // lead to (the BFF reports it unavailable without reading it). + if (!entry.workspace) return value; + const names = { workspace: entry.workspace, sandbox: entry.sandboxName }; + return ( + + {value} + + ); + }, }, // The two names say which sandbox the row is about; neither is where the // row leads. A row with three links in it offers three ways out and says // nothing about which one does the thing the page is for. { - title: 'Workspace', + title: t('proposals.columns.workspace'), modifier: 'nowrap', - render: (entry) => entry.workspace, + render: (entry) => entry.workspace || '-', }, { - title: 'Sandbox', + title: t('proposals.columns.sandbox'), modifier: 'nowrap', render: (entry) => entry.sandboxName, }, { - title: 'Security', + title: t('proposals.columns.security'), render: (entry) => entry.hasSecurityFlags ? ( ) : ( '-' ), }, { - title: 'Latest', + title: t('proposals.columns.latest'), render: (entry) => entry.unavailable || !entry.latestDraftMs ? '-' @@ -94,14 +118,15 @@ const columns: ResourceTableColumn[] = [ }, ]; -// Every sandbox with a policy rule its agent has proposed and nobody has -// decided on yet, across the workspaces the signed-in user can read. +// Every sandbox with a policy rule it has proposed and nobody has decided on +// yet, across the workspaces the signed-in user can read. // // Two ways of reading the same thing, because proposals are not a platform // admin's alone: a platform admin gets the gateway's cross-workspace summary // in one request, and everyone else gets one request per workspace they are a // member of, which is what the gateway will answer for them. const ProposalsPage: React.FC = () => { + const { t } = useI18n('crossWorkspace'); const { isPlatformAdmin, isLoading: isRoleLoading } = useUserRole(); const features = useFeatureFlags(); const enabled = features.draftPolicy && !isRoleLoading; @@ -121,7 +146,9 @@ const ProposalsPage: React.FC = () => { const summary = isPlatformAdmin ? acrossWorkspaces : perWorkspace; // The workspace list is the per-workspace path's first request, so it is - // part of that path's loading and of its failure. + // part of that path's loading and of its failure. The list, and so its + // empty state, waits for every read to answer: one built from some of them + // would say nothing is waiting in the rest. const isLoading = isRoleLoading || summary.isLoading || @@ -144,29 +171,27 @@ const ProposalsPage: React.FC = () => { // rows that did load are still worth showing, and the gap is named. const partial = !isPlatformAdmin && !isError ? perWorkspace.failedWorkspaces : []; + const unavailableCount = isLoading + ? 0 + : summary.items.filter((entry) => entry.unavailable).length; return ( <> - Proposals + {t('proposals.title')} - - Policy rules proposed by the agents running in your sandboxes, and - waiting for a decision. Select a sandbox to review, edit, approve or - reject what it has proposed. - + {t('proposals.description')} {!features.draftPolicy ? ( - This dashboard was started with the draft-policy feature disabled, - so no proposals are read. + {t('proposals.disabled.body')} ) : ( <> @@ -174,28 +199,46 @@ const ProposalsPage: React.FC = () => { summary.refetch()}> - Retry + {t('proposals.partial.retry')} } > - No proposals are shown for {partial.join(', ')}. There may be - some waiting there. + {t('proposals.partial.body', { + workspaces: partial.join(', '), + })} + + )} + {unavailableCount > 0 && ( + + {t('proposals.unavailable.body')} )} `${entry.workspace}/${entry.sandboxName}`} filterText={(entry) => `${entry.workspace}/${entry.sandboxName}`} - filterPlaceholder="Filter by workspace or sandbox" - emptyBody="No sandbox you can see has a policy rule waiting for a decision." + filterPlaceholder={t('proposals.filterPlaceholder')} + emptyBody={ + partial.length > 0 + ? t('proposals.emptyPartial') + : t('proposals.empty') + } /> )} diff --git a/frontend/src/pages/__tests__/AllSandboxesPage.spec.tsx b/frontend/src/pages/__tests__/AllSandboxesPage.spec.tsx index 3355f660..5474fc87 100644 --- a/frontend/src/pages/__tests__/AllSandboxesPage.spec.tsx +++ b/frontend/src/pages/__tests__/AllSandboxesPage.spec.tsx @@ -60,15 +60,13 @@ const Location: React.FC = () => { ); }; -const renderPage = ( - props: React.ComponentProps = {}, -) => +const renderPage = () => render( - + , ); @@ -96,7 +94,7 @@ describe('AllSandboxesPage', () => { ).toBeInTheDocument(); expect( screen.getByText( - 'Secure execution environments for agents and tools, across every workspace on the gateway.', + 'Secure execution environments, across every workspace on the gateway.', ), ).toBeInTheDocument(); expect(headersOf('all-sandboxes-table')).toEqual([ @@ -135,11 +133,14 @@ describe('AllSandboxesPage', () => { }); // A row leads to the workspace-scoped page of the sandbox it is: the one in - // its own workspace, not another of the same name. + // its own workspace, not another of the same name. Both names are links, + // so they can be opened in a new tab and read as links by a screen reader. it('opens a sandbox in the workspace it belongs to', () => { renderPage(); + const link = screen.getByTestId('sandbox-link-team-b/agent'); + expect(link).toHaveAttribute('href', '/workspaces/team-b/sandboxes/agent'); - fireEvent.click(screen.getByTestId('sandbox-link-team-b/agent')); + fireEvent.click(link); expect(screen.getByTestId('location')).toHaveTextContent( /^\/workspaces\/team-b\/sandboxes\/agent$/, @@ -149,30 +150,31 @@ describe('AllSandboxesPage', () => { it('opens the workspace from the workspace column', () => { renderPage(); const firstRow = rowsOf('all-sandboxes-table')[0]; + const link = within(firstRow).getByRole('link', { name: 'team-a' }); + expect(link).toHaveAttribute('href', '/workspaces/team-a'); - fireEvent.click(within(firstRow).getByRole('button', { name: 'team-a' })); + fireEvent.click(link); expect(screen.getByTestId('location')).toHaveTextContent( /^\/workspaces\/team-a$/, ); }); - it('hands navigation to the host when it provides callbacks', () => { - const onSelectSandbox = jest.fn(); - const onSelectWorkspace = jest.fn(); - renderPage({ onSelectSandbox, onSelectWorkspace }); - - fireEvent.click(screen.getByTestId('sandbox-link-team-b/builder')); - expect(onSelectSandbox).toHaveBeenCalledWith('team-b', 'builder'); + // A name is one path segment, whatever it holds. + it('encodes the names in the paths it links to', () => { + mockUseAllSandboxes.mockReturnValue( + ok([sandbox('team a', 'x?y', 'READY')]), + ); + renderPage(); - fireEvent.click( - within(rowsOf('all-sandboxes-table')[0]).getByRole('button', { - name: 'team-a', - }), + expect(screen.getByTestId('sandbox-link-team a/x?y')).toHaveAttribute( + 'href', + '/workspaces/team%20a/sandboxes/x%3Fy', + ); + expect(screen.getByTestId('workspace-link-team a/x?y')).toHaveAttribute( + 'href', + '/workspaces/team%20a', ); - expect(onSelectWorkspace).toHaveBeenCalledWith('team-a'); - // The router was left alone. - expect(screen.getByTestId('location')).toHaveTextContent(/^\/sandboxes$/); }); it('filters the rows by workspace or name as it is typed', () => { diff --git a/frontend/src/pages/__tests__/ProposalsPage.spec.tsx b/frontend/src/pages/__tests__/ProposalsPage.spec.tsx index 1805719d..409c5050 100644 --- a/frontend/src/pages/__tests__/ProposalsPage.spec.tsx +++ b/frontend/src/pages/__tests__/ProposalsPage.spec.tsx @@ -134,7 +134,8 @@ describe('ProposalsPage', () => { expect(rows()).toEqual([['2', 'team-b', 'agent', '-', '-']]); }); - // Unreadable first, then flagged, then the most recently proposed. + // Known counts first — flagged, then the most recently proposed — and the + // sandboxes that could not be read after them, counted in one alert. it('puts what is most urgent at the top', () => { mockUseAcrossWorkspaces.mockReturnValue( summary([ @@ -162,12 +163,108 @@ describe('ProposalsPage', () => { // Sandbox name is the third column (after Pending and Workspace). expect(rows().map((cells) => cells[2])).toEqual([ - 'unknown', 'flagged', 'quiet', + 'unknown', + ]); + expect(screen.getByTestId('proposals-unavailable')).toHaveTextContent( + 'The proposals of one sandbox could not be read', + ); + }); + + // The summary is polled, one inbox read per sandbox, and the ones the BFF + // runs out of time for come back unavailable. They are one alert with a + // count, not a wall of rows above the proposals that were read. + it('counts the sandboxes it could not read in one alert', () => { + mockUseAcrossWorkspaces.mockReturnValue( + summary([ + entry({ + workspace: 'team-a', + sandboxName: 'slow-1', + unavailable: true, + }), + entry({ workspace: 'team-a', sandboxName: 'busy', pendingCount: 3 }), + entry({ + workspace: 'team-b', + sandboxName: 'slow-2', + unavailable: true, + }), + ]), + ); + renderPage(); + + expect(screen.getAllByTestId('proposals-unavailable')).toHaveLength(1); + expect(screen.getByTestId('proposals-unavailable')).toHaveTextContent( + 'The proposals of 2 sandboxes could not be read', + ); + expect(rows().map((cells) => cells[0])).toEqual([ + '3', + 'Could not be read', + 'Could not be read', ]); }); + it('shows no unavailable alert when every inbox was read', () => { + mockUseAcrossWorkspaces.mockReturnValue( + summary([ + entry({ workspace: 'team-a', sandboxName: 'busy', pendingCount: 1 }), + ]), + ); + renderPage(); + + expect( + screen.queryByTestId('proposals-unavailable'), + ).not.toBeInTheDocument(); + }); + + // Until every read has answered, "nothing waiting" would be a claim about + // workspaces that have not been read yet. + it('never says nothing is waiting while a read is still out', () => { + mockIsPlatformAdmin = false; + mockUsePerWorkspace.mockReturnValue(summary([], { isLoading: true })); + renderPage(); + + expect(screen.queryByTestId('proposals-empty')).not.toBeInTheDocument(); + expect(screen.queryByTestId('proposals-table')).not.toBeInTheDocument(); + expect( + screen.getByRole('progressbar', { name: 'Loading proposals' }), + ).toBeInTheDocument(); + }); + + it('waits for the workspace list before saying nothing is waiting', () => { + mockIsPlatformAdmin = false; + mockUseWorkspaces.mockReturnValue({ + data: undefined, + isLoading: true, + isError: false, + refetch: jest.fn(), + }); + renderPage(); + + expect(screen.queryByTestId('proposals-empty')).not.toBeInTheDocument(); + }); + + it('says nothing is waiting once every read has answered', () => { + renderPage(); + + expect(screen.getByTestId('proposals-empty')).toHaveTextContent( + 'No sandbox you can see has a policy rule waiting for a decision.', + ); + }); + + // A sandbox the gateway listed without a workspace has no page to lead to. + it('does not link a sandbox that names no workspace', () => { + mockUseAcrossWorkspaces.mockReturnValue( + summary([ + entry({ workspace: '', sandboxName: 'orphan', unavailable: true }), + ]), + ); + renderPage(); + + expect(rows()).toEqual([['Could not be read', '-', 'orphan', '-', '-']]); + expect(screen.queryAllByRole('link')).toHaveLength(0); + }); + // A sandbox whose inbox could not be read has an unknown number of // proposals waiting, which is not none. it('never shows a sandbox it could not read as empty', () => { @@ -203,8 +300,9 @@ describe('ProposalsPage', () => { '/workspaces/team-a/sandboxes/agent?tab=proposals', ); // The count is the row's one link. The two names beside it say which - // sandbox the row is about and lead nowhere. + // sandbox the row is about and lead nowhere; the link's name says it. expect(link).toHaveTextContent('1'); + expect(link).toHaveAccessibleName('1 proposal waiting in team-a/agent'); expect(screen.getAllByRole('link')).toEqual([link]); }); @@ -219,10 +317,43 @@ describe('ProposalsPage', () => { ); renderPage(); + expect(screen.getByTestId('proposals-partial')).toHaveTextContent( + 'A workspace could not be read', + ); expect(screen.getByTestId('proposals-partial')).toHaveTextContent('team-b'); expect(rows()).toHaveLength(1); }); + it('counts the workspaces it could not read, and says less when empty', () => { + mockIsPlatformAdmin = false; + mockUsePerWorkspace.mockReturnValue( + summary([], { failedWorkspaces: ['team-a', 'team-b'] }), + ); + renderPage(); + + expect(screen.getByTestId('proposals-partial')).toHaveTextContent( + '2 workspaces could not be read', + ); + expect(screen.getByTestId('proposals-partial')).toHaveTextContent( + 'team-a, team-b', + ); + // Not "none you can see": two of them were not seen. + expect(screen.getByTestId('proposals-empty')).toHaveTextContent( + 'No sandbox in the workspaces that could be read has a policy rule waiting for a decision.', + ); + }); + + it("describes proposals as the sandboxes' own", () => { + renderPage(); + + expect( + screen.getByRole('heading', { level: 1, name: 'Proposals' }), + ).toBeInTheDocument(); + expect( + screen.getByText(/^Policy rules your sandboxes have proposed/), + ).toBeInTheDocument(); + }); + it('says so when the feature is switched off, and reads nothing', () => { mockDraftPolicy = false; renderPage(); diff --git a/frontend/src/utils/__tests__/workspaceNames.spec.ts b/frontend/src/utils/__tests__/workspaceNames.spec.ts new file mode 100644 index 00000000..524d64c7 --- /dev/null +++ b/frontend/src/utils/__tests__/workspaceNames.spec.ts @@ -0,0 +1,31 @@ +import { + sandboxPath, + scopedName, + workspaceOf, + workspacePath, +} from '../workspaceNames'; + +describe('workspace names', () => { + it('reads the workspace a resource is in, or none', () => { + expect(workspaceOf({ metadata: { workspace: 'team-a' } })).toBe('team-a'); + expect(workspaceOf({ metadata: {} })).toBe(''); + }); + + // The same name in two workspaces is two resources. + it('scopes a name by its workspace', () => { + expect( + scopedName({ metadata: { workspace: 'team-a', name: 'agent' } }), + ).toBe('team-a/agent'); + expect( + scopedName({ metadata: { workspace: 'team-b', name: 'agent' } }), + ).not.toBe('team-a/agent'); + }); + + it('encodes each name as one path segment', () => { + expect(workspacePath('team-a')).toBe('/workspaces/team-a'); + expect(sandboxPath('team-a', 'agent')).toBe( + '/workspaces/team-a/sandboxes/agent', + ); + expect(sandboxPath('a/b', 'c?d')).toBe('/workspaces/a%2Fb/sandboxes/c%3Fd'); + }); +}); diff --git a/frontend/src/utils/workspaceNames.ts b/frontend/src/utils/workspaceNames.ts new file mode 100644 index 00000000..a70f4e0c --- /dev/null +++ b/frontend/src/utils/workspaceNames.ts @@ -0,0 +1,21 @@ +// What the lists across workspaces need from a resource's metadata: every +// row there names the workspace it is in. +type WorkspaceScoped = { metadata: { workspace?: string } }; + +// The workspace a resource is in, or '' when the gateway named none. +export const workspaceOf = (item: WorkspaceScoped): string => + item.metadata.workspace ?? ''; + +// A name is only unique within its workspace, so rows across workspaces are +// keyed, and filtered, on both: "team-a/agent". +export const scopedName = (item: { + metadata: { workspace?: string; name: string }; +}): string => `${workspaceOf(item)}/${item.metadata.name}`; + +// The path of a workspace's page, or of a page inside it, with each name +// encoded as one path segment. +export const workspacePath = (workspace: string): string => + `/workspaces/${encodeURIComponent(workspace)}`; + +export const sandboxPath = (workspace: string, name: string): string => + `${workspacePath(workspace)}/sandboxes/${encodeURIComponent(name)}`; From 77589140a7a552fec65b18deb089fd080a550983 Mon Sep 17 00:00:00 2001 From: Gage Krumbach Date: Mon, 19 Oct 2026 12:51:18 -0500 Subject: [PATCH 12/20] fix(bff): never read a sandbox's drafts with an empty workspace Across workspaces there is no workspace to fall back on, so a sandbox the gateway lists without one was read with an empty selector, which the gateway refuses (hard fact 12), and reported under an empty workspace. It is now reported unavailable without a read, and logged once. Co-Authored-By: Claude Opus 5.5 --- backend/pkg/handlers/drafts_handler.go | 39 +++++++++-- .../handlers/drafts_summary_handler_test.go | 70 +++++++++++++++++++ 2 files changed, 104 insertions(+), 5 deletions(-) diff --git a/backend/pkg/handlers/drafts_handler.go b/backend/pkg/handlers/drafts_handler.go index b05cec73..7189cb19 100644 --- a/backend/pkg/handlers/drafts_handler.go +++ b/backend/pkg/handlers/drafts_handler.go @@ -89,9 +89,10 @@ 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. With -// ?allWorkspaces=true it is every workspace's, which is what the Proposals -// page asks for: the gateway answers the all-workspaces sandbox list for -// platform admins only and refuses everyone else, and that refusal is +// ?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). // // With neither it answers an empty summary. Frontends older than the @@ -115,7 +116,9 @@ func (h *DraftsHandler) GetDraftSummary(w http.ResponseWriter, r *http.Request) // 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. +// 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") @@ -137,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)) { @@ -161,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 } @@ -191,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 { diff --git a/backend/pkg/handlers/drafts_summary_handler_test.go b/backend/pkg/handlers/drafts_summary_handler_test.go index f952331f..c1ffe612 100644 --- a/backend/pkg/handlers/drafts_summary_handler_test.go +++ b/backend/pkg/handlers/drafts_summary_handler_test.go @@ -628,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) + } + }) + } +} From 977c7860763b23d6eef1a7c2a7d722cf4795e24f Mon Sep 17 00:00:00 2001 From: Gage Krumbach Date: Mon, 19 Oct 2026 12:51:18 -0500 Subject: [PATCH 13/20] fix(ui): keep the security flag's warning on proposals The prototype showed a sandbox's pending proposals as info even when the prover flagged them. Flagged ones are warning again: they are the ones approve-all skips unless asked (hard fact 25). Also from review of the prototype: - The Review link is back in the alert's action links, not its close slot. - Approve is a secondary button; the private PatternFly variables that coloured it green through style are gone (ADR 0004). - The create forms' optional section no longer says it is generated, says which field is, and stays open while it holds a value. - The terminal's light theme has an ANSI palette, so white text shows. - The status filter has an accessible name; new text through the sandbox catalog. Co-Authored-By: Claude Opus 5.5 --- .../CreateSandboxFromTemplateModal.tsx | 23 ++++-- .../src/components/CreateSandboxModal.tsx | 33 +++++++-- .../SandboxDraftsTab/DraftChunkRow.tsx | 18 ++--- .../__tests__/SandboxDraftsTab.spec.tsx | 25 +++++++ .../src/components/SandboxDraftsTab/index.tsx | 21 +++--- .../src/components/SandboxDraftsTab/utils.ts | 30 ++------ .../CreateSandboxFromTemplateModal.spec.tsx | 24 +++++- .../__tests__/CreateSandboxModal.spec.tsx | 31 ++++++++ .../SandboxAttention.render.spec.tsx | 45 +++++++++-- .../__tests__/SandboxAttention.spec.ts | 9 +-- .../__tests__/SandboxTerminalTab.spec.tsx | 33 +++++++++ .../components/sandbox/SandboxAttention.tsx | 74 +++++++------------ .../components/sandbox/SandboxTerminalTab.tsx | 36 +++++++-- 13 files changed, 281 insertions(+), 121 deletions(-) diff --git a/frontend/src/components/CreateSandboxFromTemplateModal.tsx b/frontend/src/components/CreateSandboxFromTemplateModal.tsx index d67dba86..f65bb53f 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('sandbox'); const providers = useProviders(workspace); const createFromTemplate = useCreateSandboxFromTemplate(workspace); const { addSuccess } = useAlerts(); @@ -75,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. @@ -268,12 +273,15 @@ const CreateSandboxFromTemplateModal: React.FC< )) )} - {/* What the gateway fills in for a form that leaves it alone, as - in the Create sandbox form. It opens by itself around a field - that needs correcting. */} + {/* 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" > @@ -286,6 +294,11 @@ const CreateSandboxFromTemplateModal: React.FC< onChange={(_event, value) => setName(value)} placeholder="Leave empty for a generated name" /> + + + {t('create.nameHelper')} + + = ({ isOpen, onClose, }) => { + const { t } = useI18n('sandbox'); 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); @@ -213,13 +224,18 @@ const CreateSandboxModal: React.FC = ({ )) )} - {/* What the gateway fills in for a form that leaves it alone. It - is folded away so that the form opens on the three things a - sandbox is made of, and opens by itself around a field that - needs correcting. */} + {/* 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" > @@ -232,6 +248,11 @@ const CreateSandboxModal: React.FC = ({ onChange={(_event, value) => form.setName(value)} placeholder="Leave empty for a generated name" /> + + + {t('create.nameHelper')} + + = ({ onSetRejecting, onSetRejectReason, }) => { + const { t } = useI18n('sandbox'); const { approve, reject, undo } = actions; const annotation = approvalAnnotation(chunk); // The gateway takes an approval for a pending or a rejected chunk, and a @@ -155,7 +152,6 @@ const DraftChunkRow: React.FC = ({ ; ``` - 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 7117d755..a6b2cdf4 100644 --- a/frontend/src/i18n/catalogs.ts +++ b/frontend/src/i18n/catalogs.ts @@ -2,7 +2,6 @@ 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 sandbox from './locales/en/sandbox'; import sandboxes from './locales/en/sandboxes'; import workspaces from './locales/en/workspaces'; @@ -13,7 +12,6 @@ export const catalogs = { workspaces, gateway, crossWorkspace, - sandbox, sandboxes, }, } as const; diff --git a/frontend/src/i18n/init.ts b/frontend/src/i18n/init.ts index ac5ceb53..1765df1d 100644 --- a/frontend/src/i18n/init.ts +++ b/frontend/src/i18n/init.ts @@ -10,7 +10,6 @@ const NAMESPACES = [ 'workspaces', 'gateway', 'crossWorkspace', - 'sandbox', 'sandboxes', ] as const; diff --git a/frontend/src/i18n/locales/en/sandbox.ts b/frontend/src/i18n/locales/en/sandbox.ts deleted file mode 100644 index 3f4baef9..00000000 --- a/frontend/src/i18n/locales/en/sandbox.ts +++ /dev/null @@ -1,30 +0,0 @@ -// The sandbox namespace. See src/i18n/catalogs.ts. -export default { - 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/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', + }, + }, }; From 2acdd50d406405d96dccc9c426412f858ccc07f7 Mon Sep 17 00:00:00 2001 From: Gage Krumbach Date: Thu, 22 Oct 2026 17:40:20 -0500 Subject: [PATCH 17/20] refactor(ui): one copy of the cells shared across workspaces main put workspaceOf, scopedName and ImageReference in AllWorkspaceCells while this branch put the same helpers in utils/workspaceNames and components/sandbox/ImageReference. AllWorkspaceCells now re-exports those, so the lists keep their import and there is one definition of each. Co-Authored-By: Claude Opus 5.5 --- frontend/src/components/AllWorkspaceCells.tsx | 22 +++++-------------- 1 file changed, 5 insertions(+), 17 deletions(-) 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'; From 84fa46f46b4439a6eea922128ba2e0ae0ec782fc Mon Sep 17 00:00:00 2001 From: Gage Krumbach Date: Thu, 22 Oct 2026 17:42:34 -0500 Subject: [PATCH 18/20] feat(ui): page the Sandboxes page through the gateway main paged the sandboxes of every workspace through the gateway (AllSandboxList: a cursor, a page size, label and type filters) on the All workspaces page's Sandboxes tab, while this branch had moved that list to a page of its own that read every sandbox at once. The Sandboxes page now renders AllSandboxList, which keeps the page's layout: the name leads, and the name and workspace are links rather than buttons. Its only user is this page, so the navigation callbacks are gone. main's tests of the type label, the empty type match and a conflicting type key moved with the list, from the All workspaces spec to the Sandboxes page's; the page's own text filter is "current page" as main's is. Co-Authored-By: Claude Opus 5.5 --- frontend/src/components/AllSandboxList.tsx | 53 +++++------ .../src/i18n/locales/en/crossWorkspace.ts | 13 --- frontend/src/pages/AllSandboxesPage.tsx | 93 ++----------------- .../pages/__tests__/AllSandboxesPage.spec.tsx | 77 ++++++++++++++- .../__tests__/AllWorkspacesPage.spec.tsx | 2 +- 5 files changed, 106 insertions(+), 132 deletions(-) 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/i18n/locales/en/crossWorkspace.ts b/frontend/src/i18n/locales/en/crossWorkspace.ts index bd533848..33977a75 100644 --- a/frontend/src/i18n/locales/en/crossWorkspace.ts +++ b/frontend/src/i18n/locales/en/crossWorkspace.ts @@ -7,19 +7,6 @@ export default { title: 'Sandboxes', description: 'Secure execution environments, across every workspace on the gateway.', - // Names the table; ResourceTable also builds its loading, filter and - // error wording from it. - tableTitle: 'Sandboxes', - filterPlaceholder: 'Filter by workspace or name', - empty: 'No workspace on this gateway has a sandbox.', - columns: { - name: 'Name', - workspace: 'Workspace', - status: 'Status', - image: 'Image', - labels: 'Labels', - age: 'Age', - }, }, proposals: { title: 'Proposals', diff --git a/frontend/src/pages/AllSandboxesPage.tsx b/frontend/src/pages/AllSandboxesPage.tsx index 64ff4b25..958c88ba 100644 --- a/frontend/src/pages/AllSandboxesPage.tsx +++ b/frontend/src/pages/AllSandboxesPage.tsx @@ -1,82 +1,16 @@ -import { useState } from 'react'; import { Content, PageSection, Title } from '@patternfly/react-core'; -import { Link } from 'react-router-dom'; -import { useAllSandboxes } from '../api/allWorkspaces'; +import AllSandboxList from '../components/AllSandboxList'; import { useI18n } from '../i18n'; -import LabelsList from '../components/LabelsList'; -import PhaseLabel from '../components/PhaseLabel'; -import ResourceTable from '../components/ResourceTable'; -import type { ResourceTableColumn } from '../components/ResourceTable'; -import ImageReference from '../components/sandbox/ImageReference'; -import { formatAge } from '../utils/formatters'; -import { - sandboxPath, - scopedName, - workspaceOf, - workspacePath, -} from '../utils/workspaceNames'; -import type { Sandbox } from '../types'; // The sandboxes of every workspace on the gateway: `openshell sandbox list -// --all-workspaces`. 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. +// --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'); - const [sandboxSelector, setSandboxSelector] = useState(''); - const sandboxes = useAllSandboxes(sandboxSelector || undefined); - - const columns: ResourceTableColumn[] = [ - { - title: t('sandboxes.columns.name'), - modifier: 'nowrap', - render: (sandbox) => ( - - {sandbox.metadata.name} - - ), - }, - { - title: t('sandboxes.columns.workspace'), - modifier: 'nowrap', - render: (sandbox) => ( - - {workspaceOf(sandbox)} - - ), - }, - { - title: t('sandboxes.columns.status'), - render: (sandbox) => ( - - ), - }, - { - title: t('sandboxes.columns.image'), - modifier: 'truncate', - render: (sandbox) => , - }, - { - title: t('sandboxes.columns.labels'), - render: (sandbox) => ( - - ), - }, - { - title: t('sandboxes.columns.age'), - render: (sandbox) => formatAge(sandbox.metadata.createdAtMs), - }, - ]; return ( <> @@ -87,20 +21,7 @@ const AllSandboxesPage: React.FC = () => { {t('sandboxes.description')} - + ); diff --git a/frontend/src/pages/__tests__/AllSandboxesPage.spec.tsx b/frontend/src/pages/__tests__/AllSandboxesPage.spec.tsx index 5474fc87..eba485f0 100644 --- a/frontend/src/pages/__tests__/AllSandboxesPage.spec.tsx +++ b/frontend/src/pages/__tests__/AllSandboxesPage.spec.tsx @@ -4,12 +4,24 @@ import { MemoryRouter, useLocation } from 'react-router-dom'; import AllSandboxesPage from '../AllSandboxesPage'; import type { Sandbox } from '../../types'; +// The page lists the gateway's pages of sandboxes (AllSandboxList). The +// fixtures are written as whole lists, and pagedResult serves them a page at +// a time, the way main's tests of the same list do. jest.mock('../../api/allWorkspaces', () => ({ useAllSandboxes: jest.fn(), + useAllSandboxPage: jest.fn(), })); -import { useAllSandboxes } from '../../api/allWorkspaces'; +import { useAllSandboxes, useAllSandboxPage } from '../../api/allWorkspaces'; +import { pagedResult } from './pagedHookMock'; const mockUseAllSandboxes = useAllSandboxes as jest.Mock; +const mockUseAllSandboxPage = useAllSandboxPage as jest.Mock; + +beforeEach(() => { + mockUseAllSandboxPage.mockImplementation((request) => + pagedResult(mockUseAllSandboxes(request.labelSelector), request), + ); +}); const meta = (workspace: string, name: string) => ({ id: `${workspace}-${name}`, @@ -177,9 +189,12 @@ describe('AllSandboxesPage', () => { ); }); + // The text filter narrows the page on screen; the gateway pages the list. it('filters the rows by workspace or name as it is typed', () => { renderPage(); - const filter = screen.getByRole('textbox', { name: 'Filter sandboxes' }); + const filter = screen.getByRole('textbox', { + name: 'Filter current page of sandboxes', + }); fireEvent.change(filter, { target: { value: 'team-b' } }); expect(rowsOf('all-sandboxes-table')).toHaveLength(2); @@ -190,7 +205,7 @@ describe('AllSandboxesPage', () => { expect(rows[0]).toHaveTextContent('builder'); fireEvent.change(filter, { target: { value: 'nothing-like-this' } }); - expect(screen.getByText('No sandboxes match this filter.')).toBeVisible(); + expect(screen.getByText('No sandboxes match on this page.')).toBeVisible(); }); // `--selector`: the gateway applies it, so it goes to the request when it @@ -212,6 +227,62 @@ describe('AllSandboxesPage', () => { expect(rowsOf('all-sandboxes-table')).toHaveLength(2); }); + it('adds the type label to the label selector it asks the gateway for', () => { + renderPage(); + const selector = screen.getByRole('textbox', { + name: 'Filter sandboxes by label selector', + }); + fireEvent.change(selector, { target: { value: 'team=ml' } }); + fireEvent.keyDown(selector, { key: 'Enter' }); + const type = screen.getByRole('textbox', { + name: 'Filter sandboxes by type label', + }); + fireEvent.change(type, { target: { value: 'type=tool' } }); + fireEvent.keyDown(type, { key: 'Enter' }); + expect(mockUseAllSandboxPage).toHaveBeenLastCalledWith({ + pageSize: 10, + pageToken: '', + labelSelector: 'team=ml,type=tool', + }); + }); + + it('keeps the type field visible when its selector matches no sandboxes', () => { + mockUseAllSandboxes.mockImplementation((selector?: string) => + ok(selector === 'type=tool' ? [] : sandboxes), + ); + renderPage(); + const type = screen.getByRole('textbox', { + name: 'Filter sandboxes by type label', + }); + fireEvent.change(type, { target: { value: 'type=tool' } }); + fireEvent.keyDown(type, { key: 'Enter' }); + expect(screen.getByTestId('all-sandboxes-table')).toBeInTheDocument(); + expect(type).toHaveValue('type=tool'); + expect(screen.queryByTestId('all-sandboxes-empty')).not.toBeInTheDocument(); + }); + + it('rejects a conflicting type key without changing the request', () => { + renderPage(); + const selector = screen.getByRole('textbox', { + name: 'Filter sandboxes by label selector', + }); + fireEvent.change(selector, { target: { value: 'type=agent' } }); + fireEvent.keyDown(selector, { key: 'Enter' }); + const type = screen.getByRole('textbox', { + name: 'Filter sandboxes by type label', + }); + fireEvent.change(type, { target: { value: 'type=tool' } }); + fireEvent.keyDown(type, { key: 'Enter' }); + expect( + screen.getByTestId('all-sandboxes-type-label-error'), + ).toHaveTextContent('already uses type'); + expect(mockUseAllSandboxPage).toHaveBeenLastCalledWith({ + pageSize: 10, + pageToken: '', + labelSelector: 'type=agent', + }); + }); + it('shows why a label selector was refused and keeps the box', () => { mockUseAllSandboxes.mockImplementation((selector?: string) => selector diff --git a/frontend/src/pages/__tests__/AllWorkspacesPage.spec.tsx b/frontend/src/pages/__tests__/AllWorkspacesPage.spec.tsx index 8e74568b..abba576b 100644 --- a/frontend/src/pages/__tests__/AllWorkspacesPage.spec.tsx +++ b/frontend/src/pages/__tests__/AllWorkspacesPage.spec.tsx @@ -143,7 +143,7 @@ describe('AllWorkspacesPage', () => { expect(screen.getByTestId('all-providers-table')).toBeInTheDocument(); }); - it('does not show an inaccurate sandbox total', () => { + it('counts the providers, templates and services on their tabs', () => { renderPage(); expect(screen.getByRole('tab', { name: /Providers/ })).toHaveTextContent( 'Providers 2', From bca77f3569a887972c2aacd11f44cc4abe716e09 Mon Sep 17 00:00:00 2001 From: Gage Krumbach Date: Thu, 22 Oct 2026 17:43:09 -0500 Subject: [PATCH 19/20] fix(mock): answer the paged lists a page at a time main pages the workspaces, a workspace's sandboxes and the sandboxes of every workspace through the gateway (pageSize, pageToken). The mock API answered those with the whole array, which the paged hooks cannot read, so the lists were empty under make dev-mock. A list asked for with pageSize is now answered as the BFF answers it, { items, nextPageToken }; without it, as before. Co-Authored-By: Claude Opus 5.5 --- frontend/mock/mockPlugin.ts | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/frontend/mock/mockPlugin.ts b/frontend/mock/mockPlugin.ts index 20d01d6b..98b412f2 100644 --- a/frontend/mock/mockPlugin.ts +++ b/frontend/mock/mockPlugin.ts @@ -240,6 +240,22 @@ const matchRoute = ( 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, { @@ -302,7 +318,7 @@ export default function mockApiPlugin(): Plugin { ); return; } - json(res, body); + json(res, pageOf(body, urlObj.searchParams)); }, ); }, From 22bbd9211b7855d6f5ffe8475ebce791b46a921f Mon Sep 17 00:00:00 2001 From: Gage Krumbach Date: Thu, 22 Oct 2026 17:47:10 -0500 Subject: [PATCH 20/20] test(e2e): answer the cross-workspace lists as the BFF does main's list fixtures are one page, { items, nextPageToken }, and its stubs served that page to every request for the workspaces and for the sandboxes of every workspace. The BFF answers a page only when it is asked for pageSize; the shell's counts and the Gateway page ask without it and read an array, so the Gateway page failed on the page object. The two stubs now answer as the BFF would: the page with pageSize, the items without. Co-Authored-By: Claude Opus 5.5 --- frontend/cypress/support/intercepts.ts | 40 ++++++++++++++++++-------- 1 file changed, 28 insertions(+), 12 deletions(-) diff --git a/frontend/cypress/support/intercepts.ts b/frontend/cypress/support/intercepts.ts index e13116a9..ec78c14e 100644 --- a/frontend/cypress/support/intercepts.ts +++ b/frontend/cypress/support/intercepts.ts @@ -26,13 +26,28 @@ export const interceptGateway = () => { }).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, @@ -209,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'); @@ -323,9 +336,12 @@ export const interceptMisc = () => { body: { sandboxes: [], totalPending: 0 }, }).as('allWorkspacesDraftSummary'); - cy.intercept('GET', '/api/v1/sandboxes', { - fixture: 'sandboxes.json', - }).as('listAllSandboxes'); + 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',
NameKindImplementationProtocolCapabilitiesRequires from gateway{t('extensions.name')}{t('extensions.kind')}{t('extensions.implementation')}{t('extensions.protocol')}{t('extensions.capabilities')}{t('extensions.requires')}
+ {extension.configuredName || '-'} - {extensionKindLabel(extension.kind)} + + {extensionKindLabel(t, extension.kind)} + {[ extension.implementationName, extension.implementationVersion, @@ -823,15 +991,15 @@ const GatewayOverviewPage: React.FC = () => { .filter(Boolean) .join(' ') || '-'} + {extension.protocolMajor}.{extension.protocolMinor} + + @@ -840,7 +1008,7 @@ const GatewayOverviewPage: React.FC = () => { ))} {extensions.length === 0 && (
No extensions reported{t('extensions.empty')}