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.
25
1 bulan lalu
Zainul Fanani
AI Agent Dashboard Bagian 2: Dashboard, Briefing & System Monitor
📅 28 Mar 2026🤍 0 👁 0 🔗 0

📎 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:

typescript
// 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:

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:

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:

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:

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:

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:

typescript
// 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:

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:

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:

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:

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:

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:

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:

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:

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:

typescript
// 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:

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:

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:

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)

📬 Subscribe Newsletter

Free

Dapat alert setiap ada artikel baru langsung ke inbox kamu. Free, no spam. 🚀

👥 Join 0+ engineers & tech enthusiasts

F

Zainul Fanani

Founder, Radian Group. Engineering & tech enthusiast.

💬 Komentar

Catatan Fanani

Ngutak-ngatik teknologi, nulis pengalaman.

Perusahaan

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