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