mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 20:53:06 +00:00
fix SLA page layout
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user