Tech

AI Agent Dashboard Bagian 2: Dashboard, Briefing & System Monitor

Part 2 — Bikin halaman utama dashboard, morning briefing page, dan system monitor dengan real-time data.

👤 Zainul Fanani📅 28 Maret 2026⏱ 1 min read

📎 Source:openclaw-sumopod — view on GitHub & star ⭐

📊 PART 3: Dashboard Home (Status Page)

Ini halaman utama yang user liat pertama kali. Kita bikin 4 bagian: stats cards, usage chart, activity feed, dan jam real-time.

3.1 API Route — Status Data

Buat src/app/api/status/route.ts:

// src/app/api/status/route.ts import { NextResponse } from "next/server"; // Interface data status export interface StatusData { stats: { totalSessions: number; tokenUsage: number; activeModels: number; uptimeDays: number; }; usageHistory: Array<{ date: string; tokens: number; }>; recentActivity: Array<{ id: string; type: "session" | "skill" | "system" | "alert"; message: string; time: string; }>; } // Data dummy — nanti ganti dengan data asli dari agent API const mockData: StatusData = { stats: { totalSessions: 1247, tokenUsage: 2458930, activeModels: 8, uptimeDays: 42, }, usageHistory: [ { date: "22 Mar", tokens: 320000 }, { date: "23 Mar", tokens: 410000 }, { date: "24 Mar", tokens: 280000 }, { date: "25 Mar", tokens: 390000 }, { date: "26 Mar", tokens: 520000 }, { date: "27 Mar", tokens: 310000 }, { date: "28 Mar", tokens: 228930 }, ], recentActivity: [ { id: "1", type: "session", message: "Session baru dimulai — radit:main (GLM-5 Turbo)", time: "2 menit lalu", }, { id: "2", type: "skill", message: "Skill bmkg-monitor berhasil scan 3 gempa baru", time: "15 menit lalu", }, { id: "3", type: "system", message: "Memory usage normal — 62% (4.9GB / 8GB)", time: "30 menit lalu", }, { id: "4", type: "alert", message: "API rate limit tercapai — Gemini (85% quota)", time: "1 jam lalu", }, { id: "5", type: "session", message: "Session raka:content selesai — 47 pesan, 12 menit", time: "2 jam lalu", }, { id: "6", type: "system", message: "Cron job heartbeat berhasil — semua normal", time: "3 jam lalu", }, ], }; export async function GET() { try { // TODO: Fetch data asli dari agent API // const res = await fetch("http://localhost:3001/api/status"); // const data = await res.json(); // Sementara pake mock data return NextResponse.json(mockData); } catch (error) { return NextResponse.json( { error: "Gagal fetch status data" }, { status: 500 } ); } }

3.2 Stats Grid Component

Buat src/components/dashboard/stats-grid.tsx:

// src/components/dashboard/stats-grid.tsx "use client"; import { formatNumber } from "@/lib/utils"; import { Card, CardContent } from "@/components/ui/card"; import { MessageSquare, Coins, Brain, Activity, } from "lucide-react"; interface StatCard { title: string; value: string; subtitle: string; icon: React.ElementType; trend?: string; trendUp?: boolean; } interface StatsGridProps { stats: { totalSessions: number; tokenUsage: number; activeModels: number; uptimeDays: number; }; } // Konfigurasi tiap stat card const statCards: Array<{ key: keyof StatsGridProps["stats"]; title: string; icon: React.ElementType; format: (val: number) => string; subtitle: string; color: string; bgColor: string; }> = [ { key: "totalSessions", title: "Total Sessions", icon: MessageSquare, format: (v) => formatNumber(v), subtitle: "Sejak 30 hari lalu", color: "text-blue-600", bgColor: "bg-blue-50", }, { key: "tokenUsage", title: "Token Usage", icon: Coins, format: (v) => `${formatNumber(v)}`, subtitle: "Total token terpakai", color: "text-green-600", bgColor: "bg-green-50", }, { key: "activeModels", title: "Active Models", icon: Brain, format: (v) => v.toString(), subtitle: "Model terkoneksi", color: "text-purple-600", bgColor: "bg-purple-50", }, { key: "uptimeDays", title: "Uptime", icon: Activity, format: (v) => `${v} hari`, subtitle: "Non-stop running", color: "text-amber-600", bgColor: "bg-amber-50", }, ]; export function StatsGrid({ stats }: StatsGridProps) { return ( <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4"> {statCards.map((card) => { const Icon = card.icon; const value = stats[card.key]; return ( <Card key={card.key} className="hover:shadow-md transition-shadow duration-200" > <CardContent className="p-5"> <div className="flex items-start justify-between"> <div className="space-y-2"> <p className="text-sm font-medium text-slate-500"> {card.title} </p> <p className="text-2xl font-bold text-slate-900"> {card.format(value)} </p> <p className="text-xs text-slate-400">{card.subtitle}</p> </div> <div className={`${card.bgColor} p-3 rounded-xl`}> <Icon size={22} className={card.color} /> </div> </div> </CardContent> </Card> ); })} </div> ); }

3.3 Usage Chart Component

Buat src/components/dashboard/usage-chart.tsx:

