Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
67fbe4e
feat(ui): a cohesive redesign around the admin's core flows
Daniel-Warner-X Oct 19, 2026
78c82ec
perf(ui): read the navigation counts at their own pace
Gkrumbach07 Oct 19, 2026
f26d04a
fix(ui): call provider profiles provider profiles again
Gkrumbach07 Oct 19, 2026
992f64f
docs: file the mock API's notes with the other development docs
Gkrumbach07 Oct 19, 2026
3a324e0
fix(bff): order DraftSandboxSummary's fields for govet's fieldalignment
Gkrumbach07 Oct 19, 2026
9afed07
fix(mock): answer compatibility and proposal counts as the BFF does
Gkrumbach07 Oct 19, 2026
c75f1e5
perf(ui): read the Gateway page's lists at the pace of a landing page
Gkrumbach07 Oct 19, 2026
dd5d711
test(e2e): follow the redesign in the Cypress specs
Gkrumbach07 Oct 19, 2026
d39542d
chore(i18n): catalogs for the redesign's pages
Gkrumbach07 Oct 19, 2026
297a2e5
fix(ui): never call the Gateway page all clear before every list answ…
Gkrumbach07 Oct 19, 2026
de671b0
fix(ui): refresh every proposal count after a decision
Gkrumbach07 Oct 19, 2026
7758914
fix(bff): never read a sandbox's drafts with an empty workspace
Gkrumbach07 Oct 19, 2026
977c786
fix(ui): keep the security flag's warning on proposals
Gkrumbach07 Oct 19, 2026
9b64e94
fix(ui): tidy what the redesign left in the shell and the smaller pages
Gkrumbach07 Oct 19, 2026
f35c716
chore(mock): type the mock API against the dashboard's types
Gkrumbach07 Oct 19, 2026
63f027c
chore(i18n): one sandboxes namespace
Gkrumbach07 Oct 22, 2026
2acdd50
refactor(ui): one copy of the cells shared across workspaces
Gkrumbach07 Oct 22, 2026
84fa46f
feat(ui): page the Sandboxes page through the gateway
Gkrumbach07 Oct 22, 2026
bca77f3
fix(mock): answer the paged lists a page at a time
Gkrumbach07 Oct 22, 2026
22bbd92
test(e2e): answer the cross-workspace lists as the BFF does
Gkrumbach07 Oct 22, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -24,6 +24,18 @@ dev-full: ## The same behind sign-in: starts the gateway, Keycloak and the auth
scripts/dev-env.sh up --sign-in
@eval "$$(scripts/dev-env.sh env --sign-in)" && $(MAKE) -j3 dev-backend dev-frontend dev-proxy

# UI work against fixtures instead of a gateway. A Vite middleware plugin
# answers /api/v1 itself, so neither the BFF nor a gateway has to be running.
# See docs/mock-api.md for what the fixtures cover.
dev-mock: ## Start the frontend only (:3000), served from mock API data
cd frontend && MOCK_API=true npm start

# The same fixtures, emptied: the one `default` workspace a gateway starts
# with and nothing in it. This is how the Gateway page's get-started
# walkthrough, which stands in for the counts, is reached.
dev-mock-empty: ## Start the frontend only (:3000), as a gateway nobody has used yet
cd frontend && MOCK_API=true MOCK_EMPTY=true npm start

# Sign-in is off unless the caller says otherwise: AUTH_DISABLED=false make dev.
# make dev-full does, and adds what the BFF needs behind the auth proxy.
#
Expand Down
70 changes: 61 additions & 9 deletions backend/pkg/handlers/drafts_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,12 +88,24 @@ func (h *DraftsHandler) GetWorkspaceDraftSummary(w http.ResponseWriter, r *http.
}

