mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-01 12:52:11 +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>
577 lines
23 KiB
TypeScript
577 lines
23 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
|
|
import { useTranslations } from "next-intl";
|
|
import Card from "./Card";
|
|
import ProxyLogDetail from "./ProxyLogDetail";
|
|
import {
|
|
TYPE_COLORS,
|
|
LEVEL_COLORS,
|
|
PROVIDER_COLORS,
|
|
getProxyStatusStyle as getStatusStyle,
|
|
} from "@/shared/constants/colors";
|
|
import {
|
|
formatTime,
|
|
formatDuration as formatLatency,
|
|
truncateUrl,
|
|
} from "@/shared/utils/formatting";
|
|
|
|
const PROXY_COLUMN_KEYS = [
|
|
"status",
|
|
"proxy",
|
|
"tls",
|
|
"type",
|
|
"level",
|
|
"provider",
|
|
"target",
|
|
"latency",
|
|
"ip",
|
|
"time",
|
|
];
|
|
|
|
const DEFAULT_VISIBLE = Object.fromEntries(PROXY_COLUMN_KEYS.map((key) => [key, true]));
|
|
|
|
export default function ProxyLogger() {
|
|
const t = useTranslations("proxyLogger");
|
|
const [logs, setLogs] = useState([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [recording, setRecording] = useState(true);
|
|
const [search, setSearch] = useState("");
|
|
const [activeFilter, setActiveFilter] = useState("all");
|
|
const [selectedType, setSelectedType] = useState("");
|
|
const [selectedProvider, setSelectedProvider] = useState("");
|
|
const [selectedLevel, setSelectedLevel] = useState("");
|
|
const [sortBy, setSortBy] = useState("newest");
|
|
const [selectedLog, setSelectedLog] = useState(null);
|
|
const intervalRef = useRef(null);
|
|
const hasLoadedRef = useRef(false);
|
|
const logsSignatureRef = useRef("");
|
|
|
|
const statusFilters = useMemo(
|
|
() => [
|
|
{ key: "all", label: t("filterAll") },
|
|
{ key: "error", label: t("filterErrors"), icon: "error" },
|
|
{ key: "ok", label: t("filterSuccess"), icon: "check_circle" },
|
|
{ key: "timeout", label: t("filterTimeout"), icon: "timer_off" },
|
|
],
|
|
[t]
|
|
);
|
|
|
|
const columns = useMemo(
|
|
() => [
|
|
{ key: "status", label: t("colStatus") },
|
|
{ key: "proxy", label: t("colProxy") },
|
|
{ key: "tls", label: t("colTls") },
|
|
{ key: "type", label: t("colType") },
|
|
{ key: "level", label: t("colLevel") },
|
|
{ key: "provider", label: t("colProvider") },
|
|
{ key: "target", label: t("colTarget") },
|
|
{ key: "latency", label: t("colLatency") },
|
|
{ key: "ip", label: t("colPublicIp") },
|
|
{ key: "time", label: t("colTime") },
|
|
],
|
|
[t]
|
|
);
|
|
|
|
const [visibleColumns, setVisibleColumns] = useState(() => {
|
|
if (typeof window === "undefined") return DEFAULT_VISIBLE;
|
|
try {
|
|
const saved = localStorage.getItem("proxyLoggerVisibleColumns");
|
|
return saved ? { ...DEFAULT_VISIBLE, ...JSON.parse(saved) } : DEFAULT_VISIBLE;
|
|
} catch {
|
|
return DEFAULT_VISIBLE;
|
|
}
|
|
});
|
|
|
|
const toggleColumn = useCallback((key) => {
|
|
setVisibleColumns((prev) => {
|
|
const next = { ...prev, [key]: !prev[key] };
|
|
try {
|
|
localStorage.setItem("proxyLoggerVisibleColumns", JSON.stringify(next));
|
|
} catch {}
|
|
return next;
|
|
});
|
|
}, []);
|
|
|
|
const fetchLogs = useCallback(
|
|
async (showLoading = false) => {
|
|
if (showLoading) setLoading(true);
|
|
try {
|
|
const params = new URLSearchParams();
|
|
if (search) params.set("search", search);
|
|
if (activeFilter === "error") params.set("status", "error");
|
|
if (activeFilter === "ok") params.set("status", "ok");
|
|
if (activeFilter === "timeout") params.set("status", "timeout");
|
|
if (selectedType) params.set("type", selectedType);
|
|
if (selectedProvider) params.set("provider", selectedProvider);
|
|
if (selectedLevel) params.set("level", selectedLevel);
|
|
params.set("limit", "300");
|
|
|
|
const res = await fetch(`/api/usage/proxy-logs?${params}`);
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
// Skip re-render if data hasn't changed (#1369 GPU perf)
|
|
const sig = JSON.stringify(data.map?.((l: any) => l.id) ?? []);
|
|
if (sig !== logsSignatureRef.current) {
|
|
logsSignatureRef.current = sig;
|
|
setLogs(data);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error("Failed to fetch proxy logs:", error);
|
|
} finally {
|
|
if (showLoading) setLoading(false);
|
|
}
|
|
},
|
|
[search, activeFilter, selectedType, selectedProvider, selectedLevel]
|
|
);
|
|
|
|
useEffect(() => {
|
|
const showLoading = !hasLoadedRef.current;
|
|
hasLoadedRef.current = true;
|
|
fetchLogs(showLoading);
|
|
}, [fetchLogs]);
|
|
|
|
useEffect(() => {
|
|
if (intervalRef.current) clearInterval(intervalRef.current);
|
|
if (recording) {
|
|
intervalRef.current = setInterval(() => fetchLogs(false), 3000);
|
|
}
|
|
return () => {
|
|
if (intervalRef.current) clearInterval(intervalRef.current);
|
|
};
|
|
}, [recording, fetchLogs]);
|
|
|
|
const sortedLogs = useMemo(() => {
|
|
const arr = [...logs];
|
|
arr.sort((a, b) => {
|
|
switch (sortBy) {
|
|
case "oldest":
|
|
return new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime();
|
|
case "latency_desc":
|
|
return (b.latencyMs || 0) - (a.latencyMs || 0);
|
|
case "latency_asc":
|
|
return (a.latencyMs || 0) - (b.latencyMs || 0);
|
|
case "newest":
|
|
default:
|
|
return new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime();
|
|
}
|
|
});
|
|
return arr;
|
|
}, [logs, sortBy]);
|
|
|
|
const uniqueProviders = [...new Set(logs.map((l) => l.provider).filter(Boolean))].sort();
|
|
const uniqueTypes = [...new Set(logs.map((l) => l.proxy?.type).filter(Boolean))].sort();
|
|
const uniqueLevels = [...new Set(logs.map((l) => l.level).filter(Boolean))].sort();
|
|
|
|
const totalCount = logs.length;
|
|
const okCount = logs.filter((l) => l.status === "success").length;
|
|
const errorCount = logs.filter((l) => l.status === "error").length;
|
|
const timeoutCount = logs.filter((l) => l.status === "timeout").length;
|
|
const directCount = logs.filter((l) => l.level === "direct").length;
|
|
const tlsCount = logs.filter((l) => l.tlsFingerprint).length;
|
|
|
|
return (
|
|
<div className="flex flex-col gap-4">
|
|
{/* Header Bar */}
|
|
<div className="flex flex-wrap items-center gap-3">
|
|
{/* Recording Toggle */}
|
|
<button
|
|
onClick={() => setRecording(!recording)}
|
|
className={`flex items-center gap-2 px-3 py-1.5 rounded-full text-sm font-medium border transition-colors ${
|
|
recording
|
|
? "bg-red-500/10 border-red-500/30 text-red-400"
|
|
: "bg-bg-subtle border-border text-text-muted"
|
|
}`}
|
|
>
|
|
<span
|
|
className={`w-2 h-2 rounded-full ${recording ? "bg-red-500 animate-pulse" : "bg-text-muted"}`}
|
|
/>
|
|
{recording ? t("recording") : t("paused")}
|
|
</button>
|
|
|
|
{/* Search */}
|
|
<div className="flex-1 min-w-[200px] relative">
|
|
<span className="material-symbols-outlined absolute left-3 top-1/2 -translate-y-1/2 text-text-muted text-[18px]">
|
|
search
|
|
</span>
|
|
<input
|
|
type="text"
|
|
placeholder={t("searchPlaceholder")}
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
className="w-full pl-10 pr-4 py-2 rounded-lg bg-bg-subtle border border-border text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:border-primary"
|
|
/>
|
|
</div>
|
|
|
|
{/* Type Dropdown */}
|
|
<select
|
|
value={selectedType}
|
|
onChange={(e) => setSelectedType(e.target.value)}
|
|
className="px-3 py-2 rounded-lg bg-bg-subtle border border-border text-sm text-text-primary focus:outline-none focus:border-primary appearance-none cursor-pointer min-w-[120px]"
|
|
>
|
|
<option value="">{t("allTypes")}</option>
|
|
{uniqueTypes.map((t) => (
|
|
<option key={t} value={t}>
|
|
{(TYPE_COLORS[t]?.label || t).toUpperCase()}
|
|
</option>
|
|
))}
|
|
</select>
|
|
|
|
{/* Level Dropdown */}
|
|
<select
|
|
value={selectedLevel}
|
|
onChange={(e) => setSelectedLevel(e.target.value)}
|
|
className="px-3 py-2 rounded-lg bg-bg-subtle border border-border text-sm text-text-primary focus:outline-none focus:border-primary appearance-none cursor-pointer min-w-[120px]"
|
|
>
|
|
<option value="">{t("allLevels")}</option>
|
|
{uniqueLevels.map((l) => (
|
|
<option key={l} value={l}>
|
|
{LEVEL_COLORS[l]?.label || l}
|
|
</option>
|
|
))}
|
|
</select>
|
|
|
|
{/* Provider Dropdown */}
|
|
<select
|
|
value={selectedProvider}
|
|
onChange={(e) => setSelectedProvider(e.target.value)}
|
|
className="px-3 py-2 rounded-lg bg-bg-subtle border border-border text-sm text-text-primary focus:outline-none focus:border-primary appearance-none cursor-pointer min-w-[140px]"
|
|
>
|
|
<option value="">{t("allProviders")}</option>
|
|
{uniqueProviders.map((p) => {
|
|
const pc = PROVIDER_COLORS[p];
|
|
return (
|
|
<option key={p} value={p}>
|
|
{pc?.label || p.toUpperCase()}
|
|
</option>
|
|
);
|
|
})}
|
|
</select>
|
|
|
|
{/* Stats */}
|
|
<div className="flex items-center gap-2 text-xs text-text-muted">
|
|
<span className="px-2 py-1 rounded bg-bg-subtle border border-border font-mono">
|
|
{totalCount} {t("total")}
|
|
</span>
|
|
<span className="px-2 py-1 rounded bg-emerald-500/10 text-emerald-400 font-mono">
|
|
{okCount} {t("ok")}
|
|
</span>
|
|
{errorCount > 0 && (
|
|
<span className="px-2 py-1 rounded bg-red-500/10 text-red-400 font-mono">
|
|
{errorCount} {t("err")}
|
|
</span>
|
|
)}
|
|
{timeoutCount > 0 && (
|
|
<span className="px-2 py-1 rounded bg-amber-500/10 text-amber-400 font-mono">
|
|
{timeoutCount} {t("timeoutShort")}
|
|
</span>
|
|
)}
|
|
{directCount > 0 && (
|
|
<span className="px-2 py-1 rounded bg-gray-500/10 text-gray-400 font-mono">
|
|
{directCount} {t("direct")}
|
|
</span>
|
|
)}
|
|
{tlsCount > 0 && (
|
|
<span className="px-2 py-1 rounded bg-cyan-500/10 text-cyan-400 font-mono">
|
|
🔒 {tlsCount} TLS
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* Sort */}
|
|
<select
|
|
value={sortBy}
|
|
onChange={(e) => setSortBy(e.target.value)}
|
|
className="px-3 py-2 rounded-lg bg-bg-subtle border border-border text-sm text-text-primary focus:outline-none focus:border-primary appearance-none cursor-pointer min-w-[140px]"
|
|
>
|
|
<option value="newest">{t("newest")}</option>
|
|
<option value="oldest">{t("oldest")}</option>
|
|
<option value="latency_desc">{t("latencyDesc")}</option>
|
|
<option value="latency_asc">{t("latencyAsc")}</option>
|
|
</select>
|
|
|
|
{/* Refresh */}
|
|
<button
|
|
onClick={() => fetchLogs(false)}
|
|
className="p-2 rounded-lg hover:bg-bg-subtle text-text-muted hover:text-text-primary transition-colors"
|
|
title={t("refresh")}
|
|
>
|
|
<span className="material-symbols-outlined text-[18px]">refresh</span>
|
|
</button>
|
|
</div>
|
|
|
|
{/* Quick Filters */}
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
{statusFilters.map((f) => (
|
|
<button
|
|
key={f.key}
|
|
onClick={() => setActiveFilter(activeFilter === f.key ? "all" : f.key)}
|
|
className={`flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-medium border transition-all ${
|
|
activeFilter === f.key
|
|
? f.key === "error"
|
|
? "bg-red-500/20 text-red-400 border-red-500/40"
|
|
: f.key === "ok"
|
|
? "bg-emerald-500/20 text-emerald-400 border-emerald-500/40"
|
|
: f.key === "timeout"
|
|
? "bg-amber-500/20 text-amber-400 border-amber-500/40"
|
|
: "bg-primary text-white border-primary"
|
|
: "bg-bg-subtle border-border text-text-muted hover:border-text-muted"
|
|
}`}
|
|
>
|
|
{f.icon && <span className="material-symbols-outlined text-[14px]">{f.icon}</span>}
|
|
{f.label}
|
|
</button>
|
|
))}
|
|
|
|
{uniqueProviders.length > 0 && <span className="w-px h-5 bg-border mx-1" />}
|
|
|
|
{uniqueProviders.map((p) => {
|
|
const pc = PROVIDER_COLORS[p] || { bg: "#374151", text: "#fff", label: p.toUpperCase() };
|
|
const isActive = selectedProvider === p;
|
|
return (
|
|
<button
|
|
key={p}
|
|
onClick={() => setSelectedProvider(isActive ? "" : p)}
|
|
className={`px-3 py-1 rounded-full text-xs font-bold uppercase border transition-all ${
|
|
isActive
|
|
? "border-white/40 ring-1 ring-white/20"
|
|
: "border-transparent opacity-70 hover:opacity-100"
|
|
}`}
|
|
style={{
|
|
backgroundColor: isActive ? pc.bg : `${pc.bg}33`,
|
|
color: isActive ? pc.text : pc.bg,
|
|
}}
|
|
>
|
|
{pc.label}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{/* Column Visibility Toggles */}
|
|
<div className="flex flex-wrap items-center gap-1.5">
|
|
<span className="text-[10px] text-text-muted uppercase tracking-wider mr-1">
|
|
{t("columns")}
|
|
</span>
|
|
{columns.map((col) => (
|
|
<button
|
|
key={col.key}
|
|
onClick={() => toggleColumn(col.key)}
|
|
className={`px-2 py-0.5 rounded text-[10px] font-medium border transition-all ${
|
|
visibleColumns[col.key]
|
|
? "bg-primary/15 text-primary border-primary/30"
|
|
: "bg-bg-subtle text-text-muted border-border opacity-50 hover:opacity-80"
|
|
}`}
|
|
>
|
|
{col.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* Table */}
|
|
<Card className="overflow-hidden bg-black/5 dark:bg-black/20">
|
|
<div className="p-0 overflow-x-auto max-h-[calc(100vh-320px)] overflow-y-auto">
|
|
{loading && logs.length === 0 ? (
|
|
<div className="p-8 text-center text-text-muted">{t("loadingProxyLogs")}</div>
|
|
) : logs.length === 0 ? (
|
|
<div className="p-8 text-center text-text-muted">
|
|
<span className="material-symbols-outlined text-[48px] mb-2 block opacity-40">
|
|
vpn_lock
|
|
</span>
|
|
{t("noProxyLogs")}
|
|
</div>
|
|
) : sortedLogs.length === 0 ? (
|
|
<div className="p-8 text-center text-text-muted">{t("noMatchingLogs")}</div>
|
|
) : (
|
|
<table className="w-full text-left border-collapse text-xs">
|
|
<thead
|
|
className="sticky top-0 z-10"
|
|
style={{ backgroundColor: "var(--bg-primary, #0f1117)" }}
|
|
>
|
|
<tr
|
|
className="border-b border-border"
|
|
style={{ backgroundColor: "var(--bg-primary, #0f1117)" }}
|
|
>
|
|
{visibleColumns.status && (
|
|
<th className="px-3 py-2.5 font-semibold text-text-muted uppercase tracking-wider text-[10px]">
|
|
{t("colStatus")}
|
|
</th>
|
|
)}
|
|
{visibleColumns.proxy && (
|
|
<th className="px-3 py-2.5 font-semibold text-text-muted uppercase tracking-wider text-[10px]">
|
|
{t("colProxy")}
|
|
</th>
|
|
)}
|
|
{visibleColumns.tls && (
|
|
<th className="px-3 py-2.5 font-semibold text-text-muted uppercase tracking-wider text-[10px]">
|
|
{t("colTls")}
|
|
</th>
|
|
)}
|
|
{visibleColumns.type && (
|
|
<th className="px-3 py-2.5 font-semibold text-text-muted uppercase tracking-wider text-[10px]">
|
|
{t("colType")}
|
|
</th>
|
|
)}
|
|
{visibleColumns.level && (
|
|
<th className="px-3 py-2.5 font-semibold text-text-muted uppercase tracking-wider text-[10px]">
|
|
{t("colLevel")}
|
|
</th>
|
|
)}
|
|
{visibleColumns.provider && (
|
|
<th className="px-3 py-2.5 font-semibold text-text-muted uppercase tracking-wider text-[10px]">
|
|
{t("colProvider")}
|
|
</th>
|
|
)}
|
|
{visibleColumns.target && (
|
|
<th className="px-3 py-2.5 font-semibold text-text-muted uppercase tracking-wider text-[10px]">
|
|
{t("colTarget")}
|
|
</th>
|
|
)}
|
|
{visibleColumns.latency && (
|
|
<th className="px-3 py-2.5 font-semibold text-text-muted uppercase tracking-wider text-[10px] text-right">
|
|
{t("colLatency")}
|
|
</th>
|
|
)}
|
|
{visibleColumns.ip && (
|
|
<th className="px-3 py-2.5 font-semibold text-text-muted uppercase tracking-wider text-[10px]">
|
|
{t("colPublicIp")}
|
|
</th>
|
|
)}
|
|
{visibleColumns.time && (
|
|
<th className="px-3 py-2.5 font-semibold text-text-muted uppercase tracking-wider text-[10px] text-right">
|
|
{t("colTime")}
|
|
</th>
|
|
)}
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-border/30">
|
|
{sortedLogs.map((log) => {
|
|
const statusStyle = getStatusStyle(log.status);
|
|
const typeColor = TYPE_COLORS[log.proxy?.type] || {
|
|
bg: "#6B7280",
|
|
text: "#fff",
|
|
label: log.proxy?.type || "-",
|
|
};
|
|
const levelColor = LEVEL_COLORS[log.level] || LEVEL_COLORS.direct;
|
|
const providerColor = PROVIDER_COLORS[log.provider] || {
|
|
bg: "#374151",
|
|
text: "#fff",
|
|
label: (log.provider || "-").toUpperCase(),
|
|
};
|
|
const isError = log.status === "error" || log.status === "timeout";
|
|
|
|
return (
|
|
<tr
|
|
key={log.id}
|
|
onClick={() => setSelectedLog(selectedLog?.id === log.id ? null : log)}
|
|
className={`cursor-pointer hover:bg-primary/5 transition-colors ${isError ? "bg-red-500/5" : ""}`}
|
|
>
|
|
{visibleColumns.status && (
|
|
<td className="px-3 py-2">
|
|
<span
|
|
className="inline-block px-2 py-0.5 rounded text-[10px] font-bold min-w-[50px] text-center uppercase"
|
|
style={{ backgroundColor: statusStyle.bg, color: statusStyle.text }}
|
|
>
|
|
{log.status}
|
|
</span>
|
|
</td>
|
|
)}
|
|
{visibleColumns.proxy && (
|
|
<td className="px-3 py-2 font-mono text-[11px] text-primary">
|
|
{log.proxy ? `${log.proxy.host}:${log.proxy.port}` : "—"}
|
|
</td>
|
|
)}
|
|
{visibleColumns.tls && (
|
|
<td className="px-3 py-2">
|
|
{log.tlsFingerprint ? (
|
|
<span
|
|
className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-[9px] font-bold uppercase"
|
|
style={{
|
|
backgroundColor: "rgba(6, 182, 212, 0.15)",
|
|
color: "#22d3ee",
|
|
}}
|
|
title={t("tlsFingerprint")}
|
|
>
|
|
<span style={{ fontSize: "10px" }}>🔒</span> TLS
|
|
</span>
|
|
) : (
|
|
<span className="text-text-muted text-[10px]">—</span>
|
|
)}
|
|
</td>
|
|
)}
|
|
{visibleColumns.type && (
|
|
<td className="px-3 py-2">
|
|
<span
|
|
className="inline-block px-2 py-0.5 rounded text-[9px] font-bold uppercase"
|
|
style={{ backgroundColor: typeColor.bg, color: typeColor.text }}
|
|
>
|
|
{typeColor.label}
|
|
</span>
|
|
</td>
|
|
)}
|
|
{visibleColumns.level && (
|
|
<td className="px-3 py-2">
|
|
<span
|
|
className="inline-block px-2 py-0.5 rounded text-[9px] font-bold uppercase"
|
|
style={{ backgroundColor: levelColor.bg, color: levelColor.text }}
|
|
>
|
|
{levelColor.label}
|
|
</span>
|
|
</td>
|
|
)}
|
|
{visibleColumns.provider && (
|
|
<td className="px-3 py-2">
|
|
{log.provider ? (
|
|
<span
|
|
className="inline-block px-2 py-0.5 rounded text-[9px] font-bold uppercase"
|
|
style={{
|
|
backgroundColor: providerColor.bg,
|
|
color: providerColor.text,
|
|
}}
|
|
>
|
|
{providerColor.label}
|
|
</span>
|
|
) : (
|
|
<span className="text-text-muted text-[10px]">—</span>
|
|
)}
|
|
</td>
|
|
)}
|
|
{visibleColumns.target && (
|
|
<td
|
|
className="px-3 py-2 text-text-muted truncate max-w-[200px] font-mono text-[10px]"
|
|
title={log.targetUrl}
|
|
>
|
|
{truncateUrl(log.targetUrl)}
|
|
</td>
|
|
)}
|
|
{visibleColumns.latency && (
|
|
<td className="px-3 py-2 text-right text-text-muted font-mono">
|
|
{formatLatency(log.latencyMs)}
|
|
</td>
|
|
)}
|
|
{visibleColumns.ip && (
|
|
<td className="px-3 py-2 font-mono text-[11px] text-emerald-400">
|
|
{log.publicIp || "—"}
|
|
</td>
|
|
)}
|
|
{visibleColumns.time && (
|
|
<td className="px-3 py-2 text-right text-text-muted">
|
|
{formatTime(log.timestamp)}
|
|
</td>
|
|
)}
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
)}
|
|
</div>
|
|
</Card>
|
|
|
|
{/* Detail Panel */}
|
|
{selectedLog && <ProxyLogDetail log={selectedLog} onClose={() => setSelectedLog(null)} />}
|
|
</div>
|
|
);
|
|
}
|