feat(charting): unify configurable time windows
This commit is contained in:
@@ -24,11 +24,13 @@ export function fetchSchedulerRuns(
|
||||
|
||||
export function fetchSchedulerSamples(
|
||||
serviceId: string,
|
||||
windowSeconds: number,
|
||||
window: number | "all",
|
||||
): Promise<SchedulerSamplesResponse> {
|
||||
return get<SchedulerSamplesResponse>(
|
||||
`/api/scheduler/services/${serviceId}/samples`,
|
||||
{ window_seconds: String(windowSeconds) },
|
||||
window === "all"
|
||||
? { all_values: "true" }
|
||||
: { window_seconds: String(window) },
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,11 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { DEFAULT_CHART_RANGES, type ChartRangeOption } from "./chartRanges";
|
||||
import {
|
||||
DEFAULT_CHART_RANGES,
|
||||
type ChartRangeOption,
|
||||
type ChartRangeValue,
|
||||
} from "./chartRanges";
|
||||
import {
|
||||
formatScaled,
|
||||
metricScaleInfo,
|
||||
@@ -72,11 +76,11 @@ interface LineSeriesChartProps {
|
||||
scale?: MetricScale;
|
||||
/** Available displayed time ranges. Defaults to the shared range choices. */
|
||||
rangeOptions?: readonly ChartRangeOption[];
|
||||
/** Initial uncontrolled range. Defaults to the largest available option. */
|
||||
defaultRangeSeconds?: number;
|
||||
/** Initial uncontrolled range. Defaults to the largest numeric option. */
|
||||
defaultRangeSeconds?: ChartRangeValue;
|
||||
/** Controlled range for consumers that refetch when the selection changes. */
|
||||
rangeSeconds?: number;
|
||||
onRangeChange?: (rangeSeconds: number) => void;
|
||||
rangeSeconds?: ChartRangeValue;
|
||||
onRangeChange?: (range: ChartRangeValue) => void;
|
||||
}
|
||||
|
||||
/** Shared range-aware line chart renderer for Prometheus and qBittorrent data. */
|
||||
@@ -91,8 +95,11 @@ export function LineSeriesChart({
|
||||
onRangeChange,
|
||||
}: LineSeriesChartProps) {
|
||||
const initialRange =
|
||||
defaultRangeSeconds ?? rangeOptions[rangeOptions.length - 1]?.value;
|
||||
const [localRangeSeconds, setLocalRangeSeconds] = useState(initialRange);
|
||||
defaultRangeSeconds ??
|
||||
[...rangeOptions].reverse().find((range) => typeof range.value === "number")
|
||||
?.value;
|
||||
const [localRangeSeconds, setLocalRangeSeconds] =
|
||||
useState<ChartRangeValue | undefined>(initialRange);
|
||||
const selectedRangeSeconds = rangeSeconds ?? localRangeSeconds;
|
||||
const latestTimestamp = series.reduce(
|
||||
(max, seriesItem) =>
|
||||
@@ -102,9 +109,10 @@ export function LineSeriesChart({
|
||||
),
|
||||
0,
|
||||
);
|
||||
const cutoff = selectedRangeSeconds
|
||||
? latestTimestamp - selectedRangeSeconds * 1000
|
||||
: null;
|
||||
const cutoff =
|
||||
typeof selectedRangeSeconds === "number"
|
||||
? latestTimestamp - selectedRangeSeconds * 1000
|
||||
: null;
|
||||
const visibleSeries =
|
||||
cutoff !== null && latestTimestamp > 0
|
||||
? series.map((seriesItem) => ({
|
||||
@@ -125,7 +133,7 @@ export function LineSeriesChart({
|
||||
formatScaled(value, scaleInfo, unit);
|
||||
|
||||
function handleRangeChange(value: string) {
|
||||
const nextRange = Number(value);
|
||||
const nextRange: ChartRangeValue = value === "all" ? "all" : Number(value);
|
||||
setLocalRangeSeconds(nextRange);
|
||||
onRangeChange?.(nextRange);
|
||||
}
|
||||
@@ -136,7 +144,9 @@ export function LineSeriesChart({
|
||||
<div className="flex justify-end">
|
||||
<Select
|
||||
value={
|
||||
selectedRangeSeconds ? String(selectedRangeSeconds) : undefined
|
||||
selectedRangeSeconds === undefined
|
||||
? undefined
|
||||
: String(selectedRangeSeconds)
|
||||
}
|
||||
onValueChange={handleRangeChange}
|
||||
>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { LineSeriesChart } from "../LineSeriesChart";
|
||||
import type { ChartSeries } from "../LineSeriesChart";
|
||||
import { chartRangesThrough } from "../chartRanges";
|
||||
@@ -38,6 +38,22 @@ describe("LineSeriesChart", () => {
|
||||
).toHaveTextContent("2 hours");
|
||||
});
|
||||
|
||||
it("offers all loaded values and reports that selection", () => {
|
||||
const onRangeChange = vi.fn();
|
||||
render(
|
||||
<LineSeriesChart
|
||||
series={[]}
|
||||
rangeOptions={chartRangesThrough(3600)}
|
||||
onRangeChange={onRangeChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("combobox", { name: "Chart range" }));
|
||||
fireEvent.click(screen.getByRole("option", { name: "All values" }));
|
||||
|
||||
expect(onRangeChange).toHaveBeenCalledWith("all");
|
||||
});
|
||||
|
||||
it("renders with custom height", () => {
|
||||
const series: ChartSeries[] = [{ label: "dl", points: [{ t: 1, v: 1 }] }];
|
||||
const { container } = render(
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
chartRangesThrough,
|
||||
PROMETHEUS_WINDOW_VALUES,
|
||||
rangeSecondsFromWindow,
|
||||
} from "../chartRanges";
|
||||
|
||||
describe("chart ranges", () => {
|
||||
it("maps every persisted Prometheus window to its display duration", () => {
|
||||
expect(PROMETHEUS_WINDOW_VALUES).toEqual([
|
||||
"5m",
|
||||
"15m",
|
||||
"30m",
|
||||
"1h",
|
||||
"3h",
|
||||
"6h",
|
||||
"12h",
|
||||
"24h",
|
||||
"2d",
|
||||
"7d",
|
||||
"14d",
|
||||
"30d",
|
||||
]);
|
||||
expect(PROMETHEUS_WINDOW_VALUES.map(rangeSecondsFromWindow)).toEqual([
|
||||
300, 900, 1800, 3600, 10800, 21600, 43200, 86400, 172800, 604800, 1209600,
|
||||
2592000,
|
||||
]);
|
||||
});
|
||||
|
||||
it("offers all values after every finite range through the available history", () => {
|
||||
expect(chartRangesThrough(86_400).at(-1)).toEqual({
|
||||
value: "all",
|
||||
label: "All values",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,18 +1,45 @@
|
||||
export type ChartRangeValue = number | "all";
|
||||
|
||||
export interface ChartRangeOption {
|
||||
value: number;
|
||||
value: ChartRangeValue;
|
||||
label: string;
|
||||
}
|
||||
|
||||
/** Shared range choices used by every time-series chart. */
|
||||
export const DEFAULT_CHART_RANGES: ChartRangeOption[] = [
|
||||
{ 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" },
|
||||
{ value: 604800, label: "7 days" },
|
||||
interface FiniteChartRange extends ChartRangeOption {
|
||||
key: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
/** Canonical finite windows for chart configuration and display filtering. */
|
||||
const FINITE_CHART_RANGES: readonly FiniteChartRange[] = [
|
||||
{ key: "5m", value: 300, label: "5 minutes" },
|
||||
{ key: "15m", value: 900, label: "15 minutes" },
|
||||
{ key: "30m", value: 1800, label: "30 minutes" },
|
||||
{ key: "1h", value: 3600, label: "1 hour" },
|
||||
{ key: "3h", value: 10800, label: "3 hours" },
|
||||
{ key: "6h", value: 21600, label: "6 hours" },
|
||||
{ key: "12h", value: 43200, label: "12 hours" },
|
||||
{ key: "24h", value: 86400, label: "24 hours" },
|
||||
{ key: "2d", value: 172800, label: "2 days" },
|
||||
{ key: "7d", value: 604800, label: "7 days" },
|
||||
{ key: "14d", value: 1209600, label: "14 days" },
|
||||
{ key: "30d", value: 2592000, label: "30 days" },
|
||||
];
|
||||
|
||||
/** Shared range choices used by every time-series chart. */
|
||||
export const DEFAULT_CHART_RANGES: readonly ChartRangeOption[] =
|
||||
FINITE_CHART_RANGES;
|
||||
|
||||
/** Symbolic range values persisted by Prometheus chart and mean widgets. */
|
||||
export const PROMETHEUS_WINDOW_VALUES = FINITE_CHART_RANGES.map(
|
||||
(range) => range.key,
|
||||
);
|
||||
|
||||
export const ALL_VALUES_CHART_RANGE: ChartRangeOption = {
|
||||
value: "all",
|
||||
label: "All values",
|
||||
};
|
||||
|
||||
function formatRangeLabel(seconds: number): string {
|
||||
if (seconds % 604800 === 0) return `${seconds / 604800} days`;
|
||||
if (seconds % 3600 === 0) return `${seconds / 3600} hours`;
|
||||
@@ -22,27 +49,33 @@ function formatRangeLabel(seconds: number): string {
|
||||
|
||||
export function chartRangesThrough(maxSeconds: number): ChartRangeOption[] {
|
||||
if (!Number.isFinite(maxSeconds) || maxSeconds <= 0) {
|
||||
return [DEFAULT_CHART_RANGES[0]];
|
||||
return [DEFAULT_CHART_RANGES[0], ALL_VALUES_CHART_RANGE];
|
||||
}
|
||||
const ranges = DEFAULT_CHART_RANGES.filter(
|
||||
const ranges = FINITE_CHART_RANGES.filter(
|
||||
(range) => range.value < maxSeconds,
|
||||
);
|
||||
const exact = DEFAULT_CHART_RANGES.find(
|
||||
(range) => range.value === maxSeconds,
|
||||
);
|
||||
return exact
|
||||
? [...ranges, exact]
|
||||
: [...ranges, { value: maxSeconds, label: formatRangeLabel(maxSeconds) }];
|
||||
const exact = FINITE_CHART_RANGES.find((range) => range.value === maxSeconds);
|
||||
return [
|
||||
...(exact
|
||||
? [...ranges, exact]
|
||||
: [
|
||||
...ranges,
|
||||
{ value: maxSeconds, label: formatRangeLabel(maxSeconds) },
|
||||
]),
|
||||
ALL_VALUES_CHART_RANGE,
|
||||
];
|
||||
}
|
||||
|
||||
export function rangeSecondsFromWindow(window: unknown): number {
|
||||
const values: Record<string, number> = {
|
||||
"15m": 900,
|
||||
"30m": 1800,
|
||||
"1h": 3600,
|
||||
"6h": 21600,
|
||||
"24h": 86400,
|
||||
"7d": 604800,
|
||||
};
|
||||
return values[String(window)] ?? 3600;
|
||||
return (
|
||||
FINITE_CHART_RANGES.find((range) => range.key === String(window))?.value ??
|
||||
3600
|
||||
);
|
||||
}
|
||||
|
||||
/** Numeric chart windows suitable for sources with bounded local retention. */
|
||||
export function numericChartRangesThrough(maxSeconds: number): number[] {
|
||||
return FINITE_CHART_RANGES.flatMap((range) =>
|
||||
range.value <= maxSeconds ? [range.value] : [],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
fetchSchedulerStatus,
|
||||
runSchedulerAction,
|
||||
} from "../api/scheduler";
|
||||
import type { ChartRangeValue } from "../components/chartRanges";
|
||||
|
||||
export function useSchedulerStatus(serviceId: string) {
|
||||
return useQuery({
|
||||
@@ -24,10 +25,13 @@ export function useSchedulerRuns(serviceId: string) {
|
||||
});
|
||||
}
|
||||
|
||||
export function useSchedulerSamples(serviceId: string, windowSeconds: number) {
|
||||
export function useSchedulerSamples(
|
||||
serviceId: string,
|
||||
window: ChartRangeValue,
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: ["scheduler", "samples", serviceId, windowSeconds],
|
||||
queryFn: () => fetchSchedulerSamples(serviceId, windowSeconds),
|
||||
queryKey: ["scheduler", "samples", serviceId, window],
|
||||
queryFn: () => fetchSchedulerSamples(serviceId, window),
|
||||
enabled: Boolean(serviceId),
|
||||
refetchInterval: 15_000,
|
||||
});
|
||||
|
||||
@@ -69,6 +69,44 @@ describe("service registry", () => {
|
||||
expect(speed?.defaultConfig.unit).toBe("bytes_per_sec");
|
||||
});
|
||||
|
||||
it("shares expanded chart windows and an all-retained option", () => {
|
||||
const propertiesOf = (kind: string) => {
|
||||
const binding = SERVICE_REGISTRY[
|
||||
kind === "speed" ? "qbittorrent" : "prometheus"
|
||||
].widgets.find((widget) => widget.kind === kind);
|
||||
const schema = binding?.configSchema as
|
||||
| { properties?: Record<string, { enum?: string[] }> }
|
||||
| undefined;
|
||||
return schema?.properties ?? {};
|
||||
};
|
||||
|
||||
expect(propertiesOf("chart").window?.enum).toEqual([
|
||||
"5m",
|
||||
"15m",
|
||||
"30m",
|
||||
"1h",
|
||||
"3h",
|
||||
"6h",
|
||||
"12h",
|
||||
"24h",
|
||||
"2d",
|
||||
"7d",
|
||||
"14d",
|
||||
"30d",
|
||||
]);
|
||||
expect(propertiesOf("speed").window_seconds?.enum).toEqual([
|
||||
"300",
|
||||
"900",
|
||||
"1800",
|
||||
"3600",
|
||||
"10800",
|
||||
"21600",
|
||||
"43200",
|
||||
"86400",
|
||||
"all",
|
||||
]);
|
||||
});
|
||||
|
||||
it("resolves a prometheus metric widget via the services list", () => {
|
||||
const widget: WidgetInstance = {
|
||||
id: "w1",
|
||||
|
||||
@@ -17,6 +17,10 @@ import { RequestStatWidget } from "../widgets/RequestStatWidget";
|
||||
import { RequestsOverviewWidget } from "../widgets/RequestsOverviewWidget";
|
||||
import { SshTaskWidget } from "../widgets/SshTaskWidget";
|
||||
import { StaticWidget } from "../widgets/StaticWidget";
|
||||
import {
|
||||
numericChartRangesThrough,
|
||||
PROMETHEUS_WINDOW_VALUES,
|
||||
} from "../components/chartRanges";
|
||||
import type {
|
||||
ServiceInstance,
|
||||
ServiceTypeInfo,
|
||||
@@ -64,6 +68,10 @@ const UNIT_VALUES = [
|
||||
"seconds",
|
||||
];
|
||||
const SCALE_VALUES = ["auto", "k", "m", "g", "t"];
|
||||
const SPEED_WINDOW_VALUES = [
|
||||
...numericChartRangesThrough(86_400).map(String),
|
||||
"all",
|
||||
];
|
||||
const AXIS_FORMAT_PROPERTIES = {
|
||||
unit: {
|
||||
type: "string",
|
||||
@@ -186,7 +194,8 @@ export const SERVICE_REGISTRY: Record<string, ServiceBinding> = {
|
||||
},
|
||||
window: {
|
||||
type: "string",
|
||||
description: "Time window preset (1h, 6h, 24h, 7d)",
|
||||
enum: PROMETHEUS_WINDOW_VALUES,
|
||||
description: "Maximum history fetched for the chart",
|
||||
},
|
||||
...AXIS_FORMAT_PROPERTIES,
|
||||
},
|
||||
@@ -233,7 +242,8 @@ export const SERVICE_REGISTRY: Record<string, ServiceBinding> = {
|
||||
},
|
||||
window: {
|
||||
type: "string",
|
||||
description: "Time window preset (1h, 6h, 24h, 7d)",
|
||||
enum: PROMETHEUS_WINDOW_VALUES,
|
||||
description: "Time window used to calculate the average",
|
||||
},
|
||||
unit: { type: "string" },
|
||||
},
|
||||
@@ -282,7 +292,9 @@ export const SERVICE_REGISTRY: Record<string, ServiceBinding> = {
|
||||
properties: {
|
||||
window_seconds: {
|
||||
type: "integer",
|
||||
description: "Maximum data window available to the chart",
|
||||
enum: SPEED_WINDOW_VALUES,
|
||||
description:
|
||||
"Maximum history fetched for the chart, or all retained samples",
|
||||
},
|
||||
...AXIS_FORMAT_PROPERTIES,
|
||||
},
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { Activity, Clock, Play, RefreshCw, TriangleAlert } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { LineSeriesChart } from "../../components/LineSeriesChart";
|
||||
import { chartRangesThrough } from "../../components/chartRanges";
|
||||
import {
|
||||
chartRangesThrough,
|
||||
type ChartRangeValue,
|
||||
} from "../../components/chartRanges";
|
||||
import {
|
||||
useRunSchedulerAction,
|
||||
useSchedulerRuns,
|
||||
@@ -29,9 +32,9 @@ function statusVariant(
|
||||
}
|
||||
|
||||
export function QbittorrentTab({ instance }: { instance: ServiceInstance }) {
|
||||
const [windowSeconds, setWindowSeconds] = useState(1800);
|
||||
const [selectedRange, setSelectedRange] = useState<ChartRangeValue>(1800);
|
||||
const status = useSchedulerStatus(instance.id);
|
||||
const samples = useSchedulerSamples(instance.id, windowSeconds);
|
||||
const samples = useSchedulerSamples(instance.id, selectedRange);
|
||||
const runs = useSchedulerRuns(instance.id);
|
||||
const runNow = useRunSchedulerAction();
|
||||
const stale = Boolean(status.data?.enabled && status.data.is_stale);
|
||||
@@ -111,9 +114,9 @@ export function QbittorrentTab({ instance }: { instance: ServiceInstance }) {
|
||||
series={chartSeries}
|
||||
unit="bytes"
|
||||
height={300}
|
||||
rangeOptions={chartRangesThrough(86400)}
|
||||
rangeSeconds={windowSeconds}
|
||||
onRangeChange={setWindowSeconds}
|
||||
rangeOptions={chartRangesThrough(86_400)}
|
||||
rangeSeconds={selectedRange}
|
||||
onRangeChange={setSelectedRange}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
+354
-353
@@ -3,492 +3,493 @@
|
||||
*/
|
||||
|
||||
export interface MediaCounts {
|
||||
movies: number;
|
||||
series: number;
|
||||
episodes: number;
|
||||
movies: number;
|
||||
series: number;
|
||||
episodes: number;
|
||||
}
|
||||
|
||||
export interface LibraryCount {
|
||||
library: string;
|
||||
type: string;
|
||||
movies: number;
|
||||
series: number;
|
||||
episodes: number;
|
||||
total: number;
|
||||
library: string;
|
||||
type: string;
|
||||
movies: number;
|
||||
series: number;
|
||||
episodes: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface UserDirectoryItem {
|
||||
jellyfin_id: string;
|
||||
username: string;
|
||||
display_name: string;
|
||||
email: string;
|
||||
email_source: string;
|
||||
avatar: string;
|
||||
avatar_source: string;
|
||||
contactable: boolean;
|
||||
source: string;
|
||||
source_summary: string;
|
||||
name_source: string;
|
||||
access_source: string;
|
||||
jellyseerr_user_id: number | null;
|
||||
jellyseerr_username: string;
|
||||
user_type: number | null;
|
||||
user_type_label: string;
|
||||
role: string;
|
||||
permissions: number;
|
||||
permissions_label: string;
|
||||
request_count: number | null;
|
||||
jellyfin_id: string;
|
||||
username: string;
|
||||
display_name: string;
|
||||
email: string;
|
||||
email_source: string;
|
||||
avatar: string;
|
||||
avatar_source: string;
|
||||
contactable: boolean;
|
||||
source: string;
|
||||
source_summary: string;
|
||||
name_source: string;
|
||||
access_source: string;
|
||||
jellyseerr_user_id: number | null;
|
||||
jellyseerr_username: string;
|
||||
user_type: number | null;
|
||||
user_type_label: string;
|
||||
role: string;
|
||||
permissions: number;
|
||||
permissions_label: string;
|
||||
request_count: number | null;
|
||||
}
|
||||
|
||||
export interface UserDirectoryResponse {
|
||||
items: UserDirectoryItem[];
|
||||
total: number;
|
||||
jellyseerr_configured: boolean;
|
||||
jellyseerr_available: boolean;
|
||||
jellyseerr_error: string;
|
||||
jellyseerr_jellyfin_user_count: number;
|
||||
jellyseerr_user_count: number;
|
||||
enriched_count: number;
|
||||
items: UserDirectoryItem[];
|
||||
total: number;
|
||||
jellyseerr_configured: boolean;
|
||||
jellyseerr_available: boolean;
|
||||
jellyseerr_error: string;
|
||||
jellyseerr_jellyfin_user_count: number;
|
||||
jellyseerr_user_count: number;
|
||||
enriched_count: number;
|
||||
}
|
||||
|
||||
export interface UserMessageResponse {
|
||||
status: string;
|
||||
request_id: string;
|
||||
subject: string;
|
||||
from_address: string;
|
||||
recipient_count: number;
|
||||
attachment_count: number;
|
||||
recipient_labels: string[];
|
||||
skipped: Array<{ jellyfin_id: string; reason: string }>;
|
||||
status: string;
|
||||
request_id: string;
|
||||
subject: string;
|
||||
from_address: string;
|
||||
recipient_count: number;
|
||||
attachment_count: number;
|
||||
recipient_labels: string[];
|
||||
skipped: Array<{ jellyfin_id: string; reason: string }>;
|
||||
}
|
||||
|
||||
export interface UserMessageQueueStatus {
|
||||
state: "idle" | "busy" | "error" | "stopped";
|
||||
worker_running: boolean;
|
||||
stop_requested: boolean;
|
||||
pending_count: number;
|
||||
active_request_id: string | null;
|
||||
last_request_id: string | null;
|
||||
last_result: string | null;
|
||||
last_error: string;
|
||||
last_error_at: number | null;
|
||||
last_success_at: number | null;
|
||||
last_activity_at: number | null;
|
||||
sent_count: number;
|
||||
failed_count: number;
|
||||
state: "idle" | "busy" | "error" | "stopped";
|
||||
worker_running: boolean;
|
||||
stop_requested: boolean;
|
||||
pending_count: number;
|
||||
active_request_id: string | null;
|
||||
last_request_id: string | null;
|
||||
last_result: string | null;
|
||||
last_error: string;
|
||||
last_error_at: number | null;
|
||||
last_success_at: number | null;
|
||||
last_activity_at: number | null;
|
||||
sent_count: number;
|
||||
failed_count: number;
|
||||
}
|
||||
|
||||
export interface NowPlayingSession {
|
||||
user: string;
|
||||
title: string;
|
||||
type: string;
|
||||
state: string;
|
||||
transcoding: string;
|
||||
transcoding_type: string;
|
||||
device: string;
|
||||
session_id: string;
|
||||
user: string;
|
||||
title: string;
|
||||
type: string;
|
||||
state: string;
|
||||
transcoding: string;
|
||||
transcoding_type: string;
|
||||
device: string;
|
||||
session_id: string;
|
||||
}
|
||||
|
||||
export interface SSHKey {
|
||||
id: string;
|
||||
name: string;
|
||||
private_key_set: boolean;
|
||||
passphrase_set: boolean;
|
||||
public_key: string;
|
||||
fingerprint: string;
|
||||
usage_count: number;
|
||||
notes: string;
|
||||
id: string;
|
||||
name: string;
|
||||
private_key_set: boolean;
|
||||
passphrase_set: boolean;
|
||||
public_key: string;
|
||||
fingerprint: string;
|
||||
usage_count: number;
|
||||
notes: string;
|
||||
}
|
||||
|
||||
export interface SSHKeyInput {
|
||||
id?: string | null;
|
||||
name: string;
|
||||
private_key: string;
|
||||
passphrase: string;
|
||||
public_key: string;
|
||||
fingerprint: string;
|
||||
notes: string;
|
||||
id?: string | null;
|
||||
name: string;
|
||||
private_key: string;
|
||||
passphrase: string;
|
||||
public_key: string;
|
||||
fingerprint: string;
|
||||
notes: string;
|
||||
}
|
||||
|
||||
export interface SSHKeyGenerated {
|
||||
name: string;
|
||||
private_key: string;
|
||||
passphrase: string;
|
||||
notes: string;
|
||||
public_key: string;
|
||||
fingerprint: string;
|
||||
usage_count: number;
|
||||
name: string;
|
||||
private_key: string;
|
||||
passphrase: string;
|
||||
notes: string;
|
||||
public_key: string;
|
||||
fingerprint: string;
|
||||
usage_count: number;
|
||||
}
|
||||
|
||||
export interface SavedTask {
|
||||
id: string;
|
||||
name: string;
|
||||
task_type: "shell" | "python";
|
||||
content: string;
|
||||
enabled: boolean;
|
||||
service_id: string;
|
||||
notes: string;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
id: string;
|
||||
name: string;
|
||||
task_type: "shell" | "python";
|
||||
content: string;
|
||||
enabled: boolean;
|
||||
service_id: string;
|
||||
notes: string;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
export interface SavedTaskInput {
|
||||
id?: string | null;
|
||||
name: string;
|
||||
task_type: "shell" | "python";
|
||||
content: string;
|
||||
enabled: boolean;
|
||||
service_id: string;
|
||||
notes: string;
|
||||
id?: string | null;
|
||||
name: string;
|
||||
task_type: "shell" | "python";
|
||||
content: string;
|
||||
enabled: boolean;
|
||||
service_id: string;
|
||||
notes: string;
|
||||
}
|
||||
|
||||
export interface SavedTaskRun {
|
||||
id: string;
|
||||
task_id: string;
|
||||
service_id: string;
|
||||
status: "success" | "failure" | "error" | "timeout" | string;
|
||||
exit_status: number | null;
|
||||
created_at: number;
|
||||
duration_ms: number;
|
||||
stdout_tail: string;
|
||||
stderr_tail: string;
|
||||
error: string;
|
||||
id: string;
|
||||
task_id: string;
|
||||
service_id: string;
|
||||
status: "success" | "failure" | "error" | "timeout" | string;
|
||||
exit_status: number | null;
|
||||
created_at: number;
|
||||
duration_ms: number;
|
||||
stdout_tail: string;
|
||||
stderr_tail: string;
|
||||
error: string;
|
||||
}
|
||||
|
||||
export interface ResetLocalDatabaseInput {
|
||||
confirm_phrase: string;
|
||||
acknowledge_settings_loss: boolean;
|
||||
acknowledge_media_index_loss: boolean;
|
||||
acknowledge_irreversible: boolean;
|
||||
confirm_phrase: string;
|
||||
acknowledge_settings_loss: boolean;
|
||||
acknowledge_media_index_loss: boolean;
|
||||
acknowledge_irreversible: boolean;
|
||||
}
|
||||
|
||||
export interface ResetLocalDatabaseResponse {
|
||||
status: string;
|
||||
settings_db_removed: boolean;
|
||||
media_index_removed: boolean;
|
||||
settings_files: string[];
|
||||
media_index_files: string[];
|
||||
status: string;
|
||||
settings_db_removed: boolean;
|
||||
media_index_removed: boolean;
|
||||
settings_files: string[];
|
||||
media_index_files: string[];
|
||||
}
|
||||
|
||||
export interface AppVersionInfo {
|
||||
app: string;
|
||||
backend_version: string;
|
||||
backend_build: string;
|
||||
backend_label: string;
|
||||
app: string;
|
||||
backend_version: string;
|
||||
backend_build: string;
|
||||
backend_label: string;
|
||||
}
|
||||
|
||||
export interface MediaIndexStatus {
|
||||
exists: boolean;
|
||||
item_count: number;
|
||||
updated_at: number | null;
|
||||
updated_at_label: string;
|
||||
build_duration_seconds: number | null;
|
||||
build_running: boolean;
|
||||
build_stage: string;
|
||||
build_message: string;
|
||||
build_progress: number | null;
|
||||
build_items_processed: number;
|
||||
build_items_total: number;
|
||||
build_current_library: string;
|
||||
build_library_index: number;
|
||||
build_libraries_total: number;
|
||||
build_library_progress: number | null;
|
||||
build_library_items_processed: number;
|
||||
build_library_items_total: number;
|
||||
build_elapsed_seconds: number | null;
|
||||
build_eta_seconds: number | null;
|
||||
build_library_elapsed_seconds: number | null;
|
||||
build_library_eta_seconds: number | null;
|
||||
build_cancel_requested: boolean;
|
||||
build_pid: number | null;
|
||||
build_error: string;
|
||||
exists: boolean;
|
||||
item_count: number;
|
||||
updated_at: number | null;
|
||||
updated_at_label: string;
|
||||
build_duration_seconds: number | null;
|
||||
build_running: boolean;
|
||||
build_stage: string;
|
||||
build_message: string;
|
||||
build_progress: number | null;
|
||||
build_items_processed: number;
|
||||
build_items_total: number;
|
||||
build_current_library: string;
|
||||
build_library_index: number;
|
||||
build_libraries_total: number;
|
||||
build_library_progress: number | null;
|
||||
build_library_items_processed: number;
|
||||
build_library_items_total: number;
|
||||
build_elapsed_seconds: number | null;
|
||||
build_eta_seconds: number | null;
|
||||
build_library_elapsed_seconds: number | null;
|
||||
build_library_eta_seconds: number | null;
|
||||
build_cancel_requested: boolean;
|
||||
build_pid: number | null;
|
||||
build_error: string;
|
||||
}
|
||||
|
||||
export interface MediaIndexActionResponse {
|
||||
status: string;
|
||||
build_running: boolean;
|
||||
build_stage: string;
|
||||
build_message: string;
|
||||
build_progress: number | null;
|
||||
build_items_processed: number;
|
||||
build_items_total: number;
|
||||
build_current_library: string;
|
||||
build_library_index: number;
|
||||
build_libraries_total: number;
|
||||
build_library_progress: number | null;
|
||||
build_library_items_processed: number;
|
||||
build_library_items_total: number;
|
||||
build_elapsed_seconds: number | null;
|
||||
build_eta_seconds: number | null;
|
||||
build_library_elapsed_seconds: number | null;
|
||||
build_library_eta_seconds: number | null;
|
||||
build_cancel_requested: boolean;
|
||||
build_pid: number | null;
|
||||
build_error: string;
|
||||
status: string;
|
||||
build_running: boolean;
|
||||
build_stage: string;
|
||||
build_message: string;
|
||||
build_progress: number | null;
|
||||
build_items_processed: number;
|
||||
build_items_total: number;
|
||||
build_current_library: string;
|
||||
build_library_index: number;
|
||||
build_libraries_total: number;
|
||||
build_library_progress: number | null;
|
||||
build_library_items_processed: number;
|
||||
build_library_items_total: number;
|
||||
build_elapsed_seconds: number | null;
|
||||
build_eta_seconds: number | null;
|
||||
build_library_elapsed_seconds: number | null;
|
||||
build_library_eta_seconds: number | null;
|
||||
build_cancel_requested: boolean;
|
||||
build_pid: number | null;
|
||||
build_error: string;
|
||||
}
|
||||
|
||||
export interface MediaItem {
|
||||
id: string;
|
||||
title: string;
|
||||
series: string;
|
||||
season: string;
|
||||
episode: number | null;
|
||||
type: string;
|
||||
year: number | null;
|
||||
runtime_min: number | null;
|
||||
size: string;
|
||||
bitrate: string;
|
||||
hdr: string;
|
||||
video: string;
|
||||
resolution: string;
|
||||
date_added: string;
|
||||
library: string;
|
||||
path: string;
|
||||
id: string;
|
||||
title: string;
|
||||
series: string;
|
||||
season: string;
|
||||
episode: number | null;
|
||||
type: string;
|
||||
year: number | null;
|
||||
runtime_min: number | null;
|
||||
size: string;
|
||||
bitrate: string;
|
||||
hdr: string;
|
||||
video: string;
|
||||
resolution: string;
|
||||
date_added: string;
|
||||
library: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface MediaQueryResponse {
|
||||
items: MediaItem[];
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
items: MediaItem[];
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
}
|
||||
|
||||
export interface FileEntry {
|
||||
type: string;
|
||||
size: number;
|
||||
mtime: number;
|
||||
name: string;
|
||||
type: string;
|
||||
size: number;
|
||||
mtime: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface DirectoryListing {
|
||||
path: string;
|
||||
entries: FileEntry[];
|
||||
count: number;
|
||||
path: string;
|
||||
entries: FileEntry[];
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface JobTemplate {
|
||||
key: string;
|
||||
name: string;
|
||||
description: string;
|
||||
key: string;
|
||||
name: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface JobResult {
|
||||
job_key: string;
|
||||
path: string;
|
||||
exit_status: number;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
job_key: string;
|
||||
path: string;
|
||||
exit_status: number;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
}
|
||||
|
||||
export interface ResolvedPath {
|
||||
original: string;
|
||||
resolved: string;
|
||||
original: string;
|
||||
resolved: string;
|
||||
}
|
||||
|
||||
export interface DashboardShortcut {
|
||||
id: string;
|
||||
label: string;
|
||||
shortcut_type: "website" | "action" | "user";
|
||||
enabled: boolean;
|
||||
icon: string;
|
||||
url: string;
|
||||
task_id: string;
|
||||
machine_id: string;
|
||||
user_id: string;
|
||||
notes: string;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
id: string;
|
||||
label: string;
|
||||
shortcut_type: "website" | "action" | "user";
|
||||
enabled: boolean;
|
||||
icon: string;
|
||||
url: string;
|
||||
task_id: string;
|
||||
machine_id: string;
|
||||
user_id: string;
|
||||
notes: string;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
export interface DashboardShortcutInput {
|
||||
id?: string | null;
|
||||
label: string;
|
||||
shortcut_type: "website" | "action" | "user";
|
||||
enabled: boolean;
|
||||
icon: string;
|
||||
url: string;
|
||||
task_id: string;
|
||||
machine_id: string;
|
||||
user_id: string;
|
||||
notes: string;
|
||||
id?: string | null;
|
||||
label: string;
|
||||
shortcut_type: "website" | "action" | "user";
|
||||
enabled: boolean;
|
||||
icon: string;
|
||||
url: string;
|
||||
task_id: string;
|
||||
machine_id: string;
|
||||
user_id: string;
|
||||
notes: string;
|
||||
}
|
||||
|
||||
export interface AlertmanagerAlert {
|
||||
name: string;
|
||||
severity: string;
|
||||
category: string;
|
||||
job_name: string;
|
||||
summary: string;
|
||||
description: string;
|
||||
active_since: string;
|
||||
state: string;
|
||||
labels: Record<string, string>;
|
||||
name: string;
|
||||
severity: string;
|
||||
category: string;
|
||||
job_name: string;
|
||||
summary: string;
|
||||
description: string;
|
||||
active_since: string;
|
||||
state: string;
|
||||
labels: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface AlertmanagerAlertSummary {
|
||||
total: number;
|
||||
by_severity: Record<string, number>;
|
||||
alerts: AlertmanagerAlert[];
|
||||
error?: string;
|
||||
total: number;
|
||||
by_severity: Record<string, number>;
|
||||
alerts: AlertmanagerAlert[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface AlertmanagerStatus {
|
||||
up: boolean;
|
||||
version: string;
|
||||
uptime: string;
|
||||
name: string;
|
||||
peers: string[];
|
||||
service_id?: string;
|
||||
error?: string | null;
|
||||
up: boolean;
|
||||
version: string;
|
||||
uptime: string;
|
||||
name: string;
|
||||
peers: string[];
|
||||
service_id?: string;
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
export interface PrometheusStatus {
|
||||
up: boolean;
|
||||
version: string;
|
||||
service_id: string;
|
||||
name: string;
|
||||
error?: string | null;
|
||||
up: boolean;
|
||||
version: string;
|
||||
service_id: string;
|
||||
name: string;
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
export interface WidgetInstance {
|
||||
id: string;
|
||||
service_id: string | null;
|
||||
widget_kind: string;
|
||||
title: string;
|
||||
config: Record<string, unknown>;
|
||||
enabled: boolean;
|
||||
sort_order: number;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
id: string;
|
||||
service_id: string | null;
|
||||
widget_kind: string;
|
||||
title: string;
|
||||
config: Record<string, unknown>;
|
||||
enabled: boolean;
|
||||
sort_order: number;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
export interface WidgetInstanceInput {
|
||||
id?: string | null;
|
||||
service_id: string | null;
|
||||
widget_kind: string;
|
||||
title: string;
|
||||
config: Record<string, unknown>;
|
||||
enabled: boolean;
|
||||
sort_order: number;
|
||||
id?: string | null;
|
||||
service_id: string | null;
|
||||
widget_kind: string;
|
||||
title: string;
|
||||
config: Record<string, unknown>;
|
||||
enabled: boolean;
|
||||
sort_order: number;
|
||||
}
|
||||
|
||||
export interface WidgetDataResponse {
|
||||
widget_id: string;
|
||||
data: Record<string, unknown> | null;
|
||||
error: string | null;
|
||||
fetched_at: number;
|
||||
widget_id: string;
|
||||
data: Record<string, unknown> | null;
|
||||
error: string | null;
|
||||
fetched_at: number;
|
||||
}
|
||||
|
||||
export interface SecretFieldInfo {
|
||||
key: string;
|
||||
label: string;
|
||||
required: boolean;
|
||||
helper?: string | null;
|
||||
key: string;
|
||||
label: string;
|
||||
required: boolean;
|
||||
helper?: string | null;
|
||||
}
|
||||
|
||||
export interface ServiceWidgetKindInfo {
|
||||
kind: string;
|
||||
name: string;
|
||||
description: string;
|
||||
config_schema: Record<string, unknown>;
|
||||
default_config: Record<string, unknown>;
|
||||
refresh_interval_ms: number;
|
||||
kind: string;
|
||||
name: string;
|
||||
description: string;
|
||||
config_schema: Record<string, unknown>;
|
||||
default_config: Record<string, unknown>;
|
||||
refresh_interval_ms: number;
|
||||
}
|
||||
|
||||
export interface ServiceTypeInfo {
|
||||
service_type: string;
|
||||
name: string;
|
||||
description: string;
|
||||
config_schema: Record<string, unknown>;
|
||||
secret_fields: SecretFieldInfo[];
|
||||
widget_kinds: ServiceWidgetKindInfo[];
|
||||
service_type: string;
|
||||
name: string;
|
||||
description: string;
|
||||
config_schema: Record<string, unknown>;
|
||||
secret_fields: SecretFieldInfo[];
|
||||
widget_kinds: ServiceWidgetKindInfo[];
|
||||
}
|
||||
|
||||
export interface ServiceInstance {
|
||||
id: string;
|
||||
service_type: string;
|
||||
name: string;
|
||||
config: Record<string, unknown>;
|
||||
secrets_set: Record<string, boolean>;
|
||||
enabled: boolean;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
id: string;
|
||||
service_type: string;
|
||||
name: string;
|
||||
config: Record<string, unknown>;
|
||||
secrets_set: Record<string, boolean>;
|
||||
enabled: boolean;
|
||||
created_at: number;
|
||||
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;
|
||||
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;
|
||||
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;
|
||||
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;
|
||||
}>;
|
||||
service_id: string;
|
||||
window_seconds: number | null;
|
||||
all_values: boolean;
|
||||
samples: Array<{
|
||||
ts: number;
|
||||
dl_speed: number;
|
||||
up_speed: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface SchedulerManualRunResponse {
|
||||
run: SchedulerRun;
|
||||
status: SchedulerStatus;
|
||||
run: SchedulerRun;
|
||||
status: SchedulerStatus;
|
||||
}
|
||||
|
||||
export interface ServiceInstanceInput {
|
||||
id?: string | null;
|
||||
service_type: string;
|
||||
name: string;
|
||||
config: Record<string, unknown>;
|
||||
secrets: Record<string, string>;
|
||||
enabled: boolean;
|
||||
id?: string | null;
|
||||
service_type: string;
|
||||
name: string;
|
||||
config: Record<string, unknown>;
|
||||
secrets: Record<string, string>;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface ServiceTestResult {
|
||||
ok: boolean;
|
||||
detail: string;
|
||||
evidence: string | null;
|
||||
ok: boolean;
|
||||
detail: string;
|
||||
evidence: string | null;
|
||||
}
|
||||
|
||||
export interface BuiltinWidgetKindInfo {
|
||||
kind: string;
|
||||
name: string;
|
||||
description: string;
|
||||
config_schema: Record<string, unknown>;
|
||||
default_config: Record<string, unknown>;
|
||||
refresh_interval_ms: number;
|
||||
kind: string;
|
||||
name: string;
|
||||
description: string;
|
||||
config_schema: Record<string, unknown>;
|
||||
default_config: Record<string, unknown>;
|
||||
refresh_interval_ms: number;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { LineSeriesChart } from "../components/LineSeriesChart";
|
||||
import { chartRangesThrough } from "../components/chartRanges";
|
||||
import {
|
||||
chartRangesThrough,
|
||||
type ChartRangeValue,
|
||||
} from "../components/chartRanges";
|
||||
import type { ChartSeries } from "../components/LineSeriesChart";
|
||||
import type { MetricScale, MetricUnit } from "../lib/metricFormat";
|
||||
import { SectionCard } from "../components/SectionCard";
|
||||
@@ -24,7 +27,11 @@ export function QbittorrentSpeedWidget({
|
||||
// Source returns raw bytes/sec; default to bytes/sec + auto scale (MB/s, …).
|
||||
const unit = (widget.config.unit as MetricUnit) || "bytes_per_sec";
|
||||
const scale = (widget.config.scale as MetricScale) || "auto";
|
||||
const maxRangeSeconds = Number(widget.config.window_seconds) || 1800;
|
||||
const configuredRange = widget.config.window_seconds;
|
||||
const maxRangeSeconds =
|
||||
configuredRange === "all" ? 86_400 : Number(configuredRange) || 1800;
|
||||
const defaultRangeSeconds: ChartRangeValue =
|
||||
configuredRange === "all" ? "all" : maxRangeSeconds;
|
||||
|
||||
return (
|
||||
<SectionCard title={widget.title} description={description}>
|
||||
@@ -41,7 +48,7 @@ export function QbittorrentSpeedWidget({
|
||||
scale={scale}
|
||||
height={220}
|
||||
rangeOptions={chartRangesThrough(maxRangeSeconds)}
|
||||
defaultRangeSeconds={maxRangeSeconds}
|
||||
defaultRangeSeconds={defaultRangeSeconds}
|
||||
/>
|
||||
) : (
|
||||
<Alert>
|
||||
|
||||
Reference in New Issue
Block a user