mirror of
https://github.com/gethomepage/homepage.git
synced 2026-09-25 13:31:20 -07:00
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
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:
co-authored by
yorah
Claude Opus 5.5
shamoon
parent
18a5c4ad73
commit
d001910e86
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 }),
|
||||
);
|
||||
|
||||
@@ -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")),
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user