From 6b725f31b4ddc78f1235a925791906d6ecc334f4 Mon Sep 17 00:00:00 2001 From: asepharyana Date: Thu, 23 Jul 2026 15:02:53 +0700 Subject: [PATCH] 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. --- next.config.ts | 4 +- src/app/dashboard/page.tsx | 833 ++++++++----------------- src/app/layout.tsx | 10 +- src/app/page.tsx | 285 +++++++-- src/components/dashboard/donut.tsx | 91 +++ src/components/dashboard/gauge.tsx | 70 +++ src/components/dashboard/header.tsx | 81 +++ src/components/dashboard/no-data.tsx | 22 + src/components/dashboard/sparkline.tsx | 108 ++++ src/lib/dashboard/types.ts | 57 ++ 10 files changed, 906 insertions(+), 655 deletions(-) create mode 100644 src/components/dashboard/donut.tsx create mode 100644 src/components/dashboard/gauge.tsx create mode 100644 src/components/dashboard/header.tsx create mode 100644 src/components/dashboard/no-data.tsx create mode 100644 src/components/dashboard/sparkline.tsx create mode 100644 src/lib/dashboard/types.ts diff --git a/next.config.ts b/next.config.ts index 66e1566..324b07a 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,8 +1,10 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { - /* config options here */ reactCompiler: true, + turbopack: { + root: process.cwd(), + }, }; export default nextConfig; diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index 6bf7df3..ed5b68d 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -1,301 +1,20 @@ "use client"; 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"; - -interface Service { - name: string; - state: string; - hasWeb: boolean; -} - -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 ; - - 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 ( - - {segs.map((s) => { - if (!s.n) return null; - const frac = s.n / total; - const ln = frac * circ; - const el = ( - - ); - off += ln; - return el; - })} - - {total} - - - total - - {segs - .filter((s) => s.n) - .map((s, i) => ( - - - - {s.l}: {s.n} - - - ))} - - ); -} - -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 ; - - 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 ( - - {[0, 1, 2, 3, 4].map((i) => { - const y = pt + (vh * i) / 4; - return ( - - - - {(maxV * (1 - i / 4)).toFixed(0)} - - - ); - })} - - - - {lv.toFixed(1)} - - {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 ( - - {idx + 1} - - ); - })} - - ); -} - -function Gauge({ - pct, - color, - label, - unit, -}: { - pct: number | null; - color: string; - label: string; - unit: string; -}) { - if (pct === null || pct <= 0) return ; - 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 ( - - - {fw > 0 && ( - - )} - - {label} - - - {pct.toFixed(1)} - {unit} - - - ); -} - -function NoData() { - return ( - - - No data - - - ); -} +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { + type DashboardData, + gaugeColor, + safeDur, + serviceIndicator, +} from "@/lib/dashboard/types"; export default function Dashboard() { const [data, setData] = useState(null); - const [time, setTime] = useState(""); useEffect(() => { const fetchData = async () => { @@ -311,19 +30,6 @@ export default function Dashboard() { 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 = data?.services.filter((s) => s.state === "running").length ?? 0; const degraded = (data?.services.length ?? 0) - running; @@ -331,301 +37,264 @@ export default function Dashboard() { const hasTraffik = (data?.rps?.length ?? 0) > 0 || (data?.latency?.length ?? 0) > 0; 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 ( -
-
-
- - H - -

Hub Dashboard

-
-
- - - {degraded ? `${degraded} degraded` : "All Systems Operational"} - - {time} -
-
- -
-
- {/* Services */} - - - Services - - {data?.services.length ?? 0} - - - - {data?.services.length ? ( -
- {data.services.map((s) => ( - - - {s.name} - - ))} -
- ) : ( -

- No services detected -

- )} -
-
- - {/* Overview */} - - - Overview - - -
-
-
- {data?.services.length ?? "-"} -
-

- Total -

-
-
-
- {running} -
-

- Healthy -

-
-
-
- {data?.traces.length ?? 0} -
-

- Traces -

-
-
-
- 0 -
-

- Errors -

