feat(loop): loop logları kalıcı hale getir

Loop loglarının dosya sistemine kaydedilmesi, okunması ve
arşivlenmesi için yeni storage modülü eklendi. Loglar artık
oturumlar arasında korunur. UI tarafında mobil menü iyileştirmeleri
ve log paneli güncellemeleri yapıldı.
This commit is contained in:
2026-01-04 11:37:46 +03:00
parent d9ed85ad0c
commit 76418d0bb1
11 changed files with 237 additions and 20 deletions

View File

@@ -25,6 +25,8 @@ export const config = {
dataDir: "/app/data",
dbPath: "/app/data/db.json",
logsPath: "/app/data/logs.json",
loopLogsDir: "/app/data/loop-logs",
loopLogsArchiveDir: "/app/data/loop-logs-archive",
torrentArchiveDir: "/app/data/torrents",
webPublicDir: path.resolve("/app/apps/server/public"),
};

View File

@@ -4,6 +4,7 @@ import { readDb, writeDb } from "../storage/jsondb";
import { nowIso } from "../utils/time";
import { emitJobLog, emitJobMetrics } from "../realtime/emitter";
import { appendAuditLog } from "../utils/logger";
import { appendLoopLog } from "../storage/loopLogs";
const peerErrorThrottle = new Map<string, number>();
@@ -30,6 +31,12 @@ export const startEnforcementWorker = (intervalMs: number) => {
message: "Peer listing unsupported; enforcement disabled",
createdAt: nowIso(),
});
await appendLoopLog({
jobId: job.id,
level: "WARN",
message: "Peer listing unsupported; enforcement disabled",
createdAt: nowIso(),
});
continue;
}
let peersResponse;
@@ -46,6 +53,12 @@ export const startEnforcementWorker = (intervalMs: number) => {
message: "Peer listesi desteklenmiyor; enforcement devre dışı.",
createdAt: nowIso(),
});
await appendLoopLog({
jobId: job.id,
level: "WARN",
message: "Peer listesi desteklenmiyor; enforcement devre dışı.",
createdAt: nowIso(),
});
peerErrorThrottle.set(job.id, Date.now());
}
continue;
@@ -77,6 +90,12 @@ export const startEnforcementWorker = (intervalMs: number) => {
message: `Banned ${banned.length} peers`,
createdAt: nowIso(),
});
await appendLoopLog({
jobId: job.id,
level: "WARN",
message: `Banned ${banned.length} peers`,
createdAt: nowIso(),
});
await appendAuditLog({
level: "WARN",
event: "PEER_BANNED",
@@ -91,6 +110,12 @@ export const startEnforcementWorker = (intervalMs: number) => {
message: "Peer ban unsupported; warn-only enforcement",
createdAt: nowIso(),
});
await appendLoopLog({
jobId: job.id,
level: "WARN",
message: "Peer ban unsupported; warn-only enforcement",
createdAt: nowIso(),
});
}
if (!allowIpConnected) {
@@ -100,6 +125,12 @@ export const startEnforcementWorker = (intervalMs: number) => {
message: "Allowed IP not connected",
createdAt: nowIso(),
});
await appendLoopLog({
jobId: job.id,
level: "WARN",
message: "Allowed IP not connected",
createdAt: nowIso(),
});
}
job.updatedAt = nowIso();
@@ -111,6 +142,12 @@ export const startEnforcementWorker = (intervalMs: number) => {
message: "Enforcement error; continuing.",
createdAt: nowIso(),
});
await appendLoopLog({
jobId: job.id,
level: "ERROR",
message: "Enforcement error; continuing.",
createdAt: nowIso(),
});
}
}

View File

