Compare commits

..

2 Commits

Author SHA1 Message Date
shamoon
b48d283dc2 Retry Omada login on HTML response; preserve cookies 2026-03-04 11:34:44 -08:00
shamoon
d313e0a124 Add some debug logging for omada 2026-03-02 09:22:39 -08:00
19 changed files with 629 additions and 643 deletions

View File

@@ -51,7 +51,7 @@ body:
id: troubleshooting
attributes:
label: Troubleshooting
description: Please include output from your [troubleshooting steps](https://gethomepage.dev/troubleshooting/#service-widget-errors), if relevant.
description: Please include output from your [troubleshooting steps](https://gethomepage.dev/more/troubleshooting/#service-widget-errors), if relevant.
validations:
required: true
- type: markdown

View File

@@ -66,7 +66,7 @@ jobs:
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@v6
uses: docker/metadata-action@v5
with:
images: |
${{ env.IMAGE_NAME }}
@@ -115,7 +115,7 @@ jobs:
- name: Log into registry ${{ env.REGISTRY }}
if: github.event_name != 'pull_request'
uses: docker/login-action@v4
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -123,20 +123,20 @@ jobs:
- name: Login to Docker Hub
if: github.event_name != 'pull_request'
uses: docker/login-action@v4
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Setup QEMU
uses: docker/setup-qemu-action@v4.0.0
uses: docker/setup-qemu-action@v3.7.0
- name: Setup Docker buildx
uses: docker/setup-buildx-action@v4
uses: docker/setup-buildx-action@v3
- name: Build and push Docker image
id: build-and-push
uses: docker/build-push-action@v7
uses: docker/build-push-action@v6
with:
context: .
push: ${{ github.event_name != 'pull_request' }}

View File

@@ -1,18 +0,0 @@
name: PR Quality
permissions:
contents: read
issues: read
pull-requests: write
on:
pull_request_target:
types: [opened, reopened]
jobs:
anti-slop:
runs-on: ubuntu-latest
steps:
- uses: peakoss/anti-slop@v0
with:
max-failures: 4

View File

@@ -129,7 +129,7 @@ A progressive web app is an app that can be installed on a device and provide us
More information on PWAs can be found in [MDN Web Docs](https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps).
### App icons
## App icons
You can set custom icons for installable apps. More information about how you can set them can be found in the [MDN Web Docs](https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/Manifest/Reference/icons).
@@ -150,7 +150,7 @@ For icon `src` you can pass either full URL or a local path relative to the `/ap
### Shortcuts
Shortcuts can be used to specify links to tabs, to be preselected when the homepage is opened as an app.
Shortcuts can e used to specify links to tabs, to be preselected when the homepage is opened as an app.
More information about how you can set them can be found in the [MDN Web Docs](https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/Manifest/Reference/shortcuts).
```yaml

View File

@@ -223,33 +223,13 @@ spec:
- name: homepage
image: "ghcr.io/gethomepage/homepage:latest"
imagePullPolicy: Always
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop:
- ALL
runAsNonRoot: true
runAsUser: 1000
runAsGroup: 1000
seccompProfile:
type: RuntimeDefault
env:
- name: MY_POD_IP
valueFrom:
fieldRef:
fieldPath: status.podIP
- name: HOMEPAGE_ALLOWED_HOSTS
value: "$(MY_POD_IP):3000,gethomepage.dev" # See gethomepage.dev/installation/#homepage_allowed_hosts . Value before the comma is required for the k8s probe
value: gethomepage.dev # required, may need port. See gethomepage.dev/installation/#homepage_allowed_hosts
ports:
- name: http
containerPort: 3000
protocol: TCP
livenessProbe:
httpGet:
path: /api/healthcheck
port: http
initialDelaySeconds: 5
periodSeconds: 15
volumeMounts:
- mountPath: /app/config/custom.js
name: homepage-config

View File

@@ -16,7 +16,6 @@ The Glances widget allows you to monitor the resources (CPU, memory, storage, te
cpu: true # optional, enabled by default, disable by setting to false
mem: true # optional, enabled by default, disable by setting to false
cputemp: true # disabled by default
unit: imperial # optional for temp, default is metric
uptime: true # disabled by default
disk: / # disabled by default, use mount point of disk(s) in glances. Can also be a list (see below)
diskUnits: bytes # optional, bytes (default) or bbytes. Only applies to disk
@@ -32,3 +31,5 @@ disk:
- /boot
...
```
_Added in v0.4.18, updated in v0.6.11, v0.6.21_

View File

@@ -1,6 +1,6 @@
{
"name": "homepage",
"version": "1.11.0",
"version": "1.10.1",
"private": true,
"scripts": {
"preinstall": "npx only-allow pnpm",
@@ -22,19 +22,19 @@
"follow-redirects": "^1.15.11",
"gamedig": "^5.3.2",
"i18next": "^25.8.0",
"ical.js": "^2.2.1",
"ical.js": "^2.1.0",
"js-yaml": "^4.1.1",
"json-rpc-2.0": "^1.7.0",
"luxon": "^3.6.1",
"memory-cache": "^0.2.0",
"minecraftstatuspinger": "^1.2.2",
"next": "^15.5.11",
"next-i18next": "^15.4.3",
"next-i18next": "^12.1.0",
"ping": "^0.4.4",
"pretty-bytes": "^7.1.0",
"raw-body": "^3.0.2",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-i18next": "^15.5.3",
"react-icons": "^5.5.0",
"recharts": "^3.1.2",
@@ -63,9 +63,9 @@
"eslint-plugin-prettier": "^5.5.4",
"eslint-plugin-react": "^7.37.4",
"eslint-plugin-react-hooks": "^5.2.0",
"jsdom": "^28.1.0",
"jsdom": "^26.1.0",
"postcss": "^8.5.6",
"prettier": "^3.8.1",
"prettier": "^3.7.3",
"prettier-plugin-organize-imports": "^4.3.0",
"tailwind-scrollbar": "^4.0.2",
"tailwindcss": "^4.1.18",

726
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
// @vitest-environment jsdom
import { act, fireEvent, screen } from "@testing-library/react";
import { fireEvent, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
@@ -188,9 +188,7 @@ describe("components/services/item", () => {
// Still rendered while the close animation runs.
expect(screen.getByTestId("docker-widget")).toBeInTheDocument();
act(() => {
vi.advanceTimersByTime(300);
});
await vi.advanceTimersByTimeAsync(300);
expect(screen.queryByTestId("docker-widget")).not.toBeInTheDocument();
vi.useRealTimers();

View File

@@ -1,6 +1,6 @@
// @vitest-environment jsdom
import { act, screen } from "@testing-library/react";
import { screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
@@ -21,9 +21,7 @@ describe("components/widgets/datetime", () => {
// `render` wraps in `act`, so effects should flush synchronously.
expect(screen.getByText(expected0)).toBeInTheDocument();
act(() => {
vi.advanceTimersByTime(1000);
});
await vi.advanceTimersByTimeAsync(1000);
const expected1 = new Intl.DateTimeFormat("en-US", format).format(new Date());
expect(screen.getByText(expected1)).toBeInTheDocument();

View File

@@ -54,10 +54,7 @@ export default function Component({ service }) {
<Block label="beszel.cpu" value={t("common.percent", { value: system.info.cpu, maximumFractionDigits: 2 })} />
<Block label="beszel.memory" value={t("common.percent", { value: system.info.mp, maximumFractionDigits: 2 })} />
<Block label="beszel.disk" value={t("common.percent", { value: system.info.dp, maximumFractionDigits: 2 })} />
<Block
label="beszel.network"
value={t("common.byterate", { value: system.info.bb, maximumFractionDigits: 2 })}
/>
<Block label="beszel.network" value={t("common.percent", { value: system.info.b, maximumFractionDigits: 2 })} />
</Container>
);
}

View File

@@ -76,35 +76,6 @@ describe("widgets/beszel/component", () => {
expect(screen.queryByText("beszel.updated")).toBeNull();
});
it("renders optional fields", () => {
useWidgetAPI.mockReturnValue({
data: {
totalItems: 1,
items: [
{
id: "sys1",
name: "MySystem",
status: "up",
updated: 123,
info: { cpu: 10, mp: 20, dp: 30, b: 40, bb: 14.5 },
},
],
},
error: undefined,
});
const service = {
widget: { type: "beszel", systemId: "sys1", fields: ["name", "disk", "network"] },
};
const { container } = renderWithProviders(<Component service={service} />, { settings: { hideErrors: false } });
expect(service.widget.fields).toEqual(["name", "disk", "network"]);
expect(container.querySelectorAll(".service-block")).toHaveLength(3);
expectBlockValue(container, "beszel.name", "MySystem");
expectBlockValue(container, "beszel.disk", 30);
expectBlockValue(container, "beszel.network", 14.5);
});
it("renders error when systemId is not found", () => {
useWidgetAPI.mockReturnValue({
data: { totalItems: 1, items: [{ id: "sys1", name: "MySystem", status: "up", info: {} }] },

View File

@@ -25,25 +25,13 @@ async function login(widget, service) {
}),
});
let dataParsed;
try {
dataParsed = JSON.parse(data);
} catch {
logger.error("Failed to parse Crowdsec login response, status: %d", status);
cache.del(`${sessionTokenCacheKey}.${service}`);
return null;
}
const dataParsed = JSON.parse(data);
if (status !== 200 || !dataParsed.token) {
if (!(status === 200) || !dataParsed.token) {
logger.error("Failed to login to Crowdsec API, status: %d", status);
cache.del(`${sessionTokenCacheKey}.${service}`);
return null;
}
const ttl = Math.max(new Date(dataParsed.expire) - new Date(), 1);
cache.put(`${sessionTokenCacheKey}.${service}`, dataParsed.token, ttl);
return dataParsed.token;
cache.put(`${sessionTokenCacheKey}.${service}`, dataParsed.token, new Date(dataParsed.expire) - new Date());
}
export default async function crowdsecProxyHandler(req, res) {
@@ -60,10 +48,11 @@ export default async function crowdsecProxyHandler(req, res) {
return res.status(400).json({ error: "Invalid widget configuration" });
}
let token = cache.get(`${sessionTokenCacheKey}.${service}`);
if (!token) {
token = await login(widget, service);
if (!cache.get(`${sessionTokenCacheKey}.${service}`)) {
await login(widget, service);
}
const token = cache.get(`${sessionTokenCacheKey}.${service}`);
if (!token) {
return res.status(500).json({ error: "Failed to authenticate with Crowdsec" });
}
@@ -82,20 +71,7 @@ export default async function crowdsecProxyHandler(req, res) {
logger.debug("Calling Crowdsec API endpoint: %s", endpoint);
let [status, , data] = await httpProxy(url, params);
if (status === 401) {
logger.debug("Crowdsec API returned 401, refreshing token and retrying request");
cache.del(`${sessionTokenCacheKey}.${service}`);
const refreshedToken = await login(widget, service);
if (!refreshedToken) {
return res.status(500).json({ error: "Failed to authenticate with Crowdsec" });
}
params.headers.Authorization = `Bearer ${refreshedToken}`;
[status, , data] = await httpProxy(url, params);
}
const [status, , data] = await httpProxy(url, params);
if (status !== 200) {
logger.error("Error calling Crowdsec API: %d. Data: %s", status, data);

View File

@@ -89,76 +89,4 @@ describe("widgets/crowdsec/proxy", () => {
expect(res.statusCode).toBe(500);
expect(res.body).toEqual({ error: "Failed to authenticate with Crowdsec" });
});
it("re-authenticates and retries once when API returns 401", async () => {
getServiceWidget.mockResolvedValue({
type: "crowdsec",
url: "http://cs",
username: "machine",
password: "pw",
});
httpProxy
.mockResolvedValueOnce([
200,
"application/json",
JSON.stringify({ token: "tok-old", expire: new Date(Date.now() + 60_000).toISOString() }),
])
.mockResolvedValueOnce([401, "application/json", Buffer.from("bad token")])
.mockResolvedValueOnce([
200,
"application/json",
JSON.stringify({ token: "tok-new", expire: new Date(Date.now() + 60_000).toISOString() }),
])
.mockResolvedValueOnce([200, "application/json", Buffer.from("data")]);
const req = { query: { group: "g", service: "svc", endpoint: "alerts", index: "0" } };
const res = createMockRes();
await crowdsecProxyHandler(req, res);
expect(httpProxy).toHaveBeenCalledTimes(4);
expect(httpProxy.mock.calls[3][1].headers.Authorization).toBe("Bearer tok-new");
expect(res.statusCode).toBe(200);
expect(res.body).toEqual(Buffer.from("data"));
});
it("returns 500 when 401 refresh fails to get a new token", async () => {
getServiceWidget.mockResolvedValue({
type: "crowdsec",
url: "http://cs",
username: "machine",
password: "pw",
});
httpProxy
.mockResolvedValueOnce([
200,
"application/json",
JSON.stringify({ token: "tok-old", expire: new Date(Date.now() + 60_000).toISOString() }),
])
.mockResolvedValueOnce([401, "application/json", Buffer.from("bad token")])
.mockResolvedValueOnce([500, "application/json", JSON.stringify({ error: "no token" })]);
const req = { query: { group: "g", service: "svc", endpoint: "alerts", index: "0" } };
const res = createMockRes();
await crowdsecProxyHandler(req, res);
expect(res.statusCode).toBe(500);
expect(res.body).toEqual({ error: "Failed to authenticate with Crowdsec" });
});
it("returns 500 when login response is not JSON", async () => {
getServiceWidget.mockResolvedValue({ type: "crowdsec", url: "http://cs", username: "machine", password: "pw" });
httpProxy.mockResolvedValueOnce([200, "text/plain", "not-json"]);
const req = { query: { group: "g", service: "svc", endpoint: "alerts", index: "0" } };
const res = createMockRes();
await crowdsecProxyHandler(req, res);
expect(res.statusCode).toBe(500);
expect(res.body).toEqual({ error: "Failed to authenticate with Crowdsec" });
});
});

View File

@@ -6,6 +6,40 @@ const proxyName = "omadaProxyHandler";
const logger = createLogger(proxyName);
function parseOmadaJson(data, { step, status, contentType, url }) {
const body = Buffer.isBuffer(data) ? data.toString() : String(data ?? "");
try {
return JSON.parse(body);
} catch (error) {
logger.debug(
"Failed parsing Omada %s response as JSON (HTTP %d, content-type: %s, url: %s). Body: %s",
step,
status,
contentType ?? "unknown",
url,
body,
);
throw error;
}
}
function isLikelyHtmlResponse(contentType, data) {
const body = Buffer.isBuffer(data) ? data.toString() : String(data ?? "");
return contentType?.includes("text/html") || body.startsWith("<!DOCTYPE") || body.startsWith("<html");
}
function extractCookieHeader(responseHeaders) {
const setCookieHeader = responseHeaders?.["set-cookie"];
if (!setCookieHeader) return undefined;
if (Array.isArray(setCookieHeader)) {
return setCookieHeader.map((cookie) => cookie.split(";")[0]).join("; ");
}
return String(setCookieHeader).split(";")[0];
}
async function login(loginUrl, username, password, controllerVersionMajor) {
const params = {
username,
@@ -20,15 +54,17 @@ async function login(loginUrl, username, password, controllerVersionMajor) {
};
}
const [status, contentType, data] = await httpProxy(loginUrl, {
const [status, contentType, data, responseHeaders] = await httpProxy(loginUrl, {
method: "POST",
cookieHeader: "X-Bypass-Cookie",
body: JSON.stringify(params),
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
});
return [status, JSON.parse(data.toString())];
return [status, contentType, data, extractCookieHeader(responseHeaders)];
}
export default async function omadaProxyHandler(req, res) {
@@ -86,12 +122,18 @@ export default async function omadaProxyHandler(req, res) {
break;
}
const [loginStatus, loginResponseData] = await login(
const [loginStatus, loginContentType, loginData, loginCookieHeader] = await login(
loginUrl,
widget.username,
widget.password,
controllerVersionMajor,
);
const loginResponseData = parseOmadaJson(loginData, {
step: "login",
status: loginStatus,
contentType: loginContentType,
url: loginUrl,
});
if (loginStatus !== 200 || loginResponseData.errorCode > 0) {
return res
@@ -100,11 +142,13 @@ export default async function omadaProxyHandler(req, res) {
}
const { token } = loginResponseData.result;
let omadaCookieHeader = loginCookieHeader;
let sitesUrl;
let body = {};
let params = { token };
let headers = { "Csrf-Token": token };
if (omadaCookieHeader) headers.Cookie = omadaCookieHeader;
let method = "GET";
switch (controllerVersionMajor) {
@@ -134,9 +178,72 @@ export default async function omadaProxyHandler(req, res) {
params,
body: JSON.stringify(body),
headers,
cookieHeader: "X-Bypass-Cookie",
});
const sitesResponseData = JSON.parse(data);
let sitesResponseData;
try {
sitesResponseData = parseOmadaJson(data, {
step: "sites list",
status,
contentType,
url: sitesUrl,
});
} catch (parseError) {
if (!isLikelyHtmlResponse(contentType, data)) {
throw parseError;
}
logger.debug("Received HTML response for Omada sites list; retrying with a fresh login.");
const [retryLoginStatus, retryLoginContentType, retryLoginData, retryLoginCookieHeader] = await login(
loginUrl,
widget.username,
widget.password,
controllerVersionMajor,
);
const retryLoginResponseData = parseOmadaJson(retryLoginData, {
step: "login (retry)",
status: retryLoginStatus,
contentType: retryLoginContentType,
url: loginUrl,
});
if (retryLoginStatus !== 200 || retryLoginResponseData.errorCode > 0) {
return res.status(retryLoginStatus).json({
error: {
message: "Error re-authenticating to Omada controller",
url: loginUrl,
data: retryLoginResponseData,
},
});
}
const retryToken = retryLoginResponseData.result?.token;
omadaCookieHeader = retryLoginCookieHeader;
const retrySitesUrlObj = new URL(sitesUrl);
retrySitesUrlObj.searchParams.set("token", retryToken);
const retrySitesUrl = retrySitesUrlObj.toString();
[status, contentType, data] = await httpProxy(retrySitesUrl, {
method,
params: { token: retryToken },
body: JSON.stringify(body),
headers: {
...headers,
"Csrf-Token": retryToken,
...(omadaCookieHeader ? { Cookie: omadaCookieHeader } : {}),
},
cookieHeader: "X-Bypass-Cookie",
});
sitesResponseData = parseOmadaJson(data, {
step: "sites list (retry)",
status,
contentType,
url: retrySitesUrl,
});
}
if (status !== 200 || sitesResponseData.errorCode > 0) {
logger.debug(`HTTP ${status} getting sites list: ${sitesResponseData.msg}`);
@@ -174,6 +281,7 @@ export default async function omadaProxyHandler(req, res) {
},
};
headers = { "Content-Type": "application/json" };
if (omadaCookieHeader) headers.Cookie = omadaCookieHeader;
params = { token };
[status, contentType, data] = await httpProxy(switchUrl, {
@@ -181,9 +289,15 @@ export default async function omadaProxyHandler(req, res) {
params,
body: JSON.stringify(body),
headers,
cookieHeader: "X-Bypass-Cookie",
});
const switchResponseData = JSON.parse(data);
const switchResponseData = parseOmadaJson(data, {
step: "switch site",
status,
contentType,
url: switchUrl,
});
if (status !== 200 || switchResponseData.errorCode > 0) {
logger.error(`HTTP ${status} getting sites list: ${data}`);
return res.status(status).json({ error: { message: "Error switching site", url: switchUrl, data } });
@@ -197,9 +311,15 @@ export default async function omadaProxyHandler(req, res) {
method: "getGlobalStat",
}),
headers,
cookieHeader: "X-Bypass-Cookie",
});
siteResponseData = JSON.parse(data);
siteResponseData = parseOmadaJson(data, {
step: "global stats",
status,
contentType,
url: statsUrl,
});
if (status !== 200 || siteResponseData.errorCode > 0) {
return res.status(status).json({ error: { message: "Error getting stats", url: statsUrl, data } });
@@ -218,10 +338,17 @@ export default async function omadaProxyHandler(req, res) {
[status, contentType, data] = await httpProxy(siteStatsUrl, {
headers: {
"Csrf-Token": token,
...(omadaCookieHeader ? { Cookie: omadaCookieHeader } : {}),
},
cookieHeader: "X-Bypass-Cookie",
});
siteResponseData = JSON.parse(data);
siteResponseData = parseOmadaJson(data, {
step: "overview stats",
status,
contentType,
url: siteStatsUrl,
});
if (status !== 200 || siteResponseData.errorCode > 0) {
logger.debug(`HTTP ${status} getting stats for site ${widget.site} with message ${siteResponseData.msg}`);
@@ -242,9 +369,16 @@ export default async function omadaProxyHandler(req, res) {
[status, contentType, data] = await httpProxy(alertUrl, {
headers: {
"Csrf-Token": token,
...(omadaCookieHeader ? { Cookie: omadaCookieHeader } : {}),
},
cookieHeader: "X-Bypass-Cookie",
});
const alertResponseData = parseOmadaJson(data, {
step: "alerts",
status,
contentType,
url: alertUrl,
});
const alertResponseData = JSON.parse(data);
activeUser = siteResponseData.result.totalClientNum;
connectedAp = siteResponseData.result.connectedApNum;

View File

@@ -324,4 +324,73 @@ describe("widgets/omada/proxy", () => {
},
});
});
it("retries login when sites list returns HTML", async () => {
getServiceWidget.mockResolvedValue({ url: "http://omada", username: "u", password: "p", site: "Default" });
httpProxy
.mockResolvedValueOnce([
200,
"application/json",
JSON.stringify({ result: { omadacId: "cid", controllerVer: "5.0.0" } }),
])
// initial login
.mockResolvedValueOnce([
200,
"application/json",
Buffer.from(JSON.stringify({ errorCode: 0, result: { token: "t1" } })),
])
// sites list unexpectedly returns HTML
.mockResolvedValueOnce([200, "text/html;charset=utf-8", "<!DOCTYPE html><html><body>login</body></html>"])
// retry login
.mockResolvedValueOnce([
200,
"application/json",
Buffer.from(JSON.stringify({ errorCode: 0, result: { token: "t2" } })),
])
// retry sites list works
.mockResolvedValueOnce([
200,
"application/json",
JSON.stringify({ errorCode: 0, result: { data: [{ name: "Default", id: "siteid" }] } }),
])
// overview works
.mockResolvedValueOnce([
200,
"application/json",
JSON.stringify({
errorCode: 0,
result: {
totalClientNum: 11,
connectedApNum: 3,
connectedGatewayNum: 1,
connectedSwitchNum: 2,
},
}),
])
// alerts works
.mockResolvedValueOnce([200, "application/json", JSON.stringify({ errorCode: 0, result: { alertNum: 5 } })]);
const req = { query: { group: "g", service: "svc", index: "0" } };
const res = createMockRes();
await omadaProxyHandler(req, res);
expect(logger.debug).toHaveBeenCalledWith(
"Received HTML response for Omada sites list; retrying with a fresh login.",
);
expect(httpProxy.mock.calls[1][1].cookieHeader).toBe("X-Bypass-Cookie");
expect(httpProxy.mock.calls[2][1].cookieHeader).toBe("X-Bypass-Cookie");
expect(httpProxy.mock.calls[3][1].cookieHeader).toBe("X-Bypass-Cookie");
expect(httpProxy.mock.calls[4][1].cookieHeader).toBe("X-Bypass-Cookie");
expect(res.body).toBe(
JSON.stringify({
connectedAp: 3,
activeUser: 11,
alerts: 5,
connectedGateways: 1,
connectedSwitches: 2,
}),
);
});
});

View File

@@ -10,28 +10,13 @@ export default function Component({ service }) {
const { t } = useTranslation();
const { widget } = service;
const { data: transferData, error: transferError } = useWidgetAPI(widget, "transfer");
const { data: totalCountData, error: totalCountError } = useWidgetAPI(widget, "torrentCount");
const { data: completedCountData, error: completedCountError } = useWidgetAPI(widget, "torrentCount", {
filter: "completed",
});
const { data: leechTorrentData, error: leechTorrentError } = useWidgetAPI(
widget,
widget?.enableLeechProgress ? "torrents" : "",
widget?.enableLeechProgress ? { filter: "downloading" } : undefined,
);
const { data: torrentData, error: torrentError } = useWidgetAPI(widget, "torrents");
const apiError = transferError || totalCountError || completedCountError || leechTorrentError;
if (apiError) {
return <Container service={service} error={apiError} />;
if (torrentError) {
return <Container service={service} error={torrentError} />;
}
if (
!transferData ||
totalCountData === undefined ||
completedCountData === undefined ||
(widget?.enableLeechProgress && !leechTorrentData)
) {
if (!torrentData) {
return (
<Container service={service}>
<Block label="qbittorrent.leech" />
@@ -42,15 +27,24 @@ export default function Component({ service }) {
);
}
const rateDl = Number(transferData?.dl_info_speed ?? 0);
const rateUl = Number(transferData?.up_info_speed ?? 0);
const totalCount = Number(totalCountData?.all ?? totalCountData?.count ?? totalCountData ?? 0);
const completedCount = Number(
completedCountData?.completed ?? completedCountData?.count ?? completedCountData?.all ?? completedCountData ?? 0,
);
const leech = Math.max(0, totalCount - completedCount);
let rateDl = 0;
let rateUl = 0;
let completed = 0;
const leechTorrents = [];
const leechTorrents = Array.isArray(leechTorrentData) ? [...leechTorrentData] : [];
for (let i = 0; i < torrentData.length; i += 1) {
const torrent = torrentData[i];
rateDl += torrent.dlspeed;
rateUl += torrent.upspeed;
if (torrent.progress === 1) {
completed += 1;
}
if (torrent.state.includes("DL") || torrent.state === "downloading") {
leechTorrents.push(torrent);
}
}
const leech = torrentData.length - completed;
const statePriority = [
"downloading",
"forcedDL",
@@ -61,6 +55,7 @@ export default function Component({ service }) {
"queuedDL",
"pausedDL",
];
leechTorrents.sort((firstTorrent, secondTorrent) => {
const firstStateIndex = statePriority.indexOf(firstTorrent.state);
const secondStateIndex = statePriority.indexOf(secondTorrent.state);
@@ -75,7 +70,7 @@ export default function Component({ service }) {
<Container service={service}>
<Block label="qbittorrent.leech" value={t("common.number", { value: leech })} />
<Block label="qbittorrent.download" value={t("common.bibyterate", { value: rateDl, decimals: 1 })} />
<Block label="qbittorrent.seed" value={t("common.number", { value: completedCount })} />
<Block label="qbittorrent.seed" value={t("common.number", { value: completed })} />
<Block label="qbittorrent.upload" value={t("common.bibyterate", { value: rateUl, decimals: 1 })} />
</Container>
{widget?.enableLeechProgress &&

View File

@@ -34,38 +34,19 @@ describe("widgets/qbittorrent/component", () => {
expect(screen.getByText("qbittorrent.upload")).toBeInTheDocument();
});
it("uses lightweight endpoints for counts/rates and filtered torrents for leech progress", () => {
useWidgetAPI.mockImplementation((_widget, endpoint, query) => {
if (endpoint === "transfer") {
return { data: { dl_info_speed: 15, up_info_speed: 3 }, error: undefined };
}
if (endpoint === "torrentCount" && !query) {
return { data: 2, error: undefined };
}
if (endpoint === "torrentCount" && query?.filter === "completed") {
return { data: 1, error: undefined };
}
if (endpoint === "torrents" && query?.filter === "downloading") {
return {
data: [
{
name: "B",
progress: 0.5,
state: "downloading",
eta: 60,
size: 100,
amount_left: 50,
},
],
error: undefined,
};
}
return { data: undefined, error: undefined };
it("computes leech/seed counts and upload/download rates, and can render leech progress entries", () => {
useWidgetAPI.mockReturnValue({
data: [
{ name: "A", dlspeed: 10, upspeed: 1, progress: 1, state: "uploading" },
{ name: "B", dlspeed: 5, upspeed: 2, progress: 0.5, state: "downloading", eta: 60, size: 100, amount_left: 50 },
],
error: undefined,
});
const service = { widget: { type: "qbittorrent", enableLeechProgress: true } };
const { container } = renderWithProviders(<Component service={service} />, { settings: { hideErrors: false } });
// total=2, completed=1 => leech=1
expectBlockValue(container, "qbittorrent.leech", 1);
expectBlockValue(container, "qbittorrent.seed", 1);
expectBlockValue(container, "qbittorrent.download", 15);

View File

@@ -4,16 +4,8 @@ const widget = {
proxyHandler: qbittorrentProxyHandler,
mappings: {
transfer: {
endpoint: "transfer/info",
},
torrentCount: {
endpoint: "torrents/count",
optionalParams: ["filter"],
},
torrents: {
endpoint: "torrents/info",
optionalParams: ["filter"],
},
},
};