// src/components/dashboard/usage-chart.tsx "use client"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, } from "recharts"; import { formatNumber } from "@/lib/utils"; interface UsageChartProps { data: Array<{ date: string; tokens: number; }>; } export function UsageChart({ data }: UsageChartProps) { return ( <Card className="hover:shadow-md transition-shadow duration-200"> <CardHeader className="pb-2"> <CardTitle className="text-base font-semibold text-slate-900"> 📈 Token Usage — 7 Hari Terakhir </CardTitle> </CardHeader> <CardContent> <div className="h-[280px] w-full"> <ResponsiveContainer width="100%" height="100%"> <AreaChart data={data} margin={{ top: 10, right: 10, left: -10, bottom: 0 }} > {/* Grid halus */} <CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" /> <XAxis dataKey="date" tick={{ fontSize: 12, fill: "#94a3b8" }} axisLine={{ stroke: "#e2e8f0" }} tickLine={false} /> <YAxis tick={{ fontSize: 12, fill: "#94a3b8" }} axisLine={false} tickLine={false} tickFormatter={(value) => `${(value / 1000).toFixed(0)}k`} /> <Tooltip contentStyle={{ backgroundColor: "white", border: "1px solid #e2e8f0", borderRadius: "8px", fontSize: "13px", boxShadow: "0 4px 6px -1px rgba(0,0,0,0.1)", }} formatter={(value: number) => [ formatNumber(value) + " tokens", "Usage", ]} /> {/* Gradient area */} <defs> <linearGradient id="tokenGradient" x1="0" y1="0" x2="0" y2="1"> <stop offset="5%" stopColor="#22c55e" stopOpacity={0.3} /> <stop offset="95%" stopColor="#22c55e" stopOpacity={0} /> </linearGradient> </defs> <Area type="monotone" dataKey="tokens" stroke="#22c55e" strokeWidth={2.5} fill="url(#tokenGradient)" /> </AreaChart> </ResponsiveContainer> </div> </CardContent> </Card> ); }

3.4 Activity Feed Component

Buat src/components/dashboard/activity-feed.tsx:

// src/components/dashboard/activity-feed.tsx import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { MessageSquare, Zap, Monitor, AlertTriangle, } from "lucide-react"; // Mapping type ke icon & warna const typeConfig = { session: { icon: MessageSquare, color: "text-blue-500", bg: "bg-blue-50", }, skill: { icon: Zap, color: "text-green-500", bg: "bg-green-50", }, system: { icon: Monitor, color: "text-slate-500", bg: "bg-slate-50", }, alert: { icon: AlertTriangle, color: "text-amber-500", bg: "bg-amber-50", }, }; interface ActivityItem { id: string; type: "session" | "skill" | "system" | "alert"; message: string; time: string; } interface ActivityFeedProps { activities: ActivityItem[]; } export function ActivityFeed({ activities }: ActivityFeedProps) { return ( <Card className="hover:shadow-md transition-shadow duration-200"> <CardHeader className="pb-3"> <CardTitle className="text-base font-semibold text-slate-900"> 📋 Aktivitas Terbaru </CardTitle> </CardHeader> <CardContent> <div className="space-y-3"> {activities.map((activity, index) => { const config = typeConfig[activity.type]; const Icon = config.icon; return ( <div key={activity.id} className="flex items-start gap-3 py-2 border-b border-slate-100 last:border-0" > {/* Icon */} <div className={`p-2 rounded-lg ${config.bg} shrink-0`}> <Icon size={14} className={config.color} /> </div> {/* Content */} <div className="flex-1 min-w-0"> <p className="text-sm text-slate-700 leading-snug"> {activity.message} </p> <p className="text-xs text-slate-400 mt-0.5"> {activity.time} </p> </div> </div> ); })} </div> </CardContent> </Card> ); }

3.5 Real-Time Clock Component

Buat src/components/dashboard/real-time-clock.tsx:

// src/components/dashboard/real-time-clock.tsx "use client"; import { useState, useEffect } from "react"; import { Card, CardContent } from "@/components/ui/card"; export function RealTimeClock() { const [time, setTime] = useState(new Date()); useEffect(() => { const timer = setInterval(() => setTime(new Date()), 1000); return () => clearInterval(timer); }, []); // Format waktu WITA const timeStr = time.toLocaleTimeString("id-ID", { hour: "2-digit", minute: "2-digit", second: "2-digit", timeZone: "Asia/Makassar", }); const dateStr = time.toLocaleDateString("id-ID", { weekday: "long", day: "numeric", month: "long", year: "numeric", timeZone: "Asia/Makassar", }); // Detik progress (0-59 → 0%-100%) const secondProgress = (time.getSeconds() / 59) * 100; return ( <Card className="hover:shadow-md transition-shadow duration-200"> <CardContent className="p-5"> <div className="text-center space-y-2"> {/* Jam besar */} <div className="text-4xl font-mono font-bold text-slate-900 tracking-wider"> {timeStr} </div> {/* Tanggal */} <div className="text-sm text-slate-500">{dateStr}</div> {/* Progress bar detik */} <div className="w-full h-1 bg-slate-100 rounded-full overflow-hidden"> <div className="h-full bg-primary rounded-full transition-all duration-1000 ease-linear" style={{ width: `${secondProgress}%` }} /> </div> <span className="text-xs text-slate-400">Asia/Makassar (WITA)</span> </div> </CardContent> </Card> ); }

3.6 Dashboard Home Page

Buat src/app/page.tsx:

// src/app/page.tsx "use client"; import { useState, useEffect } from "react"; import { StatsGrid } from "@/components/dashboard/stats-grid"; import { UsageChart } from "@/components/dashboard/usage-chart"; import { ActivityFeed } from "@/components/dashboard/activity-feed"; import { RealTimeClock } from "@/components/dashboard/real-time-clock"; // Tipe data dari API interface StatusData { stats: { totalSessions: number; tokenUsage: number; activeModels: number; uptimeDays: number; }; usageHistory: Array<{ date: string; tokens: number }>; recentActivity: Array<{ id: string; type: "session" | "skill" | "system" | "alert"; message: string; time: string; }>; } export default function DashboardPage() { const [data, setData] = useState<StatusData | null>(null); const [loading, setLoading] = useState(true); useEffect(() => { async function fetchStatus() { try { const res = await fetch("/api/status"); const json = await res.json(); setData(json); } catch (err) { console.error("Gagal fetch status:", err); } finally { setLoading(false); } } fetchStatus(); // Auto-refresh setiap 60 detik const interval = setInterval(fetchStatus, 60000); return () => clearInterval(interval); }, []); // Loading skeleton if (loading || !data) { return ( <div className="space-y-6 animate-pulse"> {/* Skeleton stats */} <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4"> {[...Array(4)].map((_, i) => ( <div key={i} className="h-32 bg-slate-200 rounded-xl" /> ))} </div> {/* Skeleton chart */} <div className="grid grid-cols-1 lg:grid-cols-3 gap-6"> <div className="lg:col-span-2 h-80 bg-slate-200 rounded-xl" /> <div className="h-80 bg-slate-200 rounded-xl" /> </div> </div> ); } return ( <div className="space-y-6"> {/* Stats cards */} <StatsGrid stats={data.stats} /> {/* Chart + Activity Feed */} <div className="grid grid-cols-1 lg:grid-cols-3 gap-6"> {/* Chart — 2/3 width di desktop */} <div className="lg:col-span-2"> <UsageChart data={data.usageHistory} /> </div> {/* Activity feed — 1/3 width */} <div className="space-y-6"> <ActivityFeed activities={data.recentActivity} /> <RealTimeClock /> </div> </div> </div> ); }

3.7 Data Flow Diagram

subgraph APIAPI Route
subgraph APIAPI Route

💡 Tips: Loading skeleton itu penting banget buat UX. User nggak nunggu layar kosong — dia liat shape konten dulu, terus data muncul smooth. Makanya kita pake animate-pulse dari Tailwind.

⚠️ Pitfall:setInterval di useEffect WAJIB di-return cleanup (clearInterval). Kalau nggak, tiap component re-render → timer baru → memory leak!


Part 3 selesai! Dashboard home sudah punya stats, chart, activity feed, dan jam real-time. Lanjut ke Part 4.


📬 PART 4: Morning Briefing Page

Halaman briefing ini nanti menampilkan info penting di pagi hari — email, calendar, tasks, harga emas, server health, dan cuaca. Card-based, responsive, tiap card punya loading state.

4.1 API Route — Briefing Data

Buat src/app/api/briefing/route.ts:

// src/app/api/briefing/route.ts import { NextResponse } from "next/server"; export interface BriefingData { email: { unread: number; latest: Array<{ from: string; subject: string; time: string }>; }; calendar: { today: number; events: Array<{ title: string; time: string; type: string }>; }; tasks: { pending: number; completed: number; items: Array<{ title: string; priority: string }>; }; goldPrice: { price: number; change: number; updated: string; }; serverHealth: { cpu: number; ram: number; disk: number; status: "healthy" | "warning" | "critical"; }; weather: { temp: number; condition: string; city: string; humidity: number; }; } // Mock data — ganti nanti dengan fetch asli const mockData: BriefingData = { email: { unread: 12, latest: [ { from: "noreply@github.com", subject: "New PR: Fix dashboard layout", time: "08:30" }, { from: "client@rfm.co.id", subject: "Update project schedule", time: "07:45" }, { from: "alerts@vps.io", subject: "Server backup completed", time: "06:00" }, ], }, calendar: { today: 4, events: [ { title: "Standup call — Radian Group", time: "09:00", type: "meeting" }, { title: "Review proposal UST", time: "11:00", type: "task" }, { title: "Lunch with vendor", time: "12:30", type: "personal" }, { title: "Deploy dashboard v2", time: "15:00", type: "task" }, ], }, tasks: { pending: 7, completed: 23, items: [ { title: "Fix sidebar responsive bug", priority: "high" }, { title: "Add dark mode toggle", priority: "medium" }, { title: "Write API documentation", priority: "low" }, ], }, goldPrice: { price: 3128000, change: 15000, updated: "28 Mar 2026, 08:00 WITA", }, serverHealth: { cpu: 34, ram: 62, disk: 45, status: "healthy", }, weather: { temp: 31, condition: "Cerah Berawan", city: "Balikpapan", humidity: 78, }, }; export async function GET() { try { // TODO: Fetch dari berbagai source // - Email: gog gmail list --max=5 // - Calendar: gog calendar events list --today // - Gold: fetch dari API harga emas // - Weather: fetch dari BMKG/OpenWeatherMap // - Server: /api/system return NextResponse.json(mockData); } catch (error) { return NextResponse.json( { error: "Gagal fetch briefing data" }, { status: 500 } ); } }

