diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index 68a6540..3771331 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -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"), }; diff --git a/apps/server/src/enforcement/enforcement.worker.ts b/apps/server/src/enforcement/enforcement.worker.ts index 3a0269f..f95d1c7 100644 --- a/apps/server/src/enforcement/enforcement.worker.ts +++ b/apps/server/src/enforcement/enforcement.worker.ts @@ -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(); @@ -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(), + }); } } diff --git a/apps/server/src/loop/loop.engine.ts b/apps/server/src/loop/loop.engine.ts index ab6874e..1f4d77e 100644 --- a/apps/server/src/loop/loop.engine.ts +++ b/apps/server/src/loop/loop.engine.ts @@ -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; }; diff --git a/apps/server/src/loop/loop.routes.ts b/apps/server/src/loop/loop.routes.ts index 1b87006..63ae366 100644 --- a/apps/server/src/loop/loop.routes.ts +++ b/apps/server/src/loop/loop.routes.ts @@ -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; diff --git a/apps/server/src/storage/loopLogs.ts b/apps/server/src/storage/loopLogs.ts new file mode 100644 index 0000000..50610bb --- /dev/null +++ b/apps/server/src/storage/loopLogs.ts @@ -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; +}; diff --git a/apps/server/src/storage/paths.ts b/apps/server/src/storage/paths.ts index c0f74a2..04f93de 100644 --- a/apps/server/src/storage/paths.ts +++ b/apps/server/src/storage/paths.ts @@ -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 }); }; diff --git a/apps/web/src/components/layout/AppLayout.tsx b/apps/web/src/components/layout/AppLayout.tsx index fde225f..7d3e487 100644 --- a/apps/web/src/components/layout/AppLayout.tsx +++ b/apps/web/src/components/layout/AppLayout.tsx @@ -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 }) => {
-
q-buffer
+
+
q-buffer
+ +
qBittorrent {qbit.version ?? "unknown"}
@@ -80,18 +90,7 @@ export const AppLayout = ({ children }: { children: React.ReactNode }) => {
- -
+
+
+
setMenuOpen(false)} + /> +
event.stopPropagation()} + > +
Menü
+ +
+ + +
+
+
{children} ); diff --git a/apps/web/src/components/loop/LogsPanel.tsx b/apps/web/src/components/loop/LogsPanel.tsx index 337b8e8..c232f75 100644 --- a/apps/web/src/components/loop/LogsPanel.tsx +++ b/apps/web/src/components/loop/LogsPanel.tsx @@ -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 ( diff --git a/apps/web/src/components/torrents/TorrentDetailsCard.tsx b/apps/web/src/components/torrents/TorrentDetailsCard.tsx index 06de217..748025f 100644 --- a/apps/web/src/components/torrents/TorrentDetailsCard.tsx +++ b/apps/web/src/components/torrents/TorrentDetailsCard.tsx @@ -79,12 +79,12 @@ export const TorrentDetailsCard = () => {
- Hash: {torrent.hash} +   Hash: {torrent.hash}
- Size: {(torrent.size / (1024 * 1024 * 1024)).toFixed(2)} GB +   Size: {(torrent.size / (1024 * 1024 * 1024)).toFixed(2)} GB
diff --git a/apps/web/src/components/torrents/TorrentTable.tsx b/apps/web/src/components/torrents/TorrentTable.tsx index 0d1c356..b398108 100644 --- a/apps/web/src/components/torrents/TorrentTable.tsx +++ b/apps/web/src/components/torrents/TorrentTable.tsx @@ -169,7 +169,7 @@ export const TorrentTable = () => { {torrent.name}
-
+
{
{Math.round(torrent.progress * 100)}% {formatSpeed(torrent.dlspeed)} - + {renderState(torrent.state)} {getProfileName(torrent.hash) && ( {getProfileName(torrent.hash)} diff --git a/apps/web/src/store/useAppStore.ts b/apps/web/src/store/useAppStore.ts index 896a471..2bc1a2a 100644 --- a/apps/web/src/store/useAppStore.ts +++ b/apps/web/src/store/useAppStore.ts @@ -86,6 +86,7 @@ interface AppState { setSnapshot: (snapshot: StatusSnapshot) => void; updateStatus: (snapshot: Partial) => 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((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) =>