|
| 1 | +import { useEffect, useMemo, useState } from "react"; |
| 2 | +import { Link, useParams } from "react-router-dom"; |
| 3 | +import { api } from "../api"; |
| 4 | +import { useSSE } from "../SSEContext"; |
| 5 | +import type { ActivityLog, LogEvent } from "../types"; |
| 6 | + |
| 7 | +// The project activity log: a linear, cross-session feed of MCP build runs |
| 8 | +// (success/failure with the full traceback), alias creations, and Buckaroo grid |
| 9 | +// loads. Filterable by category (mcp/alias/buckaroo) and by the session that |
| 10 | +// produced the events — multiple Claude Code sessions write to one project. |
| 11 | + |
| 12 | +const CATEGORIES = ["mcp", "alias", "buckaroo"] as const; |
| 13 | +type Category = (typeof CATEGORIES)[number]; |
| 14 | + |
| 15 | +const ICON: Record<string, string> = { build_error: "✗", build_ok: "✓", alias_set: "★", buckaroo: "▤" }; |
| 16 | +const SESSION_COLORS = ["#6ea8fe", "#73ae80", "#e0915e", "#b39ddb", "#7fd1c4", "#d6b35e", "#e8849b", "#9ab07a"]; |
| 17 | + |
| 18 | +function sessionColor(s: string): string { |
| 19 | + let h = 0; |
| 20 | + for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) >>> 0; |
| 21 | + return SESSION_COLORS[h % SESSION_COLORS.length]; |
| 22 | +} |
| 23 | + |
| 24 | +function fmtTime(ts: string): string { |
| 25 | + return ts.slice(11, 19); // HH:MM:SS (UTC, as stored) |
| 26 | +} |
| 27 | + |
| 28 | +function summarize(e: LogEvent): string { |
| 29 | + switch (e.kind) { |
| 30 | + case "build_error": |
| 31 | + return `build failed · ${e.tool ?? "?"} · ${(e.message ?? "").split("\n")[0].slice(0, 120)}`; |
| 32 | + case "build_ok": |
| 33 | + return `ran · ${e.tool ?? "?"}${e.hash ? ` · ${e.hash.slice(0, 12)}` : ""}`; |
| 34 | + case "alias_set": |
| 35 | + return `${e.alias} V${e.version} · ${e.tool ?? "?"}`; |
| 36 | + case "buckaroo": |
| 37 | + return `grid ${e.status ?? "?"}${e.hash ? ` · ${e.hash.slice(0, 12)}` : ""}${e.load_ms != null ? ` · ${e.load_ms}ms` : ""}`; |
| 38 | + default: |
| 39 | + return e.kind; |
| 40 | + } |
| 41 | +} |
| 42 | + |
| 43 | +function hasDetail(e: LogEvent): boolean { |
| 44 | + return Boolean(e.code || e.prompt || e.traceback || e.detail || e.load_ms != null || e.hash); |
| 45 | +} |
| 46 | + |
| 47 | +export function LogPage() { |
| 48 | + const { project } = useParams<{ project: string }>(); |
| 49 | + const { version } = useSSE(); |
| 50 | + const [data, setData] = useState<ActivityLog | null>(null); |
| 51 | + const [cats, setCats] = useState<Set<Category>>(new Set(CATEGORIES)); |
| 52 | + const [session, setSession] = useState<string | null>(null); |
| 53 | + const [expanded, setExpanded] = useState<Set<string>>(new Set()); |
| 54 | + const [oldestFirst, setOldestFirst] = useState(false); |
| 55 | + |
| 56 | + useEffect(() => { |
| 57 | + if (!project) return; |
| 58 | + api |
| 59 | + .log(project) |
| 60 | + .then(setData) |
| 61 | + .catch(() => {}); |
| 62 | + }, [project, version]); // SSE bumps `version` on new_entry/build_failed → live refresh |
| 63 | + |
| 64 | + const events = useMemo(() => { |
| 65 | + if (!data) return []; |
| 66 | + let evs = data.events.filter((e) => cats.has(e.category as Category)); |
| 67 | + if (session) evs = evs.filter((e) => e.session === session); |
| 68 | + if (oldestFirst) evs = [...evs].reverse(); |
| 69 | + return evs; |
| 70 | + }, [data, cats, session, oldestFirst]); |
| 71 | + |
| 72 | + if (!project) return null; |
| 73 | + |
| 74 | + const toggleCat = (c: Category) => |
| 75 | + setCats((s) => { |
| 76 | + const n = new Set(s); |
| 77 | + if (n.has(c)) n.delete(c); |
| 78 | + else n.add(c); |
| 79 | + return n; |
| 80 | + }); |
| 81 | + const toggleExpand = (key: string) => |
| 82 | + setExpanded((s) => { |
| 83 | + const n = new Set(s); |
| 84 | + if (n.has(key)) n.delete(key); |
| 85 | + else n.add(key); |
| 86 | + return n; |
| 87 | + }); |
| 88 | + |
| 89 | + return ( |
| 90 | + <main style={{ padding: "8px 12px", overflow: "auto" }}> |
| 91 | + <div className="log-page"> |
| 92 | + <div className="log-filters"> |
| 93 | + <span className="meta">show</span> |
| 94 | + {CATEGORIES.map((c) => ( |
| 95 | + <button key={c} type="button" className={`chip${cats.has(c) ? " on" : ""}`} onClick={() => toggleCat(c)}> |
| 96 | + {c} |
| 97 | + </button> |
| 98 | + ))} |
| 99 | + <span className="log-sep" /> |
| 100 | + <span className="meta">session</span> |
| 101 | + <button type="button" className={`chip${session === null ? " on" : ""}`} onClick={() => setSession(null)}> |
| 102 | + all |
| 103 | + </button> |
| 104 | + {data?.sessions.map((s) => ( |
| 105 | + <button |
| 106 | + key={s.session} |
| 107 | + type="button" |
| 108 | + className={`chip${session === s.session ? " on" : ""}`} |
| 109 | + onClick={() => setSession(session === s.session ? null : s.session)} |
| 110 | + title={`${s.count} events · last ${fmtTime(s.last_ts)}`} |
| 111 | + > |
| 112 | + <span className="session-dot" style={{ background: sessionColor(s.session) }} /> {s.session} |
| 113 | + </button> |
| 114 | + ))} |
| 115 | + <span className="log-sep" /> |
| 116 | + <button type="button" className="chip" onClick={() => setOldestFirst((v) => !v)}> |
| 117 | + {oldestFirst ? "oldest first" : "newest first"} |
| 118 | + </button> |
| 119 | + </div> |
| 120 | + |
| 121 | + {!data ? ( |
| 122 | + <div className="meta">loading…</div> |
| 123 | + ) : events.length === 0 ? ( |
| 124 | + <div className="meta">{data.events.length ? "no events match the filters." : "no activity recorded yet."}</div> |
| 125 | + ) : ( |
| 126 | + <ol className="log-list"> |
| 127 | + {events.map((e, i) => { |
| 128 | + const key = `${e.ts}-${i}`; |
| 129 | + const open = expanded.has(key); |
| 130 | + const detail = hasDetail(e); |
| 131 | + return ( |
| 132 | + <li key={key} className={`log-row kind-${e.kind}`}> |
| 133 | + <button |
| 134 | + type="button" |
| 135 | + className="log-head" |
| 136 | + onClick={() => detail && toggleExpand(key)} |
| 137 | + style={{ cursor: detail ? "pointer" : "default" }} |
| 138 | + > |
| 139 | + <span className="log-time">{fmtTime(e.ts)}</span> |
| 140 | + <span className={`cat-badge cat-${e.category}`}> |
| 141 | + {ICON[e.kind] ?? "•"} {e.category} |
| 142 | + </span> |
| 143 | + {e.session ? ( |
| 144 | + <span |
| 145 | + className="session-badge" |
| 146 | + style={{ borderColor: sessionColor(e.session), color: sessionColor(e.session) }} |
| 147 | + > |
| 148 | + {e.session} |
| 149 | + </span> |
| 150 | + ) : e.origin === "companion" ? ( |
| 151 | + <span className="session-badge muted">companion</span> |
| 152 | + ) : null} |
| 153 | + <span className="log-summary">{summarize(e)}</span> |
| 154 | + {detail && <span className="log-caret">{open ? "▾" : "▸"}</span>} |
| 155 | + </button> |
| 156 | + {open && ( |
| 157 | + <div className="log-detail"> |
| 158 | + {e.prompt && ( |
| 159 | + <div className="log-prompt"> |
| 160 | + <strong>prompt:</strong> {e.prompt} |
| 161 | + </div> |
| 162 | + )} |
| 163 | + {e.hash && ( |
| 164 | + <div className="meta"> |
| 165 | + entry <Link to={`/${project}/catalog/${e.hash}`}>{e.hash.slice(0, 12)}</Link> |
| 166 | + </div> |
| 167 | + )} |
| 168 | + {e.detail && <div className="meta">{e.detail}</div>} |
| 169 | + {(e.load_ms != null || e.load_expr_ms != null) && ( |
| 170 | + <div className="meta"> |
| 171 | + timing:{e.load_ms != null && ` ${e.load_ms}ms total`} |
| 172 | + {e.load_expr_ms != null && ` · ${e.load_expr_ms}ms load_expr`} |
| 173 | + </div> |
| 174 | + )} |
| 175 | + {e.code && ( |
| 176 | + <> |
| 177 | + <div className="meta">code</div> |
| 178 | + <pre className="code">{e.code}</pre> |
| 179 | + </> |
| 180 | + )} |
| 181 | + {e.traceback && ( |
| 182 | + <> |
| 183 | + <div className="meta">traceback</div> |
| 184 | + <pre className="code log-traceback">{e.traceback}</pre> |
| 185 | + </> |
| 186 | + )} |
| 187 | + </div> |
| 188 | + )} |
| 189 | + </li> |
| 190 | + ); |
| 191 | + })} |
| 192 | + </ol> |
| 193 | + )} |
| 194 | + </div> |
| 195 | + </main> |
| 196 | + ); |
| 197 | +} |
0 commit comments