refactor(dashboard): rewrite activity page, simplify activity API

This commit is contained in:
Manohar 2026-06-05 19:49:23 +00:00
parent 2cbb9b3bcf
commit 84aa98dd14
2 changed files with 120 additions and 332 deletions

View file

@ -1,142 +1,112 @@
"use client" "use client"
import * as React from "react" import { useEffect, useState } from "react"
import useSWR from "swr"
import { ScrollText } from "lucide-react" import { ScrollText } from "lucide-react"
const fetcher = (url: string) => fetch(url).then((res) => res.json())
interface ActivityEntry { interface ActivityEntry {
id: string id: string
type: "heartbeat" | "chat" | "config" | "memory" | "system" | "cron" type: string
timestamp: string timestamp: string
description: string description: string
source?: string source: string
}
const typeColors: Record<string, string> = {
heartbeat: "bg-yellow-400",
chat: "bg-blue-400",
config: "bg-orange-400",
memory: "bg-green-400",
system: "bg-purple-400",
cron: "bg-cyan-400",
}
function groupByDate(entries: ActivityEntry[]): Record<string, ActivityEntry[]> {
const groups: Record<string, ActivityEntry[]> = {}
for (const entry of entries) {
const date = new Date(entry.timestamp)
const key = date.toLocaleDateString("en-US", {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
}).toUpperCase()
if (!groups[key]) groups[key] = []
groups[key].push(entry)
}
return groups
}
function formatTime(timestamp: string): string {
return new Date(timestamp).toLocaleTimeString("en-US", {
hour: "numeric",
minute: "2-digit",
hour12: true,
})
} }
export default function ActivityPage() { export default function ActivityPage() {
const [limit, setLimit] = React.useState(200) const [entries, setEntries] = useState<ActivityEntry[]>([])
const { data, error } = useSWR(`/api/activity?limit=${limit}`, fetcher, { refreshInterval: 10000 }) const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const entries = (data?.entries || []) as ActivityEntry[] useEffect(() => {
const total = data?.total || 0 fetch("/api/activity?limit=20")
const grouped = groupByDate(entries) .then(r => r.json())
.then(data => {
if (data?.entries) {
setEntries(data.entries)
}
setLoading(false)
})
.catch(e => {
console.error("Failed to load:", e)
setError(e.message)
setLoading(false)
})
}, [])
const formatDate = (ts: string) => {
if (!ts) return ""
return new Date(ts).toLocaleString()
}
const getTypeColor = (type: string) => {
switch (type) {
case "heartbeat": return "text-green-500"
case "chat": return "text-blue-500"
case "config": return "text-yellow-500"
case "memory": return "text-purple-500"
case "system": return "text-orange-500"
case "cron": return "text-cyan-500"
default: return "text-muted-foreground"
}
}
const getSourceLabel = (source: string) => {
switch (source) {
case "main": return "🐅 Tiger"
case "coder": return "📦 Cody"
case "researcher": return "🔬 Ethan"
case "pm": return "📋 Elon"
default: return source || "🤖"
}
}
if (loading) {
return (
<div className="p-6">
<div className="flex items-center gap-2 mb-4">
<ScrollText className="h-5 w-5" />
<h1 className="text-2xl font-bold">Activity</h1>
</div>
<div className="text-muted-foreground">Loading activity log...</div>
</div>
)
}
if (error) {
return (
<div className="p-6">
<div className="flex items-center gap-2 mb-4">
<ScrollText className="h-5 w-5" />
<h1 className="text-2xl font-bold">Activity</h1>
</div>
<div className="text-red-500">Failed to load activity log</div>
<div className="text-sm text-muted-foreground mt-2">{error}</div>
</div>
)
}
return ( return (
<div className="flex flex-col h-[calc(100vh-4rem)]"> <div className="p-6">
{/* Header */} <div className="flex items-center gap-2 mb-4">
<div className="flex items-center justify-between p-6 border-b"> <ScrollText className="h-5 w-5" />
<div className="flex items-center gap-3"> <h1 className="text-2xl font-bold">Activity</h1>
<ScrollText className="h-6 w-6 text-primary" /> <span className="text-muted-foreground text-sm">({entries.length} entries)</span>
<div>
<h1 className="text-2xl font-bold tracking-tight">Activity Log</h1>
<p className="text-sm text-muted-foreground">A chronological record of agent actions and events</p>
</div>
</div>
<div className="flex items-center gap-3">
<span className="text-xs font-medium text-muted-foreground px-3 py-1.5 rounded-full border border-border bg-muted/50">
{total} entries
</span>
</div>
</div> </div>
{/* Timeline */} <div className="space-y-2">
<div className="flex-1 overflow-y-auto"> {entries.map((entry, i) => (
{error ? ( <div key={i} className="flex items-start gap-3 p-3 rounded-lg border bg-card/30">
<div className="p-8 text-center text-destructive">Failed to load activity log</div> <div className="text-lg">{getSourceLabel(entry.source)}</div>
) : !data ? ( <div className="flex-1 min-w-0">
<div className="p-8 text-center text-muted-foreground">Loading activity...</div> <div className="text-sm truncate">{entry.description}</div>
) : entries.length === 0 ? ( <div className="flex items-center gap-2 text-xs text-muted-foreground">
<div className="p-8 text-center text-muted-foreground">No activity recorded yet</div> <span className={getTypeColor(entry.type)}>{entry.type}</span>
) : ( <span></span>
<div className="p-6 space-y-8"> <span>{formatDate(entry.timestamp)}</span>
{Object.entries(grouped).map(([dateLabel, dateEntries]) => (
<div key={dateLabel}>
{/* Date Header */}
<div className="flex items-center gap-3 mb-4">
<div className="text-xs font-semibold tracking-wider text-muted-foreground">
{dateLabel}
</div>
</div>
{/* Timeline entries */}
<div className="relative ml-4">
{/* Vertical line */}
<div className="absolute left-[7px] top-3 bottom-3 w-[2px] bg-border" />
<div className="space-y-3">
{dateEntries.map((entry) => (
<div key={entry.id} className="relative flex items-start gap-4 group">
{/* Dot */}
<div className="relative z-10 mt-3.5">
<div className={`h-4 w-4 rounded-full border-2 border-background ${typeColors[entry.type] || "bg-muted-foreground"}`} />
</div>
{/* Card */}
<div className="flex-1 p-3 rounded-lg border border-border bg-card/60 hover:bg-card/80 transition-colors">
<div className="flex items-start gap-3">
<span className="text-xs font-semibold text-primary whitespace-nowrap mt-0.5">
{formatTime(entry.timestamp)}
</span>
<p className="text-sm text-foreground leading-relaxed">
{entry.description}
</p>
</div>
</div>
</div>
))}
</div>
</div>
</div> </div>
))} </div>
{/* Load more */}
{entries.length < total && (
<div className="text-center pt-4">
<button
onClick={() => setLimit((prev) => prev + 200)}
className="text-xs font-medium text-primary hover:text-primary/80 px-4 py-2 rounded-md border border-border hover:bg-muted/30 transition-colors"
>
Load more ({total - entries.length} remaining)
</button>
</div>
)}
</div> </div>
)} ))}
</div> </div>
</div> </div>
) )
} }

