fixed styling

This commit is contained in:
Oliver Kieselbach
2026-04-13 20:41:23 +02:00
parent c415872e46
commit 0ced90850a
2 changed files with 174 additions and 146 deletions
+154 -123
View File
@@ -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>