mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-18 05:02:15 +03:00
* feat(api-keys): add rename support in permissions modal Add an editable key name field at the top of the permissions modal, allowing users to rename API keys alongside existing permission settings. The backend already supported name updates via PATCH /api/keys/:id — this wires the UI to send the name field and refreshes the key list on success. Changes: - Add keyName state and text input to PermissionsModal - Update handleUpdatePermissions to validate and send name in PATCH body - Add integration test for rename via PATCH (valid, empty, too-long names) - Update E2E mock to handle PATCH requests * chore(release): bump version to 3.7.6 * chore(release): v3.7.6 — merge API key rename feature and sync docs * chore(release): expand contributor credits to 155 PRs across full project history - Expanded acknowledgment table from 29 to 53 contributors - Added 100+ previously uncredited PRs from project inception through v3.7.5 - Moved contributor credits section to v3.7.6 (current release) - Synced llm.txt version to 3.7.6 * fix: resolve security ReDoS in codex and bugs #1797 #1789 * feat(dashboard): implement remaining v3.7.6 dashboard features and fixes * fix(xiaomi-mimo): update models to V2.5, fix Token Plan validation and default region (#1823) Integrated into release/v3.7.6 * fix(dashboard): correct loadPresets ReferenceError in CostOverviewTab * fix(codex): omit compact client metadata (#1822) Integrated into release/v3.7.6 * feat(chatgpt-web): support thinking_effort (Standard/Extended) for thinking-capable models (#1821) Integrated into release/v3.7.6 * Fix endpoint visibility, A2A status, and API catalog (#1806) Integrated into release/v3.7.6 * fix(analytics): use pure SQL aggregations — no history rows loaded (#1802) Integrated into release/v3.7.6 * fix(stability): resolve codex input validation, enable combo circuit breaker, and fix broken unit tests * docs(changelog): update for stability bug fixes #1804 #1805 * fix: clear active requests and recover providers (#1824) Integrated into release/v3.7.6 * feat: inject fallback tool names to prevent upstream 400 errors (#1775) * feat: auto-restore probe-failed database to prevent data loss (#1810) * fix: safely cast inputs to strings before calling trim() to avoid crashes on numeric fields in proxy modal (#1825) * chore(release): v3.7.6 — final stability patches for production * test: update expected db probe-failure error message for auto-restore feature * chore(workflow): mandate implementation plan generation in resolve-issues * docs(changelog): rewrite v3.7.6 with complete commit-accurate entries * feat(analytics): add cost-based usage insights and activity streaks Expand usage analytics to report total cost, per-series cost totals, API key counts, and current activity streaks using pricing-aware token calculations. Also make probe-failed database recovery choose the newest backup by its embedded timestamp instead of filesystem mtime so auto-restore selects the intended snapshot reliably. * fix(mitm): enforce transparent interception on port 443 only Reject non-443 MITM port updates in the settings API and normalize stored configuration back to the required transparent interception port. Lock the dashboard port field to 443, update the validation copy, and add integration coverage to prevent stale custom ports from being accepted or surfaced. * docs(changelog): update for analytics and mitm features --------- Co-authored-by: Andrew Munsell <andrew@wizardapps.net> Co-authored-by: Antigravity Assistant <bot@antigravity.local> Co-authored-by: Gi99lin <74502520+Gi99lin@users.noreply.github.com> Co-authored-by: Sergey Morozov <tr0st@bk.ru> Co-authored-by: payne <baboialex95@gmail.com> Co-authored-by: Randi <55005611+rdself@users.noreply.github.com> Co-authored-by: Paijo <14921983+oyi77@users.noreply.github.com> Co-authored-by: ipanghu <bypanghu@163.com>
313 lines
11 KiB
TypeScript
313 lines
11 KiB
TypeScript
"use client";
|
|
|
|
import { useTranslations } from "next-intl";
|
|
|
|
/**
|
|
* Console Log Viewer — Real-time application log viewer.
|
|
*
|
|
* Displays structured application logs from the server with a terminal-like UI.
|
|
* Polls the backend API every 5 seconds. Shows logs from the last 1 hour.
|
|
* Supports level filtering, text search, auto-scroll, and copy-to-clipboard.
|
|
*/
|
|
|
|
import { useState, useEffect, useRef, useCallback } from "react";
|
|
import { copyToClipboard } from "@/shared/utils/clipboard";
|
|
|
|
interface LogEntry {
|
|
timestamp: string;
|
|
level: string;
|
|
component?: string;
|
|
module?: string;
|
|
message?: string;
|
|
msg?: string;
|
|
correlationId?: string;
|
|
[key: string]: unknown;
|
|
}
|
|
|
|
const LEVEL_COLORS: Record<string, string> = {
|
|
debug: "text-gray-400",
|
|
trace: "text-gray-500",
|
|
info: "text-cyan-400",
|
|
warn: "text-yellow-400",
|
|
error: "text-red-400",
|
|
fatal: "text-fuchsia-400",
|
|
};
|
|
|
|
const LEVEL_BG: Record<string, string> = {
|
|
debug: "bg-gray-500/10 border-gray-500/20",
|
|
trace: "bg-gray-500/10 border-gray-500/20",
|
|
info: "bg-cyan-500/10 border-cyan-500/20",
|
|
warn: "bg-yellow-500/10 border-yellow-500/20",
|
|
error: "bg-red-500/10 border-red-500/20",
|
|
fatal: "bg-fuchsia-500/10 border-fuchsia-500/20",
|
|
};
|
|
|
|
const POLL_INTERVAL = 5000; // 5 seconds
|
|
|
|
export default function ConsoleLogViewer() {
|
|
const t = useTranslations("loggers");
|
|
const tv = useTranslations("logs.consoleViewer");
|
|
const [logs, setLogs] = useState<LogEntry[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [levelFilter, setLevelFilter] = useState("all");
|
|
const [searchText, setSearchText] = useState("");
|
|
const [autoScroll, setAutoScroll] = useState(true);
|
|
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
|
|
const [copiedIdx, setCopiedIdx] = useState<number | null>(null);
|
|
const scrollRef = useRef<HTMLDivElement>(null);
|
|
|
|
const fetchLogs = useCallback(async () => {
|
|
try {
|
|
const params = new URLSearchParams();
|
|
if (levelFilter !== "all") params.set("level", levelFilter);
|
|
params.set("limit", "500");
|
|
|
|
const res = await fetch(`/api/logs/console?${params.toString()}`);
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
const data: LogEntry[] = await res.json();
|
|
|
|
setLogs(data);
|
|
setLastUpdated(new Date());
|
|
setError(null);
|
|
} catch (err: any) {
|
|
setError(err.message || tv("fetchFailed"));
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [levelFilter, tv]);
|
|
|
|
// Initial fetch + polling
|
|
useEffect(() => {
|
|
fetchLogs();
|
|
const interval = setInterval(fetchLogs, POLL_INTERVAL);
|
|
return () => clearInterval(interval);
|
|
}, [fetchLogs]);
|
|
|
|
// Auto-scroll to bottom on new logs
|
|
useEffect(() => {
|
|
if (autoScroll && scrollRef.current) {
|
|
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
|
}
|
|
}, [logs, autoScroll]);
|
|
|
|
const handleCopy = async (entry: LogEntry, idx: number) => {
|
|
const text = JSON.stringify(entry, null, 2);
|
|
const success = await copyToClipboard(text);
|
|
if (!success) {
|
|
setError(tv("copyFailed"));
|
|
return;
|
|
}
|
|
|
|
setError(null);
|
|
setCopiedIdx(idx);
|
|
setTimeout(() => setCopiedIdx(null), 2000);
|
|
};
|
|
|
|
const formatTime = (ts: string) => {
|
|
try {
|
|
const d = new Date(ts);
|
|
return d.toLocaleTimeString("en-US", {
|
|
hour12: false,
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
second: "2-digit",
|
|
fractionalSecondDigits: 3,
|
|
});
|
|
} catch {
|
|
return ts;
|
|
}
|
|
};
|
|
|
|
const getText = (entry: LogEntry) => entry.msg || entry.message || "";
|
|
const getComponent = (entry: LogEntry) => entry.component || entry.module || "";
|
|
|
|
// Apply text search filter
|
|
const filteredLogs = searchText
|
|
? logs.filter((entry) => {
|
|
const full = JSON.stringify(entry).toLowerCase();
|
|
return full.includes(searchText.toLowerCase());
|
|
})
|
|
: logs;
|
|
|
|
return (
|
|
<div className="flex flex-col gap-4">
|
|
{/* Toolbar */}
|
|
<div className="flex flex-wrap items-center gap-3 p-4 rounded-xl bg-[var(--color-surface)] border border-[var(--color-border)]">
|
|
{/* Level filter */}
|
|
<select
|
|
value={levelFilter}
|
|
onChange={(e) => setLevelFilter(e.target.value)}
|
|
aria-label="Filter by log level"
|
|
className="px-3 py-2 rounded-lg text-sm bg-[var(--color-bg)] border border-[var(--color-border)] text-[var(--color-text-main)] focus:outline-2 focus:outline-[var(--color-accent)]"
|
|
>
|
|
<option value="all">{t("allLevels")}</option>
|
|
<option value="debug">Debug+</option>
|
|
<option value="info">Info+</option>
|
|
<option value="warn">Warn+</option>
|
|
<option value="error">Error+</option>
|
|
</select>
|
|
|
|
{/* Search */}
|
|
<input
|
|
type="text"
|
|
placeholder="Search logs..."
|
|
value={searchText}
|
|
onChange={(e) => setSearchText(e.target.value)}
|
|
aria-label="Search log entries"
|
|
className="flex-1 min-w-[200px] px-3 py-2 rounded-lg text-sm bg-[var(--color-bg)] border border-[var(--color-border)] text-[var(--color-text-main)] placeholder:text-[var(--color-text-muted)] focus:outline-2 focus:outline-[var(--color-accent)]"
|
|
/>
|
|
|
|
{/* Auto-scroll toggle */}
|
|
<button
|
|
onClick={() => setAutoScroll(!autoScroll)}
|
|
title={autoScroll ? "Disable auto-scroll" : "Enable auto-scroll"}
|
|
className={`px-3 py-2 rounded-lg text-sm font-medium border transition-colors ${
|
|
autoScroll
|
|
? "bg-cyan-500/15 text-cyan-400 border-cyan-500/30"
|
|
: "bg-[var(--color-bg)] text-[var(--color-text-muted)] border-[var(--color-border)]"
|
|
}`}
|
|
>
|
|
<span className="material-symbols-outlined text-[16px] align-middle mr-1">
|
|
{autoScroll ? "vertical_align_bottom" : "lock"}
|
|
</span>
|
|
Auto-scroll
|
|
</button>
|
|
|
|
{/* Refresh */}
|
|
<button
|
|
onClick={fetchLogs}
|
|
disabled={loading}
|
|
className="px-3 py-2 rounded-lg text-sm font-medium bg-[var(--color-bg)] border border-[var(--color-border)] text-[var(--color-text-main)] hover:bg-[var(--color-bg-alt)] disabled:opacity-50 transition-colors"
|
|
>
|
|
<span className="material-symbols-outlined text-[16px] align-middle">refresh</span>
|
|
</button>
|
|
|
|
{/* Status */}
|
|
<div className="flex items-center gap-2 ml-auto text-xs text-[var(--color-text-muted)]">
|
|
<span className="inline-block w-2 h-2 rounded-full bg-green-500 animate-pulse" />
|
|
<span>{filteredLogs.length} entries</span>
|
|
<span className="text-[var(--color-text-muted)]/50">•</span>
|
|
<span>Last 1h</span>
|
|
{lastUpdated && (
|
|
<>
|
|
<span className="text-[var(--color-text-muted)]/50">•</span>
|
|
<span>Updated {lastUpdated.toLocaleTimeString()}</span>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Error */}
|
|
{error && (
|
|
<div
|
|
className="p-4 rounded-lg bg-red-500/10 border border-red-500/30 text-red-400 text-sm"
|
|
role="alert"
|
|
>
|
|
<span className="material-symbols-outlined text-[16px] align-middle mr-2">error</span>
|
|
{error}
|
|
<span className="text-xs ml-2 opacity-70">
|
|
— Make sure the application is writing logs to file (APP_LOG_TO_FILE=true)
|
|
</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* Console output */}
|
|
<div
|
|
ref={scrollRef}
|
|
className="rounded-xl border border-[var(--color-border)] bg-[#0d1117] overflow-auto font-mono text-xs leading-relaxed"
|
|
style={{ maxHeight: "calc(100vh - 340px)", minHeight: "400px" }}
|
|
role="log"
|
|
aria-label="Application console logs"
|
|
aria-live="polite"
|
|
>
|
|
{/* Header bar */}
|
|
<div className="sticky top-0 z-10 px-4 py-2 bg-[#161b22] border-b border-[#30363d] flex items-center gap-2">
|
|
<div className="w-3 h-3 rounded-full bg-[#FF5F56]" />
|
|
<div className="w-3 h-3 rounded-full bg-[#FFBD2E]" />
|
|
<div className="w-3 h-3 rounded-full bg-[#27C93F]" />
|
|
<span className="ml-3 text-[#8b949e] text-[11px]">OmniRoute — Application Console</span>
|
|
</div>
|
|
|
|
{/* Log entries */}
|
|
<div className="p-3 space-y-px">
|
|
{filteredLogs.length === 0 && !loading ? (
|
|
<div className="text-[#8b949e] text-center py-12">
|
|
<span className="material-symbols-outlined text-[40px] block mb-2 opacity-30">
|
|
terminal
|
|
</span>
|
|
<p>{t("noLogEntries")}</p>
|
|
<p className="text-[10px] mt-1 opacity-60">
|
|
Ensure APP_LOG_TO_FILE=true is set in your .env file
|
|
</p>
|
|
</div>
|
|
) : (
|
|
filteredLogs.map((entry, idx) => {
|
|
const level = (entry.level || "info").toLowerCase();
|
|
const colorClass = LEVEL_COLORS[level] || LEVEL_COLORS.info;
|
|
const bgClass = LEVEL_BG[level] || "";
|
|
const comp = getComponent(entry);
|
|
const msg = getText(entry);
|
|
|
|
return (
|
|
<div
|
|
key={idx}
|
|
className={`group flex items-start gap-2 px-2 py-1 rounded hover:bg-white/5 transition-colors ${
|
|
level === "error" || level === "fatal" ? "bg-red-500/5" : ""
|
|
}`}
|
|
>
|
|
{/* Timestamp */}
|
|
<span className="text-[#484f58] whitespace-nowrap shrink-0 select-none">
|
|
{formatTime(entry.timestamp)}
|
|
</span>
|
|
|
|
{/* Level badge */}
|
|
<span
|
|
className={`inline-block px-1.5 py-0 rounded text-[10px] font-semibold uppercase border shrink-0 ${colorClass} ${bgClass}`}
|
|
>
|
|
{level.padEnd(5)}
|
|
</span>
|
|
|
|
{/* Component */}
|
|
{comp && <span className="text-purple-400/80 shrink-0">[{comp}]</span>}
|
|
|
|
{/* Message */}
|
|
<span className="text-[#c9d1d9] flex-1 break-all">
|
|
{msg}
|
|
{/* Extra meta */}
|
|
{entry.correlationId && (
|
|
<span className="text-[#484f58] ml-2">
|
|
cid:{entry.correlationId.slice(0, 8)}
|
|
</span>
|
|
)}
|
|
</span>
|
|
|
|
{/* Copy button */}
|
|
<button
|
|
onClick={() => handleCopy(entry, idx)}
|
|
title={tv("copyLogEntry")}
|
|
className="opacity-0 group-hover:opacity-100 transition-opacity shrink-0 text-[#8b949e] hover:text-white"
|
|
>
|
|
<span className="material-symbols-outlined text-[14px]">
|
|
{copiedIdx === idx ? "check" : "content_copy"}
|
|
</span>
|
|
</button>
|
|
</div>
|
|
);
|
|
})
|
|
)}
|
|
|
|
{loading && filteredLogs.length === 0 && (
|
|
<div className="text-[#8b949e] text-center py-12">
|
|
<span className="material-symbols-outlined text-[24px] animate-spin block mb-2">
|
|
progress_activity
|
|
</span>
|
|
Loading logs...
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|