Tech

AI Agent Dashboard Bagian 3: Sessions, Skills & Logs

Part 3 — Track sessions, manage skills hub, schedule cron jobs, dan monitoring logs.

👤 Zainul Fanani📅 28 Maret 2026⏱ 1 min read

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

💬 PART 6: Sessions Page

Halaman ini menampilkan session aktif AI agent — siapa yang chat, model apa yang dipake, berapa lama, dan status-nya. Plus chart distribusi session per model.

6.1 API Route — Sessions Data

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

// src/app/api/sessions/route.ts import { NextResponse } from "next/server"; export interface SessionItem { id: string; agent: string; model: string; messages: number; duration: number; // dalam detik status: "active" | "idle" | "completed"; startedAt: string; } export interface SessionsData { sessions: SessionItem[]; modelDistribution: Array<{ model: string; count: number }>; totalToday: number; totalActive: number; } const mockData: SessionsData = { sessions: [ { id: "radit:main", agent: "Radit", model: "GLM-5 Turbo", messages: 47, duration: 3420, status: "active", startedAt: "2026-03-28T08:30:00+08:00", }, { id: "raka:content", agent: "Raka", model: "GPT-4o", messages: 23, duration: 1200, status: "active", startedAt: "2026-03-28T09:15:00+08:00", }, { id: "rama:analytics", agent: "Rama", model: "DeepSeek V3", messages: 12, duration: 600, status: "idle", startedAt: "2026-03-28T07:00:00+08:00", }, { id: "rafi:deploy", agent: "Rafi", model: "GLM-5 Turbo", messages: 8, duration: 300, status: "completed", startedAt: "2026-03-28T06:45:00+08:00", }, { id: "radit:heartbeat", agent: "Radit", model: "GLM-5 Turbo", messages: 3, duration: 45, status: "completed", startedAt: "2026-03-28T08:00:00+08:00", }, ], modelDistribution: [ { model: "GLM-5 Turbo", count: 45 }, { model: "GPT-4o", count: 28 }, { model: "DeepSeek V3", count: 18 }, { model: "Gemini Pro", count: 8 }, { model: "Claude 3.5", count: 5 }, ], totalToday: 104, totalActive: 2, }; export async function GET() { try { // TODO: Fetch dari agent session manager return NextResponse.json(mockData); } catch (error) { return NextResponse.json( { error: "Gagal fetch sessions" }, { status: 500 } ); } }

6.2 Session Table Component

Buat src/components/sessions/session-table.tsx:

// src/components/sessions/session-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 { Badge } from "@/components/ui/badge"; import { cn, formatDuration } from "@/lib/utils"; interface Session { id: string; agent: string; model: string; messages: number; duration: number; status: "active" | "idle" | "completed"; startedAt: string; } interface SessionTableProps { sessions: Session[]; } // Konfigurasi status badge const statusConfig = { active: { label: "Active", color: "bg-green-100 text-green-700 border-green-200" }, idle: { label: "Idle", color: "bg-amber-100 text-amber-700 border-amber-200" }, completed: { label: "Done", color: "bg-slate-100 text-slate-500 border-slate-200" }, }; export function SessionTable({ sessions }: SessionTableProps) { return ( <Card className="hover:shadow-md transition-shadow duration-200"> <CardHeader className="pb-3"> <CardTitle className="text-base font-semibold text-slate-900"> 📋 Sessions Aktif </CardTitle> </CardHeader> <CardContent> <div className="overflow-x-auto"> <Table> <TableHeader> <TableRow> <TableHead>Agent</TableHead> <TableHead>Session ID</TableHead> <TableHead>Model</TableHead> <TableHead className="text-right">Messages</TableHead> <TableHead className="text-right">Duration</TableHead> <TableHead>Status</TableHead> </TableRow> </TableHeader> <TableBody> {sessions.map((session) => { const status = statusConfig[session.status]; return ( <TableRow key={session.id}> {/* Agent name */} <TableCell className="font-medium text-sm"> {session.agent} </TableCell> {/* Session ID */} <TableCell className="font-mono text-xs text-slate-400"> {session.id} </TableCell> {/* Model */} <TableCell> <Badge variant="outline" className="text-xs"> {session.model} </Badge> </TableCell> {/* Messages count */} <TableCell className="text-right font-mono text-sm"> {session.messages} </TableCell> {/* Duration */} <TableCell className="text-right font-mono text-sm text-slate-500"> {formatDuration(session.duration)} </TableCell> {/* Status */} <TableCell> <Badge variant="outline" className={cn("text-xs border", status.color)} > <span className="mr-1"> {session.status === "active" && "🟢"} {session.status === "idle" && "🟡"} {session.status === "completed" && "⚪"} </span> {status.label} </Badge> </TableCell> </TableRow> ); })} </TableBody> </Table> </div> </CardContent> </Card> ); }

6.3 Session Chart Component

Buat src/components/sessions/session-chart.tsx:

// src/components/sessions/session-chart.tsx "use client"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Cell, } from "recharts"; interface SessionChartProps { data: Array<{ model: string; count: number }>; } // Warna beda-beda buat tiap bar const BAR_COLORS = ["#22c55e", "#3b82f6", "#f59e0b", "#8b5cf6", "#ec4899"]; export function SessionChart({ data }: SessionChartProps) { return ( <Card className="hover:shadow-md transition-shadow duration-200"> <CardHeader className="pb-2"> <CardTitle className="text-base font-semibold text-slate-900"> 📊 Distribusi Model </CardTitle> </CardHeader> <CardContent> <div className="h-[280px] w-full"> <ResponsiveContainer width="100%" height="100%"> <BarChart data={data} margin={{ top: 10, right: 10, left: -10, bottom: 0 }} > <CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" /> <XAxis dataKey="model" tick={{ fontSize: 11, fill: "#94a3b8" }} axisLine={{ stroke: "#e2e8f0" }} tickLine={false} /> <YAxis tick={{ fontSize: 12, fill: "#94a3b8" }} axisLine={false} tickLine={false} /> <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) => [ `${value} sessions`, "Count", ]} /> <Bar dataKey="count" radius={[6, 6, 0, 0]}> {data.map((_, index) => ( <Cell key={`cell-${index}`} fill={BAR_COLORS[index % BAR_COLORS.length]} /> ))} </Bar> </BarChart> </ResponsiveContainer> </div> </CardContent> </Card> ); }

6.4 Sessions Page

Buat src/app/sessions/page.tsx:

// src/app/sessions/page.tsx "use client"; import { useState, useEffect, useCallback } from "react"; import { SessionTable } from "@/components/sessions/session-table"; import { SessionChart } from "@/components/sessions/session-chart"; import { Card, CardContent } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { RefreshCw } from "lucide-react"; import { Button } from "@/components/ui/button"; interface Session { id: string; agent: string; model: string; messages: number; duration: number; status: "active" | "idle" | "completed"; startedAt: string; } interface SessionsData { sessions: Session[]; modelDistribution: Array<{ model: string; count: number }>; totalToday: number; totalActive: number; } const REFRESH_INTERVAL = 30000; // 30 detik export default function SessionsPage() { const [data, setData] = useState<SessionsData | null>(null); const [loading, setLoading] = useState(true); const [autoRefresh, setAutoRefresh] = useState(true); const fetchSessions = useCallback(async () => { try { const res = await fetch("/api/sessions"); const json = await res.json(); setData(json); } catch (err) { console.error("Gagal fetch sessions:", err); } finally { setLoading(false); } }, []); useEffect(() => { fetchSessions(); if (autoRefresh) { const interval = setInterval(fetchSessions, REFRESH_INTERVAL); return () => clearInterval(interval); } }, [autoRefresh, fetchSessions]); if (loading || !data) { return ( <div className="space-y-6 animate-pulse"> <div className="grid grid-cols-1 md:grid-cols-3 gap-4"> {[...Array(3)].map((_, i) => ( <div key={i} className="h-24 bg-slate-200 rounded-xl" /> ))} </div> <div className="h-80 bg-slate-200 rounded-xl" /> </div> ); } return ( <div className="space-y-6"> {/* Summary cards */} <div className="grid grid-cols-1 md:grid-cols-3 gap-4"> <Card> <CardContent className="p-5 flex items-center gap-4"> <div className="p-3 rounded-xl bg-blue-50"> <span className="text-2xl">💬</span> </div> <div> <p className="text-sm text-slate-500">Total Hari Ini</p> <p className="text-2xl font-bold text-slate-900"> {data.totalToday} </p> </div> </CardContent> </Card> <Card> <CardContent className="p-5 flex items-center gap-4"> <div className="p-3 rounded-xl bg-green-50"> <span className="text-2xl">🟢</span> </div> <div> <p className="text-sm text-slate-500">Aktif Sekarang</p> <p className="text-2xl font-bold text-green-600"> {data.totalActive} </p> </div> </CardContent> </Card> <Card> <CardContent className="p-5 flex items-center gap-4"> <div className="p-3 rounded-xl bg-purple-50"> <span className="text-2xl">🤖</span> </div> <div> <p className="text-sm text-slate-500">Models</p> <p className="text-2xl font-bold text-slate-900"> {data.modelDistribution.length} </p> </div> </CardContent> </Card> </div> {/* Auto-refresh control */} <div className="flex items-center justify-between"> <div className="flex items-center gap-2 text-sm text-slate-500"> <span className={autoRefresh ? "text-green-500" : "text-slate-400"}> {autoRefresh ? "●" : "○"} </span> <span> {autoRefresh ? `Auto-refresh aktif (${REFRESH_INTERVAL / 1000} detik)` : "Auto-refresh mati"} </span> </div> <Button variant="outline" size="sm" onClick={() => setAutoRefresh(!autoRefresh)} className="gap-2" > <RefreshCw size={14} className={autoRefresh ? "animate-spin" : ""} /> {autoRefresh ? "Pause" : "Resume"} </Button> </div> {/* Table + Chart */} <div className="grid grid-cols-1 lg:grid-cols-3 gap-6"> <div className="lg:col-span-2"> <SessionTable sessions={data.sessions} /> </div> <div> <SessionChart data={data.modelDistribution} /> </div> </div> </div> ); }

6.5 Session Lifecycle State Diagram

