fix SLA page layout

This commit is contained in:
Oliver Kieselbach
2026-04-13 21:08:42 +02:00
parent 8ab14f0d0a
commit 559167863c
2 changed files with 255 additions and 237 deletions
+221 -178
View File
@@ -86,12 +86,7 @@ function formatDuration(seconds: number): string {
}
const statusLabels: Record<number, string> = {
0: "InProgress",
1: "Pending",
2: "Stalled",
3: "Succeeded",
4: "Failed",
5: "Unknown",
0: "InProgress", 1: "Pending", 2: "Stalled", 3: "Succeeded", 4: "Failed", 5: "Unknown",
};
export default function SlaPage() {
@@ -108,13 +103,9 @@ export default function SlaPage() {
const fetchMetrics = useCallback(async (showRefreshing = false) => {
if (!tenantId) return;
try {
if (showRefreshing) {
setRefreshing(true);
} else {
setLoading(true);
}
if (showRefreshing) setRefreshing(true);
else setLoading(true);
// First load always bypasses cache (user may have just changed SLA config)
const useFresh = initialLoad;
if (initialLoad) setInitialLoad(false);
@@ -126,8 +117,7 @@ export default function SlaPage() {
addNotification('error', 'Error', `Failed to load SLA metrics: ${response.statusText}`, 'sla-fetch-error');
return;
}
const data = await response.json();
setMetrics(data);
setMetrics(await response.json());
} catch (err) {
if (err instanceof TokenExpiredError) {
addNotification('error', 'Session Expired', err.message, 'session-expired-error');
@@ -141,9 +131,7 @@ export default function SlaPage() {
}
}, [tenantId, months, getAccessToken, addNotification]);
useEffect(() => {
fetchMetrics();
}, [fetchMetrics]);
useEffect(() => { fetchMetrics(); }, [fetchMetrics]);
const hasTargets = metrics &&
(metrics.targetSuccessRate != null || metrics.targetMaxDurationMinutes != null ||
@@ -153,7 +141,7 @@ export default function SlaPage() {
return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 dark:from-gray-900 dark:to-gray-800 flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto" />
<p className="mt-4 text-gray-600 dark:text-gray-400">Loading SLA metrics...</p>
</div>
</div>
@@ -163,6 +151,7 @@ export default function SlaPage() {
return (
<ProtectedRoute>
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
{/* Header */}
<header className="bg-white dark:bg-gray-800 shadow">
<div className="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between">
@@ -174,7 +163,7 @@ export default function SlaPage() {
<>
{" · "}Computed at {new Date(metrics.computedAt).toLocaleString()} in {metrics.computeDurationMs}ms
{metrics.fromCache && (
<span className="ml-2 px-2 py-0.5 bg-blue-100 text-blue-700 text-xs rounded">
<span className="ml-2 px-2 py-0.5 bg-blue-100 dark:bg-blue-900/40 text-blue-700 dark:text-blue-400 text-xs rounded">
From Cache
</span>
)}
@@ -186,7 +175,7 @@ export default function SlaPage() {
<select
value={months}
onChange={(e) => setMonths(Number(e.target.value))}
className="text-sm border border-gray-300 dark:border-gray-600 rounded-md px-2 py-1.5 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
className="text-sm border border-gray-300 dark:border-gray-600 rounded-md px-2 py-1.5 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value={1}>Last month</option>
<option value={3}>Last 3 months</option>
@@ -208,15 +197,20 @@ export default function SlaPage() {
</header>
<div className="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8">
{/* No targets state */}
{!hasTargets && (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-8 text-center">
<div className="bg-white dark:bg-gray-800 shadow rounded-lg p-8 text-center">
<svg className="h-16 w-16 text-gray-300 dark:text-gray-600 mx-auto mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z" />
</svg>
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">No SLA Targets Configured</h2>
<p className="text-gray-500 dark:text-gray-400 mb-4">
Set up SLA targets to track enrollment success rate and duration compliance.
</p>
<Link
href="/settings/tenant/sla-targets"
className="inline-flex items-center px-4 py-2 bg-indigo-600 text-white rounded-md text-sm hover:bg-indigo-500"
className="inline-flex items-center px-4 py-2 bg-indigo-600 text-white rounded-md text-sm hover:bg-indigo-500 transition-colors"
>
Configure SLA Targets
</Link>
@@ -225,173 +219,164 @@ export default function SlaPage() {
{metrics && hasTargets && (
<>
{/* Gauge Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-6">
{/* Overall status banner */}
<OverallStatusBanner metrics={metrics} />
{/* SLA Gauges */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5 mb-8">
{metrics.targetSuccessRate != null && (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<SlaGauge
value={metrics.currentMonth.successRate}
target={metrics.targetSuccessRate}
label="Success Rate"
unit="%"
/>
</div>
<SlaGauge
value={metrics.currentMonth.successRate}
target={metrics.targetSuccessRate}
label="Enrollment Success Rate"
unit="%"
/>
)}
{metrics.targetMaxDurationMinutes != null && (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<SlaGauge
value={metrics.currentMonth.p95DurationMinutes}
target={metrics.targetMaxDurationMinutes}
label="P95 Duration"
unit="min"
invert
/>
</div>
<SlaGauge
value={metrics.currentMonth.p95DurationMinutes}
target={metrics.targetMaxDurationMinutes}
label="P95 Enrollment Duration"
unit="min"
invert
/>
)}
{metrics.targetAppInstallSuccessRate != null && metrics.appInstallSla && (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<SlaGauge
value={metrics.appInstallSla.successRate}
target={metrics.targetAppInstallSuccessRate}
label="App Install Rate"
unit="%"
/>
</div>
<SlaGauge
value={metrics.appInstallSla.successRate}
target={metrics.targetAppInstallSuccessRate}
label="App Install Success Rate"
unit="%"
/>
)}
{/* Overall Status */}
<div className="bg-white rounded-lg shadow p-6 flex flex-col items-center justify-center">
<OverallStatus metrics={metrics} />
</div>
</div>
{/* Summary Stats */}
<div className="mb-6">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4">Current Month</h2>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<div className="text-sm text-gray-500 dark:text-gray-400 mb-1">Total Sessions</div>
<div className="text-3xl font-bold text-gray-900 dark:text-white">{metrics.currentMonth.totalCompleted}</div>
</div>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<div className="text-sm text-gray-500 dark:text-gray-400 mb-1">Succeeded</div>
<div className="text-3xl font-bold text-green-600">{metrics.currentMonth.succeeded}</div>
</div>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<div className="text-sm text-gray-500 dark:text-gray-400 mb-1">Failed</div>
<div className="text-3xl font-bold text-red-600">{metrics.currentMonth.failed}</div>
</div>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<div className="text-sm text-gray-500 dark:text-gray-400 mb-1">Duration Violations</div>
<div className="text-3xl font-bold text-amber-600">{metrics.currentMonth.durationViolationCount}</div>
</div>
</div>
{/* Current month stats */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-5 mb-8">
<StatCard label="Total Sessions" value={metrics.currentMonth.totalCompleted} color="blue" />
<StatCard label="Succeeded" value={metrics.currentMonth.succeeded} color="green" />
<StatCard label="Failed" value={metrics.currentMonth.failed} color="red" />
<StatCard label="Duration Violations" value={metrics.currentMonth.durationViolationCount} color="yellow" />
</div>
{/* Trend Chart */}
{/* Monthly trend */}
{metrics.monthlyTrend.length > 1 && (
<div className="mb-6">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4">Monthly Trend</h2>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<AppLineChart
data={[...metrics.monthlyTrend].reverse() as unknown as Array<Record<string, unknown>>}
xKey="month"
series={[
{ dataKey: "successRate", label: "Success Rate (%)", color: chartColors.primary },
...(metrics.targetAppInstallSuccessRate != null
? [{ dataKey: "appInstallSuccessRate", label: "App Install Rate (%)", color: chartColors.success }]
: []),
]}
height={300}
/>
</div>
<div className="bg-white dark:bg-gray-800 shadow rounded-lg p-6 mb-8">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">Monthly Trend</h2>
<AppLineChart
data={[...metrics.monthlyTrend].reverse() as unknown as Array<Record<string, unknown>>}
xKey="month"
series={[
{ dataKey: "successRate", label: "Success Rate (%)", color: chartColors.primary },
...(metrics.targetAppInstallSuccessRate != null
? [{ dataKey: "appInstallSuccessRate", label: "App Install Rate (%)", color: chartColors.success }]
: []),
]}
height={300}
/>
</div>
)}
{/* Top Failing Apps */}
{/* Two-column layout: Failing Apps + Summary */}
{metrics.appInstallSla && metrics.appInstallSla.topFailingApps.length > 0 && (
<div className="mb-6">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4">Top Failing Apps</h2>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-gray-50 dark:bg-gray-750">
<tr className="text-gray-500 dark:text-gray-400 border-b dark:border-gray-700">
<th className="text-left py-3 px-4 font-medium">App Name</th>
<th className="text-right py-3 px-4 font-medium">Failures</th>
<th className="text-right py-3 px-4 font-medium">Total</th>
<th className="text-right py-3 px-4 font-medium">Success Rate</th>
</tr>
</thead>
<tbody>
{metrics.appInstallSla.topFailingApps.map((app) => (
<tr key={app.appName} className="border-b dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-750">
<td className="py-3 px-4 text-gray-900 dark:text-white">{app.appName}</td>
<td className="py-3 px-4 text-right text-red-600 font-medium">{app.failCount}</td>
<td className="py-3 px-4 text-right text-gray-500">{app.totalCount}</td>
<td className="py-3 px-4 text-right text-gray-700 dark:text-gray-300">{app.successRate.toFixed(1)}%</td>
</tr>
))}
</tbody>
</table>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
<div className="bg-white dark:bg-gray-800 shadow rounded-lg p-6">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">Top Failing Apps</h2>
<div className="space-y-3">
{metrics.appInstallSla.topFailingApps.map((app, i) => (
<div key={app.appName} className="flex items-center space-x-3">
<span className="text-xs text-gray-400 w-4">{i + 1}</span>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between mb-1">
<span className="text-sm text-gray-700 dark:text-gray-300 truncate pr-2">{app.appName}</span>
<span className="text-sm font-medium text-red-600 dark:text-red-400 flex-shrink-0">{app.failCount} failed</span>
</div>
<div className="w-full h-2 bg-gray-100 dark:bg-gray-700 rounded-full overflow-hidden">
<div
className="h-full bg-red-400 rounded-full"
style={{ width: `${100 - app.successRate}%` }}
/>
</div>
<span className="text-xs text-gray-400">{app.successRate.toFixed(1)}% success ({app.totalCount} total)</span>
</div>
</div>
))}
</div>
</div>
<div className="bg-white dark:bg-gray-800 shadow rounded-lg p-6">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">App Install Summary</h2>
<div className="space-y-4">
<div className="flex justify-between items-center">
<span className="text-sm text-gray-500 dark:text-gray-400">Total Installs</span>
<span className="text-lg font-semibold text-gray-900 dark:text-white">{metrics.appInstallSla.totalInstalls}</span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm text-gray-500 dark:text-gray-400">Succeeded</span>
<span className="text-lg font-semibold text-green-700 dark:text-green-400">{metrics.appInstallSla.succeeded}</span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm text-gray-500 dark:text-gray-400">Failed</span>
<span className="text-lg font-semibold text-red-700 dark:text-red-400">{metrics.appInstallSla.failed}</span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm text-gray-500 dark:text-gray-400">Success Rate</span>
<span className={`text-lg font-semibold ${metrics.appInstallSla.targetMet ? "text-green-700 dark:text-green-400" : "text-red-700 dark:text-red-400"}`}>
{metrics.appInstallSla.successRate.toFixed(1)}%
</span>
</div>
</div>
</div>
</div>
)}
{/* Violators Table */}
{/* Violators table */}
{metrics.violators.length > 0 && (
<div className="mb-6">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4">
SLA Violators ({metrics.violators.length})
</h2>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
<table className="w-full text-sm">
<div className="bg-white dark:bg-gray-800 shadow rounded-lg overflow-hidden mb-8">
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
SLA Violators
<span className="ml-2 text-sm font-normal text-gray-500 dark:text-gray-400">({metrics.violators.length})</span>
</h2>
</div>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead className="bg-gray-50 dark:bg-gray-750">
<tr className="text-gray-500 dark:text-gray-400 border-b dark:border-gray-700">
<th className="text-left py-3 px-4 font-medium">Device</th>
<th className="text-left py-3 px-4 font-medium">Serial</th>
<th className="text-left py-3 px-4 font-medium">Started</th>
<th className="text-right py-3 px-4 font-medium">Duration</th>
<th className="text-left py-3 px-4 font-medium">Status</th>
<th className="text-left py-3 px-4 font-medium">Violation</th>
<th className="text-left py-3 px-4 font-medium">Failure Reason</th>
<tr>
{["Device", "Serial", "Started", "Duration", "Status", "Violation", "Failure Reason"].map((h) => (
<th key={h} className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
{h}
</th>
))}
</tr>
</thead>
<tbody>
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
{metrics.violators.map((v) => (
<tr key={v.sessionId} className="border-b dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-750">
<td className="py-3 px-4">
<Link
href={`/session/${v.tenantId}/${v.sessionId}`}
className="text-indigo-600 hover:underline"
>
<tr key={v.sessionId} className="hover:bg-gray-50 dark:hover:bg-gray-750">
<td className="px-6 py-4 whitespace-nowrap">
<Link href={`/session/${v.tenantId}/${v.sessionId}`} className="text-indigo-600 dark:text-indigo-400 hover:underline text-sm">
{v.deviceName || "Unknown"}
</Link>
</td>
<td className="py-3 px-4 text-gray-500 dark:text-gray-400">{v.serialNumber || "-"}</td>
<td className="py-3 px-4 text-gray-500 dark:text-gray-400">
{new Date(v.startedAt).toLocaleDateString()}
</td>
<td className="py-3 px-4 text-right text-gray-700 dark:text-gray-300">
{v.durationSeconds ? formatDuration(v.durationSeconds) : "-"}
</td>
<td className="py-3 px-4">
<span className={v.status === 4 ? "text-red-600 font-medium" : "text-gray-700"}>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">{v.serialNumber || "-"}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">{new Date(v.startedAt).toLocaleDateString()}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-700 dark:text-gray-300">{v.durationSeconds ? formatDuration(v.durationSeconds) : "-"}</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
v.status === 4 ? "bg-red-100 dark:bg-red-900/40 text-red-700 dark:text-red-400" : "bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300"
}`}>
{statusLabels[v.status] ?? "Unknown"}
</span>
</td>
<td className="py-3 px-4">
<span className={
v.violationType === "Both" ? "text-red-600 font-medium" :
v.violationType === "Failed" ? "text-red-600" : "text-amber-600"
}>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
v.violationType === "Both" ? "bg-red-100 dark:bg-red-900/40 text-red-700 dark:text-red-400" :
v.violationType === "Failed" ? "bg-red-100 dark:bg-red-900/40 text-red-700 dark:text-red-400" :
"bg-yellow-100 dark:bg-yellow-900/40 text-yellow-700 dark:text-yellow-400"
}`}>
{v.violationType}
</span>
</td>
<td className="py-3 px-4 text-gray-500 dark:text-gray-400 max-w-xs truncate">
{v.failureReason || "-"}
</td>
<td className="px-6 py-4 text-sm text-gray-500 dark:text-gray-400 max-w-xs truncate">{v.failureReason || "-"}</td>
</tr>
))}
</tbody>
@@ -399,6 +384,15 @@ export default function SlaPage() {
</div>
</div>
)}
{metrics.violators.length === 0 && (
<div className="bg-white dark:bg-gray-800 shadow rounded-lg p-6 mb-8 text-center">
<svg className="h-12 w-12 text-green-400 mx-auto mb-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<p className="text-gray-500 dark:text-gray-400 text-sm">No SLA violations in the selected period</p>
</div>
)}
</>
)}
</div>
@@ -407,35 +401,84 @@ export default function SlaPage() {
);
}
function OverallStatus({ metrics }: { metrics: SlaMetricsResponse }) {
const checks: boolean[] = [];
/* ---------- Overall status banner ---------- */
function OverallStatusBanner({ metrics }: { metrics: SlaMetricsResponse }) {
const checks: { label: string; met: boolean }[] = [];
if (metrics.targetSuccessRate != null)
checks.push(metrics.currentMonth.successRateMet);
checks.push({ label: "Success Rate", met: metrics.currentMonth.successRateMet });
if (metrics.targetMaxDurationMinutes != null)
checks.push(metrics.currentMonth.durationTargetMet);
checks.push({ label: "Duration", met: metrics.currentMonth.durationTargetMet });
if (metrics.targetAppInstallSuccessRate != null && metrics.appInstallSla)
checks.push(metrics.appInstallSla.targetMet);
checks.push({ label: "App Installs", met: metrics.appInstallSla.targetMet });
const allMet = checks.every(Boolean);
const anyBreached = checks.some(c => !c);
if (checks.length === 0) {
return (
<span className="text-gray-500 dark:text-gray-400 text-sm">No targets configured</span>
);
}
const allMet = checks.every(c => c.met);
const metCount = checks.filter(c => c.met).length;
return (
<div className="flex flex-col items-center gap-2">
<div className={`text-4xl ${allMet ? "" : "animate-pulse"}`}>
{allMet ? "\u2705" : "\u26a0\ufe0f"}
<div className={`rounded-lg shadow p-4 mb-8 flex items-center justify-between ${
allMet
? "bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800"
: "bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800"
}`}>
<div className="flex items-center space-x-3">
{allMet ? (
<svg className="h-8 w-8 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
) : (
<svg className="h-8 w-8 text-red-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
)}
<div>
<span className={`text-lg font-semibold ${allMet ? "text-green-800 dark:text-green-300" : "text-red-800 dark:text-red-300"}`}>
{allMet ? "All SLA Targets Met" : "SLA Targets Breached"}
</span>
<span className="text-sm text-gray-500 dark:text-gray-400 ml-2">
{metCount}/{checks.length} targets on track
</span>
</div>
</div>
<div className="flex items-center space-x-4">
{checks.map((c) => (
<div key={c.label} className="flex items-center space-x-1.5">
{c.met ? (
<svg className="h-4 w-4 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 13l4 4L19 7" />
</svg>
) : (
<svg className="h-4 w-4 text-red-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M6 18L18 6M6 6l12 12" />
</svg>
)}
<span className={`text-sm ${c.met ? "text-green-700 dark:text-green-400" : "text-red-700 dark:text-red-400"}`}>
{c.label}
</span>
</div>
))}
</div>
</div>
);
}
/* ---------- Stat card (FleetStatCard pattern) ---------- */
const colorMap = {
blue: { border: "border-blue-500", bg: "bg-blue-50 dark:bg-blue-900/20", text: "text-blue-700 dark:text-blue-400" },
green: { border: "border-green-500", bg: "bg-green-50 dark:bg-green-900/20", text: "text-green-700 dark:text-green-400" },
red: { border: "border-red-500", bg: "bg-red-50 dark:bg-red-900/20", text: "text-red-700 dark:text-red-400" },
yellow: { border: "border-yellow-500", bg: "bg-yellow-50 dark:bg-yellow-900/20", text: "text-yellow-700 dark:text-yellow-400" },
};
function StatCard({ label, value, color }: { label: string; value: number; color: keyof typeof colorMap }) {
const c = colorMap[color];
return (
<div className={`bg-white dark:bg-gray-800 overflow-hidden shadow rounded-lg border-l-4 ${c.border}`}>
<div className={`p-5 ${c.bg}`}>
<div className="text-sm font-medium text-gray-500 dark:text-gray-400">{label}</div>
<div className={`text-3xl font-bold ${c.text} mt-1`}>{value.toLocaleString()}</div>
</div>
<span className={`text-lg font-bold ${allMet ? "text-green-600 dark:text-green-400" : anyBreached ? "text-red-600 dark:text-red-400" : "text-amber-600 dark:text-amber-400"}`}>
{allMet ? "Compliant" : "Breached"}
</span>
<span className="text-xs text-gray-500">
{checks.filter(Boolean).length}/{checks.length} targets met
</span>
</div>
);
}
@@ -1,8 +1,5 @@
"use client";
import { useEffect, useState } from "react";
import { PieChart, Pie, Cell, ResponsiveContainer } from "recharts";
interface SlaGaugeProps {
value: number;
target: number;
@@ -11,21 +8,15 @@ interface SlaGaugeProps {
invert?: boolean;
}
/**
* SLA compliance gauge using a simple arc visualization.
* Matches the FleetStatCard design language (border-l-4 + colored backgrounds).
*/
export function SlaGauge({ value, target, label, unit, invert = false }: SlaGaugeProps) {
const [isDark, setIsDark] = useState(false);
useEffect(() => {
const check = () => setIsDark(document.documentElement.classList.contains("dark"));
check();
const observer = new MutationObserver(check);
observer.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });
return () => observer.disconnect();
}, []);
const getStatus = (): "met" | "warning" | "breached" => {
if (invert) {
if (value <= target) return "met";
if (value <= target * 1.05) return "warning";
if (value <= target * 1.1) return "warning";
return "breached";
} else {
if (value >= target) return "met";
@@ -35,57 +26,41 @@ export function SlaGauge({ value, target, label, unit, invert = false }: SlaGaug
};
const status = getStatus();
const statusColor =
status === "met" ? "#16a34a" :
status === "warning" ? "#d97706" :
"#dc2626";
const statusLabel =
status === "met" ? "Compliant" :
status === "warning" ? "At Risk" :
"Breached";
const colors = {
met: { border: "border-green-500", bg: "bg-green-50 dark:bg-green-900/20", text: "text-green-700 dark:text-green-400", badge: "bg-green-100 dark:bg-green-900/40 text-green-700 dark:text-green-400", ring: "#22c55e" },
warning: { border: "border-yellow-500", bg: "bg-yellow-50 dark:bg-yellow-900/20", text: "text-yellow-700 dark:text-yellow-400", badge: "bg-yellow-100 dark:bg-yellow-900/40 text-yellow-700 dark:text-yellow-400", ring: "#eab308" },
breached: { border: "border-red-500", bg: "bg-red-50 dark:bg-red-900/20", text: "text-red-700 dark:text-red-400", badge: "bg-red-100 dark:bg-red-900/40 text-red-700 dark:text-red-400", ring: "#ef4444" },
};
const emptyColor = isDark ? "#374151" : "#e5e7eb"; // gray-700 / gray-200
const c = colors[status];
const statusLabel = status === "met" ? "On Target" : status === "warning" ? "At Risk" : "Breached";
const maxVal = invert ? target * 2 : 100;
const fillPercent = Math.min(Math.max(value / maxVal, 0), 1);
const data = [
{ name: "filled", value: fillPercent * 100 },
{ name: "empty", value: (1 - fillPercent) * 100 },
];
// Progress bar percentage (clamped 0-100)
const progress = invert
? Math.max(0, Math.min(100, ((target * 2 - value) / (target * 2)) * 100))
: Math.max(0, Math.min(100, value));
return (
<div className="flex flex-col items-center">
<div className="relative" style={{ width: 180, height: 100 }}>
<ResponsiveContainer width="100%" height={180}>
<PieChart>
<Pie
data={data}
cx="50%"
cy="100%"
startAngle={180}
endAngle={0}
innerRadius={60}
outerRadius={80}
dataKey="value"
stroke="none"
>
<Cell fill={statusColor} />
<Cell fill={emptyColor} />
</Pie>
</PieChart>
</ResponsiveContainer>
<div className="absolute inset-0 flex flex-col items-center justify-end pb-1">
<span className="text-2xl font-bold text-gray-900 dark:text-white">
{value.toFixed(1)}{unit}
</span>
</div>
<div className={`bg-white dark:bg-gray-800 overflow-hidden shadow rounded-lg border-l-4 ${c.border} p-5`}>
<div className="flex items-center justify-between mb-3">
<span className="text-sm font-medium text-gray-500 dark:text-gray-400">{label}</span>
<span className={`text-xs font-medium px-2 py-0.5 rounded-full ${c.badge}`}>
{statusLabel}
</span>
</div>
<div className={`text-3xl font-bold ${c.text} mb-1`}>
{value.toFixed(1)}<span className="text-lg font-normal ml-0.5">{unit}</span>
</div>
<div className="w-full h-2 bg-gray-100 dark:bg-gray-700 rounded-full overflow-hidden mt-2 mb-2">
<div
className="h-full rounded-full transition-all duration-500"
style={{ width: `${progress}%`, backgroundColor: c.ring }}
/>
</div>
<div className="text-xs text-gray-400 dark:text-gray-500">
Target: {target}{unit}
</div>
<span className="text-sm text-gray-600 dark:text-gray-400 mt-1">{label}</span>
<span className="text-xs font-medium mt-0.5 px-2 py-0.5 rounded" style={{ color: statusColor }}>
{statusLabel} (target: {target}{unit})
</span>
</div>
);
}