Files
doktainer-upstream/src/app/logs/page.tsx
T

627 lines
17 KiB
TypeScript

"use client";
import DashboardLayout from "@/components/DashboardLayout";
import SearchField from "@/components/SearchField";
import ToastViewport from "@/components/ToastViewport";
import {
logs as logsApi,
servers as serversApi,
type AuditLog,
type Server,
} from "@/lib/api";
import { sanitizeLogText } from "@/lib/terminal-output";
import { useToastManager } from "@/lib/use-toast-manager";
import { FileText, Download, Loader2 } from "lucide-react";
import { useEffect, useRef, useState } from "react";
const categoryOptions = [
"ALL",
"AUTH",
"SERVER",
"CONTAINER",
"DOMAIN",
"SSL",
"SECURITY",
"SYSTEM",
"TERMINAL",
] as const;
const levelOptions = [
"ALL",
"DEBUG",
"INFO",
"WARNING",
"ERROR",
"SUCCESS",
] as const;
type LogCategoryFilter = (typeof categoryOptions)[number];
type LogLevelFilter = (typeof levelOptions)[number];
function sanitizeAuditLog(log: AuditLog): AuditLog {
return {
...log,
message: sanitizeLogText(log.message),
action: sanitizeLogText(log.action),
category: sanitizeLogText(log.category),
level: sanitizeLogText(log.level),
server: log.server
? {
...log.server,
name: sanitizeLogText(log.server.name),
}
: log.server,
user: log.user
? {
...log.user,
name: sanitizeLogText(log.user.name),
}
: log.user,
};
}
const levelColors: Record<string, string> = {
INFO: "#3b82f6",
WARNING: "#f59e0b",
ERROR: "#ef4444",
DEBUG: "#64748b",
SUCCESS: "#10b981",
};
function formatLogTimestamp(value: string) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return value;
}
return date.toLocaleString("id-ID", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
fractionalSecondDigits: 3,
hour12: false,
});
}
function formatLastUpdated(value: string | null) {
if (!value) {
return "Belum ada sinkronisasi";
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return "Belum ada sinkronisasi";
}
return date.toLocaleTimeString("id-ID", {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
});
}
function escapeCsvCell(value: string) {
const escaped = value.replace(/"/g, '""');
return `"${escaped}"`;
}
function buildExportFileName() {
return `logs-${new Date().toISOString().replace(/[.:]/g, "-")}.csv`;
}
export default function LogsPage() {
const [search, setSearch] = useState("");
const [level, setLevel] = useState<LogLevelFilter>("ALL");
const [category, setCategory] = useState<LogCategoryFilter>("ALL");
const [serverId, setServerId] = useState("");
const [live, setLive] = useState(true);
const [logs, setLogs] = useState<AuditLog[]>([]);
const [servers, setServers] = useState<Server[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [exporting, setExporting] = useState(false);
const [error, setError] = useState("");
const [lastUpdatedAt, setLastUpdatedAt] = useState<string | null>(null);
const bottomRef = useRef<HTMLDivElement>(null);
const { toasts, pushToast, dismissToast } = useToastManager();
useEffect(() => {
let cancelled = false;
async function loadServers() {
try {
const response = await serversApi.list();
if (!cancelled) {
setServers(response.data ?? []);
}
} catch {
if (!cancelled) {
pushToast({
tone: "warning",
title: "Servers tidak termuat",
message:
"Filter server tetap tersedia, tetapi daftar server gagal diambil.",
});
}
}
}
void loadServers();
return () => {
cancelled = true;
};
}, [pushToast]);
useEffect(() => {
let cancelled = false;
async function loadLogs(showLoading: boolean) {
if (showLoading) {
setLoading(true);
} else {
setRefreshing(true);
}
try {
const response = await logsApi.list({
serverId: serverId || undefined,
limit: 200,
offset: 0,
});
if (cancelled) {
return;
}
setLogs((response.data ?? []).map(sanitizeAuditLog));
setError("");
setLastUpdatedAt(new Date().toISOString());
} catch (err) {
if (cancelled) {
return;
}
setError(
err instanceof Error ? err.message : "Gagal memuat data logs.",
);
} finally {
if (!cancelled) {
setLoading(false);
setRefreshing(false);
}
}
}
void loadLogs(true);
if (!live) {
return () => {
cancelled = true;
};
}
const intervalId = window.setInterval(() => {
void loadLogs(false);
}, 5000);
return () => {
cancelled = true;
window.clearInterval(intervalId);
};
}, [live, serverId]);
const searchTerm = search.trim().toLowerCase();
const filtered = [...logs]
.filter((log) => {
const matchesSearch =
!searchTerm ||
[
log.message,
log.action,
log.category,
log.level,
log.server?.name ?? "",
log.user?.name ?? "",
].some((value) => value.toLowerCase().includes(searchTerm));
const matchesLevel = level === "ALL" || log.level === level;
const matchesCategory = category === "ALL" || log.category === category;
return matchesSearch && matchesLevel && matchesCategory;
})
.reverse();
useEffect(() => {
if (live) {
bottomRef.current?.scrollIntoView({ block: "end" });
}
}, [filtered.length, live]);
function handleExport() {
if (!filtered.length) {
pushToast({
tone: "warning",
title: "Tidak ada data",
message: "Tidak ada log yang bisa diekspor untuk filter saat ini.",
});
return;
}
setExporting(true);
try {
const csvRows = [
[
"Timestamp",
"Level",
"Category",
"Server",
"Action",
"Message",
"User",
],
...filtered.map((log) => [
formatLogTimestamp(log.createdAt),
log.level,
log.category,
log.server?.name ?? "-",
log.action,
log.message,
log.user?.name ?? "-",
]),
];
const csv = csvRows
.map((row) => row.map((cell) => escapeCsvCell(String(cell))).join(","))
.join("\r\n");
const blob = new Blob([`\uFEFF${csv}`], {
type: "text/csv;charset=utf-8;",
});
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = buildExportFileName();
link.click();
window.URL.revokeObjectURL(url);
pushToast({
tone: "success",
title: "Export berhasil",
message: `${filtered.length} log berhasil diekspor ke CSV.`,
});
} catch {
pushToast({
tone: "error",
title: "Export gagal",
message: "Terjadi masalah saat membuat file export logs.",
});
} finally {
setExporting(false);
}
}
return (
<>
<DashboardLayout
title="Live Logs"
subtitle="Real audit logs with periodic refresh and CSV export"
>
<div
className="animate-slide-in"
style={{ display: "flex", flexDirection: "column", gap: 14 }}
>
<div
className="card"
style={{
padding: "12px 16px",
display: "flex",
alignItems: "center",
gap: 12,
flexWrap: "wrap",
}}
>
<SearchField
placeholder="Cari message, action, server, atau user..."
value={search}
onChange={(e) => setSearch(e.target.value)}
containerStyle={{
flex: "1 1 280px",
minWidth: 220,
maxWidth: 480,
}}
/>
<select
value={serverId}
onChange={(e) => setServerId(e.target.value)}
className="input"
style={{ width: 190, appearance: "none", flex: "0 0 auto" }}
>
<option value="">All servers</option>
{servers.map((server) => (
<option key={server.id} value={server.id}>
{server.name}
</option>
))}
</select>
<select
value={category}
onChange={(e) => setCategory(e.target.value as LogCategoryFilter)}
className="input"
style={{ width: 160, appearance: "none", flex: "0 0 auto" }}
>
{categoryOptions.map((option) => (
<option key={option} value={option}>
{option === "ALL" ? "All categories" : option}
</option>
))}
</select>
<div
style={{
display: "flex",
gap: 4,
background: "var(--bg-input)",
borderRadius: 8,
padding: 3,
flexWrap: "wrap",
}}
>
{levelOptions.map((option) => (
<button
key={option}
onClick={() => setLevel(option)}
type="button"
style={{
padding: "4px 10px",
borderRadius: 6,
border: "none",
cursor: "pointer",
background:
level === option ? "var(--bg-card)" : "transparent",
color:
level === option
? levelColors[option] || "var(--text-primary)"
: "var(--text-muted)",
fontSize: 11,
fontWeight: 600,
}}
>
{option}
</button>
))}
</div>
<div style={{ marginLeft: "auto", display: "flex", gap: 8 }}>
<button
onClick={() => setLive((current) => !current)}
type="button"
style={{
display: "flex",
alignItems: "center",
gap: 6,
padding: "6px 12px",
borderRadius: 8,
border: `1px solid ${live ? "rgba(16,185,129,0.3)" : "var(--border)"}`,
background: live
? "rgba(16,185,129,0.08)"
: "var(--bg-input)",
color: live ? "#10b981" : "var(--text-muted)",
fontSize: 12,
fontWeight: 500,
cursor: "pointer",
}}
>
{live && (
<span
className="animate-pulse-dot"
style={{
display: "inline-block",
width: 6,
height: 6,
borderRadius: "50%",
background: "#10b981",
}}
/>
)}
{live ? "Live" : "Paused"}
</button>
<button
className="btn btn-ghost"
style={{ fontSize: 12 }}
onClick={handleExport}
type="button"
disabled={exporting || !filtered.length}
>
{exporting ? (
<Loader2 size={12} className="animate-spin" />
) : (
<Download size={12} />
)}
Export CSV
</button>
</div>
</div>
<div
className="card"
style={{
padding: "10px 14px",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 12,
flexWrap: "wrap",
}}
>
<span
style={{
fontSize: 12,
color: error ? "#fca5a5" : "var(--text-secondary)",
}}
>
{error ||
`Showing ${filtered.length} of ${logs.length} latest logs`}
</span>
<span
style={{
fontSize: 11,
color: "var(--text-muted)",
}}
>
Last updated {formatLastUpdated(lastUpdatedAt)}
</span>
</div>
<div
className="card terminal-text"
style={{
background: "var(--terminal-surface)",
border: "1px solid var(--border)",
borderRadius: 10,
overflow: "hidden",
}}
>
<div
style={{
padding: "8px 16px",
background: "var(--terminal-header)",
borderBottom: "1px solid var(--border)",
display: "flex",
alignItems: "center",
gap: 10,
}}
>
<FileText size={13} style={{ color: "#3b82f6" }} />
<span
style={{
fontSize: 12,
color: "var(--text-secondary)",
fontWeight: 500,
}}
>
Audit Log Stream
</span>
{(loading || refreshing) && (
<span
style={{
fontSize: 11,
color: "#60a5fa",
display: "flex",
alignItems: "center",
gap: 6,
}}
>
<Loader2 size={12} className="animate-spin" />
Synchronizing...
</span>
)}
<span
style={{
marginLeft: "auto",
fontSize: 11,
color: "var(--text-muted)",
}}
>
{filtered.length} lines
</span>
</div>
<div
style={{
height: "calc(100vh - 340px)",
overflowY: "auto",
padding: "12px 0",
}}
>
{!loading && !filtered.length ? (
<div
style={{
padding: "24px 16px",
color: "var(--text-muted)",
fontSize: 13,
}}
>
Tidak ada log yang cocok dengan filter saat ini.
</div>
) : (
filtered.map((log) => (
<div
key={log.id}
style={{
display: "flex",
gap: 0,
padding: "2px 16px",
lineHeight: 1.7,
}}
onMouseEnter={(e) =>
((e.currentTarget as HTMLElement).style.background =
"var(--terminal-row-hover)")
}
onMouseLeave={(e) =>
((e.currentTarget as HTMLElement).style.background =
"transparent")
}
>
<span
style={{
color: "var(--terminal-timestamp)",
flexShrink: 0,
width: 205,
}}
>
{formatLogTimestamp(log.createdAt)}
</span>
<span
style={{
color: levelColors[log.level] || "#3b82f6",
flexShrink: 0,
width: 86,
fontWeight: 700,
}}
>
[{log.level}]
</span>
<span
style={{ color: "#3b82f6", flexShrink: 0, width: 170 }}
>
{log.server?.name ?? log.category}
</span>
<span
style={{
color: "#94a3b8",
flexShrink: 0,
width: 320,
}}
>
{log.action}
</span>
<span
style={{
color:
log.level === "ERROR"
? "#ef4444"
: log.level === "WARNING"
? "#f59e0b"
: log.level === "SUCCESS"
? "#10b981"
: "var(--terminal-body)",
}}
>
{log.message}
</span>
</div>
))
)}
<div ref={bottomRef} />
</div>
</div>
</div>
</DashboardLayout>
<ToastViewport toasts={toasts} onClose={dismissToast} />
</>
);
}