stateDiagramv2
stateDiagramv2

💡 Tips: Session table pake font-mono buat ID & angka biar gampang dibaca. Data technical kayak PID, session ID, duration — semuanya lebih enak pake monospace font.

⚠️ Pitfall: Auto-refresh 30 detik itu cukup untuk session list. Tapi jangan lupa cleanup interval di useEffect return! Kalau component unmount tapi interval masih jalan → memory leak & error console.


Part 6 selesai! Sessions page dengan table + chart sudah siap. Lanjut ke Part 7 — Skills Hub.


⚡ PART 7: Skills Hub

Ini halaman terakhir dan paling kompleks — skill management hub. Bisa search, filter, audit, edit, dan AI-optimize skill.

7.1 API Route — Skills Data

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

// src/app/api/skills/route.ts import { NextResponse } from "next/server"; export interface SkillItem { name: string; category: string; description: string; hasSkillMd: boolean; hasScriptsDir: boolean; hasExecutePermission: boolean; issues: string[]; } export interface SkillsData { skills: SkillItem[]; categories: string[]; totalSkills: number; issuesCount: number; } // Mock skills data const mockSkills: SkillsData = { skills: [ { name: "smart-search", category: "search", description: "Web search pakai Brave API", hasSkillMd: true, hasScriptsDir: true, hasExecutePermission: true, issues: [], }, { name: "bmkg-monitor", category: "monitoring", description: "Monitor gempa dan cuaca Indonesia", hasSkillMd: true, hasScriptsDir: true, hasExecutePermission: true, issues: [], }, { name: "football-livescore", category: "entertainment", description: "Cek skor bola real-time", hasSkillMd: true, hasScriptsDir: true, hasExecutePermission: false, issues: ["scripts/ tidak punya execute permission"], }, { name: "email-summarizer", category: "communication", description: "Ringkas email otomatis", hasSkillMd: true, hasScriptsDir: false, hasExecutePermission: false, issues: ["scripts/ directory tidak ada", "scripts/ tidak punya execute permission"], }, { name: "gold-price", category: "finance", description: "Cek harga emas real-time", hasSkillMd: false, hasScriptsDir: true, hasExecutePermission: true, issues: ["SKILL.md tidak ditemukan"], }, { name: "github-deploy", category: "devops", description: "Auto-deploy dari GitHub push", hasSkillMd: true, hasScriptsDir: true, hasExecutePermission: true, issues: [], }, { name: "weather-forecast", category: "monitoring", description: "Cuaca 7 hari ke depan", hasSkillMd: true, hasScriptsDir: true, hasExecutePermission: true, issues: [], }, { name: "cron-manager", category: "automation", description: "Kelola cron jobs", hasSkillMd: true, hasScriptsDir: false, hasExecutePermission: false, issues: ["scripts/ directory tidak ada", "scripts/ tidak punya execute permission"], }, ], categories: [ "search", "monitoring", "entertainment", "communication", "finance", "devops", "automation", ], totalSkills: 8, issuesCount: 5, }; // GET — Ambil semua skills export async function GET() { return NextResponse.json(mockSkills); } // POST — Fix skill issues export async function POST(request: Request) { const body = await request.json(); const { skillName, action } = body; // TODO: Implementasi fix sesungguhnya // Contoh: chmod +x scripts/*.sh, touch SKILL.md, mkdir scripts console.log(`Fix request: ${skillName} - ${action}`); return NextResponse.json({ success: true, message: `Fixed ${action} for ${skillName}`, }); } // PUT — Update SKILL.md content export async function PUT(request: Request) { const body = await request.json(); const { skillName, content } = body; // TODO: Tulis ke file SKILL.md console.log(`Update SKILL.md for ${skillName}: ${content.length} chars`); return NextResponse.json({ success: true, message: `Updated SKILL.md for ${skillName}`, }); }

7.2 Skill Card Component

Buat src/components/skills/skill-card.tsx:

// src/components/skills/skill-card.tsx "use client"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Zap, AlertTriangle, CheckCircle2, FileText, FolderOpen, Shield, Pencil, Sparkles, Wrench, } from "lucide-react"; import { cn } from "@/lib/utils"; interface SkillItem { name: string; category: string; description: string; hasSkillMd: boolean; hasScriptsDir: boolean; hasExecutePermission: boolean; issues: string[]; } interface SkillCardProps { skill: SkillItem; onEdit: (name: string) => void; onOptimize: (name: string) => void; onFix: (name: string) => void; } // Warna badge per kategori const categoryColors: Record<string, string> = { search: "bg-blue-100 text-blue-700", monitoring: "bg-green-100 text-green-700", entertainment: "bg-purple-100 text-purple-700", communication: "bg-cyan-100 text-cyan-700", finance: "bg-amber-100 text-amber-700", devops: "bg-red-100 text-red-700", automation: "bg-indigo-100 text-indigo-700", }; export function SkillCard({ skill, onEdit, onOptimize, onFix }: SkillCardProps) { const hasIssues = skill.issues.length > 0; const allGood = !hasIssues; return ( <Card className={cn( "hover:shadow-md transition-all duration-200 border", hasIssues ? "border-amber-200" : "border-transparent" )} > <CardHeader className="pb-3"> <div className="flex items-start justify-between"> <div className="flex items-center gap-2"> <div className={cn( "p-2 rounded-lg", allGood ? "bg-green-50" : "bg-amber-50" )} > <Zap size={18} className={allGood ? "text-green-600" : "text-amber-600"} /> </div> <div> <CardTitle className="text-base font-semibold text-slate-900"> {skill.name} </CardTitle> <Badge className={cn( "text-[10px] mt-1 border-0", categoryColors[skill.category] || "bg-slate-100 text-slate-600" )} variant="outline" > {skill.category} </Badge> </div> </div> {/* Status indicator */} {allGood ? ( <CheckCircle2 size={20} className="text-green-500" /> ) : ( <AlertTriangle size={20} className="text-amber-500" /> )} </div> </CardHeader> <CardContent className="space-y-3"> {/* Deskripsi */} <p className="text-sm text-slate-600">{skill.description}</p> {/* Checklist */} <div className="grid grid-cols-3 gap-2"> <CheckItem label="SKILL.md" ok={skill.hasSkillMd} icon={FileText} /> <CheckItem label="scripts/" ok={skill.hasScriptsDir} icon={FolderOpen} /> <CheckItem label="chmod +x" ok={skill.hasExecutePermission} icon={Shield} /> </div> {/* Issues list */} {hasIssues && ( <div className="space-y-1"> {skill.issues.map((issue, i) => ( <div key={i} className="flex items-start gap-1.5 text-xs text-amber-600" > <span className="mt-0.5">⚠️</span> <span>{issue}</span> </div> ))} </div> )} {/* Action buttons */} <div className="flex gap-2 pt-1"> <Button variant="outline" size="sm" className="flex-1 gap-1 text-xs" onClick={() => onEdit(skill.name)} > <Pencil size={12} /> Edit </Button> <Button variant="outline" size="sm" className="flex-1 gap-1 text-xs" onClick={() => onOptimize(skill.name)} > <Sparkles size={12} /> AI Fix </Button> {hasIssues && ( <Button variant="outline" size="sm" className="gap-1 text-xs text-amber-600 border-amber-200 hover:bg-amber-50" onClick={() => onFix(skill.name)} > <Wrench size={12} /> Fix </Button> )} </div> </CardContent> </Card> ); } /** Checklist item kecil */ function CheckItem({ label, ok, icon: Icon, }: { label: string; ok: boolean; icon: React.ElementType; }) { return ( <div className="flex items-center gap-1.5 text-xs"> <Icon size={12} className={ok ? "text-green-500" : "text-red-400"} /> <span className={ok ? "text-slate-600" : "text-red-500 line-through"}> {label} </span> </div> ); }

7.3 Skill Audit Component

Buat src/components/skills/skill-audit.tsx:

// src/components/skills/skill-audit.tsx "use client"; import { useState } from "react"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { CheckCircle2, AlertTriangle, Search } from "lucide-react"; import { toast } from "sonner"; interface SkillItem { name: string; issues: string[]; } interface SkillAuditProps { skills: SkillItem[]; onFixAll: () => void; } export function SkillAudit({ skills, onFixAll }: SkillAuditProps) { const [auditing, setAuditing] = useState(false); const totalSkills = skills.length; const skillsWithIssues = skills.filter((s) => s.issues.length > 0); const totalIssues = skills.reduce((sum, s) => sum + s.issues.length, 0); const allClean = totalIssues === 0; async function runAudit() { setAuditing(true); // Simulasi audit process await new Promise((resolve) => setTimeout(resolve, 1500)); setAuditing(false); toast.success(`Audit selesai! ${totalIssues} issues ditemukan.`); } return ( <Card> <CardHeader className="pb-3"> <div className="flex items-center justify-between"> <CardTitle className="text-base font-semibold text-slate-900"> 🔍 Skill Audit </CardTitle> <Button variant="outline" size="sm" onClick={runAudit} disabled={auditing} className="gap-2" > <Search size={14} className={auditing ? "animate-pulse" : ""} /> {auditing ? "Scanning..." : "Run Audit"} </Button> </div> </CardHeader> <CardContent> <div className="grid grid-cols-3 gap-4 mb-4"> {/* Total */} <div className="text-center p-3 bg-slate-50 rounded-lg"> <p className="text-2xl font-bold text-slate-900">{totalSkills}</p> <p className="text-xs text-slate-500">Total Skills</p> </div> {/* Clean */} <div className="text-center p-3 bg-green-50 rounded-lg"> <p className="text-2xl font-bold text-green-600"> {totalSkills - skillsWithIssues.length} </p> <p className="text-xs text-slate-500">Clean ✅</p> </div> {/* Issues */} <div className="text-center p-3 bg-amber-50 rounded-lg"> <p className="text-2xl font-bold text-amber-600">{totalIssues}</p> <p className="text-xs text-slate-500">Issues ⚠️</p> </div> </div> {/* Skills with issues */} {skillsWithIssues.length > 0 && ( <div className="space-y-2"> <p className="text-sm font-medium text-slate-700"> Skills dengan masalah: </p> {skillsWithIssues.map((skill) => ( <div key={skill.name} className="flex items-center justify-between p-2 bg-amber-50 rounded-lg" > <div className="flex items-center gap-2"> <AlertTriangle size={14} className="text-amber-500" /> <span className="text-sm font-medium text-slate-700"> {skill.name} </span> </div> <Badge variant="outline" className="text-xs text-amber-700 border-amber-200"> {skill.issues.length} issues </Badge> </div> ))} <Button variant="outline" size="sm" onClick={onFixAll} className="w-full mt-2 gap-2 text-amber-600 border-amber-200 hover:bg-amber-50" > 🛠️ Fix All Issues </Button> </div> )} {/* All clean */} {allClean && ( <div className="text-center py-4"> <CheckCircle2 size={32} className="text-green-500 mx-auto mb-2" /> <p className="text-sm text-green-600 font-medium"> Semua skill sudah clean! 🎉 </p> </div> )} </CardContent> </Card> ); }

