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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user