mirror of
https://github.com/gethomepage/homepage.git
synced 2026-03-31 07:12:17 -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
|
||||
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
|
||||
|
||||
12
.github/workflows/docker-publish.yml
vendored
12
.github/workflows/docker-publish.yml
vendored
@@ -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' }}
|
||||
|
||||
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).
|
||||
|
||||
### 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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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_
|
||||
|
||||
14
package.json
14
package.json
@@ -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
726
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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: {} }] },
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 { 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 &&
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user