7.4 Skill Editor Component

Buat src/components/skills/skill-editor.tsx:

// src/components/skills/skill-editor.tsx "use client"; import { useState, useEffect } from "react"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Textarea } from "@/components/ui/textarea"; import { X, Save, Sparkles, Loader2 } from "lucide-react"; import { toast } from "sonner"; interface SkillEditorProps { skillName: string; onClose: () => void; } export function SkillEditor({ skillName, onClose }: SkillEditorProps) { const [content, setContent] = useState(""); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [optimizing, setOptimizing] = useState(false); const [originalContent, setOriginalContent] = useState(""); // Load SKILL.md content useEffect(() => { async function loadSkill() { try { const res = await fetch(`/api/skills?name=${skillName}`); const data = await res.json(); // Mock content — nanti fetch asli dari file const mockContent = `# ${skillName} ## Deskripsi Skill untuk ${skillName} — AI agent automation. ## Usage \`\`\`bash bash skills/${skillName}/scripts/run.sh \`\`\` ## Dependencies - bash - curl ## Notes - Pastikan API key sudah terkonfigurasi - Run otomatis via cron job `; setContent(mockContent); setOriginalContent(mockContent); } catch (err) { toast.error("Gagal load SKILL.md"); } finally { setLoading(false); } } loadSkill(); }, [skillName]); // Save content async function handleSave() { setSaving(true); try { const res = await fetch("/api/skills", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ skillName, content }), }); if (res.ok) { setOriginalContent(content); toast.success(`SKILL.md ${skillName} berhasil disimpan!`); } } catch { toast.error("Gagal menyimpan"); } finally { setSaving(false); } } // AI Optimize via Gemini async function handleOptimize() { setOptimizing(true); try { const res = await fetch("/api/skills/optimize", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ skillName, content }), }); const data = await res.json(); if (data.optimized) { setContent(data.optimized); toast.success("SKILL.md berhasil dioptimasi AI! ✨"); } } catch { toast.error("Gagal optimize — cek Gemini API key"); } finally { setOptimizing(false); } } const hasChanges = content !== originalContent; if (loading) { return ( <Card> <CardContent className="p-6 flex items-center justify-center"> <Loader2 size={24} className="animate-spin text-primary" /> <span className="ml-2 text-sm text-slate-500">Loading SKILL.md...</span> </CardContent> </Card> ); } return ( <Card className="border-primary/20"> <CardHeader className="pb-3"> <div className="flex items-center justify-between"> <CardTitle className="text-base font-semibold text-slate-900"> ✏️ Edit: {skillName}/SKILL.md </CardTitle> <Button variant="ghost" size="icon" onClick={onClose} className="h-8 w-8" > <X size={16} /> </Button> </div> </CardHeader> <CardContent className="space-y-3"> {/* Textarea editor */} <Textarea value={content} onChange={(e) => setContent(e.target.value)} className="min-h-[300px] font-mono text-sm" placeholder="Edit SKILL.md di sini..." /> {/* Action bar */} <div className="flex items-center justify-between"> <div className="flex gap-2"> <Button variant="default" size="sm" onClick={handleSave} disabled={saving || !hasChanges} className="gap-2" > <Save size={14} /> {saving ? "Menyimpan..." : "Simpan"} </Button> <Button variant="outline" size="sm" onClick={handleOptimize} disabled={optimizing} className="gap-2 text-purple-600 border-purple-200 hover:bg-purple-50" > {optimizing ? ( <Loader2 size={14} className="animate-spin" /> ) : ( <Sparkles size={14} /> )} {optimizing ? "Mengoptimasi..." : "AI Optimize"} </Button> </div> {hasChanges && ( <span className="text-xs text-amber-600"> ● Perubahan belum disimpan </span> )} </div> </CardContent> </Card> ); }

7.5 Skills Hub Page

Buat src/app/skills/page.tsx:

// src/app/skills/page.tsx "use client"; import { useState, useEffect, useMemo } from "react"; import { SkillCard } from "@/components/skills/skill-card"; import { SkillAudit } from "@/components/skills/skill-audit"; import { SkillEditor } from "@/components/skills/skill-editor"; import { Input } from "@/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { Button } from "@/components/ui/button"; import { Search, Plus } from "lucide-react"; import { toast } from "sonner"; interface SkillItem { name: string; category: string; description: string; hasSkillMd: boolean; hasScriptsDir: boolean; hasExecutePermission: boolean; issues: string[]; } export default function SkillsPage() { const [skills, setSkills] = useState<SkillItem[]>([]); const [categories, setCategories] = useState<string[]>([]); const [loading, setLoading] = useState(true); const [search, setSearch] = useState(""); const [categoryFilter, setCategoryFilter] = useState("all"); const [editingSkill, setEditingSkill] = useState<string | null>(null); // Fetch skills useEffect(() => { async function fetchSkills() { try { const res = await fetch("/api/skills"); const data = await res.json(); setSkills(data.skills); setCategories(data.categories); } catch (err) { toast.error("Gagal fetch skills"); } finally { setLoading(false); } } fetchSkills(); }, []); // Filter skills berdasarkan search & category const filteredSkills = useMemo(() => { return skills.filter((skill) => { const matchSearch = skill.name.toLowerCase().includes(search.toLowerCase()) || skill.description.toLowerCase().includes(search.toLowerCase()); const matchCategory = categoryFilter === "all" || skill.category === categoryFilter; return matchSearch && matchCategory; }); }, [skills, search, categoryFilter]); // Fix issues untuk satu skill async function handleFix(skillName: string) { try { const res = await fetch("/api/skills", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ skillName, action: "fix" }), }); if (res.ok) { toast.success(`Issues ${skillName} berhasil di-fix! 🛠️`); // Refresh skills const refetch = await fetch("/api/skills"); const data = await refetch.json(); setSkills(data.skills); } } catch { toast.error("Gagal fix issues"); } } // Fix all issues async function handleFixAll() { const skillsWithIssues = skills.filter((s) => s.issues.length > 0); toast.loading(`Fixing ${skillsWithIssues.length} skills...`, { id: "fix-all", }); for (const skill of skillsWithIssues) { await handleFix(skill.name); } toast.success("Semua issues berhasil di-fix! 🎉", { id: "fix-all" }); } // Edit skill function handleEdit(name: string) { setEditingSkill(name); } // AI Optimize skill function handleOptimize(name: string) { setEditingSkill(name); toast.info("Buka editor, lalu klik 'AI Optimize' ✨"); } // Loading state if (loading) { return ( <div className="space-y-6 animate-pulse"> <div className="h-12 bg-slate-200 rounded-xl" /> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> {[...Array(6)].map((_, i) => ( <div key={i} className="h-56 bg-slate-200 rounded-xl" /> ))} </div> </div> ); } return ( <div className="space-y-6"> {/* Search & filter bar */} <div className="flex flex-col sm:flex-row gap-3"> <div className="relative flex-1"> <Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" /> <Input placeholder="Cari skill..." value={search} onChange={(e) => setSearch(e.target.value)} className="pl-9" /> </div> <Select value={categoryFilter} onValueChange={setCategoryFilter}> <SelectTrigger className="w-full sm:w-48"> <SelectValue placeholder="Kategori" /> </SelectTrigger> <SelectContent> <SelectItem value="all">Semua Kategori</SelectItem> {categories.map((cat) => ( <SelectItem key={cat} value={cat}> {cat} </SelectItem> ))} </SelectContent> </Select> <Button variant="outline" className="gap-2" disabled> <Plus size={16} /> Tambah Skill </Button> </div> {/* Skill audit summary */} <SkillAudit skills={skills} onFixAll={handleFixAll} /> {/* Skill editor (kalau sedang edit) */} {editingSkill && ( <SkillEditor skillName={editingSkill} onClose={() => setEditingSkill(null)} /> )} {/* Skills grid */} <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> {filteredSkills.map((skill) => ( <SkillCard key={skill.name} skill={skill} onEdit={handleEdit} onOptimize={handleOptimize} onFix={handleFix} /> ))} </div> {/* Empty state */} {filteredSkills.length === 0 && ( <div className="text-center py-12"> <p className="text-slate-400"> {search || categoryFilter !== "all" ? "Tidak ada skill yang cocok dengan filter." : "Belum ada skills."} </p> </div> )} </div> ); }

7.6 Skill Audit & Fix Flow Diagram

StartUser buka Skills Hub  FetchGET apiskills
StartUser buka Skills Hub FetchGET apiskills

💡 Tips:useMemo buat filter skills itu penting biar nggak re-render semua card tiap kali user ngetik di search bar. Memoization = cache hasil komputasi, hanya recompute kalau dependency berubah.

⚠️ Pitfall: Toast notification dari Sonner itu fire-and-forget — nggak blocking UI. Jadi user tetap bisa ngelakuin sesuatu sambil toast muncul. Tapi jangan abuse! Maksimal 1 toast per action, jangan spam.


🎉 Wrapping Up — Part 1-7 Selesai!

Kita udah bangun:

PartHalamanKomponen Utama
1SetupNext.js 14, Tailwind, shadcn/ui, folder structure
2LayoutSidebar, Header, Shell wrapper
3DashboardStats, Chart, Activity Feed, Clock
4BriefingEmail, Calendar, Tasks, Gold, Health, Weather cards
5SystemGauge SVG, Process Table, Auto-polling
6SessionsSession Table, Bar Chart, Auto-refresh
7Skills HubSkill Cards, Search/Filter, Audit, Editor, AI Optimize

