Merge branch 'dev'

This commit is contained in:
shamoon 2025-07-15 09:32:52 -07:00
commit cbf2e1a509
No known key found for this signature in database
47 changed files with 944 additions and 167 deletions

View File

@ -35,7 +35,6 @@ jobs:
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 10
run_install: false
- name: Setup Node.js
@ -94,7 +93,6 @@ jobs:
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 10
run_install: false
- name: Setup Node.js

32
.vscode/launch.json vendored
View File

@ -1,19 +1,31 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "Next.js: debug full stack",
"name": "Debug homepage",
"type": "node",
"request": "launch",
"runtimeExecutable": "${workspaceFolder}/node_modules/.bin/next",
"serverReadyAction": {
"pattern": "started server on .+, url: (https?://.+)",
"uriFormat": "%s",
"action": "debugWithChrome"
"runtimeExecutable": "pnpm",
"runtimeArgs": ["run", "dev"],
"env": {
"LOG_LEVEL": "debug"
},
"skipFiles": ["<node_internals>/**"],
"console": "integratedTerminal",
"serverReadyAction":{
"pattern": ".*http://localhost:3000.*",
"action": "startDebugging",
"name": "Launch Chromium",
"killOnServerStop": true,
}
},
{
"name": "Launch Chromium",
"type": "chrome",
"request": "launch",
"url": "http://localhost:3000",
"urlFilter": "http://localhost:3000",
"webRoot": "${workspaceFolder}",
"trace": true
}
]
}

View File

@ -44,9 +44,15 @@ fi
if [ -d /app/.next ]; then
CURRENT_UID=$(stat -c %u /app/.next)
if [ "$CURRENT_UID" -ne "$PUID" ]; then
CURRENT_GID=$(stat -c %g /app/.next)
if [ "$PUID" -ne 0 ] && ([ "$CURRENT_UID" -ne "$PUID" ] || [ "$CURRENT_GID" -ne "$PGID" ]); then
echo "Fixing ownership of /app/.next"
chown -R "$PUID:$PGID" /app/.next || echo "Warning: Could not chown /app/.next"
if ! chown -R "$PUID:$PGID" /app/.next 2>/dev/null; then
echo "Warning: Could not chown /app/.next; continuing anyway"
fi
else
echo "/app/.next already owned by correct UID/GID or running as root, skipping chown"
fi
fi

79
docs/configs/proxmox.md Normal file
View File

@ -0,0 +1,79 @@
---
title: Proxmox
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.
```yaml
url: https://proxmox.host.or.ip:8006
token: username@pam!Token ID
secret: secret
```
## Services
Once the Proxmox connection is configured, individual services can be configured to pull statistics of VMs or LXCs. Only CPU and Memory are currently supported.
### Configuration Options
- `proxmoxNode`: The name of the Proxmox node where your VM/LXC is running
- `proxmoxVMID`: The ID of the Proxmox VM or LXC container
- `proxmoxType`: (Optional) The type of Proxmox virtual machine. Defaults to `qemu` for VMs, but can be set to `lxc` for LXC containers
#### Examples
For a QEMU VM (default):
```yaml
- HomeAssistant:
icon: home-assistant.png
href: http://homeassistant.local/
description: Home automation
proxmoxNode: pve
proxmoxVMID: 101
# proxmoxType: qemu # This is the default, so it can be omitted
```
For an LXC container:
```yaml
- Nginx:
icon: nginx.png
href: http://nginx.local/
description: Web server
proxmoxNode: pve
proxmoxVMID: 200
proxmoxType: lxc
```
## Create token
You will need to generate an API Token for new or an existing user. Here is an example of how to do this for a new user.
1. Navigate to the Proxmox portal, click on Datacenter
2. Expand Permissions, click on Groups
3. Click the Create button
4. Name the group something informative, like api-ro-users
5. Click on the Permissions "folder"
6. Click Add -> Group Permission
- Path: /
- Group: group from bullet 4 above
- Role: PVEAuditor
- Propagate: Checked
7. Expand Permissions, click on Users
8. Click the Add button
- User name: something informative like `api`
- Realm: Linux PAM standard authentication
- Group: group from bullet 4 above
9. Expand Permissions, click on API Tokens
10. Click the Add button
- User: user from bullet 8 above
- Token ID: something informative like the application or purpose like `homepage`
- Privilege Separation: Checked
11. Go back to the "Permissions" menu
12. Click Add -> API Token Permission
- Path: /
- API Token: select the Token ID created in Step 10
- Role: PVE Auditor
- Propagate: Checked

View File

@ -22,6 +22,7 @@ widget:
service_group: Media # group name where widget exists
service_name: Sonarr # service name for that widget
color: teal # optional - defaults to pre-defined color for the service (teal for sonarr)
baseUrl: https://sonarr.domain.url # optional - adds links to sonarr/radarr pages
params: # optional - additional params for the service
unmonitored: true # optional - defaults to false, used with *arr stack
- type: ical # Show calendar events from another service

View File

@ -17,6 +17,7 @@ widget:
enableBlocks: true # optional, defaults to false
enableNowPlaying: true # optional, defaults to true
enableUser: true # optional, defaults to false
enableMediaControl: false # optional, defaults to true
showEpisodeNumber: true # optional, defaults to false
expandOneStreamToTwoRows: false # optional, defaults to true
```

View File

@ -5,11 +5,18 @@ description: Grafana Widget Configuration
Learn more about [Grafana](https://github.com/grafana/grafana).
| Grafana Version | Homepage Widget Version |
| --------------- | ----------------------- |
| <= v10.4 | 1 (default) |
| > v10.4 | 2 |
Allowed fields: `["dashboards", "datasources", "totalalerts", "alertstriggered"]`.
```yaml
widget:
type: grafana
version: 2 # optional, default is 1
alerts: alertmanager # optional, default is grafana
url: http://grafana.host.or.ip:port
username: username
password: password

View File

@ -17,6 +17,7 @@ widget:
enableBlocks: true # optional, defaults to false
enableNowPlaying: true # optional, defaults to true
enableUser: true # optional, defaults to false
enableMediaControl: false # optional, defaults to true
showEpisodeNumber: true # optional, defaults to false
expandOneStreamToTwoRows: false # optional, defaults to true
```

View File

@ -0,0 +1,22 @@
---
title: Komodo
description: Komodo Widget Configuration
---
This widget shows either details about all containers or stacks (if `showStacks` is true) managed by [Komodo](https://komo.do/) or the number of running servers, containers and stacks when `showSummary` is enabled.
The api key and secret can be found in the Komodo settings.
Allowed fields (max 4): `["total", "running", "stopped", "unhealthy", "unknown"]`.
Allowed fields with `showStacks` (max 4): `["total", "running", "down", "unhealthy", "unknown"]`.
Allowed fields with `showSummary`: `["servers", "stacks", "containers"]`.
```yaml
widget:
type: komodo
url: http://komodo.hostname.or.ip:port
key: K-xxxxxx...
secret: S-xxxxxx...
showSummary: true # optional, default: false
showStacks: true # optional, default: false
```

View File

@ -7,12 +7,16 @@ Learn more about [Portainer](https://github.com/portainer/portainer).
You'll need to make sure you have the correct environment set for the integration to work properly. From the Environments section inside of Portainer, click the one you'd like to connect to and observe the ID at the end of the URL (should be), something like `#!/endpoints/1`, here `1` is the value to set as the `env` value. In order to generate an API key, please follow the steps outlined here https://docs.portainer.io/api/access.
Allowed fields: `["running", "stopped", "total"]`.
Allowed fields:
- For Docker mode (default): `["running", "stopped", "total"]`
- For Kubernetes mode (`kubernetes: true`): `["applications", "services", "namespaces"]`
```yaml
widget:
type: portainer
url: https://portainer.host.or.ip:9443
env: 1
kubernetes: true # optional, defaults to false
key: ptr_accesskeyaccesskeyaccesskeyaccesskey
```

View File

