Files
q-buffer/apps/web/src/pages/TimerPage.tsx
wisecolt 967eb2d2a4 fix(server): hata yönetimini ve dayanıklılığı iyileştir
Loop scheduler ve timer worker için hata yakalama ekle. qBit client'ta
geçici ağ hatalarını tanıyarak login durumunu sıfırla. Scheduler
hatalarında durum güncellemesi gönder ve timer worker crash önle.
2026-01-31 11:25:51 +03:00

663 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useEffect, useMemo, useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "../components/ui/Card";
import { Button } from "../components/ui/Button";
import { Input } from "../components/ui/Input";
import { ScrollArea } from "../components/ui/ScrollArea";
import { api } from "../api/client";
import { useAppStore } from "../store/useAppStore";
import { useUiStore } from "../store/useUiStore";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "../components/ui/AlertDialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "../components/ui/Select";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faClockRotateLeft,
faClock,
faTags,
faTrash,
faChartBar,
faPlus,
faHourglassHalf,
} from "@fortawesome/free-solid-svg-icons";
const unitOptions = [
{ label: "Saat", value: "hours", seconds: 3600 },
{ label: "Gün", value: "days", seconds: 86400 },
{ label: "Hafta", value: "weeks", seconds: 604800 },
] as const;
const sortOptions = [
{ label: "İsim", value: "name" },
{ label: "Boyut", value: "size" },
{ label: "Geri Sayım", value: "countdown" },
{ label: "Tracker", value: "tracker" },
{ label: "Eklenme Tarihi", value: "addedOn" },
] as const;
const formatBytes = (value: number) => {
if (!Number.isFinite(value)) return "0 B";
const units = ["B", "KB", "MB", "GB", "TB"];
let size = value;
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex += 1;
}
return `${size.toFixed(size >= 10 ? 0 : 1)} ${units[unitIndex]}`;
};
const formatDuration = (seconds: number) => {
if (!Number.isFinite(seconds)) return "0 sn";
if (seconds >= 86400) {
return `${(seconds / 86400).toFixed(1)} gün`;
}
if (seconds >= 3600) {
return `${(seconds / 3600).toFixed(1)} saat`;
}
return `${Math.max(1, Math.round(seconds / 60))} dk`;
};
const formatCountdown = (seconds: number) => {
if (!Number.isFinite(seconds)) return "—";
const clamped = Math.max(0, Math.floor(seconds));
const days = Math.floor(clamped / 86400);
const hours = Math.floor((clamped % 86400) / 3600);
const minutes = Math.floor((clamped % 3600) / 60);
const secs = clamped % 60;
const pad = (value: number) => value.toString().padStart(2, "0");
return `${days}g ${pad(hours)}:${pad(minutes)}:${pad(secs)}`;
};
const formatAddedOn = (addedOn?: number) => {
if (!Number.isFinite(addedOn)) return "Bilinmiyor";
return new Date(addedOn * 1000).toLocaleString();
};
const trackerLabel = (tracker?: string) => {
if (!tracker) return "Bilinmiyor";
try {
const host = new URL(tracker).hostname;
return host.replace(/^www\./, "");
} catch {
return tracker;
}
};
export const TimerPage = () => {
const torrents = useAppStore((s) => s.torrents);
const timerRules = useAppStore((s) => s.timerRules);
const timerLogs = useAppStore((s) => s.timerLogs);
const timerSummary = useAppStore((s) => s.timerSummary);
const setTimerRules = useAppStore((s) => s.setTimerRules);
const setTimerLogs = useAppStore((s) => s.setTimerLogs);
const setTimerSummary = useAppStore((s) => s.setTimerSummary);
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [seedValue, setSeedValue] = useState(2);
const [seedUnit, setSeedUnit] = useState<(typeof unitOptions)[number]["value"]>(
"weeks"
);
const [sortKey, setSortKey] =
useState<(typeof sortOptions)[number]["value"]>("countdown");
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc");
const [deleteFiles, setDeleteFiles] = useState(true);
const [busy, setBusy] = useState(false);
const pushAlert = useUiStore((s) => s.pushAlert);
const [nowTick, setNowTick] = useState(() => Date.now());
const tagOptions = useMemo(() => {
const tags = new Set<string>();
torrents.forEach((torrent) => {
const tagList = torrent.tags
? torrent.tags.split(",").map((tag) => tag.trim())
: [];
tagList.filter(Boolean).forEach((tag) => tags.add(tag));
if (torrent.category) {
tags.add(torrent.category);
}
});
return Array.from(tags.values()).sort((a, b) => a.localeCompare(b));
}, [torrents]);
const rulesForDisplay = useMemo(() => {
return [...timerRules].sort((a, b) => b.createdAt.localeCompare(a.createdAt));
}, [timerRules]);
const matchingTorrents = useMemo(() => {
if (timerRules.length === 0) {
return [];
}
return torrents
.map((torrent) => {
const tags = (torrent.tags ?? "")
.split(",")
.map((tag) => tag.trim().toLowerCase())
.filter(Boolean);
if (torrent.category) {
tags.push(torrent.category.toLowerCase());
}
const addedOnMs = Number(torrent.added_on ?? 0) * 1000;
const matchingRules = timerRules.filter((rule) => {
return rule.tags.some((tag) => tags.includes(tag.toLowerCase()));
});
if (matchingRules.length === 0) {
return null;
}
const rule = matchingRules.reduce((best, current) =>
current.seedLimitSeconds < best.seedLimitSeconds ? current : best
);
const baseMs = addedOnMs || nowTick;
const elapsedSeconds = Math.max(0, (nowTick - baseMs) / 1000);
const remainingSeconds = rule.seedLimitSeconds - elapsedSeconds;
return {
torrent,
rule,
remainingSeconds,
};
})
.filter(Boolean) as Array<{
torrent: typeof torrents[number];
rule: typeof timerRules[number];
remainingSeconds: number;
}>;
}, [timerRules, torrents, nowTick]);
const sortedMatchingTorrents = useMemo(() => {
const direction = sortDirection === "asc" ? 1 : -1;
const withFallback = (value: number | string | undefined, fallback: number | string) =>
value === undefined || value === null || value === "" ? fallback : value;
return [...matchingTorrents].sort((a, b) => {
switch (sortKey) {
case "name":
return (
String(withFallback(a.torrent.name, ""))
.localeCompare(String(withFallback(b.torrent.name, "")), "tr") *
direction
);
case "size":
return (
(Number(withFallback(a.torrent.size, 0)) -
Number(withFallback(b.torrent.size, 0))) *
direction
);
case "tracker":
return (
trackerLabel(a.torrent.tracker)
.localeCompare(trackerLabel(b.torrent.tracker), "tr") * direction
);
case "addedOn":
return (
(Number(withFallback(a.torrent.added_on, 0)) -
Number(withFallback(b.torrent.added_on, 0))) *
direction
);
case "countdown":
default:
return (
(Number(withFallback(a.remainingSeconds, 0)) -
Number(withFallback(b.remainingSeconds, 0))) *
direction
);
}
});
}, [matchingTorrents, sortDirection, sortKey]);
useEffect(() => {
let active = true;
const load = async () => {
try {
const [rulesRes, logsRes, summaryRes] = await Promise.all([
api.get("/api/timer/rules"),
api.get("/api/timer/logs"),
api.get("/api/timer/summary"),
]);
if (!active) return;
setTimerRules(rulesRes.data ?? []);
setTimerLogs((logsRes.data ?? []).reverse());
if (summaryRes.data) {
setTimerSummary(summaryRes.data);
}
} catch (err) {
if (active) {
pushAlert({
title: "Timer verileri alınamadı",
description: "Bağlantıyı kontrol edip tekrar deneyin.",
variant: "error",
});
}
}
};
load();
return () => {
active = false;
};
}, [setTimerLogs, setTimerRules, setTimerSummary]);
useEffect(() => {
const interval = setInterval(() => setNowTick(Date.now()), 1000);
return () => clearInterval(interval);
}, []);
const toggleTag = (tag: string) => {
setSelectedTags((current) =>
current.includes(tag)
? current.filter((value) => value !== tag)
: [...current, tag]
);
};
const handleSaveRule = async () => {
const unit = unitOptions.find((item) => item.value === seedUnit);
if (!unit) return;
if (selectedTags.length === 0) {
pushAlert({
title: "Etiket seçilmedi",
description: "En az bir etiket seçmelisiniz.",
variant: "warn",
});
return;
}
if (!Number.isFinite(seedValue) || seedValue <= 0) {
pushAlert({
title: "Seed süresi geçersiz",
description: "Seed süresi 0dan büyük olmalı.",
variant: "warn",
});
return;
}
const seedLimitSeconds = Math.round(seedValue * unit.seconds);
setBusy(true);
try {
const response = await api.post("/api/timer/rules", {
tags: selectedTags,
seedLimitSeconds,
deleteFiles,
});
setTimerRules([response.data, ...timerRules]);
setSelectedTags([]);
setDeleteFiles(true);
pushAlert({
title: "Kural kaydedildi",
description: "Timer kuralı aktif edildi.",
variant: "success",
});
} catch (err) {
pushAlert({
title: "Kural kaydedilemedi",
description: "Lütfen daha sonra tekrar deneyin.",
variant: "error",
});
} finally {
setBusy(false);
}
};
const handleDeleteRule = async (ruleId: string) => {
try {
await api.delete(`/api/timer/rules/${ruleId}`);
setTimerRules(timerRules.filter((rule) => rule.id !== ruleId));
pushAlert({
title: "Kural silindi",
description: "Timer kuralı kaldırıldı.",
variant: "success",
});
} catch (err) {
pushAlert({
title: "Kural silinemedi",
description: "Lütfen daha sonra tekrar deneyin.",
variant: "error",
});
}
};
const summary = timerSummary ?? {
totalDeleted: 0,
totalSeededSeconds: 0,
totalUploadedBytes: 0,
updatedAt: "",
};
return (
<div className="grid w-full grid-cols-1 gap-6 lg:grid-cols-[1.3fr_0.7fr]">
<div className="min-w-0 space-y-6">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FontAwesomeIcon icon={faHourglassHalf} className="text-slate-400" />
Zamanlayıcı Torrentleri
</CardTitle>
<div className="flex items-center gap-2 text-xs font-semibold text-slate-500">
<span>Sıralama</span>
<Select
value={sortKey}
onValueChange={(value) => {
if (value !== sortKey) {
setSortKey(value as typeof sortKey);
setSortDirection("asc");
}
}}
>
<SelectTrigger className="h-9 w-[180px] flex-shrink-0 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{sortOptions.map((option) => (
<SelectItem
key={option.value}
value={option.value}
onPointerDown={() => {
if (option.value === sortKey) {
setSortDirection((current) =>
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}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</CardHeader>
<CardContent className="space-y-4">
{sortedMatchingTorrents.length === 0 ? (
<div className="text-sm text-slate-500">
Bu kurallara bağlı aktif torrent bulunamadı.
</div>
) : (
<div className="space-y-3">
{sortedMatchingTorrents.map(({ torrent, rule, remainingSeconds }) => (
<div
key={torrent.hash}
className="rounded-lg border border-slate-200 bg-white px-3 py-2"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div
className="truncate text-sm font-semibold text-slate-900"
title={torrent.name}
>
{torrent.name}
</div>
<div className="truncate text-xs text-slate-500">
{formatBytes(torrent.size)} {trackerLabel(torrent.tracker)}
</div>
</div>
<div className="w-24 flex-shrink-0 text-right text-xs text-slate-600">
<div className="font-semibold text-slate-900">
{formatCountdown(remainingSeconds)}
</div>
<div>Kural: {formatDuration(rule.seedLimitSeconds)}</div>
</div>
</div>
<div className="mt-2 flex items-center gap-1 text-xs text-slate-600">
<div className="truncate">
Hash: {torrent.hash.slice(0, 12)}...
</div>
<div className="truncate">
Etiket:{" "}
{(torrent.tags || torrent.category || "-")
.split(",")
.map((tag) => tag.trim())
.filter(Boolean)
.join(", ") || "-"}
</div>
<div className="truncate">
Added: {formatAddedOn(torrent.added_on)}
</div>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FontAwesomeIcon icon={faTrash} className="text-slate-400" />
Silinen Torrent Logları
</CardTitle>
</CardHeader>
<CardContent className="space-y-3 overflow-hidden">
{timerLogs.length === 0 ? (
<div className="text-sm text-slate-500">Henüz log yok.</div>
) : (
<div className="overflow-hidden" style={{ height: 560 }}>
<ScrollArea className="h-full w-full" type="always">
<div className="space-y-3 pr-3">
{timerLogs.map((log) => (
<div
key={log.id}
className="rounded-lg border border-slate-200 bg-white px-3 py-2"
>
<div className="flex items-start justify-between gap-3">
<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 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>
)}
</CardContent>
</Card>
</div>
<div className="min-w-0 space-y-6">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FontAwesomeIcon icon={faPlus} className="text-slate-400" />
Timer Kuralı Oluştur
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div>
<div className="flex items-center gap-2 text-xs font-semibold text-slate-500">
<FontAwesomeIcon icon={faTags} className="text-slate-400" />
Etiketler
</div>
<div className="mt-2 flex flex-wrap gap-2">
{tagOptions.length === 0 ? (
<div className="text-sm text-slate-500">
Henüz etiket bulunamadı.
</div>
) : (
tagOptions.map((tag) => (
<button
key={tag}
type="button"
onClick={() => toggleTag(tag)}
className={`rounded-full border px-3 py-1 text-xs font-semibold ${
selectedTags.includes(tag)
? "border-slate-900 bg-slate-900 text-white"
: "border-slate-200 bg-white text-slate-600 hover:border-slate-300"
}`}
>
{tag}
</button>
))
)}
</div>
</div>
<div>
<div className="flex items-center gap-2 text-xs font-semibold text-slate-500">
<FontAwesomeIcon icon={faClock} className="text-slate-400" />
Seed Süresi
</div>
<div className="mt-2 flex gap-2">
<Input
type="number"
min={1}
value={seedValue}
onChange={(event) => setSeedValue(Number(event.target.value))}
/>
<select
value={seedUnit}
onChange={(event) =>
setSeedUnit(event.target.value as typeof seedUnit)
}
className="h-10 rounded-md border border-slate-200 bg-white px-3 text-sm text-slate-700"
>
{unitOptions.map((unit) => (
<option key={unit.value} value={unit.value}>
{unit.label}
</option>
))}
</select>
</div>
</div>
<label className="flex items-start gap-2 text-sm text-slate-700">
<input
type="checkbox"
checked={deleteFiles}
onChange={(event) => setDeleteFiles(event.target.checked)}
className="mt-1 h-4 w-4 rounded border-slate-300 text-slate-900"
/>
<span>
Dosyayı Disk'ten Kaldır
<span className="block text-xs text-slate-500">
İşaretli değilse torrent sadece qBittorrent'tan kaldırılır, dosya disk üzerinde kalır.
</span>
</span>
</label>
<Button onClick={handleSaveRule} disabled={busy}>
{busy ? "Kaydediliyor..." : "Kuralı Kaydet"}
</Button>
<div className="border-t border-slate-200 pt-3">
<div className="mb-2 flex items-center gap-2 text-xs font-semibold text-slate-500">
<FontAwesomeIcon icon={faClockRotateLeft} className="text-slate-400" />
Eklenen Kurallar
</div>
{rulesForDisplay.length === 0 ? (
<div className="text-sm text-slate-500">
Henüz kural yok.
</div>
) : (
<div className="space-y-2">
{rulesForDisplay.map((rule) => (
<div
key={rule.id}
className="flex items-center justify-between rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-sm"
>
<div>
<div className="font-semibold text-slate-900">
{rule.tags.join(", ")}
</div>
<div className="text-xs text-slate-500">
Seed limiti: {formatDuration(rule.seedLimitSeconds)}
{" • "}
{rule.deleteFiles ?? true
? "Silme: Disk + qBittorrent"
: "Silme: Sadece qBittorrent"}
</div>
</div>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="ghost"
className="text-rose-600 hover:text-rose-700"
>
<FontAwesomeIcon icon={faTrash} className="mr-2" />
Sil
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Kural silinsin mi?</AlertDialogTitle>
<AlertDialogDescription>
Bu kural kaldırılınca zamanlayıcı artık bu etiketleri izlemeyecek.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>İptal</AlertDialogCancel>
<AlertDialogAction onClick={() => handleDeleteRule(rule.id)}>
Sil
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
))}
</div>
)}
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FontAwesomeIcon icon={faChartBar} className="text-slate-400" />
Timer Özeti
</CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-sm text-slate-700">
<div className="flex items-center justify-between">
<span>Silinen dosya sayısı</span>
<span className="font-semibold text-slate-900">
{summary.totalDeleted}
</span>
</div>
<div className="flex items-center justify-between">
<span>Toplam seed süresi</span>
<span className="font-semibold text-slate-900">
{formatDuration(summary.totalSeededSeconds)}
</span>
</div>
<div className="flex items-center justify-between">
<span>Toplam upload</span>
<span className="font-semibold text-slate-900">
{formatBytes(summary.totalUploadedBytes)}
</span>
</div>
</CardContent>
</Card>
</div>
</div>
);
};