refactor(dashboard): rewrite activity page, simplify activity API
This commit is contained in:
parent
2cbb9b3bcf
commit
84aa98dd14
2 changed files with 120 additions and 332 deletions
|
|
@ -1,142 +1,112 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import useSWR from "swr"
|
||||
import { useEffect, useState } from "react"
|
||||
import { ScrollText } from "lucide-react"
|
||||
|
||||
const fetcher = (url: string) => fetch(url).then((res) => res.json())
|
||||
|
||||
interface ActivityEntry {
|
||||
id: string
|
||||
type: "heartbeat" | "chat" | "config" | "memory" | "system" | "cron"
|
||||
type: string
|
||||
timestamp: string
|
||||
description: 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,
|
||||
})
|
||||
source: string
|
||||
}
|
||||
|
||||
export default function ActivityPage() {
|
||||
const [limit, setLimit] = React.useState(200)
|
||||
const { data, error } = useSWR(`/api/activity?limit=${limit}`, fetcher, { refreshInterval: 10000 })
|
||||
const [entries, setEntries] = useState<ActivityEntry[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const entries = (data?.entries || []) as ActivityEntry[]
|
||||
const total = data?.total || 0
|
||||
const grouped = groupByDate(entries)
|
||||
useEffect(() => {
|
||||
fetch("/api/activity?limit=20")
|
||||
.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 (
|
||||
<div className="flex flex-col h-[calc(100vh-4rem)]">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b">
|
||||
<div className="flex items-center gap-3">
|
||||
<ScrollText className="h-6 w-6 text-primary" />
|
||||
<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 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>
|
||||
<span className="text-muted-foreground text-sm">({entries.length} entries)</span>
|
||||
</div>
|
||||
|
||||
{/* Timeline */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{error ? (
|
||||
<div className="p-8 text-center text-destructive">Failed to load activity log</div>
|
||||
) : !data ? (
|
||||
<div className="p-8 text-center text-muted-foreground">Loading activity...</div>
|
||||
) : entries.length === 0 ? (
|
||||
<div className="p-8 text-center text-muted-foreground">No activity recorded yet</div>
|
||||
) : (
|
||||
<div className="p-6 space-y-8">
|
||||
{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 className="space-y-2">
|
||||
{entries.map((entry, i) => (
|
||||
<div key={i} className="flex items-start gap-3 p-3 rounded-lg border bg-card/30">
|
||||
<div className="text-lg">{getSourceLabel(entry.source)}</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm truncate">{entry.description}</div>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span className={getTypeColor(entry.type)}>{entry.type}</span>
|
||||
<span>•</span>
|
||||
<span>{formatDate(entry.timestamp)}</span>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,226 +1,44 @@
|
|||
import { NextResponse } from "next/server"
|
||||
import fs from "fs"
|
||||
import path from "path"
|
||||
import os from "os"
|
||||
import { bridgeGet } from "@/lib/bridge"
|
||||
|
||||
interface ActivityEntry {
|
||||
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 const dynamic = "force-dynamic"
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
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")
|
||||
const workspace = "/Users/manohar_air/clawd"
|
||||
const memoryDir = path.join(workspace, "memory")
|
||||
// Get activity from bridge endpoint that already works
|
||||
const bridgeData = await bridgeGet("/tiger/agents/activity") as {
|
||||
ok: boolean
|
||||
events: Array<{
|
||||
agentId: string
|
||||
agentName: string
|
||||
agentEmoji: string
|
||||
path: string
|
||||
action: string
|
||||
ts: number
|
||||
}>
|
||||
}
|
||||
|
||||
// Aggregate from all sources
|
||||
const commandEntries = parseCommandsLog(path.join(clawdbotLogsDir, "commands.log"))
|
||||
const gatewayEntries = parseGatewayLog(path.join(clawdbotLogsDir, "gateway.log"))
|
||||
const memoryEntries = parseMemoryFiles(memoryDir)
|
||||
if (!bridgeData?.ok || !bridgeData.events) {
|
||||
return NextResponse.json({ entries: [], total: 0 })
|
||||
}
|
||||
|
||||
// Merge and sort by timestamp descending
|
||||
const allEntries = [...commandEntries, ...gatewayEntries, ...memoryEntries]
|
||||
allEntries.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime())
|
||||
|
||||
// Apply limit
|
||||
const entries = allEntries.slice(0, limit)
|
||||
// Transform bridge format to activity format
|
||||
const entries = bridgeData.events.slice(0, limit).map((e) => ({
|
||||
id: `${e.agentId}-${e.ts}`,
|
||||
type: "system",
|
||||
timestamp: new Date(e.ts).toISOString(),
|
||||
description: `${e.agentName} modified ${e.path}`,
|
||||
source: e.agentId,
|
||||
}))
|
||||
|
||||
return NextResponse.json({
|
||||
entries,
|
||||
total: allEntries.length,
|
||||
total: bridgeData.events.length,
|
||||
})
|
||||
} catch {
|
||||
} catch (err) {
|
||||
return NextResponse.json({ error: "Failed to fetch activity" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue