mirror of
https://github.com/gethomepage/homepage.git
synced 2026-04-03 08:41:21 -07:00
Compare commits
21 Commits
feature/om
...
v1.11.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a4e29bc7a7 | ||
|
|
a7982bda06 | ||
|
|
f7c12ad642 | ||
|
|
a6639b04b9 | ||
|
|
6b3bff1f1d | ||
|
|
597059045f | ||
|
|
b676424d98 | ||
|
|
e87b62f3ac | ||
|
|
776f190aed | ||
|
|
71a524da89 | ||
|
|
9dea3a4d4f | ||
|
|
adc042fa8a | ||
|
|
f16878bca9 | ||
|
|
01b951f3ba | ||
|
|
94122ba078 | ||
|
|
fb88da5a5a | ||
|
|
de7e730283 | ||
|
|
b5b502b433 | ||
|
|
db9b2d0245 | ||
|
|
e3ca0adf11 | ||
|
|
d62404f164 |
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/more/troubleshooting/#service-widget-errors), if relevant.
|
description: Please include output from your [troubleshooting steps](https://gethomepage.dev/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@v5
|
uses: docker/metadata-action@v6
|
||||||
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@v3
|
uses: docker/login-action@v4
|
||||||
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@v3
|
uses: docker/login-action@v4
|
||||||
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@v3.7.0
|
uses: docker/setup-qemu-action@v4.0.0
|
||||||
|
|
||||||
- name: Setup Docker buildx
|
- name: Setup Docker buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v4
|
||||||
|
|
||||||
- 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@v6
|
uses: docker/build-push-action@v7
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
push: ${{ github.event_name != 'pull_request' }}
|
push: ${{ github.event_name != 'pull_request' }}
|
||||||
|
|||||||
18
.github/workflows/pr-quality.yml
vendored
Normal file
18
.github/workflows/pr-quality.yml
vendored
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
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 e used to specify links to tabs, to be preselected when the homepage is opened as an app.
|
Shortcuts can be 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,13 +223,33 @@ 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: gethomepage.dev # required, may need port. See gethomepage.dev/installation/#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
|
||||||
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,6 +16,7 @@ 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
|
||||||
@@ -31,5 +32,3 @@ 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.10.1",
|
"version": "1.11.0",
|
||||||
"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.1.0",
|
"ical.js": "^2.2.1",
|
||||||
"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": "^12.1.0",
|
"next-i18next": "^15.4.3",
|
||||||
"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": "^18.3.1",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^19.2.4",
|
||||||
"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": "^26.1.0",
|
"jsdom": "^28.1.0",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"prettier": "^3.7.3",
|
"prettier": "^3.8.1",
|
||||||
"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 { fireEvent, screen } from "@testing-library/react";
|
import { act, 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,7 +188,9 @@ 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();
|
||||||
|
|
||||||
await vi.advanceTimersByTimeAsync(300);
|
act(() => {
|
||||||
|
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 { screen } from "@testing-library/react";
|
import { act, 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,7 +21,9 @@ 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();
|
||||||
|
|
||||||
await vi.advanceTimersByTimeAsync(1000);
|
act(() => {
|
||||||
|
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,7 +54,10 @@ 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 label="beszel.network" value={t("common.percent", { value: system.info.b, maximumFractionDigits: 2 })} />
|
<Block
|
||||||
|
label="beszel.network"
|
||||||
|
value={t("common.byterate", { value: system.info.bb, maximumFractionDigits: 2 })}
|
||||||
|
/>
|
||||||
</Container>
|
</Container>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,6 +76,35 @@ 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,13 +25,25 @@ async function login(widget, service) {
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const dataParsed = JSON.parse(data);
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
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) {
|
||||||
@@ -48,11 +60,10 @@ 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" });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!cache.get(`${sessionTokenCacheKey}.${service}`)) {
|
let token = cache.get(`${sessionTokenCacheKey}.${service}`);
|
||||||
await login(widget, service);
|
if (!token) {
|
||||||
|
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" });
|
||||||
}
|
}
|
||||||
@@ -71,7 +82,20 @@ export default async function crowdsecProxyHandler(req, res) {
|
|||||||
|
|
||||||
logger.debug("Calling Crowdsec API endpoint: %s", endpoint);
|
logger.debug("Calling Crowdsec API endpoint: %s", endpoint);
|
||||||
|
|
||||||
const [status, , data] = await httpProxy(url, params);
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
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,4 +89,76 @@ 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" });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,13 +10,28 @@ export default function Component({ service }) {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { widget } = service;
|
const { widget } = service;
|
||||||
|
|
||||||
const { data: torrentData, error: torrentError } = useWidgetAPI(widget, "torrents");
|
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,
|
||||||
|
);
|
||||||
|
|
||||||
if (torrentError) {
|
const apiError = transferError || totalCountError || completedCountError || leechTorrentError;
|
||||||
return <Container service={service} error={torrentError} />;
|
if (apiError) {
|
||||||
|
return <Container service={service} error={apiError} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!torrentData) {
|
if (
|
||||||
|
!transferData ||
|
||||||
|
totalCountData === undefined ||
|
||||||
|
completedCountData === undefined ||
|
||||||
|
(widget?.enableLeechProgress && !leechTorrentData)
|
||||||
|
) {
|
||||||
return (
|
return (
|
||||||
<Container service={service}>
|
<Container service={service}>
|
||||||
<Block label="qbittorrent.leech" />
|
<Block label="qbittorrent.leech" />
|
||||||
@@ -27,24 +42,15 @@ export default function Component({ service }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let rateDl = 0;
|
const rateDl = Number(transferData?.dl_info_speed ?? 0);
|
||||||
let rateUl = 0;
|
const rateUl = Number(transferData?.up_info_speed ?? 0);
|
||||||
let completed = 0;
|
const totalCount = Number(totalCountData?.all ?? totalCountData?.count ?? totalCountData ?? 0);
|
||||||
const leechTorrents = [];
|
const completedCount = Number(
|
||||||
|
completedCountData?.completed ?? completedCountData?.count ?? completedCountData?.all ?? completedCountData ?? 0,
|
||||||
|
);
|
||||||
|
const leech = Math.max(0, totalCount - completedCount);
|
||||||
|
|
||||||
for (let i = 0; i < torrentData.length; i += 1) {
|
const leechTorrents = Array.isArray(leechTorrentData) ? [...leechTorrentData] : [];
|
||||||
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",
|
||||||
@@ -55,7 +61,6 @@ 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);
|
||||||
@@ -70,7 +75,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: completed })} />
|
<Block label="qbittorrent.seed" value={t("common.number", { value: completedCount })} />
|
||||||
<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,19 +34,38 @@ describe("widgets/qbittorrent/component", () => {
|
|||||||
expect(screen.getByText("qbittorrent.upload")).toBeInTheDocument();
|
expect(screen.getByText("qbittorrent.upload")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("computes leech/seed counts and upload/download rates, and can render leech progress entries", () => {
|
it("uses lightweight endpoints for counts/rates and filtered torrents for leech progress", () => {
|
||||||
useWidgetAPI.mockReturnValue({
|
useWidgetAPI.mockImplementation((_widget, endpoint, query) => {
|
||||||
data: [
|
if (endpoint === "transfer") {
|
||||||
{ name: "A", dlspeed: 10, upspeed: 1, progress: 1, state: "uploading" },
|
return { data: { dl_info_speed: 15, up_info_speed: 3 }, error: undefined };
|
||||||
{ name: "B", dlspeed: 5, upspeed: 2, progress: 0.5, state: "downloading", eta: 60, size: 100, amount_left: 50 },
|
}
|
||||||
],
|
if (endpoint === "torrentCount" && !query) {
|
||||||
error: undefined,
|
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 };
|
||||||
});
|
});
|
||||||
|
|
||||||
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,8 +4,16 @@ 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