4.2 Briefing Card Component

Buat src/components/briefing/briefing-card.tsx:

// src/components/briefing/briefing-card.tsx import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { cn } from "@/lib/utils"; import { Skeleton } from "@/components/ui/skeleton"; import { type LucideIcon } from "lucide-react"; interface BriefingCardProps { title: string; icon: LucideIcon; iconColor?: string; iconBg?: string; loading?: boolean; children: React.ReactNode; className?: string; } /** * Card reusable buat briefing. * Tiap card di briefing page pake komponen ini sebagai wrapper. */ export function BriefingCard({ title, icon: Icon, iconColor = "text-primary", iconBg = "bg-green-50", loading = false, children, className, }: BriefingCardProps) { return ( <Card className={cn( "hover:shadow-md transition-shadow duration-200", className )} > <CardHeader className="pb-3"> <div className="flex items-center gap-2"> <div className={cn("p-2 rounded-lg", iconBg)}> <Icon size={18} className={iconColor} /> </div> <CardTitle className="text-base font-semibold text-slate-900"> {title} </CardTitle> </div> </CardHeader> <CardContent> {loading ? ( <div className="space-y-3"> <Skeleton className="h-4 w-3/4" /> <Skeleton className="h-4 w-1/2" /> <Skeleton className="h-4 w-2/3" /> </div> ) : ( children )} </CardContent> </Card> ); }

⚠️ Pitfall: Pastikan Skeleton dari shadcn/ui udah ke-install: npx shadcn@latest add skeleton

4.3 Briefing Sub-Components

Buat src/components/briefing/email-card.tsx:

// src/components/briefing/email-card.tsx "use client"; import { BriefingCard } from "./briefing-card"; import { Mail } from "lucide-react"; interface EmailData { unread: number; latest: Array<{ from: string; subject: string; time: string }>; } export function EmailCard({ data }: { data: EmailData }) { return ( <BriefingCard title="Email" icon={Mail} iconColor="text-blue-600" iconBg="bg-blue-50" > <div className="space-y-3"> {/* Badge jumlah unread */} <div className="flex items-center gap-2"> <span className="inline-flex items-center justify-center px-2.5 py-0.5 rounded-full text-xs font-bold bg-red-100 text-red-700"> {data.unread} unread </span> </div> {/* List email terbaru */} {data.latest.map((email, i) => ( <div key={i} className="flex items-start justify-between py-2 border-b border-slate-100 last:border-0" > <div className="min-w-0 flex-1"> <p className="text-xs text-slate-500 truncate">{email.from}</p> <p className="text-sm text-slate-700 truncate font-medium"> {email.subject} </p> </div> <span className="text-xs text-slate-400 shrink-0 ml-2"> {email.time} </span> </div> ))} </div> </BriefingCard> ); }

Buat src/components/briefing/calendar-card.tsx:

// src/components/briefing/calendar-card.tsx "use client"; import { BriefingCard } from "./briefing-card"; import { Calendar } from "lucide-react"; import { Badge } from "@/components/ui/badge"; interface CalendarData { today: number; events: Array<{ title: string; time: string; type: string }>; } const typeColors: Record<string, string> = { meeting: "bg-blue-100 text-blue-700", task: "bg-green-100 text-green-700", personal: "bg-purple-100 text-purple-700", }; export function CalendarCard({ data }: { data: CalendarData }) { return ( <BriefingCard title="Calendar" icon={Calendar} iconColor="text-purple-600" iconBg="bg-purple-50" > <div className="space-y-3"> <p className="text-sm text-slate-500"> <span className="font-bold text-slate-900">{data.today}</span> event hari ini </p> {data.events.map((event, i) => ( <div key={i} className="flex items-center gap-3 py-1.5" > <span className="text-xs font-mono text-slate-400 w-12 shrink-0"> {event.time} </span> <Badge className={cn("text-[10px] border-0", typeColors[event.type])} variant="outline" > {event.type} </Badge> <span className="text-sm text-slate-700 truncate">{event.title}</span> </div> ))} </div> </BriefingCard> ); } import { cn } from "@/lib/utils";

Buat src/components/briefing/tasks-card.tsx:

// src/components/briefing/tasks-card.tsx "use client"; import { BriefingCard } from "./briefing-card"; import { CheckSquare } from "lucide-react"; import { Badge } from "@/components/ui/badge"; interface TasksData { pending: number; completed: number; items: Array<{ title: string; priority: string }>; } const priorityColors: Record<string, string> = { high: "bg-red-100 text-red-700", medium: "bg-amber-100 text-amber-700", low: "bg-slate-100 text-slate-600", }; export function TasksCard({ data }: { data: TasksData }) { // Progress bar const total = data.pending + data.completed; const progress = total > 0 ? (data.completed / total) * 100 : 0; return ( <BriefingCard title="Tasks" icon={CheckSquare} iconColor="text-green-600" iconBg="bg-green-50" > <div className="space-y-3"> {/* Progress */} <div> <div className="flex justify-between text-xs text-slate-500 mb-1"> <span> {data.completed}/{total} selesai </span> <span>{Math.round(progress)}%</span> </div> <div className="w-full h-2 bg-slate-100 rounded-full overflow-hidden"> <div className="h-full bg-primary rounded-full transition-all duration-500" style={{ width: `${progress}%` }} /> </div> </div> {/* Task list */} {data.items.map((task, i) => ( <div key={i} className="flex items-center gap-2 py-1"> <Badge className={cn( "text-[10px] border-0 shrink-0", priorityColors[task.priority] )} variant="outline" > {task.priority} </Badge> <span className="text-sm text-slate-700">{task.title}</span> </div> ))} </div> </BriefingCard> ); } import { cn } from "@/lib/utils";

