feat(phase-16): enhanced Tests view, Red/Blue dashboard metrics, and Template admin panel (T-122, T-123, T-124)

This commit is contained in:
2026-02-09 13:00:07 +01:00
parent fd7f855008
commit a95defcee4
12 changed files with 1769 additions and 159 deletions

View File

@@ -1,21 +1,30 @@
"""Coverage-metrics endpoints. """Coverage-metrics endpoints.
Provides aggregated views of MITRE ATT&CK technique coverage for 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 collections import defaultdict
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
from sqlalchemy import func from sqlalchemy import func
from sqlalchemy.orm import Session from sqlalchemy.orm import Session, joinedload
from app.database import get_db from app.database import get_db
from app.dependencies.auth import get_current_user 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.technique import Technique
from app.models.test import Test
from app.models.user import User 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"]) router = APIRouter(prefix="/metrics", tags=["metrics"])
@@ -117,3 +126,190 @@ def coverage_by_tactic(
) )
return result 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
]

View File

@@ -3,9 +3,11 @@
Endpoints Endpoints
--------- ---------
GET /test-templates — list with filters + pagination GET /test-templates — list with filters + pagination
GET /test-templates/stats — catalog statistics (admin)
GET /test-templates/{id} — detail GET /test-templates/{id} — detail
POST /test-templates — create custom (admin) POST /test-templates — create custom (admin)
PATCH /test-templates/{id} — update (admin) PATCH /test-templates/{id} — update (admin)
PATCH /test-templates/{id}/toggle-active — toggle active/inactive (admin)
DELETE /test-templates/{id} — soft delete (admin) DELETE /test-templates/{id} — soft delete (admin)
GET /test-templates/by-technique/{mitre_id} — templates for a MITRE technique 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 - severity: low | medium | high | critical
- mitre_technique_id: filter by specific technique - mitre_technique_id: filter by specific technique
- search: full-text search across name and description - search: full-text search across name and description
- is_active: true | false (default only active)
- offset / limit: pagination (default limit=50) - offset / limit: pagination (default limit=50)
""" """
@@ -23,7 +26,7 @@ import uuid
from typing import Optional from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query, status from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import or_ from sqlalchemy import func, or_
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.database import get_db from app.database import get_db
@@ -57,7 +60,7 @@ def list_templates(
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(get_current_user), 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 query = db.query(TestTemplate).filter(TestTemplate.is_active == True) # noqa: E712
if source: if source:
@@ -87,6 +90,53 @@ def list_templates(
return 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} # GET /test-templates/by-technique/{mitre_id}
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -208,6 +258,41 @@ def update_template(
return 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) # DELETE /test-templates/{id} — soft delete (admin only)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View File

@@ -88,18 +88,37 @@ def _get_test_with_technique(db: Session, test_id: uuid.UUID) -> Test:
def list_tests( def list_tests(
state: Optional[str] = Query(None, description="Filter by test state"), state: Optional[str] = Query(None, description="Filter by test state"),
technique_id: Optional[uuid.UUID] = Query(None, description="Filter by technique"), 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), offset: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200), limit: int = Query(50, ge=1, le=200),
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
): ):
"""Return a paginated list of tests, optionally filtered by state or technique.""" """Return a paginated list of tests, optionally filtered by state, technique, platform or creator."""
query = db.query(Test) query = db.query(Test).options(joinedload(Test.technique))
if state: if state:
query = query.filter(Test.state == state) query = query.filter(Test.state == state)
if technique_id: if technique_id:
query = query.filter(Test.technique_id == 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() tests = query.order_by(Test.created_at.desc()).offset(offset).limit(limit).all()
return tests return tests

View File

@@ -1,6 +1,8 @@
"""Pydantic schemas for coverage-metrics endpoints.""" """Pydantic schemas for coverage-metrics endpoints."""
from pydantic import BaseModel from datetime import datetime
from pydantic import BaseModel, ConfigDict
class CoverageSummary(BaseModel): class CoverageSummary(BaseModel):
@@ -25,3 +27,59 @@ class TacticCoverage(BaseModel):
not_covered: int not_covered: int
not_evaluated: int not_evaluated: int
in_progress: 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)

View File

@@ -126,4 +126,16 @@ class TestOut(BaseModel):
blue_validation_status: str | None = None blue_validation_status: str | None = None
blue_validation_notes: 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) 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)

View File

