| 1 | "use client"; |
| 2 | |
| 3 | import { useCallback, useEffect, useState } from "react"; |
| 4 | import { ring, type RingLogEntry } from "@/lib/api"; |
| 5 | import { RingLogo } from "@/app/components/ring-logo"; |
| 6 | |
| 7 | interface RingLogViewerProps { |
| 8 | owner?: string; |
| 9 | repo?: string; |
| 10 | } |
| 11 | |
| 12 | function formatTs(ts: string): string { |
| 13 | const time = new Date(ts); |
| 14 | if (Number.isNaN(time.getTime())) return ts; |
| 15 | return time.toLocaleString(); |
| 16 | } |
| 17 | |
| 18 | function levelStyle(level: string): { bg: string; fg: string } { |
| 19 | switch (level.toLowerCase()) { |
| 20 | case "error": |
| 21 | return { bg: "var(--status-closed-bg)", fg: "var(--status-closed-text)" }; |
| 22 | case "warn": |
| 23 | case "warning": |
| 24 | return { bg: "var(--bg-inset)", fg: "var(--text-muted)" }; |
| 25 | case "debug": |
| 26 | return { bg: "var(--bg-inset)", fg: "var(--text-faint)" }; |
| 27 | default: |
| 28 | return { bg: "var(--status-open-bg)", fg: "var(--status-open-text)" }; |
| 29 | } |
| 30 | } |
| 31 | |
| 32 | function renderPayload(payload: unknown): string { |
| 33 | if (typeof payload === "string") return payload; |
| 34 | try { |
| 35 | return JSON.stringify(payload, null, 2); |
| 36 | } catch { |
| 37 | return String(payload); |
| 38 | } |
| 39 | } |
| 40 | |
| 41 | function hasExpandedPayload(entry: RingLogEntry): boolean { |
| 42 | if (entry.payload === null) return false; |
| 43 | if (typeof entry.payload === "string") return entry.payload !== entry.message; |
| 44 | if (typeof entry.payload === "number" || typeof entry.payload === "boolean") { |
| 45 | return String(entry.payload) !== entry.message; |
| 46 | } |
| 47 | return true; |
| 48 | } |
| 49 | |
| 50 | export function RingLogViewer({ owner, repo }: RingLogViewerProps) { |
| 51 | const scoped = Boolean(owner && repo); |
| 52 | const endpointPath = scoped |
| 53 | ? `/api/repos/${owner}/${repo}/ring/logs` |
| 54 | : "/api/ring/logs"; |
| 55 | |
| 56 | const [entries, setEntries] = useState<RingLogEntry[]>([]); |
| 57 | const [loading, setLoading] = useState(true); |
| 58 | const [error, setError] = useState<string | null>(null); |
| 59 | |
| 60 | const fetchLogs = useCallback( |
| 61 | async (isBackground = false) => { |
| 62 | if (!isBackground) setLoading(true); |
| 63 | try { |
| 64 | const data = scoped && owner && repo |
| 65 | ? await ring.listRepoLogs(owner, repo, { limit: 250 }) |
| 66 | : await ring.listLogs({ limit: 250 }); |
| 67 | setEntries(data.entries); |
| 68 | setError(null); |
| 69 | } catch { |
| 70 | setError(`Ring API unavailable. Could not load ${endpointPath}.`); |
| 71 | } finally { |
| 72 | setLoading(false); |
| 73 | } |
| 74 | }, |
| 75 | [endpointPath, owner, repo, scoped] |
| 76 | ); |
| 77 | |
| 78 | useEffect(() => { |
| 79 | void fetchLogs(); |
| 80 | }, [fetchLogs]); |
| 81 | |
| 82 | useEffect(() => { |
| 83 | const interval = window.setInterval(() => { |
| 84 | void fetchLogs(true); |
| 85 | }, 2500); |
| 86 | return () => window.clearInterval(interval); |
| 87 | }, [fetchLogs]); |
| 88 | |
| 89 | return ( |
| 90 | <div className="max-w-3xl mx-auto px-4 py-6"> |
| 91 | {error && ( |
| 92 | <div |
| 93 | className="mb-4 text-sm px-3 py-2" |
| 94 | style={{ |
| 95 | border: "1px solid var(--status-closed-border)", |
| 96 | backgroundColor: "var(--status-closed-bg)", |
| 97 | color: "var(--status-closed-text)", |
| 98 | }} |
| 99 | > |
| 100 | {error} |
| 101 | </div> |
| 102 | )} |
| 103 | |
| 104 | <div style={{ border: "1px solid var(--border-subtle)" }}> |
| 105 | <div |
| 106 | className="text-xs px-3 py-2 flex items-center justify-between" |
| 107 | style={{ |
| 108 | borderBottom: "1px solid var(--divide)", |
| 109 | color: "var(--text-faint)", |
| 110 | backgroundColor: "var(--bg-card)", |
| 111 | }} |
| 112 | > |
| 113 | <span>Newest first</span> |
| 114 | <span>{entries.length} shown</span> |
| 115 | </div> |
| 116 | |
| 117 | {loading && entries.length === 0 ? ( |
| 118 | <div className="p-3 flex flex-col gap-2"> |
| 119 | {Array.from({ length: 6 }).map((_, i) => ( |
| 120 | <div key={i} className="skeleton" style={{ height: "2rem" }} /> |
| 121 | ))} |
| 122 | </div> |
| 123 | ) : entries.length === 0 ? ( |
| 124 | <div |
| 125 | className="px-3 py-10 text-sm text-center" |
| 126 | style={{ |
| 127 | color: "var(--text-faint)", |
| 128 | backgroundColor: "var(--bg-card)", |
| 129 | }} |
| 130 | > |
| 131 | <div className="flex justify-center mb-2 opacity-70"> |
| 132 | <RingLogo size={26} /> |
| 133 | </div> |
| 134 | No logs yet. Send JSON to <code>{endpointPath}</code>. |
| 135 | </div> |
| 136 | ) : ( |
| 137 | <div> |
| 138 | {entries.map((entry, i) => { |
| 139 | const level = levelStyle(entry.level); |
| 140 | return ( |
| 141 | <div |
| 142 | key={`${entry.ts}-${i}`} |
| 143 | className="px-3 py-2" |
| 144 | style={{ borderTop: i > 0 ? "1px solid var(--divide)" : undefined }} |
| 145 | > |
| 146 | <div className="flex flex-wrap items-center gap-2 text-xs mb-1"> |
| 147 | <span |
| 148 | className="font-mono" |
| 149 | style={{ color: "var(--text-faint)" }} |
| 150 | title={entry.ts} |
| 151 | > |
| 152 | {formatTs(entry.ts)} |
| 153 | </span> |
| 154 | <span |
| 155 | className="px-1.5 py-0.5 rounded" |
| 156 | style={{ |
| 157 | border: "1px solid var(--border-subtle)", |
| 158 | color: "var(--text-muted)", |
| 159 | backgroundColor: "var(--bg-card)", |
| 160 | }} |
| 161 | > |
| 162 | {entry.source || "ingest"} |
| 163 | </span> |
| 164 | <span |
| 165 | className="px-1.5 py-0.5 rounded capitalize" |
| 166 | style={{ color: level.fg, backgroundColor: level.bg }} |
| 167 | > |
| 168 | {entry.level || "info"} |
| 169 | </span> |
| 170 | </div> |
| 171 | <div className="text-sm font-mono break-words" style={{ color: "var(--text-primary)" }}> |
| 172 | {entry.message} |
| 173 | </div> |
| 174 | {hasExpandedPayload(entry) && ( |
| 175 | <details className="mt-1"> |
| 176 | <summary |
| 177 | className="text-xs cursor-pointer py-1" |
| 178 | style={{ color: "var(--text-faint)" }} |
| 179 | > |
| 180 | Payload |
| 181 | </summary> |
| 182 | <pre |
| 183 | className="mt-1 p-2 overflow-x-auto text-xs max-w-full" |
| 184 | style={{ |
| 185 | border: "1px solid var(--border-subtle)", |
| 186 | backgroundColor: "var(--bg-card)", |
| 187 | color: "var(--text-muted)", |
| 188 | }} |
| 189 | > |
| 190 | {renderPayload(entry.payload)} |
| 191 | </pre> |
| 192 | </details> |
| 193 | )} |
| 194 | </div> |
| 195 | ); |
| 196 | })} |
| 197 | </div> |
| 198 | )} |
| 199 | </div> |
| 200 | </div> |
| 201 | ); |
| 202 | } |
| 203 | |