Compare commits
2 Commits
075780435c
...
967eb2d2a4
| Author | SHA1 | Date | |
|---|---|---|---|
| 967eb2d2a4 | |||
| 9b495b7bf7 |
@@ -1,7 +1,7 @@
|
|||||||
import { QbitClient } from "../qbit/qbit.client"
|
import { QbitClient } from "../qbit/qbit.client"
|
||||||
import { tickLoopJobs } from "./loop.engine"
|
import { tickLoopJobs } from "./loop.engine"
|
||||||
import { getStatusSnapshot, refreshJobsStatus, setTorrentsStatus } from "../status/status.service"
|
import { getStatusSnapshot, refreshJobsStatus, setQbitStatus, setTorrentsStatus } from "../status/status.service"
|
||||||
import { emitStatusUpdate } from "../realtime/emitter"
|
import { emitQbitHealth, emitStatusUpdate } from "../realtime/emitter"
|
||||||
import { logger } from "../utils/logger"
|
import { logger } from "../utils/logger"
|
||||||
|
|
||||||
export const startLoopScheduler = (qbit: QbitClient, intervalMs: number) => {
|
export const startLoopScheduler = (qbit: QbitClient, intervalMs: number) => {
|
||||||
@@ -20,7 +20,19 @@ export const startLoopScheduler = (qbit: QbitClient, intervalMs: number) => {
|
|||||||
jobs,
|
jobs,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Unknown error";
|
||||||
logger.error({ error }, "Loop scheduler tick failed");
|
logger.error({ error }, "Loop scheduler tick failed");
|
||||||
|
setQbitStatus({ ok: false, lastError: message });
|
||||||
|
emitQbitHealth({ ok: false, lastError: message });
|
||||||
|
try {
|
||||||
|
const current = await getStatusSnapshot();
|
||||||
|
emitStatusUpdate({
|
||||||
|
...current,
|
||||||
|
qbit: { ...current.qbit, ok: false, lastError: message },
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Swallow secondary status errors to keep scheduler alive.
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, intervalMs);
|
}, intervalMs);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -48,6 +48,18 @@ export class QbitClient {
|
|||||||
}
|
}
|
||||||
return await fn();
|
return await fn();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (axios.isAxiosError(error)) {
|
||||||
|
const code = error.code ?? "";
|
||||||
|
const transient =
|
||||||
|
code === "EAI_AGAIN" ||
|
||||||
|
code === "ENOTFOUND" ||
|
||||||
|
code === "ECONNREFUSED" ||
|
||||||
|
code === "ECONNRESET" ||
|
||||||
|
code === "ETIMEDOUT";
|
||||||
|
if (transient) {
|
||||||
|
this.loggedIn = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
axios.isAxiosError(error) &&
|
axios.isAxiosError(error) &&
|
||||||
(error.response?.status === 401 || error.response?.status === 403)
|
(error.response?.status === 401 || error.response?.status === 403)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { readDb, writeDb } from "../storage/jsondb";
|
|||||||
import { TimerLog, TimerSummary } from "../types";
|
import { TimerLog, TimerSummary } from "../types";
|
||||||
import { emitTimerLog, emitTimerSummary } from "../realtime/emitter";
|
import { emitTimerLog, emitTimerSummary } from "../realtime/emitter";
|
||||||
import { nowIso } from "../utils/time";
|
import { nowIso } from "../utils/time";
|
||||||
|
import { logger } from "../utils/logger";
|
||||||
|
|
||||||
const MAX_LOGS = 2000;
|
const MAX_LOGS = 2000;
|
||||||
|
|
||||||
@@ -17,76 +18,80 @@ const normalizeTags = (tags?: string, category?: string) => {
|
|||||||
|
|
||||||
export const startTimerWorker = (qbit: QbitClient, intervalMs: number) => {
|
export const startTimerWorker = (qbit: QbitClient, intervalMs: number) => {
|
||||||
setInterval(async () => {
|
setInterval(async () => {
|
||||||
const db = await readDb();
|
try {
|
||||||
const rules = db.timerRules ?? [];
|
const db = await readDb();
|
||||||
if (rules.length === 0) {
|
const rules = db.timerRules ?? [];
|
||||||
return;
|
if (rules.length === 0) {
|
||||||
}
|
return;
|
||||||
const torrents = await qbit.getTorrentsInfo();
|
|
||||||
let summary: TimerSummary =
|
|
||||||
db.timerSummary ?? {
|
|
||||||
totalDeleted: 0,
|
|
||||||
totalSeededSeconds: 0,
|
|
||||||
totalUploadedBytes: 0,
|
|
||||||
updatedAt: nowIso(),
|
|
||||||
};
|
|
||||||
|
|
||||||
const logs: TimerLog[] = [];
|
|
||||||
|
|
||||||
for (const torrent of torrents) {
|
|
||||||
const tags = normalizeTags(torrent.tags, torrent.category);
|
|
||||||
const matchingRules = rules.filter((rule) => {
|
|
||||||
return rule.tags.some((tag) => tags.includes(tag.toLowerCase()));
|
|
||||||
});
|
|
||||||
if (matchingRules.length === 0) {
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
const matchingRule = matchingRules.reduce((best, current) =>
|
const torrents = await qbit.getTorrentsInfo();
|
||||||
current.seedLimitSeconds < best.seedLimitSeconds ? current : best
|
let summary: TimerSummary =
|
||||||
);
|
db.timerSummary ?? {
|
||||||
const addedOnMs = Number(torrent.added_on ?? 0) * 1000;
|
totalDeleted: 0,
|
||||||
const elapsedSeconds =
|
totalSeededSeconds: 0,
|
||||||
addedOnMs > 0
|
totalUploadedBytes: 0,
|
||||||
? Math.max(0, (Date.now() - addedOnMs) / 1000)
|
updatedAt: nowIso(),
|
||||||
: Number(torrent.seeding_time ?? 0);
|
};
|
||||||
const seedingSeconds = Number(torrent.seeding_time ?? 0);
|
|
||||||
if (elapsedSeconds < matchingRule.seedLimitSeconds) {
|
const logs: TimerLog[] = [];
|
||||||
continue;
|
|
||||||
|
for (const torrent of torrents) {
|
||||||
|
const tags = normalizeTags(torrent.tags, torrent.category);
|
||||||
|
const matchingRules = rules.filter((rule) => {
|
||||||
|
return rule.tags.some((tag) => tags.includes(tag.toLowerCase()));
|
||||||
|
});
|
||||||
|
if (matchingRules.length === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const matchingRule = matchingRules.reduce((best, current) =>
|
||||||
|
current.seedLimitSeconds < best.seedLimitSeconds ? current : best
|
||||||
|
);
|
||||||
|
const addedOnMs = Number(torrent.added_on ?? 0) * 1000;
|
||||||
|
const elapsedSeconds =
|
||||||
|
addedOnMs > 0
|
||||||
|
? Math.max(0, (Date.now() - addedOnMs) / 1000)
|
||||||
|
: Number(torrent.seeding_time ?? 0);
|
||||||
|
const seedingSeconds = Number(torrent.seeding_time ?? 0);
|
||||||
|
if (elapsedSeconds < matchingRule.seedLimitSeconds) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await qbit.deleteTorrent(torrent.hash, matchingRule.deleteFiles ?? true);
|
||||||
|
} catch (error) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const logEntry: TimerLog = {
|
||||||
|
id: randomUUID(),
|
||||||
|
hash: torrent.hash,
|
||||||
|
name: torrent.name,
|
||||||
|
sizeBytes: torrent.size,
|
||||||
|
tracker: torrent.tracker,
|
||||||
|
tags,
|
||||||
|
category: torrent.category,
|
||||||
|
seedingTimeSeconds: seedingSeconds,
|
||||||
|
uploadedBytes: torrent.uploaded ?? 0,
|
||||||
|
deletedAt: nowIso(),
|
||||||
|
};
|
||||||
|
logs.push(logEntry);
|
||||||
|
summary = {
|
||||||
|
totalDeleted: summary.totalDeleted + 1,
|
||||||
|
totalSeededSeconds: summary.totalSeededSeconds + seedingSeconds,
|
||||||
|
totalUploadedBytes: summary.totalUploadedBytes + (torrent.uploaded ?? 0),
|
||||||
|
updatedAt: nowIso(),
|
||||||
|
};
|
||||||
|
emitTimerLog(logEntry);
|
||||||
|
emitTimerSummary(summary);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
if (logs.length > 0) {
|
||||||
await qbit.deleteTorrent(torrent.hash, matchingRule.deleteFiles ?? true);
|
db.timerLogs = [...(db.timerLogs ?? []), ...logs].slice(-MAX_LOGS);
|
||||||
} catch (error) {
|
db.timerSummary = summary;
|
||||||
continue;
|
await writeDb(db);
|
||||||
}
|
}
|
||||||
|
} catch (error) {
|
||||||
const logEntry: TimerLog = {
|
logger.error({ error }, "Timer worker tick failed");
|
||||||
id: randomUUID(),
|
|
||||||
hash: torrent.hash,
|
|
||||||
name: torrent.name,
|
|
||||||
sizeBytes: torrent.size,
|
|
||||||
tracker: torrent.tracker,
|
|
||||||
tags,
|
|
||||||
category: torrent.category,
|
|
||||||
seedingTimeSeconds: seedingSeconds,
|
|
||||||
uploadedBytes: torrent.uploaded ?? 0,
|
|
||||||
deletedAt: nowIso(),
|
|
||||||
};
|
|
||||||
logs.push(logEntry);
|
|
||||||
summary = {
|
|
||||||
totalDeleted: summary.totalDeleted + 1,
|
|
||||||
totalSeededSeconds: summary.totalSeededSeconds + seedingSeconds,
|
|
||||||
totalUploadedBytes: summary.totalUploadedBytes + (torrent.uploaded ?? 0),
|
|
||||||
updatedAt: nowIso(),
|
|
||||||
};
|
|
||||||
emitTimerLog(logEntry);
|
|
||||||
emitTimerSummary(summary);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (logs.length > 0) {
|
|
||||||
db.timerLogs = [...(db.timerLogs ?? []), ...logs].slice(-MAX_LOGS);
|
|
||||||
db.timerSummary = summary;
|
|
||||||
await writeDb(db);
|
|
||||||
}
|
}
|
||||||
}, intervalMs);
|
}, intervalMs);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
"@fortawesome/react-fontawesome": "^0.2.2",
|
"@fortawesome/react-fontawesome": "^0.2.2",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.2",
|
"@radix-ui/react-alert-dialog": "^1.1.2",
|
||||||
"@radix-ui/react-select": "^2.1.2",
|
"@radix-ui/react-select": "^2.1.2",
|
||||||
|
"@radix-ui/react-scroll-area": "^1.2.2",
|
||||||
"axios": "^1.7.7",
|
"axios": "^1.7.7",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
|
|||||||
27
apps/web/src/components/ui/ScrollArea.tsx
Normal file
27
apps/web/src/components/ui/ScrollArea.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
|
||||||
|
import clsx from "clsx";
|
||||||
|
|
||||||
|
export const ScrollArea = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<ScrollAreaPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={clsx("relative overflow-hidden", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ScrollAreaPrimitive.Viewport className="h-full w-full overflow-y-auto rounded-[inherit]">
|
||||||
|
{children}
|
||||||
|
</ScrollAreaPrimitive.Viewport>
|
||||||
|
<ScrollAreaPrimitive.Scrollbar
|
||||||
|
orientation="vertical"
|
||||||
|
className="flex w-2.5 touch-none select-none rounded-full bg-transparent p-0.5 opacity-100"
|
||||||
|
>
|
||||||
|
<ScrollAreaPrimitive.Thumb className="relative flex-1 rounded-full bg-slate-200" />
|
||||||
|
</ScrollAreaPrimitive.Scrollbar>
|
||||||
|
<ScrollAreaPrimitive.Corner />
|
||||||
|
</ScrollAreaPrimitive.Root>
|
||||||
|
));
|
||||||
|
|
||||||
|
ScrollArea.displayName = "ScrollArea";
|
||||||
@@ -2,6 +2,7 @@ import React, { useEffect, useMemo, useState } from "react";
|
|||||||
import { Card, CardContent, CardHeader, CardTitle } from "../components/ui/Card";
|
import { Card, CardContent, CardHeader, CardTitle } from "../components/ui/Card";
|
||||||
import { Button } from "../components/ui/Button";
|
import { Button } from "../components/ui/Button";
|
||||||
import { Input } from "../components/ui/Input";
|
import { Input } from "../components/ui/Input";
|
||||||
|
import { ScrollArea } from "../components/ui/ScrollArea";
|
||||||
import { api } from "../api/client";
|
import { api } from "../api/client";
|
||||||
import { useAppStore } from "../store/useAppStore";
|
import { useAppStore } from "../store/useAppStore";
|
||||||
import { useUiStore } from "../store/useUiStore";
|
import { useUiStore } from "../store/useUiStore";
|
||||||
@@ -360,13 +361,21 @@ export const TimerPage = () => {
|
|||||||
<SelectItem
|
<SelectItem
|
||||||
key={option.value}
|
key={option.value}
|
||||||
value={option.value}
|
value={option.value}
|
||||||
onClick={() => {
|
onPointerDown={() => {
|
||||||
if (option.value === sortKey) {
|
if (option.value === sortKey) {
|
||||||
setSortDirection((current) =>
|
setSortDirection((current) =>
|
||||||
current === "asc" ? "desc" : "asc"
|
current === "asc" ? "desc" : "asc"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (option.value !== sortKey) return;
|
||||||
|
if (event.key === "Enter" || event.key === " ") {
|
||||||
|
setSortDirection((current) =>
|
||||||
|
current === "asc" ? "desc" : "asc"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{option.label}
|
{option.label}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
@@ -435,39 +444,43 @@ export const TimerPage = () => {
|
|||||||
Silinen Torrent Logları
|
Silinen Torrent Logları
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3">
|
<CardContent className="space-y-3 overflow-hidden">
|
||||||
{timerLogs.length === 0 ? (
|
{timerLogs.length === 0 ? (
|
||||||
<div className="text-sm text-slate-500">Henüz log yok.</div>
|
<div className="text-sm text-slate-500">Henüz log yok.</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="overflow-hidden" style={{ height: 560 }}>
|
||||||
{timerLogs.map((log) => (
|
<ScrollArea className="h-full w-full" type="always">
|
||||||
<div
|
<div className="space-y-3 pr-3">
|
||||||
key={log.id}
|
{timerLogs.map((log) => (
|
||||||
className="rounded-lg border border-slate-200 bg-white px-3 py-2"
|
<div
|
||||||
>
|
key={log.id}
|
||||||
<div className="flex items-start justify-between gap-3">
|
className="rounded-lg border border-slate-200 bg-white px-3 py-2"
|
||||||
<div>
|
>
|
||||||
<div className="text-sm font-semibold text-slate-900">
|
<div className="flex items-start justify-between gap-3">
|
||||||
{log.name}
|
<div>
|
||||||
|
<div className="text-sm font-semibold text-slate-900">
|
||||||
|
{log.name}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-slate-500">
|
||||||
|
{formatBytes(log.sizeBytes)} •{" "}
|
||||||
|
{trackerLabel(log.tracker)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-slate-400">
|
||||||
|
{new Date(log.deletedAt).toLocaleString()}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-slate-500">
|
<div className="mt-2 flex flex-wrap gap-2 text-xs text-slate-600">
|
||||||
{formatBytes(log.sizeBytes)} •{" "}
|
<span>Seed: {formatDuration(log.seedingTimeSeconds)}</span>
|
||||||
{trackerLabel(log.tracker)}
|
<span>Upload: {formatBytes(log.uploadedBytes)}</span>
|
||||||
|
{log.tags?.length ? (
|
||||||
|
<span>Tags: {log.tags.join(", ")}</span>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-slate-400">
|
))}
|
||||||
{new Date(log.deletedAt).toLocaleString()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="mt-2 flex flex-wrap gap-2 text-xs text-slate-600">
|
|
||||||
<span>Seed: {formatDuration(log.seedingTimeSeconds)}</span>
|
|
||||||
<span>Upload: {formatBytes(log.uploadedBytes)}</span>
|
|
||||||
{log.tags?.length ? (
|
|
||||||
<span>Tags: {log.tags.join(", ")}</span>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
</ScrollArea>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
748
pnpm-lock.yaml
generated
748
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
5610
pnpm-lock_.yaml
Normal file
5610
pnpm-lock_.yaml
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user