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

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

View File

@@ -1,4 +1,5 @@
import { useQuery } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom";
import {
Shield,
CheckCircle,
@@ -9,12 +10,54 @@ import {
Percent,
Loader2,
AlertCircle,
Play,
Eye,
Users,
TrendingUp,
ArrowRight,
} from "lucide-react";
import { getCoverageSummary, getCoverageByTactic } from "../api/metrics";
import {
getCoverageSummary,
getCoverageByTactic,
getTestPipeline,
getTeamActivity,
getValidationRate,
getRecentTests,
type TestPipelineCounts,
type TeamActivityItem,
type ValidationRateItem,
type RecentTestItem,
} from "../api/metrics";
import CoverageSummaryCard from "../components/CoverageSummaryCard";
import TacticCoverageChart from "../components/TacticCoverageChart";
import type { TestState } from "../types/models";
/* ── Badge colours (reused from TestsPage) ─────────────────────────── */
const testStateBadgeColors: Record<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() {
const navigate = useNavigate();
// Existing coverage queries
const {
data: summary,
isLoading: summaryLoading,
@@ -33,6 +76,27 @@ export default function DashboardPage() {
queryFn: getCoverageByTactic,
});
// V2 queries
const { data: pipeline, isLoading: pipelineLoading } = useQuery({
queryKey: ["metrics", "test-pipeline"],
queryFn: getTestPipeline,
});
const { data: teamActivity, isLoading: teamLoading } = useQuery({
queryKey: ["metrics", "team-activity"],
queryFn: getTeamActivity,
});
const { data: validationRates, isLoading: validationLoading } = useQuery({
queryKey: ["metrics", "validation-rate"],
queryFn: getValidationRate,
});
const { data: recentTests, isLoading: recentLoading } = useQuery({
queryKey: ["metrics", "recent-tests"],
queryFn: getRecentTests,
});
if (summaryLoading || tacticsLoading) {
return (
<div className="flex h-64 items-center justify-center">
@@ -127,8 +191,263 @@ export default function DashboardPage() {
</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} />}
</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>
);
}