Files
manage/frontend/src/pages/service-tabs/QbittorrentTab.tsx
T
2026-07-15 15:21:57 +00:00

193 lines
5.5 KiB
TypeScript

import { Activity, Clock, Play, RefreshCw, TriangleAlert } from "lucide-react";
import { useState } from "react";
import { LineSeriesChart } from "../../components/LineSeriesChart";
import {
chartRangesThrough,
type ChartRangeValue,
} from "../../components/chartRanges";
import {
useRunSchedulerAction,
useSchedulerRuns,
useSchedulerSamples,
useSchedulerStatus,
} from "../../hooks/useScheduler";
import type { ServiceInstance } from "../../types";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
function formatTimestamp(value: number | null): string {
return value ? new Date(value * 1000).toLocaleString() : "Never";
}
function statusVariant(
status: string,
): "default" | "secondary" | "destructive" | "outline" {
if (status === "success") return "default";
if (status === "failure") return "destructive";
if (status === "running") return "secondary";
return "outline";
}
export function QbittorrentTab({ instance }: { instance: ServiceInstance }) {
const [selectedRange, setSelectedRange] = useState<ChartRangeValue>(1800);
const status = useSchedulerStatus(instance.id);
const samples = useSchedulerSamples(instance.id, selectedRange);
const runs = useSchedulerRuns(instance.id);
const runNow = useRunSchedulerAction();
const stale = Boolean(status.data?.enabled && status.data.is_stale);
const hasError = Boolean(status.data?.last_error || runNow.error);
const chartSeries = samples.data
? [
{
label: "download",
points: samples.data.samples.map((sample) => ({
t: sample.ts * 1000,
v: sample.dl_speed,
})),
},
{
label: "upload",
points: samples.data.samples.map((sample) => ({
t: sample.ts * 1000,
v: sample.up_speed,
})),
},
]
: [];
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Activity className="h-4 w-4" />
Headless speed polling
{status.data && (
<Badge variant={status.data.enabled ? "default" : "secondary"}>
{status.data.enabled ? "Enabled" : "Paused"}
</Badge>
)}
</div>
<Button
onClick={() => runNow.mutate(instance.id)}
disabled={runNow.isPending || status.data?.running}
size="sm"
>
{runNow.isPending ? (
<RefreshCw className="mr-2 h-4 w-4 animate-spin" />
) : (
<Play className="mr-2 h-4 w-4" />
)}
Run now
</Button>
</div>
{(stale || hasError) && (
<Alert variant={hasError ? "destructive" : "default"}>
<TriangleAlert className="h-4 w-4" />
<AlertTitle>
{hasError ? "Polling error" : "Data may be stale"}
</AlertTitle>
<AlertDescription>
{status.data?.last_error ||
runNow.error?.message ||
"No successful sample has been recorded recently."}
</AlertDescription>
</Alert>
)}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Activity className="h-4 w-4" />
Speed history
</CardTitle>
</CardHeader>
<CardContent>
{samples.isLoading ? (
<Skeleton className="h-[300px] w-full" />
) : (
<LineSeriesChart
series={chartSeries}
unit="bytes"
height={300}
rangeOptions={chartRangesThrough(86_400)}
rangeSeconds={selectedRange}
onRangeChange={setSelectedRange}
/>
)}
</CardContent>
</Card>
<div className="grid gap-4 md:grid-cols-3">
<Card>
<CardContent className="pt-6">
<div className="text-xs text-muted-foreground">Last success</div>
<div className="mt-1 font-medium">
{formatTimestamp(status.data?.last_success_at ?? null)}
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="text-xs text-muted-foreground">Next poll</div>
<div className="mt-1 font-medium">
{formatTimestamp(status.data?.next_run_at ?? null)}
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="text-xs text-muted-foreground">
Consecutive failures
</div>
<div className="mt-1 font-medium">
{status.data?.consecutive_failures ?? 0}
</div>
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Clock className="h-4 w-4" />
Recent runs
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{runs.isLoading ? (
<Skeleton className="h-12 w-full" />
) : runs.data?.items.length ? (
runs.data.items.map((run) => (
<div
key={run.id}
className="flex flex-wrap items-center justify-between gap-2 rounded-md border p-3 text-sm"
>
<div>
<Badge variant={statusVariant(run.status)}>
{run.status}
</Badge>
<span className="ml-2 text-muted-foreground">
{run.trigger} · {formatTimestamp(run.started_at)}
</span>
</div>
{run.error && (
<span className="text-destructive">{run.error}</span>
)}
</div>
))
) : (
<div className="text-sm text-muted-foreground">
No runs recorded yet.
</div>
)}
</CardContent>
</Card>
</div>
);
}