Compare commits

...

24 Commits

Author SHA1 Message Date
shamoon
b48d283dc2 Retry Omada login on HTML response; preserve cookies 2026-03-04 11:34:44 -08:00
shamoon
d313e0a124 Add some debug logging for omada 2026-03-02 09:22:39 -08:00
shamoon
51d718a21a Fix: small fixes for Omada proxy (#6372) 2026-02-28 11:36:43 -08:00
shamoon
29e2502d74 Fix: Await async proxy handlers (#6371) 2026-02-28 11:22:54 -08:00
shamoon
d529f81cb4 Enhancement: fallback for missing si network stats (#6367) 2026-02-27 10:39:02 -08:00
Erkan
1645c1b8a1 Documentation: clarify array value quoting in Docker label mapping syntax (#6356)
Co-authored-by: shamoon <shamoon@users.noreply.github.com>
2026-02-23 22:16:22 +00:00
shamoon
614a87d768 DRY 2026-02-20 20:25:34 -08:00
shamoon
862c5d9f38 Add missing tracearr to docs 2026-02-20 20:21:27 -08:00
shamoon
d3374dc461 Feature: sparkyfitness service widget (#6346) 2026-02-20 20:20:59 -08:00
shamoon
795e2505ca Fix links in CONTRIBUTING.md for development guidelines 2026-02-20 09:54:23 -08:00
shamoon
cb8421df0b feature request template search reminder 2026-02-16 14:38:34 -08:00
shamoon
152888d611 Enhancement: cover more basic statuses in containers list (#6334) 2026-02-16 08:43:06 -08:00
shamoon
ea527e4fb1 Enhancement: add "Temperature" label to list of possible CPU sensors (#6331) 2026-02-16 00:09:14 -08:00
shamoon
09bab7637e Chore: merge Overseerr into Seerr, add aliases (#6330) 2026-02-15 19:12:15 -08:00
shamoon
597f6ecf16 Enhancement: jellyseer completed (#6329) 2026-02-15 18:44:03 -08:00
shamoon
fe0b214334 Chore: rename Jellyseerr widget to Seerr and update references (#6322) 2026-02-14 16:11:00 -08:00
shamoon
cdc96438cd Adjust process list vertical offset 2026-02-14 07:14:14 -08:00
shamoon
ca7dfb56c8 Improvement: better handle highlighting with units (#6318) 2026-02-13 10:00:46 -08:00
shamoon
95852d23c2 Lower Codecov coverage thresholds 2026-02-09 19:36:33 -08:00
Bothari
84231a1754 Feature: add Tracearr widget for displaying active Plex streams (#6306)
Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com>
2026-02-10 03:35:54 +00:00
Matt Popovich
f4f54cea60 Documentation: clarify jellyfin api key location, add ports (#6298)
Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com>
2026-02-07 09:38:17 +00:00
shamoon
06595ef107 Revise PR template checklist for clarity and links 2026-02-06 07:53:26 -08:00
dependabot[bot]
91b9aa479a Chore(deps): Bump actions/setup-node from 4 to 6 (#6285)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-05 13:48:18 -08:00
dependabot[bot]
08cde2f597 Chore(deps): Bump actions/checkout from 4 to 6 (#6284)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-05 13:40:57 -08:00
166 changed files with 1731 additions and 998 deletions

View File

@@ -9,11 +9,11 @@ coverage:
project:
default:
target: 100%
threshold: 25%
threshold: 15%
patch:
default:
target: 100%
threshold: 25%
threshold: 10%
comment:
layout: "reach,diff,flags,files"

View File

@@ -1,6 +1,10 @@
title: "[Feature Request] "
labels: ["enhancement"]
body:
- type: markdown
attributes:
value: |
#### ⚠️ Don't forget to search [existing issues](https://github.com/gethomepage/homepage/search?q=&type=issues) and [discussions](https://github.com/gethomepage/homepage/search?q=&type=discussions) (including closed ones!).
- type: textarea
id: description
attributes:

View File

@@ -35,8 +35,8 @@ What type of change does your PR introduce to Homepage?
## Checklist:
- [ ] If applicable, I have added corresponding documentation changes.
- [ ] If applicable, I have added or updated tests for new features and bug fixes.
- [ ] If applicable, I have reviewed the [feature / enhancement](https://gethomepage.dev/more/development/#new-feature-guidelines) and / or [service widget guidelines](https://gethomepage.dev/more/development/#service-widget-guidelines).
- [ ] I have checked that all code style checks pass using [pre-commit hooks](https://gethomepage.dev/more/development/#code-formatting-with-pre-commit-hooks) and [linting checks](https://gethomepage.dev/more/development/#code-linting).
- [ ] If applicable, I have added or updated tests for new features and bug fixes (see [testing](https://gethomepage.dev/widgets/authoring/getting-started/#testing)).
- [ ] If applicable, I have reviewed the [feature / enhancement](https://gethomepage.dev/widgets/authoring/getting-started/#new-feature-guidelines) and / or [service widget guidelines](https://gethomepage.dev/widgets/authoring/getting-started/#service-widget-guidelines).
- [ ] I have checked that all code style checks pass using [pre-commit hooks](https://gethomepage.dev/widgets/authoring/getting-started/#code-formatting-with-pre-commit-hooks) and [linting checks](https://gethomepage.dev/widgets/authoring/getting-started/#code-linting).
- [ ] If applicable, I have tested my code for new features & regressions on both mobile & desktop devices, using the latest version of major browsers.
- [ ] In the description above I have disclosed the use of AI tools in the coding of this PR.

View File

@@ -13,13 +13,13 @@ jobs:
matrix:
shard: [1, 2, 3, 4]
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v4
with:
version: 9
- uses: actions/setup-node@v4
- uses: actions/setup-node@v6
with:
node-version: 20
cache: pnpm

View File

@@ -38,11 +38,11 @@ People _love_ thorough bug reports. I'm not even kidding.
## Development Guidelines
Please see the [documentation regarding development](https://gethomepage.dev/more/development/) and specifically the [guidelines for new service widgets](https://gethomepage.dev/more/development/#service-widget-guidelines) if you are considering making one.
Please see the [documentation regarding development](https://gethomepage.dev/widgets/authoring/getting-started/#development) and specifically the [guidelines for new service widgets](https://gethomepage.dev/widgets/authoring/getting-started/#service-widget-guidelines) if you are considering making one.
## Use a Consistent Coding Style
Please see information in the docs regarding [code formatting with pre-commit hooks](https://gethomepage.dev/more/development/#code-formatting-with-pre-commit-hooks).
Please see information in the docs regarding [code formatting with pre-commit hooks](https://gethomepage.dev/widgets/authoring/getting-started/#code-formatting-with-pre-commit-hooks).
## License

View File

@@ -177,6 +177,16 @@ labels:
- homepage.widget.fields=["field1","field2"] # optional
```
!!! note
If you use mapping syntax (`:`) for labels instead of list syntax (`-`), array values like `fields` must be wrapped in single quotes so they are passed as a string:
```yaml
labels:
...
homepage.widget.fields: '["field1","field2"]'
```
Multiple widgets can be specified by incrementing the index, e.g.
```yaml

View File

@@ -7,13 +7,17 @@ You can include all or some of the available resources. If you do not want to se
The disk path is the path reported by `df` (Mounted On), or the mount point of the disk.
!!! note
Any disk you wish to access must be mounted to your container as a volume.
The cpu and memory resource information are the container's usage while [glances](glances.md) displays statistics for the host machine on which it is installed.
The resources widget primarily relies on a popular tool called [systeminformation](https://systeminformation.io). Thus, any limitiations of that software apply, for example, BRTFS RAID is not supported for the disk usage. In this case users may want to use the [glances widget](glances.md) instead.
_Note: unfortunately, the package used for getting CPU temp ([systeminformation](https://systeminformation.io)) is not compatible with some setups and will not report any value(s) for CPU temp._
!!! warning
**Any disk you wish to access must be mounted to your container as a volume.**
The package used for getting CPU temp ([systeminformation](https://systeminformation.io)) is not compatible with some setups and will not report any value(s) for CPU temp.
```yaml
- resources:
@@ -75,3 +79,10 @@ You can additionally supply an optional `expanded` property set to true in order
```
![194136533-c4238c82-4d67-41a4-b3c8-18bf26d33ac2](https://user-images.githubusercontent.com/3441425/194728642-a9885274-922b-4027-acf5-a746f58fdfce.png)
To monitor a named host network interface in Docker (for example `network: eno1`), mount host `/sys` (read-only):
```yaml
volumes:
- /sys:/sys:ro
```

View File

@@ -67,7 +67,7 @@ You can also find a list of all available service widgets in the sidebar navigat
- [Jackett](jackett.md)
- [JDownloader](jdownloader.md)
- [Jellyfin](jellyfin.md)
- [Jellyseerr](jellyseerr.md)
- [Seerr](seerr.md)
- [Jellystat](jellystat.md)
- [Kavita](kavita.md)
- [Komga](komga.md)
@@ -101,7 +101,6 @@ You can also find a list of all available service widgets in the sidebar navigat
- [OpenMediaVault](openmediavault.md)
- [OpenWRT](openwrt.md)
- [OPNsense](opnsense.md)
- [Overseerr](overseerr.md)
- [PaperlessNGX](paperlessngx.md)
- [Peanut](peanut.md)
- [pfSense](pfsense.md)

View File

@@ -5,7 +5,7 @@ description: Jellyfin Widget Configuration
Learn more about [Jellyfin](https://github.com/jellyfin/jellyfin).
You can create an API key from inside Jellyfin at `Settings > Advanced > Api Keys`.
You can create an API key from inside the Jellyfin Administration Dashboard under `Advanced > API Keys`.
As of v0.6.11 the widget supports fields `["movies", "series", "episodes", "songs"]`. These blocks are disabled by default but can be enabled with the `enableBlocks` option, and the "Now Playing" feature (enabled by default) can be disabled with the `enableNowPlaying` option.
@@ -17,7 +17,7 @@ As of v0.6.11 the widget supports fields `["movies", "series", "episodes", "song
```yaml
widget:
type: jellyfin
url: http://jellyfin.host.or.ip
url: http://jellyfin.host.or.ip:port
key: apikeyapikeyapikeyapikeyapikey
version: 2 # optional, default is 1
enableBlocks: true # optional, defaults to false

View File

@@ -1,18 +0,0 @@
---
title: Jellyseerr
description: Jellyseerr Widget Configuration
---
Learn more about [Jellyseerr](https://github.com/Fallenbagel/jellyseerr).
Find your API key under `Settings > General > API Key`.
Allowed fields: `["pending", "approved", "available", "issues"]`.
Default fields: `["pending", "approved", "available"]`.
```yaml
widget:
type: jellyseerr
url: http://jellyseerr.host.or.ip
key: apikeyapikeyapikeyapikeyapikey
```

View File

@@ -1,17 +0,0 @@
---
title: Overseerr
description: Overseerr Widget Configuration
---
Learn more about [Overseerr](https://github.com/sct/overseerr).
Find your API key under `Settings > General`.
Allowed fields: `["pending", "approved", "available", "processing"]`.
```yaml
widget:
type: overseerr
url: http://overseerr.host.or.ip
key: apikeyapikeyapikeyapikeyapikey
```

View File

@@ -12,7 +12,7 @@ Allowed fields: no configurable fields for this widget.
```yaml
widget:
type: tautulli
url: http://tautulli.host.or.ip
url: http://tautulli.host.or.ip:port
key: apikeyapikeyapikeyapikeyapikey
enableUser: true # optional, defaults to false
showEpisodeNumber: true # optional, defaults to false

View File

@@ -0,0 +1,20 @@
---
title: Seerr Widget
description: Seerr Widget Configuration
---
Learn more about [Seerr](https://github.com/seerr-team/seerr).
Find your API key under `Settings > General > API Key`.
_Jellyseerr and Overseerr merged into Seerr. Use `type: seerr` (legacy `type: jellyseerr` and `type: overseerr` are aliased)._
Allowed fields: `["pending", "approved", "available", "completed", "processing", "issues"]`.
Default fields: `["pending", "approved", "completed"]`.
```yaml
widget:
type: seerr
url: http://seerr.host.or.ip
key: apikeyapikeyapikeyapikeyapikey
```

View File

@@ -0,0 +1,15 @@
---
title: SparkyFitness
description: SparkyFitness Widget Configuration
---
Learn more about [SparkyFitness](https://github.com/CodeWithCJ/SparkyFitness).
Allowed fields: `["eaten", "burned", "remaining", "steps"]`.
```yaml
widget:
type: sparkyfitness
url: http://sparkyfitness.host.or.ip
key: apikeyapikeyapikeyapikeyapikey
```

View File

@@ -0,0 +1,21 @@
---
title: Tracearr
description: Tracearr Widget Configuration
---
Learn more about [Tracearr](https://www.tracearr.com/).
Provides detailed information about currently active streams across multiple servers.
Allowed fields (for summary view): `["streams", "transcodes", "directplay", "bitrate"]`.
```yaml
widget:
type: tracearr
url: http://tracearr.host.or.ip:3000
key: apikeyapikeyapikeyapikeyapikey
view: both # optional, "summary", "details", or "both", defaults to "details"
enableUser: true # optional, defaults to false
showEpisodeNumber: true # optional, defaults to false
expandOneStreamToTwoRows: false # optional, defaults to true
```

View File

@@ -91,7 +91,6 @@ nav:
- widgets/services/jackett.md
- widgets/services/jdownloader.md
- widgets/services/jellyfin.md
- widgets/services/jellyseerr.md
- widgets/services/jellystat.md
- widgets/services/kavita.md
- widgets/services/komga.md
@@ -125,7 +124,6 @@ nav:
- widgets/services/openmediavault.md
- widgets/services/opnsense.md
- widgets/services/openwrt.md
- widgets/services/overseerr.md
- widgets/services/pangolin.md
- widgets/services/paperlessngx.md
- widgets/services/peanut.md
@@ -151,8 +149,10 @@ nav:
- widgets/services/rutorrent.md
- widgets/services/sabnzbd.md
- widgets/services/scrutiny.md
- widgets/services/seerr.md
- widgets/services/slskd.md
- widgets/services/sonarr.md
- widgets/services/sparkyfitness.md
- widgets/services/speedtest-tracker.md
- widgets/services/spoolman.md
- widgets/services/stash.md
@@ -165,6 +165,7 @@ nav:
- widgets/services/technitium.md
- widgets/services/tdarr.md
- widgets/services/traefik.md
- widgets/services/tracearr.md
- widgets/services/transmission.md
- widgets/services/trilium.md
- widgets/services/truenas.md

View File

@@ -184,6 +184,13 @@
"no_active": "No Active Streams",
"plex_connection_error": "Check Plex Connection"
},
"tracearr": {
"no_active": "No Active Streams",
"streams": "Streams",
"transcodes": "Transcodes",
"directplay": "Direct Play",
"bitrate": "Bitrate"
},
"omada": {
"connectedAp": "Connected APs",
"activeUser": "Active devices",
@@ -282,17 +289,13 @@
"approved": "Approved",
"available": "Available"
},
"jellyseerr": {
"seerr": {
"pending": "Pending",
"approved": "Approved",
"available": "Available",
"issues": "Open Issues"
},
"overseerr": {
"pending": "Pending",
"completed": "Completed",
"processing": "Processing",
"approved": "Approved",
"available": "Available"
"issues": "Open Issues"
},
"netalertx": {
"total": "Total",
@@ -1171,5 +1174,11 @@
"paused": "Paused",
"total": "Total",
"environment_not_found": "Environment Not Found"
},
"sparkyfitness": {
"eaten": "Eaten",
"burned": "Burned",
"remaining": "Remaining",
"steps": "Steps"
}
}

View File

@@ -344,4 +344,17 @@ describe("pages/api/services/proxy", () => {
expect(res.statusCode).toBe(500);
expect(res.body).toEqual({ error: "Unexpected error" });
});
it("returns 500 when an async proxy handler throws", async () => {
getServiceWidget.mockResolvedValue({ type: "linkwarden" });
handlerFn.handler.mockRejectedValueOnce(new Error("proxy boom"));
const req = { method: "GET", query: { group: "g", service: "s", index: "0" } };
const res = createMockRes();
await servicesProxy(req, res);
expect(res.statusCode).toBe(500);
expect(res.body).toEqual({ error: "Unexpected error" });
});
});

View File

@@ -90,17 +90,74 @@ describe("pages/api/widgets/resources", () => {
});
it("returns 404 when requested network interface does not exist", async () => {
si.networkStats.mockResolvedValueOnce([{ iface: "en0" }]);
si.networkStats.mockResolvedValueOnce([{ iface: "en0" }]).mockResolvedValueOnce([
{
iface: "missing",
operstate: "unknown",
rx_bytes: 0,
rx_dropped: 0,
rx_errors: 0,
tx_bytes: 0,
tx_dropped: 0,
tx_errors: 0,
rx_sec: null,
tx_sec: null,
ms: 0,
},
]);
const req = { query: { type: "network", interfaceName: "missing" } };
const res = createMockRes();
await handler(req, res);
expect(si.networkStats).toHaveBeenNthCalledWith(1, "*");
expect(si.networkStats).toHaveBeenNthCalledWith(2, "missing");
expect(res.statusCode).toBe(404);
expect(res.body).toEqual({ error: "Interface not found" });
});
it("falls back to direct named interface query when wildcard enumeration misses it", async () => {
si.networkStats.mockResolvedValueOnce([{ iface: "eth0", rx_bytes: 1 }]).mockResolvedValueOnce([
{
iface: "eno1",
operstate: "up",
rx_bytes: 1000,
rx_dropped: 0,
rx_errors: 0,
tx_bytes: 500,
tx_dropped: 0,
tx_errors: 0,
rx_sec: null,
tx_sec: null,
ms: 0,
},
]);
const req = { query: { type: "network", interfaceName: "eno1" } };
const res = createMockRes();
await handler(req, res);
expect(si.networkStats).toHaveBeenNthCalledWith(1, "*");
expect(si.networkStats).toHaveBeenNthCalledWith(2, "eno1");
expect(res.statusCode).toBe(200);
expect(res.body.interface).toBe("eno1");
expect(res.body.network).toEqual({
iface: "eno1",
operstate: "up",
rx_bytes: 1000,
rx_dropped: 0,
rx_errors: 0,
tx_bytes: 500,
tx_dropped: 0,
tx_errors: 0,
rx_sec: null,
tx_sec: null,
ms: 0,
});
});
it("returns default interface network stats", async () => {
si.networkStats.mockResolvedValueOnce([{ iface: "en0", rx_bytes: 1 }]);
si.networkInterfaceDefault.mockResolvedValueOnce("en0");

View File

@@ -9,6 +9,8 @@ import { buildHighlightConfig } from "utils/highlights";
const ALIASED_WIDGETS = {
pialert: "netalertx",
hoarder: "karakeep",
jellyseerr: "seerr",
overseerr: "seerr",
};
export default function Container({ error = false, children, service }) {

View File

@@ -58,6 +58,26 @@ describe("components/services/widget/container", () => {
expect(screen.getByTestId("karakeep.count")).toBeInTheDocument();
});
it("supports seerr aliases when filtering (jellyseerr/overseerr -> seerr)", () => {
renderWithProviders(
<Container service={{ widget: { type: "jellyseerr", fields: ["pending"] } }}>
<Dummy label="seerr.pending" />
</Container>,
{ settings: {} },
);
expect(screen.getByTestId("seerr.pending")).toBeInTheDocument();
renderWithProviders(
<Container service={{ widget: { type: "overseerr", fields: ["processing"] } }}>
<Dummy label="seerr.processing" />
</Container>,
{ settings: {} },
);
expect(screen.getByTestId("seerr.processing")).toBeInTheDocument();
});
it("returns null when errors are hidden via settings.hideErrors", () => {
const { container } = renderWithProviders(
<Container error="nope" service={{ widget: { type: "omada", hide_errors: false } }}>

View File

@@ -11,7 +11,7 @@ import Resource from "../widget/resource";
import Resources from "../widget/resources";
import WidgetLabel from "../widget/widget_label";
const cpuSensorLabels = ["cpu_thermal", "Core", "Tctl"];
const cpuSensorLabels = ["cpu_thermal", "Core", "Tctl", "Temperature"];
function convertToFahrenheit(t) {
return (t * 9) / 5 + 32;

View File

@@ -29,7 +29,7 @@ export default async function handler(req, res) {
if (serviceProxyHandler instanceof Function) {
// quick return for no endpoint services, calendar is an exception
if (!req.query.endpoint || serviceProxyHandler === calendarProxyHandler) {
return serviceProxyHandler(req, res);
return await serviceProxyHandler(req, res);
}
// map opaque endpoints to their actual endpoint
@@ -90,15 +90,15 @@ export default async function handler(req, res) {
}
if (endpointProxy instanceof Function) {
return endpointProxy(req, res, map);
return await endpointProxy(req, res, map);
}
return serviceProxyHandler(req, res, map);
return await serviceProxyHandler(req, res, map);
}
if (widget.allowedEndpoints instanceof RegExp) {
if (widget.allowedEndpoints.test(req.query.endpoint)) {
return serviceProxyHandler(req, res);
return await serviceProxyHandler(req, res);
}
}

View File

@@ -4,6 +4,21 @@ import createLogger from "utils/logger";
const logger = createLogger("resources");
function isMissingNetworkStat(networkData, interfaceName) {
return (
networkData.operstate === "unknown" &&
networkData.rx_bytes === 0 &&
networkData.rx_dropped === 0 &&
networkData.rx_errors === 0 &&
networkData.tx_bytes === 0 &&
networkData.tx_dropped === 0 &&
networkData.tx_errors === 0 &&
networkData.rx_sec === null &&
networkData.tx_sec === null &&
networkData.ms === 0
);
}
export default async function handler(req, res) {
const { type, target, interfaceName = "default" } = req.query;
@@ -64,6 +79,17 @@ export default async function handler(req, res) {
logger.debug("networkData:", JSON.stringify(networkData));
if (interfaceName && interfaceName !== "default") {
networkData = networkData.filter((network) => network.iface === interfaceName).at(0);
if (!networkData) {
// Fallback for e.g. docker where networkStats("*") may not return stats for host interfaces
const directNetworkData = await si.networkStats(interfaceName);
logger.debug("directNetworkData:", JSON.stringify(directNetworkData));
networkData = Array.isArray(directNetworkData) ? directNetworkData.at(0) : null;
// si returns unknown + zeroes when interface truly does not exist
if (!networkData || isMissingNetworkStat(networkData, interfaceName)) {
networkData = null;
}
}
if (!networkData) {
return res.status(404).json({
error: "Interface not found",

View File

@@ -1,4 +1,12 @@
import { expect } from "vitest";
export function findServiceBlockByLabel(container, label) {
const blocks = Array.from(container.querySelectorAll(".service-block"));
return blocks.find((b) => b.textContent?.includes(label));
}
export function expectBlockValue(container, label, value) {
const block = findServiceBlockByLabel(container, label);
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}

View File

@@ -313,7 +313,7 @@ export function cleanServiceGroups(groups) {
enableNowPlaying,
enableMediaControl,
// emby, jellyfin, tautulli
// emby, jellyfin, tautulli, tracearr
enableUser,
expandOneStreamToTwoRows,
showEpisodeNumber,
@@ -542,12 +542,15 @@ export function cleanServiceGroups(groups) {
if (enableBlocks !== undefined) widget.enableBlocks = JSON.parse(enableBlocks);
if (enableNowPlaying !== undefined) widget.enableNowPlaying = JSON.parse(enableNowPlaying);
}
if (["emby", "jellyfin", "tautulli"].includes(type)) {
if (["emby", "jellyfin", "tautulli", "tracearr"].includes(type)) {
if (expandOneStreamToTwoRows !== undefined)
widget.expandOneStreamToTwoRows = !!JSON.parse(expandOneStreamToTwoRows);
if (showEpisodeNumber !== undefined) widget.showEpisodeNumber = !!JSON.parse(showEpisodeNumber);
if (enableUser !== undefined) widget.enableUser = !!JSON.parse(enableUser);
}
if (type === "tracearr") {
if (view !== undefined) widget.view = view;
}
if (["sonarr", "radarr"].includes(type)) {
if (enableQueue !== undefined) widget.enableQueue = JSON.parse(enableQueue);
}

View File

@@ -312,6 +312,13 @@ describe("utils/config/service-helpers", () => {
{ type: "healthchecks", uuid: "u" },
{ type: "speedtest", bitratePrecision: "3", version: "1" },
{ type: "stocks", watchlist: "AAPL", showUSMarketStatus: true },
{
type: "tracearr",
expandOneStreamToTwoRows: "true",
showEpisodeNumber: "true",
enableUser: "true",
view: "both",
},
{ type: "wgeasy", threshold: "10", version: "1" },
{ type: "technitium", range: "24h" },
{ type: "lubelogger", vehicleID: "12" },
@@ -350,6 +357,14 @@ describe("utils/config/service-helpers", () => {
expect(widgets.find((w) => w.type === "speedtest")).toEqual(
expect.objectContaining({ bitratePrecision: 3, version: 1 }),
);
expect(widgets.find((w) => w.type === "tracearr")).toEqual(
expect.objectContaining({
expandOneStreamToTwoRows: true,
showEpisodeNumber: true,
enableUser: true,
view: "both",
}),
);
expect(widgets.find((w) => w.type === "jellystat")).toEqual(expect.objectContaining({ days: 7 }));
expect(widgets.find((w) => w.type === "lubelogger")).toEqual(expect.objectContaining({ vehicleID: 12 }));
});

View File

@@ -74,6 +74,21 @@ const toNumber = (value) => {
return undefined;
};
const extractNumericToken = (value) => {
if (typeof value !== "string") return undefined;
const match = value.match(/[-+]?\d[\d\s.,]*/);
if (!match) return undefined;
const token = match[0].trim();
if (!token) return undefined;
const prefix = value.slice(0, match.index).trim();
const suffix = value.slice((match.index ?? 0) + match[0].length).trim();
if (/\d/.test(prefix) || /\d/.test(suffix)) return undefined;
return token;
};
const parseNumericValue = (value) => {
if (value === null || value === undefined) return undefined;
if (typeof value === "number" && Number.isFinite(value)) return value;
@@ -85,7 +100,9 @@ const parseNumericValue = (value) => {
const direct = Number(trimmed);
if (!Number.isNaN(direct)) return direct;
const compact = trimmed.replace(/\s+/g, "");
const candidate = extractNumericToken(trimmed);
const numericString = candidate ?? trimmed;
const compact = numericString.replace(/\s+/g, "");
if (!compact || !/^[-+]?[0-9.,]+$/.test(compact)) return undefined;
const commaCount = (compact.match(/,/g) || []).length;

View File

@@ -136,6 +136,9 @@ describe("utils/highlights", () => {
const cfg = buildHighlightConfig(null, {
// string numeric rule values go through toNumber()
gt: { numeric: { when: "gt", value: "5", level: "warn" } },
withUnitSuffix: { numeric: { when: "gt", value: 5, level: "warn" } },
withUnitPrefix: { numeric: { when: "gt", value: 5, level: "warn" } },
localizedUnitSuffix: { numeric: { when: "gt", value: 0.5, level: "warn" } },
commaGrouped: { numeric: { when: "eq", value: 1234, level: "good" } },
commaDecimal: { numeric: { when: "eq", value: 12.34, level: "good" } },
dotDecimal: { numeric: { when: "eq", value: 12.34, level: "good" } },
@@ -143,6 +146,12 @@ describe("utils/highlights", () => {
});
expect(evaluateHighlight("gt", "6", cfg)).toMatchObject({ level: "warn", source: "numeric" });
expect(evaluateHighlight("withUnitSuffix", "5.2 ms", cfg)).toMatchObject({ level: "warn", source: "numeric" });
expect(evaluateHighlight("withUnitPrefix", "ms 5.2", cfg)).toMatchObject({ level: "warn", source: "numeric" });
expect(evaluateHighlight("localizedUnitSuffix", "0,71\u202Fms", cfg)).toMatchObject({
level: "warn",
source: "numeric",
});
expect(evaluateHighlight("commaGrouped", "1,234", cfg)).toMatchObject({ level: "good", source: "numeric" });
expect(evaluateHighlight("commaDecimal", "12,34", cfg)).toMatchObject({ level: "good", source: "numeric" });
// Include a space so Number(trimmed) fails and we exercise the dot parsing branch.
@@ -161,6 +170,9 @@ describe("utils/highlights", () => {
// "1.2.3" is not a valid grouped or decimal number for our parser.
expect(evaluateHighlight("num", "1.2.3", cfg)).toBeNull();
// Multiple numbers in one string should not be treated as a single numeric value.
expect(evaluateHighlight("num", "5/10 ms", cfg)).toBeNull();
// JSX-ish values should not be treated as numeric.
expect(evaluateHighlight("num", { props: { children: "x" } }, cfg)).toBeNull();
});

View File

@@ -64,6 +64,7 @@ export default async function credentialedProxyHandler(req, res, map) {
"pangolin",
"tailscale",
"tandoor",
"tracearr",
"pterodactyl",
"vikunja",
"firefly",

View File

@@ -4,6 +4,7 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
@@ -13,13 +14,6 @@ vi.mock("utils/proxy/use-widget-api", () => ({
import Component from "./component";
function expectBlockValue(container, label, value) {
const blocks = Array.from(container.querySelectorAll(".service-block"));
const block = blocks.find((b) => b.textContent?.includes(label));
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/arcane/component", () => {
beforeEach(() => {
vi.clearAllMocks();

View File

@@ -4,6 +4,7 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
@@ -13,13 +14,6 @@ vi.mock("utils/proxy/use-widget-api", () => ({
import Component from "./component";
function expectBlockValue(container, label, value) {
const blocks = Array.from(container.querySelectorAll(".service-block"));
const block = blocks.find((b) => b.textContent?.includes(label));
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/argocd/component", () => {
beforeEach(() => {
vi.clearAllMocks();

View File

@@ -4,6 +4,7 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
@@ -13,13 +14,6 @@ vi.mock("utils/proxy/use-widget-api", () => ({
import Component from "./component";
function expectBlockValue(container, label, value) {
const blocks = Array.from(container.querySelectorAll(".service-block"));
const block = blocks.find((b) => b.textContent?.includes(label));
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/audiobookshelf/component", () => {
beforeEach(() => {
vi.clearAllMocks();

View File

@@ -4,6 +4,7 @@ import { screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
@@ -13,13 +14,6 @@ vi.mock("utils/proxy/use-widget-api", () => ({
import Component from "./component";
function expectBlockValue(container, label, value) {
const blocks = Array.from(container.querySelectorAll(".service-block"));
const block = blocks.find((b) => b.textContent?.includes(label));
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/authentik/component", () => {
beforeEach(() => {
vi.clearAllMocks();

View File

@@ -4,6 +4,7 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
@@ -13,13 +14,6 @@ vi.mock("utils/proxy/use-widget-api", () => ({
import Component from "./component";
function expectBlockValue(container, label, value) {
const blocks = Array.from(container.querySelectorAll(".service-block"));
const block = blocks.find((b) => b.textContent?.includes(label));
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/autobrr/component", () => {
beforeEach(() => {
vi.clearAllMocks();

View File

@@ -4,6 +4,7 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
@@ -13,13 +14,6 @@ vi.mock("utils/proxy/use-widget-api", () => ({
import Component from "./component";
function expectBlockValue(container, label, value) {
const blocks = Array.from(container.querySelectorAll(".service-block"));
const block = blocks.find((b) => b.textContent?.includes(label));
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/azuredevops/component", () => {
beforeEach(() => {
vi.clearAllMocks();

View File

@@ -4,7 +4,7 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
import { findServiceBlockByLabel } from "test-utils/widget-assertions";
import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
@@ -12,12 +12,6 @@ vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component";
function expectBlockValue(container, label, value) {
const block = findServiceBlockByLabel(container, label);
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/beszel/component", () => {
beforeEach(() => {
vi.clearAllMocks();

View File

@@ -4,7 +4,7 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
import { findServiceBlockByLabel } from "test-utils/widget-assertions";
import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
@@ -12,12 +12,6 @@ vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component";
function expectBlockValue(container, label, value) {
const block = findServiceBlockByLabel(container, label);
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/caddy/component", () => {
beforeEach(() => {
vi.clearAllMocks();

View File

@@ -4,7 +4,7 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
import { findServiceBlockByLabel } from "test-utils/widget-assertions";
import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
@@ -12,12 +12,6 @@ vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component";
function expectBlockValue(container, label, value) {
const block = findServiceBlockByLabel(container, label);
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/changedetectionio/component", () => {
beforeEach(() => {
vi.clearAllMocks();

View File

@@ -4,7 +4,7 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
import { findServiceBlockByLabel } from "test-utils/widget-assertions";
import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
@@ -12,12 +12,6 @@ vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component";
function expectBlockValue(container, label, value) {
const block = findServiceBlockByLabel(container, label);
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/channelsdvrserver/component", () => {
beforeEach(() => {
vi.clearAllMocks();

View File

@@ -4,7 +4,7 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
import { findServiceBlockByLabel } from "test-utils/widget-assertions";
import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
@@ -12,12 +12,6 @@ vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component";
function expectBlockValue(container, label, value) {
const block = findServiceBlockByLabel(container, label);
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/checkmk/component", () => {
beforeEach(() => {
vi.clearAllMocks();

View File

@@ -4,7 +4,7 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
import { findServiceBlockByLabel } from "test-utils/widget-assertions";
import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
@@ -12,12 +12,6 @@ vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component";
function expectBlockValue(container, label, value) {
const block = findServiceBlockByLabel(container, label);
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/cloudflared/component", () => {
beforeEach(() => {
vi.clearAllMocks();

View File

@@ -65,7 +65,7 @@ const components = {
jackett: dynamic(() => import("./jackett/component")),
jdownloader: dynamic(() => import("./jdownloader/component")),
jellyfin: dynamic(() => import("./jellyfin/component")),
jellyseerr: dynamic(() => import("./jellyseerr/component")),
jellyseerr: dynamic(() => import("./seerr/component")),
jellystat: dynamic(() => import("./jellystat/component")),
kavita: dynamic(() => import("./kavita/component")),
komga: dynamic(() => import("./komga/component")),
@@ -97,7 +97,7 @@ const components = {
ombi: dynamic(() => import("./ombi/component")),
opendtu: dynamic(() => import("./opendtu/component")),
opnsense: dynamic(() => import("./opnsense/component")),
overseerr: dynamic(() => import("./overseerr/component")),
overseerr: dynamic(() => import("./seerr/component")),
openmediavault: dynamic(() => import("./openmediavault/component")),
openwrt: dynamic(() => import("./openwrt/component")),
paperlessngx: dynamic(() => import("./paperlessngx/component")),
@@ -124,8 +124,10 @@ const components = {
rutorrent: dynamic(() => import("./rutorrent/component")),
sabnzbd: dynamic(() => import("./sabnzbd/component")),
scrutiny: dynamic(() => import("./scrutiny/component")),
seerr: dynamic(() => import("./seerr/component")),
slskd: dynamic(() => import("./slskd/component")),
sonarr: dynamic(() => import("./sonarr/component")),
sparkyfitness: dynamic(() => import("./sparkyfitness/component")),
speedtest: dynamic(() => import("./speedtest/component")),
spoolman: dynamic(() => import("./spoolman/component")),
stash: dynamic(() => import("./stash/component")),
@@ -138,6 +140,7 @@ const components = {
tautulli: dynamic(() => import("./tautulli/component")),
technitium: dynamic(() => import("./technitium/component")),
tdarr: dynamic(() => import("./tdarr/component")),
tracearr: dynamic(() => import("./tracearr/component")),
traefik: dynamic(() => import("./traefik/component")),
transmission: dynamic(() => import("./transmission/component")),
trilium: dynamic(() => import("./trilium/component")),

View File

@@ -4,7 +4,7 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
import { findServiceBlockByLabel } from "test-utils/widget-assertions";
import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
@@ -12,12 +12,6 @@ vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component";
function expectBlockValue(container, label, value) {
const block = findServiceBlockByLabel(container, label);
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/crowdsec/component", () => {
beforeEach(() => {
vi.clearAllMocks();

View File

@@ -4,7 +4,7 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
import { findServiceBlockByLabel } from "test-utils/widget-assertions";
import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
@@ -16,12 +16,6 @@ vi.mock("../../components/widgets/queue/queueEntry", () => ({
import Component from "./component";
function expectBlockValue(container, label, value) {
const block = findServiceBlockByLabel(container, label);
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/deluge/component", () => {
beforeEach(() => {
vi.clearAllMocks();

View File

@@ -4,19 +4,13 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
import { findServiceBlockByLabel } from "test-utils/widget-assertions";
import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component";
function expectBlockValue(container, label, value) {
const block = findServiceBlockByLabel(container, label);
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/diskstation/component", () => {
beforeEach(() => {
vi.clearAllMocks();

View File

@@ -4,19 +4,13 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
import { findServiceBlockByLabel } from "test-utils/widget-assertions";
import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component";
function expectBlockValue(container, label, value) {
const block = findServiceBlockByLabel(container, label);
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/dispatcharr/component", () => {
beforeEach(() => {
vi.clearAllMocks();

View File

@@ -4,19 +4,13 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
import { findServiceBlockByLabel } from "test-utils/widget-assertions";
import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component";
function expectBlockValue(container, label, value) {
const block = findServiceBlockByLabel(container, label);
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/dockhand/component", () => {
beforeEach(() => {
vi.clearAllMocks();

View File

@@ -4,19 +4,13 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
import { findServiceBlockByLabel } from "test-utils/widget-assertions";
import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component";
function expectBlockValue(container, label, value) {
const block = findServiceBlockByLabel(container, label);
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/downloadstation/component", () => {
beforeEach(() => {
vi.clearAllMocks();

View File

@@ -4,19 +4,13 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
import { findServiceBlockByLabel } from "test-utils/widget-assertions";
import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component";
function expectBlockValue(container, label, value) {
const block = findServiceBlockByLabel(container, label);
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/esphome/component", () => {
beforeEach(() => {
vi.clearAllMocks();

View File

@@ -4,19 +4,13 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
import { findServiceBlockByLabel } from "test-utils/widget-assertions";
import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component";
function expectBlockValue(container, label, value) {
const block = findServiceBlockByLabel(container, label);
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/evcc/component", () => {
beforeEach(() => {
vi.clearAllMocks();

View File

@@ -4,19 +4,13 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
import { findServiceBlockByLabel } from "test-utils/widget-assertions";
import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component";
function expectBlockValue(container, label, value) {
const block = findServiceBlockByLabel(container, label);
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/filebrowser/component", () => {
beforeEach(() => {
vi.clearAllMocks();

View File

@@ -4,19 +4,13 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
import { findServiceBlockByLabel } from "test-utils/widget-assertions";
import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component";
function expectBlockValue(container, label, value) {
const block = findServiceBlockByLabel(container, label);
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/fileflows/component", () => {
beforeEach(() => {
vi.clearAllMocks();

View File

@@ -4,19 +4,13 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
import { findServiceBlockByLabel } from "test-utils/widget-assertions";
import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component";
function expectBlockValue(container, label, value) {
const block = findServiceBlockByLabel(container, label);
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/flood/component", () => {
beforeEach(() => {
vi.clearAllMocks();

View File

@@ -4,19 +4,13 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
import { findServiceBlockByLabel } from "test-utils/widget-assertions";
import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component";
function expectBlockValue(container, label, value) {
const block = findServiceBlockByLabel(container, label);
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/freshrss/component", () => {
beforeEach(() => {
vi.clearAllMocks();

View File

@@ -4,19 +4,13 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
import { findServiceBlockByLabel } from "test-utils/widget-assertions";
import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component";
function expectBlockValue(container, label, value) {
const block = findServiceBlockByLabel(container, label);
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/frigate/component", () => {
beforeEach(() => {
vi.clearAllMocks();

View File

@@ -4,19 +4,13 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
import { findServiceBlockByLabel } from "test-utils/widget-assertions";
import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component, { fritzboxDefaultFields } from "./component";
function expectBlockValue(container, label, value) {
const block = findServiceBlockByLabel(container, label);
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/fritzbox/component", () => {
beforeEach(() => {
vi.clearAllMocks();

View File

@@ -4,19 +4,13 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
import { findServiceBlockByLabel } from "test-utils/widget-assertions";
import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component";
function expectBlockValue(container, label, value) {
const block = findServiceBlockByLabel(container, label);
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/gamedig/component", () => {
beforeEach(() => {
vi.clearAllMocks();

View File

@@ -4,19 +4,13 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
import { findServiceBlockByLabel } from "test-utils/widget-assertions";
import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component";
function expectBlockValue(container, label, value) {
const block = findServiceBlockByLabel(container, label);
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/gatus/component", () => {
beforeEach(() => {
vi.clearAllMocks();

View File

@@ -4,19 +4,13 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
import { findServiceBlockByLabel } from "test-utils/widget-assertions";
import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component";
function expectBlockValue(container, label, value) {
const block = findServiceBlockByLabel(container, label);
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/ghostfolio/component", () => {
beforeEach(() => {
vi.clearAllMocks();

View File

@@ -4,19 +4,13 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
import { findServiceBlockByLabel } from "test-utils/widget-assertions";
import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component";
function expectBlockValue(container, label, value) {
const block = findServiceBlockByLabel(container, label);
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/gitea/component", () => {
beforeEach(() => {
vi.clearAllMocks();

View File

@@ -4,19 +4,13 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
import { findServiceBlockByLabel } from "test-utils/widget-assertions";
import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component";
function expectBlockValue(container, label, value) {
const block = findServiceBlockByLabel(container, label);
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/gitlab/component", () => {
beforeEach(() => {
vi.clearAllMocks();

View File

@@ -8,7 +8,9 @@ import useWidgetAPI from "utils/proxy/use-widget-api";
const statusMap = {
running: <ResolvedIcon icon="mdi-circle" width={32} height={32} />,
healthy: <ResolvedIcon icon="mdi-circle" width={32} height={32} />,
paused: <ResolvedIcon icon="mdi-circle-outline" width={32} height={32} />,
stopped: <ResolvedIcon icon="mdi-circle-double" width={32} height={32} />,
};
const defaultInterval = 1000;

View File

@@ -11,6 +11,15 @@ vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
// Avoid pulling Next/Image + ThemeContext requirements into these unit tests.
vi.mock("components/resolvedicon", () => ({ default: () => <span data-testid="resolvedicon" /> }));
vi.mock("next-i18next", () => ({
useTranslation: () => ({
t: (key, opts) => (key === "common.bytes" ? `${key}:${opts?.value}` : key),
}),
}));
// Avoid pulling Next/Image + ThemeContext requirements into these unit tests.
vi.mock("components/resolvedicon", () => ({ default: () => <span data-testid="resolvedicon" /> }));
import Component from "./containers";
describe("widgets/glances/metrics/containers", () => {
@@ -21,4 +30,78 @@ describe("widgets/glances/metrics/containers", () => {
});
expect(screen.getByText("-")).toBeInTheDocument();
});
it("renders a placeholder while loading", () => {
useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });
renderWithProviders(<Component service={{ widget: { chart: false, version: 3 } }} />, {
settings: { hideErrors: false },
});
expect(screen.getByText("-")).toBeInTheDocument();
});
it("renders nothing when there is an error", () => {
useWidgetAPI.mockReturnValue({ data: undefined, error: new Error("fail") });
renderWithProviders(<Component service={{ widget: { chart: false, version: 3 } }} />, {
settings: { hideErrors: false },
});
expect(screen.queryByText("resources.cpu")).not.toBeInTheDocument();
expect(screen.queryByText("-")).not.toBeInTheDocument();
});
it("renders container rows using v3 keys and formats values", () => {
useWidgetAPI.mockReturnValue({
data: [
{
Id: "one",
Status: "running",
name: "alpha",
cpu_percent: 12.34,
memory: { usage: 1000, inactive_file: 400 },
},
{
Id: "two",
Status: "paused",
name: "beta",
cpu_percent: 99.99,
memory: { usage: 2000, inactive_file: 1000 },
},
],
error: undefined,
});
renderWithProviders(<Component service={{ widget: { chart: false, version: 3 } }} />, {
settings: { hideErrors: false },
});
// data.splice(1) keeps only one item when chart is false
expect(screen.getByText("resources.cpu")).toBeInTheDocument();
expect(screen.getByText("resources.mem")).toBeInTheDocument();
expect(screen.getByText("alpha")).toBeInTheDocument();
expect(screen.queryByText("beta")).not.toBeInTheDocument();
expect(screen.getByText("12.3%")).toBeInTheDocument();
expect(screen.getByText("common.bytes:600")).toBeInTheDocument();
expect(screen.getAllByTestId("resolvedicon")).toHaveLength(1);
});
it("limits rows to 5 when chart is enabled", () => {
const data = Array.from({ length: 6 }).map((_, index) => ({
Id: `id-${index}`,
Status: "healthy",
name: `item-${index}`,
cpu_percent: index + 0.1,
memory: { usage: 100 * (index + 1), inactive_file: 0 },
}));
useWidgetAPI.mockReturnValue({ data, error: undefined });
renderWithProviders(<Component service={{ widget: { chart: true, version: 3 } }} />, {
settings: { hideErrors: false },
});
expect(screen.getByText("item-0")).toBeInTheDocument();
expect(screen.getByText("item-4")).toBeInTheDocument();
expect(screen.queryByText("item-5")).not.toBeInTheDocument();
});
});

View File

@@ -46,7 +46,7 @@ export default function Component({ service }) {
let listYPosition = "bottom-4";
if (chart) {
headerYPosition = "-top-6";
listYPosition = "-top-3";
listYPosition = "-top-2";
}
return (

View File

@@ -4,19 +4,13 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
import { findServiceBlockByLabel } from "test-utils/widget-assertions";
import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component";
function expectBlockValue(container, label, value) {
const block = findServiceBlockByLabel(container, label);
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/gluetun/component", () => {
beforeEach(() => {
vi.clearAllMocks();

View File

@@ -4,19 +4,13 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
import { findServiceBlockByLabel } from "test-utils/widget-assertions";
import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component";
function expectBlockValue(container, label, value) {
const block = findServiceBlockByLabel(container, label);
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/gotify/component", () => {
beforeEach(() => {
vi.clearAllMocks();

View File

@@ -4,19 +4,13 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
import { findServiceBlockByLabel } from "test-utils/widget-assertions";
import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component";
function expectBlockValue(container, label, value) {
const block = findServiceBlockByLabel(container, label);
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/grafana/component", () => {
beforeEach(() => {
vi.clearAllMocks();

View File

@@ -4,19 +4,13 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
import { findServiceBlockByLabel } from "test-utils/widget-assertions";
import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component";
function expectBlockValue(container, label, value) {
const block = findServiceBlockByLabel(container, label);
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/hdhomerun/component", () => {
beforeEach(() => {
vi.clearAllMocks();

View File

@@ -4,19 +4,13 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
import { findServiceBlockByLabel } from "test-utils/widget-assertions";
import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component";
function expectBlockValue(container, label, value) {
const block = findServiceBlockByLabel(container, label);
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/headscale/component", () => {
beforeEach(() => {
vi.clearAllMocks();

View File

@@ -4,19 +4,13 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
import { findServiceBlockByLabel } from "test-utils/widget-assertions";
import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component";
function expectBlockValue(container, label, value) {
const block = findServiceBlockByLabel(container, label);
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/healthchecks/component", () => {
beforeEach(() => {
vi.clearAllMocks();

View File

@@ -4,19 +4,13 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
import { findServiceBlockByLabel } from "test-utils/widget-assertions";
import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component, { homeboxDefaultFields } from "./component";
function expectBlockValue(container, label, value) {
const block = findServiceBlockByLabel(container, label);
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/homebox/component", () => {
beforeEach(() => {
vi.clearAllMocks();

View File

@@ -4,19 +4,13 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
import { findServiceBlockByLabel } from "test-utils/widget-assertions";
import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component";
function expectBlockValue(container, label, value) {
const block = findServiceBlockByLabel(container, label);
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/immich/component", () => {
beforeEach(() => {
vi.clearAllMocks();

View File

@@ -4,19 +4,13 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
import { findServiceBlockByLabel } from "test-utils/widget-assertions";
import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component";
function expectBlockValue(container, label, value) {
const block = findServiceBlockByLabel(container, label);
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/jackett/component", () => {
beforeEach(() => {
vi.clearAllMocks();

View File

@@ -4,19 +4,13 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
import { findServiceBlockByLabel } from "test-utils/widget-assertions";
import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component";
function expectBlockValue(container, label, value) {
const block = findServiceBlockByLabel(container, label);
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/jdownloader/component", () => {
beforeEach(() => {
vi.clearAllMocks();

View File

@@ -1,39 +0,0 @@
import Block from "components/services/widget/block";
import Container from "components/services/widget/container";
import useWidgetAPI from "utils/proxy/use-widget-api";
export const jellyseerrDefaultFields = ["pending", "approved", "available"];
export default function Component({ service }) {
const { widget } = service;
widget.fields = widget?.fields?.length ? widget.fields : jellyseerrDefaultFields;
const isIssueEnabled = widget.fields.includes("issues");
const { data: statsData, error: statsError } = useWidgetAPI(widget, "request/count");
const { data: issueData, error: issueError } = useWidgetAPI(widget, isIssueEnabled ? "issue/count" : "");
if (statsError || (isIssueEnabled && issueError)) {
return <Container service={service} error={statsError ? statsError : issueError} />;
}
if (!statsData || (isIssueEnabled && !issueData)) {
return (
<Container service={service}>
<Block label="jellyseerr.pending" />
<Block label="jellyseerr.approved" />
<Block label="jellyseerr.available" />
<Block label="jellyseerr.issues" />
</Container>
);
}
return (
<Container service={service}>
<Block label="jellyseerr.pending" value={statsData.pending} />
<Block label="jellyseerr.approved" value={statsData.approved} />
<Block label="jellyseerr.available" value={statsData.available} />
<Block label="jellyseerr.issues" value={`${issueData?.open} / ${issueData?.total}`} />
</Container>
);
}

View File

@@ -1,63 +0,0 @@
// @vitest-environment jsdom
import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component, { jellyseerrDefaultFields } from "./component";
describe("widgets/jellyseerr/component", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("defaults fields and filters to 3 blocks while loading when issues are not enabled", () => {
useWidgetAPI
.mockReturnValueOnce({ data: undefined, error: undefined }) // request/count
.mockReturnValueOnce({ data: undefined, error: undefined }); // issue/count disabled (endpoint = "")
const service = { widget: { type: "jellyseerr", url: "http://x" } };
const { container } = renderWithProviders(<Component service={service} />, { settings: { hideErrors: false } });
expect(service.widget.fields).toEqual(jellyseerrDefaultFields);
expect(useWidgetAPI.mock.calls[1][1]).toBe("");
expect(container.querySelectorAll(".service-block")).toHaveLength(3);
expect(screen.getByText("jellyseerr.pending")).toBeInTheDocument();
expect(screen.getByText("jellyseerr.approved")).toBeInTheDocument();
expect(screen.getByText("jellyseerr.available")).toBeInTheDocument();
expect(screen.queryByText("jellyseerr.issues")).toBeNull();
});
it("renders issues when enabled (and calls the issue/count endpoint)", () => {
useWidgetAPI
.mockReturnValueOnce({ data: { pending: 1, approved: 2, available: 3 }, error: undefined })
.mockReturnValueOnce({ data: { open: 1, total: 2 }, error: undefined });
const service = {
widget: { type: "jellyseerr", url: "http://x", fields: ["pending", "approved", "available", "issues"] },
};
const { container } = renderWithProviders(<Component service={service} />, { settings: { hideErrors: false } });
expect(useWidgetAPI.mock.calls[1][1]).toBe("issue/count");
expect(container.querySelectorAll(".service-block")).toHaveLength(4);
expect(screen.getByText("1 / 2")).toBeInTheDocument();
});
it("renders error UI when issues are enabled and issue/count errors", () => {
useWidgetAPI
.mockReturnValueOnce({ data: { pending: 0, approved: 0, available: 0 }, error: undefined })
.mockReturnValueOnce({ data: undefined, error: { message: "nope" } });
renderWithProviders(
<Component service={{ widget: { type: "jellyseerr", url: "http://x", fields: ["issues"] } }} />,
{ settings: { hideErrors: false } },
);
expect(screen.getAllByText(/widget\.api_error/i).length).toBeGreaterThan(0);
expect(screen.getByText("nope")).toBeInTheDocument();
});
});

View File

@@ -4,19 +4,13 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
import { findServiceBlockByLabel } from "test-utils/widget-assertions";
import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component, { karakeepDefaultFields } from "./component";
function expectBlockValue(container, label, value) {
const block = findServiceBlockByLabel(container, label);
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/karakeep/component", () => {
beforeEach(() => {
vi.clearAllMocks();

View File

@@ -4,19 +4,13 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
import { findServiceBlockByLabel } from "test-utils/widget-assertions";
import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component";
function expectBlockValue(container, label, value) {
const block = findServiceBlockByLabel(container, label);
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/komga/component", () => {
beforeEach(() => {
vi.clearAllMocks();

View File

@@ -4,19 +4,13 @@ import { screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
import { findServiceBlockByLabel } from "test-utils/widget-assertions";
import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component";
function expectBlockValue(container, label, value) {
const block = findServiceBlockByLabel(container, label);
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/kopia/component", () => {
beforeEach(() => {
vi.clearAllMocks();

View File

@@ -4,19 +4,13 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
import { findServiceBlockByLabel } from "test-utils/widget-assertions";
import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component";
function expectBlockValue(container, label, value) {
const block = findServiceBlockByLabel(container, label);
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/lidarr/component", () => {
beforeEach(() => {
vi.clearAllMocks();

View File

@@ -4,19 +4,13 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
import { findServiceBlockByLabel } from "test-utils/widget-assertions";
import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component";
function expectBlockValue(container, label, value) {
const block = findServiceBlockByLabel(container, label);
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/linkwarden/component", () => {
beforeEach(() => {
vi.clearAllMocks();

View File

@@ -4,19 +4,13 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
import { findServiceBlockByLabel } from "test-utils/widget-assertions";
import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component";
function expectBlockValue(container, label, value) {
const block = findServiceBlockByLabel(container, label);
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/lubelogger/component", () => {
beforeEach(() => {
vi.clearAllMocks();

View File

@@ -4,19 +4,13 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
import { findServiceBlockByLabel } from "test-utils/widget-assertions";
import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component";
function expectBlockValue(container, label, value) {
const block = findServiceBlockByLabel(container, label);
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/mailcow/component", () => {
beforeEach(() => {
vi.clearAllMocks();

View File

@@ -4,19 +4,13 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
import { findServiceBlockByLabel } from "test-utils/widget-assertions";
import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component";
function expectBlockValue(container, label, value) {
const block = findServiceBlockByLabel(container, label);
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/mastodon/component", () => {
beforeEach(() => {
vi.clearAllMocks();

View File

@@ -4,19 +4,13 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
import { findServiceBlockByLabel } from "test-utils/widget-assertions";
import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component";
function expectBlockValue(container, label, value) {
const block = findServiceBlockByLabel(container, label);
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/mealie/component", () => {
beforeEach(() => {
vi.clearAllMocks();

View File

@@ -4,19 +4,13 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
import { findServiceBlockByLabel } from "test-utils/widget-assertions";
import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component";
function expectBlockValue(container, label, value) {
const block = findServiceBlockByLabel(container, label);
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/medusa/component", () => {
beforeEach(() => {
vi.clearAllMocks();

View File

@@ -4,19 +4,13 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
import { findServiceBlockByLabel } from "test-utils/widget-assertions";
import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component";
function expectBlockValue(container, label, value) {
const block = findServiceBlockByLabel(container, label);
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/mikrotik/component", () => {
beforeEach(() => {
vi.clearAllMocks();

View File

@@ -4,19 +4,13 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
import { findServiceBlockByLabel } from "test-utils/widget-assertions";
import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component";
function expectBlockValue(container, label, value) {
const block = findServiceBlockByLabel(container, label);
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/minecraft/component", () => {
beforeEach(() => {
vi.clearAllMocks();

View File

@@ -4,19 +4,13 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
import { findServiceBlockByLabel } from "test-utils/widget-assertions";
import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component";
function expectBlockValue(container, label, value) {
const block = findServiceBlockByLabel(container, label);
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/miniflux/component", () => {
beforeEach(() => {
vi.clearAllMocks();

View File

@@ -4,19 +4,13 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
import { findServiceBlockByLabel } from "test-utils/widget-assertions";
import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component";
function expectBlockValue(container, label, value) {
const block = findServiceBlockByLabel(container, label);
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/moonraker/component", () => {
beforeEach(() => {
vi.clearAllMocks();

View File

@@ -4,19 +4,13 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
import { findServiceBlockByLabel } from "test-utils/widget-assertions";
import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component";
function expectBlockValue(container, label, value) {
const block = findServiceBlockByLabel(container, label);
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/mylar/component", () => {
beforeEach(() => {
vi.clearAllMocks();

View File

@@ -4,19 +4,13 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
import { findServiceBlockByLabel } from "test-utils/widget-assertions";
import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component";
function expectBlockValue(container, label, value) {
const block = findServiceBlockByLabel(container, label);
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/myspeed/component", () => {
beforeEach(() => {
vi.clearAllMocks();

View File

@@ -4,19 +4,13 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
import { findServiceBlockByLabel } from "test-utils/widget-assertions";
import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component";
function expectBlockValue(container, label, value) {
const block = findServiceBlockByLabel(container, label);
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/netalertx/component", () => {
beforeEach(() => {
vi.clearAllMocks();

View File

@@ -4,19 +4,13 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
import { findServiceBlockByLabel } from "test-utils/widget-assertions";
import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component";
function expectBlockValue(container, label, value) {
const block = findServiceBlockByLabel(container, label);
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/netdata/component", () => {
beforeEach(() => {
vi.clearAllMocks();

View File

@@ -4,19 +4,13 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
import { findServiceBlockByLabel } from "test-utils/widget-assertions";
import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component";
function expectBlockValue(container, label, value) {
const block = findServiceBlockByLabel(container, label);
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/npm/component", () => {
beforeEach(() => {
vi.clearAllMocks();

View File

@@ -4,19 +4,13 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
import { findServiceBlockByLabel } from "test-utils/widget-assertions";
import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component";
function expectBlockValue(container, label, value) {
const block = findServiceBlockByLabel(container, label);
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/nzbget/component", () => {
beforeEach(() => {
vi.clearAllMocks();

View File

@@ -4,19 +4,13 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
import { findServiceBlockByLabel } from "test-utils/widget-assertions";
import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component";
function expectBlockValue(container, label, value) {
const block = findServiceBlockByLabel(container, label);
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/octoprint/component", () => {
beforeEach(() => {
vi.clearAllMocks();

View File

@@ -6,6 +6,40 @@ const proxyName = "omadaProxyHandler";
const logger = createLogger(proxyName);
function parseOmadaJson(data, { step, status, contentType, url }) {
const body = Buffer.isBuffer(data) ? data.toString() : String(data ?? "");
try {
return JSON.parse(body);
} catch (error) {
logger.debug(
"Failed parsing Omada %s response as JSON (HTTP %d, content-type: %s, url: %s). Body: %s",
step,
status,
contentType ?? "unknown",
url,
body,
);
throw error;
}
}
function isLikelyHtmlResponse(contentType, data) {
const body = Buffer.isBuffer(data) ? data.toString() : String(data ?? "");
return contentType?.includes("text/html") || body.startsWith("<!DOCTYPE") || body.startsWith("<html");
}
function extractCookieHeader(responseHeaders) {
const setCookieHeader = responseHeaders?.["set-cookie"];
if (!setCookieHeader) return undefined;
if (Array.isArray(setCookieHeader)) {
return setCookieHeader.map((cookie) => cookie.split(";")[0]).join("; ");
}
return String(setCookieHeader).split(";")[0];
}
async function login(loginUrl, username, password, controllerVersionMajor) {
const params = {
username,
@@ -20,15 +54,17 @@ async function login(loginUrl, username, password, controllerVersionMajor) {
};
}
const [status, contentType, data] = await httpProxy(loginUrl, {
const [status, contentType, data, responseHeaders] = await httpProxy(loginUrl, {
method: "POST",
cookieHeader: "X-Bypass-Cookie",
body: JSON.stringify(params),
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
});
return [status, JSON.parse(data.toString())];
return [status, contentType, data, extractCookieHeader(responseHeaders)];
}
export default async function omadaProxyHandler(req, res) {
@@ -86,25 +122,33 @@ 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
.status(status)
.json({ error: { message: "Error logging in to Oamda controller", url: loginUrl, data: loginResponseData } });
.status(loginStatus)
.json({ error: { message: "Error logging in to Omada controller", url: loginUrl, data: loginResponseData } });
}
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,14 +338,27 @@ 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}`);
return res.status(500).send(data);
return res.status(status === 200 ? 500 : status).json({
error: {
message: "Error getting stats",
url: siteStatsUrl,
data: siteResponseData,
},
});
}
const alertUrl =
@@ -236,9 +369,16 @@ export default async function omadaProxyHandler(req, res) {
[status, contentType, data] = await httpProxy(alertUrl, {
headers: {
"Csrf-Token": token,
...(omadaCookieHeader ? { Cookie: omadaCookieHeader } : {}),
},
cookieHeader: "X-Bypass-Cookie",
});
const alertResponseData = parseOmadaJson(data, {
step: "alerts",
status,
contentType,
url: alertUrl,
});
const alertResponseData = JSON.parse(data);
activeUser = siteResponseData.result.totalClientNum;
connectedAp = siteResponseData.result.connectedApNum;

View File

@@ -151,7 +151,7 @@ describe("widgets/omada/proxy", () => {
await omadaProxyHandler(req, res);
expect(res.statusCode).toBe(200);
expect(res.body.error.message).toBe("Error logging in to Oamda controller");
expect(res.body.error.message).toBe("Error logging in to Omada controller");
expect(res.body.error.url).toBe("http://omada/api/v2/login");
expect(res.body.error.data).toEqual({ errorCode: 1, msg: "nope" });
});
@@ -288,7 +288,7 @@ describe("widgets/omada/proxy", () => {
expect(res.body.error.message).toBe("Error switching site");
});
it("returns 500 with the raw payload when overview stats retrieval fails (v5)", async () => {
it("returns a structured error when overview stats retrieval fails (v5)", async () => {
getServiceWidget.mockResolvedValue({ url: "http://omada", username: "u", password: "p", site: "Default" });
httpProxy
@@ -316,6 +316,81 @@ describe("widgets/omada/proxy", () => {
await omadaProxyHandler(req, res);
expect(res.statusCode).toBe(500);
expect(res.body).toBe(JSON.stringify({ errorCode: 1, msg: "bad" }));
expect(res.body).toEqual({
error: {
message: "Error getting stats",
url: "http://omada/cid/api/v2/sites/siteid/dashboard/overviewDiagram?token=t&currentPage=1&currentPageSize=1000",
data: { errorCode: 1, msg: "bad" },
},
});
});
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,
}),
);
});
});

Some files were not shown because too many files have changed in this diff Show More