@@ -5,11 +5,13 @@ import { LoopJob } from "../types";
import { nowIso } from "../utils/time";
import { appendAuditLog, logger } from "../utils/logger";
import { emitJobLog, emitJobMetrics } from "../realtime/emitter";
import { appendLoopLog, archiveLoopLogs } from "../storage/loopLogs";
import { config } from "../config";
const logJob = async (jobId: string, level: "INFO" | "WARN" | "ERROR", message: string, event?: string) => {
const createdAt = nowIso();
emitJobLog({ jobId, level, message, createdAt });
await appendLoopLog({ jobId, level, message, createdAt });
if (event) {
await appendAuditLog({ level, event: event as any, message });
}
@@ -78,6 +80,7 @@ export const stopLoopJob = async (jobId: string) => {
job.updatedAt = nowIso();
await writeDb(db);
await logJob(job.id, "WARN", "Loop job stopped by user");
await archiveLoopLogs(job.id, job.torrentHash);
return job;
};

View File

@@ -9,6 +9,7 @@ import { getArchiveStatus } from "../torrent/torrent.archive";
import { config } from "../config";
import { setArchiveStatus } from "../torrent/torrent.archive";
import { nowIso } from "../utils/time";
import { readLoopLogs } from "../storage/loopLogs";
const router = Router();
@@ -145,7 +146,9 @@ router.get("/job/:jobId", async (req, res) => {
});
router.get("/logs/:jobId", async (req, res) => {
res.json({ jobId: req.params.jobId, logs: [] });
const { jobId } = req.params;
const logs = await readLoopLogs(jobId);
res.json({ jobId, logs });
});
export default router;

View File

@@ -0,0 +1,89 @@
import fs from "node:fs/promises";
import path from "node:path";
import { config } from "../config";
import { logger } from "../utils/logger";
import { Mutex } from "./mutex";
export type LoopLogEntry = {
jobId: string;
level: "INFO" | "WARN" | "ERROR";
message: string;
createdAt: string;
};
const loopLogMutex = new Mutex();
const getLoopLogPath = (jobId: string) =>
path.join(config.loopLogsDir, `${jobId}.json`);
const getArchivePath = (jobId: string, torrentHash: string | undefined) => {
const safeTime = new Date().toISOString().replace(/[:.]/g, "-");
const suffix = torrentHash ? `-${torrentHash}` : "";
return path.join(config.loopLogsArchiveDir, `${jobId}${suffix}-${safeTime}.json`);
};
export const appendLoopLog = async (entry: LoopLogEntry) => {
try {
await loopLogMutex.run(async () => {
const filePath = getLoopLogPath(entry.jobId);
let existing: LoopLogEntry[] = [];
try {
const content = await fs.readFile(filePath, "utf-8");
existing = JSON.parse(content) as LoopLogEntry[];
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
throw error;
}
}
const next = [...existing, entry].slice(-2000);
await fs.writeFile(filePath, JSON.stringify(next, null, 2), "utf-8");
});
} catch (error) {
logger.error({ error }, "Failed to append loop log");
}
};
export const readLoopLogs = async (jobId: string) => {
const filePath = getLoopLogPath(jobId);
try {
const content = await fs.readFile(filePath, "utf-8");
return JSON.parse(content) as LoopLogEntry[];
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
throw error;
}
}
try {
const entries = await fs.readdir(config.loopLogsArchiveDir);
const matches = entries
.filter((name) => name.startsWith(jobId))
.map((name) => path.join(config.loopLogsArchiveDir, name));
if (!matches.length) {
return [] as LoopLogEntry[];
}
const latest = matches.sort().at(-1) as string;
const content = await fs.readFile(latest, "utf-8");
return JSON.parse(content) as LoopLogEntry[];
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
throw error;
}
}
return [] as LoopLogEntry[];
};
export const archiveLoopLogs = async (jobId: string, torrentHash?: string) => {
const filePath = getLoopLogPath(jobId);
try {
await fs.access(filePath);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
return null;
}
throw error;
}
const target = getArchivePath(jobId, torrentHash);
await fs.rename(filePath, target);
return target;
};

View File

@@ -4,4 +4,6 @@ import { config } from "../config"
export const ensureDataPaths = async () => {
await fs.mkdir(config.dataDir, { recursive: true });
await fs.mkdir(config.torrentArchiveDir, { recursive: true });
await fs.mkdir(config.loopLogsDir, { recursive: true });
await fs.mkdir(config.loopLogsArchiveDir, { recursive: true });
};

View File