Quick Start Commands

# Setup project npx create-next-app@latest radit-dashboard --typescript --tailwind --app --src-dir --no-eslint cd radit-dashboard # Install deps npm install class-variance-authority clsx tailwind-merge lucide-react recharts npm install -D tailwindcss-animate # shadcn/ui npx shadcn@latest init npx shadcn@latest add card button badge input select table textarea scroll-area skeleton separator avatar tooltip dropdown-menu sonner # Run dev server npm run dev

Next Steps (Bagian 2)

Di bagian 2, kita bakal bahas:

  • Dark mode toggle
  • Authentication & protected routes
  • Real API integration (bukan mock data)
  • Deployment ke VPS
  • Performance optimization

💡 Tips Terakhir: Satu hal yang sering dilupakan — commit code sering-sering! Jangan nunggu semua selesai baru commit. Setiap selesai satu part → commit. Git itu asuransi, bro.


Ditulis dengan ❤️ dan ☕ oleh Radit AI AssistantTutorial ini bisa di-copy-paste langsung. Kalau ada error, cek import path dan pastikan semua dependency terinstall.

🤖 Tutorial AI Agent Dashboard — Next.js 14

Bagian 2: PART 8 — PART 14

Tutorial lengkap membangun dashboard monitoring untuk AI agent (OpenClaw).
Prasyarat: Sudah menyelesaikan Bagian 1 (PART 1-7).


📑 Daftar Isi Bagian 2

PartHalamanFitur Utama
8ScheduleCron jobs, job lifecycle
9LogsTerminal viewer, log pipeline
10ModelsModel cards, cost comparison
11Settings7 tab konfigurasi
12AnimasiFramer Motion, skeleton, toast
13API RoutesBackend Next.js API
14DeploymentPM2, Nginx, SSL

PART 8: Schedule (Cron Jobs) 🕐

Halaman schedule menampilkan semua cron job yang berjalan di AI agent. Kamu bisa melihat jadwal, status, dan mengelola job langsung dari dashboard.

Arsitektur Cron Job Lifecycle

A User Add New Job  BValidasi Input
A User Add New Job BValidasi Input

8.1 Tipe Data & API

Buat file app/schedule/types.ts:

// app/schedule/types.ts // Tipe data untuk halaman Schedule export type JobStatus = 'active' | 'disabled' | 'failed'; export interface CronJob { id: string; // Unique ID name: string; // Nama job yang mudah dibaca schedule: string; // Cron expression (contoh: "0 */6 * * *") scheduleHuman: string; // Deskripsi human-readable (contoh: "Setiap 6 jam") command: string; // Perintah yang dijalankan status: JobStatus; // Status job lastRun: string | null; // Timestamp terakhir dijalankan lastResult: 'success' | 'failed' | 'running' | null; nextRun: string | null; // Timestamp berikutnya avgDuration: number; // Rata-rata durasi dalam detik failCount: number; // Jumlah kegagalan berturut-turut createdAt: string; } export interface ScheduleStats { totalJobs: number; activeJobs: number; disabledJobs: number; failedJobs: number; }

8.2 API Route: Schedule

Buat file app/api/schedule/route.ts:

// app/api/schedule/route.ts // API endpoint untuk mengambil dan menambah cron jobs import { NextRequest, NextResponse } from 'next/server'; import { exec } from 'child_process'; import { promisify } from 'util'; import fs from 'fs/promises'; import path from 'path'; const execAsync = promisify(exec); // Path file data (simulasi — di production gunakan database) const DATA_DIR = path.join(process.cwd(), 'data'); const JOBS_FILE = path.join(DATA_DIR, 'jobs.json'); // Tipe untuk job interface CronJob { id: string; name: string; schedule: string; scheduleHuman: string; command: string; status: 'active' | 'disabled' | 'failed'; lastRun: string | null; lastResult: 'success' | 'failed' | 'running' | null; nextRun: string | null; avgDuration: number; failCount: number; createdAt: string; } // Pastikan direktori data ada async function ensureDataDir() { await fs.mkdir(DATA_DIR, { recursive: true }); } // Ambil semua jobs async function getJobs(): Promise<CronJob[]> { try { await ensureDataDir(); const data = await fs.readFile(JOBS_FILE, 'utf-8'); return JSON.parse(data); } catch { // Kalau file belum ada, return default jobs const defaultJobs: CronJob[] = [ { id: 'job-001', name: 'Health Check', schedule: '*/5 * * * *', scheduleHuman: 'Setiap 5 menit', command: 'curl -sf http://localhost:3000/api/health', status: 'active', lastRun: '2026-03-28T20:15:00+08:00', lastResult: 'success', nextRun: '2026-03-28T20:20:00+08:00', avgDuration: 1.2, failCount: 0, createdAt: '2026-03-15T08:00:00+08:00', }, { id: 'job-002', name: 'Log Rotation', schedule: '0 0 * * *', scheduleHuman: 'Setiap hari tengah malam', command: '/usr/local/bin/logrotate.sh', status: 'active', lastRun: '2026-03-28T00:00:00+08:00', lastResult: 'success', nextRun: '2026-03-29T00:00:00+08:00', avgDuration: 3.5, failCount: 0, createdAt: '2026-03-15T08:00:00+08:00', }, { id: 'job-003', name: 'Database Backup', schedule: '0 2 * * *', scheduleHuman: 'Setiap hari jam 2 pagi', command: 'pg_dump -Fc radian_db > /backup/db_$(date +%Y%m%d).dump', status: 'active', lastRun: '2026-03-28T02:00:00+08:00', lastResult: 'success', nextRun: '2026-03-29T02:00:00+08:00', avgDuration: 45.2, failCount: 0, createdAt: '2026-03-16T10:00:00+08:00', }, { id: 'job-004', name: 'Morning Briefing', schedule: '0 7 * * 1-5', scheduleHuman: 'Senin-Jumat jam 7 pagi', command: 'openclaw cron trigger morning-briefing', status: 'active', lastRun: '2026-03-28T07:00:00+08:00', lastResult: 'success', nextRun: '2026-03-29T07:00:00+08:00', avgDuration: 12.8, failCount: 0, createdAt: '2026-03-17T06:00:00+08:00', }, { id: 'job-005', name: 'Cache Cleanup', schedule: '0 3 * * 0', scheduleHuman: 'Setiap Minggu jam 3 pagi', command: 'find /tmp -name "*.cache" -mtime +7 -delete', status: 'disabled', lastRun: '2026-03-23T03:00:00+08:00', lastResult: 'success', nextRun: null, avgDuration: 2.1, failCount: 0, createdAt: '2026-03-18T09:00:00+08:00', }, { id: 'job-006', name: 'SSL Renewal Check', schedule: '0 8 1 * *', scheduleHuman: 'Tanggal 1 setiap bulan jam 8 pagi', command: 'certbot renew --dry-run', status: 'failed', lastRun: '2026-03-01T08:00:00+08:00', lastResult: 'failed', nextRun: '2026-04-01T08:00:00+08:00', avgDuration: 15.3, failCount: 1, createdAt: '2026-03-18T09:00:00+08:00', }, { id: 'job-007', name: 'Disk Usage Alert', schedule: '0 */4 * * *', scheduleHuman: 'Setiap 4 jam', command: 'df -h | awk \'NR>1 && int($5)>85\'', status: 'active', lastRun: '2026-03-28T16:00:00+08:00', lastResult: 'success', nextRun: '2026-03-28T20:00:00+08:00', avgDuration: 0.8, failCount: 0, createdAt: '2026-03-20T11:00:00+08:00', }, { id: 'job-008', name: 'Weekly Report', schedule: '0 18 * * 5', scheduleHuman: 'Setiap Jumat jam 6 sore', command: 'openclaw cron trigger weekly-summary', status: 'active', lastRun: '2026-03-27T18:00:00+08:00', lastResult: 'success', nextRun: '2026-04-04T18:00:00+08:00', avgDuration: 25.6, failCount: 0, createdAt: '2026-03-20T11:00:00+08:00', }, ]; // Simpan default ke file await fs.writeFile(JOBS_FILE, JSON.stringify(defaultJobs, null, 2)); return defaultJobs; } } // GET: Ambil semua jobs + stats export async function GET() { try { const jobs = await getJobs(); // Hitung stats const stats = { totalJobs: jobs.length, activeJobs: jobs.filter(j => j.status === 'active').length, disabledJobs: jobs.filter(j => j.status === 'disabled').length, failedJobs: jobs.filter(j => j.status === 'failed').length, }; // Hitung distribusi untuk chart const distribution = [ { name: 'Active', value: stats.activeJobs, color: '#22c55e' }, { name: 'Disabled', value: stats.disabledJobs, color: '#9ca3af' }, { name: 'Failed', value: stats.failedJobs, color: '#ef4444' }, ]; return NextResponse.json({ jobs, stats, distribution }); } catch (error) { console.error('Gagal mengambil schedule data:', error); return NextResponse.json( { error: 'Gagal mengambil data schedule' }, { status: 500 } ); } } // POST: Toggle job status atau tambah job baru export async function POST(request: NextRequest) { try { const body = await request.json(); const { action, jobId, job } = body; const jobs = await getJobs(); if (action === 'toggle') { // Toggle status active/disabled const index = jobs.findIndex(j => j.id === jobId); if (index === -1) { return NextResponse.json({ error: 'Job tidak ditemukan' }, { status: 404 }); } jobs[index].status = jobs[index].status === 'active' ? 'disabled' : 'active'; if (jobs[index].status === 'active') { jobs[index].nextRun = new Date(Date.now() + 3600000).toISOString(); } await fs.writeFile(JOBS_FILE, JSON.stringify(jobs, null, 2)); return NextResponse.json({ job: jobs[index] }); } else if (action === 'add') { // Tambah job baru const newJob: CronJob = { id: `job-${String(Date.now()).slice(-6)}`, name: job.name, schedule: job.schedule, scheduleHuman: job.scheduleHuman || job.schedule, command: job.command, status: 'active', lastRun: null, lastResult: null, nextRun: new Date(Date.now() + 60000).toISOString(), avgDuration: 0, failCount: 0, createdAt: new Date().toISOString(), }; jobs.push(newJob); await fs.writeFile(JOBS_FILE, JSON.stringify(jobs, null, 2)); return NextResponse.json({ job: newJob }, { status: 201 }); } return NextResponse.json({ error: 'Action tidak valid' }, { status: 400 }); } catch (error) { console.error('Gagal mengubah schedule:', error); return NextResponse.json( { error: 'Gagal mengubah schedule' }, { status: 500 } ); } }

