Compare commits

...

21 Commits

Author SHA1 Message Date
shamoon
a3c7823f28 Fixhancement: use stable ID for volumes in qnap widget 2025-11-26 22:47:24 -08:00
shamoon
5b50e8ff81 Enhancement: handle gluetun port forwarded API change (#6011) 2025-11-25 13:28:50 -08:00
Romloader
c36c6a9012 Enhancement: support authentication for Frigate widget (#6006)
Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com>
2025-11-25 11:34:54 -08:00
shamoon
cf990063b9 Add AI tools disclosure to PR template 2025-11-23 23:27:05 -08:00
dependabot[bot]
610f1bd974 Chore(deps): Bump actions/checkout from 5 to 6 (#5998)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-23 08:03:17 -08:00
shamoon
4031178831 Enhancement: treat 'error' as custom api field when mapped (#5999) 2025-11-21 10:36:31 -08:00
shamoon
b65c8399d8 Handle raw number errors, I guess 2025-11-21 10:05:01 -08:00
Darkangeel_hd
6b63cfd491 Chore: change MySpeed blocks layout order (#5984) 2025-11-17 06:57:47 -08:00
shamoon
196c51bf73 Enhancement: support limit crowdsec alerts to 24h (#5981)
Co-authored-by: MountainGod2 <admin@reid.ca>
2025-11-16 16:38:55 -08:00
qmph22
17c9b2631e Enhancement: add net worth field for ghostfolio (#5958)
Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com>
2025-11-13 00:31:55 +00:00
Diego Barreiro Perez
1a21189643 Enhancement: Allow Disabling Indexing (#5954)
Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com>
2025-11-13 00:13:16 +00:00
shamoon
b6b428363c 1.7.0 2025-11-11 09:03:26 -08:00
shamoon
e707fa46cf Revert "Development: specify pnpm version (#5364)"
This reverts commit 0c6c40dae7.
2025-11-11 09:03:15 -08:00
shamoon
3d040362cb Merge branch 'dev' 2025-11-11 09:00:14 -08:00
github-actions[bot]
57b193b037 New Crowdin translations by GitHub Action (#5953) 2025-11-11 08:59:39 -08:00
shamoon
8a75c9b6e3 Fixhancement: improve UID support (#5963) 2025-11-11 08:56:44 -08:00
Alessandro Travi
0dafc792f7 Documentation: note support for omada controller version 6 (#5961) 2025-11-10 23:07:28 -08:00
shamoon
afc0fe29ee Fix: enforce max field blocks for esp home widget (#5951) 2025-11-08 12:32:41 -08:00
dependabot[bot]
3ef7031eb0 Chore(deps): Bump docker/setup-qemu-action from 3.6.0 to 3.7.0 (#5939) 2025-11-05 16:41:24 +00:00
shamoon
6faf32eae9 Chore: better guard against empty data in komodo widget 2025-11-05 08:32:33 -08:00
shamoon
455e86571a Chore: improve event hash generation in iCal integration (#5938) 2025-11-05 08:11:24 -08:00
33 changed files with 237 additions and 77 deletions

View File

@@ -38,3 +38,4 @@ What type of change does your PR introduce to Homepage?
- [ ] 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 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

@@ -17,7 +17,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: crowdin action
uses: crowdin/github-action@v2
with:

View File

@@ -22,7 +22,7 @@ jobs:
runs-on: ubuntu-22.04
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: Install python
uses: actions/setup-python@v6
@@ -35,6 +35,7 @@ jobs:
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 10
run_install: false
- name: Setup Node.js
@@ -61,7 +62,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: Extract Docker metadata
id: meta
@@ -93,6 +94,7 @@ jobs:
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 10
run_install: false
- name: Setup Node.js
@@ -127,7 +129,7 @@ jobs:
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Setup QEMU
uses: docker/setup-qemu-action@v3.6.0
uses: docker/setup-qemu-action@v3.7.0
- name: Setup Docker buildx
uses: docker/setup-buildx-action@v3

View File

@@ -17,7 +17,7 @@ jobs:
runs-on: ubuntu-22.04
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: Install python
uses: actions/setup-python@v6
with:
@@ -32,7 +32,7 @@ jobs:
needs:
- pre-commit
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- uses: actions/setup-python@v6
with:
python-version: 3.x
@@ -54,7 +54,7 @@ jobs:
needs:
- pre-commit
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- name: Configure Git Credentials
run: |
git config user.name github-actions[bot]

View File

@@ -51,6 +51,8 @@ COPY --link --from=builder --chown=1000:1000 /app/.next/static/ ./.next/static
RUN apk add --no-cache su-exec iputils-ping shadow
USER root
ENV NODE_ENV=production
ENV HOSTNAME=0.0.0.0
ENV PORT=3000

View File

@@ -57,8 +57,8 @@ if [ -d /app/.next ]; then
fi
# Drop privileges (when asked to) if root, otherwise run as current user
if [ "$(id -u)" == "0" ] && [ "${PUID}" != "0" ]; then
su-exec ${PUID}:${PGID} "$@"
if [ "$(id -u)" = "0" ] && [ "${PUID}" != "0" ]; then
exec su-exec ${PUID}:${PGID} "$@"
else
exec "$@"
fi

View File

@@ -571,3 +571,18 @@ or per service widget (`services.yaml`) with:
```
If either value is set to true, the error message will be hidden.
## Disable Search Engine Indexing
You can request that search engines not to index your Homepage instance by enabling the `disableIndexing` setting.
```yaml
disableIndexing: true
```
When enabled, this will:
- Disallow all crawlers in `robots.txt`
- Add `<meta name="robots" content="noindex, nofollow">` tags to prevent indexing
By default this feature is disabled.

View File

@@ -8,6 +8,9 @@ Learn more about [Crowdsec](https://crowdsec.net).
See the [crowdsec docs](https://docs.crowdsec.net/docs/local_api/intro/#machines) for information about registering a machine,
in most instances you can use the default credentials (`/etc/crowdsec/local_api_credentials.yaml`).
!!! note
Without the `limit24h` option, the widget will fetch all alerts which is limited to 100 by the API to avoid performance issues.
Allowed fields: `["alerts", "bans"]`.
```yaml
@@ -16,4 +19,5 @@ widget:
url: http://crowdsechostorip:port
username: localhost # machine_id in crowdsec
password: password
limit24h: true # optional, limits alerts to last 24h. Default: false
```

View File

@@ -14,4 +14,6 @@ widget:
type: frigate
url: http://frigate.host.or.ip:port
enableRecentEvents: true # Optional, defaults to false
username: username # optional
password: password # optional
```

View File

@@ -15,7 +15,7 @@ See the [official docs](https://github.com/ghostfolio/ghostfolio#authorization-b
_Note that the Bearer token is valid for 6 months, after which a new one must be generated._
Allowed fields: `["gross_percent_today", "gross_percent_1y", "gross_percent_max"]`
Allowed fields: `["gross_percent_today", "gross_percent_1y", "gross_percent_max", "net_worth"]`
```yaml
widget:

View File

@@ -12,11 +12,17 @@ Learn more about [Gluetun](https://github.com/qdm12/gluetun).
Allowed fields: `["public_ip", "region", "country", "port_forwarded"]`.
Default fields: `["public_ip", "region", "country"]`.
To setup authentication, follow [the official Gluetun documentation](https://github.com/qdm12/gluetun-wiki/blob/main/setup/advanced/control-server.md#authentication). Note that to use the api key method, you must add the route `GET /v1/publicip/ip` to the `routes` array in your Gluetun config.toml. Similarly, if you want to include the `port_forwarded` field, you must add the route `GET /v1/openvpn/portforwarded` to your Gluetun config.toml.
To setup authentication, follow [the official Gluetun documentation](https://github.com/qdm12/gluetun-wiki/blob/main/setup/advanced/control-server.md#authentication). Note that to use the api key method, you must add the route `GET /v1/publicip/ip` to the `routes` array in your Gluetun config.toml. Similarly, if you want to include the `port_forwarded` field, you must add the route `GET /v1/openvpn/portforwarded` (or `/v1/portforward`) to your Gluetun config.toml.
| Gluetun Version | Homepage Widget Version |
| --------------- | ----------------------- |
| < 3.40.1 | 1 (default) |
| >= 3.40.1 | 2 |
```yaml
widget:
type: gluetun
url: http://gluetun.host.or.ip:port
key: gluetunkey # Not required if /v1/publicip/ip endpoint is configured with `auth = none`
version: 2 # optional, default is 1
```

View File

@@ -3,7 +3,7 @@ title: Omada
description: Omada Widget Configuration
---
The widget supports controller versions 3, 4 and 5.
The widget supports controller versions 3, 4, 5 and 6.
Allowed fields: `["connectedAp", "activeUser", "alerts", "connectedGateways", "connectedSwitches"]`.

View File

@@ -1,6 +1,6 @@
{
"name": "homepage",
"version": "1.6.1",
"version": "1.7.0",
"private": true,
"scripts": {
"preinstall": "npx only-allow pnpm",
@@ -63,13 +63,6 @@
"optionalDependencies": {
"osx-temperature-sensor": "^1.0.8"
},
"packageManager": "pnpm@10.8.1",
"devEngines": {
"packageManager": {
"name": "pnpm",
"version": "10.8.1"
}
},
"pnpm": {
"onlyBuiltDependencies": [
"osx-temperature-sensor",

View File

@@ -759,7 +759,8 @@
"ghostfolio": {
"gross_percent_today": "Today",
"gross_percent_1y": "One year",
"gross_percent_max": "All time"
"gross_percent_max": "All time",
"net_worth": "Net Worth"
},
"audiobookshelf": {
"podcasts": "Podcasts",

View File

@@ -200,10 +200,10 @@
"rutorrent": {
"active": "活动中",
"upload": "Upload",
"download": "Download"
"download": "下载"
},
"transmission": {
"download": "Download",
"download": "下载",
"upload": "",
"leech": "Leech",
"seed": "Seed"
@@ -223,8 +223,8 @@
"invalid": "Invalid"
},
"deluge": {
"download": "Download",
"upload": "Upload",
"download": "下载",
"upload": "上传",
"leech": "Leech",
"seed": "Seed"
},

View File

@@ -14,6 +14,8 @@ export default function Error({ error }) {
if (typeof error === "string") {
error = { message: error }; // eslint-disable-line no-param-reassign
} else if (typeof error === "number") {
error = { message: `Error ${error}` }; // eslint-disable-line no-param-reassign
}
if (error?.data?.error) {

View File

@@ -400,6 +400,7 @@ function Home({ initialSettings }) {
"A highly customizable homepage (or startpage / application dashboard) with Docker and service API integrations."
}
/>
{settings.disableIndexing && <meta name="robots" content="noindex, nofollow" />}
{settings.base && <base href={settings.base} />}
{settings.favicon ? (
<>

19
src/pages/robots.txt.js Normal file
View File

@@ -0,0 +1,19 @@
import { getSettings } from "utils/config/config";
export async function getServerSideProps({ res }) {
const settings = getSettings();
const content = ["User-agent: *", !!settings.disableIndexing ? "Disallow: /" : "Allow: /"].join("\n");
res.setHeader("Content-Type", "text/plain");
res.write(content);
res.end();
return {
props: {},
};
}
export default function RobotsTxt() {
// placeholder component
return null;
}

View File

@@ -279,6 +279,9 @@ export function cleanServiceGroups(groups) {
slugs,
symbols,
// crowdsec
limit24h,
// customapi
mappings,
display,
@@ -473,6 +476,10 @@ export function cleanServiceGroups(groups) {
if (defaultinterval) widget.defaultinterval = defaultinterval;
}
if (limit24h !== undefined) {
widget.limit24h = !!limit24h;
}
if (type === "docker") {
if (server) widget.server = server;
if (container) widget.container = container;
@@ -556,6 +563,7 @@ export function cleanServiceGroups(groups) {
"speedtest",
"wgeasy",
"grafana",
"gluetun",
].includes(type)
) {
if (version) widget.version = parseInt(version, 10);

View File

@@ -106,13 +106,19 @@ export default function Integration({ config, params, setEvents, hideErrors, tim
};
const eventsToAdd = [];
events.forEach((event, index) => {
events.forEach((event) => {
const occurrences = getOcurrencesFromRange(event);
occurrences.forEach((icalDate) => {
const date = icalDate.toJSDate();
const hash = simpleHash(`${event.id}-${event.title}-${index}-${date.toString()}`);
const occurrenceTimestamp = date.getTime();
const eventIdentifier =
event.id ??
simpleHash(
`${event.title ?? ""}-${event.type ?? ""}-${event.status ?? ""}-${event.url ?? ""}-${event.location ?? ""}`,
);
const hash = simpleHash(`${eventIdentifier}-${occurrenceTimestamp}`);
let title = event.title;
if (showName) {

View File

@@ -9,7 +9,7 @@ export default function Component({ service }) {
const { widget } = service;
const { data: alerts, error: alertsError } = useWidgetAPI(widget, "alerts");
const { data: alerts, error: alertsError } = useWidgetAPI(widget, !!widget.limit24h ? "alerts24h" : "alerts");
const { data: bans, error: bansError } = useWidgetAPI(widget, "bans");
if (alertsError || bansError) {

View File

@@ -9,6 +9,9 @@ const widget = {
alerts: {
endpoint: "alerts",
},
alerts24h: {
endpoint: "alerts?limit=0&since=24h",
},
bans: {
endpoint: "alerts?decision_type=ban&origin=crowdsec&has_active_decision=1",
},

View File

@@ -166,7 +166,11 @@ export default function Component({ service }) {
refreshInterval: Math.max(1000, refreshInterval),
});
if (customError) {
// if mappings includes an error field and the data contains an error field then show data even if there is an error
const mappingsIncludesError = Array.isArray(mappings) && mappings.find((mapping) => mapping.field === "error");
const errorIsData = customData && typeof customData === "object" && "error" in customData;
if (customError && !(mappingsIncludesError && errorIsData)) {
return <Container service={service} error={customError} />;
}

View File

@@ -14,6 +14,12 @@ export default function Component({ service }) {
return <Container service={service} error={resultError} />;
}
if (!widget.fields || widget.fields.length === 0) {
widget.fields = ["online", "offline", "offline_alt", "total"];
} else if (widget.fields.length > 4) {
widget.fields = widget.fields.slice(0, 4);
}
if (!resultData) {
return (
<Container service={service}>

View File

@@ -0,0 +1,95 @@
import getServiceWidget from "utils/config/service-helpers";
import createLogger from "utils/logger";
import { asJson, formatApiCall, sanitizeErrorURL } from "utils/proxy/api-helpers";
import { addCookieToJar } from "utils/proxy/cookie-jar";
import { httpProxy } from "utils/proxy/http";
import widgets from "widgets/widgets";
const proxyName = "frigateProxyHandler";
const logger = createLogger(proxyName);
export default async function frigateProxyHandler(req, res, map) {
const { group, service, endpoint, index } = req.query;
if (group && service) {
const widget = await getServiceWidget(group, service, index);
if (!widgets?.[widget.type]?.api) {
return res.status(403).json({ error: "Service does not support API calls" });
}
if (widget) {
const url = formatApiCall(widgets[widget.type].api, { endpoint, ...widget });
const params = {
method: "GET",
headers: {
"Content-Type": "application/json",
},
};
let [status, , data] = await httpProxy(url, params);
if (status === 401 && widget.username && widget.password) {
const loginUrl = `${widget.url}/api/login`;
logger.debug("Attempting login to Frigate at %s", loginUrl);
const [loginStatus, , , loginResponseHeaders] = await httpProxy(loginUrl, {
method: "POST",
body: JSON.stringify({ user: widget.username, password: widget.password }),
headers: {
"Content-Type": "application/json",
},
});
if (loginStatus !== 200) {
logger.error("HTTP Error %d calling %s", loginStatus, sanitizeErrorURL(loginUrl));
return res.status(status).json({
error: {
message: `HTTP Error ${status} while trying to login to Frigate`,
url: sanitizeErrorURL(url),
},
});
}
addCookieToJar(url, loginResponseHeaders);
// Retry original request with cookie set
[status, , data] = await httpProxy(url, params);
}
if (status >= 400) {
logger.error("HTTP Error %d calling %s", status, sanitizeErrorURL(url));
return res.status(status).json({
error: {
message: `HTTP Error ${status} from Frigate`,
url: sanitizeErrorURL(url),
},
});
}
data = asJson(data);
if (endpoint == "stats") {
res.status(status).send({
num_cameras: data?.cameras !== undefined ? Object.keys(data?.cameras).length : 0,
uptime: data?.service?.uptime,
version: data?.service.version,
});
} else if (endpoint == "events") {
return res.status(status).send(
data.slice(0, 5).map((event) => ({
id: event.id,
camera: event.camera,
label: event.label,
start_time: new Date(event.start_time * 1000),
thumbnail: event.thumbnail,
score: event.data.score,
type: event.data.type,
})),
);
}
}
}
logger.debug("Invalid or missing proxy service type '%s' in group '%s'", service, group);
return res.status(400).json({ error: "Invalid proxy service type" });
}

View File

@@ -1,37 +1,12 @@
import { asJson } from "utils/proxy/api-helpers";
import genericProxyHandler from "utils/proxy/handlers/generic";
import frigateProxyHandler from "./proxy";
const widget = {
api: "{url}/api/{endpoint}",
proxyHandler: genericProxyHandler,
proxyHandler: frigateProxyHandler,
mappings: {
stats: {
endpoint: "stats",
map: (data) => {
const jsonData = asJson(data);
return {
num_cameras: jsonData?.cameras !== undefined ? Object.keys(jsonData?.cameras).length : 0,
uptime: jsonData?.service?.uptime,
version: jsonData?.service.version,
};
},
},
events: {
endpoint: "events",
map: (data) =>
asJson(data)
.slice(0, 5)
.map((event) => ({
id: event.id,
camera: event.camera,
label: event.label,
start_time: new Date(event.start_time * 1000),
thumbnail: event.thumbnail,
score: event.data.score,
type: event.data.type,
})),
},
stats: { endpoint: "stats" },
events: { endpoint: "events" },
},
};

View File

@@ -20,13 +20,15 @@ function getPerformancePercent(t, performanceRange) {
export default function Component({ service }) {
const { t } = useTranslation();
const { widget } = service;
const includeNetWorth = widget.fields?.includes("net_worth");
const { data: performanceToday, error: ghostfolioErrorToday } = useWidgetAPI(widget, "today");
const { data: performanceYear, error: ghostfolioErrorYear } = useWidgetAPI(widget, "year");
const { data: performanceMax, error: ghostfolioErrorMax } = useWidgetAPI(widget, "max");
const { data: userInfo, error: ghostfolioErrorUserInfo } = useWidgetAPI(widget, includeNetWorth ? "userInfo" : "");
if (ghostfolioErrorToday || ghostfolioErrorYear || ghostfolioErrorMax) {
const finalError = ghostfolioErrorToday ?? ghostfolioErrorYear ?? ghostfolioErrorMax;
if (ghostfolioErrorToday || ghostfolioErrorYear || ghostfolioErrorMax || ghostfolioErrorUserInfo) {
const finalError = ghostfolioErrorToday ?? ghostfolioErrorYear ?? ghostfolioErrorMax ?? ghostfolioErrorUserInfo;
return <Container service={service} error={finalError} />;
}
@@ -34,12 +36,13 @@ export default function Component({ service }) {
return <Container service={service} error={performanceToday} />;
}
if (!performanceToday || !performanceYear || !performanceMax) {
if (!performanceToday || !performanceYear || !performanceMax || (includeNetWorth && !userInfo)) {
return (
<Container service={service}>
<Block label="ghostfolio.gross_percent_today" />
<Block label="ghostfolio.gross_percent_1y" />
<Block label="ghostfolio.gross_percent_max" />
{includeNetWorth && <Block label="ghostfolio.net_worth" />}
</Container>
);
}
@@ -49,6 +52,12 @@ export default function Component({ service }) {
<Block label="ghostfolio.gross_percent_today" value={getPerformancePercent(t, performanceToday)} />
<Block label="ghostfolio.gross_percent_1y" value={getPerformancePercent(t, performanceYear)} />
<Block label="ghostfolio.gross_percent_max" value={getPerformancePercent(t, performanceMax)} />
{includeNetWorth && (
<Block
label="ghostfolio.net_worth"
value={`${performanceToday.performance.currentNetWorth.toFixed(2)} ${userInfo?.settings?.currency ?? ""}`}
/>
)}
</Container>
);
}

View File

@@ -1,18 +1,21 @@
import credentialedProxyHandler from "utils/proxy/handlers/credentialed";
const widget = {
api: "{url}/api/v2/portfolio/performance?range={endpoint}",
api: "{url}/api/{endpoint}",
proxyHandler: credentialedProxyHandler,
mappings: {
today: {
endpoint: "1d",
endpoint: "v2/portfolio/performance?range=1d",
},
year: {
endpoint: "1y",
endpoint: "v2/portfolio/performance?range=1y",
},
max: {
endpoint: "max",
endpoint: "v2/portfolio/performance?range=max",
},
userInfo: {
endpoint: "v1/user",
},
},
};

View File

@@ -12,10 +12,8 @@ export default function Component({ service }) {
const { data: gluetunData, error: gluetunError } = useWidgetAPI(widget, "ip");
const includePF = widget.fields.includes("port_forwarded");
const { data: portForwardedData, error: portForwardedError } = useWidgetAPI(
widget,
includePF ? "port_forwarded" : "",
);
const pfEndpoint = widget.version > 1 ? "port_forwarded_v2" : "port_forwarded";
const { data: portForwardedData, error: portForwardedError } = useWidgetAPI(widget, includePF ? pfEndpoint : "");
if (gluetunError || (includePF && portForwardedError)) {
return <Container service={service} error={gluetunError || portForwardedError} />;

View File

@@ -13,6 +13,10 @@ const widget = {
endpoint: "openvpn/portforwarded",
validate: ["port"],
},
port_forwarded_v2: {
endpoint: "portforward",
validate: ["port"],
},
},
};

View File

@@ -32,7 +32,7 @@ export default function Component({ service }) {
if (
(!widget.showStacks && !containersData) ||
(widget.showSummary && (!stacksData || !serversData)) ||
(widget.showSummary && (!containersData || !stacksData || !serversData)) ||
(widget.showStacks && !stacksData)
) {
return widget.showSummary ? (

View File

@@ -24,9 +24,9 @@ export default function Component({ service }) {
if (!data || (data && data.length === 0)) {
return (
<Container service={service}>
<Block label="myspeed.ping" />
<Block label="myspeed.download" />
<Block label="myspeed.upload" />
<Block label="myspeed.ping" />
</Container>
);
}

View File

@@ -38,12 +38,13 @@ export default function Component({ service }) {
if (Array.isArray(statusData.volume.volumeUseList.volumeUse)) {
if (widget.volume) {
const volumeSelected = statusData.volume.volumeList.volume.findIndex(
(vl) => vl.volumeLabel._cdata === widget.volume,
);
if (volumeSelected !== -1) {
volumeTotalSize = statusData.volume.volumeUseList.volumeUse[volumeSelected].total_size._cdata;
volumeFreeSize = statusData.volume.volumeUseList.volumeUse[volumeSelected].free_size._cdata;
const volumeSelected = statusData.volume.volumeList.volume.find((vl) => vl.volumeLabel._cdata === widget.volume);
if (volumeSelected) {
const volumeUsed = statusData.volume.volumeUseList.volumeUse.find(
(vu) => vu.volumeValue._cdata === volumeSelected.volumeValue._cdata,
);
volumeTotalSize = volumeUsed.total_size._cdata;
volumeFreeSize = volumeUsed.free_size._cdata;
} else {
validVolume = false;
}