feat(ui): çalışan durumu ve profil adı göstergeleri ekle

Torrent tablosunda aktif profil adı ve durum ikonları gösterilir.
Döngü kurulum kartında çalışan profil durumu görüntülenir ve
durdurma/çalıştırma butonu duruma göre değişir.
Layout oranları ve responsive davranış iyileştirilir.
This commit is contained in:
2026-01-04 02:43:17 +03:00
parent 57f666d440
commit d9ed85ad0c
5 changed files with 100 additions and 69 deletions

View File

@@ -5,7 +5,7 @@ import { Input } from "../ui/Input";
import { Button } from "../ui/Button"; import { Button } from "../ui/Button";
import { api } from "../../api/client"; import { api } from "../../api/client";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faCircleInfo, faPen, faPlay, faTrash } from "@fortawesome/free-solid-svg-icons"; import { faCircleInfo, faPlay, faStop, faTrash } from "@fortawesome/free-solid-svg-icons";
import { import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
@@ -31,6 +31,7 @@ export const LoopSetupCard = () => {
const selectedHash = useAppStore((s) => s.selectedHash); const selectedHash = useAppStore((s) => s.selectedHash);
const loopForm = useAppStore((s) => s.loopForm); const loopForm = useAppStore((s) => s.loopForm);
const setLoopForm = useAppStore((s) => s.setLoopForm); const setLoopForm = useAppStore((s) => s.setLoopForm);
const jobs = useAppStore((s) => s.jobs);
const pushAlert = useUiStore((s) => s.pushAlert); const pushAlert = useUiStore((s) => s.pushAlert);
const [profiles, setProfiles] = useState<Profile[]>([]); const [profiles, setProfiles] = useState<Profile[]>([]);
@@ -38,7 +39,20 @@ export const LoopSetupCard = () => {
const [allowIp, setAllowIp] = useState(loopForm.allowIp || ""); const [allowIp, setAllowIp] = useState(loopForm.allowIp || "");
const [delayMs, setDelayMs] = useState(loopForm.delayMs ?? 3000); const [delayMs, setDelayMs] = useState(loopForm.delayMs ?? 3000);
const [targetLoops, setTargetLoops] = useState(loopForm.targetLoops ?? 3); const [targetLoops, setTargetLoops] = useState(loopForm.targetLoops ?? 3);
const [editingId, setEditingId] = useState<string | null>(null); const isRunning = Boolean(selectedHash && jobs.some((job) => job.torrentHash === selectedHash && job.status === "RUNNING"));
const isRunningPreset = (profile: Profile) => {
if (!selectedHash) return false;
const job = jobs.find((j) => j.torrentHash === selectedHash && j.status === "RUNNING");
if (!job) return false;
return (
job.allowIp === profile.allowIp &&
job.delayMs === profile.delayMs &&
job.targetLoops === profile.targetLoops
);
};
const formatDelay = (ms: number) => `${Math.round(ms / 60000)} dk`;
const loadProfiles = async () => { const loadProfiles = async () => {
const response = await api.get("/api/profiles"); const response = await api.get("/api/profiles");
@@ -71,18 +85,7 @@ export const LoopSetupCard = () => {
return; return;
} }
try { try {
if (editingId) { {
const response = await api.put(`/api/profiles/${editingId}`, payload);
setProfiles((prev) =>
prev.map((profile) => (profile.id === editingId ? response.data : profile))
);
pushAlert({
title: "Setup güncellendi",
description: "Kaydedilen setup güncellendi.",
variant: "success",
});
setEditingId(null);
} else {
const response = await api.post("/api/profiles", payload); const response = await api.post("/api/profiles", payload);
setProfiles((prev) => [...prev, response.data]); setProfiles((prev) => [...prev, response.data]);
pushAlert({ pushAlert({
@@ -106,19 +109,6 @@ export const LoopSetupCard = () => {
} }
}; };
const startEdit = (profile: Profile) => {
setEditingId(profile.id);
setName(profile.name);
setAllowIp(profile.allowIp);
setDelayMs(profile.delayMs);
setTargetLoops(profile.targetLoops);
setLoopForm({
allowIp: profile.allowIp,
delayMs: profile.delayMs,
targetLoops: profile.targetLoops,
});
};
const applyProfile = async (profile: Profile) => { const applyProfile = async (profile: Profile) => {
if (!selectedHash) { if (!selectedHash) {
pushAlert({ pushAlert({
@@ -234,25 +224,17 @@ export const LoopSetupCard = () => {
<div> <div>
<div className="font-semibold">{profile.name}</div> <div className="font-semibold">{profile.name}</div>
<div className="text-xs text-slate-500"> <div className="text-xs text-slate-500">
{profile.allowIp} {profile.targetLoops} loops {profile.delayMs} ms {profile.allowIp} {profile.targetLoops} loops {formatDelay(profile.delayMs)}
</div> </div>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button <Button
variant="outline" variant="outline"
className="h-8 w-8 px-0" className="h-8 w-8 px-0"
onClick={() => applyProfile(profile)} onClick={() => (isRunningPreset(profile) ? stopLoop() : applyProfile(profile))}
title="Apply" title={isRunningPreset(profile) ? "Stop" : "Apply"}
> >
<FontAwesomeIcon icon={faPlay} /> <FontAwesomeIcon icon={isRunningPreset(profile) ? faStop : faPlay} />
</Button>
<Button
variant="outline"
className="h-8 w-8 px-0"
onClick={() => startEdit(profile)}
title="Edit"
>
<FontAwesomeIcon icon={faPen} />
</Button> </Button>
<AlertDialog> <AlertDialog>
<AlertDialogTrigger asChild> <AlertDialogTrigger asChild>

View File

@@ -79,7 +79,7 @@ export const TorrentDetailsCard = () => {
</div> </div>
<div className="mt-2 flex items-center gap-2"> <div className="mt-2 flex items-center gap-2">
<FontAwesomeIcon icon={faHashtag} className="text-slate-400" /> <FontAwesomeIcon icon={faHashtag} className="text-slate-400" />
<span>Hash: {torrent.hash}</span> <span className="min-w-0 truncate">Hash: {torrent.hash}</span>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<FontAwesomeIcon icon={faDatabase} className="text-slate-400" /> <FontAwesomeIcon icon={faDatabase} className="text-slate-400" />

View File

@@ -1,4 +1,4 @@
import React, { useMemo, useState } from "react"; import React, { useEffect, useMemo, useState } from "react";
import { useAppStore } from "../../store/useAppStore"; import { useAppStore } from "../../store/useAppStore";
import { Card, CardContent, CardHeader, CardTitle } from "../ui/Card"; import { Card, CardContent, CardHeader, CardTitle } from "../ui/Card";
import { Input } from "../ui/Input"; import { Input } from "../ui/Input";
@@ -15,21 +15,73 @@ import {
AlertDialogTrigger, AlertDialogTrigger,
} from "../ui/AlertDialog"; } from "../ui/AlertDialog";
import { useUiStore } from "../../store/useUiStore"; import { useUiStore } from "../../store/useUiStore";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faBolt, faCloudArrowDown, faCloudArrowUp, faHourglassHalf } from "@fortawesome/free-solid-svg-icons";
interface Profile {
id: string;
name: string;
allowIp: string;
delayMs: number;
targetLoops: number;
}
const formatSpeed = (bytesPerSec: number) => { const formatSpeed = (bytesPerSec: number) => {
if (bytesPerSec <= 0) {
return "00.0 KB/s";
}
const kb = bytesPerSec / 1024; const kb = bytesPerSec / 1024;
if (kb >= 1024) { if (kb >= 1024) {
return `${(kb / 1024).toFixed(1)} MB/s`; return `${(kb / 1024).toFixed(1)} MB/s`;
} }
return `${Math.round(kb)} kB/s`; return `${kb.toFixed(1)} KB/s`;
}; };
export const TorrentTable = () => { export const TorrentTable = () => {
const torrents = useAppStore((s) => s.torrents); const torrents = useAppStore((s) => s.torrents);
const selected = useAppStore((s) => s.selectedHash); const selected = useAppStore((s) => s.selectedHash);
const jobs = useAppStore((s) => s.jobs);
const selectHash = useAppStore((s) => s.selectHash); const selectHash = useAppStore((s) => s.selectHash);
const pushAlert = useUiStore((s) => s.pushAlert); const pushAlert = useUiStore((s) => s.pushAlert);
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [profiles, setProfiles] = useState<Profile[]>([]);
const loadProfiles = async () => {
const response = await api.get("/api/profiles");
setProfiles(response.data);
};
useEffect(() => {
loadProfiles();
}, []);
const renderState = (state: string) => {
const value = state.toLowerCase();
if (value.includes("forced")) {
return <FontAwesomeIcon icon={faBolt} title="Forced" />;
}
if (value.includes("stalled")) {
return <FontAwesomeIcon icon={faHourglassHalf} title="Stalled" />;
}
if (value.includes("downloading") || value.includes("metadl")) {
return <FontAwesomeIcon icon={faCloudArrowDown} title="Downloading" />;
}
if (value.includes("uploading") || value.includes("up")) {
return <FontAwesomeIcon icon={faCloudArrowUp} title="Uploading" />;
}
return <span className="uppercase text-slate-400">{state}</span>;
};
const getProfileName = (hash: string) => {
const job = jobs.find((j) => j.torrentHash === hash);
if (!job) return null;
const profile = profiles.find((p) =>
p.allowIp === job.allowIp &&
p.delayMs === job.delayMs &&
p.targetLoops === job.targetLoops
);
return profile?.name ?? null;
};
const filtered = useMemo(() => { const filtered = useMemo(() => {
return torrents return torrents
@@ -103,40 +155,39 @@ export const TorrentTable = () => {
{filtered.map((torrent) => ( {filtered.map((torrent) => (
<div <div
key={torrent.hash} key={torrent.hash}
className={`flex items-start justify-between rounded-lg border px-3 py-2 text-left text-sm transition ${ className={`group flex min-w-0 items-start justify-between rounded-lg border px-3 py-2 text-left text-sm transition ${
selected === torrent.hash selected === torrent.hash
? "border-ink bg-slate-900 text-white" ? "is-selected border-ink bg-slate-900 text-white"
: "border-slate-200 bg-white hover:border-slate-300" : "border-slate-200 bg-white hover:border-slate-300"
}`} }`}
> >
<div <div
className="flex-1 cursor-pointer" className="min-w-0 flex-1 cursor-pointer"
onClick={() => selectHash(torrent.hash)} onClick={() => selectHash(torrent.hash)}
> >
<div className="truncate font-semibold" title={torrent.name}> <div className="min-w-0 truncate font-semibold" title={torrent.name}>
{torrent.name} {torrent.name}
</div> </div>
<div className="mt-2 flex items-center gap-3 text-xs"> <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 rounded-full bg-slate-200">
<div <div
className="h-2 rounded-full bg-mint" className="h-2 rounded-full bg-mint"
style={{ width: `${Math.round(torrent.progress * 100)}%` }} style={{ width: `${Math.round(torrent.progress * 100)}%` }}
/> />
</div> </div>
<span>{Math.round(torrent.progress * 100)}%</span> <span className="w-8 text-left tabular-nums">{Math.round(torrent.progress * 100)}%</span>
<span>{formatSpeed(torrent.dlspeed)}</span> <span className="w-16 text-left tabular-nums">{formatSpeed(torrent.dlspeed)}</span>
<span className="uppercase text-slate-400"> <span className="flex items-center gap-2 text-slate-500 group-[.is-selected]:text-white">
{torrent.state} {renderState(torrent.state)}
{getProfileName(torrent.hash) && (
<span className="text-xs text-slate-500 group-[.is-selected]:text-white">{getProfileName(torrent.hash)}</span>
)}
</span> </span>
</div> </div>
</div> </div>
<div className="ml-3 flex items-center gap-2"> <div className="ml-3 flex items-center gap-2">
<label <label
className={`cursor-pointer rounded-md p-2 transition ${ className="cursor-pointer rounded-md p-2 text-slate-500 transition hover:bg-slate-100 hover:text-slate-900 group-[.is-selected]:text-white group-[.is-selected]:hover:bg-white/10"
selected === torrent.hash
? "text-white/80 hover:text-white"
: "text-slate-500 hover:text-slate-900"
}`}
title="Torrent yükle" title="Torrent yükle"
> >
<input <input
@@ -169,11 +220,7 @@ export const TorrentTable = () => {
<AlertDialog> <AlertDialog>
<AlertDialogTrigger asChild> <AlertDialogTrigger asChild>
<button <button
className={`rounded-md p-2 transition ${ className="rounded-md p-2 text-slate-500 transition hover:bg-slate-100 hover:text-slate-900 group-[.is-selected]:text-white group-[.is-selected]:hover:bg-white/10"
selected === torrent.hash
? "text-white/80 hover:text-white"
: "text-slate-500 hover:text-slate-900"
}`}
title="Sil" title="Sil"
onClick={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()}
type="button" type="button"

View File

@@ -7,16 +7,18 @@ import { LogsPanel } from "../components/loop/LogsPanel";
export const DashboardPage = () => { export const DashboardPage = () => {
return ( return (
<> <>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[1.2fr_1fr]"> <div className="grid w-full grid-cols-1 gap-6 lg:grid-cols-[1.3fr_0.7fr]">
<TorrentTable /> <div className="min-w-0">
<div className="space-y-4"> <TorrentTable />
</div>
<div className="min-w-0 space-y-4">
<TorrentDetailsCard /> <TorrentDetailsCard />
<LoopStatsCard /> <LoopStatsCard />
<LoopSetupCard /> <LoopSetupCard />
</div> </div>
</div> </div>
<div className="grid grid-cols-1 gap-6"> <div className="grid w-full grid-cols-1 gap-6">
<LogsPanel /> <LogsPanel />
</div> </div>
</> </>

View File

@@ -270,8 +270,8 @@ export const TimerPage = () => {
}; };
return ( return (
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[1.25fr_0.9fr]"> <div className="grid w-full grid-cols-1 gap-6 lg:grid-cols-[1.3fr_0.7fr]">
<div className="space-y-6"> <div className="min-w-0 space-y-6">
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
@@ -310,7 +310,7 @@ export const TimerPage = () => {
<div>Kural: {formatDuration(rule.seedLimitSeconds)}</div> <div>Kural: {formatDuration(rule.seedLimitSeconds)}</div>
</div> </div>
</div> </div>
<div className="mt-2 grid grid-cols-2 gap-2 text-xs text-slate-600"> <div className="mt-2 flex items-center gap-1 text-xs text-slate-600">
<div className="truncate"> <div className="truncate">
Hash: {torrent.hash.slice(0, 12)}... Hash: {torrent.hash.slice(0, 12)}...
</div> </div>
@@ -375,7 +375,7 @@ export const TimerPage = () => {
</Card> </Card>
</div> </div>
<div className="space-y-6"> <div className="min-w-0 space-y-6">
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">