Buat src/components/briefing/gold-card.tsx:

// src/components/briefing/gold-card.tsx "use client"; import { BriefingCard } from "./briefing-card"; import { TrendingUp, TrendingDown } from "lucide-react"; import { cn, formatNumber } from "@/lib/utils"; interface GoldPriceData { price: number; change: number; updated: string; } export function GoldCard({ data }: { data: GoldPriceData }) { const isUp = data.change > 0; return ( <BriefingCard title="Harga Emas" icon={TrendingUp} iconColor="text-amber-600" iconBg="bg-amber-50" > <div className="space-y-2"> {/* Harga besar */} <div className="flex items-baseline gap-2"> <span className="text-2xl font-bold text-slate-900"> Rp {formatNumber(data.price)} </span> <span className="text-xs text-slate-400">/gram</span> </div> {/* Perubahan */} <div className="flex items-center gap-1"> {isUp ? ( <TrendingUp size={16} className="text-green-500" /> ) : ( <TrendingDown size={16} className="text-red-500" /> )} <span className={cn( "text-sm font-medium", isUp ? "text-green-600" : "text-red-600" )} > {isUp ? "+" : ""} Rp {formatNumber(Math.abs(data.change))} </span> </div> {/* Timestamp */} <p className="text-xs text-slate-400">{data.updated}</p> </div> </BriefingCard> ); }

Buat src/components/briefing/health-card.tsx:

// src/components/briefing/health-card.tsx "use client"; import { BriefingCard } from "./briefing-card"; import { HeartPulse } from "lucide-react"; import { cn } from "@/lib/utils"; import { Badge } from "@/components/ui/badge"; interface ServerHealthData { cpu: number; ram: number; disk: number; status: "healthy" | "warning" | "critical"; } const statusConfig = { healthy: { label: "Healthy", color: "bg-green-100 text-green-700" }, warning: { label: "Warning", color: "bg-amber-100 text-amber-700" }, critical: { label: "Critical", color: "bg-red-100 text-red-700" }, }; export function HealthCard({ data }: { data: ServerHealthData }) { const config = statusConfig[data.status]; // Fungsi helper buat mini progress bar const MiniBar = ({ label, value, color, }: { label: string; value: number; color: string; }) => ( <div className="space-y-1"> <div className="flex justify-between text-xs"> <span className="text-slate-500">{label}</span> <span className="font-mono font-medium text-slate-700">{value}%</span> </div> <div className="w-full h-1.5 bg-slate-100 rounded-full overflow-hidden"> <div className={cn("h-full rounded-full transition-all", color)} style={{ width: `${value}%` }} /> </div> </div> ); return ( <BriefingCard title="Server Health" icon={HeartPulse} iconColor="text-red-600" iconBg="bg-red-50" > <div className="space-y-3"> <Badge className={cn("text-xs border-0", config.color)} variant="outline"> {config.label} </Badge> <MiniBar label="CPU" value={data.cpu} color="bg-blue-500" /> <MiniBar label="RAM" value={data.ram} color="bg-purple-500" /> <MiniBar label="Disk" value={data.disk} color="bg-amber-500" /> </div> </BriefingCard> ); }

Buat src/components/briefing/weather-card.tsx:

// src/components/briefing/weather-card.tsx "use client"; import { BriefingCard } from "./briefing-card"; import { CloudSun, Droplets } from "lucide-react"; interface WeatherData { temp: number; condition: string; city: string; humidity: number; } export function WeatherCard({ data }: { data: WeatherData }) { return ( <BriefingCard title="Cuaca" icon={CloudSun} iconColor="text-sky-600" iconBg="bg-sky-50" > <div className="space-y-2"> {/* Suhu besar */} <div className="flex items-baseline gap-1"> <span className="text-3xl font-bold text-slate-900"> {data.temp}°C </span> </div> {/* Kondisi & kota */} <p className="text-sm text-slate-600">{data.condition}</p> <p className="text-xs text-slate-400">{data.city}</p> {/* Humidity */} <div className="flex items-center gap-1 text-xs text-slate-500"> <Droplets size={14} className="text-blue-400" /> <span>Humidity: {data.humidity}%</span> </div> </div> </BriefingCard> ); }

4.4 Briefing Page

Buat src/app/briefing/page.tsx:

