mirror of
https://github.com/gethomepage/homepage.git
synced 2026-04-04 01:01:22 -07:00
Compare commits
2 Commits
v1.11.0
...
feature/om
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b48d283dc2 | ||
|
|
d313e0a124 |
2
.github/DISCUSSION_TEMPLATE/support.yml
vendored
2
.github/DISCUSSION_TEMPLATE/support.yml
vendored
@@ -51,7 +51,7 @@ body:
|
|||||||
id: troubleshooting
|
id: troubleshooting
|
||||||
attributes:
|
attributes:
|
||||||
label: Troubleshooting
|
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:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
- type: markdown
|
- type: markdown
|
||||||
|
|||||||
12
.github/workflows/docker-publish.yml
vendored
12
.github/workflows/docker-publish.yml
vendored
@@ -66,7 +66,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Extract Docker metadata
|
- name: Extract Docker metadata
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@v6
|
uses: docker/metadata-action@v5
|
||||||
with:
|
with:
|
||||||
images: |
|
images: |
|
||||||
${{ env.IMAGE_NAME }}
|
${{ env.IMAGE_NAME }}
|
||||||
@@ -115,7 +115,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Log into registry ${{ env.REGISTRY }}
|
- name: Log into registry ${{ env.REGISTRY }}
|
||||||
if: github.event_name != 'pull_request'
|
if: github.event_name != 'pull_request'
|
||||||
uses: docker/login-action@v4
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
@@ -123,20 +123,20 @@ jobs:
|
|||||||
|
|
||||||
- name: Login to Docker Hub
|
- name: Login to Docker Hub
|
||||||
if: github.event_name != 'pull_request'
|
if: github.event_name != 'pull_request'
|
||||||
uses: docker/login-action@v4
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Setup QEMU
|
- name: Setup QEMU
|
||||||
uses: docker/setup-qemu-action@v4.0.0
|
uses: docker/setup-qemu-action@v3.7.0
|
||||||
|
|
||||||
- name: Setup Docker buildx
|
- name: Setup Docker buildx
|
||||||
uses: docker/setup-buildx-action@v4
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
- name: Build and push Docker image
|
- name: Build and push Docker image
|
||||||
id: build-and-push
|
id: build-and-push
|
||||||
uses: docker/build-push-action@v7
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
push: ${{ github.event_name != 'pull_request' }}
|
push: ${{ github.event_name != 'pull_request' }}
|
||||||
|
|||||||
18
.github/workflows/pr-quality.yml
vendored
18
.github/workflows/pr-quality.yml
vendored
@@ -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
|
|
||||||
@@ -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).
|
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).
|
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
|
||||||
|
|
||||||
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).
|
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
|
```yaml
|
||||||
|
|||||||
@@ -223,33 +223,13 @@ spec:
|
|||||||
- name: homepage
|
- name: homepage
|
||||||
image: "ghcr.io/gethomepage/homepage:latest"
|
image: "ghcr.io/gethomepage/homepage:latest"
|
||||||
imagePullPolicy: Always
|
imagePullPolicy: Always
|
||||||
securityContext:
|
|
||||||
allowPrivilegeEscalation: false
|
|
||||||
capabilities:
|
|
||||||
drop:
|
|
||||||
- ALL
|
|
||||||
runAsNonRoot: true
|
|
||||||
runAsUser: 1000
|
|
||||||
runAsGroup: 1000
|
|
||||||
seccompProfile:
|
|
||||||
type: RuntimeDefault
|
|
||||||
env:
|
env:
|
||||||
- name: MY_POD_IP
|
|
||||||
valueFrom:
|
|
||||||
fieldRef:
|
|
||||||
fieldPath: status.podIP
|
|
||||||
- name: HOMEPAGE_ALLOWED_HOSTS
|
- 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:
|
ports:
|
||||||
- name: http
|
- name: http
|
||||||
containerPort: 3000
|
containerPort: 3000
|
||||||
protocol: TCP
|
protocol: TCP
|
||||||
livenessProbe:
|
|
||||||
httpGet:
|
|
||||||
path: /api/healthcheck
|
|
||||||
port: http
|
|
||||||
initialDelaySeconds: 5
|
|
||||||
periodSeconds: 15
|
|
||||||
volumeMounts:
|
volumeMounts:
|
||||||
- mountPath: /app/config/custom.js
|
- mountPath: /app/config/custom.js
|
||||||
name: homepage-config
|
name: homepage-config
|
||||||
|
|||||||
@@ -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
|
cpu: true # optional, enabled by default, disable by setting to false
|
||||||
mem: 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
|
cputemp: true # disabled by default
|
||||||
unit: imperial # optional for temp, default is metric
|
|
||||||
uptime: true # disabled by default
|
uptime: true # disabled by default
|
||||||
disk: / # disabled by default, use mount point of disk(s) in glances. Can also be a list (see below)
|
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
|
diskUnits: bytes # optional, bytes (default) or bbytes. Only applies to disk
|
||||||
@@ -32,3 +31,5 @@ disk:
|
|||||||
- /boot
|
- /boot
|
||||||
...
|
...
|
||||||
```
|
```
|
||||||
|
|
||||||
|
_Added in v0.4.18, updated in v0.6.11, v0.6.21_
|
||||||
|
|||||||
14
package.json
14
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "homepage",
|
"name": "homepage",
|
||||||
"version": "1.11.0",
|
"version": "1.10.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"preinstall": "npx only-allow pnpm",
|
"preinstall": "npx only-allow pnpm",
|
||||||
@@ -22,19 +22,19 @@
|
|||||||
"follow-redirects": "^1.15.11",
|
"follow-redirects": "^1.15.11",
|
||||||
"gamedig": "^5.3.2",
|
"gamedig": "^5.3.2",
|
||||||
"i18next": "^25.8.0",
|
"i18next": "^25.8.0",
|
||||||
"ical.js": "^2.2.1",
|
"ical.js": "^2.1.0",
|
||||||
"js-yaml": "^4.1.1",
|
"js-yaml": "^4.1.1",
|
||||||
"json-rpc-2.0": "^1.7.0",
|
"json-rpc-2.0": "^1.7.0",
|
||||||
"luxon": "^3.6.1",
|
"luxon": "^3.6.1",
|
||||||
"memory-cache": "^0.2.0",
|
"memory-cache": "^0.2.0",
|
||||||
"minecraftstatuspinger": "^1.2.2",
|
"minecraftstatuspinger": "^1.2.2",
|
||||||
"next": "^15.5.11",
|
"next": "^15.5.11",
|
||||||
"next-i18next": "^15.4.3",
|
"next-i18next": "^12.1.0",
|
||||||
"ping": "^0.4.4",
|
"ping": "^0.4.4",
|
||||||
"pretty-bytes": "^7.1.0",
|
"pretty-bytes": "^7.1.0",
|
||||||
"raw-body": "^3.0.2",
|
"raw-body": "^3.0.2",
|
||||||
"react": "^19.2.4",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^18.3.1",
|
||||||
"react-i18next": "^15.5.3",
|
"react-i18next": "^15.5.3",
|
||||||
"react-icons": "^5.5.0",
|
"react-icons": "^5.5.0",
|
||||||
"recharts": "^3.1.2",
|
"recharts": "^3.1.2",
|
||||||
@@ -63,9 +63,9 @@
|
|||||||
"eslint-plugin-prettier": "^5.5.4",
|
"eslint-plugin-prettier": "^5.5.4",
|
||||||
"eslint-plugin-react": "^7.37.4",
|
"eslint-plugin-react": "^7.37.4",
|
||||||
"eslint-plugin-react-hooks": "^5.2.0",
|
"eslint-plugin-react-hooks": "^5.2.0",
|
||||||
"jsdom": "^28.1.0",
|
"jsdom": "^26.1.0",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"prettier": "^3.8.1",
|
"prettier": "^3.7.3",
|
||||||
"prettier-plugin-organize-imports": "^4.3.0",
|
"prettier-plugin-organize-imports": "^4.3.0",
|
||||||
"tailwind-scrollbar": "^4.0.2",
|
"tailwind-scrollbar": "^4.0.2",
|
||||||
"tailwindcss": "^4.1.18",
|
"tailwindcss": "^4.1.18",
|
||||||
|
|||||||
726
pnpm-lock.yaml
generated
726
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
// @vitest-environment jsdom
|
// @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 { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import { renderWithProviders } from "test-utils/render-with-providers";
|
import { renderWithProviders } from "test-utils/render-with-providers";
|
||||||
@@ -188,9 +188,7 @@ describe("components/services/item", () => {
|
|||||||
// Still rendered while the close animation runs.
|
// Still rendered while the close animation runs.
|
||||||
expect(screen.getByTestId("docker-widget")).toBeInTheDocument();
|
expect(screen.getByTestId("docker-widget")).toBeInTheDocument();
|
||||||
|
|
||||||
act(() => {
|
await vi.advanceTimersByTimeAsync(300);
|
||||||
vi.advanceTimersByTime(300);
|
|
||||||
});
|
|
||||||
expect(screen.queryByTestId("docker-widget")).not.toBeInTheDocument();
|
expect(screen.queryByTestId("docker-widget")).not.toBeInTheDocument();
|
||||||
|
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// @vitest-environment jsdom
|
// @vitest-environment jsdom
|
||||||
|
|
||||||
import { act, screen } from "@testing-library/react";
|
import { screen } from "@testing-library/react";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import { renderWithProviders } from "test-utils/render-with-providers";
|
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.
|
// `render` wraps in `act`, so effects should flush synchronously.
|
||||||
expect(screen.getByText(expected0)).toBeInTheDocument();
|
expect(screen.getByText(expected0)).toBeInTheDocument();
|
||||||
|
|
||||||
act(() => {
|
await vi.advanceTimersByTimeAsync(1000);
|
||||||
vi.advanceTimersByTime(1000);
|
|
||||||
});
|
|
||||||
const expected1 = new Intl.DateTimeFormat("en-US", format).format(new Date());
|
const expected1 = new Intl.DateTimeFormat("en-US", format).format(new Date());
|
||||||
|
|
||||||
expect(screen.getByText(expected1)).toBeInTheDocument();
|
expect(screen.getByText(expected1)).toBeInTheDocument();
|
||||||
|
|||||||
@@ -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.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.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.disk" value={t("common.percent", { value: system.info.dp, maximumFractionDigits: 2 })} />
|
||||||
<Block
|
<Block label="beszel.network" value={t("common.percent", { value: system.info.b, maximumFractionDigits: 2 })} />
|
||||||
label="beszel.network"
|
|
||||||
value={t("common.byterate", { value: system.info.bb, maximumFractionDigits: 2 })}
|
|
||||||
/>
|
|
||||||
</Container>
|
</Container>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,35 +76,6 @@ describe("widgets/beszel/component", () => {
|
|||||||
expect(screen.queryByText("beszel.updated")).toBeNull();
|
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", () => {
|
it("renders error when systemId is not found", () => {
|
||||||
useWidgetAPI.mockReturnValue({
|
useWidgetAPI.mockReturnValue({
|
||||||
data: { totalItems: 1, items: [{ id: "sys1", name: "MySystem", status: "up", info: {} }] },
|
data: { totalItems: 1, items: [{ id: "sys1", name: "MySystem", status: "up", info: {} }] },
|
||||||
|
|||||||
@@ -25,25 +25,13 @@ async function login(widget, service) {
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
let dataParsed;
|
const dataParsed = JSON.parse(data);
|
||||||
try {
|
|
||||||
dataParsed = JSON.parse(data);
|
|
||||||
} catch {
|
|
||||||
logger.error("Failed to parse Crowdsec login response, status: %d", status);
|
|
||||||
cache.del(`${sessionTokenCacheKey}.${service}`);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (status !== 200 || !dataParsed.token) {
|
if (!(status === 200) || !dataParsed.token) {
|
||||||
logger.error("Failed to login to Crowdsec API, status: %d", status);
|
logger.error("Failed to login to Crowdsec API, status: %d", status);
|
||||||
cache.del(`${sessionTokenCacheKey}.${service}`);
|
cache.del(`${sessionTokenCacheKey}.${service}`);
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
cache.put(`${sessionTokenCacheKey}.${service}`, dataParsed.token, new Date(dataParsed.expire) - new Date());
|
||||||
const ttl = Math.max(new Date(dataParsed.expire) - new Date(), 1);
|
|
||||||
cache.put(`${sessionTokenCacheKey}.${service}`, dataParsed.token, ttl);
|
|
||||||
|
|
||||||
return dataParsed.token;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function crowdsecProxyHandler(req, res) {
|
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" });
|
return res.status(400).json({ error: "Invalid widget configuration" });
|
||||||
}
|
}
|
||||||
|
|
||||||
let token = cache.get(`${sessionTokenCacheKey}.${service}`);
|
if (!cache.get(`${sessionTokenCacheKey}.${service}`)) {
|
||||||
if (!token) {
|
await login(widget, service);
|
||||||
token = await login(widget, service);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const token = cache.get(`${sessionTokenCacheKey}.${service}`);
|
||||||
if (!token) {
|
if (!token) {
|
||||||
return res.status(500).json({ error: "Failed to authenticate with Crowdsec" });
|
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);
|
logger.debug("Calling Crowdsec API endpoint: %s", endpoint);
|
||||||
|
|
||||||
let [status, , data] = await httpProxy(url, params);
|
const [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);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (status !== 200) {
|
if (status !== 200) {
|
||||||
logger.error("Error calling Crowdsec API: %d. Data: %s", status, data);
|
logger.error("Error calling Crowdsec API: %d. Data: %s", status, data);
|
||||||
|
|||||||
@@ -89,76 +89,4 @@ describe("widgets/crowdsec/proxy", () => {
|
|||||||
expect(res.statusCode).toBe(500);
|
expect(res.statusCode).toBe(500);
|
||||||
expect(res.body).toEqual({ error: "Failed to authenticate with Crowdsec" });
|
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" });
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,6 +6,40 @@ const proxyName = "omadaProxyHandler";
|
|||||||
|
|
||||||
const logger = createLogger(proxyName);
|
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) {
|
async function login(loginUrl, username, password, controllerVersionMajor) {
|
||||||
const params = {
|
const params = {
|
||||||
username,
|
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",
|
method: "POST",
|
||||||
|
cookieHeader: "X-Bypass-Cookie",
|
||||||
body: JSON.stringify(params),
|
body: JSON.stringify(params),
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"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) {
|
export default async function omadaProxyHandler(req, res) {
|
||||||
@@ -86,12 +122,18 @@ export default async function omadaProxyHandler(req, res) {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
const [loginStatus, loginResponseData] = await login(
|
const [loginStatus, loginContentType, loginData, loginCookieHeader] = await login(
|
||||||
loginUrl,
|
loginUrl,
|
||||||
widget.username,
|
widget.username,
|
||||||
widget.password,
|
widget.password,
|
||||||
controllerVersionMajor,
|
controllerVersionMajor,
|
||||||
);
|
);
|
||||||
|
const loginResponseData = parseOmadaJson(loginData, {
|
||||||
|
step: "login",
|
||||||
|
status: loginStatus,
|
||||||
|
contentType: loginContentType,
|
||||||
|
url: loginUrl,
|
||||||
|
});
|
||||||
|
|
||||||
if (loginStatus !== 200 || loginResponseData.errorCode > 0) {
|
if (loginStatus !== 200 || loginResponseData.errorCode > 0) {
|
||||||
return res
|
return res
|
||||||
@@ -100,11 +142,13 @@ export default async function omadaProxyHandler(req, res) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { token } = loginResponseData.result;
|
const { token } = loginResponseData.result;
|
||||||
|
let omadaCookieHeader = loginCookieHeader;
|
||||||
|
|
||||||
let sitesUrl;
|
let sitesUrl;
|
||||||
let body = {};
|
let body = {};
|
||||||
let params = { token };
|
let params = { token };
|
||||||
let headers = { "Csrf-Token": token };
|
let headers = { "Csrf-Token": token };
|
||||||
|
if (omadaCookieHeader) headers.Cookie = omadaCookieHeader;
|
||||||
let method = "GET";
|
let method = "GET";
|
||||||
|
|
||||||
switch (controllerVersionMajor) {
|
switch (controllerVersionMajor) {
|
||||||
@@ -134,9 +178,72 @@ export default async function omadaProxyHandler(req, res) {
|
|||||||
params,
|
params,
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
headers,
|
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) {
|
if (status !== 200 || sitesResponseData.errorCode > 0) {
|
||||||
logger.debug(`HTTP ${status} getting sites list: ${sitesResponseData.msg}`);
|
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" };
|
headers = { "Content-Type": "application/json" };
|
||||||
|
if (omadaCookieHeader) headers.Cookie = omadaCookieHeader;
|
||||||
params = { token };
|
params = { token };
|
||||||
|
|
||||||
[status, contentType, data] = await httpProxy(switchUrl, {
|
[status, contentType, data] = await httpProxy(switchUrl, {
|
||||||
@@ -181,9 +289,15 @@ export default async function omadaProxyHandler(req, res) {
|
|||||||
params,
|
params,
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
headers,
|
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) {
|
if (status !== 200 || switchResponseData.errorCode > 0) {
|
||||||
logger.error(`HTTP ${status} getting sites list: ${data}`);
|
logger.error(`HTTP ${status} getting sites list: ${data}`);
|
||||||
return res.status(status).json({ error: { message: "Error switching site", url: switchUrl, 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",
|
method: "getGlobalStat",
|
||||||
}),
|
}),
|
||||||
headers,
|
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) {
|
if (status !== 200 || siteResponseData.errorCode > 0) {
|
||||||
return res.status(status).json({ error: { message: "Error getting stats", url: statsUrl, data } });
|
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, {
|
[status, contentType, data] = await httpProxy(siteStatsUrl, {
|
||||||
headers: {
|
headers: {
|
||||||
"Csrf-Token": token,
|
"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) {
|
if (status !== 200 || siteResponseData.errorCode > 0) {
|
||||||
logger.debug(`HTTP ${status} getting stats for site ${widget.site} with message ${siteResponseData.msg}`);
|
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, {
|
[status, contentType, data] = await httpProxy(alertUrl, {
|
||||||
headers: {
|
headers: {
|
||||||
"Csrf-Token": token,
|
"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;
|
activeUser = siteResponseData.result.totalClientNum;
|
||||||
connectedAp = siteResponseData.result.connectedApNum;
|
connectedAp = siteResponseData.result.connectedApNum;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,28 +10,13 @@ export default function Component({ service }) {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { widget } = service;
|
const { widget } = service;
|
||||||
|
|
||||||
const { data: transferData, error: transferError } = useWidgetAPI(widget, "transfer");
|
const { data: torrentData, error: torrentError } = useWidgetAPI(widget, "torrents");
|
||||||
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 apiError = transferError || totalCountError || completedCountError || leechTorrentError;
|
if (torrentError) {
|
||||||
if (apiError) {
|
return <Container service={service} error={torrentError} />;
|
||||||
return <Container service={service} error={apiError} />;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (!torrentData) {
|
||||||
!transferData ||
|
|
||||||
totalCountData === undefined ||
|
|
||||||
completedCountData === undefined ||
|
|
||||||
(widget?.enableLeechProgress && !leechTorrentData)
|
|
||||||
) {
|
|
||||||
return (
|
return (
|
||||||
<Container service={service}>
|
<Container service={service}>
|
||||||
<Block label="qbittorrent.leech" />
|
<Block label="qbittorrent.leech" />
|
||||||
@@ -42,15 +27,24 @@ export default function Component({ service }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const rateDl = Number(transferData?.dl_info_speed ?? 0);
|
let rateDl = 0;
|
||||||
const rateUl = Number(transferData?.up_info_speed ?? 0);
|
let rateUl = 0;
|
||||||
const totalCount = Number(totalCountData?.all ?? totalCountData?.count ?? totalCountData ?? 0);
|
let completed = 0;
|
||||||
const completedCount = Number(
|
const leechTorrents = [];
|
||||||
completedCountData?.completed ?? completedCountData?.count ?? completedCountData?.all ?? completedCountData ?? 0,
|
|
||||||
);
|
|
||||||
const leech = Math.max(0, totalCount - completedCount);
|
|
||||||
|
|
||||||
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 = [
|
const statePriority = [
|
||||||
"downloading",
|
"downloading",
|
||||||
"forcedDL",
|
"forcedDL",
|
||||||
@@ -61,6 +55,7 @@ export default function Component({ service }) {
|
|||||||
"queuedDL",
|
"queuedDL",
|
||||||
"pausedDL",
|
"pausedDL",
|
||||||
];
|
];
|
||||||
|
|
||||||
leechTorrents.sort((firstTorrent, secondTorrent) => {
|
leechTorrents.sort((firstTorrent, secondTorrent) => {
|
||||||
const firstStateIndex = statePriority.indexOf(firstTorrent.state);
|
const firstStateIndex = statePriority.indexOf(firstTorrent.state);
|
||||||
const secondStateIndex = statePriority.indexOf(secondTorrent.state);
|
const secondStateIndex = statePriority.indexOf(secondTorrent.state);
|
||||||
@@ -75,7 +70,7 @@ export default function Component({ service }) {
|
|||||||
<Container service={service}>
|
<Container service={service}>
|
||||||
<Block label="qbittorrent.leech" value={t("common.number", { value: leech })} />
|
<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.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 })} />
|
<Block label="qbittorrent.upload" value={t("common.bibyterate", { value: rateUl, decimals: 1 })} />
|
||||||
</Container>
|
</Container>
|
||||||
{widget?.enableLeechProgress &&
|
{widget?.enableLeechProgress &&
|
||||||
|
|||||||
@@ -34,38 +34,19 @@ describe("widgets/qbittorrent/component", () => {
|
|||||||
expect(screen.getByText("qbittorrent.upload")).toBeInTheDocument();
|
expect(screen.getByText("qbittorrent.upload")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses lightweight endpoints for counts/rates and filtered torrents for leech progress", () => {
|
it("computes leech/seed counts and upload/download rates, and can render leech progress entries", () => {
|
||||||
useWidgetAPI.mockImplementation((_widget, endpoint, query) => {
|
useWidgetAPI.mockReturnValue({
|
||||||
if (endpoint === "transfer") {
|
data: [
|
||||||
return { data: { dl_info_speed: 15, up_info_speed: 3 }, error: undefined };
|
{ 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 },
|
||||||
if (endpoint === "torrentCount" && !query) {
|
],
|
||||||
return { data: 2, error: undefined };
|
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 };
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const service = { widget: { type: "qbittorrent", enableLeechProgress: true } };
|
const service = { widget: { type: "qbittorrent", enableLeechProgress: true } };
|
||||||
const { container } = renderWithProviders(<Component service={service} />, { settings: { hideErrors: false } });
|
const { container } = renderWithProviders(<Component service={service} />, { settings: { hideErrors: false } });
|
||||||
|
|
||||||
|
// total=2, completed=1 => leech=1
|
||||||
expectBlockValue(container, "qbittorrent.leech", 1);
|
expectBlockValue(container, "qbittorrent.leech", 1);
|
||||||
expectBlockValue(container, "qbittorrent.seed", 1);
|
expectBlockValue(container, "qbittorrent.seed", 1);
|
||||||
expectBlockValue(container, "qbittorrent.download", 15);
|
expectBlockValue(container, "qbittorrent.download", 15);
|
||||||
|
|||||||
@@ -4,16 +4,8 @@ const widget = {
|
|||||||
proxyHandler: qbittorrentProxyHandler,
|
proxyHandler: qbittorrentProxyHandler,
|
||||||
|
|
||||||
mappings: {
|
mappings: {
|
||||||
transfer: {
|
|
||||||
endpoint: "transfer/info",
|
|
||||||
},
|
|
||||||
torrentCount: {
|
|
||||||
endpoint: "torrents/count",
|
|
||||||
optionalParams: ["filter"],
|
|
||||||
},
|
|
||||||
torrents: {
|
torrents: {
|
||||||
endpoint: "torrents/info",
|
endpoint: "torrents/info",
|
||||||
optionalParams: ["filter"],
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user