📎 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
💡 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-pulsedari Tailwind.
⚠️ Pitfall:
setIntervaldiuseEffectWAJIB 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
Skeletondari 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
💡 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 transitionduration-1000buat smooth animation saat value berubah.
⚠️ Pitfall: Jangan lupa
-rotate-90di 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
💡 Tips: Gunakan
useCallbackbuatfetchSystembiar nggak bikin re-render tak terbatas diuseEffectdependency array. TanpauseCallback, function baru dibuat tiap render →useEffecttrigger 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.