diff --git a/docs/widgets/services/index.md b/docs/widgets/services/index.md index d99752e0f..270a7492e 100644 --- a/docs/widgets/services/index.md +++ b/docs/widgets/services/index.md @@ -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) diff --git a/docs/widgets/services/qui.md b/docs/widgets/services/qui.md new file mode 100644 index 000000000..9d5de1100 --- /dev/null +++ b/docs/widgets/services/qui.md @@ -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. diff --git a/mkdocs.yml b/mkdocs.yml index 6c38350a2..6edfcebc4 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -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 diff --git a/public/locales/en/common.json b/public/locales/en/common.json index b29a4452a..4363427d7 100644 --- a/public/locales/en/common.json +++ b/public/locales/en/common.json @@ -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", diff --git a/src/utils/config/service-helpers.js b/src/utils/config/service-helpers.js index 20303af7d..98375b765 100644 --- a/src/utils/config/service-helpers.js +++ b/src/utils/config/service-helpers.js @@ -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; } diff --git a/src/utils/config/service-helpers.test.js b/src/utils/config/service-helpers.test.js index 6d6174526..07be96d28 100644 --- a/src/utils/config/service-helpers.test.js +++ b/src/utils/config/service-helpers.test.js @@ -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 }), ); diff --git a/src/widgets/components.js b/src/widgets/components.js index ae8327578..7ae5ff9de 100644 --- a/src/widgets/components.js +++ b/src/widgets/components.js @@ -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")), diff --git a/src/widgets/qui/component.jsx b/src/widgets/qui/component.jsx new file mode 100644 index 000000000..4cb71bc02 --- /dev/null +++ b/src/widgets/qui/component.jsx @@ -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 ; + } + + if (!torrentData || !torrentData.stats) { + return ( + + + + + + + + + + + ); + } + + 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 ( + + + + + + + + {serverState && ( + + )} + {serverState && ( + + )} + + ); +} diff --git a/src/widgets/qui/component.test.jsx b/src/widgets/qui/component.test.jsx new file mode 100644 index 000000000..f54b4a86f --- /dev/null +++ b/src/widgets/qui/component.test.jsx @@ -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(, { + 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(, { 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(, { 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(, { + 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(, { 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(, { 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(, { 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(, { 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(, { 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); + }); +}); diff --git a/src/widgets/qui/widget.js b/src/widgets/qui/widget.js new file mode 100644 index 000000000..31b9f7f6f --- /dev/null +++ b/src/widgets/qui/widget.js @@ -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; diff --git a/src/widgets/qui/widget.test.js b/src/widgets/qui/widget.test.js new file mode 100644 index 000000000..2a947778e --- /dev/null +++ b/src/widgets/qui/widget.test.js @@ -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); + }); +}); diff --git a/src/widgets/widgets.js b/src/widgets/widgets.js index 5716675d0..3691e648f 100644 --- a/src/widgets/widgets.js +++ b/src/widgets/widgets.js @@ -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,