mirror of
https://github.com/gethomepage/homepage.git
synced 2025-12-05 21:47:48 +01:00
Compare commits
28 Commits
98203b99e7
...
v1.6.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
438543d8cd | ||
|
|
5a350cc9ce | ||
|
|
9b5275a854 | ||
|
|
e623196ac0 | ||
|
|
973b1f7aaf | ||
|
|
81a322cc99 | ||
|
|
36e82a8b90 | ||
|
|
1383e22acd | ||
|
|
a756a01d63 | ||
|
|
937efc9f1b | ||
|
|
fe6f32f072 | ||
|
|
226603770c | ||
|
|
2f48d21bfd | ||
|
|
4457baffa5 | ||
|
|
91d12c401c | ||
|
|
3f8da51aeb | ||
|
|
837717461f | ||
|
|
effedc28ed | ||
|
|
76b477572e | ||
|
|
6c6660b91b | ||
|
|
6886040798 | ||
|
|
1fb4850bef | ||
|
|
06cf76d724 | ||
|
|
7aeda56af4 | ||
|
|
2058b7fcae | ||
|
|
1e06e93e47 | ||
|
|
8f756d4084 | ||
|
|
f7a6b7dbf4 |
4
.github/workflows/docker-publish.yml
vendored
4
.github/workflows/docker-publish.yml
vendored
@@ -38,7 +38,7 @@ jobs:
|
|||||||
run_install: false
|
run_install: false
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v5
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version: 20
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
@@ -96,7 +96,7 @@ jobs:
|
|||||||
run_install: false
|
run_install: false
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v5
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version: 20
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
|
|||||||
@@ -271,4 +271,4 @@ You can show the docker stats by clicking the status indicator but this can also
|
|||||||
showStats: true
|
showStats: true
|
||||||
```
|
```
|
||||||
|
|
||||||
Also see the settings for [show docker stats](settings.md#show-docker-stats).
|
Also see the settings for [show docker stats](settings.md#show-container-stats).
|
||||||
|
|||||||
@@ -178,3 +178,32 @@ See [ClusterRole and ClusterRoleBinding](../installation/k8s.md#clusterrole-and-
|
|||||||
## Caveats
|
## Caveats
|
||||||
|
|
||||||
Similarly to Docker service discovery, there currently is no rigid ordering to discovered services and discovered services will be displayed above those specified in the `services.yaml`.
|
Similarly to Docker service discovery, there currently is no rigid ordering to discovered services and discovered services will be displayed above those specified in the `services.yaml`.
|
||||||
|
|
||||||
|
## Adding extra configuration files
|
||||||
|
|
||||||
|
Some Homepage features (for example, [Proxmox](../configs/proxmox.md)) require additional configuration files such as `proxmox.yaml`.
|
||||||
|
When running Homepage on Kubernetes, these files must be provided via a `ConfigMap` and mounted into the container at `/app/config`.
|
||||||
|
|
||||||
|
### ConfigMap example
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
apiVersion: v1
|
||||||
|
kind: ConfigMap
|
||||||
|
metadata:
|
||||||
|
name: homepage
|
||||||
|
data:
|
||||||
|
proxmox.yaml: |
|
||||||
|
pve:
|
||||||
|
url: https://proxmox.host.or.ip:8006
|
||||||
|
token: username@pam!Token ID
|
||||||
|
secret: secret
|
||||||
|
```
|
||||||
|
|
||||||
|
Mount the file into `/app/config` by updating the `Deployment`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
volumeMounts:
|
||||||
|
- mountPath: /app/config/proxmox.yaml
|
||||||
|
name: homepage-config
|
||||||
|
subPath: proxmox.yaml
|
||||||
|
```
|
||||||
|
|||||||
@@ -4,10 +4,10 @@ description: Proxmox Configuration
|
|||||||
---
|
---
|
||||||
|
|
||||||
The Proxmox connection is configured in the `proxmox.yaml` file. See [Create token](#create-token) section below for details on how to generate the required API token.
|
The Proxmox connection is configured in the `proxmox.yaml` file. See [Create token](#create-token) section below for details on how to generate the required API token.
|
||||||
You can configure multiple nodes - be sure to use the exact `proxmoxNode` identifier!
|
To configure multiple nodes, ensure the key name in the `proxmox.yaml` matches the `proxmoxNode` field used in your service configuration.
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
pve:
|
pve: # must match your actual Proxmox node name
|
||||||
url: https://proxmox.host.or.ip:8006
|
url: https://proxmox.host.or.ip:8006
|
||||||
token: username@pam!Token ID
|
token: username@pam!Token ID
|
||||||
secret: secret
|
secret: secret
|
||||||
|
|||||||
@@ -118,6 +118,47 @@ Each widget can optionally provide a list of which fields should be visible via
|
|||||||
key: apikeyapikeyapikeyapikeyapikey
|
key: apikeyapikeyapikeyapikeyapikey
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Block Highlighting
|
||||||
|
|
||||||
|
Widgets can tint their metric block text automatically based on rules defined alongside the service. Attach a `highlight` section to the widget configuration and map each block to one or more numeric or string rules using the field key (for example, `queued`, `lan_users`).
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- Sonarr:
|
||||||
|
icon: sonarr.png
|
||||||
|
href: http://sonarr.host.or.ip
|
||||||
|
widget:
|
||||||
|
type: sonarr
|
||||||
|
url: http://sonarr.host.or.ip
|
||||||
|
key: ${SONARR_API_KEY}
|
||||||
|
highlight:
|
||||||
|
queued:
|
||||||
|
numeric:
|
||||||
|
- level: danger
|
||||||
|
when: gte
|
||||||
|
value: 20
|
||||||
|
- level: warn
|
||||||
|
when: gte
|
||||||
|
value: 5
|
||||||
|
- level: good
|
||||||
|
when: eq
|
||||||
|
value: 0
|
||||||
|
status:
|
||||||
|
string:
|
||||||
|
- level: danger
|
||||||
|
when: regex
|
||||||
|
value: "(failed|import) pending"
|
||||||
|
- level: good
|
||||||
|
when: equals
|
||||||
|
value: "All good"
|
||||||
|
status_code:
|
||||||
|
string:
|
||||||
|
- level: warn
|
||||||
|
when: regex
|
||||||
|
value: "^5\\d{2}$"
|
||||||
|
```
|
||||||
|
|
||||||
|
Supported numeric operators for the `when` property are `gt`, `gte`, `lt`, `lte`, `eq`, `ne`, `between`, and `outside`. String rules support `equals`, `includes`, `startsWith`, `endsWith`, and `regex`. Each rule can be inverted with `negate: true`, and string rules may pass `caseSensitive: true` or custom regex `flags`. The highlight engine does its best to coerce formatted values, but you will get the most reliable results when you pass plain numbers or strings into `<Block>`.
|
||||||
|
|
||||||
## Descriptions
|
## Descriptions
|
||||||
|
|
||||||
Services may have descriptions,
|
Services may have descriptions,
|
||||||
|
|||||||
@@ -109,6 +109,20 @@ color: slate
|
|||||||
|
|
||||||
Supported colors are: `slate`, `gray`, `zinc`, `neutral`, `stone`, `amber`, `yellow`, `lime`, `green`, `emerald`, `teal`, `cyan`, `sky`, `blue`, `indigo`, `violet`, `purple`, `fuchsia`, `pink`, `rose`, `red`, `white`
|
Supported colors are: `slate`, `gray`, `zinc`, `neutral`, `stone`, `amber`, `yellow`, `lime`, `green`, `emerald`, `teal`, `cyan`, `sky`, `blue`, `indigo`, `violet`, `purple`, `fuchsia`, `pink`, `rose`, `red`, `white`
|
||||||
|
|
||||||
|
## Block Highlight Levels
|
||||||
|
|
||||||
|
You can override the default Tailwind classes applied when a widget highlight rule resolves to the `good`, `warn`, or `danger` level.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
blockHighlights:
|
||||||
|
levels:
|
||||||
|
good: "bg-emerald-500/40 text-emerald-950 dark:bg-emerald-900/60 dark:text-emerald-400"
|
||||||
|
warn: "bg-amber-300/30 text-amber-900 dark:bg-amber-900/30 dark:text-amber-200"
|
||||||
|
danger: "bg-rose-700/45 text-rose-200 dark:bg-rose-950/70 dark:text-rose-400"
|
||||||
|
```
|
||||||
|
|
||||||
|
Any unspecified level falls back to the built-in defaults.
|
||||||
|
|
||||||
## Layout
|
## Layout
|
||||||
|
|
||||||
You can configure service and bookmarks sections to be either "column" or "row" based layouts, like so:
|
You can configure service and bookmarks sections to be either "column" or "row" based layouts, like so:
|
||||||
@@ -486,9 +500,9 @@ logpath: /logfile/path
|
|||||||
|
|
||||||
By default, logs are sent both to `stdout` and to a file at the path specified. This can be changed by setting the `LOG_TARGETS` environment variable to one of `both` (default), `stdout` or `file`.
|
By default, logs are sent both to `stdout` and to a file at the path specified. This can be changed by setting the `LOG_TARGETS` environment variable to one of `both` (default), `stdout` or `file`.
|
||||||
|
|
||||||
## Show Docker Stats
|
## Show Container Stats
|
||||||
|
|
||||||
You can show all docker stats expanded in `settings.yaml`:
|
You can show all docker or proxmox stats expanded in `settings.yaml`:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
showStats: true
|
showStats: true
|
||||||
|
|||||||
@@ -62,3 +62,4 @@ To ensure cohesiveness of various widgets, the following should be used as a gui
|
|||||||
- Minimize the number of API calls
|
- Minimize the number of API calls
|
||||||
- Avoid the use of custom proxy unless absolutely necessary
|
- Avoid the use of custom proxy unless absolutely necessary
|
||||||
- Widgets should be 'read-only', as in they should not make write changes using the relevant tool's API. Homepage widgets are designed to surface information, not to be a (usually worse) replacement for the tool itself.
|
- Widgets should be 'read-only', as in they should not make write changes using the relevant tool's API. Homepage widgets are designed to surface information, not to be a (usually worse) replacement for the tool itself.
|
||||||
|
- Widgets should not allow manually overriding the "refresh interval" setting, as misconfigured refresh intervals can easily lead to performance issues for users.
|
||||||
|
|||||||
@@ -16,4 +16,5 @@ widget:
|
|||||||
username: username
|
username: username
|
||||||
password: password
|
password: password
|
||||||
enableLeechProgress: true # optional, defaults to false
|
enableLeechProgress: true # optional, defaults to false
|
||||||
|
enableLeechSize: true # optional, defaults to false
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -10,11 +10,11 @@ The Unraid widget allows you to monitor the resources of an Unraid server.
|
|||||||
**Minimum Requirements:**
|
**Minimum Requirements:**
|
||||||
|
|
||||||
- Unraid 7.2 -or- Unraid Connect plugin 2025.08.19.1850
|
- 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)
|
- API key with the **ADMIN** 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.
|
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"]`
|
**Allowed fields:** `["cpu","memoryPercent","memoryAvailable","memoryUsed","notifications","arrayFree","arrayUsedSpace","arrayUsedPercent","status","pool1UsedSpace","pool1FreeSpace","pool1UsedPercent","pool2UsedSpace","pool2FreeSpace","pool2UsedPercent","pool3UsedSpace","pool3FreeSpace","pool3UsedPercent","pool4UsedSpace","pool4FreeSpace","pool4UsedPercent"]`
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
widget:
|
widget:
|
||||||
|
|||||||
20
package.json
20
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "homepage",
|
"name": "homepage",
|
||||||
"version": "1.5.0",
|
"version": "1.6.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"preinstall": "npx only-allow pnpm",
|
"preinstall": "npx only-allow pnpm",
|
||||||
@@ -11,14 +11,14 @@
|
|||||||
"telemetry": "next telemetry disable"
|
"telemetry": "next telemetry disable"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@headlessui/react": "^2.2.7",
|
"@headlessui/react": "^2.2.9",
|
||||||
"@kubernetes/client-node": "^1.0.0",
|
"@kubernetes/client-node": "^1.0.0",
|
||||||
"classnames": "^2.5.1",
|
"classnames": "^2.5.1",
|
||||||
"compare-versions": "^6.1.1",
|
"compare-versions": "^6.1.1",
|
||||||
"dockerode": "^4.0.7",
|
"dockerode": "^4.0.7",
|
||||||
"follow-redirects": "^1.15.11",
|
"follow-redirects": "^1.15.11",
|
||||||
"gamedig": "^5.3.1",
|
"gamedig": "^5.3.2",
|
||||||
"i18next": "^24.2.3",
|
"i18next": "^25.5.3",
|
||||||
"ical.js": "^2.1.0",
|
"ical.js": "^2.1.0",
|
||||||
"js-yaml": "^4.1.0",
|
"js-yaml": "^4.1.0",
|
||||||
"json-rpc-2.0": "^1.7.0",
|
"json-rpc-2.0": "^1.7.0",
|
||||||
@@ -28,8 +28,8 @@
|
|||||||
"next": "^15.5.2",
|
"next": "^15.5.2",
|
||||||
"next-i18next": "^12.1.0",
|
"next-i18next": "^12.1.0",
|
||||||
"ping": "^0.4.4",
|
"ping": "^0.4.4",
|
||||||
"pretty-bytes": "^6.1.1",
|
"pretty-bytes": "^7.1.0",
|
||||||
"raw-body": "^3.0.0",
|
"raw-body": "^3.0.1",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-i18next": "^15.5.3",
|
"react-i18next": "^15.5.3",
|
||||||
@@ -44,18 +44,18 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/forms": "^0.5.10",
|
"@tailwindcss/forms": "^0.5.10",
|
||||||
"@tailwindcss/postcss": "^4.0.9",
|
"@tailwindcss/postcss": "^4.1.14",
|
||||||
"eslint": "^9.25.1",
|
"eslint": "^9.25.1",
|
||||||
"eslint-config-next": "^15.2.4",
|
"eslint-config-next": "^15.2.4",
|
||||||
"eslint-config-prettier": "^10.1.1",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"eslint-plugin-import": "^2.32.0",
|
"eslint-plugin-import": "^2.32.0",
|
||||||
"eslint-plugin-jsx-a11y": "^6.10.2",
|
"eslint-plugin-jsx-a11y": "^6.10.2",
|
||||||
"eslint-plugin-prettier": "^5.5.1",
|
"eslint-plugin-prettier": "^5.5.4",
|
||||||
"eslint-plugin-react": "^7.37.4",
|
"eslint-plugin-react": "^7.37.4",
|
||||||
"eslint-plugin-react-hooks": "^5.2.0",
|
"eslint-plugin-react-hooks": "^5.2.0",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"prettier": "^3.6.2",
|
"prettier": "^3.6.2",
|
||||||
"prettier-plugin-organize-imports": "^4.1.0",
|
"prettier-plugin-organize-imports": "^4.3.0",
|
||||||
"tailwind-scrollbar": "^4.0.2",
|
"tailwind-scrollbar": "^4.0.2",
|
||||||
"tailwindcss": "^4.0.9",
|
"tailwindcss": "^4.0.9",
|
||||||
"typescript": "^5.7.3"
|
"typescript": "^5.7.3"
|
||||||
|
|||||||
663
pnpm-lock.yaml
generated
663
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,16 +1,47 @@
|
|||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import { useTranslation } from "next-i18next";
|
import { useTranslation } from "next-i18next";
|
||||||
|
import { useContext, useMemo } from "react";
|
||||||
|
|
||||||
export default function Block({ value, label }) {
|
import { BlockHighlightContext } from "./highlight-context";
|
||||||
|
|
||||||
|
import { evaluateHighlight, getHighlightClass } from "utils/highlights";
|
||||||
|
|
||||||
|
export default function Block({ value, label, field }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const highlightConfig = useContext(BlockHighlightContext);
|
||||||
|
|
||||||
|
const highlight = useMemo(() => {
|
||||||
|
if (!highlightConfig) return null;
|
||||||
|
const labels = Array.isArray(label) ? label : [label];
|
||||||
|
const candidates = [];
|
||||||
|
if (typeof field === "string") candidates.push(field);
|
||||||
|
for (const candidateLabel of labels) {
|
||||||
|
if (typeof candidateLabel === "string") candidates.push(candidateLabel);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
const result = evaluateHighlight(candidate, value, highlightConfig);
|
||||||
|
if (result) return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}, [field, label, value, highlightConfig]);
|
||||||
|
|
||||||
|
const highlightClass = useMemo(() => {
|
||||||
|
if (!highlight?.level) return undefined;
|
||||||
|
return getHighlightClass(highlight.level, highlightConfig);
|
||||||
|
}, [highlight, highlightConfig]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={classNames(
|
className={classNames(
|
||||||
"bg-theme-200/50 dark:bg-theme-900/20 rounded-sm m-1 flex-1 flex flex-col items-center justify-center text-center p-1",
|
"bg-theme-200/50 dark:bg-theme-900/20 rounded-sm m-1 flex-1 flex flex-col items-center justify-center text-center p-1",
|
||||||
value === undefined ? "animate-pulse" : "",
|
value === undefined ? "animate-pulse" : "",
|
||||||
|
highlightClass,
|
||||||
"service-block",
|
"service-block",
|
||||||
)}
|
)}
|
||||||
|
data-highlight-level={highlight?.level}
|
||||||
|
data-highlight-source={highlight?.source}
|
||||||
>
|
>
|
||||||
<div className="font-thin text-sm">{value === undefined || value === null ? "-" : value}</div>
|
<div className="font-thin text-sm">{value === undefined || value === null ? "-" : value}</div>
|
||||||
<div className="font-bold text-xs uppercase">{t(label)}</div>
|
<div className="font-bold text-xs uppercase">{t(label)}</div>
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import { useContext } from "react";
|
import { useContext, useMemo } from "react";
|
||||||
import { SettingsContext } from "utils/contexts/settings";
|
import { SettingsContext } from "utils/contexts/settings";
|
||||||
|
|
||||||
import Error from "./error";
|
import Error from "./error";
|
||||||
|
import { BlockHighlightContext } from "./highlight-context";
|
||||||
|
|
||||||
|
import { buildHighlightConfig } from "utils/highlights";
|
||||||
|
|
||||||
const ALIASED_WIDGETS = {
|
const ALIASED_WIDGETS = {
|
||||||
pialert: "netalertx",
|
pialert: "netalertx",
|
||||||
@@ -11,6 +14,11 @@ const ALIASED_WIDGETS = {
|
|||||||
export default function Container({ error = false, children, service }) {
|
export default function Container({ error = false, children, service }) {
|
||||||
const { settings } = useContext(SettingsContext);
|
const { settings } = useContext(SettingsContext);
|
||||||
|
|
||||||
|
const highlightConfig = useMemo(
|
||||||
|
() => buildHighlightConfig(settings?.blockHighlights, service?.widget?.highlight, service?.widget?.type),
|
||||||
|
[settings?.blockHighlights, service?.widget?.highlight, service?.widget?.type],
|
||||||
|
);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
if (settings.hideErrors || service.widget.hide_errors) {
|
if (settings.hideErrors || service.widget.hide_errors) {
|
||||||
return null;
|
return null;
|
||||||
@@ -51,6 +59,11 @@ export default function Container({ error = false, children, service }) {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
const content = <div className="relative flex flex-row w-full service-container">{visibleChildren}</div>;
|
||||||
|
|
||||||
return <div className="relative flex flex-row w-full service-container">{visibleChildren}</div>;
|
if (!highlightConfig) {
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <BlockHighlightContext.Provider value={highlightConfig}>{content}</BlockHighlightContext.Provider>;
|
||||||
}
|
}
|
||||||
|
|||||||
3
src/components/services/widget/highlight-context.jsx
Normal file
3
src/components/services/widget/highlight-context.jsx
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import { createContext } from "react";
|
||||||
|
|
||||||
|
export const BlockHighlightContext = createContext(null);
|
||||||
@@ -113,7 +113,7 @@ export default function Widget({ options }) {
|
|||||||
<Resource
|
<Resource
|
||||||
icon={FaMemory}
|
icon={FaMemory}
|
||||||
value={t("common.bytes", {
|
value={t("common.bytes", {
|
||||||
value: data.mem.free,
|
value: data.mem.available,
|
||||||
maximumFractionDigits: 1,
|
maximumFractionDigits: 1,
|
||||||
binary: true,
|
binary: true,
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
export default function QueueEntry({ title, activity, timeLeft, progress }) {
|
export default function QueueEntry({ title, activity, timeLeft, progress, size }) {
|
||||||
return (
|
return (
|
||||||
<div className="text-theme-700 dark:text-theme-200 relative h-5 rounded-md bg-theme-200/50 dark:bg-theme-900/20 m-1 px-1 flex">
|
<div className="text-theme-700 dark:text-theme-200 relative h-5 rounded-md bg-theme-200/50 dark:bg-theme-900/20 m-1 px-1 flex">
|
||||||
<div
|
<div
|
||||||
@@ -11,6 +11,7 @@ export default function QueueEntry({ title, activity, timeLeft, progress }) {
|
|||||||
<div className="absolute w-full whitespace-nowrap text-ellipsis overflow-hidden text-left">{title}</div>
|
<div className="absolute w-full whitespace-nowrap text-ellipsis overflow-hidden text-left">{title}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="self-center text-xs flex justify-end mr-1.5 pl-1 z-10 text-ellipsis overflow-hidden whitespace-nowrap">
|
<div className="self-center text-xs flex justify-end mr-1.5 pl-1 z-10 text-ellipsis overflow-hidden whitespace-nowrap">
|
||||||
|
{size && `${size} - `}
|
||||||
{timeLeft ? `${activity} - ${timeLeft}` : activity}
|
{timeLeft ? `${activity} - ${timeLeft}` : activity}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -417,6 +417,7 @@ function Home({ initialSettings }) {
|
|||||||
)}
|
)}
|
||||||
<meta name="msapplication-TileColor" content={themes[settings.color || "slate"][settings.theme || "dark"]} />
|
<meta name="msapplication-TileColor" content={themes[settings.color || "slate"][settings.theme || "dark"]} />
|
||||||
<meta name="theme-color" content={themes[settings.color || "slate"][settings.theme || "dark"]} />
|
<meta name="theme-color" content={themes[settings.color || "slate"][settings.theme || "dark"]} />
|
||||||
|
<meta name="color-scheme" content="dark light"></meta>
|
||||||
</Head>
|
</Head>
|
||||||
|
|
||||||
<Script src="/api/config/custom.js" />
|
<Script src="/api/config/custom.js" />
|
||||||
@@ -539,38 +540,48 @@ export default function Wrapper({ initialSettings, fallback }) {
|
|||||||
html.classList.add(desiredThemeClass);
|
html.classList.add(desiredThemeClass);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove any previously applied inline styles
|
if (backgroundImage) {
|
||||||
body.style.backgroundImage = "";
|
const safeBackgroundImage = backgroundImage.replace(/'/g, "\\'");
|
||||||
body.style.backgroundColor = "";
|
body.style.backgroundImage = `linear-gradient(rgb(var(--bg-color) / ${opacity}), rgb(var(--bg-color) / ${opacity})), url('${safeBackgroundImage}')`;
|
||||||
body.style.backgroundAttachment = "";
|
body.style.backgroundSize = "cover";
|
||||||
|
body.style.backgroundPosition = "center";
|
||||||
|
body.style.backgroundAttachment = "fixed";
|
||||||
|
body.style.backgroundRepeat = "no-repeat";
|
||||||
|
body.style.backgroundColor = "";
|
||||||
|
} else {
|
||||||
|
body.style.backgroundImage = "none";
|
||||||
|
body.style.backgroundColor = "rgb(var(--bg-color))";
|
||||||
|
body.style.backgroundSize = "";
|
||||||
|
body.style.backgroundPosition = "";
|
||||||
|
body.style.backgroundAttachment = "";
|
||||||
|
body.style.backgroundRepeat = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
body.style.backgroundImage = "";
|
||||||
|
body.style.backgroundColor = "";
|
||||||
|
body.style.backgroundSize = "";
|
||||||
|
body.style.backgroundPosition = "";
|
||||||
|
body.style.backgroundAttachment = "";
|
||||||
|
body.style.backgroundRepeat = "";
|
||||||
|
};
|
||||||
}, [backgroundImage, opacity, theme, color, initialSettings.color]);
|
}, [backgroundImage, opacity, theme, color, initialSettings.color]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div id="page_wrapper" className="relative min-h-screen">
|
||||||
{backgroundImage && (
|
<div
|
||||||
<div
|
id="inner_wrapper"
|
||||||
id="background"
|
tabIndex="-1"
|
||||||
aria-hidden="true"
|
className={classNames(
|
||||||
style={{
|
"w-full min-h-screen overflow-auto",
|
||||||
backgroundImage: `linear-gradient(rgb(var(--bg-color) / ${opacity}), rgb(var(--bg-color) / ${opacity})), url('${backgroundImage}')`,
|
backgroundBlur &&
|
||||||
}}
|
`backdrop-blur${initialSettings.background.blur?.length ? `-${initialSettings.background.blur}` : ""}`,
|
||||||
/>
|
backgroundSaturate && `backdrop-saturate-${initialSettings.background.saturate}`,
|
||||||
)}
|
backgroundBrightness && `backdrop-brightness-${initialSettings.background.brightness}`,
|
||||||
<div id="page_wrapper" className="relative h-full">
|
)}
|
||||||
<div
|
>
|
||||||
id="inner_wrapper"
|
<Index initialSettings={initialSettings} fallback={fallback} />
|
||||||
tabIndex="-1"
|
|
||||||
className={classNames(
|
|
||||||
"w-full h-full overflow-auto",
|
|
||||||
backgroundBlur &&
|
|
||||||
`backdrop-blur${initialSettings.background.blur?.length ? `-${initialSettings.background.blur}` : ""}`,
|
|
||||||
backgroundSaturate && `backdrop-saturate-${initialSettings.background.saturate}`,
|
|
||||||
backgroundBrightness && `backdrop-brightness-${initialSettings.background.brightness}`,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Index initialSettings={initialSettings} fallback={fallback} />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,18 +30,6 @@ body,
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
background-color: rgb(var(--bg-color));
|
|
||||||
}
|
|
||||||
|
|
||||||
#background {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
z-index: 0;
|
|
||||||
background-size: cover;
|
|
||||||
background-position: center;
|
|
||||||
background-repeat: no-repeat;
|
|
||||||
background-attachment: scroll;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
html,
|
html,
|
||||||
|
|||||||
@@ -254,6 +254,7 @@ export function cleanServiceGroups(groups) {
|
|||||||
// all widgets
|
// all widgets
|
||||||
fields,
|
fields,
|
||||||
hideErrors,
|
hideErrors,
|
||||||
|
highlight,
|
||||||
type,
|
type,
|
||||||
|
|
||||||
// azuredevops
|
// azuredevops
|
||||||
@@ -284,6 +285,7 @@ export function cleanServiceGroups(groups) {
|
|||||||
|
|
||||||
// deluge, qbittorrent
|
// deluge, qbittorrent
|
||||||
enableLeechProgress,
|
enableLeechProgress,
|
||||||
|
enableLeechSize,
|
||||||
|
|
||||||
// diskstation
|
// diskstation
|
||||||
volume,
|
volume,
|
||||||
@@ -440,6 +442,21 @@ export function cleanServiceGroups(groups) {
|
|||||||
index,
|
index,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (highlight) {
|
||||||
|
let parsedHighlight = highlight;
|
||||||
|
if (typeof highlight === "string") {
|
||||||
|
try {
|
||||||
|
parsedHighlight = JSON.parse(highlight);
|
||||||
|
} catch (e) {
|
||||||
|
logger.error("Invalid highlight configuration detected in config for service '%s'", service.name);
|
||||||
|
parsedHighlight = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (parsedHighlight && typeof parsedHighlight === "object") {
|
||||||
|
widget.highlight = parsedHighlight;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (type === "azuredevops") {
|
if (type === "azuredevops") {
|
||||||
if (userEmail) widget.userEmail = userEmail;
|
if (userEmail) widget.userEmail = userEmail;
|
||||||
if (repositoryId) widget.repositoryId = repositoryId;
|
if (repositoryId) widget.repositoryId = repositoryId;
|
||||||
@@ -493,6 +510,7 @@ export function cleanServiceGroups(groups) {
|
|||||||
}
|
}
|
||||||
if (["deluge", "qbittorrent"].includes(type)) {
|
if (["deluge", "qbittorrent"].includes(type)) {
|
||||||
if (enableLeechProgress !== undefined) widget.enableLeechProgress = JSON.parse(enableLeechProgress);
|
if (enableLeechProgress !== undefined) widget.enableLeechProgress = JSON.parse(enableLeechProgress);
|
||||||
|
if (enableLeechSize !== undefined) widget.enableLeechSize = JSON.parse(enableLeechSize);
|
||||||
}
|
}
|
||||||
if (["opnsense", "pfsense"].includes(type)) {
|
if (["opnsense", "pfsense"].includes(type)) {
|
||||||
if (wan) widget.wan = wan;
|
if (wan) widget.wan = wan;
|
||||||
|
|||||||
257
src/utils/highlights.js
Normal file
257
src/utils/highlights.js
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
const DEFAULT_LEVEL_CLASSES = {
|
||||||
|
good: "bg-emerald-500/40 text-emerald-950 dark:bg-emerald-900/60 dark:text-emerald-400",
|
||||||
|
warn: "bg-amber-300/30 text-amber-900 dark:bg-amber-900/30 dark:text-amber-200",
|
||||||
|
danger: "bg-rose-700/45 text-rose-200 dark:bg-rose-950/70 dark:text-rose-400",
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeFieldKeys = (fields, widgetType) => {
|
||||||
|
if (!fields || typeof fields !== "object") return {};
|
||||||
|
|
||||||
|
return Object.entries(fields).reduce((acc, [key, value]) => {
|
||||||
|
if (value === null || value === undefined) return acc;
|
||||||
|
if (typeof key !== "string") return acc;
|
||||||
|
const trimmedKey = key.trim();
|
||||||
|
if (trimmedKey === "") return acc;
|
||||||
|
|
||||||
|
acc[trimmedKey] = value;
|
||||||
|
|
||||||
|
if (widgetType && !trimmedKey.includes(".")) {
|
||||||
|
const namespacedKey = `${widgetType}.${trimmedKey}`;
|
||||||
|
if (!(namespacedKey in acc)) {
|
||||||
|
acc[namespacedKey] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildHighlightConfig = (globalConfig, widgetConfig, widgetType) => {
|
||||||
|
const levels = {
|
||||||
|
...DEFAULT_LEVEL_CLASSES,
|
||||||
|
...(globalConfig?.levels || {}),
|
||||||
|
...(widgetConfig?.levels || {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const { levels: _levels, ...fields } = widgetConfig || {};
|
||||||
|
const normalizedFields = normalizeFieldKeys(fields, widgetType);
|
||||||
|
|
||||||
|
const hasLevels = Object.values(levels).some(Boolean);
|
||||||
|
const hasFields = Object.keys(normalizedFields).length > 0;
|
||||||
|
|
||||||
|
if (!hasLevels && !hasFields) return null;
|
||||||
|
|
||||||
|
return { levels, fields: normalizedFields };
|
||||||
|
};
|
||||||
|
|
||||||
|
const NUMERIC_OPERATORS = {
|
||||||
|
gt: (value, target) => value > target,
|
||||||
|
gte: (value, target) => value >= target,
|
||||||
|
lt: (value, target) => value < target,
|
||||||
|
lte: (value, target) => value <= target,
|
||||||
|
eq: (value, target) => value === target,
|
||||||
|
ne: (value, target) => value !== target,
|
||||||
|
};
|
||||||
|
|
||||||
|
const STRING_OPERATORS = {
|
||||||
|
equals: (value, target, caseSensitive) =>
|
||||||
|
caseSensitive ? value === target : value.toLowerCase() === target.toLowerCase(),
|
||||||
|
includes: (value, target, caseSensitive) =>
|
||||||
|
caseSensitive ? value.includes(target) : value.toLowerCase().includes(target.toLowerCase()),
|
||||||
|
startsWith: (value, target, caseSensitive) =>
|
||||||
|
caseSensitive ? value.startsWith(target) : value.toLowerCase().startsWith(target.toLowerCase()),
|
||||||
|
endsWith: (value, target, caseSensitive) =>
|
||||||
|
caseSensitive ? value.endsWith(target) : value.toLowerCase().endsWith(target.toLowerCase()),
|
||||||
|
};
|
||||||
|
|
||||||
|
const toNumber = (value) => {
|
||||||
|
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||||
|
if (typeof value === "string" && value.trim()) {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
const candidate = Number(trimmed);
|
||||||
|
if (!Number.isNaN(candidate)) return candidate;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseNumericValue = (value) => {
|
||||||
|
if (value === null || value === undefined) return undefined;
|
||||||
|
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||||
|
|
||||||
|
if (typeof value === "string") {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed) return undefined;
|
||||||
|
|
||||||
|
const direct = Number(trimmed);
|
||||||
|
if (!Number.isNaN(direct)) return direct;
|
||||||
|
|
||||||
|
const compact = trimmed.replace(/\s+/g, "");
|
||||||
|
if (!compact || !/^[-+]?[0-9.,]+$/.test(compact)) return undefined;
|
||||||
|
|
||||||
|
const commaCount = (compact.match(/,/g) || []).length;
|
||||||
|
const dotCount = (compact.match(/\./g) || []).length;
|
||||||
|
|
||||||
|
if (commaCount && dotCount) {
|
||||||
|
const lastComma = compact.lastIndexOf(",");
|
||||||
|
const lastDot = compact.lastIndexOf(".");
|
||||||
|
if (lastComma > lastDot) {
|
||||||
|
const asDecimal = compact.replace(/\./g, "").replace(/,/g, ".");
|
||||||
|
const parsed = Number(asDecimal);
|
||||||
|
return Number.isNaN(parsed) ? undefined : parsed;
|
||||||
|
}
|
||||||
|
const asThousands = compact.replace(/,/g, "");
|
||||||
|
const parsed = Number(asThousands);
|
||||||
|
return Number.isNaN(parsed) ? undefined : parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (commaCount) {
|
||||||
|
const parts = compact.split(",");
|
||||||
|
if (commaCount === 1 && parts[1]?.length <= 2) {
|
||||||
|
const parsed = Number(compact.replace(",", "."));
|
||||||
|
if (!Number.isNaN(parsed)) return parsed;
|
||||||
|
}
|
||||||
|
const isGrouped = parts.length > 1 && parts.slice(1).every((part) => part.length === 3);
|
||||||
|
if (isGrouped) {
|
||||||
|
const parsed = Number(compact.replace(/,/g, ""));
|
||||||
|
if (!Number.isNaN(parsed)) return parsed;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dotCount) {
|
||||||
|
const parts = compact.split(".");
|
||||||
|
if (dotCount === 1 && parts[1]?.length <= 2) {
|
||||||
|
const parsed = Number(compact);
|
||||||
|
if (!Number.isNaN(parsed)) return parsed;
|
||||||
|
}
|
||||||
|
const isGrouped = parts.length > 1 && parts.slice(1).every((part) => part.length === 3);
|
||||||
|
if (isGrouped) {
|
||||||
|
const parsed = Number(compact.replace(/\./g, ""));
|
||||||
|
if (!Number.isNaN(parsed)) return parsed;
|
||||||
|
}
|
||||||
|
const parsed = Number(compact);
|
||||||
|
return Number.isNaN(parsed) ? undefined : parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = Number(compact);
|
||||||
|
return Number.isNaN(parsed) ? undefined : parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === "object" && value !== null && "props" in value) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const evaluateNumericRule = (value, rule) => {
|
||||||
|
if (!rule || typeof rule !== "object") return false;
|
||||||
|
const operator = rule.when && NUMERIC_OPERATORS[rule.when];
|
||||||
|
const numericValue = toNumber(rule.value);
|
||||||
|
if (operator && numericValue !== undefined) {
|
||||||
|
const passes = operator(value, numericValue);
|
||||||
|
return rule.negate ? !passes : passes;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rule.when === "between") {
|
||||||
|
const min = toNumber(rule.min ?? rule.value?.min);
|
||||||
|
const max = toNumber(rule.max ?? rule.value?.max);
|
||||||
|
if (min === undefined && max === undefined) return false;
|
||||||
|
const lowerBound = min ?? Number.NEGATIVE_INFINITY;
|
||||||
|
const upperBound = max ?? Number.POSITIVE_INFINITY;
|
||||||
|
const passes = value >= lowerBound && value <= upperBound;
|
||||||
|
return rule.negate ? !passes : passes;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rule.when === "outside") {
|
||||||
|
const min = toNumber(rule.min ?? rule.value?.min);
|
||||||
|
const max = toNumber(rule.max ?? rule.value?.max);
|
||||||
|
if (min === undefined && max === undefined) return false;
|
||||||
|
const passes = value < (min ?? Number.NEGATIVE_INFINITY) || value > (max ?? Number.POSITIVE_INFINITY);
|
||||||
|
return rule.negate ? !passes : passes;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const evaluateStringRule = (value, rule) => {
|
||||||
|
if (!rule || typeof rule !== "object") return false;
|
||||||
|
if (rule.when === "regex" && typeof rule.value === "string") {
|
||||||
|
try {
|
||||||
|
const flags = rule.flags || (rule.caseSensitive ? "" : "i");
|
||||||
|
const regex = new RegExp(rule.value, flags);
|
||||||
|
const passes = regex.test(value);
|
||||||
|
return rule.negate ? !passes : passes;
|
||||||
|
} catch (error) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const operator = rule.when && STRING_OPERATORS[rule.when];
|
||||||
|
if (!operator || typeof rule.value !== "string") return false;
|
||||||
|
const passes = operator(value, rule.value, Boolean(rule.caseSensitive));
|
||||||
|
return rule.negate ? !passes : passes;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ensureArray = (value) => {
|
||||||
|
if (Array.isArray(value)) return value;
|
||||||
|
if (value === undefined || value === null) return [];
|
||||||
|
return [value];
|
||||||
|
};
|
||||||
|
|
||||||
|
const findHighlightLevel = (ruleSet, numericValue, stringValue) => {
|
||||||
|
const { numeric, string } = ruleSet;
|
||||||
|
|
||||||
|
if (numeric && numericValue !== undefined) {
|
||||||
|
const numericRules = ensureArray(numeric);
|
||||||
|
const numericCandidates = Array.isArray(numericValue) ? numericValue : [numericValue];
|
||||||
|
for (const candidate of numericCandidates) {
|
||||||
|
for (const rule of numericRules) {
|
||||||
|
if (rule?.level && evaluateNumericRule(candidate, rule)) {
|
||||||
|
return { level: rule.level, source: "numeric", rule };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string && stringValue !== undefined) {
|
||||||
|
const stringRules = ensureArray(string);
|
||||||
|
for (const rule of stringRules) {
|
||||||
|
if (rule?.level && evaluateStringRule(stringValue, rule)) {
|
||||||
|
return { level: rule.level, source: "string", rule };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const evaluateHighlight = (fieldKey, value, highlightConfig) => {
|
||||||
|
if (!highlightConfig || !fieldKey) return null;
|
||||||
|
const { fields } = highlightConfig;
|
||||||
|
if (!fields || typeof fields !== "object") return null;
|
||||||
|
|
||||||
|
const ruleSet = fields[fieldKey];
|
||||||
|
if (!ruleSet) return null;
|
||||||
|
|
||||||
|
const numericValue = parseNumericValue(value);
|
||||||
|
let stringValue;
|
||||||
|
if (typeof value === "string") {
|
||||||
|
stringValue = value;
|
||||||
|
} else if (typeof value === "number" || typeof value === "bigint") {
|
||||||
|
stringValue = String(value);
|
||||||
|
} else if (typeof value === "boolean") {
|
||||||
|
stringValue = value ? "true" : "false";
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedString = typeof stringValue === "string" ? stringValue.trim() : stringValue;
|
||||||
|
|
||||||
|
return findHighlightLevel(ruleSet, numericValue, normalizedString);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getHighlightClass = (level, highlightConfig) => {
|
||||||
|
if (!level || !highlightConfig) return undefined;
|
||||||
|
return highlightConfig.levels?.[level];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getDefaultHighlightLevels = () => DEFAULT_LEVEL_CLASSES;
|
||||||
@@ -24,10 +24,11 @@ function buildResponse(plans) {
|
|||||||
|
|
||||||
plans.forEach((plan) => {
|
plans.forEach((plan) => {
|
||||||
const statuses = plan?.recentBackups?.status;
|
const statuses = plan?.recentBackups?.status;
|
||||||
|
// See https://github.com/garethgeorge/backrest/blob/4357295a17cb2e71639473c9929a060c4dd1b624/proto/v1/operations.proto#L78-L87
|
||||||
if (Array.isArray(statuses) && statuses.length > 0) {
|
if (Array.isArray(statuses) && statuses.length > 0) {
|
||||||
if (statuses[0] === "STATUS_SUCCESS") {
|
if (statuses[0] === "STATUS_SUCCESS") {
|
||||||
numSuccessLatest++;
|
numSuccessLatest++;
|
||||||
} else {
|
} else if (statuses[0] === "STATUS_ERROR") {
|
||||||
numFailureLatest++;
|
numFailureLatest++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -205,13 +205,14 @@ export default function Component({ service }) {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const { widget } = service;
|
const { widget } = service;
|
||||||
|
const enableNowPlaying = service.widget?.enableNowPlaying ?? true;
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: sessionsData,
|
data: sessionsData,
|
||||||
error: sessionsError,
|
error: sessionsError,
|
||||||
mutate: sessionMutate,
|
mutate: sessionMutate,
|
||||||
} = useWidgetAPI(widget, "Sessions", {
|
} = useWidgetAPI(widget, enableNowPlaying ? "Sessions" : "", {
|
||||||
refreshInterval: 5000,
|
refreshInterval: enableNowPlaying ? 5000 : undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: countData, error: countError } = useWidgetAPI(widget, "Count", {
|
const { data: countData, error: countError } = useWidgetAPI(widget, "Count", {
|
||||||
@@ -239,13 +240,12 @@ export default function Component({ service }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const enableBlocks = service.widget?.enableBlocks;
|
const enableBlocks = service.widget?.enableBlocks;
|
||||||
const enableNowPlaying = service.widget?.enableNowPlaying ?? true;
|
|
||||||
const enableMediaControl = service.widget?.enableMediaControl !== false; // default is true
|
const enableMediaControl = service.widget?.enableMediaControl !== false; // default is true
|
||||||
const enableUser = !!service.widget?.enableUser; // default is false
|
const enableUser = !!service.widget?.enableUser; // default is false
|
||||||
const expandOneStreamToTwoRows = service.widget?.expandOneStreamToTwoRows !== false; // default is true
|
const expandOneStreamToTwoRows = service.widget?.expandOneStreamToTwoRows !== false; // default is true
|
||||||
const showEpisodeNumber = !!service.widget?.showEpisodeNumber; // default is false
|
const showEpisodeNumber = !!service.widget?.showEpisodeNumber; // default is false
|
||||||
|
|
||||||
if (!sessionsData || !countData) {
|
if ((enableNowPlaying && !sessionsData) || !countData) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{enableBlocks && <CountBlocks service={service} countData={null} />}
|
{enableBlocks && <CountBlocks service={service} countData={null} />}
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export default function Component({ service }) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (data) {
|
if (data) {
|
||||||
setDataPoints((prevDataPoints) => {
|
setDataPoints((prevDataPoints) => {
|
||||||
const newDataPoints = [...prevDataPoints, { a: data.used, b: data.free }];
|
const newDataPoints = [...prevDataPoints, { a: data.used, b: data.available }];
|
||||||
if (newDataPoints.length > pointsLimit) {
|
if (newDataPoints.length > pointsLimit) {
|
||||||
newDataPoints.shift();
|
newDataPoints.shift();
|
||||||
}
|
}
|
||||||
@@ -67,10 +67,10 @@ export default function Component({ service }) {
|
|||||||
|
|
||||||
{data && !error && (
|
{data && !error && (
|
||||||
<Block position="bottom-3 left-3">
|
<Block position="bottom-3 left-3">
|
||||||
{data.free && chart && (
|
{data.available && chart && (
|
||||||
<div className="text-xs opacity-50">
|
<div className="text-xs opacity-50">
|
||||||
{t("common.bytes", {
|
{t("common.bytes", {
|
||||||
value: data.free,
|
value: data.available,
|
||||||
maximumFractionDigits: 1,
|
maximumFractionDigits: 1,
|
||||||
binary: true,
|
binary: true,
|
||||||
})}{" "}
|
})}{" "}
|
||||||
@@ -93,10 +93,10 @@ export default function Component({ service }) {
|
|||||||
|
|
||||||
{!chart && (
|
{!chart && (
|
||||||
<Block position="top-3 right-3">
|
<Block position="top-3 right-3">
|
||||||
{data.free && (
|
{data.available && (
|
||||||
<div className="text-xs opacity-50">
|
<div className="text-xs opacity-50">
|
||||||
{t("common.bytes", {
|
{t("common.bytes", {
|
||||||
value: data.free,
|
value: data.available,
|
||||||
maximumFractionDigits: 1,
|
maximumFractionDigits: 1,
|
||||||
binary: true,
|
binary: true,
|
||||||
})}{" "}
|
})}{" "}
|
||||||
|
|||||||
@@ -33,14 +33,6 @@ export default function Component({ service }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Container service={service}>
|
<Container service={service}>
|
||||||
<Block
|
|
||||||
label="myspeed.ping"
|
|
||||||
value={t("common.ms", {
|
|
||||||
value: data[0].ping,
|
|
||||||
style: "unit",
|
|
||||||
unit: "millisecond",
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
<Block
|
<Block
|
||||||
label="myspeed.download"
|
label="myspeed.download"
|
||||||
value={t("common.bitrate", {
|
value={t("common.bitrate", {
|
||||||
@@ -55,6 +47,14 @@ export default function Component({ service }) {
|
|||||||
decimals: 2,
|
decimals: 2,
|
||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
|
<Block
|
||||||
|
label="myspeed.ping"
|
||||||
|
value={t("common.ms", {
|
||||||
|
value: data[0].ping,
|
||||||
|
style: "unit",
|
||||||
|
unit: "millisecond",
|
||||||
|
})}
|
||||||
|
/>
|
||||||
</Container>
|
</Container>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ export default async function omadaProxyHandler(req, res) {
|
|||||||
|
|
||||||
const controllerVersionMajor = parseInt(controllerVersion.split(".")[0], 10);
|
const controllerVersionMajor = parseInt(controllerVersion.split(".")[0], 10);
|
||||||
|
|
||||||
if (![3, 4, 5].includes(controllerVersionMajor)) {
|
if (![3, 4, 5, 6].includes(controllerVersionMajor)) {
|
||||||
return res.status(500).json({ error: { message: "Error determining controller version", data } });
|
return res.status(500).json({ error: { message: "Error determining controller version", data } });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,6 +80,7 @@ export default async function omadaProxyHandler(req, res) {
|
|||||||
loginUrl = `${url}/api/v2/login`;
|
loginUrl = `${url}/api/v2/login`;
|
||||||
break;
|
break;
|
||||||
case 5:
|
case 5:
|
||||||
|
case 6:
|
||||||
loginUrl = `${url}/${cId}/api/v2/login`;
|
loginUrl = `${url}/${cId}/api/v2/login`;
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
@@ -122,6 +123,7 @@ export default async function omadaProxyHandler(req, res) {
|
|||||||
sitesUrl = `${url}/api/v2/sites?token=${token}¤tPage=1¤tPageSize=1000`;
|
sitesUrl = `${url}/api/v2/sites?token=${token}¤tPage=1¤tPageSize=1000`;
|
||||||
break;
|
break;
|
||||||
case 5:
|
case 5:
|
||||||
|
case 6:
|
||||||
sitesUrl = `${url}/${cId}/api/v2/sites?token=${token}¤tPage=1¤tPageSize=1000`;
|
sitesUrl = `${url}/${cId}/api/v2/sites?token=${token}¤tPage=1¤tPageSize=1000`;
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
@@ -207,8 +209,8 @@ export default async function omadaProxyHandler(req, res) {
|
|||||||
connectedAp = siteResponseData.result.connectedAp;
|
connectedAp = siteResponseData.result.connectedAp;
|
||||||
activeUser = siteResponseData.result.activeUser;
|
activeUser = siteResponseData.result.activeUser;
|
||||||
alerts = siteResponseData.result.alerts;
|
alerts = siteResponseData.result.alerts;
|
||||||
} else if (controllerVersionMajor === 4 || controllerVersionMajor === 5) {
|
} else if ([4, 5, 6].includes(controllerVersionMajor)) {
|
||||||
const siteName = controllerVersionMajor === 5 ? site.id : site.key;
|
const siteName = controllerVersionMajor > 4 ? site.id : site.key;
|
||||||
const siteStatsUrl =
|
const siteStatsUrl =
|
||||||
controllerVersionMajor === 4
|
controllerVersionMajor === 4
|
||||||
? `${url}/api/v2/sites/${siteName}/dashboard/overviewDiagram?token=${token}¤tPage=1¤tPageSize=1000`
|
? `${url}/api/v2/sites/${siteName}/dashboard/overviewDiagram?token=${token}¤tPage=1¤tPageSize=1000`
|
||||||
|
|||||||
@@ -80,6 +80,11 @@ export default function Component({ service }) {
|
|||||||
timeLeft={t("common.duration", { value: queueEntry.eta })}
|
timeLeft={t("common.duration", { value: queueEntry.eta })}
|
||||||
title={queueEntry.name}
|
title={queueEntry.name}
|
||||||
activity={queueEntry.state}
|
activity={queueEntry.state}
|
||||||
|
size={
|
||||||
|
widget?.enableLeechSize
|
||||||
|
? t("common.bbytes", { value: queueEntry.size, maximumFractionDigits: 1 })
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
key={`${queueEntry.name}-${queueEntry.amount_left}`}
|
key={`${queueEntry.name}-${queueEntry.amount_left}`}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ export default function Component({ service }) {
|
|||||||
let status;
|
let status;
|
||||||
let uptime = 0;
|
let uptime = 0;
|
||||||
let logIndex = 0;
|
let logIndex = 0;
|
||||||
|
const hasLogs = Array.isArray(monitor.logs) && monitor.logs.length > 0;
|
||||||
|
|
||||||
switch (monitor.status) {
|
switch (monitor.status) {
|
||||||
case 0:
|
case 0:
|
||||||
@@ -62,7 +63,7 @@ export default function Component({ service }) {
|
|||||||
break;
|
break;
|
||||||
case 2:
|
case 2:
|
||||||
status = t("uptimerobot.up");
|
status = t("uptimerobot.up");
|
||||||
uptime = t("common.duration", { value: monitor.logs[0].duration });
|
uptime = t("common.duration", { value: hasLogs ? monitor.logs[0].duration : 0 });
|
||||||
logIndex = 1;
|
logIndex = 1;
|
||||||
break;
|
break;
|
||||||
case 8:
|
case 8:
|
||||||
@@ -76,14 +77,14 @@ export default function Component({ service }) {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
const lastDown = new Date(monitor.logs[logIndex].datetime * 1000).toLocaleString();
|
const lastDown = hasLogs ? new Date(monitor.logs[logIndex].datetime * 1000).toLocaleString() : "";
|
||||||
const downDuration = t("common.duration", { value: monitor.logs[logIndex].duration });
|
const downDuration = t("common.duration", { value: hasLogs ? monitor.logs[logIndex].duration : 0 });
|
||||||
const hideDown = logIndex === 1 && monitor.logs[logIndex].type !== 1;
|
const hideDown = !hasLogs || (logIndex === 1 && monitor.logs[logIndex].type !== 1);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container service={service}>
|
<Container service={service}>
|
||||||
<Block label="uptimerobot.status" value={status} />
|
<Block label="uptimerobot.status" value={status} />
|
||||||
<Block label="uptimerobot.uptime" value={uptime} />
|
{hasLogs && <Block label="uptimerobot.uptime" value={uptime} />}
|
||||||
{!hideDown && <Block label="uptimerobot.lastDown" value={lastDown} />}
|
{!hideDown && <Block label="uptimerobot.lastDown" value={lastDown} />}
|
||||||
{!hideDown && <Block label="uptimerobot.downDuration" value={downDuration} />}
|
{!hideDown && <Block label="uptimerobot.downDuration" value={downDuration} />}
|
||||||
</Container>
|
</Container>
|
||||||
|
|||||||
Reference in New Issue
Block a user