From bb8b9a6a72c8376efca8405f612b684d2984d699 Mon Sep 17 00:00:00 2001 From: kitos Date: Fri, 19 Jun 2026 10:41:22 +0200 Subject: [PATCH] feat(dashboard): time range filter for operational metrics (30d/90d/6m/1y/all) --- backend/app/routers/operational_metrics.py | 32 ++-- .../services/operational_metrics_service.py | 173 +++++++----------- frontend/src/api/operational-metrics.ts | 12 +- frontend/src/pages/ExecutiveDashboardPage.tsx | 76 +++++++- 4 files changed, 156 insertions(+), 137 deletions(-) diff --git a/backend/app/routers/operational_metrics.py b/backend/app/routers/operational_metrics.py index 6f14cdd..56ec361 100644 --- a/backend/app/routers/operational_metrics.py +++ b/backend/app/routers/operational_metrics.py @@ -19,8 +19,11 @@ from app.dependencies.auth import get_current_user # Import User from app.models.user from app.models.user import User +from datetime import datetime, date + # Import from app.services.operational_metrics_service from app.services.operational_metrics_service import ( + get_all_operational_metrics, get_metrics_by_team, get_operational_trend, ) @@ -33,18 +36,20 @@ router = APIRouter(prefix="/metrics/operational", tags=["operational-metrics"]) @router.get("") -# Define function operational_metrics def operational_metrics( - # Entry: db db: Session = Depends(get_db), - # Entry: current_user current_user: User = Depends(get_current_user), + since: str | None = Query(None, description="ISO date YYYY-MM-DD — filter metrics to tests on or after this date"), ) -> dict: - """Get all operational metrics (MTTD, MTTR, etc.) — cached for 5 min.""" - # Import get_operational_metrics_cached from app.services.score_cache - from app.services.score_cache import get_operational_metrics_cached + """Get all operational metrics (MTTD, MTTR, etc.). Uses cache when no time filter is set.""" + if since: + try: + since_dt = datetime.combine(date.fromisoformat(since), datetime.min.time()) + except ValueError: + since_dt = None + return get_all_operational_metrics(db, since_dt) - # Return get_operational_metrics_cached(db) + from app.services.score_cache import get_operational_metrics_cached return get_operational_metrics_cached(db) @@ -70,13 +75,16 @@ def operational_trend( @router.get("/by-team") -# Define function metrics_by_team def metrics_by_team( - # Entry: db db: Session = Depends(get_db), - # Entry: current_user current_user: User = Depends(get_current_user), + since: str | None = Query(None, description="ISO date YYYY-MM-DD — filter to tests on or after this date"), ) -> dict: """Get metrics broken down by Red Team vs Blue Team.""" - # Return get_metrics_by_team(db) - return get_metrics_by_team(db) + since_dt = None + if since: + try: + since_dt = datetime.combine(date.fromisoformat(since), datetime.min.time()) + except ValueError: + pass + return get_metrics_by_team(db, since_dt) diff --git a/backend/app/services/operational_metrics_service.py b/backend/app/services/operational_metrics_service.py index 59e21de..2c5b21d 100644 --- a/backend/app/services/operational_metrics_service.py +++ b/backend/app/services/operational_metrics_service.py @@ -61,7 +61,7 @@ def _safe_stats(values: list[float]) -> dict: # ── MTTD (Mean Time to Detect) ─────────────────────────────────────── -def calculate_mttd(db: Session) -> Optional[dict]: +def calculate_mttd(db: Session, since: Optional[datetime] = None) -> Optional[dict]: """Calculate Mean Time to Detect. Uses direct timestamp fields on the Test record: @@ -71,14 +71,13 @@ def calculate_mttd(db: Session) -> Optional[dict]: MTTD = blue_started_at - red_started_at - red_paused_seconds Represents how long Red Team spent executing before Blue received the test. """ - tests = ( - db.query(Test) - .filter( - Test.red_started_at.isnot(None), - Test.blue_started_at.isnot(None), - ) - .all() + q = db.query(Test).filter( + Test.red_started_at.isnot(None), + Test.blue_started_at.isnot(None), ) + if since: + q = q.filter(Test.red_started_at >= since) + tests = q.all() # Assign detection_times = [] detection_times = [] @@ -95,7 +94,7 @@ def calculate_mttd(db: Session) -> Optional[dict]: # ── MTTR (Mean Time to Respond/Remediate) ───────────────────────────── -def calculate_mttr(db: Session) -> Optional[dict]: +def calculate_mttr(db: Session, since: Optional[datetime] = None) -> Optional[dict]: """Calculate Mean Time to Respond. Redefined as total pipeline time from attack start to full validation: @@ -104,17 +103,14 @@ def calculate_mttr(db: Session) -> Optional[dict]: Represents how long the full security testing cycle takes end-to-end. Only uses tests that have been fully validated (both sides approved). """ - tests = ( - db.query(Test) - # Chain .filter() call - .filter( - Test.state == TestState.validated, - Test.red_started_at.isnot(None), - Test.blue_validated_at.isnot(None), - ) - # Chain .all() call - .all() + q = db.query(Test).filter( + Test.state == TestState.validated, + Test.red_started_at.isnot(None), + Test.blue_validated_at.isnot(None), ) + if since: + q = q.filter(Test.red_started_at >= since) + tests = q.all() # Assign response_times = [] response_times = [] @@ -132,7 +128,7 @@ def calculate_mttr(db: Session) -> Optional[dict]: # ── Detection Efficacy ─────────────────────────────────────────────── -def calculate_detection_efficacy(db: Session) -> dict: +def calculate_detection_efficacy(db: Session, since: Optional[datetime] = None) -> dict: """Calculate detection efficacy: detected / total validated tests. Args: @@ -143,13 +139,10 @@ def calculate_detection_efficacy(db: Session) -> dict: ``not_detected``, and ``total``. """ # Assign validated_tests = ( - validated_tests = ( - db.query(Test) - # Chain .filter() call - .filter(Test.state == TestState.validated) - # Chain .all() call - .all() - ) + _vq = db.query(Test).filter(Test.state == TestState.validated) + if since: + _vq = _vq.filter(Test.created_at >= since) + validated_tests = _vq.all() # Assign total = len(validated_tests) total = len(validated_tests) @@ -324,7 +317,7 @@ def calculate_coverage_velocity(db: Session) -> dict: # ── Validation Throughput ──────────────────────────────────────────── -def calculate_validation_throughput(db: Session) -> dict: +def calculate_validation_throughput(db: Session, since: Optional[datetime] = None) -> dict: """Pipeline Conversion Rate — activity-based, no time dependency. Measures what percentage of tests that have entered the validation @@ -336,21 +329,23 @@ def calculate_validation_throughput(db: Session) -> dict: 0% = nothing has been validated yet. Lower = backlog or quality issues blocking approvals. """ + _since_filter = [Test.created_at >= since] if since else [] + validated_count = ( db.query(func.count(Test.id)) - .filter(Test.state == TestState.validated) + .filter(Test.state == TestState.validated, *_since_filter) .scalar() ) or 0 rejected_count = ( db.query(func.count(Test.id)) - .filter(Test.state == TestState.rejected) + .filter(Test.state == TestState.rejected, *_since_filter) .scalar() ) or 0 in_review_count = ( db.query(func.count(Test.id)) - .filter(Test.state == TestState.in_review) + .filter(Test.state == TestState.in_review, *_since_filter) .scalar() ) or 0 @@ -386,7 +381,7 @@ def calculate_validation_throughput(db: Session) -> dict: # ── Rejection Rate ────────────────────────────────────────────────── -def calculate_rejection_rate(db: Session) -> dict: +def calculate_rejection_rate(db: Session, since: Optional[datetime] = None) -> dict: """Calculate rejection rate, broken down by red_lead and blue_lead. Args: @@ -397,65 +392,45 @@ def calculate_rejection_rate(db: Session) -> dict: (red-lead rejection percentage), and ``by_blue_lead`` (blue-lead rejection percentage). """ - # Assign validated_count = ( + _sf = [Test.created_at >= since] if since else [] + validated_count = ( db.query(func.count(Test.id)) - # Chain .filter() call - .filter(Test.state == TestState.validated) - # Chain .scalar() call + .filter(Test.state == TestState.validated, *_sf) .scalar() ) or 0 - # Assign rejected_count = ( rejected_count = ( db.query(func.count(Test.id)) - # Chain .filter() call - .filter(Test.state == TestState.rejected) - # Chain .scalar() call + .filter(Test.state == TestState.rejected, *_sf) .scalar() ) or 0 - # Assign total = validated_count + rejected_count total = validated_count + rejected_count - # Assign overall_pct = round((rejected_count / total) * 100, 1) if total > 0 else 0 overall_pct = round((rejected_count / total) * 100, 1) if total > 0 else 0 - # By red_lead (red_validation_status == "rejected") red_rejected = ( db.query(func.count(Test.id)) - # Chain .filter() call - .filter(Test.red_validation_status == "rejected") - # Chain .scalar() call + .filter(Test.red_validation_status == "rejected", *_sf) .scalar() ) or 0 - # Assign red_total = ( red_total = ( db.query(func.count(Test.id)) - # Chain .filter() call - .filter(Test.red_validation_status.in_(["approved", "rejected"])) - # Chain .scalar() call + .filter(Test.red_validation_status.in_(["approved", "rejected"]), *_sf) .scalar() ) or 0 - # Assign red_pct = round((red_rejected / red_total) * 100, 1) if red_total > 0 else 0 red_pct = round((red_rejected / red_total) * 100, 1) if red_total > 0 else 0 - # By blue_lead blue_rejected = ( db.query(func.count(Test.id)) - # Chain .filter() call - .filter(Test.blue_validation_status == "rejected") - # Chain .scalar() call + .filter(Test.blue_validation_status == "rejected", *_sf) .scalar() ) or 0 - # Assign blue_total = ( blue_total = ( db.query(func.count(Test.id)) - # Chain .filter() call - .filter(Test.blue_validation_status.in_(["approved", "rejected"])) - # Chain .scalar() call + .filter(Test.blue_validation_status.in_(["approved", "rejected"]), *_sf) .scalar() ) or 0 - # Assign blue_pct = round((blue_rejected / blue_total) * 100, 1) if blue_total > 0 else 0 blue_pct = round((blue_rejected / blue_total) * 100, 1) if blue_total > 0 else 0 # Return { @@ -472,7 +447,7 @@ def calculate_rejection_rate(db: Session) -> dict: # ── Aggregated Operational Metrics ─────────────────────────────────── -def get_all_operational_metrics(db: Session) -> dict: +def get_all_operational_metrics(db: Session, since: Optional[datetime] = None) -> dict: """Return all operational metrics combined in a single response. Args: @@ -483,22 +458,14 @@ def get_all_operational_metrics(db: Session) -> dict: ``alert_fidelity``, ``coverage_velocity``, ``validation_throughput``, and ``rejection_rate`` keys. """ - # Return { return { - # Literal argument value - "mttd": calculate_mttd(db), - # Literal argument value - "mttr": calculate_mttr(db), - # Literal argument value - "detection_efficacy": calculate_detection_efficacy(db), - # Literal argument value - "alert_fidelity": calculate_alert_fidelity(db), - # Literal argument value - "coverage_velocity": calculate_coverage_velocity(db), - # Literal argument value - "validation_throughput": calculate_validation_throughput(db), - # Literal argument value - "rejection_rate": calculate_rejection_rate(db), + "mttd": calculate_mttd(db, since), + "mttr": calculate_mttr(db, since), + "detection_efficacy": calculate_detection_efficacy(db, since), + "alert_fidelity": calculate_alert_fidelity(db), # TestDetectionResult, no created_at filter + "coverage_velocity": calculate_coverage_velocity(db), # uses its own 12-week window + "validation_throughput": calculate_validation_throughput(db, since), + "rejection_rate": calculate_rejection_rate(db, since), } @@ -583,7 +550,7 @@ def get_operational_trend(db: Session, period: str = "90d") -> list: # ── By Team ────────────────────────────────────────────────────────── -def get_metrics_by_team(db: Session) -> dict: +def get_metrics_by_team(db: Session, since: Optional[datetime] = None) -> dict: """Return metrics broken down by Red vs Blue team. Args: @@ -594,33 +561,30 @@ def get_metrics_by_team(db: Session) -> dict: ``tests_completed``, ``avg_completion_hours``, and ``rejection_rate``. """ + _sf = [Test.created_at >= since] if since else [] + # Red team metrics red_tests_completed = ( db.query(func.count(Test.id)) - # Chain .filter() call .filter(Test.state.in_([ TestState.blue_evaluating, TestState.in_review, TestState.validated, TestState.rejected, - ])) - # Chain .scalar() call + ]), *_sf) .scalar() ) or 0 - # Assign red_avg_time = None red_avg_time = None - # Assign red_times = [] red_times = [] # Red team avg execution time: red_started_at → blue_started_at (net of paused) - tests_with_red = ( - db.query(Test) - .filter( - Test.red_started_at.isnot(None), - Test.blue_started_at.isnot(None), - ) - .all() + _rq = db.query(Test).filter( + Test.red_started_at.isnot(None), + Test.blue_started_at.isnot(None), ) + if since: + _rq = _rq.filter(Test.red_started_at >= since) + tests_with_red = _rq.all() # Iterate over tests_with_red for t in tests_with_red: gross = (t.blue_started_at - t.red_started_at).total_seconds() @@ -635,33 +599,23 @@ def get_metrics_by_team(db: Session) -> dict: # Blue team: count tests that reached the blue evaluation phase blue_tests_completed = ( db.query(func.count(Test.id)) - # Chain .filter() call .filter(Test.state.in_([ TestState.in_review, TestState.validated, TestState.rejected, - ])) - # Chain .scalar() call + ]), *_sf) .scalar() ) or 0 - # Blue avg evaluation time: - # Prefer blue_work_started_at (actual pick-up) → blue_validated_at. - # Fall back to blue_started_at if blue_work_started_at is not set. blue_avg_time = None - # Assign blue_times = [] blue_times = [] - # Assign tests_with_blue = ( - tests_with_blue = ( - db.query(Test) - # Chain .filter() call - .filter( - Test.blue_started_at.isnot(None), - Test.blue_validated_at.isnot(None), - ) - # Chain .all() call - .all() + _bq = db.query(Test).filter( + Test.blue_started_at.isnot(None), + Test.blue_validated_at.isnot(None), ) + if since: + _bq = _bq.filter(Test.blue_started_at >= since) + tests_with_blue = _bq.all() # Iterate over tests_with_blue for t in tests_with_blue: phase_start = t.blue_work_started_at or t.blue_started_at @@ -685,15 +639,12 @@ def get_metrics_by_team(db: Session) -> dict: # Literal argument value "avg_completion_hours": red_avg_time, "avg_unit": "min" if (red_avg_raw is not None and red_avg_raw < 1) else "hrs", - "rejection_rate": calculate_rejection_rate(db)["by_red_lead"], + "rejection_rate": calculate_rejection_rate(db, since)["by_red_lead"], }, - # Literal argument value "blue_team": { - # Literal argument value "tests_completed": blue_tests_completed, - # Literal argument value "avg_completion_hours": blue_avg_time, "avg_unit": "min" if (blue_avg_raw is not None and blue_avg_raw < 1) else "hrs", - "rejection_rate": calculate_rejection_rate(db)["by_blue_lead"], + "rejection_rate": calculate_rejection_rate(db, since)["by_blue_lead"], }, } diff --git a/frontend/src/api/operational-metrics.ts b/frontend/src/api/operational-metrics.ts index 93aabad..7518f19 100644 --- a/frontend/src/api/operational-metrics.ts +++ b/frontend/src/api/operational-metrics.ts @@ -85,8 +85,10 @@ export interface TeamMetrics { // ── API Functions ──────────────────────────────────────────────────── -export async function getOperationalMetrics(): Promise { - const { data } = await client.get("/metrics/operational"); +export async function getOperationalMetrics(since?: string): Promise { + const { data } = await client.get("/metrics/operational", { + params: since ? { since } : undefined, + }); return data; } @@ -99,7 +101,9 @@ export async function getOperationalTrend( return data; } -export async function getMetricsByTeam(): Promise { - const { data } = await client.get("/metrics/operational/by-team"); +export async function getMetricsByTeam(since?: string): Promise { + const { data } = await client.get("/metrics/operational/by-team", { + params: since ? { since } : undefined, + }); return data; } diff --git a/frontend/src/pages/ExecutiveDashboardPage.tsx b/frontend/src/pages/ExecutiveDashboardPage.tsx index 7b926d9..ab7966a 100644 --- a/frontend/src/pages/ExecutiveDashboardPage.tsx +++ b/frontend/src/pages/ExecutiveDashboardPage.tsx @@ -1,4 +1,4 @@ -import { useEffect } from "react"; +import { useEffect, useState, useMemo } from "react"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useNavigate } from "react-router-dom"; import { @@ -141,9 +141,29 @@ function KPICard({ // ── Main Component ────────────────────────────────────────────────── +type TimeRange = "30d" | "90d" | "6m" | "1y" | "all"; + +const TIME_RANGE_OPTIONS: { value: TimeRange; label: string; days: number | null }[] = [ + { value: "30d", label: "Last 30 days", days: 30 }, + { value: "90d", label: "Last 90 days", days: 90 }, + { value: "6m", label: "Last 6 months", days: 182 }, + { value: "1y", label: "Last 12 months",days: 365 }, + { value: "all", label: "All time", days: null }, +]; + +function sinceDate(days: number | null): string | undefined { + if (!days) return undefined; + const d = new Date(Date.now() - days * 86400_000); + return d.toISOString().split("T")[0]; +} + export default function ExecutiveDashboardPage() { const navigate = useNavigate(); + const [timeRange, setTimeRange] = useState("all"); + const rangeOption = TIME_RANGE_OPTIONS.find((o) => o.value === timeRange)!; + const since = useMemo(() => sinceDate(rangeOption.days), [rangeOption]); + const { data: orgScore, isLoading: loadingScore } = useQuery({ queryKey: ["org-score"], queryFn: getOrganizationScore, @@ -155,13 +175,13 @@ export default function ExecutiveDashboardPage() { }); const { data: opMetrics, isLoading: loadingMetrics } = useQuery({ - queryKey: ["operational-metrics"], - queryFn: getOperationalMetrics, + queryKey: ["operational-metrics", since], + queryFn: () => getOperationalMetrics(since), }); const { data: teamMetrics } = useQuery({ - queryKey: ["team-metrics"], - queryFn: getMetricsByTeam, + queryKey: ["team-metrics", since], + queryFn: () => getMetricsByTeam(since), }); const { data: tacticCoverage } = useQuery({ @@ -276,11 +296,30 @@ export default function ExecutiveDashboardPage() { return (
{/* Header */} -
-

Executive Dashboard

-

- Red/Blue Team programme coverage and maturity overview -

+
+
+

Executive Dashboard

+

+ Red/Blue Team programme coverage and maturity overview +

+
+ + {/* Time range filter */} +
+ {TIME_RANGE_OPTIONS.map((opt) => ( + + ))} +
{/* Section 1: Score Card + Sub-scores */} @@ -363,6 +402,12 @@ export default function ExecutiveDashboardPage() { {/* Section 3: Team Performance */} {teamMetrics && ( +
+ {timeRange !== "all" && ( +

+ Operational metrics filtered to: {rangeOption.label} +

+ )}
{/* Red Team */}
@@ -426,6 +471,7 @@ export default function ExecutiveDashboardPage() {
+
)} {/* Section 4: Threat Actor Exposure vs Detection Strength */} @@ -570,6 +616,15 @@ export default function ExecutiveDashboardPage() { })()} {/* Section 4: Operational KPIs */} +
+
+

Operational KPIs

+ {timeRange !== "all" && ( + + {rangeOption.label} + + )} +
+
{/* Section 5: Coverage by Tactic */}