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:
+3
-1
@@ -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
@@ -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 — 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
@@ -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
@@ -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 & 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'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 &
|
||||||
traces across a multi-VPS deployment orchestrated with Dapr and
|
Traefik, distributed tracing with OpenTelemetry & Jaeger, and
|
||||||
NATS.
|
event-driven architectures on Dapr & 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 ("AI
|
||||||
service discovery, TLS termination via Traefik, and real-time
|
for Smart Education" by Pijak × 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 & 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 × 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 — Blight, Rust, Leaf Spot,
|
||||||
|
or Healthy
|
||||||
|
</li>
|
||||||
|
<li>Get treatment & 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 & 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 & 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">
|
||||||
© {new Date().getFullYear()} Asep Haryana Saputra. Built with
|
<p>
|
||||||
Next.js, shadcn/ui, and a lot of Docker.
|
© {new Date().getFullYear()} Asep Haryana Saputra —{" "}
|
||||||
|
<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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user