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
+3 -1
View File
@@ -1,8 +1,10 @@
import type { NextConfig } from "next"; import type { NextConfig } from "next";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
/* config options here */
reactCompiler: true, reactCompiler: true,
turbopack: {
root: process.cwd(),
},
}; };
export default nextConfig; export default nextConfig;
+251 -582
View File
@@ -1,301 +1,20 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"; import { Donut } from "@/components/dashboard/donut";
import { Gauge } from "@/components/dashboard/gauge";
import { Sparkline } from "@/components/dashboard/sparkline";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
interface Service { import {
name: string; type DashboardData,
state: string; gaugeColor,
hasWeb: boolean; safeDur,
} serviceIndicator,
} from "@/lib/dashboard/types";
interface Trace {
service: string;
operation: string;
duration: number;
spans: number;
hasError: boolean;
}
interface DashboardData {
services: Service[];
traces: Trace[];
node: {
cpu: number | null;
ram: number | null;
disk: number | null;
load1: number | null;
load5: number | null;
load15: number | null;
netIn: number | null;
netOut: number | null;
};
rps: number[];
latency: number[];
errors: number[];
traceVolume: number[];
links: { url: string; label: string }[];
}
function safeDur(us: number): string {
if (us < 1000) return `${us}µs`;
if (us < 1_000_000) return `${(us / 1000).toFixed(1)}ms`;
return `${(us / 1_000_000).toFixed(2)}s`;
}
function Donut({ running, degraded }: { running: number; degraded: number }) {
const total = running + degraded;
if (!total) return <NoData />;
const cx = 100,
cy = 90,
R = 60,
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">
{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>
);
}
function Sparkline({ data, color }: { data: number[]; color: string }) {
const w = 300,
h = 160,
pl = 45,
pt = 20,
pr = 10,
pb = 25;
const vw = w - pl - pr,
vh = h - pt - pb;
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}`}>
{[0, 1, 2, 3, 4].map((i) => {
const y = pt + (vh * i) / 4;
return (
<g key={i}>
<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"
>
{(maxV * (1 - i / 4)).toFixed(0)}
</text>
</g>
);
})}
<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>
{data.length > 5 &&
[...Array(5)].map((_, i) => {
const idx = Math.floor(((i + 1) * (data.length - 1)) / 5);
const x = pl + (vw * idx) / (data.length - 1);
return (
<text
key={i}
x={x}
y={pt + vh + 16}
textAnchor="middle"
fill="#6e7681"
fontSize={9}
fontFamily="system-ui,sans-serif"
>
{idx + 1}
</text>
);
})}
</svg>
);
}
function Gauge({
pct,
color,
label,
unit,
}: {
pct: number | null;
color: string;
label: string;
unit: string;
}) {
if (pct === null || pct <= 0) return <NoData />;
const w = 220,
h = 100,
bw = 200,
bh = 14;
const bx = (w - bw) / 2,
by = 30;
const fw = bw * Math.min(pct / 100, 1);
return (
<svg width={w} height={h} viewBox={`0 0 ${w} ${h}`}>
<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>
);
}
function NoData() {
return (
<svg width={200} height={100} viewBox="0 0 200 100">
<text
x={100}
y={50}
textAnchor="middle"
fill="#6e7681"
fontSize={12}
fontFamily="system-ui,sans-serif"
>
No data
</text>
</svg>
);
}
export default function Dashboard() { export default function Dashboard() {
const [data, setData] = useState<DashboardData | null>(null); const [data, setData] = useState<DashboardData | null>(null);
const [time, setTime] = useState("");
useEffect(() => { useEffect(() => {
const fetchData = async () => { const fetchData = async () => {
@@ -311,19 +30,6 @@ export default function Dashboard() {
return () => clearInterval(id); 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);
}, []);
const running = const running =
data?.services.filter((s) => s.state === "running").length ?? 0; data?.services.filter((s) => s.state === "running").length ?? 0;
const degraded = (data?.services.length ?? 0) - running; const degraded = (data?.services.length ?? 0) - running;
@@ -331,301 +37,264 @@ export default function Dashboard() {
const hasTraffik = const hasTraffik =
(data?.rps?.length ?? 0) > 0 || (data?.latency?.length ?? 0) > 0; (data?.rps?.length ?? 0) > 0 || (data?.latency?.length ?? 0) > 0;
const node = data?.node; const node = data?.node;
const gaugeColor = (v: number | null) => {
if (v === null) return "#3fb950";
if (v > 80) return "#f85149";
if (v > 60) return "#d29922";
return "#3fb950";
};
return ( return (
<div className="min-h-screen bg-background text-foreground"> <div className="mx-auto w-full max-w-[1440px] flex-1 px-4 py-3">
<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"> <div className="grid grid-cols-1 gap-2.5 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
<div className="flex items-center gap-2.5"> {/* Services */}
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-gradient-to-br from-blue-400 to-purple-400 text-xs font-bold text-white"> <Card>
H <CardHeader className="flex flex-row items-center justify-between">
</span> <CardTitle>Services</CardTitle>
<h1 className="text-base font-semibold">Hub Dashboard</h1> <Badge variant="secondary" className="text-[10px] font-normal">
</div> {data?.services.length ?? 0}
<div className="flex items-center gap-2.5"> </Badge>
<Badge </CardHeader>
variant="outline" <CardContent>
className={`h-auto gap-1.5 rounded-full px-3 py-1 text-xs font-medium ${ {data?.services.length ? (
degraded
? "border-amber-500/30 bg-amber-900/20 text-amber-500"
: "border-emerald-500/30 bg-emerald-900/20 text-emerald-400"
}`}
>
<span
className={`h-1.5 w-1.5 rounded-full ${degraded ? "bg-amber-500" : "bg-emerald-400"}`}
/>
{degraded ? `${degraded} degraded` : "All Systems Operational"}
</Badge>
<span className="text-[10px] text-muted-foreground">{time}</span>
</div>
</header>
<div className="mx-auto max-w-[1440px] px-4 py-3">
<div className="grid grid-cols-1 gap-2.5 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
{/* Services */}
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Services</CardTitle>
<Badge variant="secondary" className="text-[10px] font-normal">
{data?.services.length ?? 0}
</Badge>
</CardHeader>
<CardContent>
{data?.services.length ? (
<div className="flex flex-wrap gap-1">
{data.services.map((s) => (
<Badge
key={s.name}
variant="outline"
className="h-auto gap-1.5 px-2 py-1.5 text-xs font-normal"
>
<span
className={`h-1.5 w-1.5 shrink-0 rounded-full ${
s.state === "running"
? "bg-green-400"
: s.state === "jaeger"
? "bg-blue-400"
: "bg-red-400"
}`}
/>
<span className="font-mono text-[10px]">{s.name}</span>
</Badge>
))}
</div>
) : (
<p className="py-4 text-center text-[11px] text-muted-foreground">
No services detected
</p>
)}
</CardContent>
</Card>
{/* Overview */}
<Card>
<CardHeader>
<CardTitle>Overview</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-1.5">
<div className="rounded-lg bg-muted/50 p-2.5 text-center">
<div className="font-mono text-lg font-bold leading-tight text-blue-400">
{data?.services.length ?? "-"}
</div>
<p className="mt-0.5 text-[10px] uppercase tracking-wider text-muted-foreground">
Total
</p>
</div>
<div className="rounded-lg bg-muted/50 p-2.5 text-center">
<div className="font-mono text-lg font-bold leading-tight text-blue-400">
{running}
</div>
<p className="mt-0.5 text-[10px] uppercase tracking-wider text-muted-foreground">
Healthy
</p>
</div>
<div className="rounded-lg bg-muted/50 p-2.5 text-center">
<div className="font-mono text-lg font-bold leading-tight text-green-400">
{data?.traces.length ?? 0}
</div>
<p className="mt-0.5 text-[10px] uppercase tracking-wider text-muted-foreground">
Traces
</p>
</div>
<div className="rounded-lg bg-muted/50 p-2.5 text-center">
<div className="font-mono text-lg font-bold leading-tight text-red-400">
0
</div>
<p className="mt-0.5 text-[10px] uppercase tracking-wider text-muted-foreground">
Errors
</p>
</div>
</div>
</CardContent>
</Card>
{/* Health */}
<Card>
<CardHeader>
<CardTitle>Health</CardTitle>
</CardHeader>
<CardContent className="flex justify-center">
<Donut running={running} degraded={degraded} />
</CardContent>
</Card>
{/* Links */}
<Card>
<CardHeader>
<CardTitle>Links</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
{data?.links.map((l) => ( {data.services.map((s) => (
<a <Badge
key={l.url} key={s.name}
href={l.url} variant="outline"
target="_blank" className="h-auto gap-1.5 px-2 py-1.5 text-xs font-normal"
className="rounded-md border border-border px-2 py-1 text-[10px] text-blue-400 no-underline transition-colors hover:border-blue-400 hover:bg-muted"
> >
{l.label} <span
</a> className={`h-1.5 w-1.5 shrink-0 rounded-full ${serviceIndicator(s.state)}`}
/>
<span className="font-mono text-[10px]">{s.name}</span>
</Badge>
))} ))}
</div> </div>
</CardContent> ) : (
</Card> <p className="py-4 text-center text-[11px] text-muted-foreground">
No services detected
</p>
)}
</CardContent>
</Card>
{/* System Resources */} {/* Overview */}
{hasNode && ( <Card>
<Card> <CardHeader>
<CardHeader className="flex flex-row items-center justify-between"> <CardTitle>Overview</CardTitle>
<CardTitle>System Resources</CardTitle> </CardHeader>
<Badge <CardContent>
variant="secondary" <div className="grid grid-cols-2 gap-1.5">
className="font-mono text-[10px] font-normal" <StatBox
value={data?.services.length}
label="Total"
color="text-blue-400"
/>
<StatBox value={running} label="Healthy" color="text-blue-400" />
<StatBox
value={data?.traces.length ?? 0}
label="Traces"
color="text-green-400"
/>
<StatBox value={0} label="Errors" color="text-red-400" />
</div>
</CardContent>
</Card>
{/* Health */}
<Card>
<CardHeader>
<CardTitle>Health</CardTitle>
</CardHeader>
<CardContent className="flex justify-center">
<Donut running={running} degraded={degraded} />
</CardContent>
</Card>
{/* Links */}
<Card>
<CardHeader>
<CardTitle>Links</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-1">
{data?.links.map((l) => (
<a
key={l.url}
href={l.url}
target="_blank"
rel="noopener noreferrer"
className="rounded-md border border-border px-2 py-1 text-[10px] text-blue-400 no-underline transition-colors hover:border-blue-400 hover:bg-muted"
> >
{node?.load1?.toFixed(2)} {node?.load5?.toFixed(2)}{" "} {l.label}
{node?.load15?.toFixed(2)} </a>
</Badge> ))}
</CardHeader> </div>
<CardContent> </CardContent>
<div className="flex flex-wrap justify-center gap-1.5"> </Card>
<Gauge
pct={node?.cpu ?? null}
color={gaugeColor(node?.cpu ?? null)}
label="CPU Usage"
unit="%"
/>
<Gauge
pct={node?.ram ?? null}
color={gaugeColor(node?.ram ?? null)}
label="Memory Usage"
unit="%"
/>
<Gauge
pct={node?.disk ?? null}
color={gaugeColor(node?.disk ?? null)}
label="Disk Usage"
unit="%"
/>
</div>
</CardContent>
</Card>
)}
{/* Request Rate */} {/* System Resources */}
{hasTraffik && ( {hasNode && (
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Request Rate</CardTitle>
<Badge variant="secondary" className="text-[10px] font-normal">
{data?.rps?.length
? `${data.rps[data.rps.length - 1].toFixed(1)}/s`
: "-"}
</Badge>
</CardHeader>
<CardContent className="flex justify-center">
<Sparkline data={data?.rps ?? []} color="#58a6ff" />
</CardContent>
</Card>
)}
{/* Latency */}
{hasTraffik && (
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Latency</CardTitle>
<Badge variant="secondary" className="text-[10px] font-normal">
{data?.latency?.length
? `${data.latency[data.latency.length - 1].toFixed(0)}ms`
: "-"}
</Badge>
</CardHeader>
<CardContent className="flex justify-center">
<Sparkline data={data?.latency ?? []} color="#bc8cff" />
</CardContent>
</Card>
)}
{/* Error Rate */}
{hasTraffik && (
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Error Rate</CardTitle>
<Badge variant="secondary" className="text-[10px] font-normal">
{data?.errors?.length
? `${data.errors[data.errors.length - 1].toFixed(1)}/s`
: "-"}
</Badge>
</CardHeader>
<CardContent className="flex justify-center">
<Sparkline data={data?.errors ?? []} color="#f85149" />
</CardContent>
</Card>
)}
{/* Trace Volume */}
<Card> <Card>
<CardHeader className="flex flex-row items-center justify-between"> <CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Trace Volume</CardTitle> <CardTitle>System Resources</CardTitle>
<Badge variant="secondary" className="text-[10px] font-normal"> <Badge
{data?.traces.length ?? 0} traces variant="secondary"
</Badge> className="font-mono text-[10px] font-normal"
</CardHeader> >
<CardContent className="flex justify-center"> {node?.load1?.toFixed(2)} {node?.load5?.toFixed(2)}{" "}
<Sparkline data={data?.traceVolume ?? []} color="#3fb950" /> {node?.load15?.toFixed(2)}
</CardContent>
</Card>
{/* Recent Traces */}
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Recent Traces</CardTitle>
<Badge variant="secondary" className="text-[10px] font-normal">
{data?.traces.length ?? 0}
</Badge> </Badge>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{data?.traces.length ? ( <div className="flex flex-wrap justify-center gap-1.5">
<ul className="list-none"> <Gauge
{data.traces.map((t, i) => ( pct={node?.cpu ?? null}
<li color={gaugeColor(node?.cpu ?? null)}
key={i} label="CPU Usage"
className="flex items-center justify-between gap-1.5 border-b border-border py-1.5 text-[11px] last:border-0" unit="%"
> />
<div className="min-w-0"> <Gauge
<div className="truncate font-medium">{t.service}</div> pct={node?.ram ?? null}
<div className="max-w-[200px] truncate font-mono text-[10px] text-muted-foreground"> color={gaugeColor(node?.ram ?? null)}
{t.operation} label="Memory Usage"
</div> unit="%"
</div> />
<div className="flex shrink-0 items-center gap-2 text-[10px] text-muted-foreground"> <Gauge
<span className="font-mono font-medium text-blue-400"> pct={node?.disk ?? null}
{safeDur(t.duration)} color={gaugeColor(node?.disk ?? null)}
</span> label="Disk Usage"
<span>{t.spans}</span> unit="%"
{t.hasError && ( />
<span className="rounded-sm bg-red-500/10 px-1.5 py-0.5 text-[9px] text-red-500"> </div>
err
</span>
)}
</div>
</li>
))}
</ul>
) : (
<p className="py-4 text-center text-[11px] text-muted-foreground">
No traces data appears once services send OTel telemetry
</p>
)}
</CardContent> </CardContent>
</Card> </Card>
</div> )}
{/* Request Rate */}
{hasTraffik && (
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Request Rate</CardTitle>
<Badge variant="secondary" className="text-[10px] font-normal">
{data?.rps?.length
? `${data.rps[data.rps.length - 1].toFixed(1)}/s`
: "-"}
</Badge>
</CardHeader>
<CardContent className="flex justify-center">
<Sparkline data={data?.rps ?? []} color="#58a6ff" />
</CardContent>
</Card>
)}
{/* Latency */}
{hasTraffik && (
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Latency</CardTitle>
<Badge variant="secondary" className="text-[10px] font-normal">
{data?.latency?.length
? `${data.latency[data.latency.length - 1].toFixed(0)}ms`
: "-"}
</Badge>
</CardHeader>
<CardContent className="flex justify-center">
<Sparkline data={data?.latency ?? []} color="#bc8cff" />
</CardContent>
</Card>
)}
{/* Error Rate */}
{hasTraffik && (
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Error Rate</CardTitle>
<Badge variant="secondary" className="text-[10px] font-normal">
{data?.errors?.length
? `${data.errors[data.errors.length - 1].toFixed(1)}/s`
: "-"}
</Badge>
</CardHeader>
<CardContent className="flex justify-center">
<Sparkline data={data?.errors ?? []} color="#f85149" />
</CardContent>
</Card>
)}
{/* Trace Volume */}
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Trace Volume</CardTitle>
<Badge variant="secondary" className="text-[10px] font-normal">
{data?.traces.length ?? 0} traces
</Badge>
</CardHeader>
<CardContent className="flex justify-center">
<Sparkline data={data?.traceVolume ?? []} color="#3fb950" />
</CardContent>
</Card>
{/* Recent Traces */}
<Card className="2xl:col-span-2">
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Recent Traces</CardTitle>
<Badge variant="secondary" className="text-[10px] font-normal">
{data?.traces.length ?? 0}
</Badge>
</CardHeader>
<CardContent>
{data?.traces.length ? (
<ul className="list-none">
{data.traces.map((t, i) => (
<li
key={`${t.service}-${t.operation}-${i}`}
className="flex items-center justify-between gap-1.5 border-b border-border py-1.5 text-[11px] last:border-0"
>
<div className="min-w-0">
<div className="truncate font-medium">{t.service}</div>
<div className="max-w-[200px] truncate font-mono text-[10px] text-muted-foreground">
{t.operation}
</div>
</div>
<div className="flex shrink-0 items-center gap-2 text-[10px] text-muted-foreground">
<span className="font-mono font-medium text-blue-400">
{safeDur(t.duration)}
</span>
<span>{t.spans}</span>
{t.hasError && (
<span className="rounded-sm bg-red-500/10 px-1.5 py-0.5 text-[9px] text-red-500">
err
</span>
)}
</div>
</li>
))}
</ul>
) : (
<p className="py-4 text-center text-[11px] text-muted-foreground">
No traces &mdash; data appears once services send OTel telemetry
</p>
)}
</CardContent>
</Card>
</div> </div>
</div> </div>
); );
} }
function StatBox({
value,
label,
color,
}: {
value: number | string | undefined;
label: string;
color: string;
}) {
return (
<div className="rounded-lg bg-muted/50 p-2.5 text-center">
<div className={`font-mono text-lg font-bold leading-tight ${color}`}>
{value ?? "-"}
</div>
<p className="mt-0.5 text-[10px] uppercase tracking-wider text-muted-foreground">
{label}
</p>
</div>
);
}
+7 -3
View File
@@ -1,5 +1,6 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google"; import { Geist, Geist_Mono } from "next/font/google";
import { DashboardHeader } from "@/components/dashboard/header";
import "./globals.css"; import "./globals.css";
const geistSans = Geist({ const geistSans = Geist({
@@ -13,9 +14,9 @@ const geistMono = Geist_Mono({
}); });
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Asep Haryana Saputra — Hub", title: "Asep Haryana Saputra",
description: description:
"Personal portfolio and infrastructure monitoring dashboard by Asep Haryana Saputra.", "Backend & infrastructure engineer — portfolio, projects, and live monitoring dashboard by Asep Haryana Saputra.",
}; };
export default function RootLayout({ export default function RootLayout({
@@ -28,7 +29,10 @@ export default function RootLayout({
lang="en" lang="en"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`} className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
> >
<body className="min-h-full flex flex-col">{children}</body> <body className="flex min-h-full flex-col bg-background text-foreground">
<DashboardHeader />
{children}
</body>
</html> </html>
); );
} }
+216 -69
View File
@@ -1,35 +1,50 @@
import { import {
Activity,
ArrowRight, ArrowRight,
BarChart3, BookOpen,
Container, Container,
Cpu, Cpu,
Database,
ExternalLink, ExternalLink,
GitBranch,
GitFork,
HardDrive,
} from "lucide-react"; } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { buttonVariants } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { cn } from "@/lib/utils";
const features = [ const skills = [
{ { icon: HardDrive, label: "Backend (Bun, Elysia, Axum)" },
icon: Container, { icon: Container, label: "Docker & Traefik" },
title: "Service Health", { icon: Database, label: "PostgreSQL, Redis, NATS" },
description: "Real-time Docker container status across the cluster.", { icon: Cpu, label: "Rust, TypeScript, Python" },
}, { icon: GitBranch, label: "CI/CD, GitHub Actions" },
{ { icon: BookOpen, label: "Dapr, Prometheus, Jaeger" },
icon: BarChart3, ];
title: "Request Metrics",
description: "RPS, latency, and error rates scraped from Traefik.", const techTags = [
}, "TypeScript",
{ "Rust",
icon: Cpu, "Python",
title: "System Resources", "Next.js",
description: "CPU, memory, disk, and network on each VPS node.", "React",
}, "Bun",
{ "Elysia",
icon: Activity, "Drizzle",
title: "Distributed Tracing", "Axum",
description: "Jaeger trace visualization for service-to-service calls.", "TensorFlow",
}, "ONNX",
"Docker",
"Traefik",
"Tailscale",
"Dapr",
"NATS",
"PostgreSQL",
"Redis",
"Prometheus",
"Tauri",
]; ];
export default function Home() { export default function Home() {
@@ -37,29 +52,26 @@ export default function Home() {
<div className="flex flex-1 flex-col"> <div className="flex flex-1 flex-col">
{/* Hero */} {/* Hero */}
<section className="flex flex-col items-center justify-center px-6 py-24 text-center sm:py-32"> <section className="flex flex-col items-center justify-center px-6 py-24 text-center sm:py-32">
<div className="mb-6 flex size-14 items-center justify-center rounded-2xl bg-gradient-to-br from-blue-500 to-purple-600 text-xl font-bold text-white shadow-lg shadow-blue-500/20"> <div className="mb-6 flex size-14 items-center justify-center rounded-2xl bg-foreground text-xl font-bold text-background">
AH AH
</div> </div>
<h1 className="max-w-2xl text-4xl font-semibold tracking-tight sm:text-5xl"> <h1 className="max-w-2xl text-4xl font-semibold tracking-tight sm:text-5xl">
Asep Haryana Saputra Asep Haryana Saputra
</h1> </h1>
<p className="mt-3 max-w-md text-lg text-muted-foreground"> <p className="mt-3 max-w-lg text-lg text-muted-foreground">
Software engineer building resilient, observable infrastructure for Backend &amp; infrastructure engineer who builds distributed systems,
personal and production systems. RESTful APIs, and production-grade observability stacks.
</p> </p>
<div className="mt-8 flex flex-wrap items-center justify-center gap-3"> <div className="mt-8 flex flex-wrap items-center justify-center gap-3">
<a <a href="#projects" className={cn(buttonVariants({ size: "lg" }))}>
href="/dashboard" View Projects
className="inline-flex h-9 items-center justify-center gap-1.5 rounded-lg bg-primary px-5 text-sm font-medium text-primary-foreground whitespace-nowrap transition-colors hover:bg-primary/80"
>
Dashboard
<ArrowRight className="size-4" /> <ArrowRight className="size-4" />
</a> </a>
<a <a
href="https://github.com/asepharyana" href="https://github.com/asepharyana"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="inline-flex h-9 items-center justify-center gap-1.5 rounded-lg border border-border bg-background px-5 text-sm font-medium whitespace-nowrap transition-colors hover:bg-muted hover:text-foreground" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}
> >
GitHub GitHub
<ExternalLink className="size-4" /> <ExternalLink className="size-4" />
@@ -71,55 +83,181 @@ export default function Home() {
<section className="mx-auto flex w-full max-w-3xl flex-col gap-8 px-6 pb-24"> <section className="mx-auto flex w-full max-w-3xl flex-col gap-8 px-6 pb-24">
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>About This Hub</CardTitle> <CardTitle>About</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="text-muted-foreground"> <CardContent className="text-muted-foreground">
<p> <p>
This is the control center for Asep Haryana Saputra&apos;s I build backend systems and infrastructure for personal projects,
infrastructure. It monitors Docker containers, Traefik request research collaborations, and production deployments. My work spans
metrics, Prometheus system resources, and Jaeger distributed RESTful API design, container orchestration with Docker &amp;
traces across a multi-VPS deployment orchestrated with Dapr and Traefik, distributed tracing with OpenTelemetry &amp; Jaeger, and
NATS. event-driven architectures on Dapr &amp; NATS.
</p> </p>
<p className="mt-3"> <p className="mt-3">
The stack runs on Tailscale-connected VPS nodes with automatic I was the Back-End lead on the ZeaVis Edu capstone team (&quot;AI
service discovery, TLS termination via Traefik, and real-time for Smart Education&quot; by Pijak &times; IBM SkillsBuild), where
streaming telemetry through JetStream-backed Dapr pub/sub. I designed the system architecture, API contracts, and deployment
pipeline for a computer vision-based corn leaf disease detection
platform.
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
{/* Features */} {/* Skills */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2"> <Card>
{features.map((f) => { <CardHeader>
const Icon = f.icon; <CardTitle>Skills &amp; Tools</CardTitle>
return ( </CardHeader>
<Card key={f.title} size="sm"> <CardContent>
<CardHeader> <div className="mb-4 grid grid-cols-1 gap-2 sm:grid-cols-2">
<CardTitle className="flex items-center gap-2"> {skills.map((s) => {
<Icon className="size-4 text-blue-500" /> const Icon = s.icon;
{f.title} return (
</CardTitle> <div
</CardHeader> key={s.label}
<CardContent className="text-sm text-muted-foreground"> className="flex items-center gap-2 rounded-lg border border-border px-3 py-2 text-sm"
{f.description} >
</CardContent> <Icon className="size-4 shrink-0 text-muted-foreground" />
</Card> <span>{s.label}</span>
); </div>
})} );
})}
</div>
<div className="flex flex-wrap gap-1.5">
{techTags.map((t) => (
<Badge
key={t}
variant="secondary"
className="text-[11px] font-normal"
>
{t}
</Badge>
))}
</div>
</CardContent>
</Card>
{/* Projects */}
<div id="projects" className="scroll-mt-20">
<h2 className="mb-4 text-xl font-semibold tracking-tight">
Featured Project
</h2>
<Card>
<CardHeader>
<div className="flex items-start justify-between gap-4">
<div>
<CardTitle>ZeaVis Edu</CardTitle>
<p className="mt-1 text-sm text-muted-foreground">
Asisten Edukasi Interaktif untuk Deteksi Penyakit Daun
Jagung
</p>
</div>
<div className="flex shrink-0 gap-2">
<a
href="https://zeavisedu.asepharyana.my.id/"
target="_blank"
rel="noopener noreferrer"
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
)}
>
<ExternalLink className="size-3.5" />
Live
</a>
<a
href="https://github.com/ATLAS-PJK-GM007/ZeaVis-Edu"
target="_blank"
rel="noopener noreferrer"
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
)}
>
<GitFork className="size-3.5" />
Source
</a>
</div>
</div>
</CardHeader>
<CardContent className="text-sm text-muted-foreground">
<p>
A Computer Vision-powered educational platform that helps
farmers, agricultural students, and field extension officers
identify corn leaf diseases by uploading a photo. Built as a
capstone project for the Pijak &times; IBM SkillsBuild program.
</p>
<div className="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<p className="mb-1.5 text-xs font-semibold uppercase tracking-wider">
What it does
</p>
<ul className="list-inside list-disc space-y-1 text-muted-foreground">
<li>Upload a photo of a corn leaf</li>
<li>
AI classifies the disease &mdash; Blight, Rust, Leaf Spot,
or Healthy
</li>
<li>Get treatment &amp; prevention recommendations</li>
</ul>
</div>
<div>
<p className="mb-1.5 text-xs font-semibold uppercase tracking-wider">
My role
</p>
<ul className="list-inside list-disc space-y-1 text-muted-foreground">
<li>System architecture &amp; API design</li>
<li>RESTful API (Bun + Elysia + Drizzle + PostgreSQL)</li>
<li>Rust inference engine (Axum + ONNX Runtime)</li>
<li>
Docker deployment on VPS with Traefik &amp; Tailscale
</li>
</ul>
</div>
</div>
<div className="mt-4 flex flex-wrap gap-1.5">
{[
"React",
"TypeScript",
"Bun",
"Elysia",
"Drizzle",
"PostgreSQL",
"Rust",
"Axum",
"ONNX",
"TensorFlow",
"EfficientNetV2B0",
"Tauri 2",
"Docker",
"Traefik",
].map((t) => (
<Badge
key={t}
variant="outline"
className="text-[10px] font-normal"
>
{t}
</Badge>
))}
</div>
</CardContent>
</Card>
</div> </div>
{/* CTA */} {/* Infrastructure CTA */}
<Card> <Card>
<CardContent className="flex flex-col items-center gap-4 py-8 text-center"> <CardContent className="flex flex-col items-center gap-4 py-8 text-center">
<p className="text-base font-medium"> <p className="text-base font-medium">
Ready to see the live infrastructure? This site doubles as a live infrastructure monitor.
</p> </p>
<a <p className="max-w-md text-sm text-muted-foreground">
href="/dashboard" The dashboard tracks Docker services, Traefik request metrics,
className="inline-flex h-9 items-center justify-center gap-1.5 rounded-lg bg-primary px-5 text-sm font-medium text-primary-foreground whitespace-nowrap transition-colors hover:bg-primary/80" Prometheus system resources, and Jaeger traces across the
> deployment.
Open Dashboard </p>
<a href="/dashboard" className={cn(buttonVariants())}>
Live Dashboard
<ArrowRight className="size-4" /> <ArrowRight className="size-4" />
</a> </a>
</CardContent> </CardContent>
@@ -128,8 +266,17 @@ export default function Home() {
{/* Footer */} {/* Footer */}
<footer className="mt-auto border-t py-6 text-center text-xs text-muted-foreground"> <footer className="mt-auto border-t py-6 text-center text-xs text-muted-foreground">
&copy; {new Date().getFullYear()} Asep Haryana Saputra. Built with <p>
Next.js, shadcn/ui, and a lot of Docker. &copy; {new Date().getFullYear()} Asep Haryana Saputra &mdash;{" "}
<a
href="https://github.com/asepharyana"
target="_blank"
rel="noopener noreferrer"
className="underline underline-offset-2 hover:text-foreground"
>
GitHub
</a>
</p>
</footer> </footer>
</div> </div>
); );
+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>
);
}
+57
View File
@@ -0,0 +1,57 @@
export interface Service {
name: string;
state: string;
hasWeb: boolean;
}
export interface Trace {
service: string;
operation: string;
duration: number;
spans: number;
hasError: boolean;
}
export interface DashboardData {
services: Service[];
traces: Trace[];
node: {
cpu: number | null;
ram: number | null;
disk: number | null;
load1: number | null;
load5: number | null;
load15: number | null;
netIn: number | null;
netOut: number | null;
};
rps: number[];
latency: number[];
errors: number[];
traceVolume: number[];
links: { url: string; label: string }[];
}
export function safeDur(us: number): string {
if (us < 1000) return `${us}\u00b5s`;
if (us < 1_000_000) return `${(us / 1000).toFixed(1)}ms`;
return `${(us / 1_000_000).toFixed(2)}s`;
}
export function gaugeColor(v: number | null): string {
if (v === null) return "#3fb950";
if (v > 80) return "#f85149";
if (v > 60) return "#d29922";
return "#3fb950";
}
export function serviceIndicator(state: string): string {
switch (state) {
case "running":
return "bg-green-400";
case "jaeger":
return "bg-blue-400";
default:
return "bg-red-400";
}
}