web/app/ring/ring-log-viewer.tsxblame
View source
3cbdca61"use client";
3cbdca62
3cbdca63import { useCallback, useEffect, useState } from "react";
3cbdca64import { ring, type RingLogEntry } from "@/lib/api";
3cbdca65import { RingLogo } from "@/app/components/ring-logo";
3cbdca66
3cbdca67interface RingLogViewerProps {
3cbdca68 owner?: string;
3cbdca69 repo?: string;
3cbdca610}
3cbdca611
3cbdca612function formatTs(ts: string): string {
3cbdca613 const time = new Date(ts);
3cbdca614 if (Number.isNaN(time.getTime())) return ts;
3cbdca615 return time.toLocaleString();
3cbdca616}
3cbdca617
3cbdca618function levelStyle(level: string): { bg: string; fg: string } {
3cbdca619 switch (level.toLowerCase()) {
3cbdca620 case "error":
3cbdca621 return { bg: "var(--status-closed-bg)", fg: "var(--status-closed-text)" };
3cbdca622 case "warn":
3cbdca623 case "warning":
3cbdca624 return { bg: "var(--bg-inset)", fg: "var(--text-muted)" };
3cbdca625 case "debug":
3cbdca626 return { bg: "var(--bg-inset)", fg: "var(--text-faint)" };
3cbdca627 default:
3cbdca628 return { bg: "var(--status-open-bg)", fg: "var(--status-open-text)" };
3cbdca629 }
3cbdca630}
3cbdca631
3cbdca632function renderPayload(payload: unknown): string {
3cbdca633 if (typeof payload === "string") return payload;
3cbdca634 try {
3cbdca635 return JSON.stringify(payload, null, 2);
3cbdca636 } catch {
3cbdca637 return String(payload);
3cbdca638 }
3cbdca639}
3cbdca640
3cbdca641function hasExpandedPayload(entry: RingLogEntry): boolean {
3cbdca642 if (entry.payload === null) return false;
3cbdca643 if (typeof entry.payload === "string") return entry.payload !== entry.message;
3cbdca644 if (typeof entry.payload === "number" || typeof entry.payload === "boolean") {
3cbdca645 return String(entry.payload) !== entry.message;
3cbdca646 }
3cbdca647 return true;
3cbdca648}
3cbdca649
3cbdca650export function RingLogViewer({ owner, repo }: RingLogViewerProps) {
3cbdca651 const scoped = Boolean(owner && repo);
3cbdca652 const endpointPath = scoped
3cbdca653 ? `/api/repos/${owner}/${repo}/ring/logs`
3cbdca654 : "/api/ring/logs";
3cbdca655
3cbdca656 const [entries, setEntries] = useState<RingLogEntry[]>([]);
3cbdca657 const [loading, setLoading] = useState(true);
3cbdca658 const [error, setError] = useState<string | null>(null);
3cbdca659
3cbdca660 const fetchLogs = useCallback(
3cbdca661 async (isBackground = false) => {
3cbdca662 if (!isBackground) setLoading(true);
3cbdca663 try {
3cbdca664 const data = scoped && owner && repo
3cbdca665 ? await ring.listRepoLogs(owner, repo, { limit: 250 })
3cbdca666 : await ring.listLogs({ limit: 250 });
3cbdca667 setEntries(data.entries);
3cbdca668 setError(null);
3cbdca669 } catch {
3cbdca670 setError(`Ring API unavailable. Could not load ${endpointPath}.`);
3cbdca671 } finally {
3cbdca672 setLoading(false);
3cbdca673 }
3cbdca674 },
3cbdca675 [endpointPath, owner, repo, scoped]
3cbdca676 );
3cbdca677
3cbdca678 useEffect(() => {
3cbdca679 void fetchLogs();
3cbdca680 }, [fetchLogs]);
3cbdca681
3cbdca682 useEffect(() => {
3cbdca683 const interval = window.setInterval(() => {
3cbdca684 void fetchLogs(true);
3cbdca685 }, 2500);
3cbdca686 return () => window.clearInterval(interval);
3cbdca687 }, [fetchLogs]);
3cbdca688
3cbdca689 return (
3cbdca690 <div className="max-w-3xl mx-auto px-4 py-6">
3cbdca691 {error && (
3cbdca692 <div
3cbdca693 className="mb-4 text-sm px-3 py-2"
3cbdca694 style={{
3cbdca695 border: "1px solid var(--status-closed-border)",
3cbdca696 backgroundColor: "var(--status-closed-bg)",
3cbdca697 color: "var(--status-closed-text)",
3cbdca698 }}
3cbdca699 >
3cbdca6100 {error}
3cbdca6101 </div>
3cbdca6102 )}
3cbdca6103
3cbdca6104 <div style={{ border: "1px solid var(--border-subtle)" }}>
3cbdca6105 <div
3cbdca6106 className="text-xs px-3 py-2 flex items-center justify-between"
3cbdca6107 style={{
3cbdca6108 borderBottom: "1px solid var(--divide)",
3cbdca6109 color: "var(--text-faint)",
3cbdca6110 backgroundColor: "var(--bg-card)",
3cbdca6111 }}
3cbdca6112 >
3cbdca6113 <span>Newest first</span>
3cbdca6114 <span>{entries.length} shown</span>
3cbdca6115 </div>
3cbdca6116
3cbdca6117 {loading && entries.length === 0 ? (
3cbdca6118 <div className="p-3 flex flex-col gap-2">
3cbdca6119 {Array.from({ length: 6 }).map((_, i) => (
3cbdca6120 <div key={i} className="skeleton" style={{ height: "2rem" }} />
3cbdca6121 ))}
3cbdca6122 </div>
3cbdca6123 ) : entries.length === 0 ? (
3cbdca6124 <div
3cbdca6125 className="px-3 py-10 text-sm text-center"
3cbdca6126 style={{
3cbdca6127 color: "var(--text-faint)",
3cbdca6128 backgroundColor: "var(--bg-card)",
3cbdca6129 }}
3cbdca6130 >
3cbdca6131 <div className="flex justify-center mb-2 opacity-70">
3cbdca6132 <RingLogo size={26} />
3cbdca6133 </div>
3cbdca6134 No logs yet. Send JSON to <code>{endpointPath}</code>.
3cbdca6135 </div>
3cbdca6136 ) : (
3cbdca6137 <div>
3cbdca6138 {entries.map((entry, i) => {
3cbdca6139 const level = levelStyle(entry.level);
3cbdca6140 return (
3cbdca6141 <div
3cbdca6142 key={`${entry.ts}-${i}`}
3cbdca6143 className="px-3 py-2"
3cbdca6144 style={{ borderTop: i > 0 ? "1px solid var(--divide)" : undefined }}
3cbdca6145 >
3cbdca6146 <div className="flex flex-wrap items-center gap-2 text-xs mb-1">
3cbdca6147 <span
3cbdca6148 className="font-mono"
3cbdca6149 style={{ color: "var(--text-faint)" }}
3cbdca6150 title={entry.ts}
3cbdca6151 >
3cbdca6152 {formatTs(entry.ts)}
3cbdca6153 </span>
3cbdca6154 <span
3cbdca6155 className="px-1.5 py-0.5 rounded"
3cbdca6156 style={{
3cbdca6157 border: "1px solid var(--border-subtle)",
3cbdca6158 color: "var(--text-muted)",
3cbdca6159 backgroundColor: "var(--bg-card)",
3cbdca6160 }}
3cbdca6161 >
3cbdca6162 {entry.source || "ingest"}
3cbdca6163 </span>
3cbdca6164 <span
3cbdca6165 className="px-1.5 py-0.5 rounded capitalize"
3cbdca6166 style={{ color: level.fg, backgroundColor: level.bg }}
3cbdca6167 >
3cbdca6168 {entry.level || "info"}
3cbdca6169 </span>
3cbdca6170 </div>
3cbdca6171 <div className="text-sm font-mono break-words" style={{ color: "var(--text-primary)" }}>
3cbdca6172 {entry.message}
3cbdca6173 </div>
3cbdca6174 {hasExpandedPayload(entry) && (
3cbdca6175 <details className="mt-1">
3cbdca6176 <summary
3cbdca6177 className="text-xs cursor-pointer py-1"
3cbdca6178 style={{ color: "var(--text-faint)" }}
3cbdca6179 >
3cbdca6180 Payload
3cbdca6181 </summary>
3cbdca6182 <pre
3cbdca6183 className="mt-1 p-2 overflow-x-auto text-xs max-w-full"
3cbdca6184 style={{
3cbdca6185 border: "1px solid var(--border-subtle)",
3cbdca6186 backgroundColor: "var(--bg-card)",
3cbdca6187 color: "var(--text-muted)",
3cbdca6188 }}
3cbdca6189 >
3cbdca6190 {renderPayload(entry.payload)}
3cbdca6191 </pre>
3cbdca6192 </details>
3cbdca6193 )}
3cbdca6194 </div>
3cbdca6195 );
3cbdca6196 })}
3cbdca6197 </div>
3cbdca6198 )}
3cbdca6199 </div>
3cbdca6200 </div>
3cbdca6201 );
3cbdca6202}