feat(charting): unify configurable time windows

This commit is contained in:
Developer
2026-07-15 15:21:57 +00:00
parent 3871f24724
commit d76ea49777
23 changed files with 1398 additions and 1146 deletions
+4 -2
View File
@@ -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) },
);
}
+22 -12
View File
@@ -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",
});
});
});
+59 -26
View File
@@ -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] : [],
);
}
+7 -3
View File
@@ -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",
+15 -3
View File
@@ -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
View File
@@ -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>