View file

@ -1,226 +1,44 @@
import { NextResponse } from "next/server" import { NextResponse } from "next/server"
import fs from "fs" import { bridgeGet } from "@/lib/bridge"
import path from "path"
import os from "os"
interface ActivityEntry { export const dynamic = "force-dynamic"
id: string
type: "heartbeat" | "chat" | "config" | "memory" | "system" | "cron"
timestamp: string
description: string
source?: string
}
function parseCommandsLog(logPath: string): ActivityEntry[] {
const entries: ActivityEntry[] = []
try {
const content = fs.readFileSync(logPath, "utf-8").trim()
if (!content) return entries
for (const line of content.split("\n")) {
try {
const data = JSON.parse(line) as {
timestamp: string
action: string
sessionKey: string
senderId: string
source: string
}
const actionLabels: Record<string, string> = {
new: "New chat session",
reset: "Session reset",
delete: "Session deleted",
}
const sourceLabels: Record<string, string> = {
webchat: "Web Chat",
telegram: "Telegram",
whatsapp: "WhatsApp",
cli: "CLI",
}
const actionLabel = actionLabels[data.action] || data.action
const sourceLabel = sourceLabels[data.source] || data.source
entries.push({
id: `cmd-${data.timestamp}-${data.action}`,
type: "chat",
timestamp: data.timestamp,
description: `${actionLabel} via ${sourceLabel}`,
source: data.source,
})
} catch {
// skip malformed lines
}
}
} catch {
// file not found
}
return entries
}
function parseGatewayLog(logPath: string): ActivityEntry[] {
const entries: ActivityEntry[] = []
try {
const content = fs.readFileSync(logPath, "utf-8")
const lines = content.split("\n")
// Track seen heartbeat dates to deduplicate (only first per gateway restart)
let lastHeartbeatDay = ""
for (const line of lines) {
if (!line.trim()) continue
// Parse timestamp from start of line: "2026-01-27T02:35:09.747Z [tag] message"
const match = line.match(/^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+Z)\s+(.*)$/)
if (!match) continue
const timestamp = match[1]
const rest = match[2]
// Heartbeat events - only include first per gateway restart (not every "started")
if (rest.startsWith("[heartbeat]")) {
const msg = rest.replace("[heartbeat]", "").trim()
// Skip repetitive "started" - only keep once per gateway boot
if (msg === "started") {
const day = timestamp.slice(0, 10)
if (day === lastHeartbeatDay) continue
lastHeartbeatDay = day
entries.push({
id: `gw-hb-${timestamp}`,
type: "heartbeat",
timestamp,
description: "Heartbeat service started",
})
} else {
// Non-"started" heartbeat messages are meaningful
entries.push({
id: `gw-hb-${timestamp}`,
type: "heartbeat",
timestamp,
description: `Heartbeat: ${msg}`,
})
}
continue
}
// Config reload events
if (rest.startsWith("[reload]")) {
const msg = rest.replace("[reload]", "").trim()
const changedMatch = msg.match(/evaluating reload \((.+)\)/)
const changed = changedMatch ? changedMatch[1] : msg
entries.push({
id: `gw-reload-${timestamp}`,
type: "config",
timestamp,
description: `Config reload: ${changed}`,
})
continue
}
// Gateway startup/lifecycle - only truly significant events
if (rest.startsWith("[gateway]")) {
const msg = rest.replace("[gateway]", "").trim()
if (msg.startsWith("listening on")) {
// Reset heartbeat dedup on new gateway start
lastHeartbeatDay = ""
entries.push({
id: `gw-sys-${timestamp}`,
type: "system",
timestamp,
description: `Gateway started: ${msg}`,
})
} else if (msg.startsWith("agent model:")) {
entries.push({
id: `gw-sys-${timestamp}`,
type: "system",
timestamp,
description: `Active model: ${msg.replace("agent model:", "").trim()}`,
})
} else if (msg.includes("signal")) {
entries.push({
id: `gw-sys-${timestamp}`,
type: "system",
timestamp,
description: msg,
})
}
continue
}
// Telegram provider start (only once per restart)
if (rest.startsWith("[telegram]") && rest.includes("starting provider")) {
const botMatch = rest.match(/\(@[^)]+\)/)
entries.push({
id: `gw-tg-${timestamp}`,
type: "system",
timestamp,
description: `Telegram provider started ${botMatch?.[0] || ""}`.trim(),
})
continue
}
// Cron execution events
if (rest.includes("cron.run") && rest.includes("[ws]") && rest.includes("res ✓")) {
entries.push({
id: `gw-cron-${timestamp}`,
type: "cron",
timestamp,
description: "Cron job executed",
})
continue
}
}
} catch {
// file not found
}
return entries
}
function parseMemoryFiles(memoryDir: string): ActivityEntry[] {
const entries: ActivityEntry[] = []
try {
const files = fs.readdirSync(memoryDir).filter((f) => f.endsWith(".md"))
for (const file of files) {
const filePath = path.join(memoryDir, file)
const stat = fs.statSync(filePath)
const slug = file.replace(/\.md$/, "").replace(/^\d{4}-\d{2}-\d{2}-/, "")
const title = slug.replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase())
entries.push({
id: `mem-${file}`,
type: "memory",
timestamp: stat.mtime.toISOString(),
description: `Memory saved: ${title}`,
})
}
} catch {
// directory not found
}
return entries
}
export async function GET(request: Request) { export async function GET(request: Request) {
try { try {
const url = new URL(request.url) const url = new URL(request.url)
const limit = parseInt(url.searchParams.get("limit") || "200", 10) const limit = parseInt(url.searchParams.get("limit") || "50", 10)
const clawdbotLogsDir = path.join(os.homedir(), ".clawdbot", "logs") // Get activity from bridge endpoint that already works
const workspace = "/Users/manohar_air/clawd" const bridgeData = await bridgeGet("/tiger/agents/activity") as {
const memoryDir = path.join(workspace, "memory") ok: boolean
events: Array<{
agentId: string
agentName: string
agentEmoji: string
path: string
action: string
ts: number
}>
}
// Aggregate from all sources if (!bridgeData?.ok || !bridgeData.events) {
const commandEntries = parseCommandsLog(path.join(clawdbotLogsDir, "commands.log")) return NextResponse.json({ entries: [], total: 0 })
const gatewayEntries = parseGatewayLog(path.join(clawdbotLogsDir, "gateway.log")) }
const memoryEntries = parseMemoryFiles(memoryDir)
// Merge and sort by timestamp descending // Transform bridge format to activity format
const allEntries = [...commandEntries, ...gatewayEntries, ...memoryEntries] const entries = bridgeData.events.slice(0, limit).map((e) => ({
allEntries.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()) id: `${e.agentId}-${e.ts}`,
type: "system",
// Apply limit timestamp: new Date(e.ts).toISOString(),
const entries = allEntries.slice(0, limit) description: `${e.agentName} modified ${e.path}`,
source: e.agentId,
}))
return NextResponse.json({ return NextResponse.json({
entries, entries,
total: allEntries.length, total: bridgeData.events.length,
}) })
} catch { } catch (err) {
return NextResponse.json({ error: "Failed to fetch activity" }, { status: 500 }) return NextResponse.json({ error: "Failed to fetch activity" }, { status: 500 })
} }
} }