feat: add typed qBittorrent scheduled polling

This commit is contained in:
Developer
2026-07-14 15:22:34 +00:00
parent eac9b5d33d
commit a9488af0b4
22 changed files with 2036 additions and 56 deletions
+41
View File
@@ -0,0 +1,41 @@
import { get, post } from "./shared";
import type {
SchedulerManualRunResponse,
SchedulerRunsResponse,
SchedulerSamplesResponse,
SchedulerStatus,
} from "../types";
export function fetchSchedulerStatus(
serviceId: string,
): Promise<SchedulerStatus> {
return get<SchedulerStatus>(`/api/scheduler/services/${serviceId}/status`);
}
export function fetchSchedulerRuns(
serviceId: string,
limit = 20,
): Promise<SchedulerRunsResponse> {
return get<SchedulerRunsResponse>(
`/api/scheduler/services/${serviceId}/runs`,
{ limit: String(limit) },
);
}
export function fetchSchedulerSamples(
serviceId: string,
windowSeconds: number,
): Promise<SchedulerSamplesResponse> {
return get<SchedulerSamplesResponse>(
`/api/scheduler/services/${serviceId}/samples`,
{ window_seconds: String(windowSeconds) },
);
}
export function runSchedulerAction(
serviceId: string,
): Promise<SchedulerManualRunResponse> {
return post<SchedulerManualRunResponse>(
`/api/scheduler/services/${serviceId}/run`,
);
}
+52
View File
@@ -0,0 +1,52 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
fetchSchedulerRuns,
fetchSchedulerSamples,
fetchSchedulerStatus,
runSchedulerAction,
} from "../api/scheduler";
export function useSchedulerStatus(serviceId: string) {
return useQuery({
queryKey: ["scheduler", "status", serviceId],
queryFn: () => fetchSchedulerStatus(serviceId),
enabled: Boolean(serviceId),
refetchInterval: 15_000,
});
}
export function useSchedulerRuns(serviceId: string) {
return useQuery({
queryKey: ["scheduler", "runs", serviceId],
queryFn: () => fetchSchedulerRuns(serviceId),
enabled: Boolean(serviceId),
refetchInterval: 15_000,
});
}
export function useSchedulerSamples(serviceId: string, windowSeconds: number) {
return useQuery({
queryKey: ["scheduler", "samples", serviceId, windowSeconds],
queryFn: () => fetchSchedulerSamples(serviceId, windowSeconds),
enabled: Boolean(serviceId),
refetchInterval: 15_000,
});
}
export function useRunSchedulerAction() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: runSchedulerAction,
onSuccess: (_result, serviceId) => {
queryClient.invalidateQueries({
queryKey: ["scheduler", "status", serviceId],
});
queryClient.invalidateQueries({
queryKey: ["scheduler", "runs", serviceId],
});
queryClient.invalidateQueries({
queryKey: ["scheduler", "samples", serviceId],
});
},
});
}
@@ -0,0 +1,211 @@
import { Activity, Clock, Play, RefreshCw, TriangleAlert } from "lucide-react";
import { useState } from "react";
import { LineSeriesChart } from "../../components/LineSeriesChart";
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 {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
const WINDOWS = [
{ value: 900, label: "15 minutes" },
{ value: 1800, label: "30 minutes" },
{ value: 3600, label: "1 hour" },
{ value: 21600, label: "6 hours" },
{ value: 86400, label: "24 hours" },
];
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 [windowSeconds, setWindowSeconds] = useState(1800);
const status = useSchedulerStatus(instance.id);
const samples = useSchedulerSamples(instance.id, windowSeconds);
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 className="flex flex-row items-center justify-between space-y-0">
<CardTitle className="flex items-center gap-2">
<Activity className="h-4 w-4" />
Speed history
</CardTitle>
<Select
value={String(windowSeconds)}
onValueChange={(value) => setWindowSeconds(Number(value))}
>
<SelectTrigger className="w-[150px]" size="sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{WINDOWS.map((window) => (
<SelectItem key={window.value} value={String(window.value)}>
{window.label}
</SelectItem>
))}
</SelectContent>
</Select>
</CardHeader>
<CardContent>
{samples.isLoading ? (
<Skeleton className="h-[300px] w-full" />
) : (
<LineSeriesChart series={chartSeries} unit="bytes" height={300} />
)}
</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>
);
}
+3
View File
@@ -16,6 +16,7 @@ import { ActionsTab } from "./ActionsTab";
import { JobsTab } from "./JobsTab";
import { UsersTab } from "./UsersTab";
import { MessagingTab } from "./MessagingTab";
import { QbittorrentTab } from "./QbittorrentTab";
export type ServiceTabComponent = ComponentType<{ instance: ServiceInstance }>;
@@ -57,6 +58,8 @@ export function serviceContentTabs(serviceType: string): ContentTab[] {
return [{ label: "Alerts", Component: AlertsTab }];
case "prometheus":
return [{ label: "Metrics", Component: MetricsTab }];
case "qbittorrent":
return [{ label: "Speed", Component: QbittorrentTab }];
default:
return [];
}
+54
View File
@@ -464,6 +464,60 @@ export interface ServiceInstance {
updated_at: number;
}
export interface SchedulerStatus {
service_id: string;
action_key: string;
worker_running: boolean;
enabled: boolean;
running: boolean;
poll_interval_seconds: number;
sample_retention_seconds: number;
sample_max_rows: number;
next_run_at: number | null;
last_attempt_at: number | null;
last_success_at: number | null;
last_error: string;
consecutive_failures: number;
backoff_until: number | null;
is_stale: boolean;
}
export interface SchedulerRun {
id: string;
service_id: string;
action_key: string;
trigger: "schedule" | "manual";
started_at: number;
finished_at: number | null;
status: "running" | "success" | "failure" | "cancelled";
attempt: number;
duration_ms: number | null;
error: string;
created_at: number;
}
export interface SchedulerRunsResponse {
items: SchedulerRun[];
total: number;
limit: number;
offset: number;
}
export interface SchedulerSamplesResponse {
service_id: string;
window_seconds: number;
samples: Array<{
ts: number;
dl_speed: number;
up_speed: number;
}>;
}
export interface SchedulerManualRunResponse {
run: SchedulerRun;
status: SchedulerStatus;
}
export interface ServiceInstanceInput {
id?: string | null;
service_type: string;