Tech

AI Agent Dashboard Bagian 3: Sessions, Skills & Logs

Part 3 — Track sessions, manage skills hub, schedule cron jobs, dan monitoring logs.
47
1 bulan lalu
Zainul Fanani
AI Agent Dashboard Bagian 3: Sessions, Skills & Logs
📅 28 Mar 2026🤍 0 👁 0 🔗 0

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

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

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:

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:

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:

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

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:

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:

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:

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:

Quick Start Commands

bash
# 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 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:

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

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

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:

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:

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:

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:

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:

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:

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

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:

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:

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)

📬 Subscribe Newsletter

Free

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

👥 Join 0+ engineers & tech enthusiasts

F

Zainul Fanani

Founder, Radian Group. Engineering & tech enthusiast.

💬 Komentar

Catatan Fanani

Ngutak-ngatik teknologi, nulis pengalaman.

Perusahaan

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