8.3 Komponen Stats Cards

Buat file app/schedule/components/StatsCards.tsx:

// app/schedule/components/StatsCards.tsx // Kartu statistik untuk halaman Schedule 'use client'; import { useEffect, useState } from 'react'; interface StatsCardsProps { stats: { totalJobs: number; activeJobs: number; disabledJobs: number; failedJobs: number; }; } // Komponen animasi counter — angka naik dari 0 ke target function AnimatedCounter({ target, duration = 1000 }: { target: number; duration?: number }) { const [count, setCount] = useState(0); useEffect(() => { let startTime: number; let animationFrame: number; const animate = (timestamp: number) => { if (!startTime) startTime = timestamp; const progress = Math.min((timestamp - startTime) / duration, 1); // Easing: ease-out const eased = 1 - Math.pow(1 - progress, 3); setCount(Math.floor(eased * target)); if (progress < 1) { animationFrame = requestAnimationFrame(animate); } }; animationFrame = requestAnimationFrame(animate); return () => cancelAnimationFrame(animationFrame); }, [target, duration]); return <span>{count}</span>; } export default function StatsCards({ stats }: StatsCardsProps) { const cards = [ { label: 'Total Jobs', value: stats.totalJobs, icon: '📋', color: 'bg-blue-500/10 text-blue-400 border-blue-500/20', iconBg: 'bg-blue-500/20', }, { label: 'Active', value: stats.activeJobs, icon: '✅', color: 'bg-green-500/10 text-green-400 border-green-500/20', iconBg: 'bg-green-500/20', }, { label: 'Disabled', value: stats.disabledJobs, icon: '⏸️', color: 'bg-gray-500/10 text-gray-400 border-gray-500/20', iconBg: 'bg-gray-500/20', }, { label: 'Failed', value: stats.failedJobs, icon: '❌', color: 'bg-red-500/10 text-red-400 border-red-500/20', iconBg: 'bg-red-500/20', }, ]; return ( <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4"> {cards.map((card) => ( <div key={card.label} className={`rounded-xl border p-5 ${card.color} transition-all duration-200 hover:scale-[1.02]`} > <div className="flex items-center justify-between mb-3"> <span className="text-sm font-medium opacity-80">{card.label}</span> <span className={`text-2xl p-2 rounded-lg ${card.iconBg}`}>{card.icon}</span> </div> <div className="text-3xl font-bold"> <AnimatedCounter target={card.value} /> </div> </div> ))} </div> ); }

💡 Tips: AnimatedCounter pakai requestAnimationFrame supaya smooth dan nggak blocking main thread. Lebih baik daripada setInterval untuk animasi angka.

8.4 Komponen Job Distribution Chart

Buat file app/schedule/components/JobChart.tsx:

// app/schedule/components/JobChart.tsx // Pie chart distribusi job berdasarkan status 'use client'; import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip, Legend } from 'recharts'; interface DistributionItem { name: string; value: number; color: string; } interface JobChartProps { distribution: DistributionItem[]; } // Custom tooltip function CustomTooltip({ active, payload }: { active?: boolean; payload?: Array<{ name: string; value: number; color: string }> }) { if (!active || !payload?.length) return null; return ( <div className="bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 shadow-xl"> <p className="text-sm font-medium" style={{ color: payload[0].color }}> {payload[0].name}: {payload[0].value} job(s) </p> </div> ); } export default function JobChart({ distribution }: JobChartProps) { // Filter hanya yang nilainya > 0 const filtered = distribution.filter(d => d.value > 0); return ( <div className="bg-gray-900/50 border border-gray-800 rounded-xl p-6"> <h3 className="text-lg font-semibold text-white mb-4">📊 Distribusi Job</h3> {filtered.length === 0 ? ( <div className="flex items-center justify-center h-48 text-gray-500"> Belum ada data job </div> ) : ( <ResponsiveContainer width="100%" height={250}> <PieChart> <Pie data={filtered} cx="50%" cy="50%" innerRadius={60} outerRadius={90} paddingAngle={4} dataKey="value" stroke="none" > {filtered.map((entry, index) => ( <Cell key={`cell-${index}`} fill={entry.color} /> ))} </Pie> <Tooltip content={<CustomTooltip />} /> <Legend wrapperStyle={{ fontSize: '13px' }} formatter={(value: string) => ( <span className="text-gray-300">{value}</span> )} /> </PieChart> </ResponsiveContainer> )} </div> ); }

8.5 Komponen Toggle Switch

Buat file app/schedule/components/ToggleSwitch.tsx:

// app/schedule/components/ToggleSwitch.tsx // Toggle switch untuk enable/disable job 'use client'; import { useState } from 'react'; interface ToggleSwitchProps { enabled: boolean; onToggle: () => void; label?: string; } export default function ToggleSwitch({ enabled, onToggle, label }: ToggleSwitchProps) { const [loading, setLoading] = useState(false); const handleToggle = async () => { setLoading(true); try { await onToggle(); } finally { setLoading(false); } }; return ( <button onClick={handleToggle} disabled={loading} className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:ring-offset-gray-900 ${ enabled ? 'bg-green-500' : 'bg-gray-600' } ${loading ? 'opacity-50 cursor-wait' : 'cursor-pointer'}`} aria-label={label || (enabled ? 'Disable job' : 'Enable job')} title={label || (enabled ? 'Klik untuk disable' : 'Klik untuk enable')} > <span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform duration-200 ${ enabled ? 'translate-x-6' : 'translate-x-1' }`} /> </button> ); }

8.6 Komponen Job Table

Buat file app/schedule/components/JobTable.tsx:

// app/schedule/components/JobTable.tsx // Tabel daftar semua cron jobs 'use client'; import { CronJob } from '../types'; import ToggleSwitch from './ToggleSwitch'; interface JobTableProps { jobs: CronJob[]; onToggle: (jobId: string) => Promise<void>; } // Format relative time (contoh: "5 menit lalu") function formatRelativeTime(dateStr: string | null): string { if (!dateStr) return '—'; const now = new Date(); const date = new Date(dateStr); const diffMs = now.getTime() - date.getTime(); const diffMins = Math.floor(diffMs / 60000); const diffHours = Math.floor(diffMins / 60); const diffDays = Math.floor(diffHours / 24); if (diffMins < 1) return 'Baru saja'; if (diffMins < 60) return `${diffMins} menit lalu`; if (diffHours < 24) return `${diffHours} jam lalu`; if (diffDays < 7) return `${diffDays} hari lalu`; return date.toLocaleDateString('id-ID', { day: 'numeric', month: 'short', year: 'numeric' }); } // Badge warna untuk status function StatusBadge({ status, lastResult }: { status: string; lastResult: string | null }) { const styles: Record<string, string> = { active: 'bg-green-500/10 text-green-400 border-green-500/30', disabled: 'bg-gray-500/10 text-gray-400 border-gray-500/30', failed: 'bg-red-500/10 text-red-400 border-red-500/30', }; return ( <div className="flex items-center gap-2"> <span className={`px-2.5 py-0.5 text-xs font-medium rounded-full border ${styles[status]}`}> {status === 'active' && '🟢 Active'} {status === 'disabled' && '⚪ Disabled'} {status === 'failed' && '🔴 Failed'} </span> {lastResult === 'running' && ( <span className="text-xs text-yellow-400 animate-pulse">⏳ Running</span> )} </div> ); } export default function JobTable({ jobs, onToggle }: JobTableProps) { if (jobs.length === 0) { return ( <div className="bg-gray-900/50 border border-gray-800 rounded-xl p-12 text-center"> <p className="text-4xl mb-3">📭</p> <p className="text-gray-400">Belum ada cron job terdaftar</p> <p className="text-sm text-gray-500 mt-1">Klik tombol &quot;Add Job&quot; untuk menambahkan</p> </div> ); } return ( <div className="bg-gray-900/50 border border-gray-800 rounded-xl overflow-hidden"> {/* Header tabel */} <div className="overflow-x-auto"> <table className="w-full text-left"> <thead> <tr className="border-b border-gray-800"> <th className="px-6 py-4 text-xs font-semibold text-gray-400 uppercase tracking-wider">Job</th> <th className="px-6 py-4 text-xs font-semibold text-gray-400 uppercase tracking-wider">Schedule</th> <th className="px-6 py-4 text-xs font-semibold text-gray-400 uppercase tracking-wider">Status</th> <th className="px-6 py-4 text-xs font-semibold text-gray-400 uppercase tracking-wider">Last Run</th> <th className="px-6 py-4 text-xs font-semibold text-gray-400 uppercase tracking-wider">Next Run</th> <th className="px-6 py-4 text-xs font-semibold text-gray-400 uppercase tracking-wider">Toggle</th> </tr> </thead> <tbody className="divide-y divide-gray-800/50"> {jobs.map((job) => ( <tr key={job.id} className="hover:bg-gray-800/30 transition-colors"> {/* Nama Job */} <td className="px-6 py-4"> <div> <p className="font-medium text-white">{job.name}</p> <p className="text-xs text-gray-500 mt-1 font-mono truncate max-w-[250px]"> {job.command} </p> </div> </td> {/* Schedule */} <td className="px-6 py-4"> <div> <p className="text-sm text-gray-300">{job.scheduleHuman}</p> <p className="text-xs text-gray-500 font-mono">{job.schedule}</p> </div> </td> {/* Status */} <td className="px-6 py-4"> <StatusBadge status={job.status} lastResult={job.lastResult} /> </td> {/* Last Run */} <td className="px-6 py-4"> <p className="text-sm text-gray-300">{formatRelativeTime(job.lastRun)}</p> {job.failCount > 0 && ( <p className="text-xs text-red-400 mt-1">{job.failCount}x gagal</p> )} </td> {/* Next Run */} <td className="px-6 py-4"> <p className="text-sm text-gray-300"> {job.nextRun ? formatRelativeTime(job.nextRun) : '—'} </p> {job.avgDuration > 0 && ( <p className="text-xs text-gray-500 mt-1">~{job.avgDuration}s</p> )} </td> {/* Toggle */} <td className="px-6 py-4"> <ToggleSwitch enabled={job.status === 'active'} onToggle={() => onToggle(job.id)} label={`${job.status === 'active' ? 'Disable' : 'Enable'} ${job.name}`} /> </td> </tr> ))} </tbody> </table> </div> </div> ); }

8.7 Komponen Add Job Modal

Buat file app/schedule/components/AddJobModal.tsx:

// app/schedule/components/AddJobModal.tsx // Modal form untuk menambahkan cron job baru 'use client'; import { useState } from 'react'; interface AddJobModalProps { isOpen: boolean; onClose: () => void; onAdd: (job: { name: string; schedule: string; scheduleHuman: string; command: string; }) => Promise<void>; } // Preset jadwal yang sering dipakai const SCHEDULE_PRESETS = [ { label: 'Setiap 5 menit', value: '*/5 * * * *' }, { label: 'Setiap 15 menit', value: '*/15 * * * *' }, { label: 'Setiap 30 menit', value: '*/30 * * * *' }, { label: 'Setiap 1 jam', value: '0 * * * *' }, { label: 'Setiap 6 jam', value: '0 */6 * * *' }, { label: 'Setiap hari (tengah malam)', value: '0 0 * * *' }, { label: 'Setiap Senin-Jumat (jam 9)', value: '0 9 * * 1-5' }, { label: 'Setiap Minggu (jam 3)', value: '0 3 * * 0' }, ]; export default function AddJobModal({ isOpen, onClose, onAdd }: AddJobModalProps) { const [name, setName] = useState(''); const [schedule, setSchedule] = useState(''); const [scheduleHuman, setScheduleHuman] = useState(''); const [command, setCommand] = useState(''); const [loading, setLoading] = useState(false); const [error, setError] = useState(''); // Reset form const resetForm = () => { setName(''); setSchedule(''); setScheduleHuman(''); setCommand(''); setError(''); }; // Submit form const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); if (!name.trim() || !schedule.trim() || !command.trim()) { setError('Semua field wajib diisi'); return; } setLoading(true); setError(''); try { await onAdd({ name: name.trim(), schedule: schedule.trim(), scheduleHuman: scheduleHuman.trim() || schedule.trim(), command: command.trim(), }); resetForm(); onClose(); } catch { setError('Gagal menambahkan job'); } finally { setLoading(false); } }; // Pilih preset schedule const selectPreset = (preset: { label: string; value: string }) => { setSchedule(preset.value); setScheduleHuman(preset.label); }; if (!isOpen) return null; return ( <div className="fixed inset-0 z-50 flex items-center justify-center"> {/* Backdrop */} <div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} /> {/* Modal content */} <div className="relative bg-gray-900 border border-gray-700 rounded-2xl shadow-2xl w-full max-w-lg mx-4 max-h-[90vh] overflow-y-auto"> {/* Header */} <div className="flex items-center justify-between p-6 border-b border-gray-800"> <h2 className="text-xl font-bold text-white">➕ Tambah Job Baru</h2> <button onClick={onClose} className="text-gray-400 hover:text-white transition-colors text-xl" > </button> </div> {/* Form */} <form onSubmit={handleSubmit} className="p-6 space-y-5"> {/* Error message */} {error && ( <div className="bg-red-500/10 border border-red-500/30 text-red-400 rounded-lg px-4 py-3 text-sm"> ⚠️ {error} </div> )} {/* Nama Job */} <div> <label className="block text-sm font-medium text-gray-300 mb-2"> Nama Job * </label> <input type="text" value={name} onChange={(e) => setName(e.target.value)} placeholder="contoh: Daily Backup" className="w-full px-4 py-2.5 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none transition-all" /> </div> {/* Schedule */} <div> <label className="block text-sm font-medium text-gray-300 mb-2"> Cron Expression * </label> <input type="text" value={schedule} onChange={(e) => setSchedule(e.target.value)} placeholder="contoh: */5 * * * *" className="w-full px-4 py-2.5 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-500 font-mono focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none transition-all" /> {/* Preset buttons */} <div className="flex flex-wrap gap-2 mt-2"> {SCHEDULE_PRESETS.map((preset) => ( <button key={preset.value} type="button" onClick={() => selectPreset(preset)} className="px-3 py-1 text-xs bg-gray-800 border border-gray-700 rounded-full text-gray-300 hover:border-blue-500 hover:text-blue-400 transition-colors" > {preset.label} </button> ))} </div> </div> {/* Schedule Human-Readable */} <div> <label className="block text-sm font-medium text-gray-300 mb-2"> Deskripsi Jadwal </label> <input type="text" value={scheduleHuman} onChange={(e) => setScheduleHuman(e.target.value)} placeholder="contoh: Setiap 5 menit" className="w-full px-4 py-2.5 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none transition-all" /> </div> {/* Command */} <div> <label className="block text-sm font-medium text-gray-300 mb-2"> Command * </label> <textarea value={command} onChange={(e) => setCommand(e.target.value)} placeholder="contoh: /usr/local/bin/my-script.sh" rows={3} className="w-full px-4 py-2.5 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-500 font-mono text-sm focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none transition-all resize-none" /> </div> {/* Actions */} <div className="flex gap-3 pt-2"> <button type="button" onClick={onClose} className="flex-1 px-4 py-2.5 bg-gray-800 text-gray-300 rounded-lg hover:bg-gray-700 transition-colors" > Batal </button> <button type="submit" disabled={loading} className="flex-1 px-4 py-2.5 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors" > {loading ? ( <span className="flex items-center justify-center gap-2"> <svg className="animate-spin h-4 w-4" viewBox="0 0 24 24"> <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" /> <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" /> </svg> Menyimpan... </span> ) : ( '✨ Tambah Job' )} </button> </div> </form> </div> </div> ); }

8.8 Halaman Utama Schedule

Buat file app/schedule/page.tsx:

// app/schedule/page.tsx // Halaman utama Schedule — menampilkan semua cron jobs 'use client'; import { useEffect, useState, useCallback } from 'react'; import StatsCards from './components/StatsCards'; import JobChart from './components/JobChart'; import JobTable from './components/JobTable'; import AddJobModal from './components/AddJobModal'; import { CronJob } from './types'; export default function SchedulePage() { const [jobs, setJobs] = useState<CronJob[]>([]); const [stats, setStats] = useState({ totalJobs: 0, activeJobs: 0, disabledJobs: 0, failedJobs: 0 }); const [distribution, setDistribution] = useState<Array<{ name: string; value: number; color: string }>>([]); const [loading, setLoading] = useState(true); const [isModalOpen, setIsModalOpen] = useState(false); // Fetch data dari API const fetchData = useCallback(async () => { try { const res = await fetch('/api/schedule'); if (!res.ok) throw new Error('Gagal fetch data'); const data = await res.json(); setJobs(data.jobs); setStats(data.stats); setDistribution(data.distribution); } catch (error) { console.error('Fetch schedule error:', error); } finally { setLoading(false); } }, []); useEffect(() => { fetchData(); // Auto-refresh setiap 30 detik const interval = setInterval(fetchData, 30000); return () => clearInterval(interval); }, [fetchData]); // Toggle job status const handleToggle = async (jobId: string) => { try { const res = await fetch('/api/schedule', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'toggle', jobId }), }); if (!res.ok) throw new Error('Gagal toggle'); await fetchData(); // Refresh data } catch (error) { console.error('Toggle error:', error); } }; // Add new job const handleAddJob = async (job: { name: string; schedule: string; scheduleHuman: string; command: string; }) => { const res = await fetch('/api/schedule', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'add', job }), }); if (!res.ok) throw new Error('Gagal menambah job'); await fetchData(); }; // Loading skeleton if (loading) { return ( <div className="space-y-6 p-6"> <div className="h-8 w-48 bg-gray-800 rounded-lg animate-pulse" /> <div className="grid grid-cols-4 gap-4"> {[...Array(4)].map((_, i) => ( <div key={i} className="h-28 bg-gray-800 rounded-xl animate-pulse" /> ))} </div> <div className="h-64 bg-gray-800 rounded-xl animate-pulse" /> <div className="h-96 bg-gray-800 rounded-xl animate-pulse" /> </div> ); } return ( <div className="space-y-6 p-6"> {/* Header */} <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4"> <div> <h1 className="text-2xl font-bold text-white">🕐 Schedule</h1> <p className="text-gray-400 text-sm mt-1"> Kelola cron jobs dan tugas terjadwal agent </p> </div> <button onClick={() => setIsModalOpen(true)} className="inline-flex items-center gap-2 px-4 py-2.5 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors shadow-lg shadow-blue-500/20" > <span>➕</span> <span>Add Job</span> </button> </div> {/* Stats Cards */} <StatsCards stats={stats} /> {/* Chart + Table */} <div className="grid grid-cols-1 lg:grid-cols-3 gap-6"> {/* Pie Chart */} <div className="lg:col-span-1"> <JobChart distribution={distribution} /> </div> {/* Job Table */} <div className="lg:col-span-2"> <JobTable jobs={jobs} onToggle={handleToggle} /> </div> </div> {/* Add Job Modal */} <AddJobModal isOpen={isModalOpen} onClose={() => setIsModalOpen(false)} onAdd={handleAddJob} /> </div> ); }

⚠️ Pitfall: Jangan lupa pasang cron parser library di production (misalnya cron-parser). Di contoh ini kita pakai human-readable string yang manual. Untuk production, parse cron expression jadi waktu berikutnya yang akurat.

💡 Tips: Data disimpan di file JSON (data/jobs.json) untuk simulasi. Di production, gunakan database (PostgreSQL/Redis) untuk reliability dan concurrent access.


PART 9: Logs Page 📋

Halaman logs memberikan akses ke semua file log AI agent dengan tampilan terminal yang keren.

Arsitektur Log Pipeline