// src/app/briefing/page.tsx "use client"; import { useState, useEffect } from "react"; import { EmailCard } from "@/components/briefing/email-card"; import { CalendarCard } from "@/components/briefing/calendar-card"; import { TasksCard } from "@/components/briefing/tasks-card"; import { GoldCard } from "@/components/briefing/gold-card"; import { HealthCard } from "@/components/briefing/health-card"; import { WeatherCard } from "@/components/briefing/weather-card"; import { BriefingCard } from "@/components/briefing/briefing-card"; import { RefreshCw } from "lucide-react"; import { Button } from "@/components/ui/button"; // Type data briefing interface BriefingData { email: { unread: number; latest: Array<{ from: string; subject: string; time: string }>; }; calendar: { today: number; events: Array<{ title: string; time: string; type: string }>; }; tasks: { pending: number; completed: number; items: Array<{ title: string; priority: string }>; }; goldPrice: { price: number; change: number; updated: string; }; serverHealth: { cpu: number; ram: number; disk: number; status: "healthy" | "warning" | "critical"; }; weather: { temp: number; condition: string; city: string; humidity: number; }; } export default function BriefingPage() { const [data, setData] = useState<BriefingData | null>(null); const [loading, setLoading] = useState(true); const [refreshing, setRefreshing] = useState(false); async function fetchBriefing() { try { const res = await fetch("/api/briefing"); const json = await res.json(); setData(json); } catch (err) { console.error("Gagal fetch briefing:", err); } finally { setLoading(false); setRefreshing(false); } } useEffect(() => { fetchBriefing(); }, []); function handleRefresh() { setRefreshing(true); fetchBriefing(); } return ( <div className="space-y-6"> {/* Header section */} <div className="flex items-center justify-between"> <div> <p className="text-sm text-slate-500"> Selamat pagi! Ini ringkasan penting buat hari ini. </p> </div> <Button variant="outline" size="sm" onClick={handleRefresh} disabled={refreshing} className="gap-2" > <RefreshCw size={14} className={refreshing ? "animate-spin" : ""} /> Refresh </Button> </div> {/* Cards grid — responsive */} <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> {/* Email */} {data ? ( <EmailCard data={data.email} /> ) : ( <BriefingCard title="Email" icon={RefreshCw} loading /> )} {/* Calendar */} {data ? ( <CalendarCard data={data.calendar} /> ) : ( <BriefingCard title="Calendar" icon={RefreshCw} loading /> )} {/* Tasks */} {data ? ( <TasksCard data={data.tasks} /> ) : ( <BriefingCard title="Tasks" icon={RefreshCw} loading /> )} {/* Gold Price */} {data ? ( <GoldCard data={data.goldPrice} /> ) : ( <BriefingCard title="Harga Emas" icon={RefreshCw} loading /> )} {/* Server Health */} {data ? ( <HealthCard data={data.serverHealth} /> ) : ( <BriefingCard title="Server Health" icon={RefreshCw} loading /> )} {/* Weather */} {data ? ( <WeatherCard data={data.weather} /> ) : ( <BriefingCard title="Cuaca" icon={RefreshCw} loading /> )} </div> </div> ); }

4.5 API Data Sources Sequence Diagram

participant Page as Briefing Page
participant Page as Briefing Page

💡 Tips: Loading state di briefing card itu penting karena data di-fetch dari 6 source berbeda. Card yang datanya udah siap muncul dulu, yang belum tetap nampilin skeleton. Jangan nunggu semua selesai baru render!

⚠️ Pitfall: Harga emas itu data finansial — JANGAN cache! Selalu fetch fresh data. Beda sama cuaca yang bisa cache 30 menit, harga emas bisa berubah tiap menit.


Part 4 selesai! Morning briefing page siap. Lanjut ke Part 5 — System Monitor.


🖥️ PART 5: System Monitor

Ini halaman yang nampilin real-time system metrics — CPU, RAM, Disk — dalam bentuk gauge SVG yang animasinya smooth abis. Plus tabel proses yang auto-refresh.

5.1 API Route — System Data

Buat src/app/api/system/route.ts:

// src/app/api/system/route.ts import { NextResponse } from "next/server"; export interface SystemData { metrics: { cpu: number; ram: number; disk: number; }; processes: Array<{ pid: number; name: string; cpu: number; mem: number; }>; uptime: string; } // Mock data — nanti ganti dengan baca /proc/stat dll function getMockSystemData(): SystemData { // Randomize sedikit biar kayak real-time const jitter = () => Math.random() * 10 - 5; return { metrics: { cpu: Math.max(0, Math.min(100, 34 + jitter())), ram: Math.max(0, Math.min(100, 62 + jitter())), disk: 45, // Disk jarang berubah }, processes: [ { pid: 1, name: "openclaw", cpu: 12.5, mem: 8.3 }, { pid: 2, name: "node (gateway)", cpu: 8.2, mem: 15.1 }, { pid: 3, name: "nginx", cpu: 1.3, mem: 2.4 }, { pid: 4, name: "postgres", cpu: 5.7, mem: 12.8 }, { pid: 5, name: "redis-server", cpu: 0.8, mem: 3.2 }, { pid: 6, name: "python3 (skills)", cpu: 3.1, mem: 5.6 }, { pid: 7, name: "gog", cpu: 0.4, mem: 1.8 }, { pid: 8, name: "n8n", cpu: 2.9, mem: 9.7 }, { pid: 9, name: "cron", cpu: 0.1, mem: 0.3 }, { pid: 10, name: "sshd", cpu: 0.0, mem: 0.5 }, ].map((p) => ({ ...p, cpu: Math.max(0, p.cpu + Math.random() * 2 - 1), })), uptime: "42 hari, 7 jam, 23 menit", }; } export async function GET() { try { // TODO: Baca data asli dari system // const cpu = await readCpuUsage(); // const ram = await readMemInfo(); // const disk = await readDiskUsage(); // const procs = await readProcesses(); const data = getMockSystemData(); return NextResponse.json(data); } catch (error) { return NextResponse.json( { error: "Gagal fetch system data" }, { status: 500 } ); } }

