mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 04:32:32 +00:00
fixed styling
This commit is contained in:
@@ -101,12 +101,17 @@ export default function SlaPage() {
|
||||
|
||||
const [metrics, setMetrics] = useState<SlaMetricsResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [months, setMonths] = useState(3);
|
||||
|
||||
const fetchMetrics = useCallback(async () => {
|
||||
const fetchMetrics = useCallback(async (showRefreshing = false) => {
|
||||
if (!tenantId) return;
|
||||
try {
|
||||
setLoading(true);
|
||||
if (showRefreshing) {
|
||||
setRefreshing(true);
|
||||
} else {
|
||||
setLoading(true);
|
||||
}
|
||||
const response = await authenticatedFetch(
|
||||
api.metrics.sla(tenantId, months),
|
||||
getAccessToken
|
||||
@@ -118,11 +123,15 @@ export default function SlaPage() {
|
||||
const data = await response.json();
|
||||
setMetrics(data);
|
||||
} catch (err) {
|
||||
if (err instanceof TokenExpiredError) return;
|
||||
console.error('Error loading SLA metrics:', err);
|
||||
addNotification('error', 'Error', 'Failed to load SLA metrics', 'sla-fetch-error');
|
||||
if (err instanceof TokenExpiredError) {
|
||||
addNotification('error', 'Session Expired', err.message, 'session-expired-error');
|
||||
} else {
|
||||
console.error('Error loading SLA metrics:', err);
|
||||
addNotification('error', 'Error', 'Failed to load SLA metrics', 'sla-fetch-error');
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
}
|
||||
}, [tenantId, months, getAccessToken, addNotification]);
|
||||
|
||||
@@ -134,53 +143,74 @@ export default function SlaPage() {
|
||||
(metrics.targetSuccessRate != null || metrics.targetMaxDurationMinutes != null ||
|
||||
metrics.targetAppInstallSuccessRate != null);
|
||||
|
||||
if (loading && !metrics) {
|
||||
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>
|
||||
<p className="mt-4 text-gray-600 dark:text-gray-400">Loading SLA metrics...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ProtectedRoute>
|
||||
<main className="flex-1 overflow-y-auto">
|
||||
<div className="max-w-7xl mx-auto px-6 py-8">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">SLA Compliance</h1>
|
||||
<p className="text-sm text-gray-400 mt-1">
|
||||
Monitor enrollment performance against your SLA targets
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<select
|
||||
value={months}
|
||||
onChange={(e) => setMonths(Number(e.target.value))}
|
||||
className="bg-gray-800 text-gray-300 border border-gray-700 rounded-md px-3 py-1.5 text-sm"
|
||||
>
|
||||
<option value={1}>Last month</option>
|
||||
<option value={3}>Last 3 months</option>
|
||||
<option value={6}>Last 6 months</option>
|
||||
</select>
|
||||
<button
|
||||
onClick={fetchMetrics}
|
||||
disabled={loading}
|
||||
className="px-3 py-1.5 bg-gray-800 text-gray-300 border border-gray-700 rounded-md text-sm hover:bg-gray-700 disabled:opacity-50"
|
||||
>
|
||||
{loading ? "Loading..." : "Refresh"}
|
||||
</button>
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||
<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">
|
||||
<div>
|
||||
<h1 className="text-2xl font-normal text-gray-900 dark:text-white">SLA Compliance</h1>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||
Monitor enrollment performance against your SLA targets
|
||||
{metrics && (
|
||||
<>
|
||||
{" · "}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">
|
||||
From Cache
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<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"
|
||||
>
|
||||
<option value={1}>Last month</option>
|
||||
<option value={3}>Last 3 months</option>
|
||||
<option value={6}>Last 6 months</option>
|
||||
</select>
|
||||
<button
|
||||
onClick={() => fetchMetrics(true)}
|
||||
disabled={refreshing}
|
||||
className="px-4 py-2 bg-white dark:bg-gray-700 border border-gray-200 dark:border-gray-600 text-gray-700 dark:text-gray-200 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center space-x-2"
|
||||
>
|
||||
<svg className={`h-5 w-5 ${refreshing ? 'animate-spin' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
<span>{refreshing ? 'Refreshing...' : 'Refresh'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{loading && !metrics && (
|
||||
<div className="flex justify-center items-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-400" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !hasTargets && (
|
||||
<div className="bg-gray-800/50 border border-gray-700 rounded-lg p-8 text-center">
|
||||
<h2 className="text-lg font-semibold text-white mb-2">No SLA Targets Configured</h2>
|
||||
<p className="text-gray-400 mb-4">
|
||||
<div className="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8">
|
||||
{!hasTargets && (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-8 text-center">
|
||||
<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-blue-600 text-white rounded-md text-sm hover:bg-blue-500"
|
||||
className="inline-flex items-center px-4 py-2 bg-indigo-600 text-white rounded-md text-sm hover:bg-indigo-500"
|
||||
>
|
||||
Configure SLA Targets
|
||||
</Link>
|
||||
@@ -190,9 +220,9 @@ 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-8">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-6">
|
||||
{metrics.targetSuccessRate != null && (
|
||||
<div className="bg-gray-800/50 border border-gray-700 rounded-lg p-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<SlaGauge
|
||||
value={metrics.currentMonth.successRate}
|
||||
target={metrics.targetSuccessRate}
|
||||
@@ -203,7 +233,7 @@ export default function SlaPage() {
|
||||
)}
|
||||
|
||||
{metrics.targetMaxDurationMinutes != null && (
|
||||
<div className="bg-gray-800/50 border border-gray-700 rounded-lg p-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<SlaGauge
|
||||
value={metrics.currentMonth.p95DurationMinutes}
|
||||
target={metrics.targetMaxDurationMinutes}
|
||||
@@ -215,7 +245,7 @@ export default function SlaPage() {
|
||||
)}
|
||||
|
||||
{metrics.targetAppInstallSuccessRate != null && metrics.appInstallSla && (
|
||||
<div className="bg-gray-800/50 border border-gray-700 rounded-lg p-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<SlaGauge
|
||||
value={metrics.appInstallSla.successRate}
|
||||
target={metrics.targetAppInstallSuccessRate}
|
||||
@@ -226,58 +256,75 @@ export default function SlaPage() {
|
||||
)}
|
||||
|
||||
{/* Overall Status */}
|
||||
<div className="bg-gray-800/50 border border-gray-700 rounded-lg p-4 flex flex-col items-center justify-center">
|
||||
<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="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
|
||||
<StatCard label="Total Sessions" value={metrics.currentMonth.totalCompleted} />
|
||||
<StatCard label="Succeeded" value={metrics.currentMonth.succeeded} color="text-emerald-400" />
|
||||
<StatCard label="Failed" value={metrics.currentMonth.failed} color="text-red-400" />
|
||||
<StatCard label="Duration Violations" value={metrics.currentMonth.durationViolationCount} color="text-amber-400" />
|
||||
<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>
|
||||
</div>
|
||||
|
||||
{/* Trend Chart */}
|
||||
{metrics.monthlyTrend.length > 1 && (
|
||||
<div className="bg-gray-800/50 border border-gray-700 rounded-lg p-6 mb-8">
|
||||
<h2 className="text-lg font-semibold 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 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>
|
||||
)}
|
||||
|
||||
{/* Top Failing Apps */}
|
||||
{metrics.appInstallSla && metrics.appInstallSla.topFailingApps.length > 0 && (
|
||||
<div className="bg-gray-800/50 border border-gray-700 rounded-lg p-6 mb-8">
|
||||
<h2 className="text-lg font-semibold text-white mb-4">Top Failing Apps</h2>
|
||||
<div className="overflow-x-auto">
|
||||
<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>
|
||||
<tr className="text-gray-400 border-b border-gray-700">
|
||||
<th className="text-left py-2 px-3">App Name</th>
|
||||
<th className="text-right py-2 px-3">Failures</th>
|
||||
<th className="text-right py-2 px-3">Total</th>
|
||||
<th className="text-right py-2 px-3">Success Rate</th>
|
||||
<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 border-gray-800 hover:bg-gray-800/50">
|
||||
<td className="py-2 px-3 text-white">{app.appName}</td>
|
||||
<td className="py-2 px-3 text-right text-red-400">{app.failCount}</td>
|
||||
<td className="py-2 px-3 text-right text-gray-400">{app.totalCount}</td>
|
||||
<td className="py-2 px-3 text-right text-gray-300">{app.successRate.toFixed(1)}%</td>
|
||||
<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>
|
||||
@@ -288,55 +335,55 @@ export default function SlaPage() {
|
||||
|
||||
{/* Violators Table */}
|
||||
{metrics.violators.length > 0 && (
|
||||
<div className="bg-gray-800/50 border border-gray-700 rounded-lg p-6">
|
||||
<h2 className="text-lg font-semibold text-white mb-4">
|
||||
<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="overflow-x-auto">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-gray-400 border-b border-gray-700">
|
||||
<th className="text-left py-2 px-3">Device</th>
|
||||
<th className="text-left py-2 px-3">Serial</th>
|
||||
<th className="text-left py-2 px-3">Started</th>
|
||||
<th className="text-right py-2 px-3">Duration</th>
|
||||
<th className="text-left py-2 px-3">Status</th>
|
||||
<th className="text-left py-2 px-3">Violation</th>
|
||||
<th className="text-left py-2 px-3">Failure Reason</th>
|
||||
<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>
|
||||
</thead>
|
||||
<tbody>
|
||||
{metrics.violators.map((v) => (
|
||||
<tr key={v.sessionId} className="border-b border-gray-800 hover:bg-gray-800/50">
|
||||
<td className="py-2 px-3">
|
||||
<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-blue-400 hover:underline"
|
||||
className="text-indigo-600 hover:underline"
|
||||
>
|
||||
{v.deviceName || "Unknown"}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="py-2 px-3 text-gray-400">{v.serialNumber || "-"}</td>
|
||||
<td className="py-2 px-3 text-gray-400">
|
||||
<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-2 px-3 text-right text-gray-300">
|
||||
<td className="py-3 px-4 text-right text-gray-700 dark:text-gray-300">
|
||||
{v.durationSeconds ? formatDuration(v.durationSeconds) : "-"}
|
||||
</td>
|
||||
<td className="py-2 px-3">
|
||||
<span className={v.status === 4 ? "text-red-400" : "text-gray-300"}>
|
||||
<td className="py-3 px-4">
|
||||
<span className={v.status === 4 ? "text-red-600 font-medium" : "text-gray-700"}>
|
||||
{statusLabels[v.status] ?? "Unknown"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2 px-3">
|
||||
<td className="py-3 px-4">
|
||||
<span className={
|
||||
v.violationType === "Both" ? "text-red-400 font-medium" :
|
||||
v.violationType === "Failed" ? "text-red-400" : "text-amber-400"
|
||||
v.violationType === "Both" ? "text-red-600 font-medium" :
|
||||
v.violationType === "Failed" ? "text-red-600" : "text-amber-600"
|
||||
}>
|
||||
{v.violationType}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2 px-3 text-gray-400 max-w-xs truncate">
|
||||
<td className="py-3 px-4 text-gray-500 dark:text-gray-400 max-w-xs truncate">
|
||||
{v.failureReason || "-"}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -346,17 +393,10 @@ export default function SlaPage() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Cache / Timing info */}
|
||||
<div className="text-xs text-gray-500 mt-4 text-right">
|
||||
Computed at {new Date(metrics.computedAt).toLocaleTimeString()}
|
||||
{metrics.fromCache && " (cached)"}
|
||||
{" "}({metrics.computeDurationMs}ms)
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</ProtectedRoute>
|
||||
);
|
||||
}
|
||||
@@ -375,7 +415,7 @@ function OverallStatus({ metrics }: { metrics: SlaMetricsResponse }) {
|
||||
|
||||
if (checks.length === 0) {
|
||||
return (
|
||||
<span className="text-gray-400 text-sm">No targets configured</span>
|
||||
<span className="text-gray-500 dark:text-gray-400 text-sm">No targets configured</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -384,7 +424,7 @@ function OverallStatus({ metrics }: { metrics: SlaMetricsResponse }) {
|
||||
<div className={`text-4xl ${allMet ? "" : "animate-pulse"}`}>
|
||||
{allMet ? "\u2705" : "\u26a0\ufe0f"}
|
||||
</div>
|
||||
<span className={`text-lg font-bold ${allMet ? "text-emerald-400" : anyBreached ? "text-red-400" : "text-amber-400"}`}>
|
||||
<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">
|
||||
@@ -393,12 +433,3 @@ function OverallStatus({ metrics }: { metrics: SlaMetricsResponse }) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatCard({ label, value, color = "text-white" }: { label: string; value: number; color?: string }) {
|
||||
return (
|
||||
<div className="bg-gray-800/50 border border-gray-700 rounded-lg p-4">
|
||||
<div className={`text-2xl font-bold ${color}`}>{value}</div>
|
||||
<div className="text-sm text-gray-400 mt-1">{label}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,36 +1,33 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { PieChart, Pie, Cell, ResponsiveContainer } from "recharts";
|
||||
import { chartColors } from "./chartTheme";
|
||||
|
||||
interface SlaGaugeProps {
|
||||
/** Current value (e.g. 94.5 for success rate, 32 for duration in minutes) */
|
||||
value: number;
|
||||
/** Target value (e.g. 95.0 for success rate, 60 for max duration) */
|
||||
target: number;
|
||||
/** Label shown below the value */
|
||||
label: string;
|
||||
/** Unit shown after the value (e.g. "%" or "min") */
|
||||
unit: string;
|
||||
/** If true, lower is better (e.g. duration) */
|
||||
invert?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Semi-circular gauge displaying current value vs target with color zones.
|
||||
* Green = at/above target, Yellow = within 5% margin, Red = >5% below target.
|
||||
* For inverted metrics (duration), lower is better.
|
||||
*/
|
||||
export function SlaGauge({ value, target, label, unit, invert = false }: SlaGaugeProps) {
|
||||
// Determine compliance status
|
||||
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) {
|
||||
// Lower is better (e.g. duration)
|
||||
if (value <= target) return "met";
|
||||
if (value <= target * 1.05) return "warning";
|
||||
return "breached";
|
||||
} else {
|
||||
// Higher is better (e.g. success rate)
|
||||
if (value >= target) return "met";
|
||||
if (value >= target * 0.95) return "warning";
|
||||
return "breached";
|
||||
@@ -39,17 +36,17 @@ export function SlaGauge({ value, target, label, unit, invert = false }: SlaGaug
|
||||
|
||||
const status = getStatus();
|
||||
const statusColor =
|
||||
status === "met" ? chartColors.success :
|
||||
status === "warning" ? chartColors.warning :
|
||||
chartColors.danger;
|
||||
status === "met" ? "#16a34a" :
|
||||
status === "warning" ? "#d97706" :
|
||||
"#dc2626";
|
||||
|
||||
const statusLabel =
|
||||
status === "met" ? "Compliant" :
|
||||
status === "warning" ? "At Risk" :
|
||||
"Breached";
|
||||
|
||||
// PieChart data for semi-circle gauge
|
||||
// Fill portion vs empty portion (mapped to 180 degrees)
|
||||
const emptyColor = isDark ? "#374151" : "#e5e7eb"; // gray-700 / gray-200
|
||||
|
||||
const maxVal = invert ? target * 2 : 100;
|
||||
const fillPercent = Math.min(Math.max(value / maxVal, 0), 1);
|
||||
|
||||
@@ -75,18 +72,18 @@ export function SlaGauge({ value, target, label, unit, invert = false }: SlaGaug
|
||||
stroke="none"
|
||||
>
|
||||
<Cell fill={statusColor} />
|
||||
<Cell fill="#374151" />
|
||||
<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-white">
|
||||
<span className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{value.toFixed(1)}{unit}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-sm text-gray-400 mt-1">{label}</span>
|
||||
<span className="text-xs mt-0.5" style={{ color: statusColor }}>
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user