feat(logs): add Logs Dashboard with real-time Console Viewer

- Consolidated 4-tab Logs page: Request Logs, Proxy Logs, Audit Logs, Console
- Terminal-style Console Log Viewer with level filter, search, auto-scroll
- Console interceptor captures all console.log/warn/error to JSON log file
- Integrated in Next.js instrumentation.ts for both dev and prod
- Log rotation by size + retention-based cleanup
- Fixed pino logger file transport (pino/file targets only)
- Moved initAuditLog() to instrumentation.ts for proper initialization
- Bumped version to 1.0.3
- Updated CHANGELOG, all 8 README translations, and .env.example
This commit is contained in:
diegosouzapw
2026-02-19 04:38:04 -03:00
parent 2ddf63fde1
commit 1e332babd6
27 changed files with 1244 additions and 62 deletions

View File

@@ -0,0 +1,232 @@
"use client";
/**
* Audit Log Tab — Embedded version of the audit-log page for the Logs dashboard.
* Fetches from /api/compliance/audit-log with filter support.
*/
import { useState, useEffect, useCallback } from "react";
interface AuditEntry {
id: number;
timestamp: string;
action: string;
actor: string;
target: string | null;
details: any;
ip_address: string | null;
}
const PAGE_SIZE = 25;
export default function AuditLogTab() {
const [entries, setEntries] = useState<AuditEntry[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [actionFilter, setActionFilter] = useState("");
const [actorFilter, setActorFilter] = useState("");
const [offset, setOffset] = useState(0);
const [hasMore, setHasMore] = useState(false);
const fetchEntries = useCallback(async () => {
setLoading(true);
setError(null);
try {
const params = new URLSearchParams();
if (actionFilter) params.set("action", actionFilter);
if (actorFilter) params.set("actor", actorFilter);
params.set("limit", String(PAGE_SIZE + 1));
params.set("offset", String(offset));
const res = await fetch(`/api/compliance/audit-log?${params.toString()}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data: AuditEntry[] = await res.json();
setHasMore(data.length > PAGE_SIZE);
setEntries(data.slice(0, PAGE_SIZE));
} catch (err: any) {
setError(err.message || "Failed to fetch audit log");
} finally {
setLoading(false);
}
}, [actionFilter, actorFilter, offset]);
useEffect(() => {
fetchEntries();
}, [fetchEntries]);
const handleSearch = () => {
setOffset(0);
fetchEntries();
};
const formatTimestamp = (ts: string) => {
try {
return new Date(ts).toLocaleString();
} catch {
return ts;
}
};
const actionBadgeColor = (action: string) => {
if (action.includes("delete") || action.includes("remove"))
return "bg-red-500/15 text-red-400 border-red-500/20";
if (action.includes("create") || action.includes("add"))
return "bg-green-500/15 text-green-400 border-green-500/20";
if (action.includes("update") || action.includes("change"))
return "bg-blue-500/15 text-blue-400 border-blue-500/20";
if (action.includes("login") || action.includes("auth"))
return "bg-purple-500/15 text-purple-400 border-purple-500/20";
return "bg-gray-500/15 text-gray-400 border-gray-500/20";
};
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-xl font-bold text-[var(--color-text-main)]">Audit Log</h2>
<p className="text-sm text-[var(--color-text-muted)] mt-1">
Administrative actions and security events
</p>
</div>
<button
onClick={fetchEntries}
disabled={loading}
aria-label="Refresh audit log"
className="px-4 py-2 rounded-lg text-sm font-medium bg-[var(--color-surface)] border border-[var(--color-border)] text-[var(--color-text-main)] hover:bg-[var(--color-bg-alt)] transition-colors disabled:opacity-50"
>
{loading ? "Loading..." : "Refresh"}
</button>
</div>
{/* Filters */}
<div
className="flex flex-wrap gap-3 p-4 rounded-xl bg-[var(--color-surface)] border border-[var(--color-border)]"
role="search"
aria-label="Filter audit log entries"
>
<input
type="text"
placeholder="Filter by action..."
value={actionFilter}
onChange={(e) => setActionFilter(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
aria-label="Filter by action type"
className="flex-1 min-w-[180px] 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)]"
/>
<input
type="text"
placeholder="Filter by actor..."
value={actorFilter}
onChange={(e) => setActorFilter(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
aria-label="Filter by actor"
className="flex-1 min-w-[180px] 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)]"
/>
<button
onClick={handleSearch}
className="px-4 py-2 rounded-lg text-sm font-medium bg-[var(--color-accent)] text-white hover:bg-[var(--color-accent-hover)] transition-colors focus:outline-2 focus:outline-offset-2 focus:outline-[var(--color-accent)]"
>
Search
</button>
</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"
>
{error}
</div>
)}
{/* Table */}
<div className="overflow-x-auto rounded-xl border border-[var(--color-border)]">
<table className="w-full text-sm" role="table" aria-label="Audit log entries">
<thead>
<tr className="bg-[var(--color-bg-alt)] border-b border-[var(--color-border)]">
<th className="text-left px-4 py-3 font-medium text-[var(--color-text-muted)]">
Timestamp
</th>
<th className="text-left px-4 py-3 font-medium text-[var(--color-text-muted)]">
Action
</th>
<th className="text-left px-4 py-3 font-medium text-[var(--color-text-muted)]">
Actor
</th>
<th className="text-left px-4 py-3 font-medium text-[var(--color-text-muted)]">
Target
</th>
<th className="text-left px-4 py-3 font-medium text-[var(--color-text-muted)]">
Details
</th>
<th className="text-left px-4 py-3 font-medium text-[var(--color-text-muted)]">IP</th>
</tr>
</thead>
<tbody>
{entries.length === 0 && !loading ? (
<tr>
<td colSpan={6} className="px-4 py-8 text-center text-[var(--color-text-muted)]">
No audit log entries found
</td>
</tr>
) : (
entries.map((entry) => (
<tr
key={entry.id}
className="border-b border-[var(--color-border)] hover:bg-[var(--color-bg-alt)] transition-colors"
>
<td className="px-4 py-3 whitespace-nowrap text-[var(--color-text-muted)] font-mono text-xs">
{formatTimestamp(entry.timestamp)}
</td>
<td className="px-4 py-3">
<span
className={`inline-block px-2 py-0.5 rounded-md text-xs font-medium border ${actionBadgeColor(entry.action)}`}
>
{entry.action}
</span>
</td>
<td className="px-4 py-3 text-[var(--color-text-main)]">{entry.actor}</td>
<td className="px-4 py-3 text-[var(--color-text-muted)] max-w-[200px] truncate">
{entry.target || "—"}
</td>
<td className="px-4 py-3 text-[var(--color-text-muted)] max-w-[300px] truncate font-mono text-xs">
{entry.details ? JSON.stringify(entry.details) : "—"}
</td>
<td className="px-4 py-3 text-[var(--color-text-muted)] font-mono text-xs whitespace-nowrap">
{entry.ip_address || "—"}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{/* Pagination */}
<div className="flex items-center justify-between">
<p className="text-xs text-[var(--color-text-muted)]">
Showing {entries.length} entries (offset {offset})
</p>
<div className="flex gap-2">
<button
onClick={() => setOffset(Math.max(0, offset - PAGE_SIZE))}
disabled={offset === 0}
className="px-3 py-1.5 rounded-lg text-xs font-medium bg-[var(--color-surface)] border border-[var(--color-border)] text-[var(--color-text-main)] hover:bg-[var(--color-bg-alt)] disabled:opacity-30 transition-colors"
>
Previous
</button>
<button
onClick={() => setOffset(offset + PAGE_SIZE)}
disabled={!hasMore}
className="px-3 py-1.5 rounded-lg text-xs font-medium bg-[var(--color-surface)] border border-[var(--color-border)] text-[var(--color-text-main)] hover:bg-[var(--color-bg-alt)] disabled:opacity-30 transition-colors"
>
Next
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,31 @@
"use client";
import { useState } from "react";
import { RequestLoggerV2, ProxyLogger, SegmentedControl } from "@/shared/components";
import ConsoleLogViewer from "@/shared/components/ConsoleLogViewer";
import AuditLogTab from "./AuditLogTab";
export default function LogsPage() {
const [activeTab, setActiveTab] = useState("request-logs");
return (
<div className="flex flex-col gap-6">
<SegmentedControl
options={[
{ value: "request-logs", label: "Request Logs" },
{ value: "proxy-logs", label: "Proxy Logs" },
{ value: "audit-logs", label: "Audit Logs" },
{ value: "console", label: "Console" },
]}
value={activeTab}
onChange={setActiveTab}
/>
{/* Content */}
{activeTab === "request-logs" && <RequestLoggerV2 />}
{activeTab === "proxy-logs" && <ProxyLogger />}
{activeTab === "audit-logs" && <AuditLogTab />}
{activeTab === "console" && <ConsoleLogViewer />}
</div>
);
}

View File

@@ -0,0 +1,115 @@
/**
* Console Log API — GET /api/logs/console
*
* Reads the application log file and returns entries from the last 1 hour.
* Supports filtering by level and limiting the number of entries.
*
* Query params:
* - level: minimum log level (debug|info|warn|error) — default: all
* - limit: max entries to return — default: 500
* - component: filter by component/module name
*/
import { NextRequest, NextResponse } from "next/server";
import { readFileSync, existsSync } from "fs";
import { join } from "path";
const LEVEL_ORDER: Record<string, number> = {
trace: 5,
debug: 10,
info: 20,
warn: 30,
error: 40,
fatal: 50,
};
// Map pino numeric levels to string levels
const NUMERIC_LEVEL_MAP: Record<number, string> = {
10: "trace",
20: "info",
30: "warn",
40: "error",
50: "fatal",
60: "fatal",
};
function getLogFilePath(): string {
return process.env.LOG_FILE_PATH || join(process.cwd(), "logs", "application", "app.log");
}
function parseLevel(raw: string | number): string {
if (typeof raw === "number") {
return NUMERIC_LEVEL_MAP[raw] || "info";
}
return String(raw).toLowerCase();
}
export async function GET(req: NextRequest) {
try {
const { searchParams } = new URL(req.url);
const levelFilter = searchParams.get("level") || "all";
const limit = Math.min(parseInt(searchParams.get("limit") || "500", 10), 2000);
const componentFilter = searchParams.get("component") || "";
const logPath = getLogFilePath();
if (!existsSync(logPath)) {
return NextResponse.json([], { status: 200 });
}
const raw = readFileSync(logPath, "utf-8");
const lines = raw.trim().split("\n").filter(Boolean);
const oneHourAgo = Date.now() - 60 * 60 * 1000;
const minLevel = LEVEL_ORDER[levelFilter] || 0;
const entries: any[] = [];
for (const line of lines) {
try {
const entry = JSON.parse(line);
// Filter by time (last 1 hour)
const ts = entry.time || entry.timestamp;
if (ts) {
const entryTime = new Date(ts).getTime();
if (entryTime < oneHourAgo) continue;
}
// Normalize level
entry.level = parseLevel(entry.level);
// Filter by level
const entryLevelNum = LEVEL_ORDER[entry.level] || 0;
if (minLevel > 0 && entryLevelNum < minLevel) continue;
// Filter by component
if (componentFilter) {
const comp = entry.component || entry.module || "";
if (!comp.toLowerCase().includes(componentFilter.toLowerCase())) continue;
}
// Normalize timestamp field
if (entry.time && !entry.timestamp) {
entry.timestamp = entry.time;
}
entries.push(entry);
} catch {
// Skip unparseable lines
}
}
// Return last N entries (most recent)
const result = entries.slice(-limit);
return NextResponse.json(result, {
status: 200,
headers: {
"Cache-Control": "no-store, no-cache, must-revalidate",
},
});
} catch (err: any) {
return NextResponse.json({ error: err.message || "Failed to read logs" }, { status: 500 });
}
}