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"
|
"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>
|
||||||
</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 { 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 })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Loading…
Add table
Reference in a new issue