A AI Agent stdoutstderr B Log Writer
A AI Agent stdoutstderr B Log Writer

9.1 API Route: Logs

Buat file app/api/logs/route.ts:

// app/api/logs/route.ts // API endpoint untuk membaca file log import { NextRequest, NextResponse } from 'next/server'; import { promises as fs } from 'fs'; import path from 'path'; // Direktori log (sesuaikan dengan environment kamu) const LOG_DIR = path.join(process.cwd(), 'data', 'logs'); // Tipe untuk file log interface LogFile { name: string; size: number; sizeFormatted: string; lastModified: string; category: 'system' | 'application' | 'security' | 'errors' | 'other'; } // Format ukuran file function formatSize(bytes: number): string { if (bytes === 0) return '0 B'; const units = ['B', 'KB', 'MB', 'GB']; const i = Math.floor(Math.log(bytes) / Math.log(1024)); return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${units[i]}`; } // Tentukan kategori dari nama file function getCategory(filename: string): LogFile['category'] { if (filename.includes('system') || filename.includes('daemon')) return 'system'; if (filename.includes('app') || filename.includes('agent')) return 'application'; if (filename.includes('security') || filename.includes('auth')) return 'security'; if (filename.includes('error') || filename.includes('crash')) return 'errors'; return 'other'; } // Pastikan direktori log ada async function ensureLogDir() { await fs.mkdir(LOG_DIR, { recursive: true }); } // Buat sample log files kalau belum ada async function ensureSampleLogs() { await ensureLogDir(); const sampleLogs: Record<string, string> = { 'system.log': `[2026-03-28 20:00:01] INFO System started successfully [2026-03-28 20:00:02] INFO Loading configuration from /etc/openclaw/config.json [2026-03-28 20:00:03] INFO Database connection established (PostgreSQL 15.2) [2026-03-28 20:00:04] INFO Redis cache connected (localhost:6379) [2026-03-28 20:00:05] INFO Starting HTTP server on port 3000 [2026-03-28 20:05:01] INFO Health check passed (latency: 12ms) [2026-03-28 20:10:01] INFO Health check passed (latency: 8ms) [2026-03-28 20:15:01] INFO Health check passed (latency: 15ms) [2026-03-28 20:15:30] WARN High memory usage detected: 82% (threshold: 80%) [2026-03-28 20:20:01] INFO Health check passed (latency: 11ms) [2026-03-28 20:25:01] INFO Health check passed (latency: 9ms)`, 'application.log': `[2026-03-28 20:00:10] INFO Agent initialized with model: glm-5-turbo [2026-03-28 20:00:11] INFO Loading 45 skills from /root/.agents/skills [2026-03-28 20:00:12] INFO Telegram bot connected (@radit_bot) [2026-03-28 20:01:05] INFO Session started: user=Fanani channel=telegram [2026-03-28 20:01:30] INFO Tool call: exec(command="ls -la") [2026-03-28 20:02:15] INFO Skill loaded: smart-search [2026-03-28 20:05:00] INFO Subagent spawned: task=weather-check [2026-03-28 20:05:45] INFO Subagent completed: task=weather-check duration=45s [2026-03-28 20:10:00] INFO Heartbeat check: HEARTBEAT_OK [2026-03-28 20:15:00] INFO Heartbeat check: HEARTBEAT_OK [2026-03-28 20:16:30] WARN Rate limit approaching: 85% of daily quota used`, 'security.log': `[2026-03-28 19:50:00] INFO API key validated: session_radit_main [2026-03-28 20:00:00] INFO Authentication successful: user=Fanani method=telegram [2026-03-28 20:01:00] INFO Permission check passed: exec(command="ls -la") [2026-03-28 20:05:00] INFO Permission check passed: subagent(spawn=true) [2026-03-28 20:10:00] WARN Suspicious request pattern: 10 failed auth attempts from 192.168.1.100 [2026-03-28 20:12:00] WARN IP rate limited: 192.168.1.100 (too many attempts) [2026-03-28 20:15:00] INFO Session timeout: session_guest_42 (idle: 30min) [2026-03-28 20:20:00] INFO API key rotated successfully`, 'errors.log`: `[2026-03-28 19:45:00] ERROR Database connection timeout after 30s at connect (db.js:45:12) caused by: ETIMEDOUT 127.0.0.1:5432 [2026-03-28 20:00:00] ERROR Failed to load skill "broken-skill": ENOENT at loadSkill (skill-loader.js:89:5) [2026-03-28 20:10:00] WARN Retry attempt 2/3 for webhook delivery to https://example.com/hook [2026-03-28 20:10:05] ERROR Webhook delivery failed permanently: HTTP 503 URL: https://example.com/hook Status: 503 Service Unavailable Retries exhausted.`, }; for (const [filename, content] of Object.entries(sampleLogs)) { const filePath = path.join(LOG_DIR, filename); try { await fs.access(filePath); } catch { await fs.writeFile(filePath, content); } } } // GET: List log files atau baca konten log export async function GET(request: NextRequest) { const { searchParams } = new URL(request.url); const filename = searchParams.get('file'); const category = searchParams.get('category') || 'all'; const search = searchParams.get('search') || ''; await ensureSampleLogs(); // Kalau ada filename → baca konten file if (filename) { try { const filePath = path.join(LOG_DIR, filename); // Keamanan: cegah path traversal const resolvedPath = path.resolve(filePath); if (!resolvedPath.startsWith(path.resolve(LOG_DIR))) { return NextResponse.json({ error: 'Akses ditolak' }, { status: 403 }); } const content = await fs.readFile(resolvedPath, 'utf-8'); const lines = content.split('\n'); const filtered = search ? lines.filter(line => line.toLowerCase().includes(search.toLowerCase())) : lines; return NextResponse.json({ filename, totalLines: lines.length, filteredLines: filtered.length, lines: filtered.map((line, index) => ({ number: index + 1, content: line, level: line.includes('ERROR') ? 'error' : line.includes('WARN') ? 'warn' : line.includes('INFO') ? 'info' : 'debug', })), }); } catch (error) { console.error('Gagal membaca log:', error); return NextResponse.json({ error: 'File log tidak ditemukan' }, { status: 404 }); } } // Kalau tidak → list semua file log try { const files = await fs.readdir(LOG_DIR); const logFiles: LogFile[] = []; for (const file of files) { if (!file.endsWith('.log')) continue; const stat = await fs.stat(path.join(LOG_DIR, file)); logFiles.push({ name: file, size: stat.size, sizeFormatted: formatSize(stat.size), lastModified: stat.mtime.toISOString(), category: getCategory(file), }); } // Sort berdasarkan last modified (terbaru dulu) logFiles.sort((a, b) => new Date(b.lastModified).getTime() - new Date(a.lastModified).getTime()); // Filter by category const filtered = category === 'all' ? logFiles : logFiles.filter(f => f.category === category); return NextResponse.json({ files: filtered, totalFiles: filtered.length }); } catch (error) { console.error('Gagal membaca direktori log:', error); return NextResponse.json({ error: 'Gagal membaca direktori log' }, { status: 500 }); } }

9.2 Komponen Log Sidebar

Buat file app/logs/components/LogSidebar.tsx:

// app/logs/components/LogSidebar.tsx // Sidebar daftar file log 'use client'; import { useState } from 'react'; interface LogFile { name: string; size: number; sizeFormatted: string; lastModified: string; category: 'system' | 'application' | 'security' | 'errors' | 'other'; } interface LogSidebarProps { files: LogFile[]; activeFile: string | null; onSelectFile: (filename: string) => void; activeCategory: string; onCategoryChange: (category: string) => void; } // Warna badge per kategori const CATEGORY_COLORS: Record<string, string> = { system: 'bg-blue-500/20 text-blue-400', application: 'bg-green-500/20 text-green-400', security: 'bg-yellow-500/20 text-yellow-400', errors: 'bg-red-500/20 text-red-400', other: 'bg-gray-500/20 text-gray-400', }; // Ikon per kategori const CATEGORY_ICONS: Record<string, string> = { system: '🖥️', application: '🤖', security: '🔒', errors: '💥', other: '📄', }; export default function LogSidebar({ files, activeFile, onSelectFile, activeCategory, onCategoryChange, }: LogSidebarProps) { const [search, setSearch] = useState(''); const categories = ['all', 'system', 'application', 'security', 'errors']; // Filter file berdasarkan search const filteredFiles = search ? files.filter(f => f.name.toLowerCase().includes(search.toLowerCase())) : files; return ( <div className="bg-gray-900/50 border border-gray-800 rounded-xl flex flex-col h-full"> {/* Header */} <div className="p-4 border-b border-gray-800"> <h3 className="text-sm font-semibold text-gray-300 mb-3">📂 Log Files</h3> {/* Search */} <div className="relative"> <input type="text" value={search} onChange={(e) => setSearch(e.target.value)} placeholder="Cari file..." className="w-full pl-8 pr-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm text-white placeholder-gray-500 focus:ring-1 focus:ring-blue-500 outline-none" /> <span className="absolute left-2.5 top-2.5 text-gray-500 text-sm">🔍</span> </div> </div> {/* Category tabs */} <div className="p-3 border-b border-gray-800 flex flex-wrap gap-1.5"> {categories.map((cat) => ( <button key={cat} onClick={() => onCategoryChange(cat)} className={`px-2.5 py-1 text-xs rounded-full transition-colors ${ activeCategory === cat ? 'bg-blue-500/20 text-blue-400 border border-blue-500/30' : 'bg-gray-800 text-gray-400 border border-gray-700 hover:border-gray-600' }`} > {cat === 'all' ? '📋' : CATEGORY_ICONS[cat] || '📄'} {cat} </button> ))} </div> {/* File list */} <div className="flex-1 overflow-y-auto p-2 space-y-1"> {filteredFiles.length === 0 ? ( <p className="text-gray-500 text-sm text-center py-8"> {search ? 'Tidak ada file cocok' : 'Tidak ada file log'} </p> ) : ( filteredFiles.map((file) => ( <button key={file.name} onClick={() => onSelectFile(file.name)} className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-left transition-all ${ activeFile === file.name ? 'bg-blue-500/10 border border-blue-500/30' : 'hover:bg-gray-800/50 border border-transparent' }`} > {/* Ikon kategori */} <span className="text-lg flex-shrink-0"> {CATEGORY_ICONS[file.category] || '📄'} </span> {/* Info file */} <div className="flex-1 min-w-0"> <p className={`text-sm font-medium truncate ${ activeFile === file.name ? 'text-blue-400' : 'text-gray-300' }`}> {file.name} </p> <p className="text-xs text-gray-500"> {file.sizeFormatted} •{' '} {new Date(file.lastModified).toLocaleDateString('id-ID', { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit', })} </p> </div> {/* Badge kategori */} <span className={`px-2 py-0.5 text-[10px] rounded-full font-medium flex-shrink-0 ${CATEGORY_COLORS[file.category]}`}> {file.category} </span> </button> )) )} </div> {/* Footer */} <div className="p-3 border-t border-gray-800"> <p className="text-xs text-gray-500 text-center"> {filteredFiles.length} file log </p> </div> </div> ); }

