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 { api } from "../../api/client";
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 {
AlertDialog,
AlertDialogAction,
@@ -31,6 +31,7 @@ export const LoopSetupCard = () => {
const selectedHash = useAppStore((s) => s.selectedHash);
const loopForm = useAppStore((s) => s.loopForm);
const setLoopForm = useAppStore((s) => s.setLoopForm);
const jobs = useAppStore((s) => s.jobs);
const pushAlert = useUiStore((s) => s.pushAlert);
const [profiles, setProfiles] = useState<Profile[]>([]);
@@ -38,7 +39,20 @@ export const LoopSetupCard = () => {
const [allowIp, setAllowIp] = useState(loopForm.allowIp || "");
const [delayMs, setDelayMs] = useState(loopForm.delayMs ?? 3000);
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 response = await api.get("/api/profiles");
@@ -71,18 +85,7 @@ export const LoopSetupCard = () => {
return;
}
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);
setProfiles((prev) => [...prev, response.data]);
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) => {
if (!selectedHash) {
pushAlert({
@@ -234,25 +224,17 @@ export const LoopSetupCard = () => {
<div>
<div className="font-semibold">{profile.name}</div>
<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 className="flex items-center gap-2">
<Button
variant="outline"
className="h-8 w-8 px-0"
onClick={() => applyProfile(profile)}
title="Apply"
onClick={() => (isRunningPreset(profile) ? stopLoop() : applyProfile(profile))}
title={isRunningPreset(profile) ? "Stop" : "Apply"}
>
<FontAwesomeIcon icon={faPlay} />
</Button>
<Button
variant="outline"
className="h-8 w-8 px-0"
onClick={() => startEdit(profile)}
title="Edit"
>
<FontAwesomeIcon icon={faPen} />
<FontAwesomeIcon icon={isRunningPreset(profile) ? faStop : faPlay} />
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>

View File

@@ -79,7 +79,7 @@ export const TorrentDetailsCard = () => {
</div>
<div className="mt-2 flex items-center gap-2">
<FontAwesomeIcon icon={faHashtag} className="text-slate-400" />
<span>Hash: {torrent.hash}</span>
<span className="min-w-0 truncate">Hash: {torrent.hash}</span>
</div>
<div className="flex items-center gap-2">
<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 { Card, CardContent, CardHeader, CardTitle } from "../ui/Card";
import { Input } from "../ui/Input";
@@ -15,21 +15,73 @@ import {
AlertDialogTrigger,
} from "../ui/AlertDialog";
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) => {
if (bytesPerSec <= 0) {
return "00.0 KB/s";
}
const kb = bytesPerSec / 1024;
if (kb >= 1024) {
return `${(kb / 1024).toFixed(1)} MB/s`;
}
return `${Math.round(kb)} kB/s`;
return `${kb.toFixed(1)} KB/s`;
};
export const TorrentTable = () => {
const torrents = useAppStore((s) => s.torrents);
const selected = useAppStore((s) => s.selectedHash);
const jobs = useAppStore((s) => s.jobs);
const selectHash = useAppStore((s) => s.selectHash);
const pushAlert = useUiStore((s) => s.pushAlert);
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(() => {
return torrents
@@ -103,40 +155,39 @@ export const TorrentTable = () => {
{filtered.map((torrent) => (
<div
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
? "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"
}`}
>
<div
className="flex-1 cursor-pointer"
className="min-w-0 flex-1 cursor-pointer"
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}
</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 rounded-full bg-mint"
style={{ width: `${Math.round(torrent.progress * 100)}%` }}
/>
</div>
<span>{Math.round(torrent.progress * 100)}%</span>
<span>{formatSpeed(torrent.dlspeed)}</span>
<span className="uppercase text-slate-400">
{torrent.state}
<span className="w-8 text-left tabular-nums">{Math.round(torrent.progress * 100)}%</span>
<span className="w-16 text-left tabular-nums">{formatSpeed(torrent.dlspeed)}</span>
<span className="flex items-center gap-2 text-slate-500 group-[.is-selected]:text-white">
{renderState(torrent.state)}
{getProfileName(torrent.hash) && (
<span className="text-xs text-slate-500 group-[.is-selected]:text-white">{getProfileName(torrent.hash)}</span>
)}
</span>
</div>
</div>
<div className="ml-3 flex items-center gap-2">
<label
className={`cursor-pointer rounded-md p-2 transition ${
selected === torrent.hash
? "text-white/80 hover:text-white"
: "text-slate-500 hover:text-slate-900"
}`}
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"
title="Torrent yükle"
>
<input
@@ -169,11 +220,7 @@ export const TorrentTable = () => {
<AlertDialog>
<AlertDialogTrigger asChild>
<button
className={`rounded-md p-2 transition ${
selected === torrent.hash
? "text-white/80 hover:text-white"
: "text-slate-500 hover:text-slate-900"
}`}
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"
title="Sil"
onClick={(event) => event.stopPropagation()}
type="button"

View File

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

View File

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