@@ -1,6 +1,8 @@
import client from "./client"; import client from "./client";
import type { CoverageSummary, TacticCoverage } from "../types/models"; import type { CoverageSummary, TacticCoverage } from "../types/models";
// ── V1 — Coverage ───────────────────────────────────────────────────
/** Fetch the global coverage summary. */ /** Fetch the global coverage summary. */
export async function getCoverageSummary(): Promise<CoverageSummary> { export async function getCoverageSummary(): Promise<CoverageSummary> {
const { data } = await client.get<CoverageSummary>("/metrics/summary"); const { data } = await client.get<CoverageSummary>("/metrics/summary");
@@ -12,3 +14,69 @@ export async function getCoverageByTactic(): Promise<TacticCoverage[]> {
const { data } = await client.get<TacticCoverage[]>("/metrics/by-tactic"); const { data } = await client.get<TacticCoverage[]>("/metrics/by-tactic");
return data; 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<TestPipelineCounts> {
const { data } = await client.get<TestPipelineCounts>("/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<TeamActivityItem[]> {
const { data } = await client.get<TeamActivityItem[]>("/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<ValidationRateItem[]> {
const { data } = await client.get<ValidationRateItem[]>("/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<RecentTestItem[]> {
const { data } = await client.get<RecentTestItem[]>("/metrics/recent-tests");
return data;
}

View File

@@ -95,6 +95,53 @@ export async function createTemplate(
return data; return data;
} }
// ── Stats (admin) ──────────────────────────────────────────────────
export interface TemplateStats {
total: number;
active: number;
inactive: number;
by_source: Record<string, number>;
by_platform: Record<string, number>;
}
/** Fetch template catalog statistics. Admin only. */
export async function getTemplateStats(): Promise<TemplateStats> {
const { data } = await client.get<TemplateStats>("/test-templates/stats");
return data;
}
// ── Toggle active (admin) ──────────────────────────────────────────
/** Toggle a template between active/inactive. Admin only. */
export async function toggleTemplateActive(
id: string,
): Promise<TestTemplate> {
const { data } = await client.patch<TestTemplate>(
`/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<TestTemplate[]> {
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<TestTemplate[]>(
`/test-templates${params.toString() ? `?${params}` : ""}`,
);
return data;
}
// ── Import Atomic Red Team ───────────────────────────────────────── // ── Import Atomic Red Team ─────────────────────────────────────────
/** Trigger Atomic Red Team import. Admin only. */ /** Trigger Atomic Red Team import. Admin only. */

View File

@@ -59,6 +59,9 @@ export interface TestValidatePayload {
export interface TestListFilters { export interface TestListFilters {
state?: TestState; state?: TestState;
technique_id?: string; technique_id?: string;
platform?: string;
created_by?: string;
pending_validation_side?: "red" | "blue";
offset?: number; offset?: number;
limit?: number; limit?: number;
} }
@@ -70,6 +73,9 @@ export async function getTests(filters?: TestListFilters): Promise<Test[]> {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (filters?.state) params.append("state", filters.state); if (filters?.state) params.append("state", filters.state);
if (filters?.technique_id) params.append("technique_id", filters.technique_id); 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?.offset !== undefined) params.append("offset", String(filters.offset));
if (filters?.limit !== undefined) params.append("limit", String(filters.limit)); if (filters?.limit !== undefined) params.append("limit", String(filters.limit));

View File

@@ -1,4 +1,5 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom";
import { import {
Shield, Shield,
CheckCircle, CheckCircle,
@@ -9,12 +10,54 @@ import {
Percent, Percent,
Loader2, Loader2,
AlertCircle, AlertCircle,
Play,
Eye,
Users,
TrendingUp,
ArrowRight,
} from "lucide-react"; } 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 CoverageSummaryCard from "../components/CoverageSummaryCard";
import TacticCoverageChart from "../components/TacticCoverageChart"; import TacticCoverageChart from "../components/TacticCoverageChart";
import type { TestState } from "../types/models";
/* ── Badge colours (reused from TestsPage) ─────────────────────────── */
const testStateBadgeColors: Record<string, string> = {
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<string, string> = {
draft: "Draft",
red_executing: "Red Executing",
blue_evaluating: "Blue Evaluating",
in_review: "In Review",
validated: "Validated",
rejected: "Rejected",
};
/* ── Component ──────────────────────────────────────────────────────── */
export default function DashboardPage() { export default function DashboardPage() {
const navigate = useNavigate();
// Existing coverage queries
const { const {
data: summary, data: summary,
isLoading: summaryLoading, isLoading: summaryLoading,
@@ -33,6 +76,27 @@ export default function DashboardPage() {
queryFn: getCoverageByTactic, 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) { if (summaryLoading || tacticsLoading) {
return ( return (
<div className="flex h-64 items-center justify-center"> <div className="flex h-64 items-center justify-center">
@@ -127,8 +191,263 @@ export default function DashboardPage() {
</div> </div>
)} )}
{/* Tactic Coverage Table */} {/* ── V2 Section: Test Pipeline ────────────────────────────────── */}
<div className="rounded-xl border border-gray-800 bg-gray-900 p-6">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-semibold text-white flex items-center gap-2">
<TrendingUp className="h-5 w-5 text-cyan-400" />
Test Pipeline
</h2>
<button
onClick={() => navigate("/tests")}
className="text-sm text-cyan-400 hover:underline flex items-center gap-1"
>
View all tests <ArrowRight className="h-3.5 w-3.5" />
</button>
</div>
{pipelineLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-cyan-400" />
</div>
) : pipeline ? (
<PipelineFunnel pipeline={pipeline} />
) : null}
</div>
{/* ── V2 Section: Team Activity + Validation Rate ──────────────── */}
<div className="grid gap-6 lg:grid-cols-2">
{/* Team Activity */}
<div className="rounded-xl border border-gray-800 bg-gray-900 p-6">
<h2 className="mb-4 text-lg font-semibold text-white flex items-center gap-2">
<Users className="h-5 w-5 text-cyan-400" />
Team Activity
</h2>
{teamLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-cyan-400" />
</div>
) : teamActivity ? (
<div className="space-y-4">
{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 (
<div key={team.team} className="rounded-lg border border-gray-800 bg-gray-800/50 p-4">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<div
className={`h-3 w-3 rounded-full ${
isRed ? "bg-red-500" : "bg-blue-500"
}`}
/>
<span className="font-medium text-white">{team.team}</span>
</div>
<span className="text-sm text-gray-400">
{team.tests_completed} completed / {team.tests_pending} pending
</span>
</div>
<div className="h-2 rounded-full bg-gray-700 overflow-hidden">
<div
className={`h-full rounded-full transition-all ${
isRed ? "bg-red-500" : "bg-blue-500"
}`}
style={{ width: `${pct}%` }}
/>
</div>
<p className="mt-1 text-xs text-gray-500">
{pct.toFixed(0)}% completion rate
</p>
</div>
);
})}
</div>
) : null}
</div>
{/* Validation Rate */}
<div className="rounded-xl border border-gray-800 bg-gray-900 p-6">
<h2 className="mb-4 text-lg font-semibold text-white flex items-center gap-2">
<CheckCircle className="h-5 w-5 text-cyan-400" />
Validation Rate
</h2>
{validationLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-cyan-400" />
</div>
) : validationRates ? (
<div className="space-y-4">
{validationRates.map((rate: ValidationRateItem) => {
const isRed = rate.role === "red_lead";
return (
<div key={rate.role} className="rounded-lg border border-gray-800 bg-gray-800/50 p-4">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<div
className={`h-3 w-3 rounded-full ${
isRed ? "bg-red-500" : "bg-blue-500"
}`}
/>
<span className="font-medium text-white">
{isRed ? "Red Lead" : "Blue Lead"}
</span>
</div>
<span className="text-sm text-gray-400">
{rate.total_reviewed} reviewed
</span>
</div>
<div className="flex gap-4 text-sm">
<div className="flex items-center gap-1">
<CheckCircle className="h-3.5 w-3.5 text-green-400" />
<span className="text-green-400">{rate.approved} approved</span>
</div>
<div className="flex items-center gap-1">
<XCircle className="h-3.5 w-3.5 text-red-400" />
<span className="text-red-400">{rate.rejected} rejected</span>
</div>
</div>
<div className="mt-3 h-2 rounded-full bg-gray-700 overflow-hidden">
<div
className="h-full rounded-full bg-green-500 transition-all"
style={{ width: `${rate.approval_rate}%` }}
/>
</div>
<p className="mt-1 text-xs text-gray-500">
{rate.approval_rate}% approval rate
</p>
</div>
);
})}
</div>
) : null}
</div>
</div>
{/* ── V2 Section: Recent Tests ─────────────────────────────────── */}
<div className="rounded-xl border border-gray-800 bg-gray-900 p-6">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-semibold text-white">Recent Tests</h2>
<button
onClick={() => navigate("/tests")}
className="text-sm text-cyan-400 hover:underline flex items-center gap-1"
>
View all <ArrowRight className="h-3.5 w-3.5" />
</button>
</div>
{recentLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-cyan-400" />
</div>
) : recentTests && recentTests.length > 0 ? (
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead>
<tr className="border-b border-gray-800">
<th className="pb-3 pr-4 font-medium text-gray-400">Name</th>
<th className="pb-3 px-4 font-medium text-gray-400">Technique</th>
<th className="pb-3 px-4 font-medium text-gray-400">State</th>
<th className="pb-3 pl-4 font-medium text-gray-400">Created</th>
</tr>
</thead>
<tbody>
{recentTests.map((t: RecentTestItem) => (
<tr
key={t.id}
className="border-b border-gray-800/50 hover:bg-gray-800/30 transition-colors cursor-pointer"
onClick={() => navigate(`/tests/${t.id}`)}
>
<td className="py-3 pr-4 font-medium text-gray-200">{t.name}</td>
<td className="py-3 px-4">
{t.technique_mitre_id ? (
<span className="font-mono text-xs text-cyan-400">
{t.technique_mitre_id}
</span>
) : (
<span className="text-gray-500">-</span>
)}
</td>
<td className="py-3 px-4">
<span
className={`inline-flex rounded-full border px-2 py-0.5 text-xs font-medium ${
testStateBadgeColors[t.state] || "bg-gray-800 text-gray-400 border-gray-600"
}`}
>
{testStateLabels[t.state] || t.state}
</span>
</td>
<td className="py-3 pl-4 text-gray-400 text-xs">
{t.created_at
? new Date(t.created_at).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
})
: "-"}
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="py-8 text-center text-gray-400">
No tests created yet.
</div>
)}
</div>
{/* Tactic Coverage Table (original V1) */}
{tactics && <TacticCoverageChart data={tactics} />} {tactics && <TacticCoverageChart data={tactics} />}
</div> </div>
); );
} }
/* ── 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: <Clock className="h-4 w-4 text-gray-400" /> },
{ key: "red_executing", label: "Red Executing", color: "bg-orange-500", icon: <Play className="h-4 w-4 text-orange-400" /> },
{ key: "blue_evaluating", label: "Blue Evaluating", color: "bg-indigo-500", icon: <Shield className="h-4 w-4 text-indigo-400" /> },
{ key: "in_review", label: "In Review", color: "bg-blue-500", icon: <Eye className="h-4 w-4 text-blue-400" /> },
{ key: "validated", label: "Validated", color: "bg-green-500", icon: <CheckCircle className="h-4 w-4 text-green-400" /> },
{ key: "rejected", label: "Rejected", color: "bg-red-500", icon: <XCircle className="h-4 w-4 text-red-400" /> },
];
const maxCount = Math.max(...stages.map((s) => pipeline[s.key] as number), 1);
return (
<div className="space-y-3">
{stages.map((stage) => {
const count = pipeline[stage.key] as number;
const pct = (count / maxCount) * 100;
return (
<div key={stage.key} className="flex items-center gap-3">
<div className="flex items-center gap-2 w-36 shrink-0">
{stage.icon}
<span className="text-sm text-gray-300 truncate">{stage.label}</span>
</div>
<div className="flex-1 h-6 rounded bg-gray-800 overflow-hidden">
<div
className={`h-full rounded ${stage.color} transition-all flex items-center justify-end px-2`}
style={{ width: `${Math.max(pct, count > 0 ? 8 : 0)}%` }}
>
{count > 0 && (
<span className="text-xs font-medium text-white">{count}</span>
)}
</div>
</div>
<span className="text-sm font-mono text-gray-400 w-8 text-right">{count}</span>
</div>
);
})}
<div className="pt-2 border-t border-gray-800 flex justify-between text-sm">
<span className="text-gray-400">Total tests</span>
<span className="font-medium text-white">{pipeline.total}</span>
</div>
</div>
);
}

View File

@@ -12,6 +12,13 @@ import {
XCircle, XCircle,
Shield, Shield,
Search, Search,
FlaskConical,
Download,
Plus,
ToggleLeft,
ToggleRight,
BarChart3,
X,
} from "lucide-react"; } from "lucide-react";
import { import {
triggerMitreSync, triggerMitreSync,
@@ -20,12 +27,26 @@ import {
type SyncMitreResponse, type SyncMitreResponse,
type IntelScanResponse, type IntelScanResponse,
} from "../api/system"; } 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() { export default function SystemPage() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [syncResult, setSyncResult] = useState<SyncMitreResponse | null>(null); const [syncResult, setSyncResult] = useState<SyncMitreResponse | null>(null);
const [intelResult, setIntelResult] = useState<IntelScanResponse | null>(null); const [intelResult, setIntelResult] = useState<IntelScanResponse | null>(null);
const [importResult, setImportResult] = useState<ImportAtomicResponse | null>(null);
const [showCreateForm, setShowCreateForm] = useState(false);
// ── Existing queries ─────────────────────────────────────────────
const { const {
data: schedulerStatus, data: schedulerStatus,
isLoading: statusLoading, isLoading: statusLoading,
@@ -33,9 +54,27 @@ export default function SystemPage() {
} = useQuery({ } = useQuery({
queryKey: ["scheduler-status"], queryKey: ["scheduler-status"],
queryFn: getSchedulerStatus, 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({ const mitreSyncMutation = useMutation({
mutationFn: triggerMitreSync, mutationFn: triggerMitreSync,
onSuccess: (data) => { 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) => { const formatNextRun = (dateStr: string | null) => {
if (!dateStr) return "Not scheduled"; if (!dateStr) return "Not scheduled";
const date = new Date(dateStr); const date = new Date(dateStr);
@@ -68,7 +136,7 @@ export default function SystemPage() {
<div> <div>
<h1 className="text-2xl font-bold text-white">System Administration</h1> <h1 className="text-2xl font-bold text-white">System Administration</h1>
<p className="mt-1 text-sm text-gray-400"> <p className="mt-1 text-sm text-gray-400">
Manage synchronization jobs and system status Manage synchronization jobs, templates, and system status
</p> </p>
</div> </div>
@@ -86,7 +154,6 @@ export default function SystemPage() {
Synchronize techniques from the MITRE ATT&CK framework via TAXII or GitHub fallback. Synchronize techniques from the MITRE ATT&CK framework via TAXII or GitHub fallback.
</p> </p>
{/* Status */}
{schedulerStatus && ( {schedulerStatus && (
<div className="mt-4 flex items-center gap-2 text-sm"> <div className="mt-4 flex items-center gap-2 text-sm">
<Clock className="h-4 w-4 text-gray-500" /> <Clock className="h-4 w-4 text-gray-500" />
@@ -99,7 +166,6 @@ export default function SystemPage() {
</div> </div>
)} )}
{/* Result */}
{syncResult && ( {syncResult && (
<div className="mt-4 rounded-lg border border-green-500/30 bg-green-900/20 p-3"> <div className="mt-4 rounded-lg border border-green-500/30 bg-green-900/20 p-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -158,7 +224,6 @@ export default function SystemPage() {
Scan RSS feeds and security blogs for new threat intelligence related to techniques. Scan RSS feeds and security blogs for new threat intelligence related to techniques.
</p> </p>
{/* Status */}
{schedulerStatus && ( {schedulerStatus && (
<div className="mt-4 flex items-center gap-2 text-sm"> <div className="mt-4 flex items-center gap-2 text-sm">
<Clock className="h-4 w-4 text-gray-500" /> <Clock className="h-4 w-4 text-gray-500" />
@@ -171,7 +236,6 @@ export default function SystemPage() {
</div> </div>
)} )}
{/* Result */}
{intelResult && ( {intelResult && (
<div className="mt-4 rounded-lg border border-green-500/30 bg-green-900/20 p-3"> <div className="mt-4 rounded-lg border border-green-500/30 bg-green-900/20 p-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -213,11 +277,278 @@ export default function SystemPage() {
</div> </div>
</div> </div>
{/* ────────────────────────────────────────────────────────────────
TEMPLATE ADMINISTRATION (T-124)
──────────────────────────────────────────────────────────────── */}
{/* Import Atomic Red Team + Stats */}
<div className="grid gap-6 lg:grid-cols-2">
{/* Import Atomic Red Team */}
<div className="rounded-xl border border-gray-800 bg-gray-900 p-6">
<div className="flex items-start gap-4">
<div className="rounded-lg bg-red-500/10 p-3">
<Download className="h-6 w-6 text-red-400" />
</div>
<div className="flex-1">
<h2 className="text-lg font-semibold text-white">Import Atomic Red Team</h2>
<p className="mt-1 text-sm text-gray-400">
Import test templates from the Atomic Red Team repository by Red Canary, mapped to MITRE ATT&CK techniques.
</p>
{importResult && (
<div className="mt-4 rounded-lg border border-green-500/30 bg-green-900/20 p-3">
<div className="flex items-center gap-2">
<CheckCircle className="h-4 w-4 text-green-400" />
<span className="text-sm font-medium text-green-400">Import Complete</span>
</div>
<div className="mt-2 grid grid-cols-3 gap-2 text-sm">
<div>
<span className="text-gray-400">Imported:</span>
<span className="ml-1 font-medium text-white">{importResult.imported}</span>
</div>
<div>
<span className="text-gray-400">Skipped:</span>
<span className="ml-1 font-medium text-white">{importResult.skipped}</span>
</div>
<div>
<span className="text-gray-400">Parsed:</span>
<span className="ml-1 font-medium text-white">{importResult.total_parsed}</span>
</div>
</div>
</div>
)}
{importAtomicMutation.isError && (
<div className="mt-4 rounded-lg border border-red-500/30 bg-red-900/20 p-3">
<div className="flex items-center gap-2">
<XCircle className="h-4 w-4 text-red-400" />
<span className="text-sm text-red-400">
Import failed: {(importAtomicMutation.error as Error)?.message}
</span>
</div>
</div>
)}
<button
onClick={() => 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 ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Download className="h-4 w-4" />
)}
{importAtomicMutation.isPending ? "Importing..." : "Import Now"}
</button>
</div>
</div>
</div>
{/* Template Catalog Stats */}
<div className="rounded-xl border border-gray-800 bg-gray-900 p-6">
<div className="flex items-start gap-4">
<div className="rounded-lg bg-yellow-500/10 p-3">
<BarChart3 className="h-6 w-6 text-yellow-400" />
</div>
<div className="flex-1">
<h2 className="text-lg font-semibold text-white">Catalog Statistics</h2>
<p className="mt-1 text-sm text-gray-400">
Overview of the test template catalog.
</p>
{statsLoading ? (
<div className="mt-4 flex items-center justify-center py-4">
<Loader2 className="h-5 w-5 animate-spin text-cyan-400" />
</div>
) : templateStats ? (
<div className="mt-4 space-y-4">
{/* Totals */}
<div className="grid grid-cols-3 gap-3">
<div className="rounded-lg border border-gray-700 bg-gray-800/50 p-3 text-center">
<p className="text-2xl font-bold text-cyan-400">{templateStats.total}</p>
<p className="text-xs text-gray-400">Total</p>
</div>
<div className="rounded-lg border border-gray-700 bg-gray-800/50 p-3 text-center">
<p className="text-2xl font-bold text-green-400">{templateStats.active}</p>
<p className="text-xs text-gray-400">Active</p>
</div>
<div className="rounded-lg border border-gray-700 bg-gray-800/50 p-3 text-center">
<p className="text-2xl font-bold text-gray-400">{templateStats.inactive}</p>
<p className="text-xs text-gray-400">Inactive</p>
</div>
</div>
{/* By source */}
<div>
<p className="text-xs font-medium uppercase text-gray-500 mb-2">By Source</p>
<div className="flex flex-wrap gap-2">
{Object.entries(templateStats.by_source).map(([source, count]) => (
<span
key={source}
className="inline-flex items-center gap-1 rounded-full border border-gray-700 bg-gray-800 px-2.5 py-1 text-xs text-gray-300"
>
{source.replace(/_/g, " ")}
<span className="font-medium text-cyan-400">{count}</span>
</span>
))}
{Object.keys(templateStats.by_source).length === 0 && (
<span className="text-xs text-gray-500">No templates yet</span>
)}
</div>
</div>
{/* By platform */}
<div>
<p className="text-xs font-medium uppercase text-gray-500 mb-2">By Platform</p>
<div className="flex flex-wrap gap-2">
{Object.entries(templateStats.by_platform).map(([platform, count]) => (
<span
key={platform}
className="inline-flex items-center gap-1 rounded-full border border-gray-700 bg-gray-800 px-2.5 py-1 text-xs text-gray-300"
>
{platform}
<span className="font-medium text-cyan-400">{count}</span>
</span>
))}
{Object.keys(templateStats.by_platform).length === 0 && (
<span className="text-xs text-gray-500">No templates yet</span>
)}
</div>
</div>
</div>
) : null}
</div>
</div>
</div>
</div>
{/* Create Custom Template Form (modal-style inline) */}
{showCreateForm && (
<CreateTemplateForm
onClose={() => setShowCreateForm(false)}
onSubmit={(payload) => createTemplateMutation.mutate(payload)}
isPending={createTemplateMutation.isPending}
error={createTemplateMutation.isError ? (createTemplateMutation.error as Error)?.message : null}
/>
)}
{/* Templates Management Table */}
<div className="rounded-xl border border-gray-800 bg-gray-900 p-6">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-semibold text-white flex items-center gap-2">
<FlaskConical className="h-5 w-5 text-cyan-400" />
Manage Templates
</h2>
<button
onClick={() => 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"
>
<Plus className="h-4 w-4" />
Create Custom Template
</button>
</div>
{templatesLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-cyan-400" />
</div>
) : templates && templates.length > 0 ? (
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead>
<tr className="border-b border-gray-800">
<th className="pb-3 pr-4 font-medium text-gray-400">Name</th>
<th className="pb-3 px-4 font-medium text-gray-400">Technique</th>
<th className="pb-3 px-4 font-medium text-gray-400">Source</th>
<th className="pb-3 px-4 font-medium text-gray-400">Platform</th>
<th className="pb-3 px-4 font-medium text-gray-400">Status</th>
<th className="pb-3 pl-4 font-medium text-gray-400">Action</th>
</tr>
</thead>
<tbody>
{(templates as TestTemplate[]).map((tpl) => (
<tr
key={tpl.id}
className="border-b border-gray-800/50 hover:bg-gray-800/30 transition-colors"
>
<td className="py-3 pr-4">
<span className="font-medium text-gray-200 truncate block max-w-[200px]">
{tpl.name}
</span>
</td>
<td className="py-3 px-4">
<span className="font-mono text-xs text-cyan-400">
{tpl.mitre_technique_id}
</span>
</td>
<td className="py-3 px-4">
<span
className={`inline-flex rounded-full border px-2 py-0.5 text-xs font-medium ${
tpl.source === "atomic_red_team"
? "bg-red-900/50 text-red-400 border-red-500/30"
: tpl.source === "mitre"
? "bg-blue-900/50 text-blue-400 border-blue-500/30"
: "bg-gray-800/50 text-gray-400 border-gray-600/30"
}`}
>
{tpl.source.replace(/_/g, " ")}
</span>
</td>
<td className="py-3 px-4 text-gray-400 text-xs">
{tpl.platform || "-"}
</td>
<td className="py-3 px-4">
<span
className={`inline-flex rounded-full border px-2 py-0.5 text-xs font-medium ${
tpl.is_active
? "bg-green-900/50 text-green-400 border-green-500/30"
: "bg-gray-800/50 text-gray-500 border-gray-600/30"
}`}
>
{tpl.is_active ? "Active" : "Inactive"}
</span>
</td>
<td className="py-3 pl-4">
<button
onClick={() => 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 ? (
<>
<ToggleRight className="h-4 w-4" />
Deactivate
</>
) : (
<>
<ToggleLeft className="h-4 w-4" />
Activate
</>
)}
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="py-8 text-center text-gray-400">
No templates found. Import from Atomic Red Team or create a custom template.
</div>
)}
</div>
{/* System Information */} {/* System Information */}
<div className="rounded-xl border border-gray-800 bg-gray-900 p-6"> <div className="rounded-xl border border-gray-800 bg-gray-900 p-6">
<h2 className="mb-4 text-lg font-semibold text-white">System Information</h2> <h2 className="mb-4 text-lg font-semibold text-white">System Information</h2>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4"> <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{/* Backend Status */}
<div className="rounded-lg border border-gray-800 bg-gray-800/50 p-4"> <div className="rounded-lg border border-gray-800 bg-gray-800/50 p-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Server className="h-5 w-5 text-green-400" /> <Server className="h-5 w-5 text-green-400" />
@@ -227,8 +558,6 @@ export default function SystemPage() {
</div> </div>
</div> </div>
</div> </div>
{/* Database Status */}
<div className="rounded-lg border border-gray-800 bg-gray-800/50 p-4"> <div className="rounded-lg border border-gray-800 bg-gray-800/50 p-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Database className="h-5 w-5 text-green-400" /> <Database className="h-5 w-5 text-green-400" />
@@ -238,8 +567,6 @@ export default function SystemPage() {
</div> </div>
</div> </div>
</div> </div>
{/* MinIO Status */}
<div className="rounded-lg border border-gray-800 bg-gray-800/50 p-4"> <div className="rounded-lg border border-gray-800 bg-gray-800/50 p-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<HardDrive className="h-5 w-5 text-green-400" /> <HardDrive className="h-5 w-5 text-green-400" />
@@ -249,8 +576,6 @@ export default function SystemPage() {
</div> </div>
</div> </div>
</div> </div>
{/* Scheduler Status */}
<div className="rounded-lg border border-gray-800 bg-gray-800/50 p-4"> <div className="rounded-lg border border-gray-800 bg-gray-800/50 p-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Clock <Clock
@@ -368,3 +693,210 @@ export default function SystemPage() {
</div> </div>
); );
} }
/* ── 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<CreateTemplatePayload>({
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 (
<div className="rounded-xl border border-cyan-500/30 bg-gray-900 p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-white flex items-center gap-2">
<Plus className="h-5 w-5 text-cyan-400" />
Create Custom Template
</h3>
<button
onClick={onClose}
className="text-gray-400 hover:text-white transition-colors"
>
<X className="h-5 w-5" />
</button>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
{/* MITRE Technique ID */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-1">
MITRE Technique ID *
</label>
<input
type="text"
value={form.mitre_technique_id}
onChange={(e) => 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"
/>
</div>
{/* Name */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-1">
Template Name *
</label>
<input
type="text"
value={form.name}
onChange={(e) => 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"
/>
</div>
{/* Platform */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-1">
Platform
</label>
<select
value={form.platform || ""}
onChange={(e) => setForm({ ...form, platform: e.target.value || undefined })}
className="w-full 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"
>
<option value="">Select platform...</option>
<option value="windows">Windows</option>
<option value="linux">Linux</option>
<option value="macos">macOS</option>
</select>
</div>
{/* Severity */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-1">
Severity
</label>
<select
value={form.severity || ""}
onChange={(e) => setForm({ ...form, severity: e.target.value || undefined })}
className="w-full 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"
>
<option value="">Select severity...</option>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
<option value="critical">Critical</option>
</select>
</div>
</div>
{/* Description */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-1">
Description
</label>
<textarea
value={form.description || ""}
onChange={(e) => setForm({ ...form, description: e.target.value })}
placeholder="Template description..."
rows={2}
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"
/>
</div>
{/* Attack Procedure */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-1">
Attack Procedure
</label>
<textarea
value={form.attack_procedure || ""}
onChange={(e) => setForm({ ...form, attack_procedure: e.target.value })}
placeholder="Steps for the red team to execute..."
rows={3}
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"
/>
</div>
{/* Expected Detection */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-1">
Expected Detection
</label>
<textarea
value={form.expected_detection || ""}
onChange={(e) => setForm({ ...form, expected_detection: e.target.value })}
placeholder="What the blue team should detect..."
rows={2}
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"
/>
</div>
{/* Tool Suggested */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-1">
Suggested Tool
</label>
<input
type="text"
value={form.tool_suggested || ""}
onChange={(e) => setForm({ ...form, tool_suggested: e.target.value })}
placeholder="e.g. PowerShell, Cobalt Strike"
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"
/>
</div>
{/* Error */}
{error && (
<div className="rounded-lg border border-red-500/30 bg-red-900/20 p-3">
<div className="flex items-center gap-2">
<XCircle className="h-4 w-4 text-red-400" />
<span className="text-sm text-red-400">{error}</span>
</div>
</div>
)}
{/* Buttons */}
<div className="flex items-center gap-3">
<button
type="submit"
disabled={isPending || !form.mitre_technique_id || !form.name}
className="flex items-center gap-2 rounded-lg bg-cyan-600 px-4 py-2 text-sm font-medium text-white hover:bg-cyan-500 disabled:opacity-50 transition-colors"
>
{isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Plus className="h-4 w-4" />
)}
{isPending ? "Creating..." : "Create Template"}
</button>
<button
type="button"
onClick={onClose}
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"
>
Cancel
</button>
</div>
</form>
</div>
);
}

View File

@@ -1,58 +1,198 @@
import { useState, useMemo } from "react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { Loader2, AlertCircle, FlaskConical, Plus } from "lucide-react"; import {
import { getTechniques, type TechniqueSummary } from "../api/techniques"; Loader2,
import type { TestState, TestResult } from "../types/models"; 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"; import { useAuth } from "../context/AuthContext";
/* ── Badge colour map ──────────────────────────────────────────────── */
const testStateBadgeColors: Record<TestState, string> = { const testStateBadgeColors: Record<TestState, string> = {
draft: "bg-gray-800/50 text-gray-400 border-gray-600/30", 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", 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", validated: "bg-green-900/50 text-green-400 border-green-500/30",
rejected: "bg-red-900/50 text-red-400 border-red-500/30", rejected: "bg-red-900/50 text-red-400 border-red-500/30",
}; };
const testResultBadgeColors: Record<TestResult, string> = { const testStateLabels: Record<TestState, string> = {
detected: "bg-green-900/50 text-green-400 border-green-500/30", draft: "Draft",
not_detected: "bg-red-900/50 text-red-400 border-red-500/30", red_executing: "Red Executing",
partially_detected: "bg-yellow-900/50 text-yellow-400 border-yellow-500/30", blue_evaluating: "Blue Evaluating",
in_review: "In Review",
validated: "Validated",
rejected: "Rejected",
}; };
interface TestSummary { const ALL_STATES: TestState[] = [
id: string; "draft",
technique_id: string; "red_executing",
technique_mitre_id: string; "blue_evaluating",
technique_name: string; "in_review",
name: string; "validated",
state: TestState; "rejected",
result: TestResult | null; ];
platform: string | null;
created_at: string; /* ── 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() { export default function TestsPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const { user } = useAuth(); const { user } = useAuth();
const canCreate = 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<TestState | "">("");
const [platformFilter, setPlatformFilter] = useState("");
const [searchText, setSearchText] = useState("");
const [showMyTasks, setShowMyTasks] = useState(false);
// Build API filters
const filters = useMemo<TestListFilters>(() => {
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 { const {
data: techniques, data: allTests,
isLoading, isLoading,
error, error,
} = useQuery({ } = useQuery({
queryKey: ["techniques"], queryKey: ["tests", filters],
queryFn: () => getTechniques(), queryFn: () => getTests(filters),
}); });
// Note: Since we don't have a direct /tests list endpoint, we're showing // Client-side filtering for search text and "my tasks" for red_tech
// a message to navigate through techniques. In a full implementation, const tests = useMemo(() => {
// you'd add a /tests endpoint to the backend. 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<string, number> = {};
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<string, number> = {};
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", { return new Date(dateStr).toLocaleDateString("en-US", {
year: "numeric", year: "numeric",
month: "short", 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) { if (isLoading) {
return ( return (
<div className="flex h-64 items-center justify-center"> <div className="flex h-64 items-center justify-center">
@@ -72,7 +231,7 @@ export default function TestsPage() {
return ( return (
<div className="flex h-64 flex-col items-center justify-center gap-2"> <div className="flex h-64 flex-col items-center justify-center gap-2">
<AlertCircle className="h-10 w-10 text-red-400" /> <AlertCircle className="h-10 w-10 text-red-400" />
<p className="text-red-400">Failed to load data</p> <p className="text-red-400">Failed to load tests</p>
</div> </div>
); );
} }
@@ -84,7 +243,7 @@ export default function TestsPage() {
<div> <div>
<h1 className="text-2xl font-bold text-white">Tests</h1> <h1 className="text-2xl font-bold text-white">Tests</h1>
<p className="mt-1 text-sm text-gray-400"> <p className="mt-1 text-sm text-gray-400">
Security tests for technique validation Security tests for technique validation Red/Blue workflow
</p> </p>
</div> </div>
{canCreate && ( {canCreate && (
@@ -98,128 +257,233 @@ export default function TestsPage() {
)} )}
</div> </div>
{/* Info Card */} {/* ── State Counter Cards ───────────────────────────────────────── */}
<div className="rounded-xl border border-cyan-500/30 bg-cyan-500/10 p-6"> <div className="grid gap-3 grid-cols-2 sm:grid-cols-3 lg:grid-cols-6">
<div className="flex items-start gap-4"> {ALL_STATES.map((state) => {
<div className="rounded-lg bg-cyan-500/20 p-3"> const icons: Record<TestState, React.ReactNode> = {
<FlaskConical className="h-6 w-6 text-cyan-400" /> draft: <Clock className="h-5 w-5 text-gray-400" />,
red_executing: <Play className="h-5 w-5 text-orange-400" />,
blue_evaluating: <Shield className="h-5 w-5 text-indigo-400" />,
in_review: <Eye className="h-5 w-5 text-blue-400" />,
validated: <CheckCircle className="h-5 w-5 text-green-400" />,
rejected: <XCircle className="h-5 w-5 text-red-400" />,
};
const colorMap: Record<TestState, string> = {
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 (
<button
key={state}
onClick={() => {
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"
}`}
>
<div className="flex items-center gap-2">
{icons[state]}
<span className="text-xs text-gray-400 truncate">
{testStateLabels[state]}
</span>
</div> </div>
<div> <p className={`mt-2 text-2xl font-bold ${colorMap[state]}`}>
<h2 className="text-lg font-semibold text-white">Browse Tests by Technique</h2> {globalCounts[state]}
<p className="mt-1 text-sm text-gray-400">
Tests are organized by MITRE ATT&CK technique. Navigate to a technique from the{" "}
<button
onClick={() => navigate("/techniques")}
className="text-cyan-400 hover:underline"
>
Techniques page
</button>{" "}
to view and manage its associated tests.
</p> </p>
<div className="mt-4 flex flex-wrap gap-3">
<button
onClick={() => 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
</button> </button>
{canCreate && ( );
})}
</div>
{/* ── Filters Bar ───────────────────────────────────────────────── */}
<div className="rounded-xl border border-gray-800 bg-gray-900 p-4">
<div className="flex flex-wrap items-center gap-3">
{/* My tasks toggle */}
{user?.role !== "admin" && user?.role !== "viewer" && (
<button <button
onClick={() => navigate("/tests/new")} onClick={() => {
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" 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"
}`}
> >
Create Standalone Test <ListChecks className="h-4 w-4" />
{myTasksLabel}
</button>
)}
{/* State filter */}
<div className="flex items-center gap-1.5">
<Filter className="h-4 w-4 text-gray-500" />
<select
value={stateFilter}
onChange={(e) => 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"
>
<option value="">All States</option>
{ALL_STATES.map((s) => (
<option key={s} value={s}>
{testStateLabels[s]}
</option>
))}
</select>
</div>
{/* Platform filter */}
<input
type="text"
value={platformFilter}
onChange={(e) => 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 */}
<div className="relative flex-1 min-w-[200px]">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-500" />
<input
type="text"
value={searchText}
onChange={(e) => 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"
/>
</div>
{/* Clear filters */}
{(stateFilter || platformFilter || searchText || showMyTasks) && (
<button
onClick={() => {
setStateFilter("");
setPlatformFilter("");
setSearchText("");
setShowMyTasks(false);
}}
className="text-xs text-gray-400 hover:text-white transition-colors"
>
Clear all
</button> </button>
)} )}
</div> </div>
{/* Active filter summary */}
{(stateFilter || showMyTasks) && (
<div className="mt-3 flex items-center gap-2 text-xs text-gray-400">
<span>Showing:</span>
{showMyTasks && (
<span className="rounded-full border border-cyan-500/30 bg-cyan-500/10 px-2 py-0.5 text-cyan-400">
{myTasksLabel}
</span>
)}
{stateFilter && (
<span className="rounded-full border border-gray-600 bg-gray-800 px-2 py-0.5 text-gray-300">
{testStateLabels[stateFilter as TestState]}
</span>
)}
<span className="text-gray-500">
({tests.length} of {totalTests} tests)
</span>
</div> </div>
</div> )}
</div> </div>
{/* Quick Stats */} {/* ── Tests Table ───────────────────────────────────────────────── */}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<div className="rounded-xl border border-gray-800 bg-gray-900 p-5">
<p className="text-sm text-gray-400">Techniques with Tests</p>
<p className="mt-1 text-2xl font-bold text-cyan-400">
{techniques?.filter((t: TechniqueSummary) => t.status_global !== "not_evaluated").length || 0}
</p>
</div>
<div className="rounded-xl border border-gray-800 bg-gray-900 p-5">
<p className="text-sm text-gray-400">Validated</p>
<p className="mt-1 text-2xl font-bold text-green-400">
{techniques?.filter((t: TechniqueSummary) => t.status_global === "validated").length || 0}
</p>
</div>
<div className="rounded-xl border border-gray-800 bg-gray-900 p-5">
<p className="text-sm text-gray-400">In Progress</p>
<p className="mt-1 text-2xl font-bold text-blue-400">
{techniques?.filter((t: TechniqueSummary) => t.status_global === "in_progress").length || 0}
</p>
</div>
<div className="rounded-xl border border-gray-800 bg-gray-900 p-5">
<p className="text-sm text-gray-400">Pending Evaluation</p>
<p className="mt-1 text-2xl font-bold text-gray-400">
{techniques?.filter((t: TechniqueSummary) => t.status_global === "not_evaluated").length || 0}
</p>
</div>
</div>
{/* Techniques with Recent Activity */}
<div className="rounded-xl border border-gray-800 bg-gray-900 p-6"> <div className="rounded-xl border border-gray-800 bg-gray-900 p-6">
<h2 className="mb-4 text-lg font-semibold text-white">Techniques Being Tested</h2> <div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-semibold text-white">
{showMyTasks ? myTasksLabel : "All Tests"}
</h2>
<span className="text-sm text-gray-400">{tests.length} tests</span>
</div>
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full text-left text-sm"> <table className="w-full text-left text-sm">
<thead> <thead>
<tr className="border-b border-gray-800"> <tr className="border-b border-gray-800">
<th className="pb-3 pr-4 font-medium text-gray-400">Technique</th> <th className="pb-3 pr-4 font-medium text-gray-400">Name</th>
<th className="pb-3 px-4 font-medium text-gray-400">Name</th> <th className="pb-3 px-4 font-medium text-gray-400">Technique</th>
<th className="pb-3 px-4 font-medium text-gray-400">Status</th> <th className="pb-3 px-4 font-medium text-gray-400">State</th>
<th className="pb-3 px-4 font-medium text-gray-400">Current Team</th>
<th className="pb-3 px-4 font-medium text-gray-400">Platform</th>
<th className="pb-3 px-4 font-medium text-gray-400">Updated</th>
<th className="pb-3 pl-4 font-medium text-gray-400">Action</th> <th className="pb-3 pl-4 font-medium text-gray-400">Action</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{techniques {tests.map((test: Test) => (
?.filter((t: TechniqueSummary) => t.status_global !== "not_evaluated")
.slice(0, 10)
.map((tech: TechniqueSummary) => (
<tr <tr
key={tech.id} key={test.id}
className="border-b border-gray-800/50 hover:bg-gray-800/30 transition-colors" className="border-b border-gray-800/50 hover:bg-gray-800/30 transition-colors cursor-pointer"
onClick={() => navigate(`/tests/${test.id}`)}
> >
<td className="py-3 pr-4"> <td className="py-3 pr-4">
<span className="font-mono text-cyan-400">{tech.mitre_id}</span> <span className="font-medium text-gray-200">{test.name}</span>
</td>
<td className="py-3 px-4">
{test.technique_mitre_id ? (
<div className="flex flex-col">
<span className="font-mono text-xs text-cyan-400">
{test.technique_mitre_id}
</span>
<span className="text-xs text-gray-500 truncate max-w-[160px]">
{test.technique_name}
</span>
</div>
) : (
<span className="text-gray-500">-</span>
)}
</td> </td>
<td className="py-3 px-4 text-gray-200">{tech.name}</td>
<td className="py-3 px-4"> <td className="py-3 px-4">
<span <span
className={`inline-flex rounded-full border px-2 py-0.5 text-xs font-medium ${ className={`inline-flex rounded-full border px-2 py-0.5 text-xs font-medium ${
tech.status_global === "validated" testStateBadgeColors[test.state]
? testStateBadgeColors.validated
: tech.status_global === "in_progress"
? testStateBadgeColors.in_review
: tech.status_global === "partial"
? "bg-yellow-900/50 text-yellow-400 border-yellow-500/30"
: testStateBadgeColors.draft
}`} }`}
> >
{tech.status_global.replace(/_/g, " ")} {testStateLabels[test.state]}
</span> </span>
</td> </td>
<td className="py-3 px-4 text-gray-400 text-xs">
{currentTeamForState(test.state)}
</td>
<td className="py-3 px-4 text-gray-400 text-xs">
{test.platform || "-"}
</td>
<td className="py-3 px-4 text-gray-400 text-xs">
{formatDate(test.created_at)}
</td>
<td className="py-3 pl-4"> <td className="py-3 pl-4">
<button <button
onClick={() => navigate(`/techniques/${tech.mitre_id}`)} onClick={(e) => {
e.stopPropagation();
navigate(`/tests/${test.id}`);
}}
className="text-sm text-cyan-400 hover:underline" className="text-sm text-cyan-400 hover:underline"
> >
View Tests View
</button> </button>
</td> </td>
</tr> </tr>
))} ))}
</tbody> </tbody>
</table> </table>
{techniques?.filter((t: TechniqueSummary) => t.status_global !== "not_evaluated").length === 0 && (
<div className="py-8 text-center text-gray-400"> {tests.length === 0 && (
No techniques have been tested yet. Create your first test to get started. <div className="py-12 text-center text-gray-400">
{showMyTasks
? "No pending tasks for your role."
: "No tests found matching your filters."}
</div> </div>
)} )}
</div> </div>

View File

@@ -86,6 +86,10 @@ export interface Test {
blue_validation_status: ValidationStatus | null; blue_validation_status: ValidationStatus | null;
blue_validation_notes: string | null; blue_validation_notes: string | null;
// Technique info (populated in list endpoints)
technique_mitre_id: string | null;
technique_name: string | null;
// Separated evidences // Separated evidences
red_evidences: Evidence[]; red_evidences: Evidence[];
blue_evidences: Evidence[]; blue_evidences: Evidence[];