-
-
-
-
- - {/* Health */} - - - Health - - - - - - - {/* Links */} - - - Links - - +
+
+ {/* Services */} + + + Services + + {data?.services.length ?? 0} + + + + {data?.services.length ? (
- {data?.links.map((l) => ( - ( + - {l.label} - + + {s.name} + ))}
-
-
+ ) : ( +

+ No services detected +

+ )} + + - {/* System Resources */} - {hasNode && ( - - - System Resources - + + Overview + + +
+ + + + +
+
+
+ + {/* Health */} + + + Health + + + + + + + {/* Links */} + + + Links + + + + + - {/* Request Rate */} - {hasTraffik && ( - - - Request Rate - - {data?.rps?.length - ? `${data.rps[data.rps.length - 1].toFixed(1)}/s` - : "-"} - - - - - - - )} - - {/* Latency */} - {hasTraffik && ( - - - Latency - - {data?.latency?.length - ? `${data.latency[data.latency.length - 1].toFixed(0)}ms` - : "-"} - - - - - - - )} - - {/* Error Rate */} - {hasTraffik && ( - - - Error Rate - - {data?.errors?.length - ? `${data.errors[data.errors.length - 1].toFixed(1)}/s` - : "-"} - - - - - - - )} - - {/* Trace Volume */} + {/* System Resources */} + {hasNode && ( - Trace Volume - - {data?.traces.length ?? 0} traces - - - - - - - - {/* Recent Traces */} - - - Recent Traces - - {data?.traces.length ?? 0} + System Resources + + {node?.load1?.toFixed(2)} {node?.load5?.toFixed(2)}{" "} + {node?.load15?.toFixed(2)} - {data?.traces.length ? ( -
    - {data.traces.map((t, i) => ( -
  • -
    -
    {t.service}
    -
    - {t.operation} -
    -
    -
    - - {safeDur(t.duration)} - - {t.spans} - {t.hasError && ( - - err - - )} -
    -
  • - ))} -
- ) : ( -

- No traces — data appears once services send OTel telemetry -

- )} +
+ + + +
-
+ )} + + {/* Request Rate */} + {hasTraffik && ( + + + Request Rate + + {data?.rps?.length + ? `${data.rps[data.rps.length - 1].toFixed(1)}/s` + : "-"} + + + + + + + )} + + {/* Latency */} + {hasTraffik && ( + + + Latency + + {data?.latency?.length + ? `${data.latency[data.latency.length - 1].toFixed(0)}ms` + : "-"} + + + + + + + )} + + {/* Error Rate */} + {hasTraffik && ( + + + Error Rate + + {data?.errors?.length + ? `${data.errors[data.errors.length - 1].toFixed(1)}/s` + : "-"} + + + + + + + )} + + {/* Trace Volume */} + + + Trace Volume + + {data?.traces.length ?? 0} traces + + + + + + + + {/* Recent Traces */} + + + Recent Traces + + {data?.traces.length ?? 0} + + + + {data?.traces.length ? ( +
    + {data.traces.map((t, i) => ( +
  • +
    +
    {t.service}
    +
    + {t.operation} +
    +
    +
    + + {safeDur(t.duration)} + + {t.spans} + {t.hasError && ( + + err + + )} +
    +
  • + ))} +
+ ) : ( +

+ No traces — data appears once services send OTel telemetry +

+ )} +
+
); } + +function StatBox({ + value, + label, + color, +}: { + value: number | string | undefined; + label: string; + color: string; +}) { + return ( +
+
+ {value ?? "-"} +
+

+ {label} +

+
+ ); +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index f2f1483..bfec96c 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,5 +1,6 @@ import type { Metadata } from "next"; import { Geist, Geist_Mono } from "next/font/google"; +import { DashboardHeader } from "@/components/dashboard/header"; import "./globals.css"; const geistSans = Geist({ @@ -13,9 +14,9 @@ const geistMono = Geist_Mono({ }); export const metadata: Metadata = { - title: "Asep Haryana Saputra — Hub", + title: "Asep Haryana Saputra", 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({ @@ -28,7 +29,10 @@ export default function RootLayout({ lang="en" className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`} > - {children} + + + {children} + ); } diff --git a/src/app/page.tsx b/src/app/page.tsx index fdbd18f..dd33a48 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,35 +1,50 @@ import { - Activity, ArrowRight, - BarChart3, + BookOpen, Container, Cpu, + Database, ExternalLink, + GitBranch, + GitFork, + HardDrive, } 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 { cn } from "@/lib/utils"; -const features = [ - { - icon: Container, - title: "Service Health", - description: "Real-time Docker container status across the cluster.", - }, - { - icon: BarChart3, - title: "Request Metrics", - description: "RPS, latency, and error rates scraped from Traefik.", - }, - { - icon: Cpu, - title: "System Resources", - description: "CPU, memory, disk, and network on each VPS node.", - }, - { - icon: Activity, - title: "Distributed Tracing", - description: "Jaeger trace visualization for service-to-service calls.", - }, +const skills = [ + { icon: HardDrive, label: "Backend (Bun, Elysia, Axum)" }, + { icon: Container, label: "Docker & Traefik" }, + { icon: Database, label: "PostgreSQL, Redis, NATS" }, + { icon: Cpu, label: "Rust, TypeScript, Python" }, + { icon: GitBranch, label: "CI/CD, GitHub Actions" }, + { icon: BookOpen, label: "Dapr, Prometheus, Jaeger" }, +]; + +const techTags = [ + "TypeScript", + "Rust", + "Python", + "Next.js", + "React", + "Bun", + "Elysia", + "Drizzle", + "Axum", + "TensorFlow", + "ONNX", + "Docker", + "Traefik", + "Tailscale", + "Dapr", + "NATS", + "PostgreSQL", + "Redis", + "Prometheus", + "Tauri", ]; export default function Home() { @@ -37,29 +52,26 @@ export default function Home() {
{/* Hero */}
-
+
AH

Asep Haryana Saputra

-

- Software engineer building resilient, observable infrastructure for - personal and production systems. +

+ Backend & infrastructure engineer who builds distributed systems, + RESTful APIs, and production-grade observability stacks.

- - Dashboard + + View Projects GitHub @@ -71,55 +83,181 @@ export default function Home() {
- About This Hub + About

- This is the control center for Asep Haryana Saputra's - infrastructure. It monitors Docker containers, Traefik request - metrics, Prometheus system resources, and Jaeger distributed - traces across a multi-VPS deployment orchestrated with Dapr and - NATS. + I build backend systems and infrastructure for personal projects, + research collaborations, and production deployments. My work spans + RESTful API design, container orchestration with Docker & + Traefik, distributed tracing with OpenTelemetry & Jaeger, and + event-driven architectures on Dapr & NATS.

- The stack runs on Tailscale-connected VPS nodes with automatic - service discovery, TLS termination via Traefik, and real-time - streaming telemetry through JetStream-backed Dapr pub/sub. + I was the Back-End lead on the ZeaVis Edu capstone team ("AI + for Smart Education" by Pijak × IBM SkillsBuild), where + I designed the system architecture, API contracts, and deployment + pipeline for a computer vision-based corn leaf disease detection + platform.

- {/* Features */} -
- {features.map((f) => { - const Icon = f.icon; - return ( - - - - - {f.title} - - - - {f.description} - - - ); - })} + {/* Skills */} + + + Skills & Tools + + +
+ {skills.map((s) => { + const Icon = s.icon; + return ( +
+ + {s.label} +
+ ); + })} +
+
+ {techTags.map((t) => ( + + {t} + + ))} +
+
+
+ + {/* Projects */} +
+

+ Featured Project +

+ + +
+ + +

+ 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. +

+ +
+
+

+ What it does +

+
    +
  • Upload a photo of a corn leaf
  • +
  • + AI classifies the disease — Blight, Rust, Leaf Spot, + or Healthy +
  • +
  • Get treatment & prevention recommendations
  • +
+
+
+

+ My role +

+
    +
  • System architecture & API design
  • +
  • RESTful API (Bun + Elysia + Drizzle + PostgreSQL)
  • +
  • Rust inference engine (Axum + ONNX Runtime)
  • +
  • + Docker deployment on VPS with Traefik & Tailscale +
  • +
+
+
+ +
+ {[ + "React", + "TypeScript", + "Bun", + "Elysia", + "Drizzle", + "PostgreSQL", + "Rust", + "Axum", + "ONNX", + "TensorFlow", + "EfficientNetV2B0", + "Tauri 2", + "Docker", + "Traefik", + ].map((t) => ( + + {t} + + ))} +
+
+
- {/* CTA */} + {/* Infrastructure CTA */}

- Ready to see the live infrastructure? + This site doubles as a live infrastructure monitor.

- - Open Dashboard +

+ The dashboard tracks Docker services, Traefik request metrics, + Prometheus system resources, and Jaeger traces across the + deployment. +

+
+ Live Dashboard
@@ -128,8 +266,17 @@ export default function Home() { {/* Footer */}
- © {new Date().getFullYear()} Asep Haryana Saputra. Built with - Next.js, shadcn/ui, and a lot of Docker. +

+ © {new Date().getFullYear()} Asep Haryana Saputra —{" "} + + GitHub + +

); diff --git a/src/components/dashboard/donut.tsx b/src/components/dashboard/donut.tsx new file mode 100644 index 0000000..aa48ba0 --- /dev/null +++ b/src/components/dashboard/donut.tsx @@ -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 ; + + 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 ( + + {segs.map((s) => { + if (!s.n) return null; + const frac = s.n / total; + const ln = frac * circ; + const el = ( + + ); + off += ln; + return el; + })} + + {total} + + + total + + {segs + .filter((s) => s.n) + .map((s, i) => ( + + + + {s.l}: {s.n} + + + ))} + + ); +} diff --git a/src/components/dashboard/gauge.tsx b/src/components/dashboard/gauge.tsx new file mode 100644 index 0000000..6bd2ae5 --- /dev/null +++ b/src/components/dashboard/gauge.tsx @@ -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 ; + const fw = BW * Math.min(pct / 100, 1); + + return ( + + + {fw > 0 && ( + + )} + + {label} + + + {pct.toFixed(1)} + {unit} + + + ); +} diff --git a/src/components/dashboard/header.tsx b/src/components/dashboard/header.tsx new file mode 100644 index 0000000..b203581 --- /dev/null +++ b/src/components/dashboard/header.tsx @@ -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 ( +
+ + + AH + +

Asep Haryana

+
+
+ 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 && ( + 0 ? "bg-amber-500" : "bg-emerald-400" + }`} + /> + )} + {total === 0 + ? "No services" + : degraded > 0 + ? `${degraded} degraded` + : "All Systems Operational"} + + {time} +
+
+ ); +} diff --git a/src/components/dashboard/no-data.tsx b/src/components/dashboard/no-data.tsx new file mode 100644 index 0000000..109ec60 --- /dev/null +++ b/src/components/dashboard/no-data.tsx @@ -0,0 +1,22 @@ +export function NoData() { + return ( + + + No data + + + ); +} diff --git a/src/components/dashboard/sparkline.tsx b/src/components/dashboard/sparkline.tsx new file mode 100644 index 0000000..b2e042b --- /dev/null +++ b/src/components/dashboard/sparkline.tsx @@ -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 ( + + + + {val.toFixed(0)} + + + ); + }); +} + +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 ( + + {idx + 1} + + ); + }); +} + +export function Sparkline({ data, color }: { data: number[]; color: string }) { + if (!data.length) return ; + + 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 ( + + {gridLines(maxV)} + + + + {lv.toFixed(1)} + + {xAxisLabels(data.length)} + + ); +} diff --git a/src/lib/dashboard/types.ts b/src/lib/dashboard/types.ts new file mode 100644 index 0000000..6fc0fdc --- /dev/null +++ b/src/lib/dashboard/types.ts @@ -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"; + } +}