Tech

AI Agent Dashboard Bagian 1: Setup, Layout & Navigasi

Part 1 — Fondasi dashboard AI agent dari nol. Next.js 14, Tailwind, shadcn/ui, dan arsitektur layout yang scalable.

👤 Zainul Fanani📅 28 Maret 2026⏱ 1 min read

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

PertanyaanJawab
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 pakai nvm install 18 dulu.

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-ui command nggak kerja, coba npx 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:

subgraph Client Client Browser
subgraph Client Client Browser

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 harus lg: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

RootLayoutRootLayoutbrapplayouttsx
RootLayoutRootLayoutbrapplayouttsx

2.6 Navigation State Diagram

stateDiagramv2
stateDiagramv2

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


Ada Pertanyaan? Yuk Ngobrol!

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

Book a Call — Gratis

via Cal.com • WITA (UTC+8)

F

Zainul Fanani

Founder, Radian Group. Engineering & tech enthusiast.

Radian Group

Engineering Excellence Across Indonesia

Perusahaan

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