Merge branch 'gethomepage:main' into main

pull/3286/head
brikim 1 month ago committed by GitHub
commit cea07a227c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -1,12 +1,12 @@
## Proposed change
<!--
Please include a summary of the change. Screenshots and / or videos can also be helpful if appropriate.
Please include a summary of the change. Screenshots and/or videos can also be helpful if appropriate.
*** Please see the development guidelines for new widgets: https://gethomepage.dev/latest/more/development/#service-widget-guidelines
*** If you do not follow these guidelines your PR will likely be closed without review.
New service widgets should include example(s) of relevant relevant API output as well updates to the docs for the new widget.
New service widgets should include example(s) of relevant API output as well as updates to the docs for the new widget.
-->
Closes # (issue)

@ -41,7 +41,7 @@ With features like quick search, bookmarks, weather support, a wide range of int
## Docker Integration
Homepage has built-in support for Docker, and can automatically discover and add services to the homepage based on labels. See the [Docker](https://gethomepage.dev/latest/installation/docker/) page for more information.
Homepage has built-in support for Docker, and can automatically discover and add services to the homepage based on labels. See the [Docker Service Discovery](https://gethomepage.dev/latest/configs/docker/#automatic-service-discovery) page for more information.
## Service Widgets

@ -7,10 +7,10 @@ Learn more about [Azure DevOps](https://azure.microsoft.com/en-us/products/devop
This widget has 2 functions:
1. Pipelines: checks if the relevant pipeline is running or not, and if not, reports the last status.\
1. Pipelines: checks if the relevant pipeline is running or not, and if not, reports the last status.<br>
Allowed fields: `["result", "status"]`.
2. Pull Requests: returns the amount of open PRs, the amount of the PRs you have open, and how many PRs that you open are marked as 'Approved' by at least 1 person and not yet completed.\
2. Pull Requests: returns the amount of open PRs, the amount of the PRs you have open, and how many PRs that you open are marked as 'Approved' by at least 1 person and not yet completed.<br>
Allowed fields: `["totalPrs", "myPrs", "approved"]`.
You will need to generate a personal access token for an existing user, see the [azure documentation](https://learn.microsoft.com/en-us/azure/devops/organizations/accounts/use-personal-access-tokens-to-authenticate?view=azure-devops&tabs=Windows#create-a-pat)

@ -8,7 +8,7 @@ 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`).
Allowed fields: ["alerts", "bans"]
Allowed fields: `["alerts", "bans"]`.
```yaml
widget:

@ -11,22 +11,27 @@ An optional 'volume' parameter can be supplied to specify which volume's free sp
Allowed fields: `["uptime", "volumeAvailable", "resources.cpu", "resources.mem"]`.
To access these system metrics you need to connect to the DiskStation with an account that is a member of the default `Administrators` group. That is because these metrics are requested from the API's `SYNO.Core.System` part that is only available to admin users. In order to keep the security impact as small as possible we can set the account in DSM up to limit the user's permissions inside the Synology system. In DSM 7.x, for instance, follow these steps:
To access these system metrics you need to connect to the DiskStation (`DSM`) with an account that is a member of the default `Administrators` group. That is because these metrics are requested from the API's `SYNO.Core.System` part that is only available to admin users. In order to keep the security impact as small as possible we can set the account in DSM up to limit the user's permissions inside the Synology system. In DSM 7.x, for instance, follow these steps:
1. Create a new user, i.e. `remote_stats`.
2. Set up a strong password for the new user
3. Under the `User Groups` tab of the user config dialogue check the box for `Administrators`.
4. On the `Permissions` tab check the top box for `No Access`, effectively prohibiting the user from accessing anything in the shared folders.
5. Under `Applications` check the box next to `Deny` in the header to explicitly prohibit login to all applications.
6. Now _only_ allow login to the `Download Station` application, either by
6. Now _only_ allow login to the `DSM` application, either by
- unchecking `Deny` in the respective row, or (if inheriting permission doesn't work because of other group settings)
- checking `Allow` for this app, or
- checking `By IP` for this app to limit the source of login attempts to one or more IP addresses/subnets.
7. When the `Preview` column shows `Allow` in the `Download Station` row, click `Save`.
7. When the `Preview` column shows `Allow` in the `DSM` row, click `Save`.
Now configure the widget with the correct login information and test it.
If you encounter issues during testing, make sure to uncheck the option for automatic blocking due to invalid logins under `Control Panel > Security > Protection`. If desired, this setting can be reactivated once the login is established working.
If you encounter issues during testing:
1. Make sure to uncheck the option for automatic blocking due to invalid logins under `Control Panel > Security > Protection`.
- If desired, this setting can be reactivated once the login is established working.
2. Login to your Synology DSM with the newly created account and accept terms and conditions.
3. Reattempt
```yaml
widget:

@ -7,7 +7,7 @@ Learn more about [Gitea](https://gitea.com).
API token requires `notifications`, `repository` and `issue` permissions. See the [gitea documentation](https://docs.gitea.com/development/api-usage#generating-and-listing-api-tokens) for details on generating tokens.
Allowed fields: ["notifications", "issues", "pulls"]
Allowed fields: `["notifications", "issues", "pulls"]`.
```yaml
widget:

@ -9,7 +9,7 @@ This widget adds support for [Network UPS Tools](https://networkupstools.org/) v
The default ups name is `ups`. To configure more than one ups, you must create multiple peanut services.
Allowed fields: `["battery_charge", "ups_load", "ups_status"]`
Allowed fields: `["battery_charge", "ups_load", "ups_status"]`.
!!! note

@ -15,6 +15,7 @@ Note: by default the "blocked" and "blocked_percent" fields are merged e.g. "1,2
widget:
type: pihole
url: http://pi.hole.or.ip
version: 6 # required if running v6 or higher, defaults to 5
key: yourpiholeapikey # optional
```

@ -5,7 +5,7 @@ description: Prometheus Widget Configuration
Learn more about [Prometheus](https://github.com/prometheus/prometheus).
Allowed fields: `["targets_up", "targets_down", "targets_total"]`
Allowed fields: `["targets_up", "targets_down", "targets_total"]`.
```yaml
widget:

@ -5,7 +5,7 @@ description: Pterodactyl Widget Configuration
Learn more about [Pterodactyl](https://github.com/pterodactyl).
Allowed fields: `["nodes", "servers"]`
Allowed fields: `["nodes", "servers"]`.
```yaml
widget:

@ -11,6 +11,8 @@ To create an API Key, follow [the official TrueNAS documentation](https://www.tr
A detailed pool listing is disabled by default, but can be enabled with the `enablePools` option.
To use the `enablePools` option with TrueNAS Core, the `nasType` parameter is required.
```yaml
widget:
type: truenas
@ -19,4 +21,5 @@ widget:
password: pass # not required if using api key
key: yourtruenasapikey # not required if using username / password
enablePools: true # optional, defaults to false
nasType: scale # defaults to scale, must be set to 'core' if using enablePools with TrueNAS Core
```

662
package-lock.json generated

File diff suppressed because it is too large Load Diff

@ -16,7 +16,7 @@
"classnames": "^2.5.1",
"compare-versions": "^6.1.0",
"dockerode": "^4.0.2",
"follow-redirects": "^1.15.5",
"follow-redirects": "^1.15.6",
"gamedig": "^4.3.1",
"i18next": "^21.10.0",
"js-yaml": "^4.1.0",
@ -33,7 +33,7 @@
"react-dom": "^18.2.0",
"react-i18next": "^11.18.6",
"react-icons": "^4.12.0",
"recharts": "^2.12.2",
"recharts": "^2.12.3",
"rrule": "^2.8.1",
"swr": "^1.3.0",
"systeminformation": "^5.22.0",
@ -52,12 +52,12 @@
"eslint-plugin-import": "^2.29.1",
"eslint-plugin-jsx-a11y": "^6.8.0",
"eslint-plugin-prettier": "^4.2.1",
"eslint-plugin-react": "^7.33.2",
"eslint-plugin-react": "^7.34.1",
"eslint-plugin-react-hooks": "^4.6.0",
"postcss": "^8.4.35",
"postcss": "^8.4.38",
"prettier": "^3.2.5",
"tailwind-scrollbar": "^3.0.5",
"tailwindcss": "^3.4.1",
"tailwindcss": "^3.4.3",
"typescript": "^4.9.5"
},
"optionalDependencies": {

File diff suppressed because it is too large Load Diff

@ -2,11 +2,7 @@ import { getAllClasses, getInnerBlock, getBottomBlock } from "./container";
export default function ContainerForm({ children = [], options, additionalClassNames = "", callback }) {
return (
<form
type="button"
onSubmit={callback}
className={`${getAllClasses(options, additionalClassNames)} information-widget-form`}
>
<form onSubmit={callback} className={`${getAllClasses(options, additionalClassNames)} information-widget-form`}>
{getInnerBlock(children)}
{getBottomBlock(children)}
</form>

@ -393,8 +393,10 @@ export function cleanServiceGroups(groups) {
enableBlocks,
enableNowPlaying,
// glances
// glances, pihole
version,
// glances
chart,
metric,
pointsLimit,
@ -448,6 +450,7 @@ export function cleanServiceGroups(groups) {
// truenas
enablePools,
nasType,
// unifi
site,
@ -520,6 +523,7 @@ export function cleanServiceGroups(groups) {
}
if (type === "truenas") {
if (enablePools !== undefined) cleanedService.widget.enablePools = JSON.parse(enablePools);
if (nasType !== undefined) cleanedService.widget.nasType = nasType;
}
if (["diskstation", "qnap"].includes(type)) {
if (volume) cleanedService.widget.volume = volume;
@ -528,8 +532,10 @@ export function cleanServiceGroups(groups) {
if (snapshotHost) cleanedService.widget.snapshotHost = snapshotHost;
if (snapshotPath) cleanedService.widget.snapshotPath = snapshotPath;
}
if (type === "glances") {
if (["glances", "pihole"].includes(type)) {
if (version) cleanedService.widget.version = version;
}
if (type === "glances") {
if (metric) cleanedService.widget.metric = metric;
if (chart !== undefined) {
cleanedService.widget.chart = chart;

@ -20,12 +20,11 @@ export default function Component({ service }) {
const [dataPoints, setDataPoints] = useState(new Array(pointsLimit).fill({ value: 0 }, 0, pointsLimit));
const { data, error } = useWidgetAPI(service.widget, "cpu", {
const { data, error } = useWidgetAPI(service.widget, `${version}/cpu`, {
refreshInterval: Math.max(defaultInterval, refreshInterval),
version,
});
const { data: quicklookData, error: quicklookError } = useWidgetAPI(service.widget, "quicklook", { version });
const { data: quicklookData, error: quicklookError } = useWidgetAPI(service.widget, `${version}/quicklook`);
useEffect(() => {
if (data) {

@ -24,9 +24,8 @@ export default function Component({ service }) {
);
const [ratePoints, setRatePoints] = useState(new Array(pointsLimit).fill({ a: 0, b: 0 }, 0, pointsLimit));
const { data, error } = useWidgetAPI(service.widget, "diskio", {
const { data, error } = useWidgetAPI(service.widget, `${version}/diskio`, {
refreshInterval: Math.max(defaultInterval, refreshInterval),
version,
});
const calculateRates = (d) =>

@ -15,9 +15,8 @@ export default function Component({ service }) {
const [, fsName] = widget.metric.split("fs:");
const diskUnits = widget.diskUnits === "bbytes" ? "common.bbytes" : "common.bytes";
const { data, error } = useWidgetAPI(widget, "fs", {
const { data, error } = useWidgetAPI(widget, `${version}/fs`, {
refreshInterval: Math.max(defaultInterval, refreshInterval),
version,
});
if (error) {

@ -21,9 +21,8 @@ export default function Component({ service }) {
const [dataPoints, setDataPoints] = useState(new Array(pointsLimit).fill({ a: 0, b: 0 }, 0, pointsLimit));
const { data, error } = useWidgetAPI(widget, "gpu", {
const { data, error } = useWidgetAPI(widget, `${version}/gpu`, {
refreshInterval: Math.max(defaultInterval, refreshInterval),
version,
});
useEffect(() => {

@ -76,14 +76,12 @@ export default function Component({ service }) {
const { widget } = service;
const { chart, refreshInterval = defaultInterval, version = 3 } = widget;
const { data: quicklookData, errorL: quicklookError } = useWidgetAPI(service.widget, "quicklook", {
const { data: quicklookData, errorL: quicklookError } = useWidgetAPI(service.widget, `${version}/quicklook`, {
refreshInterval,
version,
});
const { data: systemData, errorL: systemError } = useWidgetAPI(service.widget, "system", {
const { data: systemData, errorL: systemError } = useWidgetAPI(service.widget, `${version}/system`, {
refreshInterval: defaultSystemInterval,
version,
});
if (quicklookError) {

@ -21,9 +21,8 @@ export default function Component({ service }) {
const [dataPoints, setDataPoints] = useState(new Array(pointsLimit).fill({ value: 0 }, 0, pointsLimit));
const { data, error } = useWidgetAPI(service.widget, "mem", {
const { data, error } = useWidgetAPI(service.widget, `${version}/mem`, {
refreshInterval: Math.max(defaultInterval(chart), refreshInterval),
version,
});
useEffect(() => {

@ -26,9 +26,8 @@ export default function Component({ service }) {
const [dataPoints, setDataPoints] = useState(new Array(pointsLimit).fill({ value: 0 }, 0, pointsLimit));
const { data, error } = useWidgetAPI(widget, "network", {
const { data, error } = useWidgetAPI(widget, `${version}/network`, {
refreshInterval: Math.max(defaultInterval(chart), refreshInterval),
version,
});
useEffect(() => {
@ -51,7 +50,7 @@ export default function Component({ service }) {
});
}
}
}, [data, interfaceName, pointsLimit]);
}, [data, interfaceName, pointsLimit, rxKey, txKey]);
if (error) {
return (

@ -26,9 +26,8 @@ export default function Component({ service }) {
const memoryInfoKey = version === 3 ? 0 : "data";
const { data, error } = useWidgetAPI(service.widget, "processlist", {
const { data, error } = useWidgetAPI(service.widget, `${version}/processlist`, {
refreshInterval: Math.max(defaultInterval, refreshInterval),
version,
});
if (error) {

@ -21,9 +21,8 @@ export default function Component({ service }) {
const [dataPoints, setDataPoints] = useState(new Array(pointsLimit).fill({ value: 0 }, 0, pointsLimit));
const { data, error } = useWidgetAPI(service.widget, "sensors", {
const { data, error } = useWidgetAPI(service.widget, `${version}/sensors`, {
refreshInterval: Math.max(defaultInterval, refreshInterval),
version,
});
useEffect(() => {

@ -1,7 +1,7 @@
import credentialedProxyHandler from "utils/proxy/handlers/credentialed";
const widget = {
api: "{url}/api/{version}/{endpoint}",
api: "{url}/api/{endpoint}",
proxyHandler: credentialedProxyHandler,
};

@ -14,7 +14,7 @@ async function login(widget, service) {
const endpoint = "auth/login";
const api = widgets?.[widget.type]?.api;
const loginUrl = new URL(formatApiCall(api, { endpoint, ...widget }));
const loginBody = { username: widget.username, password: widget.password };
const loginBody = { username: widget.username.toString(), password: widget.password.toString() };
const headers = { "Content-Type": "application/json" };
// eslint-disable-next-line no-unused-vars
const [status, contentType, data, responseHeaders] = await httpProxy(loginUrl, {

@ -9,7 +9,7 @@ export default function Component({ service }) {
const { widget } = service;
const { data: piholeData, error: piholeError } = useWidgetAPI(widget, "summaryRaw");
const { data: piholeData, error: piholeError } = useWidgetAPI(widget);
if (piholeError) {
return <Container service={service} error={piholeError} />;

@ -0,0 +1,95 @@
import cache from "memory-cache";
import { httpProxy } from "utils/proxy/http";
import { formatApiCall } from "utils/proxy/api-helpers";
import getServiceWidget from "utils/config/service-helpers";
import createLogger from "utils/logger";
import widgets from "widgets/widgets";
const proxyName = "piholeProxyHandler";
const logger = createLogger(proxyName);
const sessionSIDCacheKey = `${proxyName}__sessionSID`;
async function login(widget, service) {
const url = formatApiCall(widgets[widget.type].api, { ...widget, endpoint: "auth" });
const [status, , data] = await httpProxy(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
password: widget.key,
}),
});
const dataParsed = JSON.parse(data);
if (status !== 200 || !dataParsed.session) {
logger.error("Failed to login to Pi-Hole API, status: %d", status);
cache.del(`${sessionSIDCacheKey}.${service}`);
} else {
cache.put(`${sessionSIDCacheKey}.${service}`, dataParsed.session.sid, dataParsed.session.validity);
}
}
export default async function piholeProxyHandler(req, res) {
const { group, service } = req.query;
let endpoint = "stats/summary";
if (!group || !service) {
logger.error("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);
if (!widget) {
logger.error("Invalid or missing widget for service '%s' in group '%s'", service, group);
return res.status(400).json({ error: "Invalid widget configuration" });
}
let status;
let data;
if (!widget.version || widget.version < 6) {
// pihole v5
endpoint = "summaryRaw";
[status, , data] = await httpProxy(formatApiCall(widgets[widget.type].apiv5, { ...widget, endpoint }));
return res.status(status).send(data);
}
// pihole v6
if (!cache.get(`${sessionSIDCacheKey}.${service}`)) {
await login(widget, service);
}
const sid = cache.get(`${sessionSIDCacheKey}.${service}`);
if (!sid) {
return res.status(500).json({ error: "Failed to authenticate with Pi-hole" });
}
try {
logger.debug("Calling Pi-hole API endpoint: %s", endpoint);
[status, , data] = await httpProxy(formatApiCall(widgets[widget.type].api, { ...widget, endpoint }), {
headers: {
"Content-Type": "application/json",
"X-FTL-SID": sid,
},
});
if (status !== 200) {
logger.error("Error calling Pi-Hole API: %d. Data: %s", status, data);
return res.status(status).json({ error: "Pi-Hole API Error", data });
}
const dataParsed = JSON.parse(data);
return res.status(status).json({
domains_being_blocked: dataParsed.gravity.domains_being_blocked,
ads_blocked_today: dataParsed.queries.blocked,
ads_percentage_today: dataParsed.queries.percent_blocked,
dns_queries_today: dataParsed.queries.total,
});
} catch (error) {
logger.error("Exception calling Pi-Hole API: %s", error.message);
return res.status(500).json({ error: "Pi-Hole API Error", message: error.message });
}
}

@ -1,15 +1,9 @@
import genericProxyHandler from "utils/proxy/handlers/generic";
import piholeProxyHandler from "./proxy";
const widget = {
api: "{url}/admin/api.php?{endpoint}&auth={key}",
proxyHandler: genericProxyHandler,
mappings: {
summaryRaw: {
endpoint: "summaryRaw",
validate: ["dns_queries_today", "ads_blocked_today", "ads_percentage_today", "domains_being_blocked"],
},
},
api: "{url}/api/{endpoint}",
apiv5: "{url}/admin/api.php?{endpoint}&auth={key}",
proxyHandler: piholeProxyHandler,
};
export default widget;

@ -40,7 +40,15 @@ export default function Component({ service }) {
</Container>
{enablePools &&
poolsData.map((pool) => (
<Pool key={pool.id} name={pool.name} healthy={pool.healthy} allocated={pool.allocated} free={pool.free} />
<Pool
key={pool.id}
name={pool.name}
healthy={pool.healthy}
allocated={pool.allocated}
free={pool.free}
data={pool.data}
nasType={widget?.nasType ?? "scale"}
/>
))}
</>
);

@ -1,8 +1,18 @@
import classNames from "classnames";
import prettyBytes from "pretty-bytes";
export default function Pool({ name, free, allocated, healthy }) {
const total = free + allocated;
export default function Pool({ name, free, allocated, healthy, data, nasType }) {
let total = 0;
if (nasType === "scale") {
total = free + allocated;
} else {
allocated = 0; // eslint-disable-line no-param-reassign
for (let i = 0; i < data.length; i += 1) {
total += data[i].stats.size;
allocated += data[i].stats.allocated; // eslint-disable-line no-param-reassign
}
}
const usedPercent = Math.round((allocated / total) * 100);
const statusColor = healthy ? "bg-green-500" : "bg-yellow-500";

@ -25,6 +25,7 @@ const widget = {
healthy: entry.healthy,
allocated: entry.allocated,
free: entry.free,
data: entry.topology.data,
})),
},
},

Loading…
Cancel
Save