Feature: qui widget (#7192)
Docker CI / Docker Build & Push (push) Waiting to run
Lint / Linting Checks (push) Waiting to run
Release Drafter / Update Release Draft (push) Waiting to run
Release Drafter / Auto Label PR (push) Waiting to run
Tests / vitest (1) (push) Waiting to run
Tests / vitest (2) (push) Waiting to run
Tests / vitest (3) (push) Waiting to run
Tests / vitest (4) (push) Waiting to run

Co-authored-by: yorah <yoram.harmelin@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com>
This commit is contained in:
Gabriel
2026-09-25 16:46:43 +00:00
committed by GitHub
co-authored by yorah Claude Opus 5.5 shamoon
parent 18a5c4ad73
commit d001910e86
12 changed files with 309 additions and 0 deletions
+1
View File
@@ -120,6 +120,7 @@ You can also find a list of all available service widgets in the sidebar navigat
- [PyLoad](pyload.md)
- [qBittorrent](qbittorrent.md)
- [QNAP](qnap.md)
- [qui](qui.md)
- [Radarr](radarr.md)
- [Readarr](readarr.md)
- [ROMM](romm.md)
+30
View File
@@ -0,0 +1,30 @@
---
title: qui
description: qui Widget Configuration
---
Learn more about [qui](https://github.com/autobrr/qui).
Generate an API key in qui under **Settings → API Keys**.
The widget has two modes:
- **Aggregated (default):** omit `instance` to show combined stats across all qBittorrent instances monitored by qui.
- **Per-instance:** set `instance` to the qui ID (visible in the qui UI) to show just that one.
```yaml
widget:
type: qui
url: http://qui.host.or.ip:7476
key: quiapikeyquiapikeyquiapikey
instance: 1 # optional; omit for aggregated stats across all instances
fields: ["leech", "download", "seed", "upload"] # optional
```
## Fields
Allowed fields: `["leech", "download", "seed", "upload", "total", "errored", "ratio", "freeSpace"]` (maximum of 4).
Default fields: `["leech", "download", "seed", "upload"]`.
Note: `ratio` and `freeSpace` are only available with the 'single instance' version of the widget.
+1
View File
@@ -149,6 +149,7 @@ nav:
- widgets/services/pyload.md
- widgets/services/qbittorrent.md
- widgets/services/qnap.md
- widgets/services/qui.md
- widgets/services/radarr.md
- widgets/services/readarr.md
- widgets/services/romm.md
+10
View File
@@ -256,6 +256,16 @@
"leech": "Leech",
"seed": "Seed"
},
"qui": {
"leech": "Leech",
"download": "Download",
"seed": "Seed",
"upload": "Upload",
"total": "Total",
"errored": "Errored",
"ratio": "Ratio",
"freeSpace": "Free"
},
"qnap": {
"cpuUsage": "CPU Usage",
"memUsage": "MEM Usage",
+6
View File
@@ -433,6 +433,9 @@ export function cleanServiceGroups(groups) {
// proxmoxbackupserver
datastore,
// qui
instance,
// speedtest
bitratePrecision,
@@ -543,6 +546,9 @@ export function cleanServiceGroups(groups) {
if (type === "portainer") {
if (kubernetes) widget.kubernetes = !!JSON.parse(kubernetes);
}
if (type === "qui") {
if (instance !== undefined) widget.instance = instance;
}
if (type === "proxmox") {
if (node) widget.node = node;
}
+2
View File
@@ -403,6 +403,7 @@ describe("utils/config/service-helpers", () => {
{ type: "radarr", enableQueue: "true" },
{ type: "truenas", enablePools: "true", nasType: "scale" },
{ type: "qnap", volume: "vol1" },
{ type: "qui", instance: 2 },
{ type: "dispatcharr", enableActiveStreams: "true" },
{ type: "gamedig", gameToken: "t" },
{ type: "kopia", snapshotHost: "h", snapshotPath: "/p" },
@@ -468,6 +469,7 @@ describe("utils/config/service-helpers", () => {
expect.objectContaining({ namespace: "default", app: "app", podSelector: "app=test" }),
);
expect(widgets.find((w) => w.type === "qnap")).toEqual(expect.objectContaining({ volume: "vol1" }));
expect(widgets.find((w) => w.type === "qui")).toEqual(expect.objectContaining({ instance: 2 }));
expect(widgets.find((w) => w.type === "speedtest")).toEqual(
expect.objectContaining({ bitratePrecision: 3, version: 1 }),
);
+1
View File
@@ -123,6 +123,7 @@ const components = {
pyload: dynamic(() => import("./pyload/component")),
qbittorrent: dynamic(() => import("./qbittorrent/component")),
qnap: dynamic(() => import("./qnap/component")),
qui: dynamic(() => import("./qui/component")),
radarr: dynamic(() => import("./radarr/component")),
readarr: dynamic(() => import("./readarr/component")),
romm: dynamic(() => import("./romm/component")),
+77
View File
@@ -0,0 +1,77 @@
import { useTranslation } from "next-i18next/pages";
import Block from "components/services/widget/block";
import Container from "components/services/widget/container";
import useWidgetAPI from "utils/proxy/use-widget-api";
import withWidgetFields from "utils/widget-fields";
const DEFAULT_FIELDS = ["leech", "download", "seed", "upload"];
export default function Component({ service: configuredService }) {
const { t } = useTranslation();
const service = withWidgetFields(configuredService, DEFAULT_FIELDS);
const { widget } = service;
// A specific instance id selects per-instance stats; otherwise aggregate all instances.
const perInstance = widget.instance != null && widget.instance !== "";
const mapping = perInstance ? "torrents" : "torrentsAll";
const { data: torrentData, error: torrentError } = useWidgetAPI(widget, mapping);
if (torrentError) {
return <Container service={service} error={torrentError} />;
}
if (!torrentData || !torrentData.stats) {
return (
<Container service={service}>
<Block label="qui.leech" />
<Block label="qui.download" />
<Block label="qui.seed" />
<Block label="qui.upload" />
<Block label="qui.total" />
<Block label="qui.errored" />
<Block label="qui.ratio" />
<Block label="qui.freeSpace" />
</Container>
);
}
const { stats } = torrentData;
const status = torrentData.counts?.status;
const serverState = torrentData.serverState;
// counts.status uses qBittorrent's sidebar semantics (complete vs incomplete). Seed/Leech show
// "active / total" — actively transferring over complete/incomplete. Fall back to stats-only.
const completed = status ? status.completed : stats.seeding;
const incomplete = status ? status.all - status.completed : stats.downloading;
const seedValue = `${t("common.number", { value: stats.seeding })} / ${t("common.number", { value: completed })}`;
const leechValue = `${t("common.number", { value: stats.downloading })} / ${t("common.number", { value: incomplete })}`;
return (
<Container service={service}>
<Block label="qui.leech" value={leechValue} />
<Block
label="qui.download"
value={t("common.bibyterate", { value: stats.totalDownloadSpeed, decimals: 1 })}
highlightValue={stats.totalDownloadSpeed}
/>
<Block label="qui.seed" value={seedValue} />
<Block
label="qui.upload"
value={t("common.bibyterate", { value: stats.totalUploadSpeed, decimals: 1 })}
highlightValue={stats.totalUploadSpeed}
/>
<Block label="qui.total" value={t("common.number", { value: status ? status.all : stats.total })} />
<Block label="qui.errored" value={t("common.number", { value: status ? status.errored : stats.error })} />
{serverState && (
<Block label="qui.ratio" value={t("common.number", { value: parseFloat(serverState.global_ratio) })} />
)}
{serverState && (
<Block
label="qui.freeSpace"
value={t("common.bbytes", { value: serverState.free_space_on_disk, maximumFractionDigits: 1 })}
/>
)}
</Container>
);
}
+149
View File
@@ -0,0 +1,149 @@
// @vitest-environment jsdom
import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component";
// Aggregated response (cross-instance) — no serverState, as qui can't merge it.
const aggregatedData = {
stats: { downloading: 1, seeding: 2, totalDownloadSpeed: 100, totalUploadSpeed: 200, total: 10, error: 0 },
counts: { status: { all: 10, completed: 8, errored: 3 } },
};
// Per-instance response — includes serverState (ratio / free space).
const perInstanceData = {
stats: { downloading: 0, seeding: 3, totalDownloadSpeed: 0, totalUploadSpeed: 141939, total: 6477, error: 0 },
counts: { status: { all: 6477, completed: 6477, errored: 0 } },
serverState: { global_ratio: "8.58", free_space_on_disk: 49820224065536 },
};
describe("widgets/qui/component", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("renders four placeholder blocks while loading", () => {
useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });
const { container } = renderWithProviders(<Component service={{ widget: { type: "qui" } }} />, {
settings: { hideErrors: false },
});
expect(container.querySelectorAll(".service-block")).toHaveLength(4);
expect(screen.getByText("qui.leech")).toBeInTheDocument();
expect(screen.getByText("qui.download")).toBeInTheDocument();
expect(screen.getByText("qui.seed")).toBeInTheDocument();
expect(screen.getByText("qui.upload")).toBeInTheDocument();
});
it("shows placeholders for selected optional fields while loading", () => {
useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });
const service = { widget: { type: "qui", instance: 1, fields: ["seed", "ratio"] } };
const { container } = renderWithProviders(<Component service={service} />, { settings: { hideErrors: false } });
// Container filters placeholders by fields, so an optional placeholder must exist to be shown.
expect(container.querySelectorAll(".service-block")).toHaveLength(2);
expect(screen.getByText("qui.seed")).toBeInTheDocument();
expect(screen.getByText("qui.ratio")).toBeInTheDocument();
});
it("renders error UI when the endpoint errors", () => {
useWidgetAPI.mockReturnValue({ data: undefined, error: { message: "nope" } });
renderWithProviders(<Component service={{ widget: { type: "qui" } }} />, { settings: { hideErrors: false } });
expect(screen.getAllByText(/widget\.api_error/i).length).toBeGreaterThan(0);
expect(screen.getByText("nope")).toBeInTheDocument();
});
it("uses the aggregated endpoint and shows active/total for seed and leech", () => {
useWidgetAPI.mockReturnValue({ data: aggregatedData, error: undefined });
const { container } = renderWithProviders(<Component service={{ widget: { type: "qui" } }} />, {
settings: { hideErrors: false },
});
// No instance id -> aggregated (cross-instance) mapping.
expect(useWidgetAPI).toHaveBeenCalledWith(expect.anything(), "torrentsAll");
expect(container.querySelectorAll(".service-block")).toHaveLength(4);
expectBlockValue(container, "qui.leech", "1 / 2"); // active downloading / incomplete (all - completed)
expectBlockValue(container, "qui.download", "100");
expectBlockValue(container, "qui.seed", "2 / 8"); // active seeding / completed
expectBlockValue(container, "qui.upload", "200");
});
it("uses the per-instance endpoint and renders serverState-backed ratio and freeSpace", () => {
useWidgetAPI.mockReturnValue({ data: perInstanceData, error: undefined });
const service = { widget: { type: "qui", instance: 1, fields: ["seed", "upload", "ratio", "freeSpace"] } };
const { container } = renderWithProviders(<Component service={service} />, { settings: { hideErrors: false } });
// Instance id set -> per-instance mapping.
expect(useWidgetAPI).toHaveBeenCalledWith(expect.anything(), "torrents");
expect(container.querySelectorAll(".service-block")).toHaveLength(4);
expectBlockValue(container, "qui.seed", "3 / 6477");
expectBlockValue(container, "qui.upload", "141939");
expectBlockValue(container, "qui.ratio", "8.58");
expectBlockValue(container, "qui.freeSpace", "49820224065536");
});
it("renders the optional total and errored fields when selected", () => {
useWidgetAPI.mockReturnValue({ data: aggregatedData, error: undefined });
const service = { widget: { type: "qui", fields: ["total", "errored"] } };
const { container } = renderWithProviders(<Component service={service} />, { settings: { hideErrors: false } });
expect(container.querySelectorAll(".service-block")).toHaveLength(2);
expectBlockValue(container, "qui.total", "10");
expectBlockValue(container, "qui.errored", "3");
});
it("falls back to stats when counts are missing", () => {
const { counts, ...statsOnly } = aggregatedData;
useWidgetAPI.mockReturnValue({ data: statsOnly, error: undefined });
const service = { widget: { type: "qui", instance: "", fields: ["leech", "seed", "total", "errored"] } };
const { container } = renderWithProviders(<Component service={service} />, { settings: { hideErrors: false } });
expect(useWidgetAPI).toHaveBeenCalledWith(expect.anything(), "torrentsAll");
expectBlockValue(container, "qui.leech", "1 / 1");
expectBlockValue(container, "qui.seed", "2 / 2");
expectBlockValue(container, "qui.total", "10");
expectBlockValue(container, "qui.errored", "0");
});
it("caps configured fields at four blocks", () => {
useWidgetAPI.mockReturnValue({ data: perInstanceData, error: undefined });
const service = {
widget: { type: "qui", instance: 1, fields: ["total", "errored", "ratio", "freeSpace", "leech", "seed"] },
};
const { container } = renderWithProviders(<Component service={service} />, { settings: { hideErrors: false } });
expect(container.querySelectorAll(".service-block")).toHaveLength(4);
expect(screen.queryByText("qui.leech")).toBeNull();
expect(screen.queryByText("qui.seed")).toBeNull();
});
it("omits ratio/freeSpace in aggregated mode where serverState is absent", () => {
useWidgetAPI.mockReturnValue({ data: aggregatedData, error: undefined });
const service = { widget: { type: "qui", fields: ["seed", "ratio", "freeSpace"] } };
const { container } = renderWithProviders(<Component service={service} />, { settings: { hideErrors: false } });
expect(screen.getByText("qui.seed")).toBeInTheDocument();
expect(screen.queryByText("qui.ratio")).toBeNull();
expect(screen.queryByText("qui.freeSpace")).toBeNull();
expect(container.querySelectorAll(".service-block")).toHaveLength(1);
});
});
+19
View File
@@ -0,0 +1,19 @@
import credentialedProxyHandler from "utils/proxy/handlers/credentialed";
const widget = {
api: "{url}/api/{endpoint}",
proxyHandler: credentialedProxyHandler,
mappings: {
torrents: {
endpoint: "instances/{instance}/torrents?limit=1",
validate: ["stats"],
},
torrentsAll: {
endpoint: "torrents/cross-instance?limit=1",
validate: ["stats"],
},
},
};
export default widget;
+11
View File
@@ -0,0 +1,11 @@
import { describe, it } from "vitest";
import { expectWidgetConfigShape } from "test-utils/widget-config";
import widget from "./widget";
describe("qui widget config", () => {
it("exports a valid widget config", () => {
expectWidgetConfigShape(widget);
});
});
+2
View File
@@ -113,6 +113,7 @@ import pulse from "./pulse/widget";
import pyload from "./pyload/widget";
import qbittorrent from "./qbittorrent/widget";
import qnap from "./qnap/widget";
import qui from "./qui/widget";
import radarr from "./radarr/widget";
import readarr from "./readarr/widget";
import romm from "./romm/widget";
@@ -279,6 +280,7 @@ const widgets = {
pyload,
qbittorrent,
qnap,
qui,
radarr,
readarr,
romm,