193 lines
5.5 KiB
TypeScript
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>
|
|
);
|
|
}
|