// GetDraftSummary is the summary outside a workspace route. With ?workspace=
// it is that workspace's, the same answer as GetWorkspaceDraftSummary.
// it is that workspace's, the same answer as GetWorkspaceDraftSummary. With
// ?allWorkspaces=true it is every workspace's, which is what a platform
// admin's shell (the Proposals count in the navigation), Gateway page and
// Proposals page ask for: the gateway answers the all-workspaces sandbox list
// for platform admins only and refuses everyone else, and that refusal is
// relayed as it is (ADR 0002).
//
// Without one it answers an empty summary. A summary of every workspace would
// be one draft read per sandbox on the gateway, and a 403 for everyone who is
// not a platform admin.
// With neither it answers an empty summary. Frontends older than the
// workspace route poll this URL without a workspace, every few seconds and
// for every user; a summary of every workspace would be one draft read per
// sandbox on the gateway per poll, and a 403 for everyone who is not a
// platform admin. So the cross-workspace summary is asked for by name,
// never inferred.
func (h *DraftsHandler) GetDraftSummary(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("allWorkspaces") == "true" {
h.writeDraftSummary(w, r, "", everyWorkspace(""))
return
}
workspace := r.URL.Query().Get("workspace")
if workspace == "" {
apiutils.WriteJSON(w, http.StatusOK, models.DraftSummary{Sandboxes: []models.DraftSandboxSummary{}})
Expand All @@ -102,13 +114,18 @@ func (h *DraftsHandler) GetDraftSummary(w http.ResponseWriter, r *http.Request)
h.writeDraftSummary(w, r, workspace)
}

func (h *DraftsHandler) writeDraftSummary(w http.ResponseWriter, r *http.Request, workspace string) {
// writeDraftSummary summarizes the sandboxes the given list options select.
// Across workspaces the workspace argument is empty and the SDK ignores it;
// each sandbox names its own, which summarizeDrafts prefers. A sandbox that
// still resolves to no workspace is reported unavailable, never read with an
// empty selector (see summarizeDrafts).
func (h *DraftsHandler) writeDraftSummary(w http.ResponseWriter, r *http.Request, workspace string, opts ...openshell.ListOptions) {
if h.sandboxes == nil {
slog.Warn("draft summary requested, but the drafts handler was given no sandbox service")
apiutils.WriteJSON(w, http.StatusOK, models.DraftSummary{Sandboxes: []models.DraftSandboxSummary{}})
return
}
sandboxes, err := h.sandboxes.ListAll(r.Context(), workspace)
sandboxes, err := h.sandboxes.ListAll(r.Context(), workspace, opts...)
if err != nil {
apiutils.WriteSDKError(w, err)
return
Expand All @@ -123,13 +140,19 @@ func (h *DraftsHandler) writeDraftSummary(w http.ResponseWriter, r *http.Request
// unavailable, which a client must not read as none pending, and so does the
// entry of a sandbox the summary ran out of time for. A sandbox that is gone
// by the time it is read is left out: there is nothing to report on.
// workspace is the fallback for a sandbox that names none itself.
// workspace is the fallback for a sandbox that names none itself. Across
// workspaces there is no fallback, and a sandbox that names no workspace is
// reported unavailable without a read: the gateway refuses a selector with
// an empty name (hard fact 12), and a client could not lead anywhere from an
// entry without one.
func (h *DraftsHandler) summarizeDrafts(ctx context.Context, workspace string, sandboxes []*openshell.Sandbox) models.DraftSummary {
ctx, cancel := context.WithTimeout(ctx, draftSummaryTimeout)
defer cancel()

entries := make([]*models.DraftSandboxSummary, len(sandboxes))
read := make([]bool, len(sandboxes))
// Decided before any read starts, so the workers never see these.
markUnscoped(workspace, sandboxes, entries, read)
indexes := make(chan int)
var wg sync.WaitGroup
for range min(draftSummaryConcurrency, len(sandboxes)) {
Expand All @@ -147,6 +170,9 @@ func (h *DraftsHandler) summarizeDrafts(ctx context.Context, workspace string, s
}
// Nothing more is started once the client has gone or the time is up.
for i := range sandboxes {
if read[i] {
continue
}
if ctx.Err() != nil {
break
}
Expand Down Expand Up @@ -177,6 +203,23 @@ func (h *DraftsHandler) summarizeDrafts(ctx context.Context, workspace string, s
return summary
}

// markUnscoped reports every sandbox that resolves to no workspace as
// unavailable and marks it read, so it is never read with an empty selector
// (hard fact 12). It logs them once.
func markUnscoped(workspace string, sandboxes []*openshell.Sandbox, entries []*models.DraftSandboxSummary, read []bool) {
var unscoped []string
for i, sandbox := range sandboxes {
if sandbox != nil && sandboxWorkspaceOr(sandbox, workspace) == "" {
entries[i] = &models.DraftSandboxSummary{SandboxName: sandbox.Name, Unavailable: true}
read[i] = true
unscoped = append(unscoped, sandbox.Name)
}
}
if len(unscoped) > 0 {
slog.Warn("pending draft chunks not read for sandboxes listed without a workspace", "sandboxes", unscoped)
}
}

// sandboxWorkspaceOr is the workspace a sandbox names, or the one it was
// listed in when it names none.
func sandboxWorkspaceOr(sandbox *openshell.Sandbox, workspace string) string {
Expand Down Expand Up @@ -216,8 +259,17 @@ func (h *DraftsHandler) pendingDrafts(ctx context.Context, workspace, name strin
if chunk.SecurityNotes != "" {
entry.HasSecurityFlags = true
}
if !chunk.CreatedAt.IsZero() && chunk.CreatedAt.UnixMilli() > entry.LatestDraftMs {
entry.LatestDraftMs = chunk.CreatedAt.UnixMilli()
if chunk.CreatedAt.IsZero() {
// No creation time to order by: keep the first rationale we see.
if entry.LatestRationale == "" {
entry.LatestRationale = chunk.Rationale
}
continue
}
createdMs := chunk.CreatedAt.UnixMilli()
if createdMs > entry.LatestDraftMs {
entry.LatestDraftMs = createdMs
entry.LatestRationale = chunk.Rationale
}
}
return entry
Expand Down
151 changes: 135 additions & 16 deletions backend/pkg/handlers/drafts_summary_handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down Expand Up @@ -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"},
Expand All @@ -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
}
Expand All @@ -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) {
Expand Down Expand Up @@ -579,3 +628,73 @@ func TestDraftSummaryStopsWhenNobodyIsWaiting(t *testing.T) {
t.Errorf("%d sandboxes reported as unavailable, want the %d that were never read", unavailable, want)
}
}

// Across workspaces there is no workspace to fall back on. A sandbox the
// gateway lists without one is reported unavailable and never read: the
// gateway refuses a selector with an empty name (hard fact 12). Any other
// sandbox in the same list is read as usual.
func TestDraftSummaryAllWorkspacesSandboxWithoutAWorkspace(t *testing.T) {
tests := []struct {
name string
listed []*openshell.Sandbox
wantReads []draftRead
want models.DraftSummary
}{
{
name: "the only sandbox names no workspace",
listed: []*openshell.Sandbox{{Name: "orphan"}},
want: models.DraftSummary{
Sandboxes: []models.DraftSandboxSummary{{SandboxName: "orphan", Unavailable: true}},
},
wantReads: []draftRead{},
},
{
name: "one of several names no workspace",
listed: []*openshell.Sandbox{
{Name: "agent", Workspace: "team-a"},
{Name: "orphan"},
{Name: "quiet", Workspace: "team-b"},
},
want: models.DraftSummary{
TotalPending: 1,
Sandboxes: []models.DraftSandboxSummary{
{Workspace: "team-a", SandboxName: "agent", PendingCount: 1},
{SandboxName: "orphan", Unavailable: true},
},
},
wantReads: []draftRead{
{workspace: "team-a", sandbox: "agent", filter: "pending"},
{workspace: "team-b", sandbox: "quiet", filter: "pending"},
},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
sdk := &mockSDK{}
sdk.sandboxes.listFn = func(context.Context, string, ...openshell.ListOptions) ([]*openshell.Sandbox, error) {
return tc.listed, nil
}
inbox := &draftInbox{chunks: map[string][]openshell.PolicyChunk{
"team-a/agent": {{ID: "c1", Status: "pending"}},
}}
sdk.policy.getDraftFn = inbox.get

w := serve(t, draftSummaryRoutes(sdk), http.MethodGet, "/draft-summary?allWorkspaces=true", "")
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body: %s", w.Code, w.Body.String())
}
var got models.DraftSummary
decodeInto(t, w, &got)
if !reflect.DeepEqual(got, tc.want) {
t.Errorf("summary differs.\ngot: %+v\nwant: %+v", got, tc.want)
}
want := map[draftRead]int{}
for _, read := range tc.wantReads {
want[read]++
}
if reads := inbox.readSet(); !reflect.DeepEqual(reads, want) {
t.Errorf("draft reads = %v, want %v", reads, want)
}
})
}
}
11 changes: 7 additions & 4 deletions backend/pkg/models/observability.go
Original file line number Diff line number Diff line change
Expand Up @@ -128,15 +128,18 @@ type DraftHistoryEntry struct {

// DraftSandboxSummary counts the pending draft chunks of one sandbox, as read
// with GetDraftPolicy and the status filter "pending". HasSecurityFlags says
// whether any of them carries security notes, and LatestDraftMs is when the
// newest was created.
// whether any of them carries security notes, LatestDraftMs is when the newest
// was created, and LatestRationale is that chunk's rationale — what the Needs
// attention row shows under the sandbox name.
//
// Unavailable marks a sandbox whose inbox could not be read. Its count is not
// known, which is not the same as none pending, so PendingCount is 0 and
// means nothing.
type DraftSandboxSummary struct {
Workspace string `json:"workspace"`
SandboxName string `json:"sandboxName"`
Workspace string `json:"workspace"`
SandboxName string `json:"sandboxName"`
// The rationale of the newest pending chunk, when it has one.
LatestRationale string `json:"latestRationale,omitempty"`
LatestDraftMs int64 `json:"latestDraftMs"`
PendingCount int `json:"pendingCount"`
HasSecurityFlags bool `json:"hasSecurityFlags"`
Expand Down
Loading
Loading