9.3 Komponen Log Viewer (Terminal Style)

Buat file app/logs/components/LogViewer.tsx:

// app/logs/components/LogViewer.tsx // Viewer log dengan gaya terminal 'use client'; import { useState, useRef, useEffect } from 'react'; interface LogLine { number: number; content: string; level: 'info' | 'warn' | 'error' | 'debug'; } interface LogViewerProps { lines: LogLine[]; filename: string | null; searchQuery: string; onSearchChange: (query: string) => void; } // Warna per log level const LEVEL_COLORS: Record<string, string> = { info: 'text-green-400', warn: 'text-yellow-400', error: 'text-red-400', debug: 'text-gray-400', }; // Highlight teks yang match search function HighlightText({ text, search }: { text: string; search: string }) { if (!search.trim()) return <>{text}</>; const regex = new RegExp(`(${search.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi'); const parts = text.split(regex); return ( <> {parts.map((part, i) => regex.test(part) ? ( <mark key={i} className="bg-yellow-500/30 text-yellow-200 rounded px-0.5"> {part} </mark> ) : ( <span key={i}>{part}</span> ) )} </> ); } export default function LogViewer({ lines, filename, searchQuery, onSearchChange }: LogViewerProps) { const [autoScroll, setAutoScroll] = useState(true); const containerRef = useRef<HTMLDivElement>(null); // Auto-scroll ke bawah useEffect(() => { if (autoScroll && containerRef.current) { containerRef.current.scrollTop = containerRef.current.scrollHeight; } }, [lines, autoScroll]); if (!filename) { return ( <div className="bg-gray-950 border border-gray-800 rounded-xl flex items-center justify-center h-full min-h-[500px]"> <div className="text-center"> <p className="text-5xl mb-4">📂</p> <p className="text-gray-400 text-lg">Pilih file log dari sidebar</p> <p className="text-gray-600 text-sm mt-2">atau gunakan search untuk filter</p> </div> </div> ); } return ( <div className="bg-gray-950 border border-gray-800 rounded-xl flex flex-col h-full min-h-[500px]"> {/* Toolbar */} <div className="flex items-center justify-between px-4 py-3 border-b border-gray-800 bg-gray-900/50"> <div className="flex items-center gap-3"> {/* Titik-titik terminal */} <div className="flex gap-1.5"> <div className="w-3 h-3 rounded-full bg-red-500" /> <div className="w-3 h-3 rounded-full bg-yellow-500" /> <div className="w-3 h-3 rounded-full bg-green-500" /> </div> {/* Filename */} <span className="text-sm text-gray-400 font-mono">{filename}</span> {/* Line count */} <span className="text-xs text-gray-600 bg-gray-800 px-2 py-0.5 rounded-full"> {lines.length} lines </span> </div> <div className="flex items-center gap-3"> {/* Search bar */} <div className="relative"> <input type="text" value={searchQuery} onChange={(e) => onSearchChange(e.target.value)} placeholder="Filter log..." className="w-48 pl-7 pr-3 py-1.5 bg-gray-800 border border-gray-700 rounded-md text-xs text-white placeholder-gray-500 font-mono focus:ring-1 focus:ring-blue-500 outline-none" /> <span className="absolute left-2 top-2 text-gray-500 text-xs">🔍</span> </div> {/* Auto-scroll toggle */} <button onClick={() => setAutoScroll(!autoScroll)} className={`flex items-center gap-1.5 px-2.5 py-1.5 text-xs rounded-md transition-colors ${ autoScroll ? 'bg-blue-500/20 text-blue-400 border border-blue-500/30' : 'bg-gray-800 text-gray-400 border border-gray-700' }`} > <span>⬇️</span> <span>Auto-scroll</span> </button> </div> </div> {/* Log content */} <div ref={containerRef} className="flex-1 overflow-y-auto p-4 font-mono text-sm" > {lines.length === 0 ? ( <div className="flex items-center justify-center h-full text-gray-500"> {searchQuery ? 'Tidak ada log yang cocok' : 'File log kosong'} </div> ) : ( <div className="space-y-0"> {lines.map((line) => ( <div key={line.number} className="flex hover:bg-gray-800/30 rounded px-2 py-0.5 group" > {/* Line number */} <span className="w-10 flex-shrink-0 text-right text-gray-600 select-none pr-3 group-hover:text-gray-400"> {line.number} </span> {/* Log content */} <span className={`flex-1 ${LEVEL_COLORS[line.level]}`}> <HighlightText text={line.content} search={searchQuery} /> </span> </div> ))} </div> )} </div> {/* Status bar */} <div className="flex items-center justify-between px-4 py-2 border-t border-gray-800 bg-gray-900/50 text-xs text-gray-500"> <span> {searchQuery && ( <span className="text-yellow-400"> Found {lines.length} matching line(s) </span> )} </span> <span className="flex items-center gap-2"> {autoScroll && <span className="w-1.5 h-1.5 rounded-full bg-green-500 animate-pulse" />} UTF-8 • LF </span> </div> </div> ); }

9.4 Halaman Utama Logs

Buat file app/logs/page.tsx:

// app/logs/page.tsx // Halaman utama Logs — terminal-style log viewer 'use client'; import { useEffect, useState, useCallback } from 'react'; import LogSidebar from './components/LogSidebar'; import LogViewer from './components/LogViewer'; interface LogFile { name: string; size: number; sizeFormatted: string; lastModified: string; category: 'system' | 'application' | 'security' | 'errors' | 'other'; } interface LogLine { number: number; content: string; level: string; } export default function LogsPage() { const [files, setFiles] = useState<LogFile[]>([]); const [activeFile, setActiveFile] = useState<string | null>(null); const [logLines, setLogLines] = useState<LogLine[]>([]); const [activeCategory, setActiveCategory] = useState('all'); const [searchQuery, setSearchQuery] = useState(''); const [loading, setLoading] = useState(true); const [logLoading, setLogLoading] = useState(false); // Fetch list file log const fetchFiles = useCallback(async () => { try { const res = await fetch(`/api/logs?category=${activeCategory}`); const data = await res.json(); setFiles(data.files); } catch (error) { console.error('Fetch files error:', error); } finally { setLoading(false); } }, [activeCategory]); // Fetch konten file log const fetchLogContent = useCallback(async (filename: string) => { setLogLoading(true); try { const searchParam = searchQuery ? `&search=${encodeURIComponent(searchQuery)}` : ''; const res = await fetch(`/api/logs?file=${encodeURIComponent(filename)}${searchParam}`); const data = await res.json(); setLogLines(data.lines); } catch (error) { console.error('Fetch log error:', error); setLogLines([]); } finally { setLogLoading(false); } }, [searchQuery]); // Initial load useEffect(() => { fetchFiles(); }, [fetchFiles]); // Load log content ketika file dipilih useEffect(() => { if (activeFile) { fetchLogContent(activeFile); } }, [activeFile, fetchLogContent]); // Auto-refresh log content setiap 10 detik useEffect(() => { if (!activeFile) return; const interval = setInterval(() => fetchLogContent(activeFile), 10000); return () => clearInterval(interval); }, [activeFile, fetchLogContent]); // Handle pilih file const handleSelectFile = (filename: string) => { setActiveFile(filename); setSearchQuery(''); }; if (loading) { return ( <div className="flex h-[calc(100vh-4rem)] gap-4 p-6"> <div className="w-72 bg-gray-800 rounded-xl animate-pulse flex-shrink-0" /> <div className="flex-1 bg-gray-950 rounded-xl animate-pulse" /> </div> ); } return ( <div className="flex flex-col h-[calc(100vh-4rem)] p-6 gap-4"> {/* Header */} <div> <h1 className="text-2xl font-bold text-white">📋 Logs</h1> <p className="text-gray-400 text-sm mt-1"> Monitor dan telusuri file log agent secara real-time </p> </div> {/* Main content: sidebar + viewer */} <div className="flex gap-4 flex-1 min-h-0"> {/* Sidebar: daftar file */} <div className="w-72 flex-shrink-0"> <LogSidebar files={files} activeFile={activeFile} onSelectFile={handleSelectFile} activeCategory={activeCategory} onCategoryChange={setActiveCategory} /> </div> {/* Viewer: konten log */} <div className="flex-1 relative"> {logLoading && activeFile && ( <div className="absolute inset-0 bg-gray-950/50 z-10 flex items-center justify-center"> <div className="animate-spin h-6 w-6 border-2 border-blue-500 border-t-transparent rounded-full" /> </div> )} <LogViewer lines={logLines} filename={activeFile} searchQuery={searchQuery} onSearchChange={(q) => { setSearchQuery(q); // Re-fetch kalau ada search query baru if (activeFile) { const timer = setTimeout(() => fetchLogContent(activeFile), 500); return () => clearTimeout(timer); } }} /> </div> </div> </div> ); }

⚠️ Pitfall: Path traversal attack! Di API route, SELALU validasi bahwa path yang direquest berada di dalam direktori log. Jangan pernah langsung pass filename dari user ke fs.readFile() tanpa sanitasi.

💡 Tips: Auto-scroll bagus untuk monitoring real-time, tapi bisa bikin pusing kalau lagi scroll ke atas untuk baca log lama. Jadi toggle-nya penting — user bisa matikan kapan saja.


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.