5.2 Circular Gauge Component

Buat src/components/system/gauge.tsx:

// src/components/system/gauge.tsx "use client"; import { cn } from "@/lib/utils"; interface GaugeProps { value: number; // 0-100 label: string; // "CPU", "RAM", dll color: string; // Tailwind stroke color class size?: number; // SVG size (default 160) strokeWidth?: number; // Ketebalan stroke (default 12) } /** * Circular SVG gauge buat monitoring. * Menggunakan stroke-dasharray & stroke-dashoffset untuk animasi fill. */ export function Gauge({ value, label, color, size = 160, strokeWidth = 12, }: GaugeProps) { // Clamp value 0-100 const clampedValue = Math.max(0, Math.min(100, value)); // Hitung circumference & offset const radius = (size - strokeWidth) / 2; const circumference = 2 * Math.PI * radius; const offset = circumference - (clampedValue / 100) * circumference; // Warna berdasarkan level const getColor = () => { if (clampedValue >= 90) return { stroke: "#ef4444", text: "text-red-600" }; // Merah — danger if (clampedValue >= 70) return { stroke: "#f59e0b", text: "text-amber-600" }; // Kuning — warning return { stroke: "#22c55e", text: "text-green-600" }; // Hijau — normal }; const colors = getColor(); return ( <div className="flex flex-col items-center"> <div className="relative" style={{ width: size, height: size }}> <svg width={size} height={size} className="-rotate-90" viewBox={`0 0 ${size} ${size}`} > {/* Background circle */} <circle cx={size / 2} cy={size / 2} r={radius} fill="none" stroke="#e2e8f0" strokeWidth={strokeWidth} /> {/* Value circle (animated) */} <circle cx={size / 2} cy={size / 2} r={radius} fill="none" stroke={colors.stroke} strokeWidth={strokeWidth} strokeLinecap="round" strokeDasharray={circumference} strokeDashoffset={offset} className="transition-all duration-1000 ease-out" /> </svg> {/* Value text di tengah */} <div className="absolute inset-0 flex flex-col items-center justify-center"> <span className={cn("text-3xl font-bold", colors.text)}> {Math.round(clampedValue)}% </span> </div> </div> {/* Label di bawah gauge */} <span className="mt-2 text-sm font-medium text-slate-600">{label}</span> </div> ); }

💡 Tips: SVG gauge itu lebih performant daripada canvas buat hal simple kayak ini. Nggak perlu requestAnimationFrame, cukup CSS transition duration-1000 buat smooth animation saat value berubah.

⚠️ Pitfall: Jangan lupa -rotate-90 di SVG. Default SVG circle mulai dari posisi 3 o'clock (kanan). Rotate -90° bikin dia mulai dari 12 o'clock (atas) — yang more natural buat gauge.

5.3 Process Table Component

Buat src/components/system/process-table.tsx:

// src/components/system/process-table.tsx "use client"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from "@/components/ui/table"; import { cn } from "@/lib/utils"; interface Process { pid: number; name: string; cpu: number; mem: number; } interface ProcessTableProps { processes: Process[]; } export function ProcessTable({ processes }: ProcessTableProps) { return ( <Card className="hover:shadow-md transition-shadow duration-200"> <CardHeader className="pb-3"> <CardTitle className="text-base font-semibold text-slate-900"> ⚙️ Proses Aktif </CardTitle> </CardHeader> <CardContent> <Table> <TableHeader> <TableRow> <TableHead className="w-16">PID</TableHead> <TableHead>Proses</TableHead> <TableHead className="w-24 text-right">CPU %</TableHead> <TableHead className="w-24 text-right">MEM %</TableHead> </TableRow> </TableHeader> <TableBody> {processes.map((proc) => ( <TableRow key={proc.pid}> <TableCell className="font-mono text-xs text-slate-400"> {proc.pid} </TableCell> <TableCell className="font-medium text-sm"> {proc.name} </TableCell> <TableCell className="text-right"> <CPUBadge value={proc.cpu} /> </TableCell> <TableCell className="text-right"> <MEMBadge value={proc.mem} /> </TableCell> </TableRow> ))} </TableBody> </Table> </CardContent> </Card> ); } /** * Badge warna-warni buat CPU usage */ function CPUBadge({ value }: { value: number }) { const color = value >= 10 ? "bg-red-100 text-red-700" : value >= 5 ? "bg-amber-100 text-amber-700" : "bg-green-100 text-green-700"; return ( <span className={cn( "inline-flex items-center justify-center px-2 py-0.5 rounded-md text-xs font-mono font-bold", color )} > {value.toFixed(1)} </span> ); } /** * Badge warna-warni buat Memory usage */ function MEMBadge({ value }: { value: number }) { const color = value >= 15 ? "bg-red-100 text-red-700" : value >= 8 ? "bg-amber-100 text-amber-700" : "bg-blue-100 text-blue-700"; return ( <span className={cn( "inline-flex items-center justify-center px-2 py-0.5 rounded-md text-xs font-mono font-bold", color )} > {value.toFixed(1)} </span> ); }

