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:
@@ -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
|
||||||
|
]
|
||||||
|
|||||||
@@ -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)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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[];
|
||||||
|
|||||||
Reference in New Issue
Block a user