6.5 KB203 lines
Blame
1"use client";
2
3import { useCallback, useEffect, useState } from "react";
4import { ring, type RingLogEntry } from "@/lib/api";
5import { RingLogo } from "@/app/components/ring-logo";
6
7interface RingLogViewerProps {
8 owner?: string;
9 repo?: string;
10}
11
12function formatTs(ts: string): string {
13 const time = new Date(ts);
14 if (Number.isNaN(time.getTime())) return ts;
15 return time.toLocaleString();
16}
17
18function 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
32function 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
41function 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
50export 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