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 +

+ +
+ + {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.team} +
+ + {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

+ +
+ + {recentLoading ? ( +
+ +
+ ) : recentTests && recentTests.length > 0 ? ( +
+ + + + + + + + + + + {recentTests.map((t: RecentTestItem) => ( + navigate(`/tests/${t.id}`)} + > + + + + + + ))} + +
NameTechniqueStateCreated
{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} + +
+
+ )} + + +
+
+
+ + {/* 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 +

+ +
+ + {templatesLoading ? ( +
+ +
+ ) : templates && templates.length > 0 ? ( +
+ + + + + + + + + + + + + {(templates as TestTemplate[]).map((tpl) => ( + + + + + + + + + ))} + +
NameTechniqueSourcePlatformStatusAction
+ + {tpl.name} + + + + {tpl.mitre_technique_id} + + + + {tpl.source.replace(/_/g, " ")} + + + {tpl.platform || "-"} + + + {tpl.is_active ? "Active" : "Inactive"} + + + +
+
+ ) : ( +
+ 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 +

+ +
+ +
+
+ {/* MITRE Technique ID */} +
+ + setForm({ ...form, mitre_technique_id: e.target.value })} + placeholder="e.g. T1059.001" + required + className="w-full 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" + /> +
+ + {/* Name */} +
+ + setForm({ ...form, name: e.target.value })} + placeholder="Test template name" + required + className="w-full 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" + /> +
+ + {/* Platform */} +
+ + +
+ + {/* Severity */} +
+ + +
+
+ + {/* Description */} +
+ +