Some checks failed
Release / build-and-release (push) Has been cancelled
Fix updater: strip v-prefix from version in latest.json, and delete old package before re-uploading to avoid 409 conflicts. Add frontend log capture (console intercept) with a log viewer card in the settings page (filterable, copyable, clearable). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
116 lines
4.2 KiB
TypeScript
116 lines
4.2 KiB
TypeScript
import { useState, useEffect, useRef, useSyncExternalStore } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { ScrollText, Trash2, Copy, Check } from "lucide-react";
|
|
import { getLogs, clearLogs, subscribe, type LogLevel } from "../../services/logService";
|
|
|
|
type Filter = "all" | LogLevel;
|
|
|
|
export default function LogViewerCard() {
|
|
const { t } = useTranslation();
|
|
const [filter, setFilter] = useState<Filter>("all");
|
|
const [copied, setCopied] = useState(false);
|
|
const listRef = useRef<HTMLDivElement>(null);
|
|
|
|
const logs = useSyncExternalStore(subscribe, getLogs, getLogs);
|
|
|
|
const filtered = filter === "all" ? logs : logs.filter((l) => l.level === filter);
|
|
|
|
useEffect(() => {
|
|
if (listRef.current) {
|
|
listRef.current.scrollTop = listRef.current.scrollHeight;
|
|
}
|
|
}, [filtered.length]);
|
|
|
|
const handleCopy = async () => {
|
|
const text = filtered
|
|
.map((l) => {
|
|
const time = new Date(l.timestamp).toLocaleTimeString();
|
|
return `[${time}] [${l.level.toUpperCase()}] ${l.message}`;
|
|
})
|
|
.join("\n");
|
|
await navigator.clipboard.writeText(text);
|
|
setCopied(true);
|
|
setTimeout(() => setCopied(false), 2000);
|
|
};
|
|
|
|
const levelColor: Record<LogLevel, string> = {
|
|
info: "text-[var(--muted-foreground)]",
|
|
warn: "text-amber-500",
|
|
error: "text-[var(--negative)]",
|
|
};
|
|
|
|
const filters: { value: Filter; label: string }[] = [
|
|
{ value: "all", label: t("settings.logs.filterAll") },
|
|
{ value: "error", label: "Error" },
|
|
{ value: "warn", label: "Warn" },
|
|
{ value: "info", label: "Info" },
|
|
];
|
|
|
|
return (
|
|
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6 space-y-4">
|
|
<div className="flex items-center justify-between">
|
|
<h2 className="text-lg font-semibold flex items-center gap-2">
|
|
<ScrollText size={18} />
|
|
{t("settings.logs.title")}
|
|
</h2>
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
onClick={handleCopy}
|
|
disabled={filtered.length === 0}
|
|
className="flex items-center gap-1 px-3 py-1.5 text-sm border border-[var(--border)] rounded-lg hover:bg-[var(--border)] transition-colors disabled:opacity-50"
|
|
>
|
|
{copied ? <Check size={14} /> : <Copy size={14} />}
|
|
{copied ? t("settings.logs.copied") : t("settings.logs.copy")}
|
|
</button>
|
|
<button
|
|
onClick={clearLogs}
|
|
disabled={logs.length === 0}
|
|
className="flex items-center gap-1 px-3 py-1.5 text-sm border border-[var(--border)] rounded-lg hover:bg-[var(--border)] transition-colors disabled:opacity-50"
|
|
>
|
|
<Trash2 size={14} />
|
|
{t("settings.logs.clear")}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex gap-1">
|
|
{filters.map((f) => (
|
|
<button
|
|
key={f.value}
|
|
onClick={() => setFilter(f.value)}
|
|
className={`px-3 py-1 text-xs rounded-md transition-colors ${
|
|
filter === f.value
|
|
? "bg-[var(--primary)] text-[var(--primary-foreground)]"
|
|
: "bg-[var(--muted)] text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
|
|
}`}
|
|
>
|
|
{f.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
<div
|
|
ref={listRef}
|
|
className="h-64 overflow-y-auto rounded-lg bg-[var(--background)] border border-[var(--border)] p-3 font-mono text-xs space-y-0.5"
|
|
>
|
|
{filtered.length === 0 ? (
|
|
<p className="text-[var(--muted-foreground)] text-center py-8">
|
|
{t("settings.logs.empty")}
|
|
</p>
|
|
) : (
|
|
filtered.map((entry, i) => (
|
|
<div key={i} className="flex gap-2">
|
|
<span className="text-[var(--muted-foreground)] shrink-0">
|
|
{new Date(entry.timestamp).toLocaleTimeString()}
|
|
</span>
|
|
<span className={`shrink-0 uppercase font-semibold w-12 ${levelColor[entry.level]}`}>
|
|
{entry.level}
|
|
</span>
|
|
<span className="text-[var(--foreground)] break-all">{entry.message}</span>
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|