refactor(dashboard): restructure dashboard components and improve data handling

- Moved dashboard components (Donut, Gauge, Sparkline) to their own files for better organization.
- Created a new DashboardHeader component to manage the header state and display service status.
- Introduced a layout component for the dashboard to encapsulate common layout styles.
- Simplified the Dashboard component by utilizing new components and reducing inline logic.
- Added type definitions for dashboard data in a new types.ts file.
- Enhanced the visual representation of data with improved SVG elements and accessibility attributes.
- Removed redundant code and improved overall readability and maintainability.
This commit is contained in:
asepharyana
2026-07-23 15:02:53 +07:00
parent e529e8c2e6
commit 6b725f31b4
10 changed files with 906 additions and 655 deletions
+91
View File
@@ -0,0 +1,91 @@
import { NoData } from "./no-data";
export function Donut({
running,
degraded,
}: {
running: number;
degraded: number;
}) {
const total = running + degraded;
if (!total) return <NoData />;
const cx = 100;
const cy = 90;
const R = 60;
const circ = 2 * Math.PI * R;
const segs = [
{ n: running, c: "#3fb950", l: "Running" },
{ n: degraded, c: "#d29922", l: "Degraded" },
];
let off = 0;
return (
<svg
width={200}
height={210}
viewBox="0 0 200 210"
role="img"
aria-label="Service health donut chart"
>
{segs.map((s) => {
if (!s.n) return null;
const frac = s.n / total;
const ln = frac * circ;
const el = (
<circle
key={s.l}
cx={cx}
cy={cy}
r={R}
fill="none"
stroke={s.c}
strokeWidth={14}
strokeDasharray={`${ln} ${circ - ln}`}
strokeDashoffset={-off}
transform={`rotate(-90 ${cx} ${cy})`}
/>
);
off += ln;
return el;
})}
<text
x={cx}
y={cy - 4}
textAnchor="middle"
fill="#e6edf3"
fontSize={26}
fontWeight={700}
fontFamily="system-ui,sans-serif"
>
{total}
</text>
<text
x={cx}
y={cy + 14}
textAnchor="middle"
fill="#8b949e"
fontSize={10}
fontFamily="system-ui,sans-serif"
>
total
</text>
{segs
.filter((s) => s.n)
.map((s, i) => (
<g key={s.l}>
<circle cx={16} cy={165 + i * 16} r={4} fill={s.c} />
<text
x={26}
y={168 + i * 16}
fill="#8b949e"
fontSize={10}
fontFamily="system-ui,sans-serif"
>
{s.l}: {s.n}
</text>
</g>
))}
</svg>
);
}
+70
View File
@@ -0,0 +1,70 @@
import { NoData } from "./no-data";
const W = 220;
const H = 100;
const BW = 200;
const BH = 14;
const BX = (W - BW) / 2;
const BY = 30;
export function Gauge({
pct,
color,
label,
unit,
}: {
pct: number | null;
color: string;
label: string;
unit: string;
}) {
if (pct === null || pct <= 0) return <NoData />;
const fw = BW * Math.min(pct / 100, 1);
return (
<svg
width={W}
height={H}
viewBox={`0 0 ${W} ${H}`}
role="img"
aria-label={`${label}: ${pct.toFixed(1)}${unit}`}
>
<rect x={BX} y={BY} width={BW} height={BH} rx={7} ry={7} fill="#1c2333" />
{fw > 0 && (
<rect
x={BX}
y={BY}
width={fw}
height={BH}
rx={7}
ry={7}
fill={color}
opacity={0.85}
/>
)}
<text
x={W / 2}
y={14}
textAnchor="middle"
fontFamily="system-ui,sans-serif"
fontSize={11}
fontWeight={600}
fill={color}
>
{label}
</text>
<text
x={W / 2}
y={BY + BH + 24}
textAnchor="middle"
fontFamily="system-ui,sans-serif"
fontSize={14}
fontWeight={700}
fill="#e6edf3"
>
{pct.toFixed(1)}
{unit}
</text>
</svg>
);
}
+81
View File
@@ -0,0 +1,81 @@
"use client";
import { useEffect, useState } from "react";
import { Badge } from "@/components/ui/badge";
import type { DashboardData } from "@/lib/dashboard/types";
export function DashboardHeader() {
const [degraded, setDegraded] = useState(0);
const [total, setTotal] = useState(0);
const [time, setTime] = useState("");
useEffect(() => {
const fetchStatus = async () => {
try {
const res = await fetch("/api/dashboard");
if (!res.ok) return;
const data: DashboardData = await res.json();
setTotal(data.services.length);
setDegraded(
data.services.length -
data.services.filter((s) => s.state === "running").length,
);
} catch {
/* ignore */
}
};
fetchStatus();
const id = setInterval(fetchStatus, 15000);
return () => clearInterval(id);
}, []);
useEffect(() => {
const tick = () =>
setTime(
new Date().toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
}),
);
tick();
const id = setInterval(tick, 10000);
return () => clearInterval(id);
}, []);
return (
<header className="sticky top-0 z-50 flex items-center justify-between gap-2 border-b bg-background/90 px-5 py-3 backdrop-blur-xl">
<a href="/" className="flex items-center gap-2.5">
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-foreground text-xs font-bold text-background">
AH
</span>
<h1 className="text-base font-semibold">Asep Haryana</h1>
</a>
<div className="flex items-center gap-2.5">
<Badge
variant="outline"
className={`h-auto gap-1.5 rounded-full px-3 py-1 text-xs font-medium ${
degraded > 0
? "border-amber-500/30 bg-amber-900/20 text-amber-500"
: total > 0
? "border-emerald-500/30 bg-emerald-900/20 text-emerald-400"
: ""
}`}
>
{total > 0 && (
<span
className={`h-1.5 w-1.5 rounded-full ${
degraded > 0 ? "bg-amber-500" : "bg-emerald-400"
}`}
/>
)}
{total === 0
? "No services"
: degraded > 0
? `${degraded} degraded`
: "All Systems Operational"}
</Badge>
<span className="text-[10px] text-muted-foreground">{time}</span>
</div>
</header>
);
}
+22
View File
@@ -0,0 +1,22 @@
export function NoData() {
return (
<svg
width={200}
height={100}
viewBox="0 0 200 100"
role="img"
aria-label="No data available"
>
<text
x={100}
y={50}
textAnchor="middle"
fill="#6e7681"
fontSize={12}
fontFamily="system-ui,sans-serif"
>
No data
</text>
</svg>
);
}
+108
View File
@@ -0,0 +1,108 @@
import { NoData } from "./no-data";
const W = 300;
const H = 160;
const PL = 45;
const PT = 20;
const PR = 10;
const PB = 25;
const VW = W - PL - PR;
const VH = H - PT - PB;
function gridLines(maxV: number) {
return Array.from({ length: 5 }, (_, i) => {
const y = PT + (VH * i) / 4;
const val = maxV * (1 - i / 4);
return (
<g key={`grid-${y}`}>
<line
x1={PL}
y1={y}
x2={PL + VW}
y2={y}
stroke="#21262d"
strokeWidth={1}
/>
<text
x={PL - 6}
y={y + 3}
textAnchor="end"
fill="#6e7681"
fontSize={9}
fontFamily="system-ui,sans-serif"
>
{val.toFixed(0)}
</text>
</g>
);
});
}
function xAxisLabels(length: number) {
if (length <= 5) return null;
return Array.from({ length: 5 }, (_, i) => {
const idx = Math.floor(((i + 1) * (length - 1)) / 5);
const x = PL + (VW * idx) / (length - 1);
return (
<text
key={`xlabel-${x}`}
x={x}
y={PT + VH + 16}
textAnchor="middle"
fill="#6e7681"
fontSize={9}
fontFamily="system-ui,sans-serif"
>
{idx + 1}
</text>
);
});
}
export function Sparkline({ data, color }: { data: number[]; color: string }) {
if (!data.length) return <NoData />;
const maxV = Math.max(...data.map(Math.abs), 1);
const pts = data
.map(
(v, i) =>
`${(PL + (VW * i) / (data.length - 1)).toFixed(1)},${(PT + VH * (1 - v / maxV)).toFixed(1)}`,
)
.join(" ");
const area = `M${PL},${PT + VH} L${pts} L${PL + VW},${PT + VH} Z`;
const lv = data[data.length - 1];
const ly = PT + VH * (1 - lv / maxV);
return (
<svg
width={W}
height={H}
viewBox={`0 0 ${W} ${H}`}
role="img"
aria-label="Sparkline chart"
>
{gridLines(maxV)}
<path d={area} fill={color} opacity={0.15} />
<polyline
points={pts}
fill="none"
stroke={color}
strokeWidth={2}
strokeLinejoin="round"
/>
<text
x={PL + VW}
y={ly - 10}
textAnchor="end"
fill={color}
fontSize={11}
fontWeight={600}
fontFamily="system-ui,sans-serif"
>
{lv.toFixed(1)}
</text>
{xAxisLabels(data.length)}
</svg>
);
}