5.4 System Monitor Page

Buat src/app/system/page.tsx:

// src/app/system/page.tsx "use client"; import { useState, useEffect, useCallback } from "react"; import { Gauge } from "@/components/system/gauge"; import { ProcessTable } from "@/components/system/process-table"; import { Card, CardContent } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { RefreshCw, Activity } from "lucide-react"; interface SystemData { metrics: { cpu: number; ram: number; disk: number }; processes: Array<{ pid: number; name: string; cpu: number; mem: number; }>; uptime: string; } // Interval polling — 5 detik const POLL_INTERVAL = 5000; export default function SystemPage() { const [data, setData] = useState<SystemData | null>(null); const [loading, setLoading] = useState(true); const [polling, setPolling] = useState(true); const [lastUpdate, setLastUpdate] = useState<Date | null>(null); const fetchSystem = useCallback(async () => { try { const res = await fetch("/api/system"); const json = await res.json(); setData(json); setLastUpdate(new Date()); } catch (err) { console.error("Gagal fetch system:", err); } finally { setLoading(false); } }, []); // Initial fetch + polling useEffect(() => { fetchSystem(); if (polling) { const interval = setInterval(fetchSystem, POLL_INTERVAL); return () => clearInterval(interval); } }, [polling, fetchSystem]); // Loading state if (loading || !data) { return ( <div className="space-y-6 animate-pulse"> <div className="grid grid-cols-1 md:grid-cols-3 gap-6"> {[...Array(3)].map((_, i) => ( <div key={i} className="h-48 bg-slate-200 rounded-xl" /> ))} </div> <div className="h-96 bg-slate-200 rounded-xl" /> </div> ); } return ( <div className="space-y-6"> {/* Header controls */} <div className="flex items-center justify-between"> <div className="flex items-center gap-2 text-sm text-slate-500"> <Activity size={14} className={polling ? "text-green-500 animate-pulse" : "text-slate-400"} /> <span> {polling ? "Auto-refresh aktif (5 detik)" : "Polling paused"} </span> {lastUpdate && ( <span className="text-xs text-slate-400"> — Terakhir update:{" "} {lastUpdate.toLocaleTimeString("id-ID", { timeZone: "Asia/Makassar" })} </span> )} </div> <div className="flex gap-2"> <Button variant="outline" size="sm" onClick={() => setPolling(!polling)} className="gap-2" > {polling ? ( <> <span className="w-2 h-2 rounded-full bg-green-500 animate-pulse" /> Pause </> ) : ( "Resume" )} </Button> <Button variant="outline" size="sm" onClick={fetchSystem} className="gap-2" > <RefreshCw size={14} /> Refresh </Button> </div> </div> {/* Gauge section */} <div className="grid grid-cols-1 md:grid-cols-3 gap-6"> <Card> <CardContent className="p-6 flex flex-col items-center"> <Gauge value={data.metrics.cpu} label="CPU Usage" /> </CardContent> </Card> <Card> <CardContent className="p-6 flex flex-col items-center"> <Gauge value={data.metrics.ram} label="RAM Usage" /> </CardContent> </Card> <Card> <CardContent className="p-6 flex flex-col items-center"> <Gauge value={data.metrics.disk} label="Disk Usage" /> </CardContent> </Card> </div> {/* Uptime info */} <Card> <CardContent className="p-4 flex items-center gap-3"> <span className="text-sm text-slate-500">Uptime:</span> <span className="text-sm font-mono font-bold text-slate-900"> {data.uptime} </span> </CardContent> </Card> {/* Process table */} <ProcessTable processes={data.processes} /> </div> ); }

5.5 Data Polling Sequence Diagram

participant User
participant User

💡 Tips: Gunakan useCallback buat fetchSystem biar nggak bikin re-render tak terbatas di useEffect dependency array. Tanpa useCallback, function baru dibuat tiap render → useEffect trigger ulang terus → infinite loop!

⚠️ Pitfall: Jangan polling terlalu cepat (< 2 detik) ke API route yang nge-fetch system data. Bisa bikin server overload. 5 detik itu sweet spot — cukup realtime tapi nggak bikin server nangis.


Part 5 selesai! System monitor dengan gauge SVG & tabel proses sudah jadi. Lanjut ke Part 6.


Ada Pertanyaan? Yuk Ngobrol!

Butuh bantuan setup OpenClaw, konsultasi IT, atau mau diskusi project engineering? Book a call langsung — gratis.

Book a Call — Gratis

via Cal.com • WITA (UTC+8)

F

Zainul Fanani

Founder, Radian Group. Engineering & tech enthusiast.

Radian Group

Engineering Excellence Across Indonesia

Perusahaan

  • CV Radian Fokus Mandiri — Balikpapan
  • PT UNO Solusi Teknik — Balikpapan
  • PT Reka Formasi Elektrika — Jakarta
  • PT Raya Fokus Solusi — Sidoarjo
© 2026 Radian Group. All rights reserved.