From a95defcee44bb963cf00a7770784363491745d1a Mon Sep 17 00:00:00 2001
From: Kitos
Date: Mon, 9 Feb 2026 13:00:07 +0100
Subject: [PATCH] feat(phase-16): enhanced Tests view, Red/Blue dashboard
metrics, and Template admin panel (T-122, T-123, T-124)
---
backend/app/routers/metrics.py | 204 +++++++++-
backend/app/routers/test_templates.py | 89 +++-
backend/app/routers/tests.py | 23 +-
backend/app/schemas/metrics.py | 60 ++-
backend/app/schemas/test.py | 12 +
frontend/src/api/metrics.ts | 68 ++++
frontend/src/api/test-templates.ts | 47 +++
frontend/src/api/tests.ts | 6 +
frontend/src/pages/DashboardPage.tsx | 323 ++++++++++++++-
frontend/src/pages/SystemPage.tsx | 558 +++++++++++++++++++++++++-
frontend/src/pages/TestsPage.tsx | 534 +++++++++++++++++-------
frontend/src/types/models.ts | 4 +
12 files changed, 1769 insertions(+), 159 deletions(-)
diff --git a/backend/app/routers/metrics.py b/backend/app/routers/metrics.py
index b2b09e2..ced2d65 100644
--- a/backend/app/routers/metrics.py
+++ b/backend/app/routers/metrics.py
@@ -1,21 +1,30 @@
"""Coverage-metrics endpoints.
Provides aggregated views of MITRE ATT&CK technique coverage for
-dashboards and reporting.
+dashboards and reporting. V2 adds pipeline, team-activity, and
+validation-rate endpoints for the Red/Blue workflow.
"""
from collections import defaultdict
from fastapi import APIRouter, Depends
from sqlalchemy import func
-from sqlalchemy.orm import Session
+from sqlalchemy.orm import Session, joinedload
from app.database import get_db
from app.dependencies.auth import get_current_user
-from app.models.enums import TechniqueStatus
+from app.models.enums import TechniqueStatus, TestState
from app.models.technique import Technique
+from app.models.test import Test
from app.models.user import User
-from app.schemas.metrics import CoverageSummary, TacticCoverage
+from app.schemas.metrics import (
+ CoverageSummary,
+ RecentTestItem,
+ TacticCoverage,
+ TeamActivity,
+ TestPipelineCounts,
+ ValidationRate,
+)
router = APIRouter(prefix="/metrics", tags=["metrics"])
@@ -117,3 +126,190 @@ def coverage_by_tactic(
)
return result
+
+
+# ---------------------------------------------------------------------------
+# GET /metrics/test-pipeline — counters per pipeline state
+# ---------------------------------------------------------------------------
+
+
+@router.get("/test-pipeline", response_model=TestPipelineCounts)
+def test_pipeline(
+ db: Session = Depends(get_db),
+ current_user: User = Depends(get_current_user),
+):
+ """Return how many tests are in each pipeline state."""
+
+ rows = (
+ db.query(Test.state, func.count(Test.id).label("cnt"))
+ .group_by(Test.state)
+ .all()
+ )
+
+ state_counts: dict[str, int] = {s.value: 0 for s in TestState}
+ for state, cnt in rows:
+ state_counts[state.value] = cnt
+
+ total = sum(state_counts.values())
+
+ return TestPipelineCounts(
+ draft=state_counts["draft"],
+ red_executing=state_counts["red_executing"],
+ blue_evaluating=state_counts["blue_evaluating"],
+ in_review=state_counts["in_review"],
+ validated=state_counts["validated"],
+ rejected=state_counts["rejected"],
+ total=total,
+ )
+
+
+# ---------------------------------------------------------------------------
+# GET /metrics/team-activity — activity per team
+# ---------------------------------------------------------------------------
+
+
+@router.get("/team-activity", response_model=list[TeamActivity])
+def team_activity(
+ db: Session = Depends(get_db),
+ current_user: User = Depends(get_current_user),
+):
+ """Return activity summary for Red and Blue teams."""
+
+ # Red Team: completed = tests past red_executing; pending = draft + red_executing
+ red_completed = (
+ db.query(func.count(Test.id))
+ .filter(Test.state.in_([
+ TestState.blue_evaluating,
+ TestState.in_review,
+ TestState.validated,
+ TestState.rejected,
+ ]))
+ .scalar()
+ ) or 0
+
+ red_pending = (
+ db.query(func.count(Test.id))
+ .filter(Test.state.in_([TestState.draft, TestState.red_executing]))
+ .scalar()
+ ) or 0
+
+ # Blue Team: completed = tests past blue_evaluating; pending = blue_evaluating
+ blue_completed = (
+ db.query(func.count(Test.id))
+ .filter(Test.state.in_([
+ TestState.in_review,
+ TestState.validated,
+ TestState.rejected,
+ ]))
+ .scalar()
+ ) or 0
+
+ blue_pending = (
+ db.query(func.count(Test.id))
+ .filter(Test.state == TestState.blue_evaluating)
+ .scalar()
+ ) or 0
+
+ return [
+ TeamActivity(
+ team="Red Team",
+ tests_completed=red_completed,
+ tests_pending=red_pending,
+ ),
+ TeamActivity(
+ team="Blue Team",
+ tests_completed=blue_completed,
+ tests_pending=blue_pending,
+ ),
+ ]
+
+
+# ---------------------------------------------------------------------------
+# GET /metrics/validation-rate — approval / rejection rates
+# ---------------------------------------------------------------------------
+
+
+@router.get("/validation-rate", response_model=list[ValidationRate])
+def validation_rate(
+ db: Session = Depends(get_db),
+ current_user: User = Depends(get_current_user),
+):
+ """Return approval and rejection rates for Red Lead and Blue Lead."""
+
+ # Red Lead validations
+ red_approved = (
+ db.query(func.count(Test.id))
+ .filter(Test.red_validation_status == "approved")
+ .scalar()
+ ) or 0
+ red_rejected = (
+ db.query(func.count(Test.id))
+ .filter(Test.red_validation_status == "rejected")
+ .scalar()
+ ) or 0
+ red_total = red_approved + red_rejected
+ red_rate = round(red_approved / red_total * 100, 1) if red_total > 0 else 0.0
+
+ # Blue Lead validations
+ blue_approved = (
+ db.query(func.count(Test.id))
+ .filter(Test.blue_validation_status == "approved")
+ .scalar()
+ ) or 0
+ blue_rejected = (
+ db.query(func.count(Test.id))
+ .filter(Test.blue_validation_status == "rejected")
+ .scalar()
+ ) or 0
+ blue_total = blue_approved + blue_rejected
+ blue_rate = round(blue_approved / blue_total * 100, 1) if blue_total > 0 else 0.0
+
+ return [
+ ValidationRate(
+ role="red_lead",
+ total_reviewed=red_total,
+ approved=red_approved,
+ rejected=red_rejected,
+ approval_rate=red_rate,
+ ),
+ ValidationRate(
+ role="blue_lead",
+ total_reviewed=blue_total,
+ approved=blue_approved,
+ rejected=blue_rejected,
+ approval_rate=blue_rate,
+ ),
+ ]
+
+
+# ---------------------------------------------------------------------------
+# GET /metrics/recent-tests — latest 10 updated tests
+# ---------------------------------------------------------------------------
+
+
+@router.get("/recent-tests", response_model=list[RecentTestItem])
+def recent_tests(
+ db: Session = Depends(get_db),
+ current_user: User = Depends(get_current_user),
+):
+ """Return the 10 most recently created tests."""
+
+ tests = (
+ db.query(Test)
+ .options(joinedload(Test.technique))
+ .order_by(Test.created_at.desc())
+ .limit(10)
+ .all()
+ )
+
+ return [
+ RecentTestItem(
+ id=str(t.id),
+ name=t.name,
+ state=t.state.value,
+ technique_mitre_id=t.technique.mitre_id if t.technique else None,
+ technique_name=t.technique.name if t.technique else None,
+ created_at=t.created_at,
+ )
+ for t in tests
+ ]
diff --git a/backend/app/routers/test_templates.py b/backend/app/routers/test_templates.py
index 2c501f6..f36e9f3 100644
--- a/backend/app/routers/test_templates.py
+++ b/backend/app/routers/test_templates.py
@@ -3,9 +3,11 @@
Endpoints
---------
GET /test-templates — list with filters + pagination
+GET /test-templates/stats — catalog statistics (admin)
GET /test-templates/{id} — detail
POST /test-templates — create custom (admin)
PATCH /test-templates/{id} — update (admin)
+PATCH /test-templates/{id}/toggle-active — toggle active/inactive (admin)
DELETE /test-templates/{id} — soft delete (admin)
GET /test-templates/by-technique/{mitre_id} — templates for a MITRE technique
@@ -16,6 +18,7 @@ Filters (GET /test-templates)
- severity: low | medium | high | critical
- mitre_technique_id: filter by specific technique
- search: full-text search across name and description
+- is_active: true | false (default only active)
- offset / limit: pagination (default limit=50)
"""
@@ -23,7 +26,7 @@ import uuid
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query, status
-from sqlalchemy import or_
+from sqlalchemy import func, or_
from sqlalchemy.orm import Session
from app.database import get_db
@@ -57,7 +60,7 @@ def list_templates(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
- """Return a paginated, filterable list of active test templates."""
+ """Return a paginated, filterable list of test templates."""
query = db.query(TestTemplate).filter(TestTemplate.is_active == True) # noqa: E712
if source:
@@ -87,6 +90,53 @@ def list_templates(
return templates
+# ---------------------------------------------------------------------------
+# GET /test-templates/stats — catalog statistics (admin)
+# ---------------------------------------------------------------------------
+
+
+@router.get("/stats")
+def template_stats(
+ db: Session = Depends(get_db),
+ current_user: User = Depends(require_role("admin")),
+):
+ """Return catalog statistics: totals by source, platform, active/inactive."""
+
+ total = db.query(func.count(TestTemplate.id)).scalar() or 0
+ active = (
+ db.query(func.count(TestTemplate.id))
+ .filter(TestTemplate.is_active == True) # noqa: E712
+ .scalar()
+ ) or 0
+ inactive = total - active
+
+ # By source
+ source_rows = (
+ db.query(TestTemplate.source, func.count(TestTemplate.id))
+ .filter(TestTemplate.is_active == True) # noqa: E712
+ .group_by(TestTemplate.source)
+ .all()
+ )
+ by_source = {source: cnt for source, cnt in source_rows}
+
+ # By platform
+ platform_rows = (
+ db.query(TestTemplate.platform, func.count(TestTemplate.id))
+ .filter(TestTemplate.is_active == True) # noqa: E712
+ .group_by(TestTemplate.platform)
+ .all()
+ )
+ by_platform = {(platform or "unspecified"): cnt for platform, cnt in platform_rows}
+
+ return {
+ "total": total,
+ "active": active,
+ "inactive": inactive,
+ "by_source": by_source,
+ "by_platform": by_platform,
+ }
+
+
# ---------------------------------------------------------------------------
# GET /test-templates/by-technique/{mitre_id}
# ---------------------------------------------------------------------------
@@ -208,6 +258,41 @@ def update_template(
return template
+# ---------------------------------------------------------------------------
+# PATCH /test-templates/{id}/toggle-active — toggle active/inactive (admin)
+# ---------------------------------------------------------------------------
+
+
+@router.patch("/{template_id}/toggle-active", response_model=TestTemplateOut)
+def toggle_template_active(
+ template_id: uuid.UUID,
+ db: Session = Depends(get_db),
+ current_user: User = Depends(require_role("admin")),
+):
+ """Toggle a template between active and inactive. Admin only."""
+ template = db.query(TestTemplate).filter(TestTemplate.id == template_id).first()
+ if template is None:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail="Test template not found",
+ )
+
+ template.is_active = not template.is_active
+ db.commit()
+ db.refresh(template)
+
+ log_action(
+ db,
+ user_id=current_user.id,
+ action="toggle_test_template",
+ entity_type="test_template",
+ entity_id=template.id,
+ details={"name": template.name, "is_active": template.is_active},
+ )
+
+ return template
+
+
# ---------------------------------------------------------------------------
# DELETE /test-templates/{id} — soft delete (admin only)
# ---------------------------------------------------------------------------
diff --git a/backend/app/routers/tests.py b/backend/app/routers/tests.py
index 8d8b05c..4f94ae3 100644
--- a/backend/app/routers/tests.py
+++ b/backend/app/routers/tests.py
@@ -88,18 +88,37 @@ def _get_test_with_technique(db: Session, test_id: uuid.UUID) -> Test:
def list_tests(
state: Optional[str] = Query(None, description="Filter by test state"),
technique_id: Optional[uuid.UUID] = Query(None, description="Filter by technique"),
+ platform: Optional[str] = Query(None, description="Filter by platform"),
+ created_by: Optional[uuid.UUID] = Query(None, description="Filter by creator"),
+ pending_validation_side: Optional[str] = Query(
+ None, description="Filter in_review tests pending validation on 'red' or 'blue' side"
+ ),
offset: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
- """Return a paginated list of tests, optionally filtered by state or technique."""
- query = db.query(Test)
+ """Return a paginated list of tests, optionally filtered by state, technique, platform or creator."""
+ query = db.query(Test).options(joinedload(Test.technique))
if state:
query = query.filter(Test.state == state)
if technique_id:
query = query.filter(Test.technique_id == technique_id)
+ if platform:
+ query = query.filter(Test.platform.ilike(f"%{platform}%"))
+ if created_by:
+ query = query.filter(Test.created_by == created_by)
+ if pending_validation_side == "red":
+ query = query.filter(
+ Test.state == TestState.in_review,
+ Test.red_validation_status.in_(["pending", None]),
+ )
+ elif pending_validation_side == "blue":
+ query = query.filter(
+ Test.state == TestState.in_review,
+ Test.blue_validation_status.in_(["pending", None]),
+ )
tests = query.order_by(Test.created_at.desc()).offset(offset).limit(limit).all()
return tests
diff --git a/backend/app/schemas/metrics.py b/backend/app/schemas/metrics.py
index 1ec2a64..1d06c36 100644
--- a/backend/app/schemas/metrics.py
+++ b/backend/app/schemas/metrics.py
@@ -1,6 +1,8 @@
"""Pydantic schemas for coverage-metrics endpoints."""
-from pydantic import BaseModel
+from datetime import datetime
+
+from pydantic import BaseModel, ConfigDict
class CoverageSummary(BaseModel):
@@ -25,3 +27,59 @@ class TacticCoverage(BaseModel):
not_covered: int
not_evaluated: int
in_progress: int
+
+
+# ── V2 — Test Pipeline ────────────────────────────────────────────────
+
+
+class TestPipelineCounts(BaseModel):
+ """Counters per state in the test pipeline."""
+
+ draft: int = 0
+ red_executing: int = 0
+ blue_evaluating: int = 0
+ in_review: int = 0
+ validated: int = 0
+ rejected: int = 0
+ total: int = 0
+
+
+# ── V2 — Team Activity ───────────────────────────────────────────────
+
+
+class TeamActivity(BaseModel):
+ """Activity summary for a team (Red or Blue)."""
+
+ team: str
+ tests_completed: int = 0
+ tests_pending: int = 0
+ avg_completion_hours: float | None = None
+
+
+# ── V2 — Validation Rate ─────────────────────────────────────────────
+
+
+class ValidationRate(BaseModel):
+ """Approval / rejection rate for a manager role."""
+
+ role: str # "red_lead" or "blue_lead"
+ total_reviewed: int = 0
+ approved: int = 0
+ rejected: int = 0
+ approval_rate: float = 0.0 # percentage
+
+
+# ── V2 — Recent Test ─────────────────────────────────────────────────
+
+
+class RecentTestItem(BaseModel):
+ """Lightweight test entry for the recent-tests widget."""
+
+ id: str
+ name: str
+ state: str
+ technique_mitre_id: str | None = None
+ technique_name: str | None = None
+ created_at: datetime | None = None
+
+ model_config = ConfigDict(from_attributes=True)
diff --git a/backend/app/schemas/test.py b/backend/app/schemas/test.py
index 4aacd9a..30b2c79 100644
--- a/backend/app/schemas/test.py
+++ b/backend/app/schemas/test.py
@@ -126,4 +126,16 @@ class TestOut(BaseModel):
blue_validation_status: str | None = None
blue_validation_notes: str | None = None
+ # Technique info (populated when joined)
+ technique_mitre_id: str | None = None
+ technique_name: str | None = None
+
model_config = ConfigDict(from_attributes=True)
+
+ @classmethod
+ def model_validate(cls, obj, **kwargs):
+ """Override to populate technique fields from the relationship."""
+ if hasattr(obj, "technique") and obj.technique is not None:
+ obj.__dict__["technique_mitre_id"] = obj.technique.mitre_id
+ obj.__dict__["technique_name"] = obj.technique.name
+ return super().model_validate(obj, **kwargs)
diff --git a/frontend/src/api/metrics.ts b/frontend/src/api/metrics.ts
index 79e7bf7..48e5d86 100644
--- a/frontend/src/api/metrics.ts
+++ b/frontend/src/api/metrics.ts
@@ -1,6 +1,8 @@
import client from "./client";
import type { CoverageSummary, TacticCoverage } from "../types/models";
+// ── V1 — Coverage ───────────────────────────────────────────────────
+
/** Fetch the global coverage summary. */
export async function getCoverageSummary(): Promise {
const { data } = await client.get("/metrics/summary");
@@ -12,3 +14,69 @@ export async function getCoverageByTactic(): Promise {
const { data } = await client.get("/metrics/by-tactic");
return data;
}
+
+// ── V2 — Test Pipeline ──────────────────────────────────────────────
+
+export interface TestPipelineCounts {
+ draft: number;
+ red_executing: number;
+ blue_evaluating: number;
+ in_review: number;
+ validated: number;
+ rejected: number;
+ total: number;
+}
+
+/** Fetch test counts per pipeline state. */
+export async function getTestPipeline(): Promise {
+ const { data } = await client.get("/metrics/test-pipeline");
+ return data;
+}
+
+// ── V2 — Team Activity ──────────────────────────────────────────────
+
+export interface TeamActivityItem {
+ team: string;
+ tests_completed: number;
+ tests_pending: number;
+ avg_completion_hours: number | null;
+}
+
+/** Fetch activity summary for Red and Blue teams. */
+export async function getTeamActivity(): Promise {
+ const { data } = await client.get("/metrics/team-activity");
+ return data;
+}
+
+// ── V2 — Validation Rate ────────────────────────────────────────────
+
+export interface ValidationRateItem {
+ role: string;
+ total_reviewed: number;
+ approved: number;
+ rejected: number;
+ approval_rate: number;
+}
+
+/** Fetch approval/rejection rates for managers. */
+export async function getValidationRate(): Promise {
+ const { data } = await client.get("/metrics/validation-rate");
+ return data;
+}
+
+// ── V2 — Recent Tests ───────────────────────────────────────────────
+
+export interface RecentTestItem {
+ id: string;
+ name: string;
+ state: string;
+ technique_mitre_id: string | null;
+ technique_name: string | null;
+ created_at: string | null;
+}
+
+/** Fetch the 10 most recently updated tests. */
+export async function getRecentTests(): Promise {
+ const { data } = await client.get("/metrics/recent-tests");
+ return data;
+}
diff --git a/frontend/src/api/test-templates.ts b/frontend/src/api/test-templates.ts
index 1e02b2f..ce2ec18 100644
--- a/frontend/src/api/test-templates.ts
+++ b/frontend/src/api/test-templates.ts
@@ -95,6 +95,53 @@ export async function createTemplate(
return data;
}
+// ── Stats (admin) ──────────────────────────────────────────────────
+
+export interface TemplateStats {
+ total: number;
+ active: number;
+ inactive: number;
+ by_source: Record;
+ by_platform: Record;
+}
+
+/** Fetch template catalog statistics. Admin only. */
+export async function getTemplateStats(): Promise {
+ const { data } = await client.get("/test-templates/stats");
+ return data;
+}
+
+// ── Toggle active (admin) ──────────────────────────────────────────
+
+/** Toggle a template between active/inactive. Admin only. */
+export async function toggleTemplateActive(
+ id: string,
+): Promise {
+ const { data } = await client.patch(
+ `/test-templates/${id}/toggle-active`,
+ );
+ return data;
+}
+
+// ── All templates (include inactive, for admin) ────────────────────
+
+/** Fetch all templates including inactive ones (for admin management). */
+export async function getAllTemplates(
+ filters?: TemplateFilters,
+): Promise {
+ const params = new URLSearchParams();
+ if (filters?.source) params.append("source", filters.source);
+ if (filters?.platform) params.append("platform", filters.platform);
+ if (filters?.search) params.append("search", filters.search);
+ if (filters?.offset !== undefined) params.append("offset", String(filters.offset));
+ if (filters?.limit !== undefined) params.append("limit", String(filters.limit));
+
+ const { data } = await client.get(
+ `/test-templates${params.toString() ? `?${params}` : ""}`,
+ );
+ return data;
+}
+
// ── Import Atomic Red Team ─────────────────────────────────────────
/** Trigger Atomic Red Team import. Admin only. */
diff --git a/frontend/src/api/tests.ts b/frontend/src/api/tests.ts
index ca020fd..e4b73ca 100644
--- a/frontend/src/api/tests.ts
+++ b/frontend/src/api/tests.ts
@@ -59,6 +59,9 @@ export interface TestValidatePayload {
export interface TestListFilters {
state?: TestState;
technique_id?: string;
+ platform?: string;
+ created_by?: string;
+ pending_validation_side?: "red" | "blue";
offset?: number;
limit?: number;
}
@@ -70,6 +73,9 @@ export async function getTests(filters?: TestListFilters): Promise {
const params = new URLSearchParams();
if (filters?.state) params.append("state", filters.state);
if (filters?.technique_id) params.append("technique_id", filters.technique_id);
+ if (filters?.platform) params.append("platform", filters.platform);
+ if (filters?.created_by) params.append("created_by", filters.created_by);
+ if (filters?.pending_validation_side) params.append("pending_validation_side", filters.pending_validation_side);
if (filters?.offset !== undefined) params.append("offset", String(filters.offset));
if (filters?.limit !== undefined) params.append("limit", String(filters.limit));
diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx
index ddfc81d..4bc9ec7 100644
--- a/frontend/src/pages/DashboardPage.tsx
+++ b/frontend/src/pages/DashboardPage.tsx
@@ -1,4 +1,5 @@
import { useQuery } from "@tanstack/react-query";
+import { useNavigate } from "react-router-dom";
import {
Shield,
CheckCircle,
@@ -9,12 +10,54 @@ import {
Percent,
Loader2,
AlertCircle,
+ Play,
+ Eye,
+ Users,
+ TrendingUp,
+ ArrowRight,
} from "lucide-react";
-import { getCoverageSummary, getCoverageByTactic } from "../api/metrics";
+import {
+ getCoverageSummary,
+ getCoverageByTactic,
+ getTestPipeline,
+ getTeamActivity,
+ getValidationRate,
+ getRecentTests,
+ type TestPipelineCounts,
+ type TeamActivityItem,
+ type ValidationRateItem,
+ type RecentTestItem,
+} from "../api/metrics";
import CoverageSummaryCard from "../components/CoverageSummaryCard";
import TacticCoverageChart from "../components/TacticCoverageChart";
+import type { TestState } from "../types/models";
+
+/* ── Badge colours (reused from TestsPage) ─────────────────────────── */
+
+const testStateBadgeColors: Record = {
+ draft: "bg-gray-800/50 text-gray-400 border-gray-600/30",
+ red_executing: "bg-orange-900/50 text-orange-400 border-orange-500/30",
+ blue_evaluating: "bg-indigo-900/50 text-indigo-400 border-indigo-500/30",
+ in_review: "bg-blue-900/50 text-blue-400 border-blue-500/30",
+ validated: "bg-green-900/50 text-green-400 border-green-500/30",
+ rejected: "bg-red-900/50 text-red-400 border-red-500/30",
+};
+
+const testStateLabels: Record = {
+ draft: "Draft",
+ red_executing: "Red Executing",
+ blue_evaluating: "Blue Evaluating",
+ in_review: "In Review",
+ validated: "Validated",
+ rejected: "Rejected",
+};
+
+/* ── Component ──────────────────────────────────────────────────────── */
export default function DashboardPage() {
+ const navigate = useNavigate();
+
+ // Existing coverage queries
const {
data: summary,
isLoading: summaryLoading,
@@ -33,6 +76,27 @@ export default function DashboardPage() {
queryFn: getCoverageByTactic,
});
+ // V2 queries
+ const { data: pipeline, isLoading: pipelineLoading } = useQuery({
+ queryKey: ["metrics", "test-pipeline"],
+ queryFn: getTestPipeline,
+ });
+
+ const { data: teamActivity, isLoading: teamLoading } = useQuery({
+ queryKey: ["metrics", "team-activity"],
+ queryFn: getTeamActivity,
+ });
+
+ const { data: validationRates, isLoading: validationLoading } = useQuery({
+ queryKey: ["metrics", "validation-rate"],
+ queryFn: getValidationRate,
+ });
+
+ const { data: recentTests, isLoading: recentLoading } = useQuery({
+ queryKey: ["metrics", "recent-tests"],
+ queryFn: getRecentTests,
+ });
+
if (summaryLoading || tacticsLoading) {
return (
@@ -127,8 +191,263 @@ export default function DashboardPage() {
)}
- {/* Tactic Coverage Table */}
+ {/* ── V2 Section: Test Pipeline ────────────────────────────────── */}
+
+
+
+
+ Test Pipeline
+
+
navigate("/tests")}
+ className="text-sm text-cyan-400 hover:underline flex items-center gap-1"
+ >
+ View all tests
+
+
+
+ {pipelineLoading ? (
+
+
+
+ ) : pipeline ? (
+
+ ) : null}
+
+
+ {/* ── V2 Section: Team Activity + Validation Rate ──────────────── */}
+
+ {/* Team Activity */}
+
+
+
+ Team Activity
+
+
+ {teamLoading ? (
+
+
+
+ ) : teamActivity ? (
+
+ {teamActivity.map((team: TeamActivityItem) => {
+ const isRed = team.team.toLowerCase().includes("red");
+ const total = team.tests_completed + team.tests_pending;
+ const pct = total > 0 ? (team.tests_completed / total) * 100 : 0;
+ return (
+
+
+
+
+ {team.tests_completed} completed / {team.tests_pending} pending
+
+
+
+
+ {pct.toFixed(0)}% completion rate
+
+
+ );
+ })}
+
+ ) : null}
+
+
+ {/* Validation Rate */}
+
+
+
+ Validation Rate
+
+
+ {validationLoading ? (
+
+
+
+ ) : validationRates ? (
+
+ {validationRates.map((rate: ValidationRateItem) => {
+ const isRed = rate.role === "red_lead";
+ return (
+
+
+
+
+
+ {isRed ? "Red Lead" : "Blue Lead"}
+
+
+
+ {rate.total_reviewed} reviewed
+
+
+
+
+
+ {rate.approved} approved
+
+
+
+ {rate.rejected} rejected
+
+
+
+
+ {rate.approval_rate}% approval rate
+
+
+ );
+ })}
+
+ ) : null}
+
+
+
+ {/* ── V2 Section: Recent Tests ─────────────────────────────────── */}
+
+
+
Recent Tests
+
navigate("/tests")}
+ className="text-sm text-cyan-400 hover:underline flex items-center gap-1"
+ >
+ View all
+
+
+
+ {recentLoading ? (
+
+
+
+ ) : recentTests && recentTests.length > 0 ? (
+
+
+
+
+ Name
+ Technique
+ State
+ Created
+
+
+
+ {recentTests.map((t: RecentTestItem) => (
+ navigate(`/tests/${t.id}`)}
+ >
+ {t.name}
+
+ {t.technique_mitre_id ? (
+
+ {t.technique_mitre_id}
+
+ ) : (
+ -
+ )}
+
+
+
+ {testStateLabels[t.state] || t.state}
+
+
+
+ {t.created_at
+ ? new Date(t.created_at).toLocaleDateString("en-US", {
+ month: "short",
+ day: "numeric",
+ })
+ : "-"}
+
+
+ ))}
+
+
+
+ ) : (
+
+ No tests created yet.
+
+ )}
+
+
+ {/* Tactic Coverage Table (original V1) */}
{tactics && }
);
}
+
+/* ── Pipeline Funnel Sub-component ─────────────────────────────────── */
+
+function PipelineFunnel({ pipeline }: { pipeline: TestPipelineCounts }) {
+ const stages: { key: keyof TestPipelineCounts; label: string; color: string; icon: React.ReactNode }[] = [
+ { key: "draft", label: "Draft", color: "bg-gray-600", icon: },
+ { key: "red_executing", label: "Red Executing", color: "bg-orange-500", icon: },
+ { key: "blue_evaluating", label: "Blue Evaluating", color: "bg-indigo-500", icon: },
+ { key: "in_review", label: "In Review", color: "bg-blue-500", icon: },
+ { key: "validated", label: "Validated", color: "bg-green-500", icon: },
+ { key: "rejected", label: "Rejected", color: "bg-red-500", icon: },
+ ];
+
+ const maxCount = Math.max(...stages.map((s) => pipeline[s.key] as number), 1);
+
+ return (
+
+ {stages.map((stage) => {
+ const count = pipeline[stage.key] as number;
+ const pct = (count / maxCount) * 100;
+ return (
+
+
+ {stage.icon}
+ {stage.label}
+
+
+
0 ? 8 : 0)}%` }}
+ >
+ {count > 0 && (
+ {count}
+ )}
+
+
+
{count}
+
+ );
+ })}
+
+ Total tests
+ {pipeline.total}
+
+
+ );
+}
diff --git a/frontend/src/pages/SystemPage.tsx b/frontend/src/pages/SystemPage.tsx
index 988e212..2fbad39 100644
--- a/frontend/src/pages/SystemPage.tsx
+++ b/frontend/src/pages/SystemPage.tsx
@@ -12,6 +12,13 @@ import {
XCircle,
Shield,
Search,
+ FlaskConical,
+ Download,
+ Plus,
+ ToggleLeft,
+ ToggleRight,
+ BarChart3,
+ X,
} from "lucide-react";
import {
triggerMitreSync,
@@ -20,12 +27,26 @@ import {
type SyncMitreResponse,
type IntelScanResponse,
} from "../api/system";
+import {
+ importAtomicTests,
+ getTemplateStats,
+ getAllTemplates,
+ createTemplate,
+ toggleTemplateActive,
+ type ImportAtomicResponse,
+ type TemplateStats,
+ type CreateTemplatePayload,
+} from "../api/test-templates";
+import type { TestTemplate } from "../types/models";
export default function SystemPage() {
const queryClient = useQueryClient();
const [syncResult, setSyncResult] = useState(null);
const [intelResult, setIntelResult] = useState(null);
+ const [importResult, setImportResult] = useState(null);
+ const [showCreateForm, setShowCreateForm] = useState(false);
+ // ── Existing queries ─────────────────────────────────────────────
const {
data: schedulerStatus,
isLoading: statusLoading,
@@ -33,9 +54,27 @@ export default function SystemPage() {
} = useQuery({
queryKey: ["scheduler-status"],
queryFn: getSchedulerStatus,
- refetchInterval: 30000, // Refresh every 30 seconds
+ refetchInterval: 30000,
});
+ // ── Template queries ─────────────────────────────────────────────
+ const {
+ data: templateStats,
+ isLoading: statsLoading,
+ } = useQuery({
+ queryKey: ["template-stats"],
+ queryFn: getTemplateStats,
+ });
+
+ const {
+ data: templates,
+ isLoading: templatesLoading,
+ } = useQuery({
+ queryKey: ["templates-admin"],
+ queryFn: () => getAllTemplates({ limit: 100 }),
+ });
+
+ // ── Mutations ────────────────────────────────────────────────────
const mitreSyncMutation = useMutation({
mutationFn: triggerMitreSync,
onSuccess: (data) => {
@@ -53,6 +92,35 @@ export default function SystemPage() {
},
});
+ const importAtomicMutation = useMutation({
+ mutationFn: importAtomicTests,
+ onSuccess: (data) => {
+ setImportResult(data);
+ queryClient.invalidateQueries({ queryKey: ["template-stats"] });
+ queryClient.invalidateQueries({ queryKey: ["templates-admin"] });
+ queryClient.invalidateQueries({ queryKey: ["test-templates"] });
+ },
+ });
+
+ const toggleActiveMutation = useMutation({
+ mutationFn: (id: string) => toggleTemplateActive(id),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ["templates-admin"] });
+ queryClient.invalidateQueries({ queryKey: ["template-stats"] });
+ queryClient.invalidateQueries({ queryKey: ["test-templates"] });
+ },
+ });
+
+ const createTemplateMutation = useMutation({
+ mutationFn: (payload: CreateTemplatePayload) => createTemplate(payload),
+ onSuccess: () => {
+ setShowCreateForm(false);
+ queryClient.invalidateQueries({ queryKey: ["templates-admin"] });
+ queryClient.invalidateQueries({ queryKey: ["template-stats"] });
+ queryClient.invalidateQueries({ queryKey: ["test-templates"] });
+ },
+ });
+
const formatNextRun = (dateStr: string | null) => {
if (!dateStr) return "Not scheduled";
const date = new Date(dateStr);
@@ -68,7 +136,7 @@ export default function SystemPage() {
System Administration
- Manage synchronization jobs and system status
+ Manage synchronization jobs, templates, and system status
@@ -86,7 +154,6 @@ export default function SystemPage() {
Synchronize techniques from the MITRE ATT&CK framework via TAXII or GitHub fallback.
- {/* Status */}
{schedulerStatus && (
@@ -99,7 +166,6 @@ export default function SystemPage() {
)}
- {/* Result */}
{syncResult && (
@@ -158,7 +224,6 @@ export default function SystemPage() {
Scan RSS feeds and security blogs for new threat intelligence related to techniques.
- {/* Status */}
{schedulerStatus && (
@@ -171,7 +236,6 @@ export default function SystemPage() {
)}
- {/* Result */}
{intelResult && (
@@ -213,11 +277,278 @@ export default function SystemPage() {
+ {/* ────────────────────────────────────────────────────────────────
+ TEMPLATE ADMINISTRATION (T-124)
+ ──────────────────────────────────────────────────────────────── */}
+
+ {/* Import Atomic Red Team + Stats */}
+
+ {/* Import Atomic Red Team */}
+
+
+
+
+
+
+
Import Atomic Red Team
+
+ Import test templates from the Atomic Red Team repository by Red Canary, mapped to MITRE ATT&CK techniques.
+
+
+ {importResult && (
+
+
+
+ Import Complete
+
+
+
+ Imported:
+ {importResult.imported}
+
+
+ Skipped:
+ {importResult.skipped}
+
+
+ Parsed:
+ {importResult.total_parsed}
+
+
+
+ )}
+
+ {importAtomicMutation.isError && (
+
+
+
+
+ Import failed: {(importAtomicMutation.error as Error)?.message}
+
+
+
+ )}
+
+
importAtomicMutation.mutate()}
+ disabled={importAtomicMutation.isPending}
+ className="mt-4 flex items-center gap-2 rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-500 disabled:opacity-50 transition-colors"
+ >
+ {importAtomicMutation.isPending ? (
+
+ ) : (
+
+ )}
+ {importAtomicMutation.isPending ? "Importing..." : "Import Now"}
+
+
+
+
+
+ {/* Template Catalog Stats */}
+
+
+
+
+
+
+
Catalog Statistics
+
+ Overview of the test template catalog.
+
+
+ {statsLoading ? (
+
+
+
+ ) : templateStats ? (
+
+ {/* Totals */}
+
+
+
{templateStats.total}
+
Total
+
+
+
{templateStats.active}
+
Active
+
+
+
{templateStats.inactive}
+
Inactive
+
+
+
+ {/* By source */}
+
+
By Source
+
+ {Object.entries(templateStats.by_source).map(([source, count]) => (
+
+ {source.replace(/_/g, " ")}
+ {count}
+
+ ))}
+ {Object.keys(templateStats.by_source).length === 0 && (
+ No templates yet
+ )}
+
+
+
+ {/* By platform */}
+
+
By Platform
+
+ {Object.entries(templateStats.by_platform).map(([platform, count]) => (
+
+ {platform}
+ {count}
+
+ ))}
+ {Object.keys(templateStats.by_platform).length === 0 && (
+ No templates yet
+ )}
+
+
+
+ ) : null}
+
+
+
+
+
+ {/* Create Custom Template Form (modal-style inline) */}
+ {showCreateForm && (
+
setShowCreateForm(false)}
+ onSubmit={(payload) => createTemplateMutation.mutate(payload)}
+ isPending={createTemplateMutation.isPending}
+ error={createTemplateMutation.isError ? (createTemplateMutation.error as Error)?.message : null}
+ />
+ )}
+
+ {/* Templates Management Table */}
+
+
+
+
+ Manage Templates
+
+
setShowCreateForm(!showCreateForm)}
+ className="flex items-center gap-1.5 rounded-lg bg-cyan-600 px-3 py-2 text-sm font-medium text-white hover:bg-cyan-500 transition-colors"
+ >
+
+ Create Custom Template
+
+
+
+ {templatesLoading ? (
+
+
+
+ ) : templates && templates.length > 0 ? (
+
+
+
+
+ Name
+ Technique
+ Source
+ Platform
+ Status
+ Action
+
+
+
+ {(templates as TestTemplate[]).map((tpl) => (
+
+
+
+ {tpl.name}
+
+
+
+
+ {tpl.mitre_technique_id}
+
+
+
+
+ {tpl.source.replace(/_/g, " ")}
+
+
+
+ {tpl.platform || "-"}
+
+
+
+ {tpl.is_active ? "Active" : "Inactive"}
+
+
+
+ toggleActiveMutation.mutate(tpl.id)}
+ disabled={toggleActiveMutation.isPending}
+ className={`flex items-center gap-1 text-xs font-medium transition-colors ${
+ tpl.is_active
+ ? "text-yellow-400 hover:text-yellow-300"
+ : "text-green-400 hover:text-green-300"
+ }`}
+ title={tpl.is_active ? "Deactivate" : "Activate"}
+ >
+ {tpl.is_active ? (
+ <>
+
+ Deactivate
+ >
+ ) : (
+ <>
+
+ Activate
+ >
+ )}
+
+
+
+ ))}
+
+
+
+ ) : (
+
+ No templates found. Import from Atomic Red Team or create a custom template.
+
+ )}
+
+
{/* System Information */}
System Information
- {/* Backend Status */}
@@ -227,8 +558,6 @@ export default function SystemPage() {
-
- {/* Database Status */}
@@ -238,8 +567,6 @@ export default function SystemPage() {
-
- {/* MinIO Status */}
@@ -249,8 +576,6 @@ export default function SystemPage() {
-
- {/* Scheduler Status */}
);
}
+
+/* ── Create Template Form (inline modal) ──────────────────────────── */
+
+function CreateTemplateForm({
+ onClose,
+ onSubmit,
+ isPending,
+ error,
+}: {
+ onClose: () => void;
+ onSubmit: (payload: CreateTemplatePayload) => void;
+ isPending: boolean;
+ error: string | null;
+}) {
+ const [form, setForm] = useState({
+ mitre_technique_id: "",
+ name: "",
+ description: "",
+ source: "custom",
+ attack_procedure: "",
+ expected_detection: "",
+ platform: "",
+ tool_suggested: "",
+ severity: "",
+ });
+
+ const handleSubmit = (e: React.FormEvent) => {
+ e.preventDefault();
+ if (!form.mitre_technique_id || !form.name) return;
+ onSubmit(form);
+ };
+
+ return (
+
+
+
+
+ Create Custom Template
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/pages/TestsPage.tsx b/frontend/src/pages/TestsPage.tsx
index 3f1be7f..7c5868e 100644
--- a/frontend/src/pages/TestsPage.tsx
+++ b/frontend/src/pages/TestsPage.tsx
@@ -1,58 +1,198 @@
+import { useState, useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom";
-import { Loader2, AlertCircle, FlaskConical, Plus } from "lucide-react";
-import { getTechniques, type TechniqueSummary } from "../api/techniques";
-import type { TestState, TestResult } from "../types/models";
+import {
+ Loader2,
+ AlertCircle,
+ Plus,
+ Filter,
+ ListChecks,
+ Clock,
+ CheckCircle,
+ XCircle,
+ Eye,
+ Play,
+ Shield,
+ Search,
+} from "lucide-react";
+import { getTests, type TestListFilters } from "../api/tests";
+import type { Test, TestState } from "../types/models";
import { useAuth } from "../context/AuthContext";
+/* ── Badge colour map ──────────────────────────────────────────────── */
+
const testStateBadgeColors: Record = {
draft: "bg-gray-800/50 text-gray-400 border-gray-600/30",
+ red_executing: "bg-orange-900/50 text-orange-400 border-orange-500/30",
+ blue_evaluating: "bg-indigo-900/50 text-indigo-400 border-indigo-500/30",
in_review: "bg-blue-900/50 text-blue-400 border-blue-500/30",
validated: "bg-green-900/50 text-green-400 border-green-500/30",
rejected: "bg-red-900/50 text-red-400 border-red-500/30",
};
-const testResultBadgeColors: Record = {
- detected: "bg-green-900/50 text-green-400 border-green-500/30",
- not_detected: "bg-red-900/50 text-red-400 border-red-500/30",
- partially_detected: "bg-yellow-900/50 text-yellow-400 border-yellow-500/30",
+const testStateLabels: Record = {
+ draft: "Draft",
+ red_executing: "Red Executing",
+ blue_evaluating: "Blue Evaluating",
+ in_review: "In Review",
+ validated: "Validated",
+ rejected: "Rejected",
};
-interface TestSummary {
- id: string;
- technique_id: string;
- technique_mitre_id: string;
- technique_name: string;
- name: string;
- state: TestState;
- result: TestResult | null;
- platform: string | null;
- created_at: string;
+const ALL_STATES: TestState[] = [
+ "draft",
+ "red_executing",
+ "blue_evaluating",
+ "in_review",
+ "validated",
+ "rejected",
+];
+
+/* ── Helper: which team "owns" the current state ────────────────────── */
+
+function currentTeamForState(state: TestState): string {
+ switch (state) {
+ case "draft":
+ case "red_executing":
+ return "Red Team";
+ case "blue_evaluating":
+ return "Blue Team";
+ case "in_review":
+ return "Managers";
+ case "validated":
+ return "-";
+ case "rejected":
+ return "Red Team";
+ default:
+ return "-";
+ }
}
+/* ── Component ──────────────────────────────────────────────────────── */
+
export default function TestsPage() {
const navigate = useNavigate();
const { user } = useAuth();
const canCreate =
- user?.role === "admin" || user?.role === "red_tech" || user?.role === "blue_tech";
+ user?.role === "admin" || user?.role === "red_tech";
+
+ // ── Filter state ──────────────────────────────────────────────────
+ const [stateFilter, setStateFilter] = useState("");
+ const [platformFilter, setPlatformFilter] = useState("");
+ const [searchText, setSearchText] = useState("");
+ const [showMyTasks, setShowMyTasks] = useState(false);
+
+ // Build API filters
+ const filters = useMemo(() => {
+ const f: TestListFilters = { limit: 200 };
+
+ if (showMyTasks && user) {
+ // Role-specific "my tasks" filtering
+ switch (user.role) {
+ case "red_tech":
+ // Tests I created in draft or red_executing
+ f.created_by = user.id;
+ if (!stateFilter) {
+ // Client-side filter for draft + red_executing
+ } else {
+ f.state = stateFilter as TestState;
+ }
+ break;
+ case "blue_tech":
+ f.state = "blue_evaluating";
+ break;
+ case "red_lead":
+ f.pending_validation_side = "red";
+ break;
+ case "blue_lead":
+ f.pending_validation_side = "blue";
+ break;
+ default:
+ // admin: show all
+ if (stateFilter) f.state = stateFilter as TestState;
+ break;
+ }
+ } else {
+ if (stateFilter) f.state = stateFilter as TestState;
+ }
+
+ if (platformFilter) f.platform = platformFilter;
+ return f;
+ }, [stateFilter, platformFilter, showMyTasks, user]);
- // For now, we'll fetch techniques to get their tests
- // In a production app, you'd want a dedicated /tests endpoint
const {
- data: techniques,
+ data: allTests,
isLoading,
error,
} = useQuery({
- queryKey: ["techniques"],
- queryFn: () => getTechniques(),
+ queryKey: ["tests", filters],
+ queryFn: () => getTests(filters),
});
- // Note: Since we don't have a direct /tests list endpoint, we're showing
- // a message to navigate through techniques. In a full implementation,
- // you'd add a /tests endpoint to the backend.
+ // Client-side filtering for search text and "my tasks" for red_tech
+ const tests = useMemo(() => {
+ if (!allTests) return [];
+ let filtered = allTests;
- const formatDate = (dateStr: string) => {
+ // Red tech "my tasks" — client-side filter for draft + red_executing
+ if (showMyTasks && user?.role === "red_tech" && !stateFilter) {
+ filtered = filtered.filter(
+ (t) => t.state === "draft" || t.state === "red_executing"
+ );
+ }
+
+ // Search text
+ if (searchText.trim()) {
+ const q = searchText.toLowerCase();
+ filtered = filtered.filter(
+ (t) =>
+ t.name.toLowerCase().includes(q) ||
+ (t.technique_mitre_id && t.technique_mitre_id.toLowerCase().includes(q)) ||
+ (t.technique_name && t.technique_name.toLowerCase().includes(q))
+ );
+ }
+
+ return filtered;
+ }, [allTests, searchText, showMyTasks, user, stateFilter]);
+
+ // ── State counters ────────────────────────────────────────────────
+ // Count from allTests (before client search filter) to show accurate pipeline
+ const stateCounts = useMemo(() => {
+ const counts: Record = {};
+ for (const s of ALL_STATES) counts[s] = 0;
+ if (allTests) {
+ for (const t of allTests) {
+ counts[t.state] = (counts[t.state] || 0) + 1;
+ }
+ }
+ return counts;
+ }, [allTests]);
+
+ // Count from unfiltered query for the top cards
+ const {
+ data: allTestsUnfiltered,
+ } = useQuery({
+ queryKey: ["tests", "unfiltered-counts"],
+ queryFn: () => getTests({ limit: 200 }),
+ });
+
+ const globalCounts = useMemo(() => {
+ const counts: Record = {};
+ for (const s of ALL_STATES) counts[s] = 0;
+ if (allTestsUnfiltered) {
+ for (const t of allTestsUnfiltered) {
+ counts[t.state] = (counts[t.state] || 0) + 1;
+ }
+ }
+ return counts;
+ }, [allTestsUnfiltered]);
+
+ const totalTests = allTestsUnfiltered?.length || 0;
+
+ // ── Formatting helpers ─────────────────────────────────────────────
+ const formatDate = (dateStr: string | null) => {
+ if (!dateStr) return "-";
return new Date(dateStr).toLocaleDateString("en-US", {
year: "numeric",
month: "short",
@@ -60,6 +200,25 @@ export default function TestsPage() {
});
};
+ // ── My tasks label ────────────────────────────────────────────────
+ const myTasksLabel = useMemo(() => {
+ if (!user) return "My Tasks";
+ switch (user.role) {
+ case "red_tech":
+ return "My Tests (Draft / Executing)";
+ case "blue_tech":
+ return "Pending Blue Evaluation";
+ case "red_lead":
+ return "Pending Red Validation";
+ case "blue_lead":
+ return "Pending Blue Validation";
+ default:
+ return "My Tasks";
+ }
+ }, [user]);
+
+ // ── Render ─────────────────────────────────────────────────────────
+
if (isLoading) {
return (
@@ -72,7 +231,7 @@ export default function TestsPage() {
return (
-
Failed to load data
+
Failed to load tests
);
}
@@ -84,7 +243,7 @@ export default function TestsPage() {
Tests
- Security tests for technique validation
+ Security tests for technique validation — Red/Blue workflow
{canCreate && (
@@ -98,128 +257,233 @@ export default function TestsPage() {
)}
- {/* Info Card */}
-
-
-
-
-
-
-
Browse Tests by Technique
-
- Tests are organized by MITRE ATT&CK technique. Navigate to a technique from the{" "}
- navigate("/techniques")}
- className="text-cyan-400 hover:underline"
- >
- Techniques page
- {" "}
- to view and manage its associated tests.
-
-
- navigate("/techniques")}
- className="rounded-lg border border-cyan-500/30 bg-cyan-500/10 px-4 py-2 text-sm font-medium text-cyan-400 hover:bg-cyan-500/20 transition-colors"
- >
- Browse Techniques Matrix
-
- {canCreate && (
- navigate("/tests/new")}
- className="rounded-lg border border-gray-700 bg-gray-800 px-4 py-2 text-sm font-medium text-gray-300 hover:border-gray-600 hover:text-white transition-colors"
- >
- Create Standalone Test
-
- )}
-
-
-
+ {/* ── State Counter Cards ───────────────────────────────────────── */}
+
+ {ALL_STATES.map((state) => {
+ const icons: Record
= {
+ draft: ,
+ red_executing: ,
+ blue_evaluating: ,
+ in_review: ,
+ validated: ,
+ rejected: ,
+ };
+ const colorMap: Record = {
+ draft: "text-gray-400",
+ red_executing: "text-orange-400",
+ blue_evaluating: "text-indigo-400",
+ in_review: "text-blue-400",
+ validated: "text-green-400",
+ rejected: "text-red-400",
+ };
+ return (
+ {
+ setShowMyTasks(false);
+ setStateFilter(stateFilter === state ? "" : state);
+ }}
+ className={`rounded-xl border p-4 text-left transition-colors ${
+ stateFilter === state
+ ? "border-cyan-500/50 bg-cyan-500/10"
+ : "border-gray-800 bg-gray-900 hover:border-gray-700"
+ }`}
+ >
+
+ {icons[state]}
+
+ {testStateLabels[state]}
+
+
+
+ {globalCounts[state]}
+
+
+ );
+ })}
- {/* Quick Stats */}
-
-
-
Techniques with Tests
-
- {techniques?.filter((t: TechniqueSummary) => t.status_global !== "not_evaluated").length || 0}
-
-
-
-
Validated
-
- {techniques?.filter((t: TechniqueSummary) => t.status_global === "validated").length || 0}
-
-
-
-
In Progress
-
- {techniques?.filter((t: TechniqueSummary) => t.status_global === "in_progress").length || 0}
-
-
-
-
Pending Evaluation
-
- {techniques?.filter((t: TechniqueSummary) => t.status_global === "not_evaluated").length || 0}
-
+ {/* ── Filters Bar ───────────────────────────────────────────────── */}
+
+
+ {/* My tasks toggle */}
+ {user?.role !== "admin" && user?.role !== "viewer" && (
+
{
+ setShowMyTasks(!showMyTasks);
+ if (!showMyTasks) setStateFilter("");
+ }}
+ className={`flex items-center gap-1.5 rounded-lg border px-3 py-2 text-sm font-medium transition-colors ${
+ showMyTasks
+ ? "border-cyan-500/50 bg-cyan-500/20 text-cyan-400"
+ : "border-gray-700 bg-gray-800 text-gray-300 hover:border-gray-600"
+ }`}
+ >
+
+ {myTasksLabel}
+
+ )}
+
+ {/* State filter */}
+
+
+ setStateFilter(e.target.value as TestState | "")}
+ className="rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-300 focus:border-cyan-500 focus:outline-none"
+ >
+ All States
+ {ALL_STATES.map((s) => (
+
+ {testStateLabels[s]}
+
+ ))}
+
+
+
+ {/* Platform filter */}
+
setPlatformFilter(e.target.value)}
+ placeholder="Platform..."
+ className="rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-300 placeholder-gray-500 focus:border-cyan-500 focus:outline-none w-32"
+ />
+
+ {/* Search */}
+
+
+ setSearchText(e.target.value)}
+ placeholder="Search by name or technique..."
+ className="w-full rounded-lg border border-gray-700 bg-gray-800 pl-9 pr-3 py-2 text-sm text-gray-300 placeholder-gray-500 focus:border-cyan-500 focus:outline-none"
+ />
+
+
+ {/* Clear filters */}
+ {(stateFilter || platformFilter || searchText || showMyTasks) && (
+
{
+ setStateFilter("");
+ setPlatformFilter("");
+ setSearchText("");
+ setShowMyTasks(false);
+ }}
+ className="text-xs text-gray-400 hover:text-white transition-colors"
+ >
+ Clear all
+
+ )}
+
+ {/* Active filter summary */}
+ {(stateFilter || showMyTasks) && (
+
+ Showing:
+ {showMyTasks && (
+
+ {myTasksLabel}
+
+ )}
+ {stateFilter && (
+
+ {testStateLabels[stateFilter as TestState]}
+
+ )}
+
+ ({tests.length} of {totalTests} tests)
+
+
+ )}
- {/* Techniques with Recent Activity */}
+ {/* ── Tests Table ───────────────────────────────────────────────── */}
-
Techniques Being Tested
+
+
+ {showMyTasks ? myTasksLabel : "All Tests"}
+
+ {tests.length} tests
+
+
- Technique
- Name
- Status
+ Name
+ Technique
+ State
+ Current Team
+ Platform
+ Updated
Action
- {techniques
- ?.filter((t: TechniqueSummary) => t.status_global !== "not_evaluated")
- .slice(0, 10)
- .map((tech: TechniqueSummary) => (
-
-
- {tech.mitre_id}
-
- {tech.name}
-
-
- {tech.status_global.replace(/_/g, " ")}
-
-
-
- navigate(`/techniques/${tech.mitre_id}`)}
- className="text-sm text-cyan-400 hover:underline"
- >
- View Tests
-
-
-
- ))}
+ {tests.map((test: Test) => (
+ navigate(`/tests/${test.id}`)}
+ >
+
+ {test.name}
+
+
+ {test.technique_mitre_id ? (
+
+
+ {test.technique_mitre_id}
+
+
+ {test.technique_name}
+
+
+ ) : (
+ -
+ )}
+
+
+
+ {testStateLabels[test.state]}
+
+
+
+ {currentTeamForState(test.state)}
+
+
+ {test.platform || "-"}
+
+
+ {formatDate(test.created_at)}
+
+
+ {
+ e.stopPropagation();
+ navigate(`/tests/${test.id}`);
+ }}
+ className="text-sm text-cyan-400 hover:underline"
+ >
+ View
+
+
+
+ ))}
- {techniques?.filter((t: TechniqueSummary) => t.status_global !== "not_evaluated").length === 0 && (
-
- No techniques have been tested yet. Create your first test to get started.
+
+ {tests.length === 0 && (
+
+ {showMyTasks
+ ? "No pending tasks for your role."
+ : "No tests found matching your filters."}
)}
diff --git a/frontend/src/types/models.ts b/frontend/src/types/models.ts
index 31cacee..e77b8bb 100644
--- a/frontend/src/types/models.ts
+++ b/frontend/src/types/models.ts
@@ -86,6 +86,10 @@ export interface Test {
blue_validation_status: ValidationStatus | null;
blue_validation_notes: string | null;
+ // Technique info (populated in list endpoints)
+ technique_mitre_id: string | null;
+ technique_name: string | null;
+
// Separated evidences
red_evidences: Evidence[];
blue_evidences: Evidence[];