feat(dashboard): auto-compute risk scores + refresh button on Critical Gaps
Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled
Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled
- Auto-trigger POST /risk/compute on first load if no profiles exist - Add "Refresh scores" button next to Critical Gaps header (spins while computing) - Add computeRiskScores() to frontend/src/api/risk.ts - After compute, invalidate risk-profiles query so table updates immediately Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -42,3 +42,9 @@ export async function getRiskProfile(techniqueId: string): Promise<RiskProfile>
|
|||||||
const { data } = await client.get<RiskProfile>(`/risk/profiles/${techniqueId}`);
|
const { data } = await client.get<RiskProfile>(`/risk/profiles/${techniqueId}`);
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Trigger recomputation of all risk scores. */
|
||||||
|
export async function computeRiskScores(): Promise<{ computed: number; skipped: number; errors: number; duration_seconds: number }> {
|
||||||
|
const { data } = await client.post("/risk/compute");
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useEffect } from "react";
|
||||||
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
Loader2,
|
Loader2,
|
||||||
@@ -7,6 +8,7 @@ import {
|
|||||||
TrendingDown,
|
TrendingDown,
|
||||||
Minus,
|
Minus,
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
|
RefreshCw,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import {
|
import {
|
||||||
LineChart,
|
LineChart,
|
||||||
@@ -28,7 +30,7 @@ import {
|
|||||||
import { getCoverageByTactic } from "../api/metrics";
|
import { getCoverageByTactic } from "../api/metrics";
|
||||||
import { getThreatActors } from "../api/threat-actors";
|
import { getThreatActors } from "../api/threat-actors";
|
||||||
import { getTechniques, type TechniqueSummary } from "../api/techniques";
|
import { getTechniques, type TechniqueSummary } from "../api/techniques";
|
||||||
import { getRiskProfiles, type RiskProfile } from "../api/risk";
|
import { getRiskProfiles, computeRiskScores, type RiskProfile } from "../api/risk";
|
||||||
|
|
||||||
// ── Score Gauge Component ────────────────────────────────────────────
|
// ── Score Gauge Component ────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -163,11 +165,28 @@ export default function ExecutiveDashboardPage() {
|
|||||||
queryFn: () => getTechniques(),
|
queryFn: () => getTechniques(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: riskProfiles } = useQuery({
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const { data: riskProfiles, isLoading: loadingRisk } = useQuery({
|
||||||
queryKey: ["risk-profiles-exec"],
|
queryKey: ["risk-profiles-exec"],
|
||||||
queryFn: () => getRiskProfiles({ limit: 500 }),
|
queryFn: () => getRiskProfiles({ limit: 500 }),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const computeMutation = useMutation({
|
||||||
|
mutationFn: computeRiskScores,
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["risk-profiles-exec"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auto-compute on first load if no profiles exist
|
||||||
|
useEffect(() => {
|
||||||
|
if (!loadingRisk && riskProfiles !== undefined && riskProfiles.length === 0) {
|
||||||
|
computeMutation.mutate();
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [loadingRisk, riskProfiles?.length]);
|
||||||
|
|
||||||
const isLoading = loadingScore || loadingMetrics;
|
const isLoading = loadingScore || loadingMetrics;
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
@@ -420,9 +439,24 @@ export default function ExecutiveDashboardPage() {
|
|||||||
|
|
||||||
{/* Section 6: Critical Gaps */}
|
{/* Section 6: Critical Gaps */}
|
||||||
<div className="rounded-xl border border-gray-800 bg-gray-900 p-4">
|
<div className="rounded-xl border border-gray-800 bg-gray-900 p-4">
|
||||||
<h2 className="mb-3 text-sm font-semibold text-gray-300">
|
<div className="mb-3 flex items-center justify-between">
|
||||||
|
<h2 className="text-sm font-semibold text-gray-300">
|
||||||
Critical Gaps — Top 10 by Risk Priority
|
Critical Gaps — Top 10 by Risk Priority
|
||||||
</h2>
|
</h2>
|
||||||
|
<button
|
||||||
|
onClick={() => computeMutation.mutate()}
|
||||||
|
disabled={computeMutation.isPending}
|
||||||
|
className="flex items-center gap-1.5 rounded-lg border border-gray-700 bg-gray-800 px-2.5 py-1 text-xs text-gray-400 hover:text-cyan-400 hover:border-cyan-500/30 disabled:opacity-50 transition-colors"
|
||||||
|
title="Recompute risk scores"
|
||||||
|
>
|
||||||
|
{computeMutation.isPending ? (
|
||||||
|
<Loader2 className="h-3 w-3 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<RefreshCw className="h-3 w-3" />
|
||||||
|
)}
|
||||||
|
{computeMutation.isPending ? "Computing…" : "Refresh scores"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
|
|||||||
Reference in New Issue
Block a user