@ -7,34 +7,7 @@ Learn more about [Proxmox](https://www.proxmox.com/en/).
This widget shows the running and total counts of both QEMU VMs and LX Containers in the Proxmox cluster. It also shows the CPU and memory usage of the first node in the cluster.
You will need to generate an API Token for new or an existing user. Here is an example of how to do this for a new user.
1. Navigate to the Proxmox portal, click on Datacenter
2. Expand Permissions, click on Groups
3. Click the Create button
4. Name the group something informative, like api-ro-users
5. Click on the Permissions "folder"
6. Click Add -> Group Permission
- Path: /
- Group: group from bullet 4 above
- Role: PVEAuditor
- Propagate: Checked
7. Expand Permissions, click on Users
8. Click the Add button
- User name: something informative like `api`
- Realm: Linux PAM standard authentication
- Group: group from bullet 4 above
9. Expand Permissions, click on API Tokens
10. Click the Add button
- User: user from bullet 8 above
- Token ID: something informative like the application or purpose like `homepage`
- Privilege Separation: Checked
11. Go back to the "Permissions" menu
12. Click Add -> API Token Permission
- Path: /
- API Token: select the Token ID created in Step 10
- Role: PVE Auditor
- Propagate: Checked
See the [Proxmox configuration documentation](../../configs/proxmox.md#create-token) for details on creating API tokens.
Use `username@pam!Token ID` as the `username` (e.g `api@pam!homepage`) setting and `Secret` as the `password` setting.

View File

@ -0,0 +1,17 @@
---
title: Trilium
description: Trilium Widget Configuration
---
Learn more about [Trilium](https://github.com/TriliumNext/Notes).
This widget is compatible with [TriliumNext](https://github.com/TriliumNext/Notes) versions >= [v0.94.0](https://github.com/TriliumNext/Notes/releases/tag/v0.94.0).
Find (or create) your ETAPI key under `Options > ETAPI > Create new ETAPI token`.
```yaml
widget:
type: trilium
url: https://trilium.host.or.ip
key: etapi_token
```

View File

@ -22,7 +22,7 @@
"ical.js": "^2.1.0",
"js-yaml": "^4.1.0",
"json-rpc-2.0": "^1.7.0",
"luxon": "^3.5.0",
"luxon": "^3.6.1",
"memory-cache": "^0.2.0",
"minecraftstatuspinger": "^1.2.2",
"next": "^15.3.1",
@ -32,11 +32,11 @@
"raw-body": "^3.0.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-i18next": "^11.18.6",
"react-i18next": "^15.5.3",
"react-icons": "^5.4.0",
"recharts": "^2.15.3",
"swr": "^2.3.3",
"systeminformation": "^5.25.11",
"systeminformation": "^5.27.7",
"tough-cookie": "^5.1.2",
"urbackup-server-api": "^0.8.9",
"winston": "^3.17.0",
@ -50,19 +50,26 @@
"eslint-config-prettier": "^10.1.1",
"eslint-plugin-import": "^2.31.0",
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-prettier": "^5.2.6",
"eslint-plugin-prettier": "^5.5.1",
"eslint-plugin-react": "^7.37.4",
"eslint-plugin-react-hooks": "^5.1.0",
"eslint-plugin-react-hooks": "^5.2.0",
"postcss": "^8.5.3",
"prettier": "^3.5.2",
"prettier": "^3.6.2",
"prettier-plugin-organize-imports": "^4.1.0",
"tailwind-scrollbar": "^4.0.1",
"tailwind-scrollbar": "^4.0.2",
"tailwindcss": "^4.0.9",
"typescript": "^5.7.3"
},
"optionalDependencies": {
"osx-temperature-sensor": "^1.0.8"
},
"packageManager": "pnpm@10.8.1",
"devEngines": {
"packageManager": {
"name": "pnpm",
"version": "10.8.1"
}
},
"pnpm": {
"onlyBuiltDependencies": [
"sharp"

132
pnpm-lock.yaml generated
View File

@ -42,8 +42,8 @@ importers:
specifier: ^1.7.0
version: 1.7.0
luxon:
specifier: ^3.5.0
version: 3.5.0
specifier: ^3.6.1
version: 3.6.1
memory-cache:
specifier: ^0.2.0
version: 0.2.0
@ -72,8 +72,8 @@ importers:
specifier: ^18.3.1
version: 18.3.1(react@18.3.1)
react-i18next:
specifier: ^11.18.6
version: 11.18.6(i18next@24.2.3(typescript@5.7.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
specifier: ^15.5.3
version: 15.5.3(i18next@24.2.3(typescript@5.7.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.7.3)
react-icons:
specifier: ^5.4.0
version: 5.4.0(react@18.3.1)
@ -84,8 +84,8 @@ importers:
specifier: ^2.3.3
version: 2.3.3(react@18.3.1)
systeminformation:
specifier: ^5.25.11
version: 5.25.11
specifier: ^5.27.7
version: 5.27.7
tough-cookie:
specifier: ^5.1.2
version: 5.1.2
@ -98,10 +98,6 @@ importers:
xml-js:
specifier: ^1.6.11
version: 1.6.11
optionalDependencies:
osx-temperature-sensor:
specifier: ^1.0.8
version: 1.0.8
devDependencies:
'@tailwindcss/forms':
specifier: ^0.5.10
@ -125,32 +121,36 @@ importers:
specifier: ^6.10.2
version: 6.10.2(eslint@9.25.1(jiti@2.4.2))
eslint-plugin-prettier:
specifier: ^5.2.6
version: 5.2.6(eslint-config-prettier@10.1.1(eslint@9.25.1(jiti@2.4.2)))(eslint@9.25.1(jiti@2.4.2))(prettier@3.5.2)
specifier: ^5.5.1
version: 5.5.1(eslint-config-prettier@10.1.1(eslint@9.25.1(jiti@2.4.2)))(eslint@9.25.1(jiti@2.4.2))(prettier@3.6.2)
eslint-plugin-react:
specifier: ^7.37.4
version: 7.37.4(eslint@9.25.1(jiti@2.4.2))
eslint-plugin-react-hooks:
specifier: ^5.1.0
version: 5.1.0(eslint@9.25.1(jiti@2.4.2))
specifier: ^5.2.0
version: 5.2.0(eslint@9.25.1(jiti@2.4.2))
postcss:
specifier: ^8.5.3
version: 8.5.3
prettier:
specifier: ^3.5.2
version: 3.5.2
specifier: ^3.6.2
version: 3.6.2
prettier-plugin-organize-imports:
specifier: ^4.1.0
version: 4.1.0(prettier@3.5.2)(typescript@5.7.3)
version: 4.1.0(prettier@3.6.2)(typescript@5.7.3)
tailwind-scrollbar:
specifier: ^4.0.1
version: 4.0.1(react@18.3.1)(tailwindcss@4.0.9)
specifier: ^4.0.2
version: 4.0.2(react@18.3.1)(tailwindcss@4.0.9)
tailwindcss:
specifier: ^4.0.9
version: 4.0.9
typescript:
specifier: ^5.7.3
version: 5.7.3
optionalDependencies:
osx-temperature-sensor:
specifier: ^1.0.8
version: 1.0.8
packages:
@ -166,8 +166,8 @@ packages:
resolution: {integrity: sha512-VtPOkrdPHZsKc/clNqyi9WUA8TINkZ4cGk63UUE3u4pmB2k+ZMQRDuIOagv8UVd6j7k0T3+RRIb7beKTebNbcw==}
engines: {node: '>=6.9.0'}
'@babel/runtime@7.27.1':
resolution: {integrity: sha512-1x3D2xEk2fRo3PAhwQwu5UubzgiVWSXTBfWpVd2Mx2AzRqJuDJCsgaDVZ7HB5iGzDW1Hl1sWN2mFyKjmR9uAog==}
'@babel/runtime@7.27.6':
resolution: {integrity: sha512-vbavdySgbTTrmFE+EsiqUTzlOr5bzlnJtUv9PynGCAKvfQqjIXbvFdumPM/GxMDfyuGMJaJAU6TO4zc1Jf1i8Q==}
engines: {node: '>=6.9.0'}
'@balena/dockerignore@1.0.2':
@ -476,8 +476,8 @@ packages:
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
engines: {node: '>=14'}
'@pkgr/core@0.2.4':
resolution: {integrity: sha512-ROFF39F6ZrnzSUEmQQZUar0Jt4xVoP9WnDRdWwF4NNcXs3xBTLgBUDoOwW141y1jP+S8nahIbdxbFC7IShw9Iw==}
'@pkgr/core@0.2.7':
resolution: {integrity: sha512-YLT9Zo3oNPJoBjBc4q8G2mjU4tqIbf5CEOORbUUr48dCD9q3umJ3IPlVqOqDakPfd2HuwccBaqlGhN4Gmr5OWg==}
engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0}
'@protobufjs/aspromise@1.1.2':
@ -1364,8 +1364,8 @@ packages:
peerDependencies:
eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9
eslint-plugin-prettier@5.2.6:
resolution: {integrity: sha512-mUcf7QG2Tjk7H055Jk0lGBjbgDnfrvqjhXh9t2xLMSCjZVcw9Rb1V6sVNXO0th3jgeO7zllWPTNRil3JW94TnQ==}
eslint-plugin-prettier@5.5.1:
resolution: {integrity: sha512-dobTkHT6XaEVOo8IO90Q4DOSxnm3Y151QxPJlM/vKC0bVy+d6cVWQZLlFiuZPP0wS6vZwSKeJgKkcS+KfMBlRw==}
engines: {node: ^14.18.0 || >=16.0.0}
peerDependencies:
'@types/eslint': '>=8.0.0'
@ -1378,8 +1378,8 @@ packages:
eslint-config-prettier:
optional: true
eslint-plugin-react-hooks@5.1.0:
resolution: {integrity: sha512-mpJRtPgHN2tNAvZ35AMfqeB3Xqeo273QxrHJsbBEPWODRM4r0yB6jfoROqKEYrOn27UtRPpcpHc2UqyBSuUNTw==}
eslint-plugin-react-hooks@5.2.0:
resolution: {integrity: sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==}
engines: {node: '>=10'}
peerDependencies:
eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0
@ -1989,8 +1989,8 @@ packages:
lru-cache@10.4.3:
resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==}
luxon@3.5.0:
resolution: {integrity: sha512-rh+Zjr6DNfUYR3bPwJEnuwDdqMbxZW7LOQfUN4B54+Cl+0o5zaU9RJ6bcidfDtC1cWCZXQ+nvX8bf6bAji37QQ==}
luxon@3.6.1:
resolution: {integrity: sha512-tJLxrKJhO2ukZ5z0gyjY1zPh3Rh88Ej9P7jNrZiHMUXHae1yvI2imgOZtL1TO8TW6biMMKfTtAOoEJANgtWBMQ==}
engines: {node: '>=12'}
math-intrinsics@1.1.0:
@ -2262,8 +2262,8 @@ packages:
vue-tsc:
optional: true
prettier@3.5.2:
resolution: {integrity: sha512-lc6npv5PH7hVqozBR7lkBNOGXV9vMwROAPlumdBkX0wTbbzPu/U1hk5yL8p2pt4Xoc+2mkT8t/sow2YrV/M5qg==}
prettier@3.6.2:
resolution: {integrity: sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==}
engines: {node: '>=14'}
hasBin: true
@ -2322,6 +2322,22 @@ packages:
react-native:
optional: true
react-i18next@15.5.3:
resolution: {integrity: sha512-ypYmOKOnjqPEJZO4m1BI0kS8kWqkBNsKYyhVUfij0gvjy9xJNoG/VcGkxq5dRlVwzmrmY1BQMAmpbbUBLwC4Kw==}
peerDependencies:
i18next: '>= 23.2.3'
react: '>= 16.8.0'
react-dom: '*'
react-native: '*'
typescript: ^5
peerDependenciesMeta:
react-dom:
optional: true
react-native:
optional: true
typescript:
optional: true
react-icons@5.4.0:
resolution: {integrity: sha512-7eltJxgVt7X64oHh6wSWNwwbKTCtMfK35hcjvJS0yxEAhPM8oUKdS3+kqaW1vicIltw+kR2unHaa12S9pPALoQ==}
peerDependencies:
@ -2632,18 +2648,18 @@ packages:
peerDependencies:
react: ^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
synckit@0.11.4:
resolution: {integrity: sha512-Q/XQKRaJiLiFIBNN+mndW7S/RHxvwzuZS6ZwmRzUBqJBv/5QIKCEwkBC8GBf8EQJKYnaFs0wOZbKTXBPj8L9oQ==}
synckit@0.11.8:
resolution: {integrity: sha512-+XZ+r1XGIJGeQk3VvXhT6xx/VpbHsRzsTkGgF6E5RX9TTXD0118l87puaEBZ566FhqblC6U0d4XnubznJDm30A==}
engines: {node: ^14.18.0 || >=16.0.0}
systeminformation@5.25.11:
resolution: {integrity: sha512-jI01fn/t47rrLTQB0FTlMCC+5dYx8o0RRF+R4BPiUNsvg5OdY0s9DKMFmJGrx5SwMZQ4cag0Gl6v8oycso9b/g==}
systeminformation@5.27.7:
resolution: {integrity: sha512-saaqOoVEEFaux4v0K8Q7caiauRwjXC4XbD2eH60dxHXbpKxQ8kH9Rf7Jh+nryKpOUSEFxtCdBlSUx0/lO6rwRg==}
engines: {node: '>=8.0.0'}
os: [darwin, linux, win32, freebsd, openbsd, netbsd, sunos, android]
hasBin: true
tailwind-scrollbar@4.0.1:
resolution: {integrity: sha512-j2ZfUI7p8xmSQdlqaCxEb4Mha8ErvWjDVyu2Ke4IstWprQ/6TmIz1GSLE62vsTlXwnMLYhuvbFbIFzaJGOGtMg==}
tailwind-scrollbar@4.0.2:
resolution: {integrity: sha512-wAQiIxAPqk0MNTPptVe/xoyWi27y+NRGnTwvn4PQnbvB9kp8QUBiGl/wsfoVBHnQxTmhXJSNt9NHTmcz9EivFA==}
engines: {node: '>=12.13.0'}
peerDependencies:
tailwindcss: 4.x
@ -2897,7 +2913,7 @@ snapshots:
dependencies:
regenerator-runtime: 0.14.1
'@babel/runtime@7.27.1': {}
'@babel/runtime@7.27.6': {}
'@balena/dockerignore@1.0.2': {}
@ -3181,7 +3197,7 @@ snapshots:
'@pkgjs/parseargs@0.11.0':
optional: true
'@pkgr/core@0.2.4': {}
'@pkgr/core@0.2.7': {}
'@protobufjs/aspromise@1.1.2': {}
@ -3929,7 +3945,7 @@ snapshots:
dom-helpers@5.2.1:
dependencies:
'@babel/runtime': 7.27.1
'@babel/runtime': 7.27.6
csstype: 3.1.3
dom-serializer@2.0.0:
@ -4089,7 +4105,7 @@ snapshots:
eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.29.0(eslint@9.25.1(jiti@2.4.2))(typescript@5.7.3))(eslint-import-resolver-typescript@3.10.0)(eslint@9.25.1(jiti@2.4.2))
eslint-plugin-jsx-a11y: 6.10.2(eslint@9.25.1(jiti@2.4.2))
eslint-plugin-react: 7.37.4(eslint@9.25.1(jiti@2.4.2))
eslint-plugin-react-hooks: 5.1.0(eslint@9.25.1(jiti@2.4.2))
eslint-plugin-react-hooks: 5.2.0(eslint@9.25.1(jiti@2.4.2))
optionalDependencies:
typescript: 5.7.3
transitivePeerDependencies:
@ -4183,16 +4199,16 @@ snapshots:
safe-regex-test: 1.1.0
string.prototype.includes: 2.0.1
eslint-plugin-prettier@5.2.6(eslint-config-prettier@10.1.1(eslint@9.25.1(jiti@2.4.2)))(eslint@9.25.1(jiti@2.4.2))(prettier@3.5.2):
eslint-plugin-prettier@5.5.1(eslint-config-prettier@10.1.1(eslint@9.25.1(jiti@2.4.2)))(eslint@9.25.1(jiti@2.4.2))(prettier@3.6.2):
dependencies:
eslint: 9.25.1(jiti@2.4.2)
prettier: 3.5.2
prettier: 3.6.2
prettier-linter-helpers: 1.0.0
synckit: 0.11.4
synckit: 0.11.8
optionalDependencies:
eslint-config-prettier: 10.1.1(eslint@9.25.1(jiti@2.4.2))
eslint-plugin-react-hooks@5.1.0(eslint@9.25.1(jiti@2.4.2)):
eslint-plugin-react-hooks@5.2.0(eslint@9.25.1(jiti@2.4.2)):
dependencies:
eslint: 9.25.1(jiti@2.4.2)
@ -4535,7 +4551,7 @@ snapshots:
i18next@21.10.0:
dependencies:
'@babel/runtime': 7.27.0
'@babel/runtime': 7.26.9
i18next@24.2.3(typescript@5.7.3):
dependencies:
@ -4842,7 +4858,7 @@ snapshots:
lru-cache@10.4.3: {}
luxon@3.5.0: {}
luxon@3.6.1: {}
math-intrinsics@1.1.0: {}
@ -5089,12 +5105,12 @@ snapshots:
dependencies:
fast-diff: 1.3.0
prettier-plugin-organize-imports@4.1.0(prettier@3.5.2)(typescript@5.7.3):
prettier-plugin-organize-imports@4.1.0(prettier@3.6.2)(typescript@5.7.3):
dependencies:
prettier: 3.5.2
prettier: 3.6.2
typescript: 5.7.3
prettier@3.5.2: {}
prettier@3.6.2: {}
pretty-bytes@6.1.1: {}
@ -5160,14 +5176,15 @@ snapshots:
optionalDependencies:
react-dom: 18.3.1(react@18.3.1)
react-i18next@11.18.6(i18next@24.2.3(typescript@5.7.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
react-i18next@15.5.3(i18next@24.2.3(typescript@5.7.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.7.3):
dependencies:
'@babel/runtime': 7.26.9
'@babel/runtime': 7.27.6
html-parse-stringify: 3.0.1
i18next: 24.2.3(typescript@5.7.3)
react: 18.3.1
optionalDependencies:
react-dom: 18.3.1(react@18.3.1)
typescript: 5.7.3
react-icons@5.4.0(react@18.3.1):
dependencies:
@ -5187,7 +5204,7 @@ snapshots:
react-transition-group@4.4.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies:
'@babel/runtime': 7.27.1
'@babel/runtime': 7.27.6
dom-helpers: 5.2.1
loose-envify: 1.4.0
prop-types: 15.8.1
@ -5559,14 +5576,13 @@ snapshots:
react: 18.3.1
use-sync-external-store: 1.5.0(react@18.3.1)
synckit@0.11.4:
synckit@0.11.8:
dependencies:
'@pkgr/core': 0.2.4
tslib: 2.8.1
'@pkgr/core': 0.2.7
systeminformation@5.25.11: {}
systeminformation@5.27.7: {}
tailwind-scrollbar@4.0.1(react@18.3.1)(tailwindcss@4.0.9):
tailwind-scrollbar@4.0.2(react@18.3.1)(tailwindcss@4.0.9):
dependencies:
prism-react-renderer: 2.4.1(react@18.3.1)
tailwindcss: 4.0.9

View File

@ -359,6 +359,12 @@
"services": "Services",
"middleware": "Middleware"
},
"trilium": {
"version": "Version",
"notesCount": "Notes",
"dbSize": "Database Size",
"unknown": "Unknown"
},
"navidrome": {
"nothing_streaming": "No Active Streams",
"please_wait": "Please Wait"
@ -1054,5 +1060,16 @@
"checkmk": {
"serviceErrors": "Service issues",
"hostErrors": "Host issues"
},
"komodo": {
"total": "Total",
"running": "Running",
"stopped": "Stopped",
"down": "Down",
"unhealthy": "Unhealthy",
"unknown": "Unknown",
"servers": "Servers",
"stacks": "Stacks",
"containers": "Containers"
}
}

View File

@ -4,9 +4,11 @@ import { useContext, useState } from "react";
import { SettingsContext } from "utils/contexts/settings";
import Docker from "widgets/docker/component";
import Kubernetes from "widgets/kubernetes/component";
import ProxmoxVM from "widgets/proxmoxvm/component";
import KubernetesStatus from "./kubernetes-status";
import Ping from "./ping";
import ProxmoxStatus from "./proxmox-status";
import SiteMonitor from "./site-monitor";
import Status from "./status";
import Widget from "./widget";
@ -121,6 +123,16 @@ export default function Item({ service, groupName, useEqualHeights }) {
<span className="sr-only">View container stats</span>
</button>
)}
{service.proxmoxNode && service.proxmoxVMID && (
<button
type="button"
onClick={() => (statsOpen ? closeStats() : setStatsOpen(true))}
className="shrink-0 flex items-center justify-center cursor-pointer service-tag service-proxmoxstatus"
>
<ProxmoxStatus service={service} style={statusStyle} />
<span className="sr-only">View Proxmox stats</span>
</button>
)}
</div>
</div>
@ -152,6 +164,26 @@ export default function Item({ service, groupName, useEqualHeights }) {
)}
</div>
)}
{service.proxmoxNode && service.proxmoxVMID && (
<div
className={classNames(
showStats || (statsOpen && !statsClosing) ? "max-h-[110px] opacity-100" : " max-h-0 opacity-0",
"w-full overflow-hidden transition-all duration-300 ease-in-out service-stats",
)}
>
{(showStats || statsOpen) && (
<ProxmoxVM
service={{
widget: {
node: service.proxmoxNode,
vmid: service.proxmoxVMID,
type: service.proxmoxType,
},
}}
/>
)}
</div>
)}
{service.widgets.map((widget) => (
<Widget widget={widget} service={service} key={widget.index} />

View File

@ -0,0 +1,65 @@
import { useTranslation } from "next-i18next";
import useSWR from "swr";
export default function ProxmoxStatus({ service, style }) {
const { t } = useTranslation();
const vmType = service.proxmoxType || "qemu";
const apiUrl = `/api/proxmox/stats/${service.proxmoxNode}/${service.proxmoxVMID}?type=${vmType}`;
const { data, error } = useSWR(apiUrl);
let statusLabel = t("docker.unknown");
let backgroundClass = "px-1.5 py-0.5 bg-theme-500/10 dark:bg-theme-900/50";
let colorClass = "text-black/20 dark:text-white/40 ";
if (error) {
statusLabel = t("docker.error");
colorClass = "text-rose-500/80";
} else if (data) {
if (data.status === "running") {
statusLabel = t("docker.running");
colorClass = "text-emerald-500/80";
}
if (data.status === "stopped") {
statusLabel = t("docker.exited");
colorClass = "text-orange-400/50 dark:text-orange-400/80";
}
if (data.status === "paused") {
statusLabel = "paused";
colorClass = "text-blue-500/80";
}
if (data.status === "offline") {
statusLabel = "offline";
colorClass = "text-orange-400/50 dark:text-orange-400/80";
}
if (data.status === "not found") {
statusLabel = t("docker.not_found");
colorClass = "text-orange-400/50 dark:text-orange-400/80";
}
}
if (style === "dot") {
colorClass = colorClass.replace(/text-/g, "bg-").replace(/\/\d\d/g, "");
backgroundClass = "p-4 hover:bg-theme-500/10 dark:hover:bg-theme-900/20";
}
return (
<div
className={`w-auto text-center overflow-hidden ${backgroundClass} rounded-b-[3px] proxmoxstatus proxmoxstatus-${statusLabel
.toLowerCase()
.replace(" ", "-")}`}
title={statusLabel}
>
{style !== "dot" ? (
<div className={`text-[8px] font-bold ${colorClass} uppercase`}>{statusLabel}</div>
) : (
<div className={`rounded-full h-3 w-3 ${colorClass}`} />
)}
</div>
);
}

View File

@ -55,7 +55,7 @@ export default function ColorToggle() {
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1"
>
<Popover.Panel className="absolute -top-[75px] left-0">
<Popover.Panel className="absolute -top-[75px] left-0 z-10">
<div className="rounded-md shadow-lg ring-1 ring-black ring-opacity-5 w-[85vw] sm:w-full">
<div className="relative grid gap-2 p-2 grid-cols-11 bg-white/50 dark:bg-white/10 shadow-black/10 dark:shadow-black/20 rounded-md shadow-md">
{colors.map((color) => (

View File

@ -0,0 +1,65 @@
import { getProxmoxConfig } from "utils/config/proxmox";
import createLogger from "utils/logger";
import { httpProxy } from "utils/proxy/http";
const logger = createLogger("proxmoxStatsService");
export default async function handler(req, res) {
const { service, type: vmType } = req.query;
const [node, vmid] = service;
if (!node) {
return res.status(400).send({
error: "Proxmox node parameter is required",
});
}
try {
const proxmoxConfig = getProxmoxConfig();
if (!proxmoxConfig) {
return res.status(500).send({
error: "Proxmox server configuration not found",
});
}
const baseUrl = `${proxmoxConfig.url}/api2/json`;
const headers = {
Authorization: `PVEAPIToken=${proxmoxConfig.token}=${proxmoxConfig.secret}`,
};
const statusUrl = `${baseUrl}/nodes/${node}/${vmType}/${vmid}/status/current`;
const [status, , data] = await httpProxy(statusUrl, {
method: "GET",
headers,
});
if (status !== 200) {
logger.error("HTTP Error %d calling Proxmox API", status);
return res.status(status).send({
error: `Failed to fetch Proxmox ${vmType} status`,
});
}
let parsedData = JSON.parse(Buffer.from(data).toString());
if (!parsedData || !parsedData.data) {
return res.status(500).send({
error: "Invalid response from Proxmox API",
});
}
return res.status(200).json({
status: parsedData.data.status || "unknown",
cpu: parsedData.data.cpu,
mem: parsedData.data.mem,
});
} catch (error) {
logger.error("Error fetching Proxmox status:", error);
return res.status(500).send({
error: "Failed to fetch Proxmox status",
});
}
}

View File

@ -1,6 +1,6 @@
import checkAndCopyConfig from "utils/config/config";
const configs = ["docker.yaml", "settings.yaml", "services.yaml", "bookmarks.yaml"];
const configs = ["docker.yaml", "settings.yaml", "services.yaml", "bookmarks.yaml", "kubernetes.yaml", "proxmox.yaml"];
export default async function handler(req, res) {
const errors = configs.map((config) => checkAndCopyConfig(config)).filter((status) => status !== true);

View File

@ -67,7 +67,7 @@ export default async function handler(req, res) {
nodeMap[nodeMetric.metadata.name].memory.percent = (mem / nodeMap[nodeMetric.metadata.name].memory.total) * 100;
});
} catch (error) {
logger.error("Error getting metrics, ensure you have metrics-server installed: s", JSON.stringify(error));
logger.error("Error getting metrics, ensure you have metrics-server installed:", JSON.stringify(error));
return res.status(500).send({
error: "Error getting metrics, check logs for more details",
});

View File

@ -0,0 +1,4 @@
---
# url: https://proxmox.host.or.ip:8006
# token: username@pam!Token ID
# secret: secret

View File

@ -0,0 +1,14 @@
import { readFileSync } from "fs";
import path from "path";
import yaml from "js-yaml";
import checkAndCopyConfig, { CONF_DIR, substituteEnvironmentVars } from "utils/config/config";
export function getProxmoxConfig() {
checkAndCopyConfig("proxmox.yaml");
const configFile = path.join(CONF_DIR, "proxmox.yaml");
const rawConfigData = readFileSync(configFile, "utf8");
const configData = substituteEnvironmentVars(rawConfigData);
return yaml.load(configData);
}

View File

@ -295,6 +295,7 @@ export function cleanServiceGroups(groups) {
// emby, jellyfin
enableBlocks,
enableNowPlaying,
enableMediaControl,
// emby, jellyfin, tautulli
enableUser,
@ -337,6 +338,10 @@ export function cleanServiceGroups(groups) {
// jellystat
days,
// komodo
showSummary,
showStacks,
// kopia
snapshotHost,
snapshotPath,
@ -362,6 +367,9 @@ export function cleanServiceGroups(groups) {
// opnsense, pfsense
wan,
// portainer
kubernetes,
// prometheusmetric
metrics,
@ -399,6 +407,9 @@ export function cleanServiceGroups(groups) {
// spoolman
spoolIds,
// grafana
alerts,
} = widgetData;
let fieldsList = fields;
@ -443,12 +454,19 @@ export function cleanServiceGroups(groups) {
if (type === "unifi") {
if (site) widget.site = site;
}
if (type === "portainer") {
if (kubernetes) widget.kubernetes = !!JSON.parse(kubernetes);
}
if (type === "proxmox") {
if (node) widget.node = node;
}
if (type === "proxmoxbackupserver") {
if (datastore) widget.datastore = datastore;
}
if (type === "komodo") {
if (showSummary !== undefined) widget.showSummary = !!JSON.parse(showSummary);
if (showStacks !== undefined) widget.showStacks = !!JSON.parse(showStacks);
}
if (type === "kubernetes") {
if (namespace) widget.namespace = namespace;
if (app) widget.app = app;
@ -471,6 +489,7 @@ export function cleanServiceGroups(groups) {
if (wan) widget.wan = wan;
}
if (["emby", "jellyfin"].includes(type)) {
if (enableMediaControl !== undefined) widget.enableMediaControl = !!JSON.parse(enableMediaControl);
if (enableBlocks !== undefined) widget.enableBlocks = JSON.parse(enableBlocks);
if (enableNowPlaying !== undefined) widget.enableNowPlaying = JSON.parse(enableNowPlaying);
}
@ -498,7 +517,18 @@ export function cleanServiceGroups(groups) {
if (snapshotPath) widget.snapshotPath = snapshotPath;
}
if (
["beszel", "glances", "immich", "komga", "mealie", "pfsense", "pihole", "speedtest", "wgeasy"].includes(type)
[
"beszel",
"glances",
"immich",
"komga",
"mealie",
"pfsense",
"pihole",
"speedtest",
"wgeasy",
"grafana",
].includes(type)
) {
if (version) widget.version = parseInt(version, 10);
}
@ -577,6 +607,9 @@ export function cleanServiceGroups(groups) {
if (type === "jellystat") {
if (days !== undefined) widget.days = parseInt(days, 10);
}
if (type === "grafana") {
if (alerts) widget.alerts = alerts;
}
return widget;
});
return cleanedService;

View File

@ -106,6 +106,8 @@ export default async function credentialedProxyHandler(req, res, map) {
} else {
headers.Authorization = widget.password;
}
} else if (widget.type === "trilium") {
headers.Authorization = widget.key;
} else if (widget.type === "gitlab") {
headers["PRIVATE-TOKEN"] = widget.key;
} else if (widget.type === "speedtest") {

View File

@ -19,9 +19,11 @@ export default async function genericProxyHandler(req, res, map) {
if (widget) {
// if there are more than one question marks, replace others to &
const url = new URL(
formatApiCall(widgets[widget.type].api, { endpoint, ...widget }).replace(/(?<=\?.*)\?/g, "&"),
);
let urlString = formatApiCall(widgets[widget.type].api, { endpoint, ...widget }).replace(/(?<=\?.*)\?/g, "&");
if (widget.type === "customapi" && widget.url?.endsWith("/")) {
urlString += "/"; // Ensure we dont lose the trailing slash for custom API calls
}
const url = new URL(urlString);
const headers = req.extraHeaders ?? widget.headers ?? widgets[widget.type].headers ?? {};

View File

@ -8,13 +8,8 @@ export default function Event({ event, colorVariants, showDate = false, showTime
const [hover, setHover] = useState(false);
const { i18n } = useTranslation();
return (
<div
className="flex flex-row text-theme-700 dark:text-theme-200 items-center text-xs relative h-5 w-full rounded-md bg-theme-200/50 dark:bg-theme-900/20 mt-1"
onMouseEnter={() => setHover(!hover)}
onMouseLeave={() => setHover(!hover)}
key={`event-${event.title}-${event.date}-${event.additional}`}
>
const children = (
<>
{showDateColumn && (
<span className="ml-2 w-12">
<span>
@ -36,6 +31,26 @@ export default function Event({ event, colorVariants, showDate = false, showTime
<IoMdCheckmarkCircleOutline />
</span>
)}
</>
);
const className =
"flex flex-row text-theme-700 dark:text-theme-200 items-center text-xs relative h-5 w-full rounded-md bg-theme-200/50 dark:bg-theme-900/20 mt-1";
const key = `event-${event.title}-${event.date}-${event.additional}`;
return event.url ? (
<a
className={classNames(className, "hover:bg-theme-300/50 dark:hover:bg-theme-800/20")}
onMouseEnter={() => setHover(!hover)}
onMouseLeave={() => setHover(!hover)}
key={key}
href={event.url}
target="_blank"
rel="noopener noreferrer"
>
{children}
</a>
) : (
<div className={className} onMouseEnter={() => setHover(!hover)} onMouseLeave={() => setHover(!hover)} key={key}>
{children}
</div>
);
}

View File

@ -54,6 +54,7 @@ export default function Integration({ config, params, setEvents, hideErrors, tim
ICAL.Time.now(), // handles events without a date
location: event.getFirstPropertyValue("location"),
status: event.getFirstPropertyValue("status"),
url: event.getFirstPropertyValue("url"),
};
};
@ -133,6 +134,7 @@ export default function Integration({ config, params, setEvents, hideErrors, tim
isCompleted: getIsCompleted(),
additional: event.location,
type: "ical",
url: event.url,
};
});
});

View File

@ -22,6 +22,7 @@ export default function Integration({ config, params, setEvents, hideErrors = fa
const cinemaTitle = `${event.title} - ${t("calendar.inCinemas")}`;
const physicalTitle = `${event.title} - ${t("calendar.physicalRelease")}`;
const digitalTitle = `${event.title} - ${t("calendar.digitalRelease")}`;
const url = config?.baseUrl && event.titleSlug && `${config.baseUrl}/movie/${event.titleSlug}`;
if (event.inCinemas) {
eventsToAdd[cinemaTitle] = {
@ -30,6 +31,7 @@ export default function Integration({ config, params, setEvents, hideErrors = fa
color: config?.color ?? "amber",
isCompleted: event.hasFile,
additional: "",
url,
};
}
@ -40,6 +42,7 @@ export default function Integration({ config, params, setEvents, hideErrors = fa
color: config?.color ?? "cyan",
isCompleted: event.hasFile,
additional: "",
url,
};
}
@ -50,6 +53,7 @@ export default function Integration({ config, params, setEvents, hideErrors = fa
color: config?.color ?? "emerald",
isCompleted: event.hasFile,
additional: "",
url,
};
}
});

View File

@ -29,6 +29,7 @@ export default function Integration({ config, params, setEvents, hideErrors = fa
color: config?.color ?? "teal",
isCompleted: event.hasFile,
additional: `S${event.seasonNumber} E${event.episodeNumber}`,
url: config?.baseUrl && event.series.titleSlug && `${config.baseUrl}/series/${event.series.titleSlug}`,
};
});

View File

@ -16,7 +16,14 @@ export default async function calendarProxyHandler(req, res) {
return res.status(403).json({ error: "No integration URL specified" });
}
const [status, contentType, data] = await httpProxy(integration.url);
const options = {};
if (integration.url?.includes("outlook")) {
// Outlook requires a user agent header
options.headers = {
"User-Agent": `gethomepage/${process.env.NEXT_PUBLIC_VERSION || "dev"}`,
};
}
const [status, contentType, data] = await httpProxy(integration.url, options);
if (contentType) res.setHeader("Content-Type", contentType);

View File

@ -63,6 +63,7 @@ const components = {
jellystat: dynamic(() => import("./jellystat/component")),
kavita: dynamic(() => import("./kavita/component")),
komga: dynamic(() => import("./komga/component")),
komodo: dynamic(() => import("./komodo/component")),
kopia: dynamic(() => import("./kopia/component")),
lidarr: dynamic(() => import("./lidarr/component")),
linkwarden: dynamic(() => import("./linkwarden/component")),
@ -132,6 +133,7 @@ const components = {
tdarr: dynamic(() => import("./tdarr/component")),
traefik: dynamic(() => import("./traefik/component")),
transmission: dynamic(() => import("./transmission/component")),
trilium: dynamic(() => import("./trilium/component")),
tubearchivist: dynamic(() => import("./tubearchivist/component")),
truenas: dynamic(() => import("./truenas/component")),
unifi: dynamic(() => import("./unifi/component")),

View File

@ -45,7 +45,7 @@ function generateStreamTitle(session, enableUser, showEpisodeNumber) {
return enableUser ? `${streamTitle} (${UserName})` : streamTitle;
}
function SingleSessionEntry({ playCommand, session, enableUser, showEpisodeNumber }) {
function SingleSessionEntry({ playCommand, session, enableUser, showEpisodeNumber, enableMediaControl }) {
const {
PlayState: { PositionTicks, IsPaused, IsMuted },
} = session;
@ -85,7 +85,7 @@ function SingleSessionEntry({ playCommand, session, enableUser, showEpisodeNumbe
}}
/>
<div className="text-xs z-10 self-center ml-1">
{IsPaused && (
{enableMediaControl && IsPaused && (
<BsFillPlayFill
onClick={() => {
playCommand(session, "Unpause");
@ -93,7 +93,7 @@ function SingleSessionEntry({ playCommand, session, enableUser, showEpisodeNumbe
className="inline-block w-4 h-4 cursor-pointer -mt-[1px] mr-1 opacity-80"
/>
)}
{!IsPaused && (
{enableMediaControl && !IsPaused && (
<BsPauseFill
onClick={() => {
playCommand(session, "Pause");
@ -114,7 +114,7 @@ function SingleSessionEntry({ playCommand, session, enableUser, showEpisodeNumbe
);
}
function SessionEntry({ playCommand, session, enableUser, showEpisodeNumber }) {
function SessionEntry({ playCommand, session, enableUser, showEpisodeNumber, enableMediaControl }) {
const {
PlayState: { PositionTicks, IsPaused, IsMuted },
} = session;
@ -139,7 +139,7 @@ function SessionEntry({ playCommand, session, enableUser, showEpisodeNumber }) {
}}
/>
<div className="text-xs z-10 self-center ml-1">
{IsPaused && (
{enableMediaControl && IsPaused && (
<BsFillPlayFill
onClick={() => {
playCommand(session, "Unpause");
@ -147,7 +147,7 @@ function SessionEntry({ playCommand, session, enableUser, showEpisodeNumber }) {
className="inline-block w-4 h-4 cursor-pointer -mt-[1px] mr-1 opacity-80"
/>
)}
{!IsPaused && (
{enableMediaControl && !IsPaused && (
<BsPauseFill
onClick={() => {
playCommand(session, "Pause");
@ -238,6 +238,7 @@ export default function Component({ service }) {
const enableBlocks = service.widget?.enableBlocks;
const enableNowPlaying = service.widget?.enableNowPlaying ?? true;
const enableMediaControl = service.widget?.enableMediaControl !== false; // default is true
const enableUser = !!service.widget?.enableUser; // default is false
const expandOneStreamToTwoRows = service.widget?.expandOneStreamToTwoRows !== false; // default is true
const showEpisodeNumber = !!service.widget?.showEpisodeNumber; // default is false
@ -304,6 +305,7 @@ export default function Component({ service }) {
session={session}
enableUser={enableUser}
showEpisodeNumber={showEpisodeNumber}
enableMediaControl={enableMediaControl}
/>
</div>
</>
@ -321,6 +323,7 @@ export default function Component({ service }) {
session={session}
enableUser={enableUser}
showEpisodeNumber={showEpisodeNumber}
enableMediaControl={enableMediaControl}
/>
))}
</div>

View File

@ -31,7 +31,8 @@ function CPU({ quicklookData, className = "" }) {
return (
quicklookData &&
quicklookData.cpu && (
quicklookData.cpu !== undefined &&
quicklookData.cpu !== null && (
<div className="text-xs flex place-content-between">
<div className={className}>{t("glances.cpu")}</div>
<div className={className}>
@ -109,10 +110,10 @@ export default function Component({ service }) {
return (
<Container chart={chart}>
{chart && (
<div className="bg-linear-to-br from-theme-500/30 via-theme-600/20 to-theme-700/10 absolute -top-10 -left-2 -right-2 -bottom-2 h-[calc(100%+3em)] w-[calc(100%+1em)]" />
<div className="bg-linear-to-br from-theme-500/30 via-theme-600/20 to-theme-700/10 absolute -top-20 -left-2 -right-2 -bottom-2" />
)}
<Block position={chart ? "-top-6 right-2" : "top-3 right-3"}>
<Block position={chart ? "-top-6 right-2" : "top-3 right-2"}>
{quicklookData && quicklookData.cpu_name && chart && (
<div className="text-[0.6rem] opacity-50">{quicklookData.cpu_name}</div>
)}
@ -124,7 +125,7 @@ export default function Component({ service }) {
</div>
)}
<div className="w-[4rem]">{!chart && <Swap quicklookData={quicklookData} className="opacity-25" />}</div>
<div>{!chart && <Swap quicklookData={quicklookData} className="opacity-25 ml-2" />}</div>
</Block>
{chart && (
@ -136,18 +137,18 @@ export default function Component({ service }) {
)}
{!chart && (
<Block position="bottom-3 left-3 w-[4rem]">
<CPU quicklookData={quicklookData} className="opacity-75" />
<Block position="bottom-3 left-3">
<CPU quicklookData={quicklookData} className="opacity-75 mr-2" />
</Block>
)}
<Block position="bottom-3 right-2 w-[4rem]">
{chart && <CPU quicklookData={quicklookData} className="opacity-50" />}
<Block position="bottom-3 right-2">
{chart && <CPU quicklookData={quicklookData} className="opacity-50 ml-2" />}
{chart && <Mem quicklookData={quicklookData} className="opacity-50" />}
{!chart && <Mem quicklookData={quicklookData} className="opacity-75" />}
{chart && <Mem quicklookData={quicklookData} className="opacity-50 ml-2" />}
{!chart && <Mem quicklookData={quicklookData} className="opacity-75 ml-2" />}
{chart && <Swap quicklookData={quicklookData} className="opacity-50" />}
{chart && <Swap quicklookData={quicklookData} className="opacity-50 ml-2" />}
</Block>
</Container>
);

View File

@ -23,7 +23,7 @@ export default function Component({ service }) {
const { widget } = service;
const { chart, refreshInterval = defaultInterval, version = 3 } = widget;
const memoryInfoKey = version === 3 ? 0 : "data";
const memoryInfoKey = version === 3 ? 0 : "rss";
const { data, error } = useWidgetAPI(service.widget, `${version}/processlist`, {
refreshInterval: Math.max(defaultInterval, refreshInterval),
@ -69,7 +69,7 @@ export default function Component({ service }) {
<div className="opacity-25 w-14 text-right">{item.cpu_percent.toFixed(1)}%</div>
<div className="opacity-25 w-14 text-right">
{t("common.bytes", {
value: item.memory_info[memoryInfoKey] ?? item.memory_info.wset,
value: item.memory_info[memoryInfoKey] ?? item.memory_info.data ?? item.memory_info.wset,
maximumFractionDigits: 0,
})}
</div>

View File

@ -6,27 +6,51 @@ import useWidgetAPI from "utils/proxy/use-widget-api";
export default function Component({ service }) {
const { t } = useTranslation();
const { widget } = service;
const { version = 1, alerts = "grafana" } = widget;
const { data: statsData, error: statsError } = useWidgetAPI(widget, "stats");
const { data: alertsData, error: alertsError } = useWidgetAPI(widget, "alerts");
const { data: alertmanagerData, error: alertmanagerError } = useWidgetAPI(widget, "alertmanager");
let alertsInt = 0;
if (alertsError || !alertsData || alertsData.length === 0) {
if (alertmanagerData) {
alertsInt = alertmanagerData.length;
}
} else {
alertsInt = alertsData.filter((a) => a.state === "alerting").length;
let primaryAlertsEndpoint = "alerts";
let secondaryAlertsEndpoint = "grafana";
if (version === 2) {
primaryAlertsEndpoint = alerts;
secondaryAlertsEndpoint = "";
}
if (statsError || (alertsError && alertmanagerError)) {
const { data: primaryAlertsData, error: primaryAlertsError } = useWidgetAPI(widget, primaryAlertsEndpoint);
const { data: secondaryAlertsData, error: secondaryAlertsError } = useWidgetAPI(widget, secondaryAlertsEndpoint);
let alertsInt = 0;
let alertsError = null;
if (version === 1) {
if (primaryAlertsError || !primaryAlertsData || primaryAlertsData.length === 0) {
if (secondaryAlertsData) {
alertsInt = secondaryAlertsData.length;
}
} else {
alertsInt = primaryAlertsData.filter((a) => a.state === "alerting").length;
}
if (primaryAlertsError && secondaryAlertsError) {
alertsError = primaryAlertsError ?? secondaryAlertsError;
}
} else if (version === 2) {
if (primaryAlertsData) {
alertsInt = primaryAlertsData.length;
}
if (primaryAlertsError) {
alertsError = primaryAlertsError;
}
}
if (statsError || alertsError) {
return <Container service={service} error={statsError ?? alertsError} />;
}
if (!statsData || (!alertsData && !alertmanagerData)) {
if (!statsData) {
return (
<Container service={service}>
<Block label="grafana.dashboards" />

View File

@ -9,6 +9,9 @@ const widget = {
endpoint: "alerts",
},
alertmanager: {
endpoint: "alertmanager/alertmanager/api/v2/alerts",
},
grafana: {
endpoint: "alertmanager/grafana/api/v2/alerts",
},
stats: {

View File

@ -0,0 +1,84 @@
import Block from "components/services/widget/block";
import Container from "components/services/widget/container";
import { useTranslation } from "next-i18next";
import useWidgetAPI from "utils/proxy/use-widget-api";
const MAX_ALLOWED_FIELDS = 4;
export default function Component({ service }) {
const { t } = useTranslation();
const { widget } = service;
const containersEndpoint = !(!widget.showSummary && widget.showStacks) ? "containers" : "";
const { data: containersData, error: containersError } = useWidgetAPI(widget, containersEndpoint);
const stacksEndpoint = widget.showSummary || widget.showStacks ? "stacks" : "";
const { data: stacksData, error: stacksError } = useWidgetAPI(widget, stacksEndpoint);
const serversEndpoint = widget.showSummary ? "servers" : "";
const { data: serversData, error: serversError } = useWidgetAPI(widget, serversEndpoint);
if (containersError || stacksError || serversError) {
return <Container service={service} error={containersError ?? stacksError ?? serversError} />;
}
if (!widget.fields || widget.fields.length === 0) {
widget.fields = widget.showSummary
? ["servers", "stacks", "containers"]
: widget.showStacks
? ["total", "running", "down", "unhealthy"]
: ["total", "running", "stopped", "unhealthy"];
} else if (widget.fields?.length > MAX_ALLOWED_FIELDS) {
widget.fields = widget.fields.slice(0, MAX_ALLOWED_FIELDS);
}
if (
(!widget.showStacks && !containersData) ||
(widget.showSummary && (!stacksData || !serversData)) ||
(widget.showStacks && !stacksData)
) {
return widget.showSummary ? (
<Container service={service}>
<Block label="komodo.servers" />
<Block label="komodo.stacks" />
<Block label="komodo.containers" />
</Container>
) : widget.showStacks ? (
<Container service={service}>
<Block label="komodo.total" />
<Block label="komodo.running" />
<Block label="komodo.down" />
<Block label="komodo.unhealthy" />
</Container>
) : (
<Container service={service}>
<Block label="komodo.total" />
<Block label="komodo.running" />
<Block label="komodo.stopped" />
<Block label="komodo.unhealthy" />
</Container>
);
}
return widget.showSummary ? (
<Container service={service}>
<Block label="komodo.servers" value={`${serversData.healthy} / ${serversData.total}`} />
<Block label="komodo.stacks" value={`${stacksData.running} / ${stacksData.total}`} />
<Block label="komodo.containers" value={`${containersData.running} / ${containersData.total}`} />
</Container>
) : widget.showStacks ? (
<Container service={service}>
<Block label="komodo.total" value={t("common.number", { value: stacksData.total })} />
<Block label="komodo.running" value={t("common.number", { value: stacksData.running })} />
<Block label="komodo.down" value={t("common.number", { value: stacksData.stopped + stacksData.down })} />
<Block label="komodo.unhealthy" value={t("common.number", { value: stacksData.unhealthy })} />
<Block label="komodo.unknown" value={t("common.number", { value: stacksData.unknown })} />
</Container>
) : (
<Container service={service}>
<Block label="komodo.total" value={t("common.number", { value: containersData.total })} />
<Block label="komodo.running" value={t("common.number", { value: containersData.running })} />
<Block label="komodo.stopped" value={t("common.number", { value: containersData.stopped })} />
<Block label="komodo.unhealthy" value={t("common.number", { value: containersData.unhealthy })} />
<Block label="komodo.unknown" value={t("common.number", { value: containersData.unknown })} />
</Container>
);
}

View File

@ -0,0 +1,55 @@
import getServiceWidget from "utils/config/service-helpers";
import createLogger from "utils/logger";
import { formatApiCall, sanitizeErrorURL } from "utils/proxy/api-helpers";
import { httpProxy } from "utils/proxy/http";
import validateWidgetData from "utils/proxy/validate-widget-data";
import widgets from "widgets/widgets";
const logger = createLogger("komodoProxyHandler");
export default async function komodoProxyHandler(req, res) {
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) {
// api uses unified read endpoint
const url = new URL(formatApiCall(widgets[widget.type].api, { endpoint: "read", ...widget })).toString();
const headers = {
"Content-Type": "application/json",
"X-API-Key": `${widget.key}`,
"X-API-Secret": `${widget.secret}`,
};
const [status, contentType, data] = await httpProxy(url, {
method: "POST",
body: JSON.stringify(widgets[widget.type].mappings?.[endpoint]?.body || {}),
headers,
});
let resultData = data;
if (status >= 400) {
logger.error("HTTP Error %d calling %s", status, sanitizeErrorURL(url));
}
if (status === 200) {
if (!validateWidgetData(widget, endpoint, resultData)) {
return res
.status(500)
.json({ error: { message: "Invalid data", url: sanitizeErrorURL(url), data: resultData } });
}
}
if (contentType) res.setHeader("Content-Type", contentType);
return res.status(status).send(resultData);
}
}
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

@ -0,0 +1,32 @@
import komodoProxyHandler from "./proxy";
const widget = {
api: "{url}/{endpoint}",
proxyHandler: komodoProxyHandler,
mappings: {
containers: {
endpoint: "containers", // api actually uses unified read endpoint
body: {
type: "GetDockerContainersSummary",
params: {},
},
},
stacks: {
endpoint: "stacks", // api actually uses unified read endpoint
body: {
type: "GetStacksSummary",
params: {},
},
},
servers: {
endpoint: "servers", // api actually uses unified read endpoint
body: {
type: "GetServersSummary",
params: {},
},
},
},
};
export default widget;

View File

@ -6,15 +6,64 @@ import useWidgetAPI from "utils/proxy/use-widget-api";
export default function Component({ service }) {
const { widget } = service;
const { data: containersData, error: containersError } = useWidgetAPI(widget, "docker/containers/json", {
all: 1,
});
if (!widget.fields) {
widget.fields = widget.kubernetes ? ["applications", "services", "namespaces"] : ["running", "stopped", "total"];
}
const { data: containersCount, error: containersError } = useWidgetAPI(
widget,
widget.kubernetes ? "" : "docker/containers",
{
all: 1,
},
);
const { data: applicationsCount, error: applicationsError } = useWidgetAPI(
widget,
widget.kubernetes ? "kubernetes/applications" : "",
);
const { data: servicesCount, error: servicesError } = useWidgetAPI(
widget,
widget.kubernetes ? "kubernetes/services" : "",
);
const { data: namespacesCount, error: namespacesError } = useWidgetAPI(
widget,
widget.kubernetes ? "kubernetes/namespaces" : "",
);
if (widget.kubernetes) {
// count can be an error object
const error = applicationsError ?? servicesError ?? namespacesError ?? applicationsCount;
if (error) {
return <Container service={service} error={error} />;
}
if (applicationsCount == undefined || servicesCount == undefined || namespacesCount == undefined) {
return (
<Container service={service}>
<Block label="portainer.applications" />
<Block label="portainer.services" />
<Block label="portainer.namespaces" />
</Container>
);
}
return (
<Container service={service}>
<Block label="portainer.applications" value={applicationsCount ?? 0} />
<Block label="portainer.services" value={servicesCount ?? 0} />
<Block label="portainer.namespaces" value={namespacesCount ?? 0} />
</Container>
);
}
if (containersError) {
return <Container service={service} error={containersError} />;
}
if (!containersData) {
if (!containersCount) {
return (
<Container service={service}>
<Block label="portainer.running" />
@ -24,14 +73,14 @@ export default function Component({ service }) {
);
}
if (containersData.error || containersData.message) {
if (containersCount.error || containersCount.message) {
// containersData can be itself an error object e.g. if environment fails
return <Container service={service} error={containersData?.error ?? containersData} />;
return <Container service={service} error={containersCount?.error ?? containersCount} />;
}
const running = containersData.filter((c) => c.State === "running").length;
const stopped = containersData.filter((c) => c.State === "exited").length;
const total = containersData.length;
const running = containersCount.filter((c) => c.State === "running").length;
const stopped = containersCount.filter((c) => c.State === "exited").length;
const total = containersCount.length;
return (
<Container service={service}>

View File

@ -1,14 +1,23 @@
import credentialedProxyHandler from "utils/proxy/handlers/credentialed";
const widget = {
api: "{url}/api/endpoints/{env}/{endpoint}",
api: "{url}/api/{endpoint}",
proxyHandler: credentialedProxyHandler,
mappings: {
"docker/containers/json": {
endpoint: "docker/containers/json",
"docker/containers": {
endpoint: "endpoints/{env}/docker/containers/json",
params: ["all"],
},
"kubernetes/applications": {
endpoint: "kubernetes/{env}/applications/count",
},
"kubernetes/services": {
endpoint: "kubernetes/{env}/services/count",
},
"kubernetes/namespaces": {
endpoint: "kubernetes/{env}/namespaces/count",
},
},
};

View File

@ -0,0 +1,32 @@
import Block from "components/services/widget/block";
import Container from "components/services/widget/container";
import { useTranslation } from "next-i18next";
import useSWR from "swr";
export default function ProxmoxVM({ service }) {
const { t } = useTranslation();
const { widget } = service;
const { data, error } = useSWR(`/api/proxmox/stats/${widget.node}/${widget.vmid}?type=${widget.type || "qemu"}`);
if (error) {
return <Container service={service} error={error} />;
}
if (!data) {
return (
<Container service={service}>
<Block label="resources.cpu" />
<Block label="resources.mem" />
</Container>
);
}
return (
<Container service={service}>
<Block label="resources.cpu" value={t("common.percent", { value: data.cpu })} />
<Block label="resources.mem" value={t("common.bytes", { value: data.mem })} />
</Container>
);
}

View File

@ -45,6 +45,25 @@ export default function Component({ service }) {
}
const leech = torrentData.length - completed;
const statePriority = [
"downloading",
"forcedDL",
"metaDL",
"forcedMetaDL",
"checkingDL",
"stalledDL",
"queuedDL",
"pausedDL",
];
leechTorrents.sort((firstTorrent, secondTorrent) => {
const firstStateIndex = statePriority.indexOf(firstTorrent.state);
const secondStateIndex = statePriority.indexOf(secondTorrent.state);
if (firstStateIndex !== secondStateIndex) {
return firstStateIndex - secondStateIndex;
}
return secondTorrent.progress - firstTorrent.progress;
});
return (
<>

View File

@ -0,0 +1,38 @@
import Block from "components/services/widget/block";
import Container from "components/services/widget/container";
import { useTranslation } from "next-i18next";
import useWidgetAPI from "utils/proxy/use-widget-api";
export default function Component({ service }) {
const { t } = useTranslation();
const { widget } = service;
const { data: metricsData, error: metricsError } = useWidgetAPI(widget, "metrics");
if (metricsError) {
return <Container service={service} error={metricsError} />;
}
if (!metricsData) {
return (
<Container service={service}>
<Block label="trilium.version" />
<Block label="trilium.notesCount" />
<Block label="trilium.dbSize" />
</Container>
);
}
const version = metricsData.version?.app;
const notesCount = metricsData.database?.activeNotes || 0;
const databaseSizeBytes = metricsData.statistics?.databaseSizeBytes || 0;
return (
<Container service={service}>
<Block label="trilium.version" value={version ? `v${version}` : t("trilium.unknown")} />
<Block label="trilium.notesCount" value={t("common.number", { value: notesCount })} />
<Block label="trilium.dbSize" value={t("common.bytes", { value: databaseSizeBytes })} />
</Container>
);
}

View File

@ -0,0 +1,15 @@
import credentialedProxyHandler from "utils/proxy/handlers/credentialed";
const widget = {
api: "{url}/etapi/{endpoint}",
proxyHandler: credentialedProxyHandler,
mappings: {
metrics: {
endpoint: "metrics?format=json",
validate: ["version", "database"],
},
},
};
export default widget;

View File

@ -54,6 +54,7 @@ import jellystat from "./jellystat/widget";
import karakeep from "./karakeep/widget";
import kavita from "./kavita/widget";
import komga from "./komga/widget";
import komodo from "./komodo/widget";
import kopia from "./kopia/widget";
import lidarr from "./lidarr/widget";
import linkwarden from "./linkwarden/widget";
@ -123,6 +124,7 @@ import tdarr from "./tdarr/widget";
import technitium from "./technitium/widget";
import traefik from "./traefik/widget";
import transmission from "./transmission/widget";
import trilium from "./trilium/widget";
import truenas from "./truenas/widget";
import tubearchivist from "./tubearchivist/widget";
import unifi from "./unifi/widget";
@ -196,6 +198,7 @@ const widgets = {
jellystat,
kavita,
komga,
komodo,
kopia,
lidarr,
linkwarden,
@ -266,6 +269,7 @@ const widgets = {
tdarr,
traefik,
transmission,
trilium,
tubearchivist,
truenas,
unifi,