Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled
Root cause: after backend restart (502 errors on startup), metric queries (pipeline, team, recent, validation) get cached in error state. When the user stays on the dashboard, the component never remounts so queries don't auto-retry. Fixes: 1. refetchOnMount:'always' — queries ALWAYS refetch when component mounts, even if cached with error/stale data. Prevents stuck empty state. 2. gcTime:0 — error state is not cached; next mount starts a fresh query. 3. retry:3 — more retries before giving up (covers slow startup windows). 4. Refresh button in header — manually invalidates and refetches all 4 metric queries with a single click. Spinner icon during refetch.
575 lines
22 KiB
TypeScript
575 lines
22 KiB
TypeScript
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import { useNavigate } from "react-router-dom";
|
|
import {
|
|
Shield,
|
|
CheckCircle,
|
|
AlertTriangle,
|
|
XCircle,
|
|
Clock,
|
|
HelpCircle,
|
|
Percent,
|
|
Loader2,
|
|
AlertCircle,
|
|
Play,
|
|
Eye,
|
|
Users,
|
|
TrendingUp,
|
|
ArrowRight,
|
|
RefreshCw,
|
|
} from "lucide-react";
|
|
import {
|
|
LineChart,
|
|
Line,
|
|
XAxis,
|
|
YAxis,
|
|
CartesianGrid,
|
|
Tooltip,
|
|
ResponsiveContainer,
|
|
Legend,
|
|
} from "recharts";
|
|
import {
|
|
getCoverageSummary,
|
|
getCoverageByTactic,
|
|
getTestPipeline,
|
|
getTeamActivity,
|
|
getValidationRate,
|
|
getRecentTests,
|
|
type TestPipelineCounts,
|
|
type TeamActivityItem,
|
|
type ValidationRateItem,
|
|
type RecentTestItem,
|
|
} from "../api/metrics";
|
|
import { getCoverageEvolution } from "../api/snapshots";
|
|
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,
|
|
error: summaryError,
|
|
} = useQuery({
|
|
queryKey: ["metrics", "summary"],
|
|
queryFn: getCoverageSummary,
|
|
});
|
|
|
|
const {
|
|
data: tactics,
|
|
isLoading: tacticsLoading,
|
|
error: tacticsError,
|
|
} = useQuery({
|
|
queryKey: ["metrics", "by-tactic"],
|
|
queryFn: getCoverageByTactic,
|
|
});
|
|
|
|
const queryClient = useQueryClient();
|
|
|
|
// Refresh all V2 metric widgets manually
|
|
const refreshMetrics = () => {
|
|
queryClient.invalidateQueries({ queryKey: ["metrics", "test-pipeline"] });
|
|
queryClient.invalidateQueries({ queryKey: ["metrics", "team-activity"] });
|
|
queryClient.invalidateQueries({ queryKey: ["metrics", "validation-rate"] });
|
|
queryClient.invalidateQueries({ queryKey: ["metrics", "recent-tests"] });
|
|
};
|
|
|
|
// V2 queries — retry:3 + refetchOnMount:'always' so queries re-run even
|
|
// when cached in error state (happens if backend was still starting on first load)
|
|
const { data: pipeline, isLoading: pipelineLoading, isError: pipelineError, isFetching: pipelineFetching } = useQuery({
|
|
queryKey: ["metrics", "test-pipeline"],
|
|
queryFn: getTestPipeline,
|
|
retry: 3,
|
|
refetchOnMount: "always",
|
|
gcTime: 0,
|
|
});
|
|
|
|
const { data: teamActivity, isLoading: teamLoading, isError: teamError } = useQuery({
|
|
queryKey: ["metrics", "team-activity"],
|
|
queryFn: getTeamActivity,
|
|
retry: 3,
|
|
refetchOnMount: "always",
|
|
gcTime: 0,
|
|
});
|
|
|
|
const { data: validationRates, isLoading: validationLoading, isError: validationError } = useQuery({
|
|
queryKey: ["metrics", "validation-rate"],
|
|
queryFn: getValidationRate,
|
|
retry: 3,
|
|
refetchOnMount: "always",
|
|
gcTime: 0,
|
|
});
|
|
|
|
const { data: recentTests, isLoading: recentLoading, isError: recentError } = useQuery({
|
|
queryKey: ["metrics", "recent-tests"],
|
|
queryFn: getRecentTests,
|
|
retry: 3,
|
|
refetchOnMount: "always",
|
|
gcTime: 0,
|
|
});
|
|
|
|
const { data: coverageEvolution, isLoading: evolutionLoading } = useQuery({
|
|
queryKey: ["snapshots", "evolution", 6],
|
|
queryFn: () => getCoverageEvolution(6),
|
|
});
|
|
|
|
if (summaryLoading || tacticsLoading) {
|
|
return (
|
|
<div className="flex h-64 items-center justify-center">
|
|
<Loader2 className="h-8 w-8 animate-spin text-cyan-400" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (summaryError || tacticsError) {
|
|
return (
|
|
<div className="flex h-64 flex-col items-center justify-center gap-2">
|
|
<AlertCircle className="h-10 w-10 text-red-400" />
|
|
<p className="text-red-400">Failed to load metrics</p>
|
|
<p className="text-sm text-gray-500">
|
|
{summaryError?.message || tacticsError?.message || "Unknown error"}
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h1 className="text-2xl font-bold text-white">Dashboard</h1>
|
|
<p className="mt-1 text-sm text-gray-400">
|
|
MITRE ATT&CK coverage overview
|
|
</p>
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
{/* Manual refresh for metric widgets */}
|
|
<button
|
|
onClick={refreshMetrics}
|
|
disabled={pipelineLoading || pipelineFetching}
|
|
className="flex items-center gap-1.5 rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-xs text-gray-400 hover:bg-gray-700 hover:text-white transition-colors disabled:opacity-50"
|
|
title="Refresh dashboard metrics"
|
|
>
|
|
<RefreshCw className={`h-3.5 w-3.5 ${pipelineFetching ? "animate-spin" : ""}`} />
|
|
Refresh
|
|
</button>
|
|
{summary && (
|
|
<div className="flex items-center gap-2 rounded-lg border border-cyan-500/30 bg-cyan-500/10 px-4 py-2">
|
|
<Percent className="h-5 w-5 text-cyan-400" />
|
|
<span className="text-lg font-bold text-cyan-400">
|
|
{summary.coverage_percentage.toFixed(1)}%
|
|
</span>
|
|
<span className="text-sm text-gray-400">Coverage</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Summary Cards */}
|
|
{summary && (
|
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">
|
|
<CoverageSummaryCard
|
|
title="Total Techniques"
|
|
value={summary.total_techniques}
|
|
icon={<Shield className="h-6 w-6 text-cyan-400" />}
|
|
colorClass="text-cyan-400"
|
|
bgClass="bg-gray-900"
|
|
/>
|
|
<CoverageSummaryCard
|
|
title="Validated"
|
|
value={summary.validated}
|
|
total={summary.total_techniques}
|
|
icon={<CheckCircle className="h-6 w-6 text-green-400" />}
|
|
colorClass="text-green-400"
|
|
bgClass="bg-green-950/20"
|
|
/>
|
|
<CoverageSummaryCard
|
|
title="Partial"
|
|
value={summary.partial}
|
|
total={summary.total_techniques}
|
|
icon={<AlertTriangle className="h-6 w-6 text-yellow-400" />}
|
|
colorClass="text-yellow-400"
|
|
bgClass="bg-yellow-950/20"
|
|
/>
|
|
<CoverageSummaryCard
|
|
title="In Progress"
|
|
value={summary.in_progress}
|
|
total={summary.total_techniques}
|
|
icon={<Clock className="h-6 w-6 text-blue-400" />}
|
|
colorClass="text-blue-400"
|
|
bgClass="bg-blue-950/20"
|
|
/>
|
|
<CoverageSummaryCard
|
|
title="Not Covered"
|
|
value={summary.not_covered}
|
|
total={summary.total_techniques}
|
|
icon={<XCircle className="h-6 w-6 text-red-400" />}
|
|
colorClass="text-red-400"
|
|
bgClass="bg-red-950/20"
|
|
/>
|
|
<CoverageSummaryCard
|
|
title="Not Evaluated"
|
|
value={summary.not_evaluated}
|
|
total={summary.total_techniques}
|
|
icon={<HelpCircle className="h-6 w-6 text-gray-400" />}
|
|
colorClass="text-gray-400"
|
|
bgClass="bg-gray-900"
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* Coverage evolution (snapshots) */}
|
|
<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">
|
|
<TrendingUp className="h-5 w-5 text-cyan-400" />
|
|
Coverage Evolution (6 months)
|
|
</h2>
|
|
{evolutionLoading ? (
|
|
<div className="flex h-48 items-center justify-center">
|
|
<Loader2 className="h-6 w-6 animate-spin text-cyan-400" />
|
|
</div>
|
|
) : coverageEvolution && coverageEvolution.length > 0 ? (
|
|
<ResponsiveContainer width="100%" height={240}>
|
|
<LineChart data={coverageEvolution}>
|
|
<CartesianGrid strokeDasharray="3 3" stroke="#1f2937" />
|
|
<XAxis
|
|
dataKey="date"
|
|
tick={{ fill: "#6b7280", fontSize: 10 }}
|
|
tickFormatter={(val) => {
|
|
const d = new Date(val);
|
|
return `${d.getMonth() + 1}/${d.getDate()}`;
|
|
}}
|
|
/>
|
|
<YAxis
|
|
domain={[0, 100]}
|
|
tick={{ fill: "#6b7280", fontSize: 10 }}
|
|
/>
|
|
<Tooltip
|
|
contentStyle={{
|
|
backgroundColor: "#111827",
|
|
border: "1px solid #374151",
|
|
borderRadius: "8px",
|
|
fontSize: "12px",
|
|
}}
|
|
labelStyle={{ color: "#9ca3af" }}
|
|
/>
|
|
<Legend />
|
|
<Line
|
|
type="monotone"
|
|
dataKey="org_score"
|
|
stroke="#06b6d4"
|
|
strokeWidth={2}
|
|
dot={{ r: 3 }}
|
|
name="Org score"
|
|
/>
|
|
<Line
|
|
type="monotone"
|
|
dataKey="coverage_pct"
|
|
stroke="#22c55e"
|
|
strokeWidth={2}
|
|
dot={{ r: 3 }}
|
|
name="Coverage %"
|
|
/>
|
|
</LineChart>
|
|
</ResponsiveContainer>
|
|
) : (
|
|
<p className="text-sm text-gray-500 py-8 text-center">
|
|
No snapshots yet. Weekly snapshots populate this chart automatically.
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* ── 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>
|
|
) : pipelineError ? (
|
|
<p className="py-8 text-center text-sm text-gray-500">Could not load pipeline data.</p>
|
|
) : 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>
|
|
) : teamError ? (
|
|
<p className="py-8 text-center text-sm text-gray-500">Could not load team activity.</p>
|
|
) : 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>
|
|
) : validationError ? (
|
|
<p className="py-8 text-center text-sm text-gray-500">Could not load validation data.</p>
|
|
) : 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>
|
|
) : recentError ? (
|
|
<p className="py-8 text-center text-sm text-gray-500">Could not load recent tests — refresh the page.</p>
|
|
) : 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>
|
|
);
|
|
}
|