mirror of
https://github.com/gethomepage/homepage.git
synced 2026-01-13 20:52:45 +08:00
Enhancement: TrueNAS widget web socket API support (#6161)
Some checks failed
Crowdin Action / Crowdin Sync (push) Has been cancelled
Docker CI / Linting Checks (push) Has been cancelled
Docker CI / Docker Build & Push (push) Has been cancelled
Repository Maintenance / Stale (push) Has been cancelled
Repository Maintenance / Lock Old Threads (push) Has been cancelled
Repository Maintenance / Close Answered Discussions (push) Has been cancelled
Repository Maintenance / Close Outdated Discussions (push) Has been cancelled
Repository Maintenance / Close Unsupported Feature Requests (push) Has been cancelled
Some checks failed
Crowdin Action / Crowdin Sync (push) Has been cancelled
Docker CI / Linting Checks (push) Has been cancelled
Docker CI / Docker Build & Push (push) Has been cancelled
Repository Maintenance / Stale (push) Has been cancelled
Repository Maintenance / Lock Old Threads (push) Has been cancelled
Repository Maintenance / Close Answered Discussions (push) Has been cancelled
Repository Maintenance / Close Outdated Discussions (push) Has been cancelled
Repository Maintenance / Close Unsupported Feature Requests (push) Has been cancelled
This commit is contained in:
@@ -5,6 +5,11 @@ description: TrueNas Scale Widget Configuration
|
|||||||
|
|
||||||
Learn more about [TrueNas](https://www.truenas.com/).
|
Learn more about [TrueNas](https://www.truenas.com/).
|
||||||
|
|
||||||
|
| TrueNAS Version | Homepage widget version |
|
||||||
|
| ----------------------- | ----------------------- |
|
||||||
|
| < 26.04 (REST API) | 1 (default) |
|
||||||
|
| > 25.04 (Websocket API) | 2 |
|
||||||
|
|
||||||
Allowed fields: `["load", "uptime", "alerts"]`.
|
Allowed fields: `["load", "uptime", "alerts"]`.
|
||||||
|
|
||||||
To create an API Key, follow [the official TrueNAS documentation](https://www.truenas.com/docs/scale/scaletutorials/toptoolbar/managingapikeys/).
|
To create an API Key, follow [the official TrueNAS documentation](https://www.truenas.com/docs/scale/scaletutorials/toptoolbar/managingapikeys/).
|
||||||
@@ -17,6 +22,7 @@ To use the `enablePools` option with TrueNAS Core, the `nasType` parameter is re
|
|||||||
widget:
|
widget:
|
||||||
type: truenas
|
type: truenas
|
||||||
url: http://truenas.host.or.ip
|
url: http://truenas.host.or.ip
|
||||||
|
version: 2 # optional, defaults to 1
|
||||||
username: user # not required if using api key
|
username: user # not required if using api key
|
||||||
password: pass # not required if using api key
|
password: pass # not required if using api key
|
||||||
key: yourtruenasapikey # not required if using username / password
|
key: yourtruenasapikey # not required if using username / password
|
||||||
|
|||||||
@@ -40,6 +40,7 @@
|
|||||||
"tough-cookie": "^6.0.0",
|
"tough-cookie": "^6.0.0",
|
||||||
"urbackup-server-api": "^0.91.0",
|
"urbackup-server-api": "^0.91.0",
|
||||||
"winston": "^3.17.0",
|
"winston": "^3.17.0",
|
||||||
|
"ws": "^8.18.3",
|
||||||
"xml-js": "^1.6.11"
|
"xml-js": "^1.6.11"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
17
pnpm-lock.yaml
generated
17
pnpm-lock.yaml
generated
@@ -95,6 +95,9 @@ importers:
|
|||||||
winston:
|
winston:
|
||||||
specifier: ^3.17.0
|
specifier: ^3.17.0
|
||||||
version: 3.17.0
|
version: 3.17.0
|
||||||
|
ws:
|
||||||
|
specifier: ^8.18.3
|
||||||
|
version: 8.18.3
|
||||||
xml-js:
|
xml-js:
|
||||||
specifier: ^1.6.11
|
specifier: ^1.6.11
|
||||||
version: 1.6.11
|
version: 1.6.11
|
||||||
@@ -3011,8 +3014,8 @@ packages:
|
|||||||
wrappy@1.0.2:
|
wrappy@1.0.2:
|
||||||
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
|
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
|
||||||
|
|
||||||
ws@8.18.0:
|
ws@8.18.3:
|
||||||
resolution: {integrity: sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==}
|
resolution: {integrity: sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==}
|
||||||
engines: {node: '>=10.0.0'}
|
engines: {node: '>=10.0.0'}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
bufferutil: ^4.0.1
|
bufferutil: ^4.0.1
|
||||||
@@ -3345,7 +3348,7 @@ snapshots:
|
|||||||
'@types/tar': 6.1.13
|
'@types/tar': 6.1.13
|
||||||
'@types/ws': 8.5.14
|
'@types/ws': 8.5.14
|
||||||
form-data: 4.0.2
|
form-data: 4.0.2
|
||||||
isomorphic-ws: 5.0.0(ws@8.18.0)
|
isomorphic-ws: 5.0.0(ws@8.18.3)
|
||||||
js-yaml: 4.1.1
|
js-yaml: 4.1.1
|
||||||
jsonpath-plus: 10.3.0
|
jsonpath-plus: 10.3.0
|
||||||
node-fetch: 2.7.0
|
node-fetch: 2.7.0
|
||||||
@@ -3355,7 +3358,7 @@ snapshots:
|
|||||||
tar: 7.4.3
|
tar: 7.4.3
|
||||||
tmp-promise: 3.0.3
|
tmp-promise: 3.0.3
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
ws: 8.18.0
|
ws: 8.18.3
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- bufferutil
|
- bufferutil
|
||||||
- encoding
|
- encoding
|
||||||
@@ -5033,9 +5036,9 @@ snapshots:
|
|||||||
|
|
||||||
isexe@2.0.0: {}
|
isexe@2.0.0: {}
|
||||||
|
|
||||||
isomorphic-ws@5.0.0(ws@8.18.0):
|
isomorphic-ws@5.0.0(ws@8.18.3):
|
||||||
dependencies:
|
dependencies:
|
||||||
ws: 8.18.0
|
ws: 8.18.3
|
||||||
|
|
||||||
iterator.prototype@1.1.5:
|
iterator.prototype@1.1.5:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -6215,7 +6218,7 @@ snapshots:
|
|||||||
|
|
||||||
wrappy@1.0.2: {}
|
wrappy@1.0.2: {}
|
||||||
|
|
||||||
ws@8.18.0: {}
|
ws@8.18.3: {}
|
||||||
|
|
||||||
xml-js@1.6.11:
|
xml-js@1.6.11:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ export default function Component({ service }) {
|
|||||||
|
|
||||||
const { data: alertData, error: alertError } = useWidgetAPI(widget, "alerts");
|
const { data: alertData, error: alertError } = useWidgetAPI(widget, "alerts");
|
||||||
const { data: statusData, error: statusError } = useWidgetAPI(widget, "status");
|
const { data: statusData, error: statusError } = useWidgetAPI(widget, "status");
|
||||||
const { data: poolsData, error: poolsError } = useWidgetAPI(widget, widget?.enablePools ? "pools" : null);
|
const { data: poolsData, error: poolsError } = useWidgetAPI(widget, widget?.enablePools ? "pools" : "");
|
||||||
const { data: datasetData, error: datasetError } = useWidgetAPI(widget, widget?.enablePools ? "dataset" : null);
|
const { data: datasetData, error: datasetError } = useWidgetAPI(widget, widget?.enablePools ? "dataset" : "");
|
||||||
|
|
||||||
if (alertError || statusError || poolsError) {
|
if (alertError || statusError || poolsError) {
|
||||||
const finalError = alertError ?? statusError ?? poolsError ?? datasetError;
|
const finalError = alertError ?? statusError ?? poolsError ?? datasetError;
|
||||||
|
|||||||
172
src/widgets/truenas/proxy.js
Normal file
172
src/widgets/truenas/proxy.js
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
import WebSocket from "ws";
|
||||||
|
|
||||||
|
import getServiceWidget from "utils/config/service-helpers";
|
||||||
|
import createLogger from "utils/logger";
|
||||||
|
import { formatApiCall, sanitizeErrorURL } from "utils/proxy/api-helpers";
|
||||||
|
import credentialedProxyHandler from "utils/proxy/handlers/credentialed";
|
||||||
|
import validateWidgetData from "utils/proxy/validate-widget-data";
|
||||||
|
import widgets from "widgets/widgets";
|
||||||
|
|
||||||
|
const logger = createLogger("truenasProxyHandler");
|
||||||
|
|
||||||
|
function waitForEvent(ws, handler, { event = "message", parseJson = true } = {}) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
cleanup();
|
||||||
|
reject(new Error("TrueNAS websocket wait timed out"));
|
||||||
|
}, 10000);
|
||||||
|
|
||||||
|
const handleEvent = (payload) => {
|
||||||
|
try {
|
||||||
|
let parsed = payload;
|
||||||
|
if (parseJson) {
|
||||||
|
if (Buffer.isBuffer(payload)) {
|
||||||
|
parsed = JSON.parse(payload.toString());
|
||||||
|
} else if (typeof payload === "string") {
|
||||||
|
parsed = JSON.parse(payload);
|
||||||
|
}
|
||||||
|
logger.info("Received TrueNAS websocket message: %o", parsed);
|
||||||
|
} else {
|
||||||
|
logger.info("Received TrueNAS websocket message: %o", payload);
|
||||||
|
}
|
||||||
|
const handlerResult = handler(parsed);
|
||||||
|
if (handlerResult !== undefined) {
|
||||||
|
cleanup();
|
||||||
|
if (handlerResult instanceof Error) {
|
||||||
|
reject(handlerResult);
|
||||||
|
} else {
|
||||||
|
resolve(handlerResult);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
cleanup();
|
||||||
|
reject(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleError = (err) => {
|
||||||
|
cleanup();
|
||||||
|
logger.error("TrueNAS websocket error: %s", err?.message ?? err);
|
||||||
|
reject(err);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
cleanup();
|
||||||
|
logger.error("TrueNAS websocket connection closed unexpectedly");
|
||||||
|
reject(new Error("TrueNAS websocket closed the connection"));
|
||||||
|
};
|
||||||
|
|
||||||
|
function cleanup() {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
ws.off(event, handleEvent);
|
||||||
|
ws.off("error", handleError);
|
||||||
|
ws.off("close", handleClose);
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.on(event, handleEvent);
|
||||||
|
ws.on("error", handleError);
|
||||||
|
ws.on("close", handleClose);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let nextId = 1;
|
||||||
|
async function sendMethod(ws, method, params = []) {
|
||||||
|
const id = nextId++;
|
||||||
|
const payload = { jsonrpc: "2.0", id, method, params };
|
||||||
|
logger.info("Sending TrueNAS websocket method %s with id %d", method, id);
|
||||||
|
ws.send(JSON.stringify(payload));
|
||||||
|
|
||||||
|
return waitForEvent(ws, (message) => {
|
||||||
|
if (message?.id !== id) return undefined;
|
||||||
|
if (message?.error) {
|
||||||
|
return new Error(message.error?.message || JSON.stringify(message.error));
|
||||||
|
}
|
||||||
|
return message?.result ?? message;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function authenticate(ws, widget) {
|
||||||
|
if (widget?.key) {
|
||||||
|
try {
|
||||||
|
const apiKeyResult = await sendMethod(ws, "auth.login_with_api_key", [widget.key]);
|
||||||
|
if (apiKeyResult === true) return;
|
||||||
|
logger.warn("TrueNAS API key authentication failed, falling back to username/password when available.");
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn("TrueNAS API key authentication failed: %s", err?.message ?? err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (widget?.username && widget?.password) {
|
||||||
|
const loginResult = await sendMethod(ws, "auth.login", [widget.username, widget.password]);
|
||||||
|
if (loginResult === true) return;
|
||||||
|
logger.warn("TrueNAS username/password authentication failed.");
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error("TrueNAS authentication failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function truenasProxyHandler(req, res, map) {
|
||||||
|
const { group, service, endpoint, 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" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!endpoint) {
|
||||||
|
return res.status(204).end();
|
||||||
|
}
|
||||||
|
|
||||||
|
const version = Number(widget.version ?? 1);
|
||||||
|
if (Number.isNaN(version) || version < 2) {
|
||||||
|
// Use legacy REST proxy for version 1
|
||||||
|
return credentialedProxyHandler(req, res, map);
|
||||||
|
}
|
||||||
|
|
||||||
|
const mappingEntry = Object.values(widgets[widget.type].mappings).find((mapping) => mapping.endpoint === endpoint);
|
||||||
|
const wsMethod = mappingEntry.wsMethod;
|
||||||
|
|
||||||
|
if (!wsMethod) {
|
||||||
|
logger.debug("Missing wsMethod mapping for TrueNAS endpoint %s", endpoint);
|
||||||
|
return res.status(500).json({ error: "Missing wsMethod mapping." });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
let data;
|
||||||
|
const wsUrl = new URL(formatApiCall(widgets[widget.type].wsAPI, { ...widget }));
|
||||||
|
const useSecure = wsUrl.protocol === "https:" || Boolean(widget.key); // API key requires secure connection
|
||||||
|
if (useSecure && wsUrl.protocol !== "https:")
|
||||||
|
logger.info("Upgrading TrueNAS websocket connection to secure wss://");
|
||||||
|
wsUrl.protocol = useSecure ? "wss:" : "ws:";
|
||||||
|
logger.info("Connecting to TrueNAS websocket at %s", wsUrl);
|
||||||
|
const ws = new WebSocket(wsUrl, { rejectUnauthorized: false });
|
||||||
|
await waitForEvent(ws, () => true, { event: "open", parseJson: false }); // wait for open
|
||||||
|
logger.info("Connected to TrueNAS websocket at %s", wsUrl);
|
||||||
|
try {
|
||||||
|
await authenticate(ws, widget);
|
||||||
|
data = await sendMethod(ws, wsMethod);
|
||||||
|
} finally {
|
||||||
|
ws.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!validateWidgetData(widget, endpoint, data)) {
|
||||||
|
return res.status(500).json({ error: { message: "Invalid data", url: sanitizeErrorURL(widget.url), data } });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (map) data = map(data);
|
||||||
|
|
||||||
|
return res.status(200).json(data);
|
||||||
|
} catch (err) {
|
||||||
|
if (err?.status) {
|
||||||
|
return res.status(err.status).json({ error: err.message });
|
||||||
|
}
|
||||||
|
logger.warn("Websocket call for TrueNAS failed: %s", err?.message ?? err);
|
||||||
|
return res.status(500).json({ error: err?.message ?? "TrueNAS websocket call failed" });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,32 +1,43 @@
|
|||||||
|
import truenasProxyHandler from "./proxy";
|
||||||
|
|
||||||
import { asJson, jsonArrayFilter } from "utils/proxy/api-helpers";
|
import { asJson, jsonArrayFilter } from "utils/proxy/api-helpers";
|
||||||
import credentialedProxyHandler from "utils/proxy/handlers/credentialed";
|
|
||||||
|
|
||||||
const widget = {
|
const widget = {
|
||||||
api: "{url}/api/v2.0/{endpoint}",
|
api: "{url}/api/v2.0/{endpoint}",
|
||||||
proxyHandler: credentialedProxyHandler,
|
wsAPI: "{url}/api/current",
|
||||||
|
proxyHandler: truenasProxyHandler,
|
||||||
|
|
||||||
mappings: {
|
mappings: {
|
||||||
alerts: {
|
alerts: {
|
||||||
endpoint: "alert/list",
|
endpoint: "alert/list",
|
||||||
map: (data) => ({
|
wsMethod: "alert.list",
|
||||||
pending: jsonArrayFilter(data, (item) => item?.dismissed === false).length,
|
map: (data) => {
|
||||||
}),
|
if (Array.isArray(data)) {
|
||||||
|
return { pending: data.filter((item) => item?.dismissed === false).length };
|
||||||
|
}
|
||||||
|
return { pending: jsonArrayFilter(data, (item) => item?.dismissed === false).length };
|
||||||
|
},
|
||||||
},
|
},
|
||||||
status: {
|
status: {
|
||||||
endpoint: "system/info",
|
endpoint: "system/info",
|
||||||
|
wsMethod: "system.info",
|
||||||
validate: ["loadavg", "uptime_seconds"],
|
validate: ["loadavg", "uptime_seconds"],
|
||||||
},
|
},
|
||||||
pools: {
|
pools: {
|
||||||
endpoint: "pool",
|
endpoint: "pool",
|
||||||
map: (data) =>
|
wsMethod: "pool.query",
|
||||||
asJson(data).map((entry) => ({
|
map: (data) => {
|
||||||
|
const list = Array.isArray(data) ? data : asJson(data);
|
||||||
|
return list.map((entry) => ({
|
||||||
id: entry.name,
|
id: entry.name,
|
||||||
name: entry.name,
|
name: entry.name,
|
||||||
healthy: entry.healthy,
|
healthy: entry.healthy,
|
||||||
})),
|
}));
|
||||||
|
},
|
||||||
},
|
},
|
||||||
dataset: {
|
dataset: {
|
||||||
endpoint: "pool/dataset",
|
endpoint: "pool/dataset",
|
||||||
|
wsMethod: "pool.dataset.query",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user