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:
@@ -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>
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
Reference in New Issue
Block a user