📎 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
💡 Tips: Session table pake
font-monobuat 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
useEffectreturn! 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
💡 Tips:
useMemobuat 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:
| Part | Halaman | Komponen Utama |
|---|---|---|
| 1 | Setup | Next.js 14, Tailwind, shadcn/ui, folder structure |
| 2 | Layout | Sidebar, Header, Shell wrapper |
| 3 | Dashboard | Stats, Chart, Activity Feed, Clock |
| 4 | Briefing | Email, Calendar, Tasks, Gold, Health, Weather cards |
| 5 | System | Gauge SVG, Process Table, Auto-polling |
| 6 | Sessions | Session Table, Bar Chart, Auto-refresh |
| 7 | Skills Hub | Skill 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
| Part | Halaman | Fitur Utama |
|---|---|---|
| 8 | Schedule | Cron jobs, job lifecycle |
| 9 | Logs | Terminal viewer, log pipeline |
| 10 | Models | Model cards, cost comparison |
| 11 | Settings | 7 tab konfigurasi |
| 12 | Animasi | Framer Motion, skeleton, toast |
| 13 | API Routes | Backend Next.js API |
| 14 | Deployment | PM2, 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
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
requestAnimationFramesupaya smooth dan nggak blocking main thread. Lebih baik daripadasetIntervaluntuk 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 "Add Job" 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
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.