@@ -8,7 +8,7 @@ import { useAppStore } from "../../store/useAppStore";
import { connectSocket } from "../../socket/socket";
import { api } from "../../api/client";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faMoon, faSun, faRightFromBracket } from "@fortawesome/free-solid-svg-icons";
import { faBars, faMoon, faRightFromBracket, faSun } from "@fortawesome/free-solid-svg-icons";
import { AlertToastStack } from "../ui/AlertToastStack";
export const AppLayout = ({ children }: { children: React.ReactNode }) => {
@@ -67,7 +67,17 @@ export const AppLayout = ({ children }: { children: React.ReactNode }) => {
<header className="rounded-xl border border-slate-200 bg-white/80 px-4 py-3">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div>
<div className="text-lg font-semibold text-slate-900">q-buffer</div>
<div className="flex items-start justify-between gap-3">
<div className="text-lg font-semibold text-slate-900">q-buffer</div>
<button
className={`inline-flex items-center justify-center rounded-md border border-slate-300 px-2 py-2 text-xs font-semibold text-slate-700 md:hidden transition-opacity ${menuOpen ? "pointer-events-none opacity-0" : "opacity-100"}`}
onClick={() => setMenuOpen(true)}
type="button"
aria-label="Menü"
>
<FontAwesomeIcon icon={faBars} />
</button>
</div>
<div className="text-xs text-slate-500">
qBittorrent {qbit.version ?? "unknown"}
</div>
@@ -80,18 +90,7 @@ export const AppLayout = ({ children }: { children: React.ReactNode }) => {
</Badge>
</div>
</div>
<button
className="inline-flex items-center justify-center rounded-md border border-slate-300 px-3 py-2 text-xs font-semibold text-slate-700 md:hidden"
onClick={() => setMenuOpen((open) => !open)}
type="button"
>
{menuOpen ? "Close" : "Menu"}
</button>
<div
className={`flex flex-col gap-3 md:flex md:flex-row md:items-center ${
menuOpen ? "flex" : "hidden md:flex"
}`}
>
<div className="hidden md:flex md:flex-row md:items-center gap-3">
<nav className="flex flex-wrap items-center gap-2 rounded-full bg-slate-100 px-2 py-1 text-xs font-semibold text-slate-600">
<NavLink
to="/buffer"
@@ -130,6 +129,68 @@ export const AppLayout = ({ children }: { children: React.ReactNode }) => {
</div>
</header>
<AlertToastStack />
<div
className={`fixed inset-0 z-50 md:hidden transition-opacity duration-500 ${menuOpen ? "opacity-100" : "pointer-events-none opacity-0"}`}
>
<div
className="absolute inset-0 bg-slate-900/30"
onClick={() => setMenuOpen(false)}
/>
<div
className={`absolute right-0 top-0 flex h-full w-64 flex-col gap-6 bg-white p-5 shadow-xl transition-transform duration-500 ease-out ${menuOpen ? "translate-x-0" : "translate-x-full"}`}
onClick={(event) => event.stopPropagation()}
>
<div className="text-sm font-semibold text-slate-700">Menü</div>
<nav className="flex flex-col gap-2 text-sm font-semibold text-slate-700">
<NavLink
to="/buffer"
onClick={() => setMenuOpen(false)}
className={({ isActive }) =>
`rounded-lg border px-3 py-2 ${
isActive ? "border-slate-900 bg-slate-900 text-white" : "border-slate-200 bg-white"
}`
}
>
Buffer
</NavLink>
<NavLink
to="/timer"
onClick={() => setMenuOpen(false)}
className={({ isActive }) =>
`rounded-lg border px-3 py-2 ${
isActive ? "border-slate-900 bg-slate-900 text-white" : "border-slate-200 bg-white"
}`
}
>
Timer
</NavLink>
</nav>
<div className="mt-auto flex items-center gap-3">
<Button
variant="outline"
onClick={() => {
applyTheme(theme === "dark" ? "light" : "dark");
setMenuOpen(false);
}}
title={theme === "dark" ? "Light" : "Dark"}
className="h-10 w-10 px-0"
>
<FontAwesomeIcon icon={theme === "dark" ? faSun : faMoon} />
</Button>
<Button
variant="outline"
onClick={() => {
logout();
setMenuOpen(false);
}}
title="Logout"
className="h-10 w-10 px-0"
>
<FontAwesomeIcon icon={faRightFromBracket} />
</Button>
</div>
</div>
</div>
{children}
</Shell>
);

View File

@@ -1,5 +1,6 @@
import React from "react";
import React, { useEffect } from "react";
import { useAppStore } from "../../store/useAppStore";
import { api } from "../../api/client";
import { Card, CardContent, CardHeader, CardTitle } from "../ui/Card";
import { Badge } from "../ui/Badge";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
@@ -12,10 +13,27 @@ import {
export const LogsPanel = () => {
const logs = useAppStore((s) => s.logs);
const setLogs = useAppStore((s) => s.setLogs);
const selectedHash = useAppStore((s) => s.selectedHash);
const jobs = useAppStore((s) => s.jobs);
const job = jobs.find((j) => j.torrentHash === selectedHash);
const filtered = job ? logs.filter((log) => log.jobId === job.id) : logs;
useEffect(() => {
const loadLogs = async () => {
if (!job) {
setLogs([]);
return;
}
try {
const response = await api.get(`/api/loop/logs/${job.id}`);
setLogs(response.data.logs ?? []);
} catch (error) {
setLogs([]);
}
};
loadLogs();
}, [job?.id, setLogs]);
return (
<Card>

View File

@@ -79,12 +79,12 @@ export const TorrentDetailsCard = () => {
</div>
<div className="mt-2 flex items-center gap-2">
<FontAwesomeIcon icon={faHashtag} className="text-slate-400" />
<span className="min-w-0 truncate">Hash: {torrent.hash}</span>
<span className="min-w-0 truncate"> &nbsp; Hash: {torrent.hash}</span>
</div>
<div className="flex items-center gap-2">
<FontAwesomeIcon icon={faDatabase} className="text-slate-400" />
<span>
Size: {(torrent.size / (1024 * 1024 * 1024)).toFixed(2)} GB
&nbsp; Size: {(torrent.size / (1024 * 1024 * 1024)).toFixed(2)} GB
</span>
</div>
<div className="flex items-center gap-2 truncate" title={torrent.tracker || "-"}>

View File

@@ -169,7 +169,7 @@ export const TorrentTable = () => {
{torrent.name}
</div>
<div className="mt-2 flex items-center gap-1 text-xs">
<div className="h-2 w-28 rounded-full bg-slate-200">
<div className="h-2 w-28 shrink-0 rounded-full bg-slate-200">
<div
className="h-2 rounded-full bg-mint"
style={{ width: `${Math.round(torrent.progress * 100)}%` }}
@@ -177,7 +177,7 @@ export const TorrentTable = () => {
</div>
<span className="w-8 text-left tabular-nums">{Math.round(torrent.progress * 100)}%</span>
<span className="w-16 text-left tabular-nums">{formatSpeed(torrent.dlspeed)}</span>
<span className="flex items-center gap-2 text-slate-500 group-[.is-selected]:text-white">
<span className="flex items-center gap-2 text-slate-500 group-[.is-selected]:text-white whitespace-nowrap">
{renderState(torrent.state)}
{getProfileName(torrent.hash) && (
<span className="text-xs text-slate-500 group-[.is-selected]:text-white">{getProfileName(torrent.hash)}</span>

View File

@@ -86,6 +86,7 @@ interface AppState {
setSnapshot: (snapshot: StatusSnapshot) => void;
updateStatus: (snapshot: Partial<StatusSnapshot>) => void;
addLog: (log: JobLog) => void;
setLogs: (logs: JobLog[]) => void;
setTimerRules: (rules: TimerRule[]) => void;
setTimerLogs: (logs: TimerLog[]) => void;
addTimerLog: (log: TimerLog) => void;
@@ -125,6 +126,7 @@ export const useAppStore = create<AppState>((set) => ({
set((state) => ({
logs: [...state.logs, log].slice(-500),
})),
setLogs: (logs) => set({ logs }),
setTimerRules: (rules) => set({ timerRules: rules }),
setTimerLogs: (logs) => set({ timerLogs: logs }),
addTimerLog: (log) =>