feat(dashboard): the portfolio, grouped by the team that owns it - #539
Merged
Conversation
The third cockpit panel, and the one that most needs the product to be a portal. A list answers "which project is worst". This answers "which team is carrying the risk" — a question that requires an organisation, teams, and projects that outlive the scan that produced their numbers. `GET /v1/dashboard/portfolio` returns the caller's visible projects grouped by owning team, worst first, with the severity counts their project-list rows already show. Those counts come from `project_list_enrichment`'s own helpers rather than a third implementation of "worst CVE per component over the latest succeeded scan": a user who opens the grid and then the list is comparing the two, and a second copy would eventually disagree with the first. Nothing to keep in sync means no parity test needed to police it. Three things the design has to protect: **Zero means two different things.** A project nobody has scanned and one that came back clean have identical numbers. Only `scanned` separates them, so the unscanned cell gets its own dashed, untinted treatment and says "never scanned" in words — painting it like a clean project would tell the reader an unexamined project is fine. **Truncation is stated, at both levels.** The grid caps cells per team and teams per grid; a row that was cut says how many it dropped, and the grid says what fraction of the portfolio it is showing. A grid quietly showing the worst twelve reads exactly like a grid showing everything, and the conclusion drawn about the rest is the opposite in each case. **Teams the caller does not belong to are not enumerable**, not even as an empty row: team names are read only for teams that own a visible project. Severity is an ordinal domain scale, not a continuous magnitude, so this is not a colour-scale heatmap. A cell is tinted by its worst bucket and prints that bucket's count as text, with a dot for shape — colour is never the only signal. Which uncovered something: `bg-risk-critical/10` has never painted anything. Tailwind applies an opacity modifier only to colours whose channels it can parse, and these tokens hold a `var()` it cannot see into, so it emits no rule at all for the slashed class and the element falls back to transparent — confirmed by compiling the stylesheet (`grep 'risk-critical\\/' → 0 matches`) and by reading the computed style off a rendered cell. Thirty-one call sites across a dozen files are written that way today, and the token comments plus the design-system doc describe the spelling as though it worked. Repairing those is its own change; the four `risk-tint-*` classes added here do what the slash was meant to do, in one place, so this panel is not added to the pile.
The axe gate rejected the first spelling: `text-muted-foreground` on every cell, which clears AA on white and misses it on a 10 % severity tint — twelve nodes, all of them the count sitting on a tinted cell. Muted stays on the plain surfaces; tinted cells take `--foreground`. This is the class of defect the machine catches and a reviewer does not. The colour was right in isolation and wrong in context, and nothing about reading the diff would have said so.
Team scoping came back clean — the reviewer traced the chain and could not
construct a leak, and confirmed the isolation test would fail if the clamps
were removed. What it found instead was six ways the grid could mislead, and
the half of the service no test reached.
**Whole teams could vanish without a word.** A cut row can say so itself; a
cut team cannot, because it is not in the response at all. The caption said
"across 15 teams" beside twelve rendered rows, which reads as "these projects
span all fifteen". The response now carries shown_team_count and the caption
names both numbers.
**Teams were ranked on a sample of themselves.** The ranking read each row
after the per-team cap had already cut it, so a team's worst high — sitting on
a project whose critical count sorted it below the cut — never reached the
key. Two teams tied on criticals, one with a project carrying five highs and
one with a single high, came out in the wrong order, and at the MAX_TEAMS
boundary that decides which team disappears. The rank is computed from the
full list now, before any cell is dropped.
**The tie-break was not total.** projects.name has no unique constraint —
only (team_id, slug) does — so two projects in one team can share a name and
all four counts. The order then fell back to the row order of an unordered
SELECT, and which of the two survived the cap flipped between identical
requests. The project id is the last key component now.
**The tooltip contradicted the cell.** An unscanned project rendered "Never
scanned" in the cell and "Critical 0 · High 0 · Medium 0 · Low 0" on hover — a
positive claim that it was measured and found empty, which is the exact
reading the panel exists to prevent, surviving on the one surface nobody
checked. The tooltip now says what the cell says, and for scanned projects it
carries the last scan date, which is what last_scan_at was fetched for and
never rendered.
**The Korean caption moved the risk onto the wrong noun.** "위험이 큰 {{total}}개
중 {{shown}}개" reads as "of the N high-risk ones", implying the whole row is
high risk; the English says the shown subset is the worst. Same data, two
conclusions.
Also: the dead `team_id is None` branch is gone (the column is NOT NULL, and
its comment claimed a divergence with /summary that cannot happen), and the
cap comment no longer implies the caps bound the work — they bound the
response; every query above them is sized by the whole portfolio, the same
shape /summary has.
The tests the review asked for, and mutation-verified: teams ranked by their
worst project (fails if the rank reads the truncated list), the MAX_TEAMS cut
with its counts and which teams were dropped, and a full tie resolving to the
same twelve twice — asserted against the order the key defines, since
stability alone would be satisfied by a query plan that happens not to vary.
Documentation was missing for three cockpit panels, not just this one. The
dashboard guide described a page that no longer exists; it now covers the
action queue, the trend series and this grid in EN and KO, including the two
distinctions that are easy to misread: a flat line meaning "nobody looked",
and an unscanned project not meaning a clean one.
Captured on the Linux runner via the ui-gates dispatch. The dashboard grew a third cockpit panel; the other seven re-render byte-identically under --update-snapshots=all. The a11y baseline is unchanged — the grid adds no violations on CI's seed, which is the second time a local drop on projects-list has failed to reproduce there. It is residue in the local database, not a fix.
The narrow-viewport gate failed on the dashboard at 390 px, and the offender was the trend panel's screen-reader table — the one added for readers the chart cannot reach. `sr-only` works by shrinking an element to 1 px and clipping it. A table refuses to shrink below its min-content width, so the width declaration was ignored, the table kept its natural size, and the page gained a horizontal scrollbar on a phone for something nobody can see. The class belongs on a wrapper; the table sits inside it. This arrived with the trend panel and is on main right now — the gate passed there because CI's seed produces single-digit counts and a narrower table, while a portfolio with four-digit numbers pushes it past the line. A gate whose verdict depends on how big the seeded numbers are is one that will eventually let something through, which is what happened. Neither the a11y scan nor the visual baselines could have caught it: the element is invisible by design and affects only layout. Also on the grid: `truncate` does not bite on a flex child without `min-w-0`, and the project count needed `shrink-0` so a long team name cannot squeeze it out of the row.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The third cockpit panel, and the one that most needs the product to be a portal. A project list answers "which project is worst". This answers "which team is carrying the risk" — a question that requires an organisation, teams, and projects that outlive the scan that produced their numbers. It completes W16-b: action queue (#536), trends (#537), portfolio grid.
The endpoint
GET /v1/dashboard/portfolioreturns the caller's visible projects grouped by owning team, worst first, with the severity counts their project-list rows already show.Those counts come from
project_list_enrichment's own helpers rather than a third implementation of "worst CVE per component over the latest succeeded scan". A user who opens the grid and then the project list is comparing the two, and a second copy would eventually disagree with the first. Nothing to keep in sync means no parity test is needed to police it — the cheapest form of hardening rule #2.Three things the design has to protect:
Zero means two different things. A project nobody has scanned and one that came back clean have identical numbers. Only
scannedseparates them, so the unscanned cell gets its own dashed, untinted treatment and says so in words — painting it like a clean project would tell the reader an unexamined project is fine.Truncation is stated, at both levels. A grid quietly showing the worst twelve reads exactly like a grid showing everything, and the conclusion drawn about the rest is the opposite in each case.
Teams the caller does not belong to are not enumerable — not even as an empty row. Team names are looked up only for teams that own a visible project.
Severity is an ordinal domain scale, not a continuous magnitude, so this is not a colour-scale heatmap: a cell is tinted by its worst bucket, prints that bucket's count, and carries a dot for shape. Colour is never the only signal.
What the gates and the review caught
The axe gate rejected the first spelling of the count label.
text-muted-foregroundclears AA on white and misses it on a 10 % severity tint — twelve nodes, all of them the count sitting on a tinted cell. Exactly the class of defect a machine catches and a reviewer does not: the colour was right in isolation and wrong in context.The security review found team scoping clean (it traced the chain and confirmed the isolation test fails if the clamps are removed) and six ways the grid could still mislead. All are fixed in
cc68a415; the ones worth naming here:shown_team_count.highcould sit on a project cut before the ranking saw it. At theMAX_TEAMSboundary that decides which team disappears entirely.projects.namehas no unique constraint, so two projects in a team can share a name and all four counts, and which survived the cap flipped between identical requests.last_scan_atwas being fetched for and never rendered).Each new test was mutation-verified: reverting the ranking to the truncated list fails one, removing the id tie-break fails another.
A defect this uncovered, not fixed here
bg-risk-critical/10has never painted anything. Tailwind applies an opacity modifier only to colours whose channels it can parse, and these tokens hold avar()it cannot see into, so it emits no rule at all for the slashed class and the element falls back to transparent. Confirmed two ways: compiling the stylesheet (grep 'risk-critical\\/' → 0 matches) and reading the computed style off a rendered cell (rgba(0, 0, 0, 0)).Thirty-one call sites across a dozen files are written that way today, and the token comments plus the design-system doc describe the spelling as if it worked. Repairing those moves pixels on a dozen screens and needs fresh baselines, so it is its own change. The four
risk-tint-*classes added here do what the slash was meant to do, in one place, so this panel does not add to the pile.Documentation
The dashboard user guide described a page that no longer exists — it predates the action queue and the trend series as well as this grid. It now covers all three in EN and KO, including the two distinctions most easily misread: a flat trend line that may mean "nobody looked", and an unscanned project that is not a clean one.
Gates
Backend: 12 tests green, ruff and mypy (whole tree) clean, OpenAPI contract snapshot updated. Frontend: 93 unit tests green, tsc clean, token-lint holds at 130. axe: the dashboard screen passes after the contrast fix. EN/KO shipped together; ko-style 0 findings.
Visual baselines need recapturing on the Linux runner — the dashboard grew a third panel.
Still open in W16
The frozen a11y ratchet stands at 19, and W16's exit criterion is ≤ 12. Seven of those are
nested-interactivein the severity and licence distribution charts, which the dashboard and the project list share; fixing them there clears both screens. That is the remaining piece, and it is not in this change.