📎 Source:openclaw-sumopod — view on GitHub & star ⭐
🚀 Membangun AI Agent Dashboard — Tutorial Lengkap (Bagian 1)
Next.js 14 + Tailwind CSS + shadcn/ui + Recharts Dari nol sampai dashboard yang bisa dipake buat monitor AI agent kamu. Bahasa Indonesia, newbie-friendly, full code — tinggal copy-paste.
📦 PART 1: Setup & Foundation
Oke, sebelum kita mulai ngoding, kita perlu setup foundation dulu. Bayangin kayak bangun rumah — fondasi harus kuat dulu sebelum pasang atap.
1.1 Buat Project Next.js 14
Buka terminal, lalu jalankan:
npx create-next-app@latest radit-dashboard --typescript --tailwind --app --src-dir --no-eslint
Nanti dia nanya beberapa hal, jawab seperti ini:
| Pertanyaan | Jawab |
|---|---|
| Would you like to use import alias? | Yes (@/*) |
Tunggu sampai selesai, lalu masuk ke folder project:
cd radit-dashboard
💡 Tips: Pastikan Node.js versi 18+ terinstall. Cek dengan
node -v. Kalau belum, install pakainvm install 18dulu.
1.2 Install Dependencies
Kita butuh beberapa library tambahan:
# shadcn/ui dependencies
npm install class-variance-authority clsx tailwind-merge lucide-react
# Recharts buat chart
npm install recharts
# shadcn/ui components (nanti kita install per component)
npx shadcn-ui@latest init
# Sonner untuk toast notification
npx shadcn-ui@latest add sonner
npx shadcn-ui@latest add card
npx shadcn-ui@latest add button
npx shadcn-ui@latest add badge
npx shadcn-ui@latest add input
npx shadcn-ui@latest add select
npx shadcn-ui@latest add table
npx shadcn-ui@latest add textarea
npx shadcn-ui@latest add dropdown-menu
npx shadcn-ui@latest add tooltip
npx shadcn-ui@latest add avatar
npx shadcn-ui@latest add separator
npx shadcn-ui@latest add scroll-area
⚠️ Pitfall: Kalau
shadcn-uicommand nggak kerja, cobanpx shadcn@latest add ...(tanpa-ui). shadcn sempat ganti nama package-nya.
1.3 Konfigurasi Tailwind CSS
Buka tailwind.config.ts dan replace isinya:
// tailwind.config.ts
import type { Config } from "tailwindcss";
const config: Config = {
darkMode: "class",
content: [
"./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
"./src/components/**/*.{js,ts,jsx,tsx,mdx}",
"./src/app/**/*.{js,ts,jsx,tsx,mdx}",
],
theme: {
extend: {
colors: {
// Warna utama — hijau khas dashboard agent
primary: {
50: "#f0fdf4",
100: "#dcfce7",
200: "#bbf7d0",
300: "#86efac",
400: "#4ade80",
500: "#22c55e",
600: "#16a34a",
700: "#15803d",
800: "#166534",
900: "#14532d",
950: "#052e16",
DEFAULT: "#22c55e",
},
// Accent — gold premium
accent: {
50: "#fffbeb",
100: "#fef3c7",
200: "#fde68a",
300: "#fcd34d",
400: "#fbbf24",
500: "#f59e0b",
600: "#d97706",
700: "#b45309",
800: "#92400e",
900: "#78350f",
DEFAULT: "#fbbf24",
},
// Sidebar dark
sidebar: {
DEFAULT: "#0f172a",
hover: "#1e293b",
active: "#334155",
},
},
fontFamily: {
sans: ["Inter", "system-ui", "sans-serif"],
mono: ["JetBrains Mono", "Fira Code", "monospace"],
},
},
},
plugins: [require("tailwindcss-animate")],
};
export default config;
💡 Tips: Warna hijau = fresh & techy, gold = premium & trustworthy. Kombinasi ini enak dilihat di dashboard yang mostly gelap.
1.4 Global Styles
Buka src/app/globals.css dan replace:
/* src/app/globals.css */
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 248 250 252; /* slate-50 */
--foreground: 15 23 42; /* slate-900 */
--card: 255 255 255;
--card-foreground: 15 23 42;
--popover: 255 255 255;
--popover-foreground: 15 23 42;
--primary: 34 197 94; /* green-500 */
--primary-foreground: 255 255 255;
--secondary: 241 245 249; /* slate-100 */
--secondary-foreground: 15 23 42;
--muted: 241 245 249;
--muted-foreground: 100 116 139; /* slate-500 */
--accent: 251 191 36; /* amber-400 */
--accent-foreground: 15 23 42;
--destructive: 239 68 68;
--destructive-foreground: 255 255 255;
--border: 226 232 240; /* slate-200 */
--input: 226 232 240;
--ring: 34 197 94;
--radius: 0.75rem;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground font-sans antialiased;
}
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
@apply bg-slate-300 rounded-full;
}
::-webkit-scrollbar-thumb:hover {
@apply bg-slate-400;
}
/* Animasi gauge SVG */
@keyframes gauge-fill {
from {
stroke-dashoffset: var(--gauge-circumference);
}
to {
stroke-dashoffset: var(--gauge-offset);
}
}
.gauge-animated {
animation: gauge-fill 1.5s ease-out forwards;
}
1.5 Utility Functions
Buat file src/lib/utils.ts:
// src/lib/utils.ts
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
/**
* Merge Tailwind classes tanpa konflik
* shadcn/ui standard utility
*/
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
/**
* Format angka dengan ribuan separator
* 12345 → "12.345"
*/
export function formatNumber(num: number): string {
return new Intl.NumberFormat("id-ID").format(num);
}
/**
* Format bytes ke KB/MB/GB
*/
export function formatBytes(bytes: number): string {
if (bytes === 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB", "TB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
}
/**
* Format durasi detik ke jam:menit:detik
*/
export function formatDuration(seconds: number): string {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = Math.floor(seconds % 60);
if (h > 0) return `${h}j ${m}m`;
if (m > 0) return `${m}m ${s}d`;
return `${s}d`;
}
/**
* Format tanggal ke format Indo
*/
export function formatDate(date: Date | string): string {
return new Intl.DateTimeFormat("id-ID", {
day: "numeric",
month: "short",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
}).format(new Date(date));
}
1.6 Folder Structure
Buat semua folder dan file kosong dulu biar gampang navigasi:
# Components
mkdir -p src/components/layout
mkdir -p src/components/dashboard
mkdir -p src/components/briefing
mkdir -p src/components/system
mkdir -p src/components/sessions
mkdir -p src/components/skills
mkdir -p src/components/ui # shadcn/ui taruh sini (auto)
# Library
mkdir -p src/lib
# API routes
mkdir -p src/app/api/status
mkdir -p src/app/api/briefing
mkdir -p src/app/api/system
mkdir -p src/app/api/sessions
mkdir -p src/app/api/skills
# Pages
mkdir -p src/app/briefing
mkdir -p src/app/system
mkdir -p src/app/sessions
mkdir -p src/app/skills
mkdir -p src/app/logs
mkdir -p src/app/schedule
mkdir -p src/app/models
mkdir -p src/app/settings
Struktur folder final:
src/
├── app/
│ ├── layout.tsx ← Root layout (import Shell)
│ ├── page.tsx ← Dashboard home
│ ├── globals.css
│ ├── briefing/page.tsx ← Morning briefing
│ ├── system/page.tsx ← System monitor
│ ├── sessions/page.tsx ← Session manager
│ ├── skills/page.tsx ← Skills hub
│ ├── logs/page.tsx
│ ├── schedule/page.tsx
│ ├── models/page.tsx
│ ├── settings/page.tsx
│ └── api/
│ ├── status/route.ts
│ ├── briefing/route.ts
│ ├── system/route.ts
│ ├── sessions/route.ts
│ └── skills/route.ts
├── components/
│ ├── ui/ ← shadcn/ui (auto-generated)
│ ├── layout/
│ │ ├── sidebar.tsx
│ │ ├── header.tsx
│ │ └── shell.tsx
│ ├── dashboard/
│ │ ├── stats-grid.tsx
│ │ ├── usage-chart.tsx
│ │ ├── activity-feed.tsx
│ │ └── real-time-clock.tsx
│ ├── briefing/
│ │ └── briefing-card.tsx
│ ├── system/
│ │ ├── gauge.tsx
│ │ └── process-table.tsx
│ ├── sessions/
│ │ ├── session-table.tsx
│ │ └── session-chart.tsx
│ └── skills/
│ ├── skill-card.tsx
│ ├── skill-editor.tsx
│ └── skill-audit.tsx
└── lib/
└── utils.ts
💡 Tips: Rapihin folder dari awal. Trust me, pas project udah gede, structure yang rapi itu nyelamatkan nyawa.
1.7 Architecture Overview
Ini gambaran besar arsitektur dashboard kita:
Penjelasan singkat:
- Client = Browser user yang render React components
- Next.js = Server-side rendering + API routes sebagai proxy ke backend
- External = Data asli dari AI agent, sistem, dan Gemini API
⚠️ Pitfall: Jangan taruh API key di client-side code! Semua yang butuh secret key harus lewat API route (
src/app/api/), bukan langsung di component.
✅ Part 1 selesai! Foundation udah siap. Lanjut ke Part 2 — kita bangun layout & navigasi.
🏗️ PART 2: Layout & Navigation
Nah, sekarang kita bangun "kerangka" dashboard — sidebar, header, dan shell yang bakal nampung semua page. Ini kayak pasang dinding & pintu rumah.
2.1 Sidebar Component
Buat file src/components/layout/sidebar.tsx:
// src/components/layout/sidebar.tsx
"use client";
import { useState } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { cn } from "@/lib/utils";
import {
Home,
Mail,
Monitor,
MessageSquare,
Zap,
Calendar,
FileText,
Brain,
Settings,
ChevronLeft,
Bot,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
// Daftar navigasi sidebar
const navItems = [
{ href: "/", label: "Home", icon: Home },
{ href: "/briefing", label: "Briefing", icon: Mail },
{ href: "/system", label: "System", icon: Monitor },
{ href: "/sessions", label: "Sessions", icon: MessageSquare },
{ href: "/skills", label: "Skills", icon: Zap },
{ href: "/schedule", label: "Schedule", icon: Calendar },
{ href: "/logs", label: "Logs", icon: FileText },
{ href: "/models", label: "Models", icon: Brain },
{ href: "/settings", label: "Settings", icon: Settings },
];
interface SidebarProps {
collapsed: boolean;
onToggle: () => void;
mobileOpen: boolean;
onMobileClose: () => void;
}
export function Sidebar({
collapsed,
onToggle,
mobileOpen,
onMobileClose,
}: SidebarProps) {
const pathname = usePathname();
// Cek apakah route aktif (termasuk nested routes)
const isActive = (href: string) => {
if (href === "/") return pathname === "/";
return pathname.startsWith(href);
};
const sidebarContent = (
<div className="flex flex-col h-full bg-sidebar text-white">
{/* Logo section */}
<div className="flex items-center gap-3 px-4 h-16 border-b border-slate-700">
<div className="flex items-center justify-center w-9 h-9 rounded-lg bg-primary text-white font-bold text-lg shrink-0">
<Bot size={22} />
</div>
{/* Text hidden kalau sidebar collapsed (desktop) */}
{!collapsed && (
<div className="flex flex-col overflow-hidden">
<span className="text-base font-bold tracking-tight whitespace-nowrap">
Radit Dashboard
</span>
<span className="text-[10px] text-slate-400 whitespace-nowrap">
AI Agent Monitor
</span>
</div>
)}
</div>
{/* Navigation items */}
<ScrollArea className="flex-1 py-3">
<nav className="space-y-1 px-3">
{navItems.map((item) => {
const Icon = item.icon;
const active = isActive(item.href);
// Kalau collapsed, tampilkan tooltip
const linkContent = (
<Link
href={item.href}
onClick={onMobileClose}
className={cn(
"flex items-center gap-3 px-3 py-2.5 rounded-lg transition-all duration-200",
"text-sm font-medium",
active
? "bg-primary text-white shadow-lg shadow-primary/20"
: "text-slate-300 hover:bg-sidebar-hover hover:text-white",
collapsed && "justify-center px-2"
)}
>
<Icon size={20} className="shrink-0" />
{!collapsed && <span>{item.label}</span>}
{/* Active indicator dot */}
{active && !collapsed && (
<span className="ml-auto w-1.5 h-1.5 rounded-full bg-white" />
)}
</Link>
);
// Desktop collapsed mode: wrap pake tooltip
if (collapsed) {
return (
<TooltipProvider key={item.href} delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>{linkContent}</TooltipTrigger>
<TooltipContent side="right" className="font-medium">
{item.label}
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
return <div key={item.href}>{linkContent}</div>;
})}
</nav>
</ScrollArea>
{/* Collapse toggle (desktop only) */}
<div className="hidden lg:flex items-center justify-center p-3 border-t border-slate-700">
<Button
variant="ghost"
size="sm"
onClick={onToggle}
className="text-slate-400 hover:text-white hover:bg-sidebar-hover w-full"
>
<ChevronLeft
size={18}
className={cn(
"transition-transform duration-300",
collapsed && "rotate-180"
)}
/>
{!collapsed && <span className="ml-2 text-xs">Collapse</span>}
</Button>
</div>
</div>
);
return (
<>
{/* ====== MOBILE: Overlay sidebar ====== */}
{mobileOpen && (
<div className="lg:hidden fixed inset-0 z-50">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
onClick={onMobileClose}
/>
{/* Sidebar panel */}
<div className="relative w-64 h-full shadow-2xl animate-in slide-in-from-left duration-200">
{sidebarContent}
</div>
</div>
)}
{/* ====== DESKTOP: Fixed sidebar ====== */}
<aside
className={cn(
"hidden lg:block fixed left-0 top-0 h-full z-40 transition-all duration-300 border-r border-slate-800",
collapsed ? "w-[68px]" : "w-64"
)}
>
{sidebarContent}
</aside>
</>
);
}
💡 Tips:
usePathname()dari Next.js itu cara paling gampang detect active route. Lebih simpel daripada bikin custom router logic.
⚠️ Pitfall: Jangan lupa
"use client"di atas setiap component yang pake hooks (useState, useEffect, usePathname). Lupa = error hydration.
2.2 Header Component
Buat src/components/layout/header.tsx:
// src/components/layout/header.tsx
"use client";
import { useState, useEffect } from "react";
import { usePathname } from "next/navigation";
import { Menu, Bell, Search } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
// Mapping route ke judul halaman
const pageTitles: Record<string, string> = {
"/": "Dashboard",
"/briefing": "Morning Briefing",
"/system": "System Monitor",
"/sessions": "Sessions",
"/skills": "Skills Hub",
"/schedule": "Schedule",
"/logs": "Activity Logs",
"/models": "Models",
"/settings": "Settings",
};
interface HeaderProps {
onMobileMenuClick: () => void;
sidebarCollapsed: boolean;
}
export function Header({
onMobileMenuClick,
sidebarCollapsed,
}: HeaderProps) {
const pathname = usePathname();
const [currentTime, setCurrentTime] = useState(new Date());
const [searchOpen, setSearchOpen] = useState(false);
// Update jam setiap detik
useEffect(() => {
const timer = setInterval(() => setCurrentTime(new Date()), 1000);
return () => clearInterval(timer);
}, []);
// Ambil judul halaman dari pathname
const pageTitle = pageTitles[pathname] || "Dashboard";
// Format jam Indonesia (WITA)
const timeString = currentTime.toLocaleTimeString("id-ID", {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
timeZone: "Asia/Makassar",
});
const dateString = currentTime.toLocaleDateString("id-ID", {
weekday: "long",
day: "numeric",
month: "long",
year: "numeric",
timeZone: "Asia/Makassar",
});
return (
<header
className={cn(
"sticky top-0 z-30 h-16 bg-white/80 backdrop-blur-md border-b border-slate-200",
"flex items-center justify-between px-4 md:px-6",
"transition-all duration-300",
sidebarCollapsed ? "lg:pl-[84px]" : "lg:pl-[280px]"
)}
>
{/* Kiri: Hamburger + Page title */}
<div className="flex items-center gap-3">
{/* Hamburger menu (mobile only) */}
<Button
variant="ghost"
size="icon"
className="lg:hidden"
onClick={onMobileMenuClick}
>
<Menu size={22} />
</Button>
<div>
<h1 className="text-lg md:text-xl font-bold text-slate-900">
{pageTitle}
</h1>
<p className="text-xs text-slate-500 hidden sm:block">
{dateString}
</p>
</div>
</div>
{/* Kanan: Search, Clock, Notifications, Avatar */}
<div className="flex items-center gap-2 md:gap-4">
{/* Search bar (desktop) */}
{searchOpen ? (
<div className="hidden md:flex items-center">
<Input
placeholder="Cari sesuatu..."
className="w-56 h-9"
autoFocus
onBlur={() => setSearchOpen(false)}
/>
</div>
) : (
<Button
variant="ghost"
size="icon"
className="hidden md:flex"
onClick={() => setSearchOpen(true)}
>
<Search size={18} className="text-slate-500" />
</Button>
)}
{/* Jam real-time */}
<div className="hidden sm:flex flex-col items-end">
<span className="text-sm font-mono font-bold text-slate-700">
{timeString}
</span>
<span className="text-[10px] text-slate-400">WITA</span>
</div>
{/* Notification bell */}
<Button variant="ghost" size="icon" className="relative">
<Bell size={18} className="text-slate-500" />
{/* Badge notification */}
<Badge className="absolute -top-1 -right-1 h-4 w-4 p-0 flex items-center justify-center text-[10px] bg-red-500 border-0">
3
</Badge>
</Button>
{/* User avatar */}
<Avatar className="h-8 w-8">
<AvatarImage src="/avatar.png" alt="User" />
<AvatarFallback className="bg-primary text-white text-xs font-bold">
RF
</AvatarFallback>
</Avatar>
</div>
</header>
);
}
// Helper cn (import dari utils)
import { cn } from "@/lib/utils";
⚠️ Pitfall: Header padding kudu sync sama sidebar width. Kalau sidebar
w-64, header padding haruslg:pl-[280px](256px + 24px gap). Nggak sync = content ketutupan sidebar.
2.3 Shell Component (Layout Wrapper)
Buat src/components/layout/shell.tsx — ini wrapper utama yang nge-wrap sidebar + header + content:
// src/components/layout/shell.tsx
"use client";
import { useState } from "react";
import { cn } from "@/lib/utils";
import { Sidebar } from "./sidebar";
import { Header } from "./header";
interface ShellProps {
children: React.ReactNode;
}
export function Shell({ children }: ShellProps) {
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [mobileOpen, setMobileOpen] = useState(false);
return (
<div className="min-h-screen bg-slate-50">
{/* Sidebar */}
<Sidebar
collapsed={sidebarCollapsed}
onToggle={() => setSidebarCollapsed(!sidebarCollapsed)}
mobileOpen={mobileOpen}
onMobileClose={() => setMobileOpen(false)}
/>
{/* Header */}
<Header
onMobileMenuClick={() => setMobileOpen(true)}
sidebarCollapsed={sidebarCollapsed}
/>
{/* Main content area */}
<main
className={cn(
"p-4 md:p-6 transition-all duration-300",
sidebarCollapsed ? "lg:ml-[84px]" : "lg:ml-[272px]"
)}
>
{children}
</main>
</div>
);
}
2.4 Update Root Layout
Replace src/app/layout.tsx:
// src/app/layout.tsx
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
import { Shell } from "@/components/layout/shell";
import { Toaster } from "sonner";
const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = {
title: "Radit Dashboard — AI Agent Monitor",
description: "Dashboard monitoring untuk AI agent system",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="id">
<body className={inter.className}>
{/* Toast notification provider */}
<Toaster
position="bottom-right"
richColors
closeButton
toastOptions={{
duration: 4000,
}}
/>
{/* Main layout shell */}
<Shell>{children}</Shell>
</body>
</html>
);
}
2.5 Component Hierarchy Diagram
2.6 Navigation State Diagram
💡 Tips: Desktop sidebar collapsed itu cuma 68px — pas banget buat ikon aja. Di mode ini, tooltip muncul on-hover buat kasih tau label-nya apa.
⚠️ Pitfall: Di mobile, jangan lupa close sidebar pas navigasi. User klik nav → sidebar tutup otomatis → dia langsung lihat halaman baru. Nggak enak kalau sidebar numpuk di atas content.
✅ Part 2 selesai! Layout & navigasi sudah jadi. Lanjut ke Part 3 — Dashboard Home.