From 70ab77075a8ab04ceaa77ce157bb43927a88d988 Mon Sep 17 00:00:00 2001
From: Haksung Jang
Date: Wed, 29 Jul 2026 07:17:10 +0900
Subject: [PATCH 1/5] feat(dashboard): the portfolio, grouped by the team that
owns it
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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.
---
apps/backend/api/v1/dashboard.py | 30 ++
apps/backend/schemas/dashboard_portfolio.py | 76 ++++
.../services/dashboard_portfolio_service.py | 243 +++++++++++
.../test_dashboard_portfolio_api.py | 378 ++++++++++++++++++
.../backend/tests/unit/openapi_endpoints.json | 1 +
.../src/features/dashboard/DashboardPage.tsx | 9 +
.../src/features/dashboard/PortfolioGrid.tsx | 214 ++++++++++
.../src/features/dashboard/api/portfolio.ts | 60 +++
apps/frontend/src/index.css | 38 ++
apps/frontend/src/locales/en/dashboard.json | 17 +
apps/frontend/src/locales/ko/dashboard.json | 17 +
.../tests/_harness/representativeScreens.ts | 1 +
.../screenshots/capture_user_guide.spec.ts | 3 +
.../features/dashboard/PortfolioGrid.test.tsx | 225 +++++++++++
14 files changed, 1312 insertions(+)
create mode 100644 apps/backend/schemas/dashboard_portfolio.py
create mode 100644 apps/backend/services/dashboard_portfolio_service.py
create mode 100644 apps/backend/tests/integration/test_dashboard_portfolio_api.py
create mode 100644 apps/frontend/src/features/dashboard/PortfolioGrid.tsx
create mode 100644 apps/frontend/src/features/dashboard/api/portfolio.ts
create mode 100644 apps/frontend/tests/unit/features/dashboard/PortfolioGrid.test.tsx
diff --git a/apps/backend/api/v1/dashboard.py b/apps/backend/api/v1/dashboard.py
index 6a3fcb6f..557c382f 100644
--- a/apps/backend/api/v1/dashboard.py
+++ b/apps/backend/api/v1/dashboard.py
@@ -6,6 +6,7 @@
GET /v1/dashboard/summary → DashboardSummary
GET /v1/dashboard/action-queue → ActionQueue
GET /v1/dashboard/trends → DashboardTrends
+ GET /v1/dashboard/portfolio → DashboardPortfolio
Auth: requires :func:`get_current_user` (JWT). There is no ``team_id`` /
``project_id`` path parameter — the caller's identity *is* the scope. The
@@ -33,8 +34,10 @@
from core.security import CurrentUser, get_current_user
from schemas.action_queue import ActionQueue
from schemas.dashboard import DashboardSummary
+from schemas.dashboard_portfolio import DashboardPortfolio
from schemas.dashboard_trends import DashboardTrends, TrendWindow
from services.action_queue_service import get_action_queue
+from services.dashboard_portfolio_service import get_dashboard_portfolio
from services.dashboard_service import get_dashboard_summary
from services.dashboard_trends_service import get_dashboard_trends
@@ -120,3 +123,30 @@ async def get_dashboard_trends_endpoint(
query count is fixed but the exposure sets read grow with the portfolio.
"""
return await get_dashboard_trends(session, actor=actor, days=days)
+
+
+@router.get(
+ "/portfolio",
+ response_model=DashboardPortfolio,
+ status_code=status.HTTP_200_OK,
+ summary="Teams and their projects, by risk (auth required)",
+)
+@limiter.limit(api_read_rate_limit, key_func=_authenticated_user_key)
+async def get_dashboard_portfolio_endpoint(
+ request: Request,
+ session: AsyncSession = Depends(get_db),
+ actor: CurrentUser = Depends(get_current_user),
+) -> DashboardPortfolio:
+ """Every project the caller can see, grouped by the team that owns it.
+
+ The grouping is the point: a list answers "which project is worst", this
+ answers "which team is carrying the risk". Same scoping contract as the
+ other three — the caller's identity is the scope, and team names are read
+ only for teams that own a visible project, so a caller cannot enumerate
+ the organisation's teams through an empty row.
+
+ Both the per-team and the overall project caps are display limits, and the
+ response reports what they cut: a grid that silently showed a subset would
+ invite the reader to conclude the rest is clean.
+ """
+ return await get_dashboard_portfolio(session, actor=actor)
diff --git a/apps/backend/schemas/dashboard_portfolio.py b/apps/backend/schemas/dashboard_portfolio.py
new file mode 100644
index 00000000..3e07e4bd
--- /dev/null
+++ b/apps/backend/schemas/dashboard_portfolio.py
@@ -0,0 +1,76 @@
+"""Response models for ``GET /v1/dashboard/portfolio``.
+
+The org-wide view: every project the caller can see, grouped by the team that
+owns it, each carrying the severity counts its project-list row shows.
+
+Truncation is part of the contract rather than a silent detail. A deployment
+with hundreds of projects cannot render a cell per project, and a view that
+quietly showed the first dozen would read as "this is your portfolio" while
+being a sample of it. Every level that can be cut says how many it dropped.
+"""
+
+from __future__ import annotations
+
+import datetime
+import uuid
+
+from pydantic import BaseModel, Field
+
+
+class PortfolioProject(BaseModel):
+ """One project cell."""
+
+ project_id: uuid.UUID
+ project_name: str
+ critical: int = Field(ge=0)
+ high: int = Field(ge=0)
+ medium: int = Field(ge=0)
+ low: int = Field(ge=0)
+ scanned: bool = Field(
+ description=(
+ "False when no scan has ever succeeded for this project. The "
+ "counts are all zero in that case, which is not the same as a "
+ "clean project and must not render as one."
+ )
+ )
+ last_scan_at: datetime.datetime | None = Field(
+ default=None,
+ description="When the latest succeeded scan started. Null when never scanned.",
+ )
+
+
+class PortfolioTeam(BaseModel):
+ """One team's row of the grid."""
+
+ team_id: uuid.UUID
+ team_name: str
+ project_count: int = Field(
+ ge=0, description="Projects this team owns that the caller can see."
+ )
+ projects: list[PortfolioProject] = Field(
+ default_factory=list,
+ description=(
+ "Worst first. Capped per team — compare the length against "
+ "project_count to know whether the row is complete."
+ ),
+ )
+
+
+class DashboardPortfolio(BaseModel):
+ """Teams and their projects, scoped to what the caller may read."""
+
+ teams: list[PortfolioTeam] = Field(default_factory=list)
+ team_count: int = Field(
+ ge=0, description="Teams with at least one visible project, before any cap."
+ )
+ project_count: int = Field(ge=0, description="Visible projects, before any cap.")
+ shown_project_count: int = Field(
+ ge=0, description="Projects actually included in ``teams``."
+ )
+ truncated: bool = Field(
+ description=(
+ "True when any team row or the team list itself was cut. The UI "
+ "must say so — a grid that silently shows a subset invites the "
+ "reader to conclude the rest is clean."
+ )
+ )
diff --git a/apps/backend/services/dashboard_portfolio_service.py b/apps/backend/services/dashboard_portfolio_service.py
new file mode 100644
index 00000000..778312db
--- /dev/null
+++ b/apps/backend/services/dashboard_portfolio_service.py
@@ -0,0 +1,243 @@
+"""``/v1/dashboard/portfolio`` — the org-wide grid, grouped by team.
+
+What this adds over the project list is not the numbers; it is the grouping.
+A list answers "which project is worst"; a grid answers "which *team* is
+carrying the risk", and that is a question only a portal with an org and team
+model can ask at all.
+
+Not a third copy of the numbers
+-------------------------------
+The severity counts come from ``services.project_list_enrichment`` — the same
+two helpers the project list uses, called with the same arguments. That is
+deliberate: a user who opens this grid and then the project list is comparing
+the two, and a second implementation of "worst CVE per component, over the
+latest succeeded scan" would eventually disagree with the first. Reusing the
+helpers means there is nothing to keep in sync, which is the cheapest form of
+CLAUDE.md hardening rule #2 — no duplicated vocabulary, so no parity test
+needed to police it.
+
+That choice also inherits the list's anchor: the latest succeeded scan that is
+not superseded. It differs from ``dashboard_service._latest_succeeded_scan_ids``,
+which ignores ``superseded_at``. The grid follows the project list rather than
+the summary because those are the two screens a reader puts side by side.
+
+Scoping
+-------
+``_accessible_project_ids`` from ``dashboard_service`` decides what the caller
+may see, exactly as the summary, the action queue and the trend series do. The
+team names are then read only for teams that own one of those projects, so a
+team the caller does not belong to cannot be enumerated through this route
+even as an empty row.
+
+Truncation
+----------
+Caps are applied per team and across teams, and every cut is reported. A grid
+that silently showed the first dozen projects would read as the whole
+portfolio while being a sample of it, and the reader would draw exactly the
+wrong conclusion about the ones not shown.
+"""
+
+from __future__ import annotations
+
+import uuid
+from datetime import datetime
+
+import structlog
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from core.security import CurrentUser
+from models import Project, Scan
+from models.auth import Team
+from schemas.dashboard_portfolio import (
+ DashboardPortfolio,
+ PortfolioProject,
+ PortfolioTeam,
+)
+from services.dashboard_service import _accessible_project_ids
+from services.project_list_enrichment import (
+ _latest_succeeded_scan_id_map,
+ _severity_summary_map,
+)
+
+logger = structlog.get_logger("dashboard_portfolio.service")
+
+# Cells per team row, and rows per grid. Both are display limits: past a dozen
+# cells a row stops being scannable, and the reader should be on the project
+# list with a filter instead. Neither cap hides work silently — the response
+# carries the full counts and a `truncated` flag.
+PROJECTS_PER_TEAM = 12
+MAX_TEAMS = 12
+
+
+def _risk_key(project: PortfolioProject) -> tuple[int, int, int, int, str]:
+ """Sort key: worst first, then by name.
+
+ The name is not cosmetic. Without a tie-break, two equally clean projects
+ swap places between requests and whichever sits at the ``PROJECTS_PER_TEAM``
+ boundary changes at random — the grid would appear to reshuffle itself
+ while nothing changed.
+ """
+ return (
+ -project.critical,
+ -project.high,
+ -project.medium,
+ -project.low,
+ project.project_name,
+ )
+
+
+async def _project_rows(
+ session: AsyncSession,
+ *,
+ project_ids: list[uuid.UUID],
+) -> list[tuple[uuid.UUID, str, uuid.UUID | None]]:
+ """``(project_id, name, team_id)`` for the accessible projects."""
+ if not project_ids:
+ return []
+ stmt = select(Project.id, Project.name, Project.team_id).where(
+ Project.id.in_(project_ids)
+ )
+ return [(row[0], row[1], row[2]) for row in (await session.execute(stmt)).all()]
+
+
+async def _team_names(
+ session: AsyncSession,
+ *,
+ team_ids: list[uuid.UUID],
+) -> dict[uuid.UUID, str]:
+ """``{team_id: name}`` for teams that own a visible project.
+
+ Only these ids are ever looked up: reading every team in the organisation
+ and filtering afterwards would let a caller who belongs to one team learn
+ the names of the others through an empty row.
+ """
+ if not team_ids:
+ return {}
+ stmt = select(Team.id, Team.name).where(Team.id.in_(team_ids))
+ return {row[0]: row[1] for row in (await session.execute(stmt)).all()}
+
+
+async def _last_scan_at(
+ session: AsyncSession,
+ *,
+ scan_ids: list[uuid.UUID],
+) -> dict[uuid.UUID, datetime]:
+ """``{scan_id: created_at}`` for the anchor scans.
+
+ ``created_at``, not ``completed_at``: it is the clock every other
+ "latest succeeded scan" decision in the codebase orders by, and
+ ``completed_at`` is nullable on older rows.
+ """
+ if not scan_ids:
+ return {}
+ stmt = select(Scan.id, Scan.created_at).where(Scan.id.in_(scan_ids))
+ return {row[0]: row[1] for row in (await session.execute(stmt)).all()}
+
+
+async def get_dashboard_portfolio(
+ session: AsyncSession,
+ *,
+ actor: CurrentUser,
+) -> DashboardPortfolio:
+ """Build the team × project grid for ``actor``.
+
+ Read-only, five batched queries, no per-project work. Returns an empty
+ grid — not an error — for a caller with no accessible projects.
+ """
+ project_ids = await _accessible_project_ids(session, actor=actor)
+ if not project_ids:
+ logger.info("dashboard_portfolio.empty", actor_id=str(actor.id))
+ return DashboardPortfolio(
+ teams=[],
+ team_count=0,
+ project_count=0,
+ shown_project_count=0,
+ truncated=False,
+ )
+
+ rows = await _project_rows(session, project_ids=project_ids)
+ succeeded_by_project = await _latest_succeeded_scan_id_map(
+ session, project_ids=project_ids
+ )
+ severity_by_project = await _severity_summary_map(
+ session, succeeded_by_project=succeeded_by_project
+ )
+ scanned_at = await _last_scan_at(
+ session, scan_ids=list(succeeded_by_project.values())
+ )
+ names = await _team_names(
+ session, team_ids=[team_id for _, _, team_id in rows if team_id is not None]
+ )
+
+ by_team: dict[uuid.UUID, list[PortfolioProject]] = {}
+ for project_id, project_name, team_id in rows:
+ if team_id is None:
+ # A project with no owning team cannot appear in a grid whose rows
+ # are teams. It is still counted by /summary, so the two totals can
+ # differ; the grid says what it shows.
+ continue
+ summary = severity_by_project.get(project_id)
+ scan_id = succeeded_by_project.get(project_id)
+ by_team.setdefault(team_id, []).append(
+ PortfolioProject(
+ project_id=project_id,
+ project_name=project_name,
+ critical=(summary or {}).get("critical", 0),
+ high=(summary or {}).get("high", 0),
+ medium=(summary or {}).get("medium", 0),
+ low=(summary or {}).get("low", 0),
+ # "Never scanned" is not "clean": the counts are zero either
+ # way, so the flag is the only thing that separates a project
+ # nobody has looked at from one that came back empty.
+ scanned=summary is not None,
+ last_scan_at=scanned_at.get(scan_id) if scan_id else None,
+ )
+ )
+
+ teams: list[PortfolioTeam] = []
+ for team_id, projects in by_team.items():
+ projects.sort(key=_risk_key)
+ teams.append(
+ PortfolioTeam(
+ team_id=team_id,
+ team_name=names.get(team_id, ""),
+ project_count=len(projects),
+ projects=projects[:PROJECTS_PER_TEAM],
+ )
+ )
+
+ # Worst team first, by its worst project — the reason to group by team is
+ # to see which one is carrying the risk.
+ teams.sort(
+ key=lambda team: (
+ -max((p.critical for p in team.projects), default=0),
+ -max((p.high for p in team.projects), default=0),
+ team.team_name,
+ )
+ )
+ kept_teams = teams[:MAX_TEAMS]
+
+ shown = sum(len(team.projects) for team in kept_teams)
+ total_projects = sum(team.project_count for team in teams)
+ truncated = shown < total_projects or len(kept_teams) < len(teams)
+
+ logger.info(
+ "dashboard_portfolio.built",
+ actor_id=str(actor.id),
+ team_count=len(teams),
+ project_count=total_projects,
+ shown=shown,
+ truncated=truncated,
+ )
+
+ return DashboardPortfolio(
+ teams=kept_teams,
+ team_count=len(teams),
+ project_count=total_projects,
+ shown_project_count=shown,
+ truncated=truncated,
+ )
+
+
+__all__ = ["MAX_TEAMS", "PROJECTS_PER_TEAM", "get_dashboard_portfolio"]
diff --git a/apps/backend/tests/integration/test_dashboard_portfolio_api.py b/apps/backend/tests/integration/test_dashboard_portfolio_api.py
new file mode 100644
index 00000000..85880ee1
--- /dev/null
+++ b/apps/backend/tests/integration/test_dashboard_portfolio_api.py
@@ -0,0 +1,378 @@
+"""``GET /v1/dashboard/portfolio`` — the team × project grid.
+
+Service behaviour and the HTTP contract are asserted in one file because the
+route has no path parameter: what the caller can see is decided entirely by
+the scoping the service applies, so the isolation cases here *are* the access
+control, not a supplement to a per-resource check.
+
+Three things beyond the happy path are worth their own cases:
+
+ - **a team the caller does not belong to must not be enumerable**, not even
+ as an empty row carrying its name;
+ - **"never scanned" is not "clean"** — both render as zero counts, and only
+ the flag separates a project nobody has looked at from one that came back
+ empty;
+ - **truncation is reported**. A grid that silently showed the first dozen
+ projects would read as the whole portfolio, and the reader would conclude
+ the ones not shown are fine.
+"""
+
+from __future__ import annotations
+
+import os
+import subprocess
+import uuid
+from collections.abc import AsyncIterator
+from pathlib import Path
+
+import httpx
+import pytest
+from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
+
+from services.dashboard_portfolio_service import (
+ PROJECTS_PER_TEAM,
+ get_dashboard_portfolio,
+)
+from tests._helpers import (
+ make_membership,
+ make_organization,
+ make_project,
+ make_scan,
+ make_team,
+ make_user,
+ principal_for,
+ unique_suffix,
+)
+
+BACKEND_ROOT = Path(__file__).resolve().parent.parent.parent
+
+pytestmark = pytest.mark.integration
+
+PASSWORD = "correct-horse-battery-staple"
+
+
+def _require_database_url() -> str:
+ url = os.getenv("DATABASE_URL")
+ if not url:
+ pytest.skip("DATABASE_URL not set — skip dashboard portfolio tests")
+ return url
+
+
+@pytest.fixture(scope="module", autouse=True)
+def _migrate_once() -> None:
+ _require_database_url()
+ result = subprocess.run(
+ ["alembic", "upgrade", "head"],
+ cwd=BACKEND_ROOT,
+ capture_output=True,
+ text=True,
+ timeout=120,
+ )
+ if result.returncode != 0:
+ pytest.skip(f"alembic upgrade head failed:\n{result.stderr}")
+
+
+@pytest.fixture
+async def db_session() -> AsyncIterator[AsyncSession]:
+ from core.audit import install_audit_listeners
+ from core.config import database_url
+
+ engine = create_async_engine(database_url(), pool_pre_ping=True, future=True)
+ factory = async_sessionmaker(engine, expire_on_commit=False, class_=AsyncSession)
+ install_audit_listeners(factory)
+ async with factory() as session:
+ yield session
+ await engine.dispose()
+
+
+@pytest.fixture
+async def client() -> AsyncIterator[httpx.AsyncClient]:
+ from main import app
+
+ transport = httpx.ASGITransport(app=app)
+ async with httpx.AsyncClient(transport=transport, base_url="http://test") as c:
+ yield c
+
+
+async def _user_with_password(session: AsyncSession):
+ from core.security import hash_password
+ from models import User
+
+ suffix = unique_suffix()
+ user = User(
+ email=f"grid-{suffix}@example.com",
+ full_name=f"Grid {suffix}",
+ hashed_password=hash_password(PASSWORD),
+ is_active=True,
+ is_superuser=False,
+ )
+ session.add(user)
+ await session.commit()
+ await session.refresh(user)
+ return user
+
+
+async def _login(client: httpx.AsyncClient, email: str) -> str:
+ response = await client.post(
+ "/auth/login", json={"email": email, "password": PASSWORD}
+ )
+ assert response.status_code == 200, response.text
+ token = response.json()["access_token"]
+ assert isinstance(token, str)
+ return token
+
+
+async def _finding(
+ session: AsyncSession, *, scan_id: uuid.UUID, severity: str = "critical"
+) -> None:
+ from models import (
+ Component,
+ ComponentVersion,
+ ScanComponent,
+ Vulnerability,
+ VulnerabilityFinding,
+ )
+
+ suffix = unique_suffix()
+ purl = f"pkg:npm/pkg-{suffix}"
+ component = Component(purl=purl, package_type="npm", name=f"pkg-{suffix}")
+ session.add(component)
+ await session.commit()
+ await session.refresh(component)
+
+ cv = ComponentVersion(
+ component_id=component.id, version="1.0.0", purl_with_version=f"{purl}@1.0.0"
+ )
+ session.add(cv)
+ await session.commit()
+ await session.refresh(cv)
+
+ session.add(
+ ScanComponent(scan_id=scan_id, component_version_id=cv.id, direct=True, raw_data={})
+ )
+ vuln = Vulnerability(
+ external_id=f"CVE-2026-{suffix}",
+ source="NVD",
+ severity=severity,
+ summary="grid fixture",
+ )
+ session.add(vuln)
+ await session.commit()
+ await session.refresh(vuln)
+
+ session.add(
+ VulnerabilityFinding(
+ scan_id=scan_id, component_version_id=cv.id, vulnerability_id=vuln.id
+ )
+ )
+ await session.commit()
+
+
+async def _scanned_project(session: AsyncSession, *, team, criticals: int = 0):
+ project = await make_project(session, team=team)
+ scan = await make_scan(session, project=project, status="succeeded")
+ for _ in range(criticals):
+ await _finding(session, scan_id=scan.id)
+ return project
+
+
+# ---------------------------------------------------------------------------
+# HTTP contract
+# ---------------------------------------------------------------------------
+
+
+async def test_requires_authentication(client: httpx.AsyncClient) -> None:
+ response = await client.get("/v1/dashboard/portfolio")
+
+ assert response.status_code == 401
+ assert response.headers["content-type"].startswith("application/problem+json")
+
+
+async def test_a_caller_with_no_projects_gets_an_empty_grid(
+ client: httpx.AsyncClient, db_session: AsyncSession
+) -> None:
+ """Empty, not an error — the widget renders an empty state, not a failure."""
+ user = await _user_with_password(db_session)
+ token = await _login(client, user.email)
+
+ response = await client.get(
+ "/v1/dashboard/portfolio", headers={"Authorization": f"Bearer {token}"}
+ )
+
+ assert response.status_code == 200
+ body = response.json()
+ assert body["teams"] == []
+ assert body["project_count"] == 0
+ assert body["truncated"] is False
+
+
+async def test_the_grid_carries_only_the_callers_teams(
+ client: httpx.AsyncClient, db_session: AsyncSession
+) -> None:
+ """The access-control assertion for a route with no path parameter.
+
+ Team B's row must be absent entirely — a row with its name and no
+ projects would still disclose that the team exists and what it is called.
+ """
+ org = await make_organization(db_session)
+ team_a = await make_team(db_session, organization=org)
+ team_b = await make_team(db_session, organization=org)
+
+ await _scanned_project(db_session, team=team_a, criticals=1)
+ await _scanned_project(db_session, team=team_b, criticals=3)
+
+ member_a = await _user_with_password(db_session)
+ await make_membership(db_session, user=member_a, team=team_a, role="developer")
+ token = await _login(client, member_a.email)
+
+ response = await client.get(
+ "/v1/dashboard/portfolio", headers={"Authorization": f"Bearer {token}"}
+ )
+
+ assert response.status_code == 200
+ body = response.json()
+ assert [team["team_id"] for team in body["teams"]] == [str(team_a.id)]
+ assert body["teams"][0]["projects"][0]["critical"] == 1
+ assert body["project_count"] == 1
+
+
+# ---------------------------------------------------------------------------
+# Service behaviour
+# ---------------------------------------------------------------------------
+
+
+async def test_a_never_scanned_project_is_not_reported_as_clean(
+ db_session: AsyncSession,
+) -> None:
+ """Zero counts mean two different things, and only the flag separates them.
+
+ A project nobody has scanned and a project whose scan came back empty are
+ identical in every number on this grid. Without ``scanned`` the UI would
+ paint them the same colour and tell the reader the unscanned one is fine.
+ """
+ org = await make_organization(db_session)
+ team = await make_team(db_session, organization=org)
+ user = await make_user(db_session)
+ await make_membership(db_session, user=user, team=team, role="developer")
+
+ clean = await _scanned_project(db_session, team=team, criticals=0)
+ never = await make_project(db_session, team=team)
+
+ grid = await get_dashboard_portfolio(
+ db_session, actor=principal_for(user, team_ids=[team.id])
+ )
+
+ by_id = {p.project_id: p for p in grid.teams[0].projects}
+ assert by_id[clean.id].scanned is True
+ assert by_id[clean.id].last_scan_at is not None
+ assert by_id[never.id].scanned is False
+ assert by_id[never.id].last_scan_at is None
+ assert by_id[never.id].critical == 0
+
+
+async def test_projects_are_ordered_worst_first(db_session: AsyncSession) -> None:
+ org = await make_organization(db_session)
+ team = await make_team(db_session, organization=org)
+ user = await make_user(db_session)
+ await make_membership(db_session, user=user, team=team, role="developer")
+
+ quiet = await _scanned_project(db_session, team=team, criticals=0)
+ worst = await _scanned_project(db_session, team=team, criticals=2)
+ middle = await _scanned_project(db_session, team=team, criticals=1)
+
+ grid = await get_dashboard_portfolio(
+ db_session, actor=principal_for(user, team_ids=[team.id])
+ )
+
+ order = [p.project_id for p in grid.teams[0].projects]
+ assert order[:3] == [worst.id, middle.id, quiet.id]
+
+
+async def test_a_cut_row_says_how_much_it_cut(db_session: AsyncSession) -> None:
+ """Truncation is reported, not silent.
+
+ The reader has to be able to tell "these are all my projects" from "these
+ are the worst twelve", because the conclusion drawn about the ones not
+ shown is the opposite in each case.
+ """
+ org = await make_organization(db_session)
+ team = await make_team(db_session, organization=org)
+ user = await make_user(db_session)
+ await make_membership(db_session, user=user, team=team, role="developer")
+
+ over_cap = PROJECTS_PER_TEAM + 3
+ for _ in range(over_cap):
+ await _scanned_project(db_session, team=team, criticals=1)
+
+ grid = await get_dashboard_portfolio(
+ db_session, actor=principal_for(user, team_ids=[team.id])
+ )
+
+ assert len(grid.teams[0].projects) == PROJECTS_PER_TEAM
+ assert grid.teams[0].project_count == over_cap
+ assert grid.project_count == over_cap
+ assert grid.shown_project_count == PROJECTS_PER_TEAM
+ assert grid.truncated is True
+
+
+async def test_an_uncut_grid_says_so(db_session: AsyncSession) -> None:
+ """The mirror: `truncated` must not be true by default.
+
+ A flag that is always set carries no information, and the UI would show a
+ "showing a subset" caption on a complete grid forever.
+ """
+ org = await make_organization(db_session)
+ team = await make_team(db_session, organization=org)
+ user = await make_user(db_session)
+ await make_membership(db_session, user=user, team=team, role="developer")
+ await _scanned_project(db_session, team=team, criticals=1)
+
+ grid = await get_dashboard_portfolio(
+ db_session, actor=principal_for(user, team_ids=[team.id])
+ )
+
+ assert grid.truncated is False
+ assert grid.shown_project_count == grid.project_count == 1
+
+
+async def test_the_counts_match_the_project_list(db_session: AsyncSession) -> None:
+ """The grid and the project list must not disagree about the same project.
+
+ They share the aggregation helpers rather than each implementing "worst
+ CVE per component over the latest succeeded scan", and this pins that:
+ if the grid ever grows its own copy, the two drift and the user sees one
+ number on the list and another on the dashboard.
+ """
+ from services.project_list_enrichment import (
+ _latest_succeeded_scan_id_map,
+ _severity_summary_map,
+ )
+
+ org = await make_organization(db_session)
+ team = await make_team(db_session, organization=org)
+ user = await make_user(db_session)
+ await make_membership(db_session, user=user, team=team, role="developer")
+
+ project = await _scanned_project(db_session, team=team, criticals=2)
+ scan = await make_scan(db_session, project=project, status="succeeded")
+ await _finding(db_session, scan_id=scan.id, severity="high")
+
+ grid = await get_dashboard_portfolio(
+ db_session, actor=principal_for(user, team_ids=[team.id])
+ )
+ succeeded = await _latest_succeeded_scan_id_map(db_session, project_ids=[project.id])
+ expected = (
+ await _severity_summary_map(db_session, succeeded_by_project=succeeded)
+ )[project.id]
+
+ cell = next(p for p in grid.teams[0].projects if p.project_id == project.id)
+ assert (cell.critical, cell.high, cell.medium, cell.low) == (
+ expected["critical"],
+ expected["high"],
+ expected["medium"],
+ expected["low"],
+ )
+ # And the newest succeeded scan is the anchor: the older scan's two
+ # criticals must not leak into a summary that describes the newer one.
+ assert cell.critical == 0
+ assert cell.high == 1
diff --git a/apps/backend/tests/unit/openapi_endpoints.json b/apps/backend/tests/unit/openapi_endpoints.json
index 72d5ae0d..65ffd3cd 100644
--- a/apps/backend/tests/unit/openapi_endpoints.json
+++ b/apps/backend/tests/unit/openapi_endpoints.json
@@ -140,6 +140,7 @@
"component_id"
],
"GET /v1/dashboard/action-queue": [],
+ "GET /v1/dashboard/portfolio": [],
"GET /v1/dashboard/summary": [],
"GET /v1/dashboard/trends": [
"days"
diff --git a/apps/frontend/src/features/dashboard/DashboardPage.tsx b/apps/frontend/src/features/dashboard/DashboardPage.tsx
index d362d095..bc2898de 100644
--- a/apps/frontend/src/features/dashboard/DashboardPage.tsx
+++ b/apps/frontend/src/features/dashboard/DashboardPage.tsx
@@ -73,6 +73,7 @@ import {
import { formatRelativeToNow } from "@/lib/relativeTime";
import RelativeTime from "@/components/RelativeTime";
import { ActionQueuePanel } from "@/features/dashboard/ActionQueuePanel";
+import { PortfolioGrid } from "@/features/dashboard/PortfolioGrid";
import { TrendsPanel } from "@/features/dashboard/TrendsPanel";
// Project list page-size ceiling matches ProjectListPage (size=100, the
@@ -452,6 +453,14 @@ export function DashboardPage() {
+ {/* Then who is carrying it. The queue and the trend are portfolio
+ totals; this is the one view that says which team the totals
+ belong to — a question that needs an org, teams and projects
+ that outlive a scan. */}
+
+
+
+
{/* KPI grid — 4 cards on lg+, 2 on md, 1 on mobile. */}
= {
+ critical: "risk-tint-critical",
+ high: "risk-tint-high",
+ medium: "risk-tint-medium",
+ low: "risk-tint-low",
+ clean: "border-border bg-card",
+ // Deliberately not a risk tint and deliberately not the clean surface: an
+ // unmeasured project is its own state, and dashed edges say "no data" in a
+ // way a colour cannot.
+ unscanned: "border-dashed border-border bg-muted/40",
+};
+
+const DOT_TONE: Record = {
+ critical: "bg-risk-critical",
+ high: "bg-risk-high",
+ medium: "bg-risk-medium",
+ low: "bg-risk-low",
+};
+
+function worstBucket(project: PortfolioProject): Bucket | null {
+ return BUCKETS.find((bucket) => project[bucket] > 0) ?? null;
+}
+
+function ProjectCell({
+ project,
+ bucketLabel,
+ unscannedLabel,
+ cleanLabel,
+ breakdown,
+}: {
+ project: PortfolioProject;
+ bucketLabel: (bucket: Bucket) => string;
+ unscannedLabel: string;
+ cleanLabel: string;
+ breakdown: string;
+}) {
+ const worst = worstBucket(project);
+ const tone = !project.scanned ? "unscanned" : (worst ?? "clean");
+
+ return (
+
+
+ {worst ? (
+
+ ) : null}
+ {project.project_name}
+
+
+ {!project.scanned
+ ? unscannedLabel
+ : worst
+ ? `${bucketLabel(worst)} ${project[worst]}`
+ : cleanLabel}
+
+
+ );
+}
+
+export function PortfolioGrid() {
+ const { t } = useTranslation("dashboard");
+ const query = useDashboardPortfolio();
+
+ if (query.isPending) {
+ return (
+
+
+ {t("portfolio.heading")}
+
+
+ {[0, 1].map((index) => (
+
+ ))}
+
+
+ );
+ }
+
+ // An empty grid and a failed one look identical once rendered, and only one
+ // of them means "you have nothing to worry about".
+ if (query.isError) {
+ return (
+
+
+ {t("portfolio.heading")}
+
+
+ {t("portfolio.error")}
+
+
+ );
+ }
+
+ const portfolio = query.data;
+
+ return (
+
+
+ {t("portfolio.heading")}
+
+
+ {portfolio.teams.length === 0 ? (
+
+ ))}
+
+ {/* Saying what was left out is not a footnote here: a grid showing the
+ * worst twelve reads exactly like a grid showing everything, and the
+ * conclusion the reader draws about the rest is opposite in each case. */}
+ {portfolio.truncated ? (
+
+ ) : null}
+
+
+ );
+}
diff --git a/apps/frontend/src/features/dashboard/api/portfolio.ts b/apps/frontend/src/features/dashboard/api/portfolio.ts
new file mode 100644
index 00000000..1fcfcd92
--- /dev/null
+++ b/apps/frontend/src/features/dashboard/api/portfolio.ts
@@ -0,0 +1,60 @@
+import { useQuery } from "@tanstack/react-query";
+
+import { api } from "@/lib/api";
+
+/**
+ * `GET /v1/dashboard/portfolio` — every visible project, grouped by team.
+ *
+ * Mirrors `schemas/dashboard_portfolio.py`. Two fields carry meaning that is
+ * easy to lose in rendering:
+ *
+ * - `scanned` — false means nobody has ever scanned this project. The counts
+ * are zero either way, so without this flag an unscanned project paints
+ * exactly like a clean one.
+ * - `truncated` — the grid is capped per team and overall. When it is set the
+ * UI has to say so, or the reader concludes the projects not shown are fine.
+ */
+
+export interface PortfolioProject {
+ project_id: string;
+ project_name: string;
+ critical: number;
+ high: number;
+ medium: number;
+ low: number;
+ scanned: boolean;
+ last_scan_at: string | null;
+}
+
+export interface PortfolioTeam {
+ team_id: string;
+ team_name: string;
+ project_count: number;
+ projects: PortfolioProject[];
+}
+
+export interface DashboardPortfolio {
+ teams: PortfolioTeam[];
+ team_count: number;
+ project_count: number;
+ shown_project_count: number;
+ truncated: boolean;
+}
+
+export async function getDashboardPortfolio(): Promise {
+ const { data } = await api.get("/v1/dashboard/portfolio");
+ return data;
+}
+
+export const PORTFOLIO_QUERY_KEY = ["dashboard", "portfolio"] as const;
+
+export function useDashboardPortfolio() {
+ return useQuery({
+ queryKey: PORTFOLIO_QUERY_KEY,
+ queryFn: getDashboardPortfolio,
+ // Composition changes when a project is registered or a scan finishes —
+ // neither is frequent enough to poll for, and the aggregate reads every
+ // project in scope.
+ staleTime: 60_000,
+ });
+}
diff --git a/apps/frontend/src/index.css b/apps/frontend/src/index.css
index 46c764e9..43c5314e 100644
--- a/apps/frontend/src/index.css
+++ b/apps/frontend/src/index.css
@@ -348,3 +348,41 @@
scroll-behavior: auto !important;
}
}
+
+/* ---------------------------------------------------------------------------
+ * Risk tints (W16).
+ *
+ * `bg-risk-critical/10` does not exist. 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 — the element falls back to transparent and the tint silently never
+ * paints. The token comments above, and the design-system doc, describe
+ * `bg-risk-X/15` as though it worked. It does not, and the call sites across
+ * `src/` written in that spelling are painting nothing today. Repairing them
+ * is its own change (it moves pixels on a dozen screens and needs fresh
+ * baselines); what matters here is that new code does not add to the pile.
+ *
+ * `color-mix` does what the slash was meant to do, in one place, with the
+ * severity hex still the single source of the hue. Text on these stays
+ * `--foreground`: near-black on a 10 % tint over white is far above AA, so
+ * no per-severity text token is needed.
+ * ------------------------------------------------------------------------- */
+.risk-tint-critical {
+ background-color: color-mix(in srgb, var(--risk-critical) 10%, transparent);
+ border-color: color-mix(in srgb, var(--risk-critical) 40%, transparent);
+}
+
+.risk-tint-high {
+ background-color: color-mix(in srgb, var(--risk-high) 10%, transparent);
+ border-color: color-mix(in srgb, var(--risk-high) 40%, transparent);
+}
+
+.risk-tint-medium {
+ background-color: color-mix(in srgb, var(--risk-medium) 12%, transparent);
+ border-color: color-mix(in srgb, var(--risk-medium) 45%, transparent);
+}
+
+.risk-tint-low {
+ background-color: color-mix(in srgb, var(--risk-low) 8%, transparent);
+ border-color: color-mix(in srgb, var(--risk-low) 35%, transparent);
+}
diff --git a/apps/frontend/src/locales/en/dashboard.json b/apps/frontend/src/locales/en/dashboard.json
index 5316a54d..fc6bc1ad 100644
--- a/apps/frontend/src/locales/en/dashboard.json
+++ b/apps/frontend/src/locales/en/dashboard.json
@@ -82,5 +82,22 @@
"kev": "Open KEV"
},
"day_summary": "{{date}}: {{added}} new, {{resolved}} resolved"
+ },
+ "portfolio": {
+ "heading": "Portfolio by team",
+ "empty": "No projects yet. Register one to see your portfolio here.",
+ "error": "Could not load the portfolio. An empty grid would read as a clean one, which is not what this means.",
+ "team_projects": "{{count}} project(s)",
+ "never_scanned": "Never scanned",
+ "clean": "Clean",
+ "bucket": {
+ "critical": "Critical",
+ "high": "High",
+ "medium": "Medium",
+ "low": "Low"
+ },
+ "breakdown": "Critical {{critical}} · High {{high}} · Medium {{medium}} · Low {{low}}",
+ "team_truncated": "Showing the worst {{shown}} of {{total}}.",
+ "truncated": "Showing {{shown}} of {{total}} projects across {{teams}} team(s) — worst first. Open the project list for the rest."
}
}
diff --git a/apps/frontend/src/locales/ko/dashboard.json b/apps/frontend/src/locales/ko/dashboard.json
index c8f4f186..5425f6ca 100644
--- a/apps/frontend/src/locales/ko/dashboard.json
+++ b/apps/frontend/src/locales/ko/dashboard.json
@@ -82,5 +82,22 @@
"kev": "미해결 KEV"
},
"day_summary": "{{date}}: 신규 {{added}}건, 해소 {{resolved}}건"
+ },
+ "portfolio": {
+ "heading": "팀별 포트폴리오",
+ "empty": "아직 프로젝트가 없습니다. 하나 등록하면 여기에 포트폴리오가 보입니다.",
+ "error": "포트폴리오를 불러오지 못했습니다. 빈 격자는 깨끗하다는 뜻으로 읽히므로 그렇게 표시하지 않습니다.",
+ "team_projects": "프로젝트 {{count}}개",
+ "never_scanned": "스캔한 적 없음",
+ "clean": "깨끗함",
+ "bucket": {
+ "critical": "Critical",
+ "high": "High",
+ "medium": "Medium",
+ "low": "Low"
+ },
+ "breakdown": "Critical {{critical}} · High {{high}} · Medium {{medium}} · Low {{low}}",
+ "team_truncated": "위험이 큰 {{total}}개 중 {{shown}}개만 표시합니다.",
+ "truncated": "팀 {{teams}}개, 프로젝트 {{total}}개 중 {{shown}}개를 위험이 큰 순서로 표시합니다. 나머지는 프로젝트 목록에서 확인하세요."
}
}
diff --git a/apps/frontend/tests/_harness/representativeScreens.ts b/apps/frontend/tests/_harness/representativeScreens.ts
index e413342a..70f6d2d2 100644
--- a/apps/frontend/tests/_harness/representativeScreens.ts
+++ b/apps/frontend/tests/_harness/representativeScreens.ts
@@ -67,6 +67,7 @@ const VISITS: Record = {
// its error card) also means a broken endpoint fails the run loudly
// instead of quietly baselining an error state.
await page.getByTestId("trends-panel").waitFor({ state: "visible" });
+ await page.getByTestId("portfolio-grid").waitFor({ state: "visible" });
},
scans: async (page) => {
await page.goto("/scans");
diff --git a/apps/frontend/tests/screenshots/capture_user_guide.spec.ts b/apps/frontend/tests/screenshots/capture_user_guide.spec.ts
index 5f6e5b8a..d009acb0 100644
--- a/apps/frontend/tests/screenshots/capture_user_guide.spec.ts
+++ b/apps/frontend/tests/screenshots/capture_user_guide.spec.ts
@@ -146,6 +146,9 @@ test.describe.serial("@screenshots user-guide/dashboard", () => {
await page
.getByTestId("trends-panel")
.waitFor({ state: "visible", timeout: 15_000 });
+ await page
+ .getByTestId("portfolio-grid")
+ .waitFor({ state: "visible", timeout: 15_000 });
await captureScreenshot(page, "user-dashboard");
});
});
diff --git a/apps/frontend/tests/unit/features/dashboard/PortfolioGrid.test.tsx b/apps/frontend/tests/unit/features/dashboard/PortfolioGrid.test.tsx
new file mode 100644
index 00000000..f78fc777
--- /dev/null
+++ b/apps/frontend/tests/unit/features/dashboard/PortfolioGrid.test.tsx
@@ -0,0 +1,225 @@
+/**
+ * The portfolio grid.
+ *
+ * The assertions that matter are the three ways this view can mislead:
+ *
+ * - painting an unscanned project like a clean one (identical numbers, an
+ * opposite meaning);
+ * - showing a capped subset without saying so, which reads as the whole
+ * portfolio;
+ * - rendering an empty grid on a failed request, which reads as "nothing to
+ * worry about".
+ */
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { render, screen } from "@testing-library/react";
+import { MemoryRouter } from "react-router-dom";
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+import type {
+ DashboardPortfolio,
+ PortfolioProject,
+} from "@/features/dashboard/api/portfolio";
+import { PortfolioGrid } from "@/features/dashboard/PortfolioGrid";
+
+const apiGet = vi.hoisted(() => vi.fn());
+
+vi.mock("@/lib/api", () => ({ api: { get: apiGet } }));
+
+function project(overrides: Partial = {}): PortfolioProject {
+ return {
+ project_id: "p-1",
+ project_name: "payments-api",
+ critical: 0,
+ high: 0,
+ medium: 0,
+ low: 0,
+ scanned: true,
+ last_scan_at: "2026-07-20T10:00:00Z",
+ ...overrides,
+ };
+}
+
+function portfolio(overrides: Partial = {}): DashboardPortfolio {
+ const teams = overrides.teams ?? [
+ {
+ team_id: "t-1",
+ team_name: "Payments",
+ project_count: 1,
+ projects: [project()],
+ },
+ ];
+ const shown = teams.reduce((sum, team) => sum + team.projects.length, 0);
+ return {
+ teams,
+ team_count: teams.length,
+ project_count: shown,
+ shown_project_count: shown,
+ truncated: false,
+ ...overrides,
+ };
+}
+
+function renderGrid() {
+ const client = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
+ return render(
+
+
+
+
+ ,
+ );
+}
+
+afterEach(() => {
+ vi.clearAllMocks();
+});
+
+describe("PortfolioGrid", () => {
+ it("groups projects under the team that owns them", async () => {
+ apiGet.mockResolvedValue({
+ data: portfolio({
+ teams: [
+ {
+ team_id: "t-1",
+ team_name: "Payments",
+ project_count: 2,
+ projects: [
+ project({ project_id: "p-1", critical: 3 }),
+ project({ project_id: "p-2", project_name: "ledger", high: 1 }),
+ ],
+ },
+ ],
+ }),
+ });
+
+ renderGrid();
+
+ expect(await screen.findByTestId("portfolio-team-t-1")).toBeInTheDocument();
+ expect(screen.getByTestId("portfolio-cell-p-1")).toHaveAttribute(
+ "href",
+ "/projects/p-1",
+ );
+ expect(screen.getByTestId("portfolio-cell-p-2")).toHaveAttribute(
+ "data-tone",
+ "high",
+ );
+ });
+
+ it("does not paint an unscanned project like a clean one", async () => {
+ apiGet.mockResolvedValue({
+ data: portfolio({
+ teams: [
+ {
+ team_id: "t-1",
+ team_name: "Payments",
+ project_count: 2,
+ projects: [
+ project({ project_id: "p-clean" }),
+ project({
+ project_id: "p-never",
+ project_name: "registered-and-forgotten",
+ scanned: false,
+ last_scan_at: null,
+ }),
+ ],
+ },
+ ],
+ }),
+ });
+
+ renderGrid();
+
+ const clean = await screen.findByTestId("portfolio-cell-p-clean");
+ const never = screen.getByTestId("portfolio-cell-p-never");
+ // Same numbers, different state — and the difference is in the text, not
+ // only in the tint.
+ expect(clean).toHaveAttribute("data-tone", "clean");
+ expect(never).toHaveAttribute("data-tone", "unscanned");
+ expect(never.textContent).toContain("Never scanned");
+ expect(clean.textContent).toContain("Clean");
+ });
+
+ it("tints by the worst bucket and prints that bucket's count", async () => {
+ apiGet.mockResolvedValue({
+ data: portfolio({
+ teams: [
+ {
+ team_id: "t-1",
+ team_name: "Payments",
+ project_count: 1,
+ projects: [project({ critical: 2, high: 9, medium: 4 })],
+ },
+ ],
+ }),
+ });
+
+ renderGrid();
+
+ const cell = await screen.findByTestId("portfolio-cell-p-1");
+ expect(cell).toHaveAttribute("data-tone", "critical");
+ // Two criticals outrank nine highs: severity is ordinal, not a total.
+ expect(cell.textContent).toContain("Critical 2");
+ });
+
+ it("says when the grid is showing a subset", async () => {
+ apiGet.mockResolvedValue({
+ data: portfolio({
+ teams: [
+ {
+ team_id: "t-1",
+ team_name: "Payments",
+ project_count: 40,
+ projects: [project()],
+ },
+ ],
+ project_count: 40,
+ shown_project_count: 1,
+ truncated: true,
+ }),
+ });
+
+ renderGrid();
+
+ // Both levels report: the row that was cut, and the grid as a whole.
+ expect(await screen.findByTestId("portfolio-truncated")).toBeInTheDocument();
+ expect(screen.getByTestId("portfolio-team-truncated-t-1")).toBeInTheDocument();
+ });
+
+ it("stays quiet when nothing was cut", async () => {
+ apiGet.mockResolvedValue({ data: portfolio() });
+
+ renderGrid();
+
+ await screen.findByTestId("portfolio-grid");
+ // A caption that never goes away carries no information.
+ expect(screen.queryByTestId("portfolio-truncated")).toBeNull();
+ expect(screen.queryByTestId("portfolio-team-truncated-t-1")).toBeNull();
+ });
+
+ it("does not render an empty portfolio when the request failed", async () => {
+ apiGet.mockRejectedValue(new Error("boom"));
+
+ renderGrid();
+
+ expect(await screen.findByTestId("portfolio-error")).toBeInTheDocument();
+ expect(screen.queryByTestId("portfolio-empty")).toBeNull();
+ expect(screen.queryByTestId("portfolio-grid")).toBeNull();
+ });
+
+ it("offers a first step when there is genuinely nothing", async () => {
+ apiGet.mockResolvedValue({
+ data: portfolio({
+ teams: [],
+ team_count: 0,
+ project_count: 0,
+ shown_project_count: 0,
+ }),
+ });
+
+ renderGrid();
+
+ expect(await screen.findByTestId("portfolio-empty")).toBeInTheDocument();
+ });
+});
From bc974aecec108360cc883ad5be57555ee5f5d3e1 Mon Sep 17 00:00:00 2001
From: Haksung Jang
Date: Wed, 29 Jul 2026 07:26:29 +0900
Subject: [PATCH 2/5] fix(dashboard): the count label needed near-black on a
tint
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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.
---
.../src/features/dashboard/PortfolioGrid.tsx | 14 +++++++++++++-
1 file changed, 13 insertions(+), 1 deletion(-)
diff --git a/apps/frontend/src/features/dashboard/PortfolioGrid.tsx b/apps/frontend/src/features/dashboard/PortfolioGrid.tsx
index b6d96374..1341b984 100644
--- a/apps/frontend/src/features/dashboard/PortfolioGrid.tsx
+++ b/apps/frontend/src/features/dashboard/PortfolioGrid.tsx
@@ -98,7 +98,19 @@ function ProjectCell({
) : null}
{project.project_name}
-
+ {/* Near-black on a tint, muted on the plain surfaces. `text-muted-
+ * foreground` everywhere was the obvious spelling and the axe gate
+ * rejected it: the muted grey clears AA on white and misses it on a
+ * 10 % severity tint, which is precisely the case a machine catches
+ * and a reviewer does not. */}
+
{!project.scanned
? unscannedLabel
: worst
From cc68a4154b08a8a7f1c0f04d5ea617c27cf36974 Mon Sep 17 00:00:00 2001
From: Haksung Jang
Date: Wed, 29 Jul 2026 07:39:32 +0900
Subject: [PATCH 3/5] fix(dashboard): what the security review found in the
portfolio grid
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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.
---
apps/backend/schemas/dashboard_portfolio.py | 10 ++
.../services/dashboard_portfolio_service.py | 81 ++++++-----
.../test_dashboard_portfolio_api.py | 129 ++++++++++++++++++
.../src/features/dashboard/PortfolioGrid.tsx | 14 +-
.../src/features/dashboard/api/portfolio.ts | 1 +
apps/frontend/src/locales/en/dashboard.json | 5 +-
apps/frontend/src/locales/ko/dashboard.json | 7 +-
.../features/dashboard/PortfolioGrid.test.tsx | 14 +-
docs-site/docs/user-guide/dashboard.md | 24 ++--
.../current/user-guide/dashboard.md | 15 +-
10 files changed, 245 insertions(+), 55 deletions(-)
diff --git a/apps/backend/schemas/dashboard_portfolio.py b/apps/backend/schemas/dashboard_portfolio.py
index 3e07e4bd..f49277ce 100644
--- a/apps/backend/schemas/dashboard_portfolio.py
+++ b/apps/backend/schemas/dashboard_portfolio.py
@@ -63,6 +63,16 @@ class DashboardPortfolio(BaseModel):
team_count: int = Field(
ge=0, description="Teams with at least one visible project, before any cap."
)
+ shown_team_count: int = Field(
+ ge=0,
+ description=(
+ "Team rows actually included. Reported separately from "
+ "``team_count`` because a dropped team leaves no trace in "
+ "``teams`` at all — its per-row caption cannot fire, so without "
+ "this number a reader counting rows against ``team_count`` "
+ "concludes the shown projects are spread across every team."
+ ),
+ )
project_count: int = Field(ge=0, description="Visible projects, before any cap.")
shown_project_count: int = Field(
ge=0, description="Projects actually included in ``teams``."
diff --git a/apps/backend/services/dashboard_portfolio_service.py b/apps/backend/services/dashboard_portfolio_service.py
index 778312db..6145f322 100644
--- a/apps/backend/services/dashboard_portfolio_service.py
+++ b/apps/backend/services/dashboard_portfolio_service.py
@@ -64,19 +64,26 @@
# Cells per team row, and rows per grid. Both are display limits: past a dozen
# cells a row stops being scannable, and the reader should be on the project
-# list with a filter instead. Neither cap hides work silently — the response
-# carries the full counts and a `truncated` flag.
+# list with a filter instead. They bound the RESPONSE, not the work — every
+# query above them is sized by the caller's whole portfolio, the same shape
+# `/summary` has. What they must never do is hide a cut silently, so the
+# response carries the full counts alongside what it actually shows.
PROJECTS_PER_TEAM = 12
MAX_TEAMS = 12
-def _risk_key(project: PortfolioProject) -> tuple[int, int, int, int, str]:
- """Sort key: worst first, then by name.
+def _risk_key(project: PortfolioProject) -> tuple[int, int, int, int, str, str]:
+ """Sort key: worst first, then by name, then by id.
- The name is not cosmetic. Without a tie-break, two equally clean projects
- swap places between requests and whichever sits at the ``PROJECTS_PER_TEAM``
- boundary changes at random — the grid would appear to reshuffle itself
- while nothing changed.
+ The tie-break is not cosmetic. Without one, two equally clean projects
+ swap places between requests and whichever sits at the
+ ``PROJECTS_PER_TEAM`` boundary changes at random — the grid would appear
+ to reshuffle itself while nothing changed.
+
+ The id is there because the name is not enough: ``projects.name`` carries
+ no unique constraint (only ``(team_id, slug)`` does), so two projects in
+ one team can share a name and four identical counts. The id makes the
+ order total, which is what "deterministic" has to mean.
"""
return (
-project.critical,
@@ -84,6 +91,7 @@ def _risk_key(project: PortfolioProject) -> tuple[int, int, int, int, str]:
-project.medium,
-project.low,
project.project_name,
+ str(project.project_id),
)
@@ -91,8 +99,12 @@ async def _project_rows(
session: AsyncSession,
*,
project_ids: list[uuid.UUID],
-) -> list[tuple[uuid.UUID, str, uuid.UUID | None]]:
- """``(project_id, name, team_id)`` for the accessible projects."""
+) -> list[tuple[uuid.UUID, str, uuid.UUID]]:
+ """``(project_id, name, team_id)`` for the accessible projects.
+
+ ``team_id`` is NOT NULL on the table, so every project has an owning team
+ and every row belongs on the grid.
+ """
if not project_ids:
return []
stmt = select(Project.id, Project.name, Project.team_id).where(
@@ -151,6 +163,7 @@ async def get_dashboard_portfolio(
return DashboardPortfolio(
teams=[],
team_count=0,
+ shown_team_count=0,
project_count=0,
shown_project_count=0,
truncated=False,
@@ -166,17 +179,10 @@ async def get_dashboard_portfolio(
scanned_at = await _last_scan_at(
session, scan_ids=list(succeeded_by_project.values())
)
- names = await _team_names(
- session, team_ids=[team_id for _, _, team_id in rows if team_id is not None]
- )
+ names = await _team_names(session, team_ids=[team_id for _, _, team_id in rows])
by_team: dict[uuid.UUID, list[PortfolioProject]] = {}
for project_id, project_name, team_id in rows:
- if team_id is None:
- # A project with no owning team cannot appear in a grid whose rows
- # are teams. It is still counted by /summary, so the two totals can
- # differ; the grid says what it shows.
- continue
summary = severity_by_project.get(project_id)
scan_id = succeeded_by_project.get(project_id)
by_team.setdefault(team_id, []).append(
@@ -195,27 +201,37 @@ async def get_dashboard_portfolio(
)
)
- teams: list[PortfolioTeam] = []
+ # Rank each team from its FULL project list, before any cell is cut. The
+ # obvious version ranks the rows after truncating them, and it is wrong in
+ # a way that only shows at the boundary: a team's worst `high` can sit on a
+ # project that sorts below the cut because its `critical` is lower, so the
+ # team is ranked — and possibly dropped by MAX_TEAMS — on a sample of
+ # itself.
+ ranked: list[tuple[tuple[int, int, str], PortfolioTeam]] = []
for team_id, projects in by_team.items():
projects.sort(key=_risk_key)
- teams.append(
- PortfolioTeam(
- team_id=team_id,
- team_name=names.get(team_id, ""),
- project_count=len(projects),
- projects=projects[:PROJECTS_PER_TEAM],
+ team_name = names.get(team_id, "")
+ rank = (
+ -max((p.critical for p in projects), default=0),
+ -max((p.high for p in projects), default=0),
+ team_name,
+ )
+ ranked.append(
+ (
+ rank,
+ PortfolioTeam(
+ team_id=team_id,
+ team_name=team_name,
+ project_count=len(projects),
+ projects=projects[:PROJECTS_PER_TEAM],
+ ),
)
)
# Worst team first, by its worst project — the reason to group by team is
# to see which one is carrying the risk.
- teams.sort(
- key=lambda team: (
- -max((p.critical for p in team.projects), default=0),
- -max((p.high for p in team.projects), default=0),
- team.team_name,
- )
- )
+ ranked.sort(key=lambda entry: entry[0])
+ teams = [team for _, team in ranked]
kept_teams = teams[:MAX_TEAMS]
shown = sum(len(team.projects) for team in kept_teams)
@@ -234,6 +250,7 @@ async def get_dashboard_portfolio(
return DashboardPortfolio(
teams=kept_teams,
team_count=len(teams),
+ shown_team_count=len(kept_teams),
project_count=total_projects,
shown_project_count=shown,
truncated=truncated,
diff --git a/apps/backend/tests/integration/test_dashboard_portfolio_api.py b/apps/backend/tests/integration/test_dashboard_portfolio_api.py
index 85880ee1..31e7ac45 100644
--- a/apps/backend/tests/integration/test_dashboard_portfolio_api.py
+++ b/apps/backend/tests/integration/test_dashboard_portfolio_api.py
@@ -30,6 +30,7 @@
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from services.dashboard_portfolio_service import (
+ MAX_TEAMS,
PROJECTS_PER_TEAM,
get_dashboard_portfolio,
)
@@ -376,3 +377,131 @@ async def test_the_counts_match_the_project_list(db_session: AsyncSession) -> No
# criticals must not leak into a summary that describes the newer one.
assert cell.critical == 0
assert cell.high == 1
+
+
+# ---------------------------------------------------------------------------
+# More than one team — the half of the service a single-team fixture cannot
+# reach at all: the team ranking, the MAX_TEAMS cut, and the counts that
+# describe it.
+# ---------------------------------------------------------------------------
+
+
+async def test_teams_are_ranked_by_their_worst_project(
+ db_session: AsyncSession,
+) -> None:
+ """Worst team first — and ranked on the whole team, not on what fits.
+
+ The subtle version of this bug ranks each row after truncating it. A
+ team's worst `high` can sit on a project that sorts below the cut because
+ its `critical` is lower, so the team gets ranked on a sample of itself and
+ can be pushed down — or off — the grid by cells it was never shown.
+ """
+ org = await make_organization(db_session)
+ user = await make_user(db_session)
+
+ # Both teams tie on criticals; the tie breaks on `high`, which for the
+ # first team lives on a project that the per-team cap will cut.
+ loud = await make_team(db_session, organization=org)
+ await make_membership(db_session, user=user, team=loud, role="developer")
+ for _ in range(PROJECTS_PER_TEAM):
+ await _scanned_project(db_session, team=loud, criticals=1)
+ buried = await make_project(db_session, team=loud)
+ buried_scan = await make_scan(db_session, project=buried, status="succeeded")
+ for _ in range(5):
+ await _finding(db_session, scan_id=buried_scan.id, severity="high")
+
+ quiet = await make_team(db_session, organization=org)
+ await make_membership(db_session, user=user, team=quiet, role="developer")
+ project = await _scanned_project(db_session, team=quiet, criticals=1)
+ quiet_scan = await make_scan(db_session, project=project, status="succeeded")
+ await _finding(db_session, scan_id=quiet_scan.id, severity="critical")
+ await _finding(db_session, scan_id=quiet_scan.id, severity="high")
+
+ grid = await get_dashboard_portfolio(
+ db_session, actor=principal_for(user, team_ids=[loud.id, quiet.id])
+ )
+
+ order = [team.team_id for team in grid.teams]
+ assert order == [loud.id, quiet.id], (
+ "the team with five highs ranked below the one with a single high — "
+ "its worst project was cut before the ranking looked at it"
+ )
+ # And the cut project really is absent from the row that outranked on it.
+ loud_row = next(team for team in grid.teams if team.team_id == loud.id)
+ assert buried.id not in {p.project_id for p in loud_row.projects}
+
+
+async def test_the_team_cap_reports_both_what_it_kept_and_what_it_dropped(
+ db_session: AsyncSession,
+) -> None:
+ """A dropped team leaves no trace in ``teams`` — so the counts must.
+
+ A row that is cut can say so itself. A team that is cut cannot: it is not
+ in the response at all, so a reader counting rows against ``team_count``
+ would conclude the shown projects are spread across every team.
+ """
+ org = await make_organization(db_session)
+ user = await make_user(db_session)
+ over_cap = MAX_TEAMS + 2
+ team_ids = []
+ for index in range(over_cap):
+ team = await make_team(db_session, organization=org)
+ await make_membership(db_session, user=user, team=team, role="developer")
+ team_ids.append(team.id)
+ # Descending risk, so the two teams that fall off the end are known.
+ await _scanned_project(db_session, team=team, criticals=over_cap - index)
+
+ grid = await get_dashboard_portfolio(
+ db_session, actor=principal_for(user, team_ids=team_ids)
+ )
+
+ assert len(grid.teams) == MAX_TEAMS
+ assert grid.shown_team_count == MAX_TEAMS
+ assert grid.team_count == over_cap
+ assert grid.project_count == over_cap
+ assert grid.shown_project_count == MAX_TEAMS
+ assert grid.truncated is True
+ # The two least risky teams are the ones dropped, not two arbitrary ones.
+ assert [team.team_id for team in grid.teams] == team_ids[:MAX_TEAMS]
+
+
+async def test_a_full_tie_still_resolves_to_the_same_twelve(
+ db_session: AsyncSession,
+) -> None:
+ """The tie-break has to be total, and this is the case that proves it.
+
+ ``projects.name`` carries no unique constraint — only ``(team_id, slug)``
+ does — so two projects in one team can share a name and all four counts.
+ With the name as the last key component the order then falls back to
+ whatever row order Postgres returned, and which of the two is shown flips
+ between requests with no data change.
+ """
+ org = await make_organization(db_session)
+ team = await make_team(db_session, organization=org)
+ user = await make_user(db_session)
+ await make_membership(db_session, user=user, team=team, role="developer")
+
+ shared_name = f"api-{unique_suffix()}"
+ created: list[uuid.UUID] = []
+ for _ in range(PROJECTS_PER_TEAM + 2):
+ project = await make_project(db_session, team=team)
+ project.name = shared_name
+ await db_session.commit()
+ scan = await make_scan(db_session, project=project, status="succeeded")
+ await _finding(db_session, scan_id=scan.id)
+ created.append(project.id)
+
+ actor = principal_for(user, team_ids=[team.id])
+ first = await get_dashboard_portfolio(db_session, actor=actor)
+ second = await get_dashboard_portfolio(db_session, actor=actor)
+
+ kept_first = [p.project_id for p in first.teams[0].projects]
+ kept_second = [p.project_id for p in second.teams[0].projects]
+ assert len(kept_first) == PROJECTS_PER_TEAM
+ assert kept_first == kept_second, (
+ "the twelve cells shown changed between two identical requests"
+ )
+ # Stability alone is a weak oracle — a query plan that happens not to vary
+ # would satisfy it. The order has to be the one the key defines, which for
+ # a full tie is by project id.
+ assert kept_first == sorted(created, key=str)[:PROJECTS_PER_TEAM]
diff --git a/apps/frontend/src/features/dashboard/PortfolioGrid.tsx b/apps/frontend/src/features/dashboard/PortfolioGrid.tsx
index 1341b984..6cfc0dec 100644
--- a/apps/frontend/src/features/dashboard/PortfolioGrid.tsx
+++ b/apps/frontend/src/features/dashboard/PortfolioGrid.tsx
@@ -66,12 +66,14 @@ function ProjectCell({
unscannedLabel,
cleanLabel,
breakdown,
+ unscannedTooltip,
}: {
project: PortfolioProject;
bucketLabel: (bucket: Bucket) => string;
unscannedLabel: string;
cleanLabel: string;
breakdown: string;
+ unscannedTooltip: string;
}) {
const worst = worstBucket(project);
const tone = !project.scanned ? "unscanned" : (worst ?? "clean");
@@ -81,7 +83,12 @@ function ProjectCell({
to={`/projects/${project.project_id}`}
data-testid={`portfolio-cell-${project.project_id}`}
data-tone={tone}
- title={breakdown}
+ // The cell text and the tint both say "never scanned"; the tooltip has
+ // to as well. "Critical 0 · High 0 · …" on an unmeasured project is a
+ // positive claim that it was looked at and found empty — the exact
+ // reading this component exists to prevent, surviving on the one
+ // surface nobody thought to check.
+ title={project.scanned ? breakdown : unscannedTooltip}
className={cn(
"flex min-w-0 items-center justify-between gap-2 rounded-md border px-3 py-2",
"text-sm transition-colors duration-fast ease-out-soft hover:bg-accent",
@@ -190,7 +197,11 @@ export function PortfolioGrid() {
high: project.high,
medium: project.medium,
low: project.low,
+ scanned: project.last_scan_at
+ ? new Date(project.last_scan_at).toLocaleDateString()
+ : "—",
})}
+ unscannedTooltip={t("portfolio.never_scanned_hint")}
/>
))}
@@ -216,6 +227,7 @@ export function PortfolioGrid() {
{t("portfolio.truncated", {
shown: portfolio.shown_project_count,
total: portfolio.project_count,
+ shownTeams: portfolio.shown_team_count,
teams: portfolio.team_count,
})}
diff --git a/apps/frontend/src/features/dashboard/api/portfolio.ts b/apps/frontend/src/features/dashboard/api/portfolio.ts
index 1fcfcd92..a248765e 100644
--- a/apps/frontend/src/features/dashboard/api/portfolio.ts
+++ b/apps/frontend/src/features/dashboard/api/portfolio.ts
@@ -36,6 +36,7 @@ export interface PortfolioTeam {
export interface DashboardPortfolio {
teams: PortfolioTeam[];
team_count: number;
+ shown_team_count: number;
project_count: number;
shown_project_count: number;
truncated: boolean;
diff --git a/apps/frontend/src/locales/en/dashboard.json b/apps/frontend/src/locales/en/dashboard.json
index fc6bc1ad..0579d9d5 100644
--- a/apps/frontend/src/locales/en/dashboard.json
+++ b/apps/frontend/src/locales/en/dashboard.json
@@ -96,8 +96,9 @@
"medium": "Medium",
"low": "Low"
},
- "breakdown": "Critical {{critical}} · High {{high}} · Medium {{medium}} · Low {{low}}",
+ "breakdown": "Critical {{critical}} · High {{high}} · Medium {{medium}} · Low {{low}} — last scan {{scanned}}",
"team_truncated": "Showing the worst {{shown}} of {{total}}.",
- "truncated": "Showing {{shown}} of {{total}} projects across {{teams}} team(s) — worst first. Open the project list for the rest."
+ "truncated": "Showing {{shown}} of {{total}} projects across {{shownTeams}} of {{teams}} teams — worst first. Open the project list for the rest.",
+ "never_scanned_hint": "No scan has ever succeeded for this project, so there is nothing measured to show."
}
}
diff --git a/apps/frontend/src/locales/ko/dashboard.json b/apps/frontend/src/locales/ko/dashboard.json
index 5425f6ca..fe507cf8 100644
--- a/apps/frontend/src/locales/ko/dashboard.json
+++ b/apps/frontend/src/locales/ko/dashboard.json
@@ -96,8 +96,9 @@
"medium": "Medium",
"low": "Low"
},
- "breakdown": "Critical {{critical}} · High {{high}} · Medium {{medium}} · Low {{low}}",
- "team_truncated": "위험이 큰 {{total}}개 중 {{shown}}개만 표시합니다.",
- "truncated": "팀 {{teams}}개, 프로젝트 {{total}}개 중 {{shown}}개를 위험이 큰 순서로 표시합니다. 나머지는 프로젝트 목록에서 확인하세요."
+ "breakdown": "Critical {{critical}} · High {{high}} · Medium {{medium}} · Low {{low}} — 마지막 스캔 {{scanned}}",
+ "team_truncated": "{{total}}개 중 위험이 큰 {{shown}}개를 표시합니다.",
+ "truncated": "팀 {{teams}}개 중 {{shownTeams}}개, 프로젝트 {{total}}개 중 {{shown}}개를 위험이 큰 순서로 표시합니다. 나머지는 프로젝트 목록에서 확인하세요.",
+ "never_scanned_hint": "이 프로젝트는 성공한 스캔이 없어 아직 측정된 값이 없습니다."
}
}
diff --git a/apps/frontend/tests/unit/features/dashboard/PortfolioGrid.test.tsx b/apps/frontend/tests/unit/features/dashboard/PortfolioGrid.test.tsx
index f78fc777..33776f60 100644
--- a/apps/frontend/tests/unit/features/dashboard/PortfolioGrid.test.tsx
+++ b/apps/frontend/tests/unit/features/dashboard/PortfolioGrid.test.tsx
@@ -52,6 +52,7 @@ function portfolio(overrides: Partial = {}): DashboardPortfo
return {
teams,
team_count: teams.length,
+ shown_team_count: teams.length,
project_count: shown,
shown_project_count: shown,
truncated: false,
@@ -139,6 +140,11 @@ describe("PortfolioGrid", () => {
expect(never).toHaveAttribute("data-tone", "unscanned");
expect(never.textContent).toContain("Never scanned");
expect(clean.textContent).toContain("Clean");
+ // The tooltip is the surface where this distinction is easiest to lose:
+ // "Critical 0 · High 0 · …" on an unmeasured project claims it was looked
+ // at and found empty.
+ expect(never.getAttribute("title")).not.toContain("Critical 0");
+ expect(clean.getAttribute("title")).toContain("Critical 0");
});
it("tints by the worst bucket and prints that bucket's count", async () => {
@@ -174,6 +180,8 @@ describe("PortfolioGrid", () => {
projects: [project()],
},
],
+ team_count: 15,
+ shown_team_count: 1,
project_count: 40,
shown_project_count: 1,
truncated: true,
@@ -183,8 +191,12 @@ describe("PortfolioGrid", () => {
renderGrid();
// Both levels report: the row that was cut, and the grid as a whole.
- expect(await screen.findByTestId("portfolio-truncated")).toBeInTheDocument();
+ const caption = await screen.findByTestId("portfolio-truncated");
expect(screen.getByTestId("portfolio-team-truncated-t-1")).toBeInTheDocument();
+ // A dropped TEAM leaves no row to carry its own caption, so the grid-level
+ // one has to name both numbers. Saying "across 15 teams" beside a single
+ // rendered row reads as "these projects span all fifteen".
+ expect(caption.textContent).toContain("1 of 15 teams");
});
it("stays quiet when nothing was cut", async () => {
diff --git a/docs-site/docs/user-guide/dashboard.md b/docs-site/docs/user-guide/dashboard.md
index 0995de78..e2c59f10 100644
--- a/docs-site/docs/user-guide/dashboard.md
+++ b/docs-site/docs/user-guide/dashboard.md
@@ -16,11 +16,12 @@ The team control in the global bar records which team you are acting *as* — ne

-The page exists to answer the four questions you typically arrive with:
+The page exists to answer the questions you typically arrive with, in the order you tend to ask them:
-- *Are there any new criticals?* (severity tiles)
-- *How many projects am I responsible for, and how many are in flight?* (portfolio + scan-status tiles)
-- *Is the license mix shifting?* (license bar)
+- *What needs me right now?* (action queue)
+- *Is the pile growing or shrinking?* (risk over time)
+- *Who is carrying the risk?* (portfolio by team)
+- *Are there any new criticals, and is the license mix shifting?* (severity tiles, license bar)
- *What ran recently?* (recent scans list)
:::note Audience
@@ -29,14 +30,17 @@ Any signed-in user. The data scoping follows your team memberships — projects
## What's on the page
-The Dashboard renders four bands stacked top-to-bottom:
+The Dashboard renders these bands, stacked top-to-bottom. The first three are the ones a single-run scan report cannot produce: each needs an account, teams, and scan history that outlives the run that created it.
-1. **Vulnerabilities by severity** — five tiles (Critical / High / Medium / Low / Info) with the open finding count across every project you can see. The count excludes findings whose VEX status is `Not affected`, `False positive`, `Fixed`, or `Suppressed` — the same exclusions the [build gate](./projects.md#build-gate-verdict-overview-tab) applies.
-2. **Portfolio** — six tiles: project count, pending approvals, and four scan-status counts (Queued / Running / Succeeded / Failed) summed across the visible portfolio.
-3. **License classification** — a horizontal bar of the four tiers (Permissive / Conditional / Prohibited / Unknown) with a per-tier count legend below.
-4. **Recent scans** — the most recent scan rows across the portfolio, each linking to its project detail page. Every row carries the project name, the release tag (when a release snapshot was recorded for the run), the scan kind (`source` / `container`), a status badge, and a relative timestamp.
+1. **Needs attention** — the action queue. Approvals nobody has reviewed, known-exploited (KEV) findings measured against their CISA remediation date, projects the build gate is blocking, and projects with no successful scan in 14 days. Each tile links to the page that resolves it, and the blocked / stale projects are listed by name underneath. A tile tints only when its number means something is wrong; the count and the hint text carry the same state without the colour. If the queue fails to load, the panel says so rather than showing an empty queue — "nothing is waiting on you" and "we could not check" are different answers.
+2. **Risk over time** — three small charts over a 7-, 30-, or 90-day window. *Open critical* and *Open KEV* are levels: the standing exposure on each day, taken from each project's latest succeeded scan on or before it. *New vs resolved* is a flow: what each scan opened and closed compared with the previous scan of the same project, branch and scan kind. Levels carry forward on days nobody scanned, so a flat line can mean nothing changed *or* nobody looked — the panel says which by reporting how many scans landed. A scan with nothing before it to compare against contributes no flow at all, because scan retention deletes superseded snapshots and "no earlier scan on record" does not mean "nothing came before".
+3. **Portfolio by team** — every project you can see, grouped by the team that owns it, worst first. A cell is tinted by its worst severity bucket and prints that bucket's count; a project nobody has scanned is drawn differently from one that came back clean, because their numbers are identical and their meanings are not. The grid caps how many cells it shows per team and how many teams it shows, and says what it left out — open the project list for the rest.
+4. **Vulnerabilities by severity** — five tiles (Critical / High / Medium / Low / Info) with the open finding count across every project you can see. The count excludes findings whose VEX status is `Not affected`, `False positive`, `Fixed`, or `Suppressed` — the same exclusions the [build gate](./projects.md#build-gate-verdict-overview-tab) applies.
+5. **Portfolio** — six tiles: project count, pending approvals, and four scan-status counts (Queued / Running / Succeeded / Failed) summed across the visible portfolio.
+6. **License classification** — a horizontal bar of the four tiers (Permissive / Conditional / Prohibited / Unknown) with a per-tier count legend below.
+7. **Recent scans** — the most recent scan rows across the portfolio, each linking to its project detail page. Every row carries the project name, the release tag (when a release snapshot was recorded for the run), the scan kind (`source` / `container`), a status badge, and a relative timestamp.
-The page polls the backend's `/v1/dashboard/summary` endpoint and renders skeletons while the first response is in flight. Subsequent reloads use the cached response and refetch in the background.
+The bands read four endpoints — `/v1/dashboard/action-queue`, `/v1/dashboard/trends`, `/v1/dashboard/portfolio` and `/v1/dashboard/summary` — each scoped server-side to the projects your team memberships reach. The page renders skeletons while the first responses are in flight. Subsequent reloads use the cached response and refetch in the background.
## Global search (⌘K)
diff --git a/docs-site/i18n/ko/docusaurus-plugin-content-docs/current/user-guide/dashboard.md b/docs-site/i18n/ko/docusaurus-plugin-content-docs/current/user-guide/dashboard.md
index 7577eb9f..d8f90267 100644
--- a/docs-site/i18n/ko/docusaurus-plugin-content-docs/current/user-guide/dashboard.md
+++ b/docs-site/i18n/ko/docusaurus-plugin-content-docs/current/user-guide/dashboard.md
@@ -29,14 +29,17 @@ sidebar_position: 0
## 페이지 구성
-대시보드는 위→아래로 네 개 밴드를 렌더합니다.
+대시보드는 위→아래로 다음 밴드를 렌더합니다. 앞의 세 밴드는 한 번 실행하고 끝나는 스캔 보고서가 만들 수 없는 화면입니다. 계정과 팀, 그리고 스캔이 끝난 뒤에도 남는 이력이 있어야 성립하기 때문입니다.
-1. **Vulnerabilities by severity (심각도별 취약점)** — Critical / High / Medium / Low / Info 다섯 타일. 사용자가 볼 수 있는 모든 프로젝트의 열린 finding 합계를 표시. VEX 상태가 `Not affected` / `False positive` / `Fixed` / `Suppressed` 인 finding 은 제외 — [빌드 게이트](./projects.md#build-gate-verdict-overview-tab) 와 같은 제외 규칙.
-2. **Portfolio (포트폴리오)** — 여섯 타일: 프로젝트 수, 대기 중 승인 수, 그리고 네 가지 스캔 상태 카운트(Queued / Running / Succeeded / Failed) 의 포트폴리오 합산.
-3. **License classification (라이선스 분류)** — 네 티어(Permissive / Conditional / Prohibited / Unknown)의 수평 바와 그 아래 티어별 카운트 범례.
-4. **Recent scans (최근 스캔)** — 포트폴리오 전체의 가장 최근 스캔 행. 각 행은 프로젝트 상세로 이동합니다. 각 행은 프로젝트 이름, 릴리스 태그(릴리스 스냅샷이 기록된 경우), 스캔 종류(`source` / `container`), 상태 배지, 상대 시간을 포함합니다.
+1. **Needs attention (확인이 필요한 일)** — 액션 큐. 아무도 검토하지 않은 승인, CISA 기한과 대조한 KEV finding, 빌드 게이트가 막고 있는 프로젝트, 14일간 성공한 스캔이 없는 프로젝트. 각 타일은 그 일을 처리하는 페이지로 이동하고, 차단·방치된 프로젝트는 아래에 이름으로 나열됩니다. 타일은 그 숫자가 문제를 뜻할 때만 색이 들어오며, 색을 보지 못해도 숫자와 설명이 같은 상태를 전달합니다. 큐를 불러오지 못하면 빈 큐 대신 실패했다고 밝힙니다. "처리할 일이 없다"와 "확인하지 못했다"는 다른 답입니다.
+2. **Risk over time (기간별 위험 추이)** — 7·30·90일 창의 작은 차트 세 개. *미해결 critical* 과 *미해결 KEV* 는 잔량입니다. 그날 기준 각 프로젝트의 최신 성공 스캔에서 읽습니다. *신규 대비 해소* 는 흐름입니다. 같은 프로젝트·브랜치·스캔 종류의 직전 스캔과 비교해 그 스캔이 연 것과 닫은 것을 셉니다. 스캔이 없는 날은 잔량을 그대로 잇기 때문에 선이 평평한 것은 변화가 없었다는 뜻일 수도, 아무도 확인하지 않았다는 뜻일 수도 있습니다. 그날 스캔이 몇 건 있었는지로 어느 쪽인지 알립니다. 비교할 직전 스캔이 없는 스캔은 흐름에 아무것도 더하지 않습니다. 스캔 보관 정책이 대체된 스냅샷을 실제로 지우므로, "기록에 없음"이 "이전에 없었음"을 뜻하지 않기 때문입니다.
+3. **Portfolio by team (팀별 포트폴리오)** — 볼 수 있는 모든 프로젝트를 소유 팀별로 묶어 위험이 큰 순으로 배열합니다. 셀은 가장 높은 심각도로 색을 입히고 그 등급의 건수를 함께 적습니다. 스캔한 적 없는 프로젝트는 깨끗한 프로젝트와 다르게 그립니다. 숫자는 같지만 뜻이 다르기 때문입니다. 격자는 팀당 셀 수와 팀 수에 상한을 두고, 무엇을 뺐는지 밝힙니다. 나머지는 프로젝트 목록에서 봅니다.
+4. **Vulnerabilities by severity (심각도별 취약점)** — Critical / High / Medium / Low / Info 다섯 타일. 사용자가 볼 수 있는 모든 프로젝트의 열린 finding 합계를 표시. VEX 상태가 `Not affected` / `False positive` / `Fixed` / `Suppressed` 인 finding 은 제외 — [빌드 게이트](./projects.md#build-gate-verdict-overview-tab) 와 같은 제외 규칙.
+5. **Portfolio (포트폴리오)** — 여섯 타일: 프로젝트 수, 대기 중 승인 수, 그리고 네 가지 스캔 상태 카운트(Queued / Running / Succeeded / Failed) 의 포트폴리오 합산.
+6. **License classification (라이선스 분류)** — 네 티어(Permissive / Conditional / Prohibited / Unknown)의 수평 바와 그 아래 티어별 카운트 범례.
+7. **Recent scans (최근 스캔)** — 포트폴리오 전체의 가장 최근 스캔 행. 각 행은 프로젝트 상세로 이동합니다. 각 행은 프로젝트 이름, 릴리스 태그(릴리스 스냅샷이 기록된 경우), 스캔 종류(`source` / `container`), 상태 배지, 상대 시간을 포함합니다.
-페이지는 백엔드 `/v1/dashboard/summary` 엔드포인트를 폴링하며 첫 응답 대기 동안 스켈레톤을 표시합니다. 이후 새로고침은 캐시된 응답을 사용하고 백그라운드로 refetch 합니다.
+밴드들은 엔드포인트 네 개를 읽습니다 — `/v1/dashboard/action-queue`, `/v1/dashboard/trends`, `/v1/dashboard/portfolio`, `/v1/dashboard/summary`. 모두 서버에서 소속 팀이 닿는 프로젝트로 스코프가 제한됩니다. 첫 응답을 기다리는 동안에는 스켈레톤을 표시하고, 이후 새로고침은 캐시된 응답을 쓰면서 백그라운드로 refetch 합니다.
## 전역 검색 (⌘K)
From 8b9035a7066ca80163b38f5ed74cd4fbdd9eb591 Mon Sep 17 00:00:00 2001
From: Haksung Jang
Date: Wed, 29 Jul 2026 07:50:53 +0900
Subject: [PATCH 4/5] test(visual): refresh baselines for the portfolio grid
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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.
---
.../visual.spec.ts-snapshots/admin-users.png | Bin 62476 -> 62449 bytes
.../visual.spec.ts-snapshots/approvals.png | Bin 58728 -> 58764 bytes
.../visual.spec.ts-snapshots/dashboard.png | Bin 96128 -> 95660 bytes
.../project-detail-overview.png | Bin 126809 -> 125836 bytes
.../project-detail-vulnerabilities.png | Bin 136427 -> 135857 bytes
.../projects-list.png | Bin 81525 -> 81552 bytes
.../visual/visual.spec.ts-snapshots/scans.png | Bin 61973 -> 61762 bytes
7 files changed, 0 insertions(+), 0 deletions(-)
diff --git a/apps/frontend/tests/visual/visual.spec.ts-snapshots/admin-users.png b/apps/frontend/tests/visual/visual.spec.ts-snapshots/admin-users.png
index dd4b6f4b76fd16beff28b058a3c8480c00ec7e7c..986fb9a7839b5cf53c6aa5b879bee09810917ea6 100644
GIT binary patch
literal 62449
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