feat: add typed qBittorrent scheduled polling
This commit is contained in:
@@ -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`,
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 [];
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user