Compare commits

...

5 Commits

Author SHA1 Message Date
shamoon
f4efc71350 Update jellyseerr.md 2025-08-29 17:22:41 -07:00
AdamWHY2K
b663e56174 Enhancement: Add issues field to Jellyseerr widget (#5725)
Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com>
2025-08-29 18:14:13 +00:00
Kieran
a9ec5aa1e7 Documentation: Document needed permissions for Immich (#5706) 2025-08-23 20:28:08 +00:00
shamoon
44405b4aae Merge branch 'main' into dev 2025-08-21 13:06:07 -07:00
Derek Kaser
842cec2fee Feature: Unraid widget (#5683)
Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com>
2025-08-21 18:06:49 +00:00
15 changed files with 328 additions and 9 deletions

View File

@@ -10,7 +10,8 @@ Learn more about [Immich](https://github.com/immich-app/immich).
| < v1.118 | 1 (default) |
| >= v1.118 | 2 |
Find your API key under `Account Settings > API Keys`.
Find your API key under `Account Settings > API Keys`. The key should have the
`server.statistics` permission.
Allowed fields: `["users" ,"photos", "videos", "storage"]`.

View File

@@ -139,6 +139,7 @@ You can also find a list of all available service widgets in the sidebar navigat
- [TubeArchivist](tubearchivist.md)
- [UniFi Controller](unifi-controller.md)
- [Unmanic](unmanic.md)
- [Unraid](unraid.md)
- [Uptime Kuma](uptime-kuma.md)
- [UptimeRobot](uptimerobot.md)
- [UrBackup](urbackup.md)

View File

@@ -7,7 +7,8 @@ Learn more about [Jellyseerr](https://github.com/Fallenbagel/jellyseerr).
Find your API key under `Settings > General > API Key`.
Allowed fields: `["pending", "approved", "available"]`.
Allowed fields: `["pending", "approved", "available", "issues"]`.
Default fields: `["pending", "approved", "available"]`.
```yaml
widget:

View File

@@ -0,0 +1,28 @@
---
title: Unraid
description: Unraid Widget Configuration
---
Learn more about [Unraid](https://unraid.net/).
The Unraid widget allows you to monitor the resources of an Unraid server.
**Minimum Requirements:**
- Unraid 7.2 -or- Unraid Connect plugin 2025.08.19.1850
- API key with the **GUEST** (read only) role: [Managing API Keys](https://docs.unraid.net/go/managing-api-keys)
The widget can display metrics for selected Unraid pools. If using one of the "pool" fields, you must also add the pool name to the settings.
**Allowed fields:** `["cpu","memoryPercent","memoryAvailable","memoryUsed","notifications","arrayFreeSpace","arrayUsedSpace","arrayUsedPercent","status","pool1UsedSpace","pool1FreeSpace","pool1UsedPercent","pool2UsedSpace","pool2FreeSpace","pool2UsedPercent","pool3UsedSpace","pool3FreeSpace","pool3UsedPercent","pool4UsedSpace","pool4FreeSpace","pool4UsedPercent"]`
```yaml
widget:
type: unraid
url: https://unraid.host.or.ip
key: api-key
pool1: pool1name # required only if using pool1 fields
pool2: pool2name # required only if using pool2 fields
pool3: pool3name # required only if using pool3 fields
pool4: pool4name # required only if using pool4 fields
```

View File

@@ -165,6 +165,7 @@ nav:
- widgets/services/tubearchivist.md
- widgets/services/unifi-controller.md
- widgets/services/unmanic.md
- widgets/services/unraid.md
- widgets/services/uptime-kuma.md
- widgets/services/uptimerobot.md
- widgets/services/urbackup.md

View File

@@ -275,7 +275,8 @@
"jellyseerr": {
"pending": "Pending",
"approved": "Approved",
"available": "Available"
"available": "Available",
"issues": "Open Issues"
},
"overseerr": {
"pending": "Pending",
@@ -1083,5 +1084,27 @@
"nextMonthlyCost": "Next Month",
"previousMonthlyCost": "Prev. Month",
"nextRenewingSubscription": "Next Payment"
},
"unraid": {
"STARTED": "Started",
"STOPPED": "Stopped",
"NEW_ARRAY": "New Array",
"RECON_DISK": "Reconstructing Disk",
"DISABLE_DISK": "Disk Disabled",
"SWAP_DSBL": "Swap Disable",
"INVALID_EXPANSION": "Invalid Expansion",
"PARITY_NOT_BIGGEST": "Parity Not Biggest",
"TOO_MANY_MISSING_DISKS": "Too Many Missing Disks",
"NEW_DISK_TOO_SMALL": "New Disk Too Small",
"NO_DATA_DISKS": "No Data Disks",
"notifications": "Notifications",
"status": "Status",
"cpu": "CPU",
"memoryUsed": "Memory Used",
"memoryAvailable": "Memory Available",
"arrayUsed": "Array Used",
"arrayFree": "Array Free",
"poolUsed": "{{pool}} Used",
"poolFree": "{{pool}} Free"
}
}

View File

@@ -37,12 +37,12 @@ export default function Container({ error = false, children, service }) {
if (!field.includes(".")) {
fullField = `${type}.${field}`;
}
let matches = fullField === child?.props?.label;
let matches = fullField === (child?.props?.field || child?.props?.label);
// check if the field is an 'alias'
if (matches) {
return true;
} else if (ALIASED_WIDGETS[type]) {
matches = fullField.replace(type, ALIASED_WIDGETS[type]) === child?.props?.label;
matches = fullField.replace(type, ALIASED_WIDGETS[type]) === (child?.props?.field || child?.props?.label);
return matches;
}

View File

@@ -396,6 +396,12 @@ export function cleanServiceGroups(groups) {
// unifi
site,
// unraid
pool1,
pool2,
pool3,
pool4,
// vikunja
enableTaskList,
@@ -611,6 +617,12 @@ export function cleanServiceGroups(groups) {
if (type === "grafana") {
if (alerts) widget.alerts = alerts;
}
if (type === "unraid") {
if (pool1) widget.pool1 = pool1;
if (pool2) widget.pool2 = pool2;
if (pool3) widget.pool3 = pool3;
if (pool4) widget.pool4 = pool4;
}
return widget;
});
return cleanedService;

View File

@@ -139,6 +139,7 @@ const components = {
truenas: dynamic(() => import("./truenas/component")),
unifi: dynamic(() => import("./unifi/component")),
unmanic: dynamic(() => import("./unmanic/component")),
unraid: dynamic(() => import("./unraid/component")),
uptimekuma: dynamic(() => import("./uptimekuma/component")),
uptimerobot: dynamic(() => import("./uptimerobot/component")),
urbackup: dynamic(() => import("./urbackup/component")),

View File

@@ -3,21 +3,27 @@ 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;
const { data: statsData, error: statsError } = useWidgetAPI(widget, "request/count");
widget.fields = widget?.fields?.length ? widget.fields : jellyseerrDefaultFields;
const isIssueEnabled = widget.fields.includes("issues");
if (statsError) {
return <Container service={service} error={statsError} />;
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) {
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>
);
}
@@ -27,6 +33,7 @@ export default function Component({ 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

@@ -9,6 +9,10 @@ const widget = {
endpoint: "request/count",
validate: ["pending", "approved", "available"],
},
"issue/count": {
endpoint: "issue/count",
validate: ["open", "total"],
},
},
};

View File

@@ -0,0 +1,93 @@
import Block from "components/services/widget/block";
import Container from "components/services/widget/container";
import { useTranslation } from "next-i18next";
import useWidgetAPI from "utils/proxy/use-widget-api";
const UNRAID_DEFAULT_FIELDS = ["status", "cpu", "memoryPercent", "notifications"];
const MAX_ALLOWED_FIELDS = 4;
const POOLS = ["pool1", "pool2", "pool3", "pool4"];
const POOL_FIELDS = [
{ param: "UsedSpace", label: "poolUsed", valueKey: "fsUsed", valueType: "common.bytes" },
{ param: "FreeSpace", label: "poolFree", valueKey: "fsFree", valueType: "common.bytes" },
{ param: "UsedPercent", label: "poolUsed", valueKey: "fsUsedPercent", valueType: "common.percent" },
];
export default function Component({ service }) {
const { t } = useTranslation();
const { widget } = service;
const { data, error } = useWidgetAPI(widget);
if (error) {
return <Container service={service} error={error} />;
}
if (!widget.fields?.length) {
widget.fields = UNRAID_DEFAULT_FIELDS;
} else if (widget.fields.length > MAX_ALLOWED_FIELDS) {
widget.fields = widget.fields.slice(0, MAX_ALLOWED_FIELDS);
}
if (!data) {
return (
<Container service={service}>
<Block label="unraid.status" />
<Block label="unraid.memoryAvailable" />
<Block label="unraid.memoryUsed" />
<Block field="unraid.memoryPercent" label="unraid.memoryUsed" />
<Block label="unraid.cpu" />
<Block label="unraid.notifications" />
<Block field="unraid.arrayUsedSpace" label="unraid.arrayUsed" />
<Block field="unraid.arrayFree" label="unraid.arrayFree" />
<Block field="unraid.arrayUsedPercent" label="unraid.arrayUsed" />
{...POOLS.flatMap((pool) =>
POOL_FIELDS.map(({ param, label }) => (
<Block
key={`${pool}-${param}`}
field={`unraid.${pool}${param}`}
label={t(`unraid.${label}`, { pool: widget?.[pool] || pool })}
/>
)),
)}
</Container>
);
}
return (
<Container service={service}>
<Block label="unraid.status" value={t(`unraid.${data.arrayState}`)} />
<Block label="unraid.memoryAvailable" value={t("common.bbytes", { value: data.memoryAvailable })} />
<Block label="unraid.memoryUsed" value={t("common.bbytes", { value: data.memoryUsed })} />
<Block
field="unraid.memoryPercent"
label="unraid.memoryUsed"
value={t("common.percent", { value: data.memoryUsedPercent })}
/>
<Block label="unraid.cpu" value={t("common.percent", { value: data.cpuPercent })} />
<Block label="unraid.notifications" value={t("common.number", { value: data.unreadNotifications })} />
<Block
field="unraid.arrayUsedSpace"
label="unraid.arrayUsed"
value={t("common.bytes", { value: data.arrayUsed })}
/>
<Block label="unraid.arrayFree" value={t("common.bytes", { value: data.arrayFree })} />
<Block
field="unraid.arrayUsedPercent"
label="unraid.arrayUsed"
value={t("common.percent", { value: data.arrayUsedPercent })}
/>
{...POOLS.flatMap((pool) =>
POOL_FIELDS.map(({ param, label, valueKey, valueType }) => (
<Block
key={`${pool}-${param}`}
field={`unraid.${pool}${param}`}
label={t(`unraid.${label}`, { pool: widget?.[pool] || pool })}
value={t(valueType, { value: data.caches?.[widget?.[pool]]?.[valueKey] || "-" })}
/>
)),
)}
</Container>
);
}

138
src/widgets/unraid/proxy.js Normal file
View File

@@ -0,0 +1,138 @@
import getServiceWidget from "utils/config/service-helpers";
import createLogger from "utils/logger";
import { asJson } from "utils/proxy/api-helpers";
import { httpProxy } from "utils/proxy/http";
const logger = createLogger("unraidProxyHandler");
const graphqlQuery = `
{
array {
state
capacity {
kilobytes {
free
total
used
}
}
caches {
name
fsType
fsSize
fsFree
fsUsed
}
}
metrics {
memory {
active
available
percentTotal
}
cpu {
percentTotal
}
}
notifications {
overview {
unread {
total
}
}
}
}
`;
function processUnraidResponse(data) {
const response = {};
try {
data = asJson(data)?.data;
response["memoryUsedPercent"] = data?.metrics?.memory?.percentTotal ?? null;
response["memoryUsed"] = data?.metrics?.memory?.active ?? null;
response["memoryAvailable"] = data?.metrics?.memory?.available ?? null;
response["cpuPercent"] = data?.metrics?.cpu?.percentTotal ?? null;
response["unreadNotifications"] = data?.notifications?.overview?.unread?.total ?? null;
response["arrayState"] = data?.array?.state ?? null;
response["arrayFree"] = data?.array?.capacity?.kilobytes?.free * 1000 ?? null;
response["arrayUsed"] = data?.array?.capacity?.kilobytes?.used * 1000 ?? null;
response["arrayUsedPercent"] =
(data?.array?.capacity?.kilobytes?.used / data?.array?.capacity?.kilobytes?.total) * 100 ?? null;
response["caches"] = {};
if (data?.array?.caches) {
data.array.caches.forEach((cache) => {
if (cache.fsType) {
response.caches[cache.name] = {
fsFree: cache.fsFree * 1000,
fsUsed: cache.fsUsed * 1000,
fsUsedPercent: (cache.fsUsed / cache.fsSize) * 100 ?? null,
};
}
});
}
} catch (error) {
return { error: error.message };
}
return response;
}
export default async function unraidProxyHandler(req, res) {
const { group, service, index } = req.query;
if (!group || !service) {
logger.debug("Invalid or missing service '%s' or group '%s'", service, group);
return res.status(400).json({ error: "Invalid proxy service type" });
}
const widget = await getServiceWidget(group, service, index);
if (!widget) {
logger.debug("Invalid or missing widget for service '%s' in group '%s'", service, group);
return res.status(400).json({ error: "Invalid proxy service type" });
}
const url = new URL(widget.url + "/graphql");
const headers = {
"Content-Type": "application/json",
Accept: `application/json`,
"X-API-Key": `${widget.key}`,
};
const params = {
method: "POST",
headers,
};
params.body = JSON.stringify({
query: graphqlQuery,
});
const [status, , data] = await httpProxy(url, params);
if (status === 204 || status === 304) {
return res.status(status).end();
}
if (status !== 200) {
logger.error(
"Error getting data from Unraid for service '%s' in group '%s': %d. Data: %s",
service,
group,
status,
data,
);
return res.status(status).send({ error: { message: "Error calling Unraid API.", data } });
}
const result = processUnraidResponse(data);
if (result.error) {
logger.error("Error processing Unraid data: %s", result.error);
return res.status(500).json({ error: result.error });
}
res.setHeader("Content-Type", "application/json");
return res.status(status).send(result);
}

View File

@@ -0,0 +1,7 @@
import unraidProxyHandler from "./proxy";
const widget = {
proxyHandler: unraidProxyHandler,
};
export default widget;

View File

@@ -130,6 +130,7 @@ import truenas from "./truenas/widget";
import tubearchivist from "./tubearchivist/widget";
import unifi from "./unifi/widget";
import unmanic from "./unmanic/widget";
import unraid from "./unraid/widget";
import uptimekuma from "./uptimekuma/widget";
import uptimerobot from "./uptimerobot/widget";
import urbackup from "./urbackup/widget";
@@ -278,6 +279,7 @@ const widgets = {
unifi,
unifi_console: unifi,
unmanic,
unraid,
uptimekuma,
uptimerobot,
urbackup,