Compare commits

...
Author SHA1 Message Date
Thomas Camlong ae2a1df453 fix(dev): preserve background build errors 2026-08-02 08:26:56 +02:00
Thomas Camlong ca7e50fb96 docs(dev): explain local PR preference 2026-08-02 08:19:45 +02:00
Thomas Camlong db6aa0b26e feat(dev): prefer current local PR builds 2026-08-02 08:19:29 +02:00
Thomas Camlong d096aecd59 feat(dev): track pull request head revisions 2026-08-02 08:19:12 +02:00
Thomas Camlong 49a41431b9 docs(dev): document dashboard navigation 2026-08-01 23:49:23 +02:00
Thomas Camlong df587ec76a feat(dev): toggle dashboard screens 2026-08-01 23:49:23 +02:00
Thomas Camlong 3b450eb04a feat(dev): launch browser by default 2026-08-01 23:49:23 +02:00
Thomas 406fb4c0b6 Merge branch 'dev' into feat/dev-cli-tool 2026-08-01 23:46:16 +02:00
Thomas Camlong f23c35645c fix(dev): surface registry pull errors 2026-08-01 23:40:41 +02:00
Thomas Camlong 667bf62396 docs(dev): document remote PR builds 2026-08-01 23:36:38 +02:00
Thomas Camlong c5a056b8e5 feat(dev): build remote pull requests locally 2026-08-01 23:35:08 +02:00
Thomas Camlong 934b6e69f4 fix(dev): shallow clone pull requests 2026-08-01 23:35:08 +02:00
Thomas Camlong c8c5dbc506 fix(dev): cache GitHub pull requests 2026-08-01 23:35:08 +02:00
Thomas 797bdd001f fix: keep direct icon URLs visible (#6504) 2026-08-01 23:28:18 +02:00
Bergasha 49314533bf feat(Media player widget): expands on Direct Play and Transcoding badges (#6489) 2026-08-01 23:21:13 +02:00
Thomas 27ca058088 feat(widgets): persist Beszel and Docker table layouts (#6490) 2026-08-01 23:20:09 +02:00
homarr-releases[bot] f4449f9581 chore: update bug report template (#6498) 2026-08-01 23:08:14 +02:00
Thomas b52bc03c8d Merge pull request #6499 from homarr-labs/docs/update-helm-docs
docs(helm): update chart installation page
2026-08-01 23:07:58 +02:00
Thomas edb56f3fcd fix: accept direct icon URLs
Use valid direct HTTP(S) icon URLs without repository search.
2026-08-01 23:06:26 +02:00
Thomas Camlong b1e79e4641 fix(dev): keep optional tools optional 2026-08-01 14:01:52 +02:00
Thomas Camlong 5b04ed1ab5 refactor(dev): make setup explicit and portable 2026-07-31 23:52:14 +02:00
homarr-crowdin[bot] fcc5130068 chore(lang): update translations from crowdin (#6457) 2026-07-31 23:50:10 +02:00
homarr-update-contributors[bot] c7751e293d chore: update contributors (#6496) 2026-07-31 23:49:37 +02:00
github-actions[bot] bd669a1a8f docs(helm): update chart installation page 2026-07-31 19:35:37 +00:00
Releases Homarr 9b5b79525f chore(release): 1.73.0 [skip ci] 2026-07-31 18:16:44 +00:00
homarr-releases[bot] 426929556f chore(release): automatic release v1.73.0 2026-07-31 18:15:13 +00:00
Thomas Camlong b733ed6a79 fix(dev): make key creation atomic 2026-07-31 15:28:00 +02:00
Thomas Camlong ae3e9f7793 fix(dev): persist unique local encryption key 2026-07-31 15:11:38 +02:00
Thomas Camlong c8485ed7fc fix(dev): address CLI review feedback 2026-07-31 14:53:43 +02:00
Thomas ade640dcea test(navidrome): complete cleanup review (#6494)
* test(navidrome): hoist album fixture helper

* test(navidrome): address coderabbit cleanup feedback
2026-07-31 14:39:11 +02:00
Thomas Camlong e97eb7fc58 feat(dev): add Docker development CLI 2026-07-31 14:28:31 +02:00
Thomas 61128ddcc8 fix(navidrome): clean up audio stats integration (#6493) 2026-07-31 14:20:51 +02:00
Thomas 6a3cee8107 fix(navidrome): complete media server session data (#6492) 2026-07-31 13:53:06 +02:00
Matt Van Horn 06d9c07d22 feat: expose Navidrome as a media-server (now-playing) integration (#6070) 2026-07-31 13:30:44 +02:00
Thomas 49d0f1a141 docs: explain Podman socket setup (#6487)
* docs: explain Podman socket setup

* docs: clarify Podman runtime requirements

* docs: complete Podman compose examples
2026-07-29 16:53:29 +02:00
112 changed files with 28159 additions and 22256 deletions

No files matched your search

+1
View File
@@ -33,6 +33,7 @@ body:
options:
# The below comment is used to insert a new version with on-release.yml
#NEXT_VERSION#
- 1.73.0
- 1.72.0
- 1.71.0
- 1.70.0
+9
View File
@@ -46,6 +46,8 @@ homarr/
├── tooling/
│ ├── typescript/ # Base tsconfig
│ └── github/ # CI setup action
├── tools/
│ └── homarr-dev/ # Go CLI for local and PR Docker images
├── development/ # Dev docker-compose (Redis, MySQL, PostgreSQL)
├── e2e/ # E2E test specs
└── Dockerfile # Multi-stage production build
@@ -68,6 +70,9 @@ When modifying user-facing code, you MUST also update the corresponding document
## Monorepo Commands
- `pnpm dev` — Next.js app only
- `pnpm dev:cli -- dev` — run the developer CLI without installing a global binary
- `pnpm db:seed` — seed default database data explicitly
- `pnpm docker:dev:up` — start the Redis development service in the background
- `pnpm dev:docs` — Docusaurus docs site only
- `pnpm turbo build` — build all packages
- `pnpm turbo build --filter=@homarr/docs` — build docs only
@@ -81,3 +86,7 @@ When modifying user-facing code, you MUST also update the corresponding document
- UI: Mantine (not Tailwind) — Tailwind is only used in docs app
- Icons: @tabler/icons-react
- Docs app can import from `@homarr/definitions` for shared types
- Run `pnpm dev:cli -- dev` to browse local `homarr:*` images and remote PR images.
- Run `pnpm dev:cli -- build <name>` from a Homarr checkout to build `homarr:<name>` with rebuild provenance.
- Run `pnpm dev:cli -- build --pr <number>` to build a PR locally from a temporary checkout.
- Run Go checks from `tools/homarr-dev` with `go test ./...` and `go vet ./...`.
+2
View File
@@ -1,3 +1,5 @@
## [1.73.0](https://github.com/homarr-labs/homarr/compare/v1.72.0...v1.73.0) (2026-07-31)
## [1.72.0](https://github.com/homarr-labs/homarr/compare/v1.71.0...v1.72.0) (2026-07-24)
## [1.71.0](https://github.com/homarr-labs/homarr/compare/v1.70.0...v1.71.0) (2026-07-17)
@@ -0,0 +1,41 @@
# Developer Docker CLI
The host-side developer CLI builds and runs local Homarr images and pull-request previews. Run it directly from a checkout with `pnpm dev:cli -- ...`; no shell-specific setup script is required. It is separate from the recovery CLI included in production images.
To install an optional `homarr` binary, run `pnpm dev:cli:install` and make your Go bin directory available on `PATH`. GitHub CLI authentication is only needed for pull-request features.
## Browse development images
```bash
pnpm dev:cli --
```
The main screen combines local `homarr:*` images with open pull requests and their GHCR preview status. Select an image to start or stop it, use `R` to build a remote pull request locally in the background or rebuild a local image, or use `p` to force a remote preview pull. Press `d` to switch to the instance dashboard and `d` again to return without losing the browser state. The explicit `dev` and `dash` subcommands start on either screen.
A locally built PR image is folded into its remote PR row only when its recorded revision exactly matches the current GitHub head SHA. The row is marked `◆ local`, and starting it prefers that local image without a registry pull. Outdated local builds remain separate rows. Navigation and dashboard switching remain available during background builds.
## Build images
```bash
pnpm dev:cli -- build <name>
pnpm dev:cli -- build --pr <number> [name]
```
The first command builds the current checkout as `homarr:<name>`. The pull-request form uses a depth-one clone in a temporary directory and defaults to `homarr:pr-<number>` when `name` is omitted.
## Rebuild images
```bash
pnpm dev:cli -- rebuild <name>
```
Images built by this CLI record their source checkout, revision, and optional pull-request number. Rebuild uses that provenance to select the original checkout or create a fresh temporary pull-request checkout.
## Run images
```bash
homarr run <name>
homarr run --pr <number>
```
Add `--detach` to run in the background, `--demo` to enable demo data, or repeat `--env KEY=VALUE` to provide additional environment variables.
@@ -17,8 +17,9 @@ This guide will help you set up your development environment to start contributi
- **Node.js (lts)**: You can download it from the [official website](https://nodejs.org/).
- **pnpm (latest)**: You can install it by running `npm install -g pnpm`.
- **Git**: You can download it from the [official website](https://git-scm.com/).
- **Docker or Docker Desktop**: You can download it from the [official website](https://www.docker.com/).
- **A GitHub account**: You can create one on the [official website](https://github.com/).
- **Docker or Docker Desktop**: Required for the Redis service and the developer Docker CLI.
- **Go 1.25+**: Required only for the developer Docker CLI. You can download it from the [official website](https://go.dev/).
- **GitHub CLI**: Required only for pull-request image features. Install it from the [official website](https://cli.github.com/), then authenticate with `gh auth login`.
### Setting up the project
@@ -34,11 +35,11 @@ This guide will help you set up your development environment to start contributi
4. Install the dependencies with `pnpm install`.
5. Start redis by running `pnpm run docker:dev`. This will run a docker-compose file with a redis service.
5. Create `.env` from `.env.example`. In a POSIX shell, run `cp .env.example .env`; in PowerShell, run `Copy-Item .env.example .env`. Set `DB_URL` to an absolute path for your SQLite file.
6. Copy the `.env.example` file to `.env` and replace `DB_URL` with the full path you want to use for the sqlite database file. For example `DB_URL='C:\\Dev\\homarr\\packages\\db\\db.sqlite'`
6. Start Redis in the background with `pnpm docker:dev:up` if your development workflow needs it.
7. Run the migrations to create the sqlite db file with `pnpm run db:migration:sqlite:run`.
7. Create or update the database with `pnpm db:migration:sqlite:run`. This also seeds the default data. To run the seed explicitly later, use `pnpm db:seed`.
8. Start the development server with `pnpm dev` and wait for a message that says `✓ Ready in 10.7s`
@@ -49,8 +50,15 @@ This guide will help you set up your development environment to start contributi
#### During development
- `pnpm dev`: Start the development server.
- `pnpm dev:cli -- dev`: Browse and run local images and remote pull-request images.
- `pnpm dev:cli -- build <name>`: Build the current checkout as `homarr:<name>`.
- `pnpm dev:cli -- build --pr <number>`: Build a pull request locally from a temporary checkout.
- `pnpm dev:cli -- rebuild <name>`: Rebuild a local image from its recorded checkout or pull request.
- `pnpm dev:cli:install`: Optionally install the developer CLI as a `homarr` binary.
- `pnpm cli`: Run cli commands.
- `pnpm db:migration:sqlite:run`: Run the migrations to create or update the sqlite db file.
- `pnpm db:seed`: Explicitly seed the default database data.
- `pnpm docker:dev:up`: Start the Redis development service in the background.
- `pnpm db:migration:sqlite:generate`: Generate a new migration file for the sqlite db. Also works with `mysql`.
- `pnpm db:studio`: Open a visual studio for the database.
- `pnpm package:new`: Create a new package in the monorepo.
@@ -63,6 +71,7 @@ This guide will help you set up your development environment to start contributi
- `pnpm typecheck`: Run the typescript type checker.
#### Building the project
- `pnpm build`: Build the project for production.
- `docker build -t homarr .`: Build a docker image for the project.
- `docker run -p 7575:7575 -e SECRET_ENCRYPTION_KEY='your_64_character_hex_string' homarr:latest`: Run the created docker image.
- `pnpm dev:cli -- build <name>`: Build a `homarr:<name>` image and record its checkout for later rebuilds.
- `docker run -p 7575:7575 -e SECRET_ENCRYPTION_KEY='your_64_character_hex_string' homarr:<name>`: Run the created docker image.
@@ -55,9 +55,9 @@ A random secret can be generated by using the following command: `openssl rand -
| Environment Variable | Description | Possible values | Default |
| ------------------------ | ----------- | --------------- | ------- |
| ``DOCKER_HOSTNAMES`` | Comma seperated list of hostnames to connect to | For example `localhost,docker.example.com` | - |
| ``DOCKER_PORTS`` | Comma seperated list of ports to connect to | For example `2375,2376` | - |
| ``DOCKER_SOCKET_PATHS`` | Comma seperated list of paths to sockets | For example `/var/run/docker.sock,/run/user/1000/podman/podman.sock` | - |
| ``DOCKER_HOSTNAMES`` | Comma-separated list of hostnames to connect to | For example `localhost,docker.example.com` | - |
| ``DOCKER_PORTS`` | Comma-separated list of ports to connect to | For example `2375,2376` | - |
| ``DOCKER_SOCKET_PATHS`` | Comma-separated list of mounted Docker-compatible socket paths. See the [Docker and Podman integration guide](/docs/integrations/docker) | For example `/var/run/docker.sock,/var/run/podman.sock` | - |
## Database
| Environment Variable | Description | Possible values | Default |
+1 -1
View File
@@ -31,4 +31,4 @@ Icons that are high quality and printable ([SVG](https://wikipedia.org/wiki/Scal
### Using external icons
If you don't find the icon you're looking for on Dashboard Icons or wish to use a different source, you can simply enter a URL that points to an icon.
It is important that the URL directly points to the file without any advertisements or elements around it.
Direct HTTP(S) image URLs are used as entered instead of being searched in the built-in repositories. It is important that the URL directly points to the file without any advertisements or elements around it.
@@ -2,9 +2,9 @@
<img src="https://raw.githubusercontent.com/homarr-labs/charts/refs/heads/main/charts/homarr/icon.svg" align="right" width="92" alt="homarr logo">
![Version: 8.24.0](https://img.shields.io/badge/Version-8.24.0-informational?style=flat)
![Version: 8.25.0](https://img.shields.io/badge/Version-8.25.0-informational?style=flat)
![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat)
![AppVersion: v1.72.0](https://img.shields.io/badge/AppVersion-v1.72.0-informational?style=flat)
![AppVersion: v1.73.0](https://img.shields.io/badge/AppVersion-v1.73.0-informational?style=flat)
A Helm chart to deploy homarr for Kubernetes
@@ -429,7 +429,7 @@ All available values are listed on the [artifacthub](https://artifacthub.io/pack
| httproute.rules[0].matches[0].path.value | string | `"/"` | Path value to match |
| image.pullPolicy | string | `"IfNotPresent"` | Image pull policy |
| image.repository | string | `"ghcr.io/homarr-labs/homarr"` | Image repository |
| image.tag | string | `"v1.72.0"` | Overrides the image tag whose default is the chart appVersion |
| image.tag | string | `"v1.73.0"` | Overrides the image tag whose default is the chart appVersion |
| imagePullSecrets | list | `[]` | Secrets for Docker registry |
| ingress.annotations | object | `{}` | Ingress annotations |
| ingress.enabled | bool | `false` | Enable ingress |
@@ -55,6 +55,74 @@ services:
With docker run, add this parameter: `-v /var/run/docker.sock:/var/run/docker.sock`.
In Windows, you'll need to adjust the slashes: `-v //var/run/docker.sock:/var/run/docker.sock`
### Using Podman
On Linux, Homarr can connect to Podman's [Docker-compatible API](https://docs.podman.io/en/latest/markdown/podman-system-service.1.html).
The Podman API socket must be running and mounted into the Homarr container.
The socket grants full access to Podman, including arbitrary code execution as the user running the API, so only mount it into trusted containers.
For rootless Podman, enable the socket for the user that runs Homarr:
```bash
systemctl --user enable --now podman.socket
```
The rootless socket is located at `$XDG_RUNTIME_DIR/podman/podman.sock` (usually `/run/user/<uid>/podman/podman.sock`).
You can confirm the path with:
```bash
podman info --format '{{.Host.RemoteSocket.Path}}'
```
Mount that socket into the Homarr container and point `DOCKER_SOCKET_PATHS` to its path **inside** the container:
```yaml
services:
homarr:
container_name: homarr
image: ghcr.io/homarr-labs/homarr:latest
restart: unless-stopped
volumes:
- ${XDG_RUNTIME_DIR}/podman/podman.sock:/var/run/podman.sock
- ./homarr/appdata:/appdata
environment:
SECRET_ENCRYPTION_KEY: your_64_character_hex_string
DOCKER_SOCKET_PATHS: /var/run/podman.sock
ports:
- "7575:7575"
```
Start Homarr with `podman compose up -d`.
Run the compose project as the same user that owns the rootless Podman socket so `$XDG_RUNTIME_DIR` resolves to the correct path.
To keep the user socket available after logout and across reboots, enable lingering with `loginctl enable-linger <user>`.
For rootful Podman, enable the system socket with `sudo systemctl enable --now podman.socket`.
Its default path is `/run/podman/podman.sock`; mount that path instead and start Homarr with `sudo podman compose up -d`.
:::note SELinux
On systems with SELinux enabled, Podman's documentation recommends disabling label separation for a container that accesses the API socket.
Add the following to the Homarr service:
```yaml
security_opt:
- label=disable
```
:::
You can connect Homarr to multiple Docker-compatible sockets by mounting each socket and listing their in-container paths, separated by commas:
```yaml
services:
homarr:
image: ghcr.io/homarr-labs/homarr:latest
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- ${XDG_RUNTIME_DIR}/podman/podman.sock:/var/run/podman.sock
environment:
DOCKER_SOCKET_PATHS: /var/run/docker.sock,/var/run/podman.sock
```
### Managing your Docker containers within Homarr
Homarr allows you to interact with Docker containers running on your system.
Each container row has an action menu with the following options:
@@ -10,19 +10,21 @@ import { AddingIntegration } from '@site/src/components/integrations/adding';
import { IntegrationSecrets } from '@site/src/components/integrations/secrets';
import { navidromeIntegration } from '.';
import { audioStatsWidget } from '@site/docs/widgets/audio-stats';
import { mediaServerWidget } from '@site/docs/widgets/media-server';
<IntegrationHeader
integration={navidromeIntegration}
categories={['Media']}
categories={['Media', 'Media server']}
/>
Navidrome provides music library statistics including artist, album, and song counts, as well as now playing information via the Subsonic API.
Navidrome provides music library statistics including artist, album, and song counts, as well as now playing audio streams via the Subsonic API.
### Widgets & Capabilities
<IntegrationCapabilites
items={[
{ widget: audioStatsWidget },
{ widget: mediaServerWidget },
]}
/>
+2 -2
View File
@@ -28,7 +28,7 @@ The name must be at least one character long and can be chosen freely.
### Icon URL
This is the URL to the icon that will be displayed on the app widget.
It can be a url to an image in the internet, one from the included icon repositories or a local file uploaded through the upload button.
You can paste a direct HTTP(S) URL to an image, search the included icon repositories by name, or upload a local file through the upload button. Direct image URLs are used as entered and are not searched in the icon repositories.
![Icon URL](./img/app-icon-url.png)
@@ -50,4 +50,4 @@ It's useful when you want to ping the service internally for example with a cust
On the apps management page you find all apps that exist in your Homarr instance. You can edit and delete them.
![Apps list](./img/app-list.png)
![Apps list](./img/app-list.png)
+24 -22
View File
@@ -1,43 +1,45 @@
---
title: 'Audio Stats'
description: 'Displays library statistics from Navidrome or Audiobookshelf, adapting its display based on the linked integration.'
title: "Audio Stats"
description: "Displays media and listening statistics from Navidrome or Audiobookshelf, adapting its display based on the linked integration."
hide_title: true
---
import { WidgetHeader } from '@site/src/components/widgets/header';
import { AddingWidget } from '@site/src/components/widgets/adding';
import { WidgetConfig } from '@site/src/components/widgets/configuration';
import { WidgetIntegrations } from '@site/src/components/widgets/integrations';
import { audioStatsWidget } from '.';
import { navidromeIntegration } from '@site/docs/integrations/navidrome';
import { audiobookshelfIntegration } from '@site/docs/integrations/audiobookshelf';
import { WidgetHeader } from "@site/src/components/widgets/header";
import { AddingWidget } from "@site/src/components/widgets/adding";
import { WidgetConfig } from "@site/src/components/widgets/configuration";
import { WidgetIntegrations } from "@site/src/components/widgets/integrations";
import { audioStatsWidget } from ".";
import { navidromeIntegration } from "@site/docs/integrations/navidrome";
import { audiobookshelfIntegration } from "@site/docs/integrations/audiobookshelf";
<WidgetHeader
widget={audioStatsWidget}
categories={['Media']}
/>
<WidgetHeader widget={audioStatsWidget} categories={["Media"]} />
This widget adapts its display based on the linked integration:
- **Navidrome**: Shows artist, album, and song counts, plus a now playing section with active streams
- **Navidrome**: Shows artist, album, and song counts
- **Audiobookshelf**: Shows library count, audiobook/podcast totals, listening time, and active sessions
Only options relevant to your linked integration are shown in the configuration panel.
### Supported Integrations
<WidgetIntegrations items={[{
integration: navidromeIntegration,
note: "Displays music library stats and now playing"
}, {
integration: audiobookshelfIntegration,
note: "Displays audiobook/podcast library stats and listening time"
}]} />
<WidgetIntegrations
items={[
{
integration: navidromeIntegration,
note: "Displays music library statistics",
},
{
integration: audiobookshelfIntegration,
note: "Displays audiobook/podcast library stats and listening time",
},
]}
/>
### Adding the widget
<AddingWidget />
### Configuration
<WidgetConfig configuration={audioStatsWidget.configuration} />
@@ -27,24 +27,6 @@ export const audioStatsWidget: WidgetDefinition = {
values: { type: "boolean" },
defaultValue: "yes",
},
{
name: "Show now playing",
description: "Displays currently playing tracks (Navidrome only)",
values: { type: "boolean" },
defaultValue: "yes",
},
{
name: "Show now playing list",
description: "Displays a detailed list of now playing tracks (Navidrome only)",
values: { type: "boolean" },
defaultValue: "yes",
},
{
name: "Max now playing items",
description: "Maximum number of now playing tracks to display (Navidrome only)",
values: "Number (1-5)",
defaultValue: "3",
},
{
name: "Show library count",
description: "Displays the number of libraries (Audiobookshelf only)",
@@ -17,10 +17,17 @@ import { beszelIntegration } from '@site/docs/integrations/beszel';
categories={['System Monitoring']}
/>
This widget displays all your Beszel-monitored systems in a sortable data table. Each row shows system metrics like CPU, memory, disk, network, temperature, and more. Columns can be toggled on or off, and the table supports sorting by any column. When Beszel reports extra filesystems, the disk column adds a hover breakdown for each mount alongside the root disk.
This widget displays all your Beszel-monitored systems in a sortable data table. Each row shows system metrics like CPU, memory, disk, network, temperature, and more. Columns can be toggled on or off, dragged into a different order, and resized from their headers. Column order and widths are saved automatically for each widget. When Beszel reports extra filesystems, the disk column adds a hover breakdown for each mount alongside the root disk.
Select a system row to open the complete statistics view, including system, filesystem, Docker, historical, and realtime charts.
### Interactions
- **Reorder columns** by hovering a header to reveal its grab handle, then dragging it
- **Resize columns** by dragging a header's right edge
- **Sort systems** by selecting a sortable column header
- **Open system statistics** by selecting a row
### Supported Integrations
<WidgetIntegrations items={[{
@@ -40,12 +40,19 @@ import dockerTable from './img/table.png';
### Interactions
Each container row has an action menu (accessible via the three-dot icon) with the following options:
Hover a column header to reveal its grab handle, then drag it to reorder the table or drag the header's edge to resize it. The layout is saved automatically for each widget instance and restored after a reload. These controls are disabled while the board is in edit mode.
Each container row has an action menu, accessible via the three-dot icon or by right-clicking the row, with the following options:
- **View logs** - opens a dedicated terminal page streaming the container's logs
- **Start** / **Stop** / **Restart** - manage the container's state
- **Remove** - remove the container
- **Remove** - permanently remove the container after a second confirmation click
- **Add to Homarr** - create a bookmark app from the container
### Adding the widget
<AddingWidget />
### Configuration
<WidgetConfig configuration={dockerContainersWidget.configuration} />
@@ -8,6 +8,12 @@ export const dockerContainersWidget: WidgetDefinition = {
path: "../../widgets/docker-containers",
configuration: {
items: [
{
name: "Columns to show",
description: "Select which columns are visible in the table",
values: "Name, State, Host, CPU usage, Memory usage, and Actions",
defaultValue: "All columns",
},
{
name: "Enable items sorting",
description: "Allows to sort containers by clicking on the column headers",
+1 -1
View File
@@ -44,7 +44,7 @@ import contextMenuImg from './img/context-menu.png';
- **Responsive table** — columns automatically adapt to widget width, hiding less important columns at smaller sizes
- **Sortable columns** — click any column header to sort ascending/descending
- **Column customization** — choose which columns to display via the widget options panel
- **Column customization** — choose which columns to display, then hover a header to reveal its grab handle for reordering or drag its edge to resize it; layouts save automatically per widget
- **Right-click context menu** — pause, resume, delete downloads, or copy download names directly from the context menu
- **Row expansion** — click any row to see full details inline without opening a separate modal
- **Hover tooltips** — hover a download name to see size, speeds, ETA, ratio, and category
@@ -14,6 +14,7 @@ import TabItem from '@theme/TabItem';
import { mediaServerWidget } from '.';
import { embyIntegration } from '@site/docs/integrations/emby';
import { jellyfinIntegration } from '@site/docs/integrations/jellyfin';
import { navidromeIntegration } from '@site/docs/integrations/navidrome';
import { plexIntegration } from '@site/docs/integrations/plex';
@@ -39,6 +40,9 @@ import mediaTable from './img/table.png';
integration: embyIntegration,
}, {
integration: jellyfinIntegration,
}, {
integration: navidromeIntegration,
note: "Will always show only currently playing audio streams, not all active sessions."
}, {
integration: plexIntegration,
note: "Will always show only currently playing streams, not all active sessions."
@@ -50,4 +54,3 @@ import mediaTable from './img/table.png';
### Configuration
<WidgetConfig configuration={mediaServerWidget.configuration} />
+1 -1
View File
@@ -10,7 +10,7 @@ export const mediaServerWidget: WidgetDefinition = {
items: [
{
name: "Show only currently playing",
description: "Disabling this will not work for plex.",
description: "Plex and Navidrome always show only currently playing streams.",
values: { type: "boolean" },
defaultValue: "yes",
},
+5 -1
View File
@@ -1,6 +1,6 @@
{
"name": "homarr",
"version": "1.72.0",
"version": "1.73.0",
"private": true,
"scripts": {
"build": "cross-env CI=true turbo build",
@@ -17,11 +17,15 @@
"db:migration:postgresql:run": "pnpm -F db migration:postgresql:run",
"db:migration:sqlite:generate": "pnpm -F db migration:sqlite:generate",
"db:migration:sqlite:run": "pnpm -F db migration:sqlite:run",
"db:seed": "pnpm -F db seed",
"db:push": "pnpm -F db push:sqlite",
"db:studio": "pnpm -F db studio",
"dev": "pnpm -F nextjs dev",
"dev:cli": "go -C tools/homarr-dev run .",
"dev:cli:install": "go -C tools/homarr-dev install .",
"dev:docs": "pnpm -F @homarr/docs dev",
"docker:dev": "docker compose -f ./development/development.docker-compose.yml up",
"docker:dev:up": "docker compose -f ./development/development.docker-compose.yml up -d redis",
"format": "turbo format --continue -- --check",
"format:fix": "turbo format --continue",
"lint": "turbo lint --continue",
@@ -17,7 +17,7 @@ export const mediaServerRouter = createTRPCRouter({
mcp: {
enabled: true,
description:
"Get currently active streams from Plex/Jellyfin/Emby media servers. REQUIRED: integrationIds (array of media server integration IDs from integration_all), showOnlyPlaying (boolean — true to filter to actively playing streams only)",
"Get currently active streams from Plex/Jellyfin/Emby/Navidrome media servers. REQUIRED: integrationIds (array of media server integration IDs from integration_all), showOnlyPlaying (boolean — true to filter to actively playing streams only)",
},
})
.concat(createMediaServerIntegrationMiddleware("query"))
+1 -1
View File
@@ -418,7 +418,7 @@ export const integrationDefs = {
name: "Navidrome",
secretKinds: [["username", "password"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/navidrome.svg",
category: ["mediaLibrary"],
category: ["mediaLibrary", "mediaService"],
documentationUrl: null,
defaultPort: 4533,
},
@@ -0,0 +1,109 @@
import { act, createElement } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeAll, beforeEach, describe, expect, test, vi } from "vitest";
import { MantineProvider } from "@mantine/core";
import type { Root } from "react-dom/client";
const { findIconsQuery } = vi.hoisted(() => ({
findIconsQuery: vi.fn((_query: { searchText: string }) => ({
data: { icons: [], countIcons: 0 },
isLoading: false,
isError: false,
refetch: vi.fn(),
})),
}));
vi.mock("@homarr/api/client", () => ({
clientApi: { icon: { findIcons: { useQuery: findIconsQuery } } },
}));
vi.mock("@homarr/auth/client", () => ({
useSession: () => ({ data: { user: { permissions: [] } } }),
}));
vi.mock("@homarr/translation", () => ({ supportedLanguages: [] }));
vi.mock("@homarr/translation/client", () => ({
useScopedI18n: () => (key: string) => key,
}));
vi.mock("../upload-media/upload-media", () => ({ UploadMedia: () => null }));
import { IconPicker } from "./icon-picker";
const directIconUrl = "https://cdn.example.com/shopify.svg";
const setInputValue = (input: HTMLInputElement, value: string) => {
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
if (!setter) throw new Error("Input value setter was not found");
setter.call(input, value);
input.dispatchEvent(new Event("input", { bubbles: true }));
};
describe("IconPicker", () => {
let container: HTMLDivElement;
let root: Root;
beforeAll(() => {
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", { value: true, writable: true });
Object.defineProperty(window, "matchMedia", {
writable: true,
value: (query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: () => undefined,
removeListener: () => undefined,
addEventListener: () => undefined,
removeEventListener: () => undefined,
dispatchEvent: () => false,
}),
});
});
beforeEach(() => {
findIconsQuery.mockClear();
});
afterEach(() => {
root.unmount();
container.remove();
});
const renderPicker = async (onChange: (value: string) => void) => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root.render(createElement(MantineProvider, null, createElement(IconPicker, { value: "", onChange })));
});
const input = container.querySelector("input");
if (!input) throw new Error("Icon picker input was not rendered");
return input;
};
test("keeps a direct URL visible without searching the icon repositories", async () => {
const onChange = vi.fn();
const input = await renderPicker(onChange);
await act(async () => {
setInputValue(input, directIconUrl);
await new Promise((resolve) => setTimeout(resolve, 150));
});
expect(onChange).toHaveBeenCalledWith(directIconUrl);
expect(input.value).toBe(directIconUrl);
expect(findIconsQuery.mock.calls.some(([query]) => query.searchText === directIconUrl)).toBe(false);
});
test("keeps icon names in repository search mode", async () => {
const onChange = vi.fn();
const input = await renderPicker(onChange);
await act(async () => {
setInputValue(input, "shopify");
await new Promise((resolve) => setTimeout(resolve, 150));
});
expect(onChange).not.toHaveBeenCalled();
expect(input.value).toBe("shopify");
expect(findIconsQuery.mock.calls.some(([query]) => query.searchText === "shopify")).toBe(true);
});
});
@@ -63,7 +63,7 @@ export const IconPicker = ({
const tCommon = useScopedI18n("common");
const [debouncedQuery] = useDebouncedValue(query, 100);
const searchText = (debouncedQuery !== (value ?? "") && debouncedQuery) || "";
const searchText = !isDirectIconUrl(debouncedQuery) && debouncedQuery !== (value ?? "") ? debouncedQuery : "";
const { data, isLoading, isError, refetch } = clientApi.icon.findIcons.useQuery(
{ searchText },
@@ -165,8 +165,15 @@ export const IconPicker = ({
leftSection={leftSection}
value={inputValue}
onChange={(event) => {
combobox.openDropdown();
setQuery(event.currentTarget.value);
const nextValue = event.currentTarget.value;
const trimmedValue = nextValue.trim();
setQuery(nextValue);
if (isDirectIconUrl(trimmedValue)) {
setValue(trimmedValue);
combobox.closeDropdown();
} else {
combobox.openDropdown();
}
}}
onClick={() => combobox.openDropdown()}
onFocus={(event) => {
@@ -220,3 +227,12 @@ const shouldShowPreview = (value: string | null | undefined): value is string =>
if (!value) return false;
return !localizationPathRegex.test(value);
};
const isDirectIconUrl = (value: string) => {
try {
const url = new URL(value.trim());
return (url.protocol === "http:" || url.protocol === "https:") && Boolean(url.hostname);
} catch {
return false;
}
};
@@ -41,7 +41,11 @@ export interface StreamSession {
audioCodec: string | null;
videoCodec: string | null;
};
isVideoDirect: boolean;
isAudioDirect: boolean;
containerChanged: boolean;
};
bitrateKbps: number | null;
} | null;
} | null;
}
@@ -56,6 +56,9 @@ export class JellyfinIntegration extends Integration implements IMediaServerInte
if (sessionInfo.NowPlayingItem) {
const positionMs = ticksToMs(sessionInfo.PlayState?.PositionTicks);
const durationMs = ticksToMs(sessionInfo.NowPlayingItem.RunTimeTicks);
const bitrateBps =
sessionInfo.TranscodingInfo?.Bitrate ?? sessionInfo.NowPlayingItem.MediaSources?.[0]?.Bitrate ?? null;
const bitrateKbps = bitrateBps !== null ? Math.round(bitrateBps / 1000) : null;
currentlyPlaying = {
type: convertJellyfinType(sessionInfo.NowPlayingItem.Type),
@@ -98,7 +101,11 @@ export class JellyfinIntegration extends Integration implements IMediaServerInte
videoCodec: sessionInfo.TranscodingInfo?.VideoCodec ?? null,
},
container: sessionInfo.TranscodingInfo?.Container ?? null,
isVideoDirect: sessionInfo.TranscodingInfo?.IsVideoDirect ?? true,
isAudioDirect: sessionInfo.TranscodingInfo?.IsAudioDirect ?? true,
containerChanged: sessionInfo.PlayState?.PlayMethod === "DirectStream",
},
bitrateKbps,
},
};
}
@@ -6,6 +6,8 @@ import type { IntegrationTestingInput } from "../base/integration";
import { Integration } from "../base/integration";
import { TestConnectionError } from "../base/test-connection/test-connection-error";
import type { TestingResult } from "../base/test-connection/test-connection-service";
import type { IMediaServerIntegration } from "../interfaces/media-server/media-server-integration";
import type { CurrentSessionsInput, StreamSession } from "../interfaces/media-server/media-server-types";
import type { NavidromeDashboardData, NavidromeNowPlayingEntry, SubsonicResponseBody } from "./navidrome-types";
import { subsonicResponseSchema } from "./navidrome-types";
@@ -22,7 +24,7 @@ const subsonicAuthErrorCodes = new Set([40, 41]);
const asArray = <TValue>(value: TValue | TValue[] | undefined): TValue[] =>
[value].flat().filter((item): item is TValue => item !== undefined);
export class NavidromeIntegration extends Integration {
export class NavidromeIntegration extends Integration implements IMediaServerIntegration {
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const url = this.url("/rest/ping.view", this.getAuthParams());
const response = await input.fetchAsync(url);
@@ -53,20 +55,49 @@ export class NavidromeIntegration extends Integration {
}
public async getDashboardDataAsync(): Promise<NavidromeDashboardData> {
const [artistCount, libraryCounts, nowPlaying] = await Promise.all([
const [artistCount, libraryCounts] = await Promise.all([
this.getArtistCountAsync(),
this.getAlbumAndSongCountsAsync(),
this.getNowPlayingAsync(),
]);
return {
artistCount,
albumCount: libraryCounts.albumCount,
songCount: libraryCounts.songCount,
nowPlaying,
};
}
public async getCurrentSessionsAsync(_options: CurrentSessionsInput): Promise<StreamSession[]> {
const nowPlaying = await this.getNowPlayingAsync();
return nowPlaying.map(
(entry): StreamSession => ({
sessionId: `${entry.username}-${entry.playerName}`,
sessionName: entry.playerName,
user: {
userId: entry.username,
username: entry.username,
profilePictureUrl: null,
},
currentlyPlaying: {
type: "audio",
name: entry.title,
seasonName: entry.artist || undefined,
episodeName: null,
albumName: entry.album,
episodeCount: null,
playback: {
state: null,
positionMs: null,
durationMs: null,
},
location: null,
metadata: null,
},
}),
);
}
private async getArtistCountAsync(): Promise<number> {
const response = await this.subsonicRequestAsync("/rest/getArtists.view", {}, { tolerateEmptyLibrary: true });
return asArray(response.artists?.index).reduce((count, index) => count + asArray(index.artist).length, 0);
@@ -67,5 +67,4 @@ export interface NavidromeDashboardData {
artistCount: number;
albumCount: number;
songCount: number;
nowPlaying: NavidromeNowPlayingEntry[];
}
@@ -1,6 +1,12 @@
// @vitest-environment node
import { Response } from "undici";
import { beforeEach, describe, expect, test, vi } from "vitest";
vi.hoisted(() => {
process.env.SKIP_ENV_VALIDATION = "true";
process.env.SECRET_ENCRYPTION_KEY = "0".repeat(64);
});
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import type { IntegrationSecret } from "../../base/types";
@@ -34,24 +40,24 @@ const subsonicOk = <T extends Record<string, unknown>>(data: T) =>
"subsonic-response": { status: "ok", version: "1.16.1", ...data },
});
const subsonicFailed = (message: string) =>
const subsonicFailed = (message: string, code = 70) =>
JSON.stringify({
"subsonic-response": { status: "failed", error: { code: 70, message } },
"subsonic-response": { status: "failed", error: { code, message } },
});
const makeAlbums = (count: number, songCount: number) =>
Array.from({ length: count }, (_, index) => ({
id: `album-${index}`,
name: `Album ${index}`,
songCount,
}));
beforeEach(() => {
mockFetch.mockReset();
});
describe("NavidromeIntegration.getDashboardDataAsync", () => {
test("paginates album list and counts songs", async () => {
const makeAlbums = (count: number, songCount: number) =>
Array.from({ length: count }, (_, i) => ({
id: `album-${i}`,
name: `Album ${i}`,
songCount,
}));
mockFetch.mockImplementation((url) => {
const urlStr = toUrlString(url);
@@ -84,13 +90,7 @@ describe("NavidromeIntegration.getDashboardDataAsync", () => {
>;
}
if (urlStr.includes("getNowPlaying")) {
return Promise.resolve(new Response(subsonicOk({ nowPlaying: {} }), { status: 200 })) as unknown as ReturnType<
typeof fetchWithTrustedCertificatesAsync
>;
}
return Promise.resolve(new Response(subsonicOk({}), { status: 200 })) as unknown as ReturnType<
return Promise.reject(new Error(`Unexpected dashboard request: ${urlStr}`)) as unknown as ReturnType<
typeof fetchWithTrustedCertificatesAsync
>;
});
@@ -101,7 +101,6 @@ describe("NavidromeIntegration.getDashboardDataAsync", () => {
expect(result.albumCount).toBe(700);
expect(result.songCount).toBe(500 * 10 + 200 * 5);
expect(result.artistCount).toBe(1);
expect(result.nowPlaying).toEqual([]);
});
test("handles empty library gracefully", async () => {
@@ -120,13 +119,7 @@ describe("NavidromeIntegration.getDashboardDataAsync", () => {
) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>;
}
if (urlStr.includes("getNowPlaying")) {
return Promise.resolve(
new Response(subsonicFailed("Library not found or empty"), { status: 200 }),
) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>;
}
return Promise.resolve(new Response(subsonicOk({}), { status: 200 })) as unknown as ReturnType<
return Promise.reject(new Error(`Unexpected dashboard request: ${urlStr}`)) as unknown as ReturnType<
typeof fetchWithTrustedCertificatesAsync
>;
});
@@ -137,7 +130,6 @@ describe("NavidromeIntegration.getDashboardDataAsync", () => {
expect(result.artistCount).toBe(0);
expect(result.albumCount).toBe(0);
expect(result.songCount).toBe(0);
expect(result.nowPlaying).toEqual([]);
});
test("throws on non-empty-library subsonic error", async () => {
@@ -151,8 +143,10 @@ describe("NavidromeIntegration.getDashboardDataAsync", () => {
const integration = createIntegration();
await expect(integration.getDashboardDataAsync()).rejects.toThrow();
});
});
test("now playing maps entries correctly", async () => {
describe("NavidromeIntegration.getCurrentSessionsAsync", () => {
test("maps now playing entries to stream sessions", async () => {
mockFetch.mockImplementation((url) => {
const urlStr = toUrlString(url);
@@ -161,7 +155,22 @@ describe("NavidromeIntegration.getDashboardDataAsync", () => {
new Response(
subsonicOk({
nowPlaying: {
entry: { title: "Song", artist: "Band", album: "LP", username: "user1", playerName: "Chrome" },
entry: [
{
title: "Song",
artist: "Band",
album: "LP",
username: "user1",
playerName: "Chrome",
},
{
title: "Second Song",
artist: "Second Artist",
album: "Second Album",
username: "user2",
playerName: "Mobile",
},
],
},
}),
{ status: 200 },
@@ -169,14 +178,72 @@ describe("NavidromeIntegration.getDashboardDataAsync", () => {
) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>;
}
if (urlStr.includes("getArtists")) {
return Promise.resolve(new Response(subsonicOk({ artists: {} }), { status: 200 })) as unknown as ReturnType<
typeof fetchWithTrustedCertificatesAsync
>;
}
return Promise.resolve(new Response(subsonicOk({}), { status: 200 })) as unknown as ReturnType<
typeof fetchWithTrustedCertificatesAsync
>;
});
if (urlStr.includes("getAlbumList2")) {
return Promise.resolve(new Response(subsonicOk({ albumList2: {} }), { status: 200 })) as unknown as ReturnType<
const integration = createIntegration();
const result = await integration.getCurrentSessionsAsync({ showOnlyPlaying: true });
expect(result).toEqual([
{
sessionId: "user1-Chrome",
sessionName: "Chrome",
user: {
userId: "user1",
username: "user1",
profilePictureUrl: null,
},
currentlyPlaying: {
type: "audio",
name: "Song",
seasonName: "Band",
episodeName: null,
albumName: "LP",
episodeCount: null,
playback: {
state: null,
positionMs: null,
durationMs: null,
},
location: null,
metadata: null,
},
},
{
sessionId: "user2-Mobile",
sessionName: "Mobile",
user: {
userId: "user2",
username: "user2",
profilePictureUrl: null,
},
currentlyPlaying: {
type: "audio",
name: "Second Song",
seasonName: "Second Artist",
episodeName: null,
albumName: "Second Album",
episodeCount: null,
playback: {
state: null,
positionMs: null,
durationMs: null,
},
location: null,
metadata: null,
},
},
]);
});
test("returns an empty array when there are no now playing entries", async () => {
mockFetch.mockImplementation((url) => {
const urlStr = toUrlString(url);
if (urlStr.includes("getNowPlaying")) {
return Promise.resolve(new Response(subsonicOk({ nowPlaying: {} }), { status: 200 })) as unknown as ReturnType<
typeof fetchWithTrustedCertificatesAsync
>;
}
@@ -187,15 +254,31 @@ describe("NavidromeIntegration.getDashboardDataAsync", () => {
});
const integration = createIntegration();
const result = await integration.getDashboardDataAsync();
const result = await integration.getCurrentSessionsAsync({ showOnlyPlaying: false });
expect(result.nowPlaying).toHaveLength(1);
expect(result.nowPlaying[0]).toEqual({
title: "Song",
artist: "Band",
album: "LP",
username: "user1",
playerName: "Chrome",
expect(result).toEqual([]);
});
test("normalizes subsonic auth failures", async () => {
mockFetch.mockImplementation((url) => {
const urlStr = toUrlString(url);
if (urlStr.includes("getNowPlaying")) {
return Promise.resolve(
new Response(subsonicFailed("Wrong username or password", 40), { status: 200 }),
) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>;
}
return Promise.resolve(new Response(subsonicOk({}), { status: 200 })) as unknown as ReturnType<
typeof fetchWithTrustedCertificatesAsync
>;
});
const integration = createIntegration();
await expect(integration.getCurrentSessionsAsync({ showOnlyPlaying: true })).rejects.toMatchObject({
message: "An unknown error occured while executing Integration method",
cause: { message: "Wrong username or password" },
});
});
});
@@ -43,6 +43,12 @@ function parseResolution(
return parsedWidth !== null && parsedHeight !== null ? { width: parsedWidth, height: parsedHeight } : null;
}
function isStreamDirect(decision: string | undefined): boolean {
// Plex reports "transcode" when a stream is re-encoded. "copy" (remuxed
// without re-encoding) and an absent decision both count as direct.
return decision !== "transcode";
}
export class PlexIntegration extends Integration implements IMediaServerIntegration, IMediaReleasesIntegration {
public async getCurrentSessionsAsync(_options: CurrentSessionsInput): Promise<StreamSession[]> {
const token = super.getSecretValue("apiKey");
@@ -87,6 +93,16 @@ export class PlexIntegration extends Integration implements IMediaServerIntegrat
const videoStream = streams.find((stream) => stream.$.streamType === "1");
const audioStream = streams.find((stream) => stream.$.streamType === "2");
const isVideoDirect = isStreamDirect(transcodeElement?.$.videoDecision);
const isAudioDirect = isStreamDirect(transcodeElement?.$.audioDecision);
const containerChanged = Boolean(
transcodeElement?.$.container &&
mediaInfoElement?.$.container &&
transcodeElement.$.container !== mediaInfoElement.$.container,
);
const bitrateKbps =
parseOptionalNumber(sessionElement?.$.bandwidth) ?? parseOptionalNumber(mediaInfoElement?.$.bitrate);
return {
sessionId: sessionElement?.$.id ?? "unknown",
sessionName: `${playerElement.$.product} (${playerElement.$.title})`,
@@ -124,7 +140,11 @@ export class PlexIntegration extends Integration implements IMediaServerIntegrat
audioCodec: transcodeElement?.$.audioCodec ?? null,
videoCodec: transcodeElement?.$.videoCodec ?? null,
},
isVideoDirect,
isAudioDirect,
containerChanged,
},
bitrateKbps,
},
},
};
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+31 -4
View File
@@ -3079,11 +3079,20 @@
"limitPerIntegration": {
"label": "crwdns3442:0crwdne3442:0",
"description": "crwdns3444:0crwdne3444:0"
},
"columnOrder": {
"label": "crwdns6121:0crwdne6121:0"
},
"columnWidths": {
"label": "crwdns6123:0crwdne6123:0"
}
},
"errors": {
"noColumns": "crwdns1194:0crwdne1194:0",
"noCommunications": "crwdns1196:0crwdne1196:0"
"noCommunications": "crwdns1196:0crwdne1196:0",
"noItems": "crwdns6125:0crwdne6125:0",
"actionFailed": "crwdns6127:0crwdne6127:0",
"actionFailedMessage": "crwdns6129:0crwdne6129:0"
},
"items": {
"actions": {
@@ -3106,7 +3115,8 @@
"detailsTitle": "crwdns1214:0crwdne1214:0"
},
"id": {
"columnTitle": "crwdns1216:0crwdne1216:0"
"columnTitle": "crwdns1216:0crwdne1216:0",
"detailsTitle": "crwdns6131:0crwdne6131:0"
},
"integration": {
"columnTitle": "crwdns1218:0crwdne1218:0"
@@ -3140,7 +3150,8 @@
},
"time": {
"columnTitle": "crwdns1246:0crwdne1246:0",
"detailsTitle": "crwdns1248:0crwdne1248:0"
"detailsTitle": "crwdns1248:0crwdne1248:0",
"eta": "crwdns6133:0crwdne6133:0"
},
"type": {
"columnTitle": "crwdns1250:0crwdne1250:0",
@@ -3184,7 +3195,23 @@
}
}
},
"globalRatio": "crwdns1300:0crwdne1300:0"
"globalRatio": "crwdns1300:0crwdne1300:0",
"contextMenu": {
"copyName": "crwdns6135:0crwdne6135:0",
"copyId": "crwdns6137:0crwdne6137:0",
"clearFilters": "crwdns6139:0crwdne6139:0"
},
"footer": {
"filter": "crwdns6141:0crwdne6141:0"
},
"stats": {
"show": "crwdns6143:0crwdne6143:0",
"hide": "crwdns6145:0crwdne6145:0",
"overallProgress": "crwdns6147:0crwdne6147:0",
"totalSize": "crwdns6149:0crwdne6149:0",
"clients": "crwdns6151:0count={count}crwdne6151:0"
},
"disconnected": "crwdns6153:0crwdne6153:0"
},
"mediaReleases": {
"name": "crwdns3532:0crwdne3532:0",
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+31 -4
View File
@@ -3079,11 +3079,20 @@
"limitPerIntegration": {
"label": "",
"description": ""
},
"columnOrder": {
"label": ""
},
"columnWidths": {
"label": ""
}
},
"errors": {
"noColumns": "Spalten in Elementen auswählen",
"noCommunications": "Kann keine Daten von der Integration laden"
"noCommunications": "Kann keine Daten von der Integration laden",
"noItems": "",
"actionFailed": "",
"actionFailedMessage": ""
},
"items": {
"actions": {
@@ -3106,7 +3115,8 @@
"detailsTitle": "Aktueller Index im Client"
},
"id": {
"columnTitle": "ID"
"columnTitle": "ID",
"detailsTitle": ""
},
"integration": {
"columnTitle": ""
@@ -3140,7 +3150,8 @@
},
"time": {
"columnTitle": "Endzeit",
"detailsTitle": "Zeit danach/zur Fertigstellung"
"detailsTitle": "Zeit danach/zur Fertigstellung",
"eta": ""
},
"type": {
"columnTitle": "Typ",
@@ -3184,7 +3195,23 @@
}
}
},
"globalRatio": "Globales Verhältnis"
"globalRatio": "Globales Verhältnis",
"contextMenu": {
"copyName": "",
"copyId": "",
"clearFilters": ""
},
"footer": {
"filter": ""
},
"stats": {
"show": "",
"hide": "",
"overallProgress": "",
"totalSize": "",
"clients": ""
},
"disconnected": ""
},
"mediaReleases": {
"name": "",
+60 -33
View File
@@ -199,8 +199,8 @@
"label": "Symbole für Pings verwenden"
},
"enableRightClickOnWidgets": {
"label": "",
"description": ""
"label": "Aktiviere Rechtsklick auf Widgets",
"description": "Zeigt Homarrs Kontextmenü, wenn mit der rechten Maustaste auf ein Widget geklickt wird. Deaktivieren, um das Standardmenü des Browsers zu verwenden (z.B. \"Link in neuem Tab öffnen\")."
},
"defaultSearchEngine": {
"label": "Standard Suchmaschine"
@@ -1353,7 +1353,7 @@
"description": ""
},
"bazarr": {
"name": "",
"name": "Bazarr",
"description": ""
}
},
@@ -1492,7 +1492,7 @@
"switchToDarkMode": "Zum dunklen Design wechseln",
"switchToLightMode": "Zum hellen Design wechseln",
"management": "Verwaltung",
"docker": "",
"docker": "Docker",
"preferences": "Ihre Einstellungen",
"logout": "Abmelden",
"login": "Anmelden",
@@ -1628,7 +1628,7 @@
"actions": "Aktionen"
},
"status": {
"success": "",
"success": "Ok",
"error": "Fehler",
"loading": "Wird geladen",
"idle": "Leerlauf"
@@ -1907,14 +1907,14 @@
"description": "ISO 8601 Standard benutzen um das Datum zu formatieren (dies wird andere Optionen überschreiben)"
},
"showWeather": {
"label": "",
"description": ""
"label": "Wetter anzeigen",
"description": "Zeigt ein kleines animiertes Wettersymbol und die Temperatur in der Ecke der Karte an"
},
"weatherLocation": {
"label": ""
"label": "Ort für Wetterbericht"
},
"isWeatherFormatFahrenheit": {
"label": ""
"label": "Fahrenheit verwenden"
}
}
},
@@ -1944,7 +1944,7 @@
"noStation": "Keine Station ausgewählt",
"option": {
"baseUrl": {
"label": ""
"label": "Search.ch URL"
},
"station": {
"label": "Station"
@@ -1976,8 +1976,8 @@
"clear": "Formatierung entfernen",
"heading": "Überschrift {level}",
"align": "Text ausrichten: {position}",
"directionLtr": "",
"directionRtl": "",
"directionLtr": "Setze Textausrichtung von links nach rechts",
"directionRtl": "Setze Textausrichtung von rechts nach links",
"blockquote": "Blockzitat",
"horizontalLine": "Horizontale Linie",
"bulletList": "Aufzählung",
@@ -2268,7 +2268,7 @@
"sunset": "Sonnenuntergang",
"maxWindSpeed": "Maximale Windgeschwindigkeit: {maxWindSpeed} {unit}",
"maxWindGusts": "Maximale Windböen: {maxWindGusts} {unit}",
"humidity": ""
"humidity": "Luftfeuchtigkeit: {humidity}%"
},
"kind": {
"clear": "Klar",
@@ -2412,7 +2412,7 @@
"description": "Statistiken Ihrer Container (Dieses Widget kann nur mit Administratorrechten hinzugefügt werden)",
"option": {
"columns": {
"label": ""
"label": "Anzuzeigende Spalten"
},
"enableRowSorting": {
"label": "Sortierung von Elementen aktivieren"
@@ -2468,7 +2468,7 @@
}
},
"vpn": {
"name": "",
"name": "VPN",
"description": "Übersicht deiner VPN-Verbindungen",
"serviceUnavailable": "VPN nicht verfügbar",
"option": {}
@@ -2733,10 +2733,10 @@
"name": "Name",
"id": "Id",
"status": "Status",
"directPlay": "",
"transcoding": "",
"paused": "",
"remaining": "",
"directPlay": "Direkte Wiedergabe",
"transcoding": "Transkodierung",
"paused": "Pausiert",
"remaining": "{minutes} Min. verbleibend",
"metadata": {
"title": "Statistiken für Nerds",
"video": {
@@ -2806,14 +2806,14 @@
"name": "",
"description": "",
"title": "",
"totalHosts": "",
"hostsNeedingUpdates": "",
"securityUpdates": "",
"totalHosts": "Hosts gesamt",
"hostsNeedingUpdates": "Update erforderlich",
"securityUpdates": "Sicherheits-Updates",
"upToDateHosts": "",
"hostsWithSecurityUpdates": "",
"recentUpdates24h": "",
"totalOutdatedPackages": "",
"totalRepos": "",
"hostsWithSecurityUpdates": "Hosts mit Sicherheits-Updates",
"recentUpdates24h": "Updates (24h)",
"totalOutdatedPackages": "Veraltete Pakete",
"totalRepos": "Repositories",
"osDistribution": "",
"compliance": "",
"complianceRatio": "",
@@ -2832,7 +2832,7 @@
"description": "Gesamtzahl der verfügbaren Sicherheits-Updates anzeigen"
},
"showUpToDateHosts": {
"label": "",
"label": "Zeige aktuelle Hosts",
"description": ""
},
"showHostsWithSecurityUpdates": {
@@ -3079,11 +3079,20 @@
"limitPerIntegration": {
"label": "Elemente pro Integration begrenzen",
"description": "Dies begrenzt die Anzahl der Elemente pro Integration, jedoch nicht global"
},
"columnOrder": {
"label": ""
},
"columnWidths": {
"label": ""
}
},
"errors": {
"noColumns": "Spalten in Elementen auswählen",
"noCommunications": "Kann keine Daten von der Integration laden"
"noCommunications": "Kann keine Daten von der Integration laden",
"noItems": "",
"actionFailed": "",
"actionFailedMessage": ""
},
"items": {
"actions": {
@@ -3106,7 +3115,8 @@
"detailsTitle": "Aktueller Index im Client"
},
"id": {
"columnTitle": "ID"
"columnTitle": "ID",
"detailsTitle": ""
},
"integration": {
"columnTitle": "Integration"
@@ -3140,7 +3150,8 @@
},
"time": {
"columnTitle": "Endzeit",
"detailsTitle": "Zeit danach/zur Fertigstellung"
"detailsTitle": "Zeit danach/zur Fertigstellung",
"eta": ""
},
"type": {
"columnTitle": "Typ",
@@ -3184,7 +3195,23 @@
}
}
},
"globalRatio": "Globales Verhältnis"
"globalRatio": "Globales Verhältnis",
"contextMenu": {
"copyName": "",
"copyId": "",
"clearFilters": ""
},
"footer": {
"filter": ""
},
"stats": {
"show": "",
"hide": "",
"overallProgress": "",
"totalSize": "",
"clients": ""
},
"disconnected": ""
},
"mediaReleases": {
"name": "Medien Veröffentlichungen",
@@ -5207,8 +5234,8 @@
"subtitle": "{count} tragen Übersetzungen in viele Sprachen bei"
},
"sponsors": {
"title": "",
"subtitle": ""
"title": "Sponsoren",
"subtitle": "{count} unterstützen das Projekt finanziell"
},
"libraries": {
"title": "Bibliotheken",
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+37 -16
View File
@@ -2428,10 +2428,21 @@
},
"descendingDefaultSort": {
"label": "Invert sorting"
},
"columnOrder": {
"label": "Column order"
},
"columnWidths": {
"label": "Column widths"
}
},
"error": {
"internalServerError": "Failed to fetch containers stats"
"internalServerError": "Failed to fetch containers stats",
"layoutSaveTitle": "Table layout not saved",
"layoutSaveMessage": "The Docker table layout could not be saved. Try moving or resizing the column again."
},
"empty": {
"noContainers": "No containers found"
}
},
"coolify": {
@@ -2725,8 +2736,20 @@
"showOnlyPlaying": {
"label": "Show only currently playing",
"description": "Disabling this will not work for plex"
},
"showBitrate": {
"label": "Show bitrate",
"description": "Show the current playback bitrate next to the location"
},
"showLocation": {
"label": "Show location",
"description": "Show whether the stream is on your LAN or WAN"
}
},
"footer": {
"streams": "{count, plural, one {# active stream} other {# active streams}}",
"totalBitrate": "Total: {bitrate}"
},
"items": {
"currentlyPlaying": "Currently playing",
"user": "User",
@@ -2734,6 +2757,9 @@
"id": "Id",
"status": "Status",
"directPlay": "Direct play",
"directStream": "Direct stream",
"transcodeVideo": "Transcoding video",
"transcodeAudio": "Transcoding audio",
"transcoding": "Transcoding",
"paused": "Paused",
"remaining": "{minutes} min left",
@@ -3768,18 +3794,6 @@
"label": "Show songs",
"description": "Show the total number of songs in your music library (Navidrome)"
},
"showNowPlaying": {
"label": "Show now playing",
"description": "Show tracks currently being played (Navidrome)"
},
"showNowPlayingList": {
"label": "Show now playing list",
"description": "Show individual tracks instead of only the active stream count (Navidrome)"
},
"maxNowPlayingItems": {
"label": "Max now playing items",
"description": "Maximum number of tracks to show in the now playing list (Navidrome)"
},
"showLibraryCount": {
"label": "Show library count",
"description": "Show the number of libraries on your instance (Audiobookshelf)"
@@ -3813,8 +3827,6 @@
"podcasts": "Podcasts",
"listeningTime": "Listening Time",
"activeSessions": "Active Sessions",
"nowPlaying": "Now Playing",
"nowPlayingCount": "{count, plural, one {# stream} other {# streams}}",
"error": {
"internalServerError": "Failed to fetch audio stats data"
}
@@ -3954,11 +3966,19 @@
},
"showAgent": {
"label": "Show agent version"
},
"columnOrder": {
"label": "Column order"
},
"columnWidths": {
"label": "Column widths"
}
},
"noRecords": "No systems found",
"error": {
"internalServerError": "Unable to connect to Beszel. Check your integration settings."
"internalServerError": "Unable to connect to Beszel. Check your integration settings.",
"layoutSaveTitle": "Unable to save table layout",
"layoutSaveMessage": "Your column order or widths could not be saved. Please try again."
},
"column": {
"system": "System",
@@ -5356,6 +5376,7 @@
},
"remove": {
"label": "Remove",
"confirm": "Click again to remove permanently",
"notification": {
"success": {
"title": "Containers removed",
+52 -25
View File
@@ -2560,30 +2560,30 @@
}
},
"umami": {
"name": "",
"description": "",
"visitors": "",
"active": "",
"pageviews": "",
"visits": "",
"bounceRate": "",
"avgDuration": "",
"name": "Estadísticas Umami",
"description": "Mostrar estadísticas de visitantes de su instancia de Umami Analytics",
"visitors": "visitantes",
"active": "activo",
"pageviews": "vistas de página",
"visits": "visitas",
"bounceRate": "rebote",
"avgDuration": "promedio",
"option": {
"websiteId": {
"label": "",
"description": "",
"placeholder": "",
"loading": "",
"noWebsites": "",
"configureIntegrationFirst": ""
"label": "Sitio web",
"description": "Seleccione un sitio web rastreado por su instancia de Umami",
"placeholder": "Seleccione un sitio web",
"loading": "Cargando sitios web...",
"noWebsites": "No se encontraron sitios web",
"configureIntegrationFirst": "Conecte primero una integración de Umami"
},
"timeFrame": {
"label": "",
"description": "",
"label": "Período de tiempo",
"description": "El período de tiempo para mostrar los datos de los visitantes",
"option": {
"today": "",
"24h": "",
"7d": "",
"today": "Hoy",
"24h": "Últimas 24 horas",
"7d": "Últimos 7 días",
"30d": "",
"month": "",
"lastMonth": ""
@@ -3079,11 +3079,20 @@
"limitPerIntegration": {
"label": "Limitar elementos por integración",
"description": "Esto limitará el número de elementos mostrados por integración, no globalmente"
},
"columnOrder": {
"label": ""
},
"columnWidths": {
"label": ""
}
},
"errors": {
"noColumns": "Seleccionar Columnas en Artículos",
"noCommunications": "No se pueden cargar los datos desde la integración"
"noCommunications": "No se pueden cargar los datos desde la integración",
"noItems": "",
"actionFailed": "",
"actionFailedMessage": ""
},
"items": {
"actions": {
@@ -3106,7 +3115,8 @@
"detailsTitle": "Índice actual dentro del cliente"
},
"id": {
"columnTitle": "ID"
"columnTitle": "ID",
"detailsTitle": ""
},
"integration": {
"columnTitle": "Integración"
@@ -3140,7 +3150,8 @@
},
"time": {
"columnTitle": "Hora de finalización",
"detailsTitle": "Tiempo desde/hasta la finalización"
"detailsTitle": "Tiempo desde/hasta la finalización",
"eta": ""
},
"type": {
"columnTitle": "Tipo",
@@ -3184,7 +3195,23 @@
}
}
},
"globalRatio": "Relación global"
"globalRatio": "Relación global",
"contextMenu": {
"copyName": "",
"copyId": "",
"clearFilters": ""
},
"footer": {
"filter": ""
},
"stats": {
"show": "",
"hide": "",
"overallProgress": "",
"totalSize": "",
"clients": ""
},
"disconnected": ""
},
"mediaReleases": {
"name": "Lanzamientos de medios",
@@ -5207,8 +5234,8 @@
"subtitle": "{count} contribuyendo con las traducciones en muchos idiomas"
},
"sponsors": {
"title": "",
"subtitle": ""
"title": "Patrocinadores",
"subtitle": "{count} apoyando económicamente el proyecto"
},
"libraries": {
"title": "Librerías",
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+181 -154
View File
@@ -1505,8 +1505,8 @@
"noResults": "לא נמצאו תוצאות",
"unsavedChanges": "ישנם שינויים שלא נשמרו!",
"servers": "שרתים",
"applications": "",
"services": "",
"applications": "אפליקציות",
"services": "שירותים",
"preview": {
"show": "הצג תצוגה מקדימה",
"hide": "הסתר תצוגה המקדימה"
@@ -1938,16 +1938,16 @@
}
},
"timetable": {
"name": "",
"description": "",
"title": "",
"noStation": "",
"name": "לוח זמנים",
"description": "מציג זמני יציאה לתחנה",
"title": "יציאות {station}",
"noStation": "לא נבחרה תחנה",
"option": {
"baseUrl": {
"label": ""
},
"station": {
"label": ""
"label": "תחנה"
}
}
},
@@ -2024,32 +2024,32 @@
}
},
"anchorNote": {
"name": "",
"description": "",
"name": "פתק Anchor",
"description": "מציג פתק נבחר מ-Anchor",
"option": {
"noteId": {
"label": ""
"label": "פתק"
},
"showTitle": {
"label": ""
"label": "הצג כותרת"
},
"showUpdatedAt": {
"label": ""
"label": "הצג עדכון אחרון"
}
},
"empty": "",
"emptyContent": "",
"notFound": "",
"noNotes": "",
"integrationRequired": "",
"updatedAt": "",
"untitled": "",
"edit": "",
"save": "",
"cancel": "",
"readOnlyViewer": "",
"saveForbidden": "",
"saveFailed": ""
"empty": "בחר פתק להצגה",
"emptyContent": "לפתק זה אין תוכן",
"notFound": "הפתק לא נמצא",
"noNotes": "לא נמצאו פתקים",
"integrationRequired": "בחר תחילה אינטגרציית Anchor",
"updatedAt": "עודכן ב- {date}",
"untitled": "ללא כותרת",
"edit": "עריכה",
"save": "שמור",
"cancel": "בטל",
"readOnlyViewer": "יש לך גישת צפייה לפתק זה. עריכה מושבתת.",
"saveForbidden": "אין לך הרשאה לערוך פתק זה.",
"saveFailed": "שמירת הפתק נכשלה. נסה שוב."
},
"iframe": {
"name": "iFrame",
@@ -2083,7 +2083,7 @@
"label": "אפשר מיקום גיאוגרפי"
},
"allowModals": {
"label": ""
"label": "אפשר חלונות קופצים"
}
},
"error": {
@@ -2319,7 +2319,7 @@
"label": "הצג מידע זיכרון"
},
"gpu": {
"label": ""
"label": "הצג מידע GPU"
},
"showUptime": {
"label": "הצג זמן פעולה"
@@ -2435,36 +2435,36 @@
}
},
"coolify": {
"name": "",
"description": "",
"name": "קוליפיי",
"description": "סקירה של מופע Coolify שלך עם שרתים ואפליקציות",
"option": {
"showServers": {
"label": ""
"label": "הצג שרתים"
},
"showApplications": {
"label": ""
"label": "הצג אפליקציות"
},
"showServices": {
"label": ""
"label": "הצג שירותים"
}
},
"empty": {
"servers": "",
"applications": "",
"services": ""
"servers": "לא נמצאו שרתים",
"applications": "לא נמצאו אפליקציות",
"services": "לא נמצאו שירותים"
},
"footer": {
"updated": ""
"updated": "עודכן ב- {when}"
},
"error": {
"noIntegration": "",
"internalServerError": ""
"noIntegration": "לא הוגדרה אינטגרציית Coolify",
"internalServerError": "אחזור נתוני Coolify נכשל"
},
"server": {
"buildServer": ""
"buildServer": "בנייה"
},
"instances": {
"title": ""
"title": "מופעים"
}
},
"vpn": {
@@ -2505,85 +2505,85 @@
}
},
"tracearr": {
"name": "",
"description": "",
"name": "טרייסאר",
"description": "ניטור שרת מדיה משופר עם נתוני זרם וקידוד מפורטים",
"option": {
"showStreams": {
"label": ""
"label": "הצג זרמים פעילים"
},
"showStats": {
"label": ""
"label": "הצג סיכום סטטיסטיקות"
},
"showRecentActivity": {
"label": ""
"label": "הצג פעילות אחרונה"
},
"showViolations": {
"label": ""
"label": "הצג הפרות"
}
},
"stats": {
"activeStreams": "",
"totalUsers": "",
"transcodes": "",
"bandwidth": ""
"activeStreams": "זרמים פעילים",
"totalUsers": "סך המשתמשים",
"transcodes": "קידודים",
"bandwidth": "רוחב פס"
},
"streams": {
"empty": "",
"user": "",
"media": "",
"state": "",
"quality": "",
"device": "",
"progress": "",
"empty": "אין זרמים פעילים",
"user": "משתמש",
"media": "מדיה",
"state": "מצב",
"quality": "איכות",
"device": "מכשיר",
"progress": "התקדמות",
"videoDecision": {
"directPlay": "",
"transcode": "",
"directStream": ""
"directPlay": "הפעלה ישירה",
"transcode": "קידוד",
"directStream": "זרם ישיר"
}
},
"recentActivity": {
"title": "",
"empty": "",
"watched": "",
"partial": ""
"title": "פעילות אחרונה",
"empty": "אין פעילות אחרונה",
"watched": "נצפה",
"partial": "חלקי"
},
"violations": {
"title": "",
"empty": "",
"rule": "",
"user": ""
"title": "הפרות",
"empty": "אין הפרות אחרונות",
"rule": "כלל",
"user": "משתמש"
},
"noSectionsEnabled": "",
"noSectionsEnabled": "לא הופעלו מקטעים",
"error": {
"noIntegration": "",
"internalServerError": ""
"noIntegration": "לא הוגדרה אינטגרציית Tracearr",
"internalServerError": "אחזור נתוני Tracearr נכשל"
}
},
"umami": {
"name": "",
"description": "",
"visitors": "",
"active": "",
"pageviews": "",
"visits": "",
"bounceRate": "",
"avgDuration": "",
"name": "Umami ניתוחים",
"description": "הצג נתוני מבקרים ממופע Umami Analytics שלך",
"visitors": "מבקרים",
"active": "פעילים",
"pageviews": "צפיות דף",
"visits": "ביקורים",
"bounceRate": "נטישה",
"avgDuration": "ממוצע",
"option": {
"websiteId": {
"label": "",
"description": "",
"placeholder": "",
"loading": "",
"noWebsites": "",
"configureIntegrationFirst": ""
"label": "אתר",
"description": "בחר אתר המנוטר על ידי מופע ה-Umami שלך",
"placeholder": "בחר אתר",
"loading": "טוען אתרים...",
"noWebsites": "לא נמצאו אתרים",
"configureIntegrationFirst": "חבר תחילה אינטגרציית Umami"
},
"timeFrame": {
"label": "",
"description": "",
"label": "מסגרת זמן",
"description": "פרק הזמן להצגת נתוני מבקרים",
"option": {
"today": "",
"24h": "",
"7d": "",
"today": "היום",
"24h": "24 השעות האחרונות",
"7d": "7 הימים האחרונים",
"30d": "",
"month": "",
"lastMonth": ""
@@ -2990,33 +2990,33 @@
}
},
"immich-serverStats": {
"name": "",
"description": "",
"name": "סטטיסטיקות שרת Immich",
"description": "מידע על מופע ה-Immich שלך",
"option": {
"showUsers": {
"label": "",
"description": ""
"label": "הצג משתמשים",
"description": "הצג מספר משתמשים במופע שלך"
},
"showPhotos": {
"label": "",
"description": ""
"label": "הצג תמונות",
"description": "הצג מספר תמונות במופע שלך"
},
"showVideos": {
"label": "",
"description": ""
"label": "הצג סרטונים",
"description": "הצג מספר סרטונים במופע שלך"
},
"showStorage": {
"label": "",
"description": ""
"label": "הצג אחסון",
"description": "הצג את כמות האחסון הכוללת בשימוש"
}
},
"users": "",
"photos": "",
"videos": "",
"storage": ""
"users": "משתמשים",
"photos": "תמונות",
"videos": "סרטונים",
"storage": "אחסון בשימוש"
},
"immich-albumCarousel": {
"name": "",
"name": "אלבום Immich",
"allPhotos": "",
"option": {
"albumId": {
@@ -3024,12 +3024,12 @@
"description": ""
},
"rotationIntervalSeconds": {
"label": "",
"description": ""
"label": "מרווח בשניות",
"description": "מחליף תמונות באלבום כל X שניות. חייב להיות לפחות 1. חייב להיות פחות מ-3600."
},
"showPhotoInfo": {
"label": "",
"description": ""
"label": "הצג מידע תמונה",
"description": "הצג תאריך התמונה והאינדקס הנוכחי בתחתית המצגת"
},
"randomizePhotos": {
"label": "",
@@ -3079,11 +3079,20 @@
"limitPerIntegration": {
"label": "הגבלת פריטים לכל אינטגרציה",
"description": "פעולה זו תגביל את מספר הפריטים המוצגים בכל אינטגרציה, לא באופן גלובלי"
},
"columnOrder": {
"label": ""
},
"columnWidths": {
"label": ""
}
},
"errors": {
"noColumns": "בחר עמודות בפריטים",
"noCommunications": "לא ניתן לטעון נתונים מהאינטגרציה"
"noCommunications": "לא ניתן לטעון נתונים מהאינטגרציה",
"noItems": "",
"actionFailed": "",
"actionFailedMessage": ""
},
"items": {
"actions": {
@@ -3106,7 +3115,8 @@
"detailsTitle": "מדד נוכחי בתוך הלקוח"
},
"id": {
"columnTitle": "מזהה"
"columnTitle": "מזהה",
"detailsTitle": ""
},
"integration": {
"columnTitle": "אינטגרציה"
@@ -3140,7 +3150,8 @@
},
"time": {
"columnTitle": "זמן סיום",
"detailsTitle": "זמן מאז/עד סיום"
"detailsTitle": "זמן מאז/עד סיום",
"eta": ""
},
"type": {
"columnTitle": "סוּג",
@@ -3184,7 +3195,23 @@
}
}
},
"globalRatio": "יחס גלובלי"
"globalRatio": "יחס גלובלי",
"contextMenu": {
"copyName": "",
"copyId": "",
"clearFilters": ""
},
"footer": {
"filter": ""
},
"stats": {
"show": "",
"hide": "",
"overallProgress": "",
"totalSize": "",
"clients": ""
},
"disconnected": ""
},
"mediaReleases": {
"name": "מדיה משוחררת",
@@ -3383,7 +3410,7 @@
"label": "הסתר תיאור"
},
"showPosterImage": {
"label": ""
"label": "הצג תמונת פוסטר"
}
}
},
@@ -3613,42 +3640,42 @@
}
},
"speedtestTracker": {
"name": "",
"description": "",
"name": "מעקב Speedtest",
"description": "מציג תוצאות בדיקת מהירות ממופע מעקב Speedtest שלך.",
"option": {
"showLatestResult": {
"label": ""
"label": "הצג תוצאה אחרונה"
},
"showStats": {
"label": ""
"label": "הצג ממוצעים"
},
"showRecentResults": {
"label": ""
"label": "הצג תוצאות אחרונות"
},
"showPingGraph": {
"label": ""
"label": "הצג גרף פינג"
}
},
"latestResult": "",
"averages": "",
"tests": "",
"recentResults": "",
"recentResultsSpeed": "",
"recentResultsPing": "",
"download": "",
"upload": "",
"ping": "",
"status": "",
"avgDownload": "",
"avgUpload": "",
"avgPing": "",
"healthy": "",
"unhealthy": "",
"ok": "",
"fail": "",
"noSectionsEnabled": "",
"latestResult": "תוצאה אחרונה",
"averages": "ממוצעים",
"tests": "{count} בדיקות",
"recentResults": "תוצאות אחרונות",
"recentResultsSpeed": "מהירות",
"recentResultsPing": "פינג",
"download": "הורדה",
"upload": "העלאה",
"ping": "פינג",
"status": "מצב",
"avgDownload": "הורדה ממוצעת",
"avgUpload": "העלאה ממוצעת",
"avgPing": "פינג ממוצע",
"healthy": "בריא",
"unhealthy": "לא בריא",
"ok": "בסדר",
"fail": "כשל",
"noSectionsEnabled": "הפעל לפחות מקטע אחד באפשרויות הווידג'ט.",
"error": {
"internalServerError": ""
"internalServerError": "אחזור נתוני speedtest נכשל"
}
},
"uptimeKuma": {
@@ -3805,7 +3832,7 @@
"option": {
"cpu": "מעבד",
"memory": "זיכרון",
"gpu": "",
"gpu": "מעבד גרפי",
"network": "רשת"
}
},
@@ -3822,35 +3849,35 @@
"card": {
"cpu": "מעבד",
"memory": "זיכרון",
"gpu": "",
"gpu": "מעבד גרפי",
"network": "רשת",
"up": "העלאה",
"down": "הורדה"
}
},
"systemDisks": {
"name": "",
"description": "",
"name": "דיסקי מערכת",
"description": "שימוש בדיסקים במערכת",
"option": {
"showTemperatureIfAvailable": {
"label": ""
"label": "הצג טמפרטורה אם זמינה"
},
"displayMode": {
"label": "",
"label": "מצב תצוגה",
"option": {
"percentage": {
"label": ""
"label": "אחוז (לדוגמה, 76%)"
},
"absolute": {
"label": ""
"label": "ערכים מוחלטים (לדוגמה, 800GB / 1TB)"
},
"free": {
"label": ""
"label": "אחוז שטח פנוי (לדוגמה, 24% פנוי)"
}
}
},
"showBackgroundBar": {
"label": ""
"label": "הצג סרגל שימוש ברקע"
},
"visibleStorageVolumes": {
"label": "",
@@ -3859,7 +3886,7 @@
}
},
"status": {
"unhealthy": ""
"unhealthy": "לא בריא"
}
},
"customApi": {
@@ -4828,8 +4855,8 @@
"user": {
"title": "משתמשים",
"enableGravatar": {
"label": "",
"description": ""
"label": "הפעל Gravatar",
"description": "מציג אווטר מ-Libravatar/Gravatar כברירת מחדל כשאין אווטר מותאם אישית וקיים דוא\"ל"
}
},
"search": {
@@ -5207,8 +5234,8 @@
"subtitle": "{count} תורמים בתרגום לשפות שונות"
},
"sponsors": {
"title": "",
"subtitle": ""
"title": "ספונסרים",
"subtitle": "{count} תומכים בפרויקט כלכלית"
},
"libraries": {
"title": "ספריות",
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+35 -8
View File
@@ -1907,14 +1907,14 @@
"description": "Usa ISO 8601 per formattare la data (questo sovrascriverà altre opzioni)"
},
"showWeather": {
"label": "",
"description": ""
"label": "Mostra meteo",
"description": "Mostra una piccola icona animata del meteo e la temperatura nell'angolo della scheda"
},
"weatherLocation": {
"label": ""
"label": "Località meteo"
},
"isWeatherFormatFahrenheit": {
"label": ""
"label": "Usa Fahrenheit"
}
}
},
@@ -3079,11 +3079,20 @@
"limitPerIntegration": {
"label": "Limite elementi per integrazione",
"description": "Ciò limiterà il numero di elementi mostrati per integrazione, non globalmente"
},
"columnOrder": {
"label": "Ordine delle colonne"
},
"columnWidths": {
"label": "Larghezza delle colonne"
}
},
"errors": {
"noColumns": "Seleziona colonne in elementi",
"noCommunications": "Impossibile caricare i dati dall'integrazione"
"noCommunications": "Impossibile caricare i dati dall'integrazione",
"noItems": "Nessun download trovato",
"actionFailed": "Azione non riuscita",
"actionFailedMessage": "L'azione non può essere completata. Per favore riprova."
},
"items": {
"actions": {
@@ -3106,7 +3115,8 @@
"detailsTitle": "Indice corrente all'interno del client"
},
"id": {
"columnTitle": "Id"
"columnTitle": "Id",
"detailsTitle": "ID elemento"
},
"integration": {
"columnTitle": "Integrazione"
@@ -3140,7 +3150,8 @@
},
"time": {
"columnTitle": "Ora di fine",
"detailsTitle": "Tempo dal/al completamento"
"detailsTitle": "Tempo dal/al completamento",
"eta": "ETA"
},
"type": {
"columnTitle": "Tipo",
@@ -3184,7 +3195,23 @@
}
}
},
"globalRatio": "Rapporto globale"
"globalRatio": "Rapporto globale",
"contextMenu": {
"copyName": "Copia nome",
"copyId": "Copia ID",
"clearFilters": "Resetta filtri"
},
"footer": {
"filter": "Filtro download"
},
"stats": {
"show": "Mostra statistiche globali",
"hide": "Nascondi statistiche globali",
"overallProgress": "Avanzamento complessivo della coda",
"totalSize": "Dimensione totale della coda",
"clients": "{count, plural, one {# cliente} other {# clienti}}"
},
"disconnected": "(disconnesso)"
},
"mediaReleases": {
"name": "Comunicati stampa",
+52 -25
View File
@@ -2560,30 +2560,30 @@
}
},
"umami": {
"name": "",
"description": "",
"visitors": "",
"active": "",
"pageviews": "",
"visits": "",
"bounceRate": "",
"avgDuration": "",
"name": "Umami 統計情報",
"description": "あなたの Umami 統計情報インスタンスから訪問者統計を表示します",
"visitors": "訪問者数",
"active": "活動中",
"pageviews": "ページビュー",
"visits": "訪問数",
"bounceRate": "直帰率",
"avgDuration": "平均",
"option": {
"websiteId": {
"label": "",
"description": "",
"placeholder": "",
"loading": "",
"noWebsites": "",
"configureIntegrationFirst": ""
"label": "ウェブサイト",
"description": "あなたの Umami インスタンスで追跡されているウェブサイトを選択してください",
"placeholder": "ウェブサイトを選択",
"loading": "ウェブサイトを読み込み中…",
"noWebsites": "ウェブサイトが見つかりません",
"configureIntegrationFirst": "最初に Umami 統合を接続してください"
},
"timeFrame": {
"label": "",
"description": "",
"label": "期間",
"description": "訪問者データを表示する期間",
"option": {
"today": "",
"24h": "",
"7d": "",
"today": "今日",
"24h": "過去24時間",
"7d": "過去7日間",
"30d": "",
"month": "",
"lastMonth": ""
@@ -3079,11 +3079,20 @@
"limitPerIntegration": {
"label": "連携機能ごとに項目を制限",
"description": "これは、連携機能ごとに表示されるアイテム数を制限します。グローバルではありません"
},
"columnOrder": {
"label": ""
},
"columnWidths": {
"label": ""
}
},
"errors": {
"noColumns": "項目内の列を選択",
"noCommunications": "連携機能からデータを読み込めません"
"noCommunications": "連携機能からデータを読み込めません",
"noItems": "",
"actionFailed": "",
"actionFailedMessage": ""
},
"items": {
"actions": {
@@ -3106,7 +3115,8 @@
"detailsTitle": "クライアント内の現在のインデックス"
},
"id": {
"columnTitle": "Id"
"columnTitle": "Id",
"detailsTitle": ""
},
"integration": {
"columnTitle": "連携機能"
@@ -3140,7 +3150,8 @@
},
"time": {
"columnTitle": "終了時刻",
"detailsTitle": "完了までの時間"
"detailsTitle": "完了までの時間",
"eta": ""
},
"type": {
"columnTitle": "種類",
@@ -3184,7 +3195,23 @@
}
}
},
"globalRatio": "グローバル比"
"globalRatio": "グローバル比",
"contextMenu": {
"copyName": "",
"copyId": "",
"clearFilters": ""
},
"footer": {
"filter": ""
},
"stats": {
"show": "",
"hide": "",
"overallProgress": "",
"totalSize": "",
"clients": ""
},
"disconnected": ""
},
"mediaReleases": {
"name": "メディアリリース",
@@ -5207,8 +5234,8 @@
"subtitle": "{count} の多くの言語で翻訳を行っている"
},
"sponsors": {
"title": "",
"subtitle": ""
"title": "スポンサー",
"subtitle": "{count}人がプロジェクトを財政的に支援しています"
},
"libraries": {
"title": "ライブラリ",
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+31 -4
View File
@@ -3079,11 +3079,20 @@
"limitPerIntegration": {
"label": "Begrens elementer per integrasjon",
"description": "Dette begrenser antall elementer vist per integrasjon, ikke globalt"
},
"columnOrder": {
"label": ""
},
"columnWidths": {
"label": ""
}
},
"errors": {
"noColumns": "Velg kolonner i elementer",
"noCommunications": "Kan ikke laste inn data fra integrasjon"
"noCommunications": "Kan ikke laste inn data fra integrasjon",
"noItems": "",
"actionFailed": "",
"actionFailedMessage": ""
},
"items": {
"actions": {
@@ -3106,7 +3115,8 @@
"detailsTitle": "Gjeldende indeks i klienten"
},
"id": {
"columnTitle": "Id"
"columnTitle": "Id",
"detailsTitle": ""
},
"integration": {
"columnTitle": "Integrasjon"
@@ -3140,7 +3150,8 @@
},
"time": {
"columnTitle": "Fullfør tid",
"detailsTitle": "Tid siden/til fullførelse"
"detailsTitle": "Tid siden/til fullførelse",
"eta": ""
},
"type": {
"columnTitle": "Type",
@@ -3184,7 +3195,23 @@
}
}
},
"globalRatio": "Global ratio"
"globalRatio": "Global ratio",
"contextMenu": {
"copyName": "",
"copyId": "",
"clearFilters": ""
},
"footer": {
"filter": ""
},
"stats": {
"show": "",
"hide": "",
"overallProgress": "",
"totalSize": "",
"clients": ""
},
"disconnected": ""
},
"mediaReleases": {
"name": "Medieutgivelser",
+203 -176
View File
@@ -199,8 +199,8 @@
"label": "Użyj ikon dla pingów"
},
"enableRightClickOnWidgets": {
"label": "",
"description": ""
"label": "Włącz prawe kliknięcie na widżetach",
"description": "Pokaż menu kontekstowe Homarr po kliknięciu prawym przyciskiem na widżet. Wyłącz, aby używać domyślnego menu przeglądarki (np. \"Otwórz link w nowej karcie\")."
},
"defaultSearchEngine": {
"label": "Domyślna wyszukiwarka"
@@ -1290,12 +1290,12 @@
"newLabel": "Nowy klucz API (Secret)"
},
"patchmonApiKey": {
"label": "",
"newLabel": ""
"label": "Klucz API",
"newLabel": "Nowy klucz API"
},
"patchmonApiSecret": {
"label": "",
"newLabel": ""
"label": "Sekret API",
"newLabel": "Nowy sekret API"
},
"githubAppId": {
"label": "Identyfikator aplikacji",
@@ -1341,20 +1341,20 @@
}
},
"traefik": {
"name": "",
"description": ""
"name": "Traefik",
"description": "Chmurowy reverse proxy i load balancer"
},
"paperlessNgx": {
"name": "Paperless-ngx",
"description": "System zarządzania dokumentami do archiwizacji i organizacji zeskanowaych dokumnteów"
},
"patchmon": {
"name": "",
"description": ""
"name": "PatchMon",
"description": "Zarządzanie łatkami i monitorowanie hostów Linux"
},
"bazarr": {
"name": "",
"description": ""
"name": "Bazarr",
"description": "Aplikacja towarzysząca Sonarr i Radarr do zarządzania i pobierania napisów"
}
},
"media": {
@@ -1628,10 +1628,10 @@
"actions": "Akcje"
},
"status": {
"success": "",
"error": "",
"loading": "",
"idle": ""
"success": "OK",
"error": "Błąd",
"loading": "Ładowanie",
"idle": "Bezczynny"
}
},
"create": {
@@ -1907,14 +1907,14 @@
"description": "Użyj ISO 8601 do formatu daty (spowoduje to nadpisanie innych opcji)"
},
"showWeather": {
"label": "",
"description": ""
"label": "Pokaż pogodę",
"description": "Wyświetl małą animowaną ikonę pogody i temperaturę w rogu karty"
},
"weatherLocation": {
"label": ""
"label": "Lokalizacja pogody"
},
"isWeatherFormatFahrenheit": {
"label": ""
"label": "Użyj Fahrenheita"
}
}
},
@@ -1976,8 +1976,8 @@
"clear": "Wyczyść formatowanie",
"heading": "Nagłówek {level}",
"align": "Wyrównanie tekstu: {position}",
"directionLtr": "",
"directionRtl": "",
"directionLtr": "Ustaw kierunek tekstu od lewej do prawej",
"directionRtl": "Ustaw kierunek tekstu od prawej do lewej",
"blockquote": "Cytat",
"horizontalLine": "Linia pozioma",
"bulletList": "Lista punktowana",
@@ -2268,7 +2268,7 @@
"sunset": "Zachód słońca",
"maxWindSpeed": "Maksymalna prędkość wiatru: {maxWindSpeed} km/h",
"maxWindGusts": "Maksymalne porywy wiatru: {maxWindGusts} km/h",
"humidity": ""
"humidity": "Wilgotność: {humidity}%"
},
"kind": {
"clear": "Bezchmurnie",
@@ -2328,9 +2328,9 @@
"label": "Pokaż informacje o systemie plików"
},
"visibleStorageVolumes": {
"label": "",
"description": "",
"placeholder": ""
"label": "Widoczne woluminy pamięci",
"description": "Dostępne tylko gdy wszystkie wybrane integracje to Synology. Pozostaw puste, aby wyświetlić wszystkie wykryte woluminy pamięci.",
"placeholder": "Wybierz woluminy pamięci"
},
"defaultTab": {
"label": "Domyślna karta"
@@ -2732,11 +2732,11 @@
"user": "Użytkownik",
"name": "Nazwa",
"id": "ID",
"status": "",
"directPlay": "",
"transcoding": "",
"paused": "",
"remaining": "",
"status": "Status",
"directPlay": "Odtwarzanie bezpośrednie",
"transcoding": "Transkodowanie",
"paused": "Zatrzymane",
"remaining": "{minutes} min pozostało",
"metadata": {
"title": "Statystyki dla nerdów",
"video": {
@@ -2803,190 +2803,190 @@
}
},
"patchmon": {
"name": "",
"description": "",
"title": "",
"totalHosts": "",
"hostsNeedingUpdates": "",
"securityUpdates": "",
"upToDateHosts": "",
"hostsWithSecurityUpdates": "",
"recentUpdates24h": "",
"totalOutdatedPackages": "",
"totalRepos": "",
"osDistribution": "",
"compliance": "",
"complianceRatio": "",
"lastUpdated": "",
"name": "PatchMon",
"description": "Przegląd stanu poprawek hostów z PatchMon",
"title": "Statystyki PatchMon",
"totalHosts": "Łączna liczba hostów",
"hostsNeedingUpdates": "Wymaga aktualizacji",
"securityUpdates": "Aktualizacje zabezpieczeń",
"upToDateHosts": "Aktualne hosty",
"hostsWithSecurityUpdates": "Hosty z aktualizacjami zabezpieczeń",
"recentUpdates24h": "Aktualizacje (24h)",
"totalOutdatedPackages": "Nieaktualne pakiety",
"totalRepos": "Repozytoria",
"osDistribution": "Dystrybucja systemu",
"compliance": "Zgodność poprawek",
"complianceRatio": "{upToDate} / {total}",
"lastUpdated": "Zaktualizowano {time}",
"option": {
"showTotalHosts": {
"label": "",
"description": ""
"label": "Pokaż łączną liczbę hostów",
"description": "Wyświetla łączną liczbę monitorowanych hostów"
},
"showHostsNeedingUpdates": {
"label": "",
"description": ""
"label": "Pokaż hosty wymagające aktualizacji",
"description": "Wyświetla hosty z co najmniej jednym nieaktualnym pakietem"
},
"showSecurityUpdates": {
"label": "",
"description": ""
"label": "Pokaż aktualizacje zabezpieczeń",
"description": "Wyświetla całkowitą liczbę dostępnych aktualizacji zabezpieczeń"
},
"showUpToDateHosts": {
"label": "",
"description": ""
"label": "Pokaż aktualne hosty",
"description": "Wyświetla hosty bez nieaktualnych pakietów"
},
"showHostsWithSecurityUpdates": {
"label": "",
"description": ""
"label": "Pokaż hosty z aktualizacjami zabezpieczeń",
"description": "Wyświetla hosty wymagające poprawek bezpieczeństwa"
},
"showRecentUpdates24h": {
"label": "",
"description": ""
"label": "Pokaż ostatnie aktualizacje (24h)",
"description": "Wyświetla liczbę aktualizacji zastosowanych w ciągu ostatnich 24 godzin"
},
"showTotalOutdatedPackages": {
"label": "",
"description": ""
"label": "Pokaż nieaktualne pakiety",
"description": "Wyświetla całkowitą liczbę nieaktualnych pakietów na wszystkich hostach"
},
"showTotalRepos": {
"label": "",
"description": ""
"label": "Pokaż repozytoria",
"description": "Wyświetla liczbę monitorowanych aktywnych repozytoriów"
},
"enableThresholdColors": {
"label": "",
"description": ""
"label": "Włącz kolory progów",
"description": "Koloruj wartości statystyk na zielono, żółto lub czerwono w zależności od ustawionych progów ważności"
},
"useCustomThresholds": {
"label": "",
"description": ""
"label": "Zaawansowane progi",
"description": "Dostosuj progi ostrzeżeń i krytyczne dla każdej statystyki, używając liczby bezwzględnej lub procentu wszystkich hostów"
},
"thresholdMode": {
"option": {
"absolute": "",
"percent": ""
"absolute": "Liczba bezwzględna",
"percent": "Procent wszystkich hostów"
}
},
"hostsNeedingUpdatesThresholdMode": {
"label": "",
"description": ""
"label": "Tryb progu dla wymagających aktualizacji",
"description": "Porównaj liczbę hostów wymagających aktualizacji jako wartość bezwzględną lub procent wszystkich hostów"
},
"hostsNeedingUpdatesWarningAt": {
"label": "",
"description": ""
"label": "Próg ostrzeżenia dla wymagających aktualizacji",
"description": "Zmień na żółty, gdy wartość osiągnie ten próg"
},
"hostsNeedingUpdatesCriticalAt": {
"label": "",
"description": ""
"label": "Próg krytyczny dla wymagających aktualizacji",
"description": "Zmień na czerwony, gdy wartość osiągnie ten próg"
},
"securityUpdatesThresholdMode": {
"label": "",
"description": ""
"label": "Tryb progu dla aktualizacji zabezpieczeń",
"description": "Porównaj liczbę aktualizacji zabezpieczeń jako wartość bezwzględną lub procent wszystkich hostów"
},
"securityUpdatesWarningAt": {
"label": "",
"description": ""
"label": "Próg ostrzeżenia dla aktualizacji zabezpieczeń",
"description": "Ustaw żółty kolor, gdy wartość osiągnie ten próg"
},
"securityUpdatesCriticalAt": {
"label": "",
"description": ""
"label": "Próg krytyczny aktualizacji zabezpieczeń",
"description": "Ustaw czerwony kolor, gdy wartość osiągnie ten próg"
},
"hostsWithSecurityUpdatesThresholdMode": {
"label": "",
"description": ""
"label": "Tryb progu liczby hostów z aktualizacjami zabezpieczeń",
"description": "Porównaj liczbę hostów jako wartość bezwzględną lub procent całkowitej liczby hostów"
},
"hostsWithSecurityUpdatesWarningAt": {
"label": "",
"description": ""
"label": "Próg ostrzegawczy dla hostów z aktualizacjami zabezpieczeń",
"description": "Ustaw żółty kolor, gdy wartość osiągnie ten próg"
},
"hostsWithSecurityUpdatesCriticalAt": {
"label": "",
"description": ""
"label": "Próg krytyczny dla hostów z aktualizacjami zabezpieczeń",
"description": "Ustaw czerwony kolor, gdy wartość osiągnie ten próg"
},
"upToDateHostsThresholdMode": {
"label": "",
"description": ""
"label": "Tryb progu dla aktualnych hostów",
"description": "Porównaj liczbę aktualnych hostów jako wartość bezwzględną lub procent całkowitej liczby hostów"
},
"upToDateHostsWarningAt": {
"label": "",
"description": ""
"label": "Próg ostrzegawczy dla aktualnych hostów",
"description": "Ustaw żółty kolor, gdy wartość jest równa lub wyższa od tego progu, ale poniżej pełnego pokrycia"
},
"upToDateHostsCriticalAt": {
"label": "",
"description": ""
"label": "Próg krytyczny dla aktualnych hostów",
"description": "Ustaw czerwony kolor, gdy wartość spadnie poniżej tego progu"
},
"totalOutdatedPackagesThresholdMode": {
"label": "",
"description": ""
"label": "Tryb progu dla nieaktualnych pakietów",
"description": "Porównaj liczbę nieaktualnych pakietów jako wartość bezwzględną lub procent całkowitej liczby hostów"
},
"totalOutdatedPackagesWarningAt": {
"label": "",
"description": ""
"label": "Próg ostrzegawczy dla nieaktualnych pakietów",
"description": "Ustaw żółty kolor, gdy wartość osiągnie ten próg"
},
"totalOutdatedPackagesCriticalAt": {
"label": "",
"description": ""
"label": "Próg krytyczny dla nieaktualnych pakietów",
"description": "Ustaw czerwony kolor, gdy wartość osiągnie ten próg"
},
"showOsDistribution": {
"label": "",
"description": ""
"label": "Pokaż dystrybucję systemu operacyjnego",
"description": "Wyświetl podział monitorowanych hostów według systemu operacyjnego"
},
"osDistributionLimit": {
"label": "",
"description": "",
"label": "Limit dystrybucji systemu operacyjnego",
"description": "Maksymalna liczba systemów operacyjnych do wyświetlenia",
"option": {
"3": "",
"5": "",
"10": "",
"all": ""
"3": "Top 3",
"5": "Top 5",
"10": "Top 10",
"all": "Wszystkie"
}
},
"showOsVersion": {
"label": "",
"description": ""
"label": "Pokaż wersję systemu operacyjnego",
"description": "Dodaj wersję systemu operacyjnego do każdej etykiety dystrybucji, jeśli jest dostępna"
},
"showComplianceHero": {
"label": "",
"description": ""
"label": "Pokaż wskaźnik zgodności",
"description": "Wyświetl pierścień zgodności pokazujący procent aktualnych hostów"
},
"osDisplayMode": {
"label": "",
"description": "",
"label": "Tryb wyświetlania systemu operacyjnego",
"description": "Wybierz sposób wyświetlania dystrybucji systemu operacyjnego",
"option": {
"bars": "",
"donut": ""
"bars": "Paski poziome",
"donut": "Wykres pierścieniowy"
}
}
},
"error": {
"internalServerError": ""
"internalServerError": "Nie udało się pobrać danych PatchMon"
}
},
"bazarr": {
"name": "",
"description": "",
"episodes": "",
"movies": "",
"providers": "",
"status": "",
"name": "Bazarr",
"description": "Przegląd brakujących napisów i stanu zdrowia z instancji Bazarr",
"episodes": "Brakujące odcinki",
"movies": "Brakujące filmy",
"providers": "Problemy dostawców",
"status": "Problemy ze stanem",
"option": {
"showMissingEpisodes": {
"label": "",
"description": ""
"label": "Pokaż brakujące odcinki",
"description": "Wyświetla liczbę odcinków z brakującymi napisami"
},
"showMissingMovies": {
"label": "",
"description": ""
"label": "Pokaż brakujące filmy",
"description": "Wyświetla liczbę filmów z brakującymi napisami"
},
"showProviderIssues": {
"label": "",
"description": ""
"label": "Pokaż problemy dostawców",
"description": "Wyświetla liczbę dostawców napisów z problemami"
},
"showHealthIssues": {
"label": "",
"description": ""
"label": "Pokaż problemy ze stanem",
"description": "Wyświetla liczbę problemów ze stanem Bazarr"
}
},
"error": {
"internalServerError": ""
"internalServerError": "Nie udało się pobrać danych z Bazarr"
}
},
"immich-serverStats": {
@@ -3017,7 +3017,7 @@
},
"immich-albumCarousel": {
"name": "Album Immich",
"allPhotos": "",
"allPhotos": "Wszystkie zdjęcia",
"option": {
"albumId": {
"label": "ID albumu",
@@ -3079,11 +3079,20 @@
"limitPerIntegration": {
"label": "Limit elementów na integrację",
"description": "To ograniczy liczbę wyświetlanych elementów dla każdej integracji, a nie globalnie."
},
"columnOrder": {
"label": ""
},
"columnWidths": {
"label": ""
}
},
"errors": {
"noColumns": "Wybierz kolumny w elementach",
"noCommunications": "Nie można załadować danych z integracji"
"noCommunications": "Nie można załadować danych z integracji",
"noItems": "",
"actionFailed": "",
"actionFailedMessage": ""
},
"items": {
"actions": {
@@ -3106,7 +3115,8 @@
"detailsTitle": "Bieżący indeks w ramach klienta"
},
"id": {
"columnTitle": "ID"
"columnTitle": "ID",
"detailsTitle": ""
},
"integration": {
"columnTitle": "Integracja"
@@ -3140,7 +3150,8 @@
},
"time": {
"columnTitle": "Czas zakończenia",
"detailsTitle": "Czas od/do zakończenia"
"detailsTitle": "Czas od/do zakończenia",
"eta": ""
},
"type": {
"columnTitle": "Typ",
@@ -3184,7 +3195,23 @@
}
}
},
"globalRatio": "Wskaźnik globalny"
"globalRatio": "Wskaźnik globalny",
"contextMenu": {
"copyName": "",
"copyId": "",
"clearFilters": ""
},
"footer": {
"filter": ""
},
"stats": {
"show": "",
"hide": "",
"overallProgress": "",
"totalSize": "",
"clients": ""
},
"disconnected": ""
},
"mediaReleases": {
"name": "Wydania mediów",
@@ -3223,10 +3250,10 @@
"label": "Otwieraj linki w nowej karcie"
},
"statusFilter": {
"label": ""
"label": "Statusy do wyświetlenia"
},
"recentDays": {
"label": ""
"label": "Pokaż tylko ostatnie żądania"
}
},
"pending": {
@@ -3335,33 +3362,33 @@
}
},
"mediaMissing": {
"name": "",
"description": "",
"name": "Brakujące i oczekujące media",
"description": "Pokazuje brakujące i oczekujące filmy i odcinki z Radarr i Sonarr",
"option": {
"showMissing": {
"label": ""
"label": "Pokaż brakujące elementy"
},
"showQueued": {
"label": ""
"label": "Pokaż oczekujące elementy"
},
"pageSize": {
"label": ""
"label": "Elementów na stronę"
}
},
"tab": {
"missing": "",
"queued": ""
"missing": "Brakujące",
"queued": "Oczekujące"
},
"type": {
"movie": "",
"episode": ""
"movie": "Film",
"episode": "Odcinek"
},
"empty": {
"missing": "",
"queued": "",
"noTabsEnabled": ""
"missing": "Brak brakujących elementów",
"queued": "Brak oczekujących elementów",
"noTabsEnabled": "Włącz co najmniej jedną kartę w ustawieniach widżetu"
},
"timeLeft": ""
"timeLeft": "Pozostały czas"
},
"rssFeed": {
"name": "Kanały RSS",
@@ -3608,7 +3635,7 @@
"noItems": "Brak powiadomień do wyświetlenia.",
"option": {
"hideLogos": {
"label": ""
"label": "Ukryj loga"
}
}
},
@@ -3692,37 +3719,37 @@
}
},
"traefik": {
"name": "",
"description": "",
"name": "Traefik",
"description": "Przegląd routerów, usług, middleware i punktów wejścia Traefik",
"option": {
"showTcp": {
"label": ""
"label": "Pokaż TCP"
},
"showUdp": {
"label": ""
"label": "Pokaż UDP"
},
"showEntryPoints": {
"label": ""
"label": "Pokaż punkty wejścia"
}
},
"instances": "",
"versionUnknown": "",
"instances": "Instancje Traefik",
"versionUnknown": "Wersja nieznana",
"summary": {
"routers": "",
"entryPoints": ""
"routers": "Routery",
"entryPoints": "Punkty wejścia"
},
"resource": {
"routers": "",
"services": "",
"middlewares": ""
"routers": "Routery",
"services": "Usługi",
"middlewares": "Middleware"
},
"status": {
"healthy": "",
"errors": "",
"warnings": ""
"healthy": "Dobry",
"errors": "{count, plural, one {<b>#</b> błąd} few {<b>#</b> błędy} many {<b>#</b> błędów} other {<b>#</b> błędów}}",
"warnings": "{count, plural, one {<b>#</b> ostrzeżenie} few {<b>#</b> ostrzeżenia} many {<b>#</b> ostrzeżeń} other {<b>#</b> ostrzeżeń}}"
},
"error": {
"internalServerError": ""
"internalServerError": "Nie udało się pobrać danych Traefik"
}
},
"audioStats": {
@@ -3853,9 +3880,9 @@
"label": "Pokaż pasek wykorzystania w tle"
},
"visibleStorageVolumes": {
"label": "",
"description": "",
"placeholder": ""
"label": "Widoczne woluminy pamięci",
"description": "Dostępne tylko gdy wszystkie wybrane integracje to Synology. Pozostaw puste, aby wyświetlić wszystkie wykryte woluminy pamięci.",
"placeholder": "Wybierz woluminy pamięci"
}
},
"status": {
@@ -4101,9 +4128,9 @@
"write": "Zapis"
},
"storage": {
"title": "",
"subtitle": "",
"series": ""
"title": "Pamięć",
"subtitle": "Użycie dysku według systemu plików",
"series": "Używane"
},
"network": {
"title": "Przepustowość",
@@ -5238,7 +5265,7 @@
"table": {
"updated": "Zaktualizowano {when}",
"refresh": {
"lastUpdated": ""
"lastUpdated": "Ostatnie odświeżenie {when}"
},
"search": "Szukaj wśród {count} kontenerów",
"selected": "Wybrano {selectCount} z {totalCount} kontenerów",
@@ -5969,10 +5996,10 @@
}
},
"enableRightClickOnWidgets": {
"label": "",
"label": "Włącz prawe kliknięcie na widżetach",
"children": {
"detail": {
"title": ""
"title": "Włącz prawe kliknięcie na widżetach"
}
}
},
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -1,8 +1,7 @@
"use client";
import type { CSSProperties } from "react";
import { Badge, Text } from "@mantine/core";
import { IconHeadphones } from "@tabler/icons-react";
import { Text } from "@mantine/core";
import type { AudiobookshelfDashboardData, NavidromeDashboardData } from "@homarr/integrations/types";
import { useScopedI18n } from "@homarr/translation/client";
@@ -40,29 +39,6 @@ export function AudioStatsContent({ backend, stats, options, width }: AudioStats
const gridCols = getGridCols(width, visibleStats.length, options.compactMode ?? false);
const iconSize = getIconSize(width, options.compactMode ?? false);
const navidromeStatsByBackend: Record<AudioStatsBackend, NavidromeDashboardData | null> = {
navidrome: stats as NavidromeDashboardData,
audiobookshelf: null,
};
const navidromeStats = navidromeStatsByBackend[backend];
const showNowPlayingSection =
navidromeStats !== null && Boolean(options.showNowPlaying) && navidromeStats.nowPlaying.length > 0;
const nowPlayingLimitByListVisibility = {
true: options.maxNowPlayingItems ?? 3,
false: 0,
} as const;
const nowPlayingTracks =
navidromeStats?.nowPlaying.slice(
0,
nowPlayingLimitByListVisibility[
String(options.showNowPlayingList) as keyof typeof nowPlayingLimitByListVisibility
],
) ?? [];
const hasContent = visibleStats.length > 0 || showNowPlayingSection;
return (
<div className={`${classes.root} ${rootClassByCompact[compactKey]}`}>
{visibleStats.length > 0 && (
@@ -80,40 +56,7 @@ export function AudioStatsContent({ backend, stats, options, width }: AudioStats
</div>
)}
{showNowPlayingSection && navidromeStats && (
<div className={classes.nowPlaying}>
<div className={classes.nowPlayingHeader}>
<IconHeadphones size={iconSize} stroke={1.5} />
<span className={classes.nowPlayingTitle}>{t("nowPlaying" as never)}</span>
{!options.showNowPlayingList && (
<Badge className={classes.nowPlayingCount} variant="light" size="sm">
{(t as unknown as (key: string, params?: { count: number }) => string)("nowPlayingCount", {
count: navidromeStats.nowPlaying.length,
})}
</Badge>
)}
</div>
{options.showNowPlayingList && (
<div className={classes.nowPlayingList}>
{nowPlayingTracks.map((track, index) => (
<div key={`${track.username}-${track.title}-${index}`} className={classes.nowPlayingItem}>
<div className={classes.trackInfo}>
<span className={classes.trackTitle}>{track.title}</span>
<span className={classes.trackArtist}>
{[track.artist, track.album].filter(Boolean).join(" · ")}
</span>
</div>
<Badge className={classes.trackUser} variant="outline" size="xs">
{track.username || track.playerName}
</Badge>
</div>
))}
</div>
)}
</div>
)}
{!hasContent && (
{visibleStats.length === 0 && (
<div className={classes.emptyState}>
<Text size="sm" c="dimmed">
—
@@ -42,7 +42,6 @@ describe("getVisibleStats", () => {
artistCount: 100,
albumCount: 200,
songCount: 3000,
nowPlaying: [],
};
const audiobookshelfStats: AudiobookshelfDashboardData = {
@@ -87,92 +87,6 @@
font-size: clamp(11px, 4cqi, 16px);
}
.nowPlaying {
display: flex;
flex-direction: column;
gap: clamp(3px, 1.2cqi, 5px);
flex: 1;
min-height: 0;
overflow: hidden;
}
.nowPlayingHeader {
display: flex;
align-items: center;
gap: clamp(4px, 1.5cqi, 6px);
font-size: clamp(10px, 2.8cqi, 12px);
font-weight: 600;
flex-shrink: 0;
min-width: 0;
}
.nowPlayingTitle {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.nowPlayingCount {
flex-shrink: 0;
margin-inline-start: auto;
}
.nowPlayingList {
display: flex;
flex-direction: column;
gap: clamp(2px, 0.8cqi, 4px);
flex: 1;
min-height: 0;
overflow: hidden;
}
.nowPlayingItem {
display: flex;
align-items: center;
justify-content: space-between;
gap: clamp(4px, 1.5cqi, 6px);
padding: clamp(3px, 1.2cqi, 6px) clamp(4px, 1.5cqi, 8px);
border-radius: var(--mantine-radius-sm);
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
min-width: 0;
flex-shrink: 0;
}
.trackInfo {
display: flex;
flex-direction: column;
gap: 1px;
min-width: 0;
flex: 1;
}
.trackTitle {
font-size: clamp(9px, 2.6cqi, 12px);
font-weight: 500;
line-height: 1.2;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.trackArtist {
font-size: clamp(8px, 2.2cqi, 10px);
color: var(--mantine-color-dimmed);
line-height: 1.2;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.trackUser {
flex-shrink: 0;
max-width: clamp(40px, 22cqi, 88px);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.emptyState {
display: flex;
flex: 1;
-12
View File
@@ -1,5 +1,4 @@
import { IconHeadphones, IconServerOff } from "@tabler/icons-react";
import { z } from "zod/v4";
import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options";
@@ -23,14 +22,6 @@ export const { definition, componentLoader } = createWidgetDefinition("audioStat
showArtists: factory.switch({ defaultValue: true, withDescription: true }),
showAlbums: factory.switch({ defaultValue: true, withDescription: true }),
showSongs: factory.switch({ defaultValue: true, withDescription: true }),
showNowPlaying: factory.switch({ defaultValue: true, withDescription: true }),
showNowPlayingList: factory.switch({ defaultValue: true, withDescription: true }),
maxNowPlayingItems: factory.number({
defaultValue: 3,
validate: z.number().min(1).max(5),
step: 1,
withDescription: true,
}),
showLibraryCount: factory.switch({ defaultValue: true, withDescription: true }),
showAudiobooks: factory.switch({ defaultValue: true, withDescription: true }),
showPodcasts: factory.switch({ defaultValue: true, withDescription: true }),
@@ -42,9 +33,6 @@ export const { definition, componentLoader } = createWidgetDefinition("audioStat
showArtists: hideUnlessNavidrome,
showAlbums: hideUnlessNavidrome,
showSongs: hideUnlessNavidrome,
showNowPlaying: hideUnlessNavidrome,
showNowPlayingList: hideUnlessNavidrome,
maxNowPlayingItems: hideUnlessNavidrome,
showLibraryCount: hideUnlessAudiobookshelf,
showAudiobooks: hideUnlessAudiobookshelf,
showPodcasts: hideUnlessAudiobookshelf,
@@ -19,9 +19,6 @@ export interface NavidromeDisplayOptions {
showArtists: boolean;
showAlbums: boolean;
showSongs: boolean;
showNowPlaying: boolean;
showNowPlayingList: boolean;
maxNowPlayingItems: number;
compactMode?: boolean;
}
@@ -0,0 +1,34 @@
import { describe, expect, test } from "vitest";
import { parseColumnOrder, parseColumnWidths } from "../common/use-persisted-table-layout";
const columnAccessors = ["name", "cpu", "memory", "disk", "temp"];
describe("Beszel table column layout options", () => {
test("ignores malformed column layout JSON", () => {
expect(parseColumnOrder("{", columnAccessors)).toEqual([]);
expect(parseColumnWidths("[]", columnAccessors)).toEqual({});
});
test("removes stale and duplicate column accessors", () => {
expect(parseColumnOrder(JSON.stringify(["memory", "removed", "cpu", "memory"]), columnAccessors)).toEqual([
"memory",
"cpu",
]);
});
test("keeps only finite positive widths for known columns", () => {
expect(
parseColumnWidths(
JSON.stringify({
cpu: 140,
memory: -1,
disk: "160px",
removed: 100,
temp: null,
}),
columnAccessors,
),
).toEqual({ cpu: 140 });
});
});
@@ -1,11 +1,8 @@
"use client";
import "./styles.css";
import { useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Center, Group, Indicator, Loader, Progress, Text } from "@mantine/core";
import type { DataTableColumn, DataTableSortStatus } from "mantine-datatable";
import { DataTable } from "mantine-datatable";
import {
Activity,
Battery,
@@ -20,10 +17,16 @@ import {
} from "lucide-react";
import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import { constructBoardPermissions } from "@homarr/auth/shared";
import { useOptionalBoard } from "@homarr/boards/context";
import { useModalAction } from "@homarr/modals";
import { showErrorNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition";
import { HomarrDataTable } from "../common/homarr-data-table";
import { usePersistedTableLayout, useTableLayoutPersistence } from "../common/use-persisted-table-layout";
import type { BeszelSystemRow } from "../beszel/_shared/types";
import { loadAvgColor, statusColorMap, thresholdColor } from "../beszel/_shared/colors";
import {
@@ -43,6 +46,21 @@ const directionMultiplier: Record<string, number> = { asc: 1, desc: -1 };
type SystemRowWithKey = BeszelSystemRow & { _key: string };
const columnAccessors = [
"name",
"cpu",
"memory",
"disk",
"gpu",
"loadAvg",
"netBytes",
"temp",
"battery",
"services",
"uptime",
"agentVersion",
] as const satisfies readonly (keyof BeszelSystemRow)[];
interface SizeConfig {
iconSize: number;
fontSize: "xs" | "sm";
@@ -75,15 +93,36 @@ export default function BeszelSystemTableWidget({
integrationIds,
isEditMode,
width,
boardId,
itemId,
setOptions,
}: WidgetComponentProps<"beszelSystemTable">) {
const t = useScopedI18n("widget.beszelSystemTable");
const { openModal } = useModalAction(BeszelSystemStatsModal);
const board = useOptionalBoard();
const { data: session } = useSession();
const hasChangeAccess = board ? constructBoardPermissions(board, session).hasChangeAccess : false;
const {
data: results = [],
error: systemsError,
isPending,
} = clientApi.widget.beszel.getSystems.useQuery({ integrationIds });
const size = getSizeConfig(width);
const size = useMemo(() => getSizeConfig(width), [width]);
const { mutate: saveItemOptions } = clientApi.widget.options.saveItemOptions.useMutation({
onError: () =>
showErrorNotification({
title: t("error.layoutSaveTitle"),
message: t("error.layoutSaveMessage"),
}),
});
const persistLayout = useTableLayoutPersistence({
boardId,
hasChangeAccess,
itemId,
saveItemOptions,
setOptions,
});
const filteredSystems = useBeszelFilteredSystems(results, options.statusFilter);
@@ -91,6 +130,12 @@ export default function BeszelSystemTableWidget({
columnAccessor: options.sortBy,
direction: options.sortDirection as "asc" | "desc",
});
useEffect(() => {
setSortStatus({
columnAccessor: options.sortBy,
direction: options.sortDirection as "asc" | "desc",
});
}, [options.sortBy, options.sortDirection]);
const sortedSystems = useMemo(() => {
const accessor = sortStatus.columnAccessor as keyof BeszelSystemRow;
@@ -122,6 +167,8 @@ export default function BeszelSystemTableWidget({
const cols: (DataTableColumn<SystemRowWithKey> | false)[] = [
{
accessor: "name",
width: 160,
ellipsis: true,
title: (
<Group gap={4} wrap="nowrap">
<Server size={size.iconSize} />
@@ -130,7 +177,7 @@ export default function BeszelSystemTableWidget({
),
sortable: true,
render: (record) => (
<Group gap={8} wrap="nowrap">
<Group gap={8} wrap="nowrap" style={{ overflow: "hidden", paddingInlineStart: 4 }}>
<Indicator color={statusColorMap[record.status]} size={7} />
<Text size={size.fontSize} fw={500} truncate>
{record.name}
@@ -140,6 +187,7 @@ export default function BeszelSystemTableWidget({
},
options.showCpu && {
accessor: "cpu",
width: 140,
title: (
<Group gap={4} wrap="nowrap">
<Cpu size={size.iconSize} />
@@ -151,6 +199,7 @@ export default function BeszelSystemTableWidget({
},
options.showMemory && {
accessor: "memory",
width: 140,
title: (
<Group gap={4} wrap="nowrap">
<MemoryStick size={size.iconSize} />
@@ -162,6 +211,7 @@ export default function BeszelSystemTableWidget({
},
options.showDisk && {
accessor: "disk",
width: 160,
title: (
<Group gap={4} wrap="nowrap">
<HardDrive size={size.iconSize} />
@@ -181,6 +231,7 @@ export default function BeszelSystemTableWidget({
},
options.showGpu && {
accessor: "gpu",
width: 140,
title: (
<Group gap={4} wrap="nowrap">
<Monitor size={size.iconSize} />
@@ -192,6 +243,7 @@ export default function BeszelSystemTableWidget({
},
options.showLoadAvg && {
accessor: "loadAvg",
width: 100,
title: (
<Group gap={4} wrap="nowrap">
<Activity size={size.iconSize} />
@@ -208,6 +260,7 @@ export default function BeszelSystemTableWidget({
},
options.showNet && {
accessor: "netBytes",
width: 100,
title: (
<Group gap={4} wrap="nowrap">
<Network size={size.iconSize} />
@@ -223,6 +276,7 @@ export default function BeszelSystemTableWidget({
},
options.showTemp && {
accessor: "temp",
width: 80,
title: (
<Group gap={4} wrap="nowrap">
<Thermometer size={size.iconSize} />
@@ -234,6 +288,7 @@ export default function BeszelSystemTableWidget({
},
options.showBattery && {
accessor: "battery",
width: 70,
title: (
<Group gap={4} wrap="nowrap">
<Battery size={size.iconSize} />
@@ -244,6 +299,7 @@ export default function BeszelSystemTableWidget({
},
options.showServices && {
accessor: "services",
width: 80,
title: (
<Group gap={4} wrap="nowrap">
<Server size={size.iconSize} />
@@ -255,6 +311,7 @@ export default function BeszelSystemTableWidget({
},
options.showUptime && {
accessor: "uptime",
width: 90,
title: (
<Group gap={4} wrap="nowrap">
<Activity size={size.iconSize} />
@@ -266,6 +323,7 @@ export default function BeszelSystemTableWidget({
},
options.showAgent && {
accessor: "agentVersion",
width: 90,
title: (
<Group gap={4} wrap="nowrap">
<Wifi size={size.iconSize} />
@@ -278,12 +336,39 @@ export default function BeszelSystemTableWidget({
];
return cols.filter(Boolean) as DataTableColumn<SystemRowWithKey>[];
}, [options, t, size]);
}, [
options.showAgent,
options.showBattery,
options.showCpu,
options.showDisk,
options.showGpu,
options.showLoadAvg,
options.showMemory,
options.showNet,
options.showServices,
options.showTemp,
options.showUptime,
size,
t,
]);
const handleRowClick = ({ record }: { record: SystemRowWithKey }) => {
const integrationId = record._key.split(":")[0] ?? "";
openModal({ integrationId, systemId: record.id }, { title: record.name });
};
const { effectiveColumns, storeKey } = usePersistedTableLayout({
columns,
columnAccessors,
columnOrder: options.columnOrder,
columnWidths: options.columnWidths,
itemId,
storeKeyPrefix: "beszel-system-table",
onLayoutChange: persistLayout,
});
const handleRowClick = useCallback(
({ record }: { record: SystemRowWithKey }) => {
const integrationId = record._key.split(":")[0] ?? "";
openModal({ integrationId, systemId: record.id }, { title: record.name });
},
[openModal],
);
if (systemsError) throw systemsError;
@@ -300,20 +385,17 @@ export default function BeszelSystemTableWidget({
<div style={{ position: "absolute", top: 4, right: 8, zIndex: 1 }}>
<BeszelIntegrationErrorIndicator results={results} />
</div>
<DataTable
style={{ pointerEvents: isEditMode ? "none" : undefined }}
withTableBorder={false}
borderRadius={0}
highlightOnHover
<HomarrDataTable
isEditMode={isEditMode}
cellPadding={`${size.cellPadding}px 8px`}
fz={size.fontSize}
records={sortedSystems}
columns={columns}
columns={effectiveColumns}
sortStatus={sortStatus}
onSortStatusChange={setSortStatus}
noRecordsText={t("noRecords")}
idAccessor="_key"
height="100%"
className="beszel-table"
storeColumnsKey={storeKey}
onRowClick={isEditMode ? undefined : handleRowClick}
/>
</div>
@@ -29,28 +29,36 @@ export const { definition, componentLoader } = createWidgetDefinition("beszelSys
supportedIntegrations: ["beszel", "mock"],
integrationsRequired: true,
createOptions() {
return optionsBuilder.from((factory) => ({
statusFilter: factory.select({
defaultValue: "all",
options: statusOptions,
return optionsBuilder.from(
(factory) => ({
statusFilter: factory.select({
defaultValue: "all",
options: statusOptions,
}),
sortBy: factory.select({ defaultValue: "name", options: sortOptions }),
sortDirection: factory.select({
defaultValue: "asc",
options: sortDirectionOptions,
}),
showCpu: factory.switch({ defaultValue: true }),
showMemory: factory.switch({ defaultValue: true }),
showDisk: factory.switch({ defaultValue: true }),
showGpu: factory.switch({ defaultValue: true }),
showLoadAvg: factory.switch({ defaultValue: true }),
showNet: factory.switch({ defaultValue: true }),
showTemp: factory.switch({ defaultValue: true }),
showBattery: factory.switch({ defaultValue: true }),
showServices: factory.switch({ defaultValue: true }),
showUptime: factory.switch({ defaultValue: true }),
showAgent: factory.switch({ defaultValue: true }),
columnOrder: factory.text({ defaultValue: "" }),
columnWidths: factory.text({ defaultValue: "" }),
}),
sortBy: factory.select({ defaultValue: "name", options: sortOptions }),
sortDirection: factory.select({
defaultValue: "asc",
options: sortDirectionOptions,
}),
showCpu: factory.switch({ defaultValue: true }),
showMemory: factory.switch({ defaultValue: true }),
showDisk: factory.switch({ defaultValue: true }),
showGpu: factory.switch({ defaultValue: true }),
showLoadAvg: factory.switch({ defaultValue: true }),
showNet: factory.switch({ defaultValue: true }),
showTemp: factory.switch({ defaultValue: true }),
showBattery: factory.switch({ defaultValue: true }),
showServices: factory.switch({ defaultValue: true }),
showUptime: factory.switch({ defaultValue: true }),
showAgent: factory.switch({ defaultValue: true }),
}));
{
columnOrder: { shouldHide: () => true },
columnWidths: { shouldHide: () => true },
},
);
},
errors: {
INTERNAL_SERVER_ERROR: {
@@ -1,47 +0,0 @@
/* Make the DataTable transparent so the board card's opacity-controlled
background shows through. The mantine-datatable defaults to
var(--mantine-color-body) which is opaque. */
.beszel-table,
.beszel-table .mantine-datatable-table,
.beszel-table .mantine-datatable-table thead,
.beszel-table .mantine-datatable-table tbody,
.beszel-table .mantine-datatable-table tfoot,
.beszel-table th,
.beszel-table td {
background-color: transparent !important;
}
.beszel-table .mantine-datatable-table tr {
background-color: transparent;
}
.beszel-table .mantine-datatable-table tbody tr:hover {
background-color: color-mix(in srgb, var(--mantine-color-default-hover) 40%, transparent) !important;
}
.beszel-table th {
white-space: nowrap;
background-color: color-mix(in srgb, var(--mantine-color-body) 60%, transparent) !important;
backdrop-filter: blur(8px);
}
.beszel-table td {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.beszel-table td .mantine-Text-root,
.beszel-table td .mantine-Group-root {
white-space: nowrap;
flex-wrap: nowrap;
}
.beszel-table .mantine-datatable-header-cell-sortable-icon {
opacity: 0;
transition: opacity 150ms;
}
.beszel-table th:hover .mantine-datatable-header-cell-sortable-icon {
opacity: 1;
}
@@ -0,0 +1,52 @@
.homarr-data-table,
.homarr-data-table .mantine-datatable-table,
.homarr-data-table .mantine-datatable-table thead,
.homarr-data-table .mantine-datatable-table tbody,
.homarr-data-table .mantine-datatable-table tfoot,
.homarr-data-table th,
.homarr-data-table td {
background-color: transparent !important;
}
.homarr-data-table .mantine-datatable-table tr {
background-color: transparent;
}
.homarr-data-table .mantine-datatable-table tbody tr:hover {
background-color: color-mix(in srgb, var(--mantine-color-default-hover) 40%, transparent) !important;
}
.homarr-data-table th {
white-space: nowrap;
background-color: color-mix(in srgb, var(--mantine-color-body) 60%, transparent) !important;
backdrop-filter: blur(8px);
}
.homarr-data-table td {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.homarr-data-table td .mantine-Text-root,
.homarr-data-table td .mantine-Group-root {
white-space: nowrap;
flex-wrap: nowrap;
}
.homarr-data-table .mantine-datatable-header-cell-sortable-icon,
.homarr-data-table .mantine-datatable-header-cell-draggable-action-icon {
opacity: 0;
transition: opacity 150ms ease-out;
}
.homarr-data-table th:hover .mantine-datatable-header-cell-sortable-icon,
.homarr-data-table th:hover .mantine-datatable-header-cell-draggable-action-icon,
.homarr-data-table .mantine-datatable-header-cell-draggable-action-icon:focus-visible,
.homarr-data-table .mantine-datatable-header-cell-drag-over .mantine-datatable-header-cell-draggable-action-icon {
opacity: 1;
}
.homarr-data-table-row-cursor-default .mantine-datatable-context-menu-cursor {
cursor: default;
}
@@ -0,0 +1,57 @@
"use client";
import "./homarr-data-table.css";
import type { DataTableProps } from "mantine-datatable";
import { DataTable } from "mantine-datatable";
type HomarrDataTableProps<T> = DataTableProps<T> & {
isEditMode: boolean;
cellPadding: string;
rowCursor?: "auto" | "default";
};
export function HomarrDataTable<T>({
isEditMode,
cellPadding,
rowCursor = "auto",
className,
defaultColumnProps,
scrollAreaProps,
style,
...props
}: HomarrDataTableProps<T>) {
const mergedClassName = [
"homarr-data-table",
rowCursor === "default" ? "homarr-data-table-row-cursor-default" : undefined,
className,
]
.filter(Boolean)
.join(" ");
return (
<DataTable<T>
withTableBorder={false}
borderRadius={0}
highlightOnHover
striped="odd"
stripedColor={{ dark: "dark.7", light: "gray.0" }}
highlightOnHoverColor={{ dark: "dark.5", light: "gray.1" }}
verticalAlign="center"
loaderBackgroundBlur={2}
textSelectionDisabled
height="100%"
{...props}
className={mergedClassName}
style={[{ pointerEvents: isEditMode ? "none" : undefined }, style]}
defaultColumnProps={{
...defaultColumnProps,
noWrap: defaultColumnProps?.noWrap ?? true,
draggable: !isEditMode,
resizable: !isEditMode,
cellsStyle: defaultColumnProps?.cellsStyle ?? (() => ({ padding: cellPadding })),
}}
scrollAreaProps={{ type: "auto", scrollbarSize: 6, ...scrollAreaProps }}
/>
);
}
@@ -0,0 +1,83 @@
import { act, createElement } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeAll, beforeEach, describe, expect, test, vi } from "vitest";
import type { Root } from "react-dom/client";
import type { DataTableColumn } from "mantine-datatable";
const tableState = vi.hoisted(() => ({
columnsOrder: ["name", "state"],
columnsWidth: [] as Record<string, string | number>[],
}));
vi.mock("mantine-datatable", () => ({
useDataTableColumns: ({ columns }: { columns: DataTableColumn<unknown>[] }) => ({
effectiveColumns: columns,
columnsOrder: tableState.columnsOrder,
columnsWidth: tableState.columnsWidth,
setColumnsOrder: vi.fn(),
setMultipleColumnWidths: vi.fn(),
}),
}));
import { usePersistedTableLayout } from "./use-persisted-table-layout";
const columns: DataTableColumn<{ name: string; state: string }>[] = [{ accessor: "name" }, { accessor: "state" }];
const columnAccessors = ["name", "state"] as const;
const LayoutHarness = ({ itemId, onLayoutChange }: { itemId: string; onLayoutChange: (layout: unknown) => void }) => {
usePersistedTableLayout({
columns,
columnAccessors,
columnOrder: "",
columnWidths: "",
itemId,
storeKeyPrefix: "test-table",
onLayoutChange,
});
return null;
};
describe("usePersistedTableLayout", () => {
let container: HTMLDivElement;
let root: Root;
beforeAll(() => {
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", { value: true, writable: true });
});
beforeEach(() => {
vi.useFakeTimers();
tableState.columnsOrder = ["name", "state"];
tableState.columnsWidth = [];
container = document.createElement("div");
root = createRoot(container);
});
afterEach(async () => {
await act(async () => root.unmount());
vi.useRealTimers();
});
test("flushes a pending layout through the callback for its originating item", async () => {
const firstItemLayoutChange = vi.fn();
const secondItemLayoutChange = vi.fn();
await act(async () => {
root.render(createElement(LayoutHarness, { itemId: "first", onLayoutChange: firstItemLayoutChange }));
});
tableState.columnsOrder = ["state", "name"];
await act(async () => {
root.render(createElement(LayoutHarness, { itemId: "first", onLayoutChange: firstItemLayoutChange }));
});
expect(firstItemLayoutChange).not.toHaveBeenCalled();
await act(async () => {
root.render(createElement(LayoutHarness, { itemId: "second", onLayoutChange: secondItemLayoutChange }));
});
expect(firstItemLayoutChange).toHaveBeenCalledWith({ columnOrder: JSON.stringify(["state", "name"]) });
expect(secondItemLayoutChange).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,233 @@
"use client";
import { useCallback, useEffect, useMemo, useRef } from "react";
import type { DataTableColumn } from "mantine-datatable";
import { useDataTableColumns } from "mantine-datatable";
interface TableLayoutOptions extends Record<string, unknown> {
columnOrder?: string;
columnWidths?: string;
}
interface PendingTableLayout {
layout: TableLayoutOptions;
onLayoutChange: (layout: TableLayoutOptions) => void;
storeKey: string;
}
interface UseTableLayoutPersistenceProps {
boardId: string | undefined;
hasChangeAccess: boolean;
itemId: string | undefined;
saveItemOptions: (input: { boardId: string; itemId: string; newOptions: TableLayoutOptions }) => void;
setOptions: (input: { newOptions: TableLayoutOptions }) => void;
}
interface UsePersistedTableLayoutProps<T> {
columns: DataTableColumn<T>[];
columnAccessors: readonly string[];
columnOrder: string;
columnWidths: string;
itemId: string | undefined;
storeKeyPrefix: string;
onLayoutChange: (layout: TableLayoutOptions) => void;
}
const layoutSaveDelay = 350;
export const useTableLayoutPersistence = ({
boardId,
hasChangeAccess,
itemId,
saveItemOptions,
setOptions,
}: UseTableLayoutPersistenceProps) =>
useCallback(
(newOptions: TableLayoutOptions) => {
setOptions({ newOptions });
if (hasChangeAccess && boardId && itemId) saveItemOptions({ boardId, itemId, newOptions });
},
[boardId, hasChangeAccess, itemId, saveItemOptions, setOptions],
);
const toPixelWidth = (width: string | number): number | undefined => {
if (typeof width === "number" && Number.isFinite(width) && width > 0) return width;
if (typeof width !== "string" || !width.endsWith("px")) return undefined;
const parsedWidth = Number.parseInt(width, 10);
return Number.isFinite(parsedWidth) && parsedWidth > 0 ? parsedWidth : undefined;
};
const getColumnWidthMap = (
columnsWidth: Record<string, string | number>[],
visibleAccessorSet: ReadonlySet<string>,
): Record<string, number> => {
const widths: Record<string, number> = {};
for (const entry of columnsWidth) {
for (const [accessor, width] of Object.entries(entry)) {
if (!visibleAccessorSet.has(accessor)) continue;
const pixelWidth = toPixelWidth(width);
if (pixelWidth !== undefined) widths[accessor] = pixelWidth;
}
}
return widths;
};
export const parseColumnOrder = (value: string, columnAccessors: readonly string[]): string[] => {
if (!value) return [];
try {
const parsed: unknown = JSON.parse(value);
if (!Array.isArray(parsed)) return [];
const allowed = new Set(columnAccessors);
return parsed.filter(
(accessor, index): accessor is string =>
typeof accessor === "string" && allowed.has(accessor) && parsed.indexOf(accessor) === index,
);
} catch {
return [];
}
};
export const parseColumnWidths = (value: string, columnAccessors: readonly string[]): Record<string, number> => {
if (!value) return {};
try {
const parsed: unknown = JSON.parse(value);
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return {};
const allowed = new Set(columnAccessors);
return Object.fromEntries(
Object.entries(parsed).filter(
(entry): entry is [string, number] =>
allowed.has(entry[0]) && typeof entry[1] === "number" && Number.isFinite(entry[1]) && entry[1] > 0,
),
);
} catch {
return {};
}
};
export const usePersistedTableLayout = <T>({
columns,
columnAccessors,
columnOrder,
columnWidths,
itemId,
storeKeyPrefix,
onLayoutChange,
}: UsePersistedTableLayoutProps<T>) => {
const savedOrder = useMemo(() => parseColumnOrder(columnOrder, columnAccessors), [columnAccessors, columnOrder]);
const savedWidths = useMemo(() => parseColumnWidths(columnWidths, columnAccessors), [columnAccessors, columnWidths]);
const visibleAccessors = useMemo(() => columns.map(({ accessor }) => String(accessor)), [columns]);
const visibleAccessorSet = useMemo(() => new Set(visibleAccessors), [visibleAccessors]);
const restoredOrder = useMemo(
() => [
...savedOrder.filter((accessor) => visibleAccessorSet.has(accessor)),
...visibleAccessors.filter((accessor) => !savedOrder.includes(accessor)),
],
[savedOrder, visibleAccessors, visibleAccessorSet],
);
const storeKey = `${storeKeyPrefix}-${itemId ?? "preview"}-${[...visibleAccessors].toSorted().join(",")}`;
const { effectiveColumns, columnsOrder, columnsWidth, setColumnsOrder, setMultipleColumnWidths } =
useDataTableColumns<T>({
key: storeKey,
columns,
});
const lastStoreKey = useRef(storeKey);
const hydrated = useRef(false);
const previousOrder = useRef(columnsOrder);
const previousWidths = useRef(columnsWidth);
useEffect(() => {
if (lastStoreKey.current !== storeKey) {
lastStoreKey.current = storeKey;
hydrated.current = false;
}
if (hydrated.current) return;
const orderMatches =
columnsOrder.length === restoredOrder.length &&
columnsOrder.every((accessor, index) => accessor === restoredOrder[index]);
const currentWidths = getColumnWidthMap(columnsWidth, visibleAccessorSet);
const visibleWidths = Object.entries(savedWidths).filter(([accessor]) => visibleAccessorSet.has(accessor));
const widthsMatch = visibleWidths.every(([accessor, width]) => currentWidths[accessor] === width);
if (orderMatches && widthsMatch) {
previousOrder.current = columnsOrder;
previousWidths.current = columnsWidth;
hydrated.current = true;
return;
}
if (!orderMatches) setColumnsOrder(restoredOrder);
if (!widthsMatch) {
setMultipleColumnWidths(visibleWidths.map(([accessor, width]) => ({ accessor, width })));
}
}, [
columnsOrder,
columnsWidth,
restoredOrder,
savedWidths,
setColumnsOrder,
setMultipleColumnWidths,
storeKey,
visibleAccessorSet,
]);
const pendingLayout = useRef<PendingTableLayout | undefined>(undefined);
const saveTimeout = useRef<number | undefined>(undefined);
const flushPendingLayout = useCallback(() => {
const pending = pendingLayout.current;
pendingLayout.current = undefined;
window.clearTimeout(saveTimeout.current);
saveTimeout.current = undefined;
if (pending) pending.onLayoutChange(pending.layout);
}, []);
useEffect(() => {
if (pendingLayout.current && pendingLayout.current.storeKey !== storeKey) flushPendingLayout();
}, [flushPendingLayout, storeKey]);
useEffect(() => () => flushPendingLayout(), [flushPendingLayout]);
useEffect(() => {
if (!hydrated.current) return;
const orderChanged = JSON.stringify(columnsOrder) !== JSON.stringify(previousOrder.current);
const widthsChanged = JSON.stringify(columnsWidth) !== JSON.stringify(previousWidths.current);
previousOrder.current = columnsOrder;
previousWidths.current = columnsWidth;
if (!orderChanged && !widthsChanged) return;
const newLayout: TableLayoutOptions = {};
if (orderChanged) {
const fullOrder = [...savedOrder, ...columnAccessors.filter((accessor) => !savedOrder.includes(accessor))];
const reorderedVisibleColumns = [...columnsOrder];
const mergedOrder = fullOrder.map((accessor) =>
visibleAccessorSet.has(accessor) ? (reorderedVisibleColumns.shift() ?? accessor) : accessor,
);
newLayout.columnOrder = JSON.stringify(mergedOrder);
}
if (widthsChanged) {
const widthMap: Record<string, number> = { ...savedWidths };
Object.assign(widthMap, getColumnWidthMap(columnsWidth, visibleAccessorSet));
newLayout.columnWidths = JSON.stringify(widthMap);
}
if (pendingLayout.current && pendingLayout.current.storeKey !== storeKey) flushPendingLayout();
pendingLayout.current = pendingLayout.current
? { ...pendingLayout.current, layout: { ...pendingLayout.current.layout, ...newLayout } }
: { layout: newLayout, onLayoutChange, storeKey };
window.clearTimeout(saveTimeout.current);
saveTimeout.current = window.setTimeout(flushPendingLayout, layoutSaveDelay);
}, [
columnAccessors,
columnsOrder,
columnsWidth,
flushPendingLayout,
onLayoutChange,
savedOrder,
savedWidths,
storeKey,
visibleAccessorSet,
]);
return { effectiveColumns, storeKey };
};
@@ -0,0 +1,33 @@
import { describe, expect, test } from "vitest";
import { parseColumnOrder, parseColumnWidths } from "../common/use-persisted-table-layout";
const columnAccessors = ["name", "state", "host", "cpuUsage", "memoryUsage", "actions"];
describe("Docker table column layout options", () => {
test("ignores malformed column layout JSON", () => {
expect(parseColumnOrder("{", columnAccessors)).toEqual([]);
expect(parseColumnWidths("[]", columnAccessors)).toEqual({});
});
test("removes stale and duplicate column accessors", () => {
expect(
parseColumnOrder(JSON.stringify(["memoryUsage", "removed", "name", "memoryUsage"]), columnAccessors),
).toEqual(["memoryUsage", "name"]);
});
test("keeps only finite positive widths for known columns", () => {
expect(
parseColumnWidths(
JSON.stringify({
name: 180,
state: -1,
host: "120px",
removed: 100,
actions: null,
}),
columnAccessors,
),
).toEqual({ name: 180 });
});
});
+427 -226
View File
@@ -1,22 +1,57 @@
"use client";
import { useMemo } from "react";
import { ActionIcon, Avatar, Badge, Center, Group, Stack, Text, Tooltip } from "@mantine/core";
import type { IconProps } from "@tabler/icons-react";
import { IconBrandDocker, IconPlayerPlay, IconPlayerStop, IconRefresh, IconRotateClockwise } from "@tabler/icons-react";
import type { MRT_ColumnDef, MRT_VisibilityState } from "mantine-react-table";
import { MantineReactTable } from "mantine-react-table";
import { useCallback, useEffect, useMemo, useState } from "react";
import { ActionIcon, Avatar, Badge, Box, Center, Group, Menu, Portal, Stack, Text, Tooltip } from "@mantine/core";
import {
IconBrandDocker,
IconCategoryPlus,
IconDots,
IconFileText,
IconPlayerPlay,
IconPlayerStop,
IconRefresh,
IconRotateClockwise,
IconTrash,
} from "@tabler/icons-react";
import type { DataTableColumn, DataTableSortStatus } from "mantine-datatable";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import { constructBoardPermissions } from "@homarr/auth/shared";
import { useOptionalBoard } from "@homarr/boards/context";
import { formatBytes, useTimeAgo } from "@homarr/common";
import type { ContainerState } from "@homarr/docker";
import { containerStateColorMap, cpuUsageColor, memoryUsageColor, safeValue } from "@homarr/docker/shared";
import { useModalAction } from "@homarr/modals";
import { AddDockerAppToHomarr } from "@homarr/modals-collection";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
import type { WidgetComponentProps } from "../definition";
import { HomarrDataTable } from "../common/homarr-data-table";
import { usePersistedTableLayout, useTableLayoutPersistence } from "../common/use-persisted-table-layout";
type DockerContainer = RouterOutputs["docker"]["getContainers"]["containers"][number];
type ContainerAction = "start" | "stop" | "restart" | "remove";
interface ContextMenuState {
x: number;
y: number;
container: DockerContainer;
}
interface ContainerActionHandlers {
onAction: (action: ContainerAction, container: DockerContainer) => void;
onAddToHomarr: (container: DockerContainer) => void;
onOpenLogs: (container: DockerContainer) => void;
}
const columnAccessors = ["name", "state", "host", "cpuUsage", "memoryUsage", "actions"] as const;
const containerMenuWidth = 240;
const createContainerLogsPath = (container: Pick<DockerContainer, "id" | "name">) =>
`/manage/tools/docker/logs/${container.id}?name=${encodeURIComponent(container.name)}`;
const ContainerStateBadge = ({ state }: { state: ContainerState }) => {
const t = useScopedI18n("docker.field.state.option");
@@ -28,276 +63,293 @@ const ContainerStateBadge = ({ state }: { state: ContainerState }) => {
);
};
const actionIconIconStyle: IconProps["style"] = {
height: "var(--ai-icon-size)",
width: "var(--ai-icon-size)",
};
const createColumns = (
t: ReturnType<typeof useScopedI18n<"docker">>,
): MRT_ColumnDef<RouterOutputs["docker"]["getContainers"]["containers"][number]>[] => [
handlers: ContainerActionHandlers,
sortingEnabled: boolean,
): DataTableColumn<DockerContainer>[] => [
{
id: "name",
accessorKey: "name",
header: t("field.name.label"),
Cell({ renderedCellValue, row }) {
return (
<Group gap="xs" wrap="nowrap">
<Avatar variant="outline" size={20} src={row.original.iconUrl} />
<Text p="0.5" size="sm" style={{ overflow: "hidden", textOverflow: "ellipsis" }}>
{renderedCellValue}
</Text>
</Group>
);
},
},
{
id: "state",
accessorKey: "state",
size: 100,
header: t("field.state.label"),
Cell({ row }) {
return <ContainerStateBadge state={row.original.state} />;
},
},
{
id: "host",
accessorKey: "host",
size: 100,
header: t("field.host.label"),
Cell({ row }) {
return (
<Text size="xs" truncate="end">
{row.original.host}
accessor: "name",
title: t("field.name.label"),
width: 180,
ellipsis: true,
sortable: sortingEnabled,
render: (container) => (
<Group gap="xs" wrap="nowrap" style={{ overflow: "hidden" }}>
<Avatar
variant="outline"
radius="sm"
size={20}
styles={{ image: { objectFit: "contain" } }}
src={container.iconUrl}
style={{ flexShrink: 0 }}
>
{container.name.at(0)?.toUpperCase()}
</Avatar>
<Text size="sm" truncate>
{container.name}
</Text>
);
},
</Group>
),
},
{
id: "cpuUsage",
sortingFn: (rowA, rowB) => {
const cpuUsageA = safeValue(rowA.original.cpuUsage);
const cpuUsageB = safeValue(rowB.original.cpuUsage);
return cpuUsageA - cpuUsageB;
},
accessorKey: "cpuUsage",
size: 80,
header: t("field.stats.cpu.label"),
Cell({ row }) {
const cpuUsage = safeValue(row.original.cpuUsage);
accessor: "state",
title: t("field.state.label"),
width: 100,
sortable: sortingEnabled,
render: (container) => <ContainerStateBadge state={container.state} />,
},
{
accessor: "host",
title: t("field.host.label"),
width: 120,
sortable: sortingEnabled,
ellipsis: true,
render: (container) => (
<Text size="xs" truncate title={container.host}>
{container.host}
</Text>
),
},
{
accessor: "cpuUsage",
title: t("field.stats.cpu.label"),
width: 80,
sortable: sortingEnabled,
render: (container) => {
const cpuUsage = safeValue(container.cpuUsage);
return (
<Text size="xs" c={cpuUsageColor(cpuUsage, row.original.state)}>
<Text size="xs" c={cpuUsageColor(cpuUsage, container.state)}>
{cpuUsage.toFixed(2)}%
</Text>
);
},
},
{
id: "memoryUsage",
sortingFn: (rowA, rowB) => {
const memoryUsageA = safeValue(rowA.original.memoryUsage);
const memoryUsageB = safeValue(rowB.original.memoryUsage);
return memoryUsageA - memoryUsageB;
},
accessorKey: "memoryUsage",
size: 80,
header: t("field.stats.memory.label"),
Cell({ row }) {
const bytesUsage = safeValue(row.original.memoryUsage);
accessor: "memoryUsage",
title: t("field.stats.memory.label"),
width: 100,
sortable: sortingEnabled,
render: (container) => {
const memoryUsage = safeValue(container.memoryUsage);
return (
<Text size="xs" c={memoryUsageColor(bytesUsage, row.original.state)}>
{formatBytes(bytesUsage)}
<Text size="xs" c={memoryUsageColor(memoryUsage, container.state)}>
{formatBytes(memoryUsage)}
</Text>
);
},
},
{
id: "actions",
accessorKey: "actions",
size: 80,
header: t("action.title"),
enableSorting: false,
Cell({ row }) {
const utils = clientApi.useUtils();
// eslint-disable-next-line no-restricted-syntax
const onSettled = async () => {
await utils.docker.getContainers.invalidate();
};
const { mutateAsync: startContainer } = clientApi.docker.startAll.useMutation({ onSettled });
const { mutateAsync: stopContainer } = clientApi.docker.stopAll.useMutation({ onSettled });
const { mutateAsync: restartContainer } = clientApi.docker.restartAll.useMutation({ onSettled });
const handleActionAsync = async (action: "start" | "stop" | "restart") => {
const mutation = action === "start" ? startContainer : action === "stop" ? stopContainer : restartContainer;
await mutation(
{ ids: [row.original.id] },
{
onSuccess() {
showSuccessNotification({
title: t(`action.${action}.notification.success.title`),
message: t(`action.${action}.notification.success.message`),
});
},
onError() {
showErrorNotification({
title: t(`action.${action}.notification.error.title`),
message: t(`action.${action}.notification.error.message`),
});
},
},
);
};
return (
<Group wrap="nowrap" gap="xs">
<Tooltip label={row.original.state === "running" ? t("action.stop.label") : t("action.start.label")}>
<ActionIcon
variant="subtle"
size="xs"
radius="100%"
onClick={() => handleActionAsync(row.original.state === "running" ? "stop" : "start")}
>
{row.original.state === "running" ? (
<IconPlayerStop style={actionIconIconStyle} />
) : (
<IconPlayerPlay style={actionIconIconStyle} />
)}
</ActionIcon>
</Tooltip>
<Tooltip label={t("action.restart.label")}>
<ActionIcon variant="subtle" size="xs" radius="100%" onClick={() => handleActionAsync("restart")}>
<IconRotateClockwise style={actionIconIconStyle} />
</ActionIcon>
</Tooltip>
</Group>
);
},
accessor: "actions",
title: "",
width: 44,
textAlign: "right",
render: (container) => <ContainerMenuButton container={container} handlers={handlers} />,
},
];
export default function DockerWidget({ options, width, isEditMode }: WidgetComponentProps<"dockerContainers">) {
const compareContainers = (
first: DockerContainer,
second: DockerContainer,
accessor: DataTableSortStatus<DockerContainer>["columnAccessor"],
) => {
if (accessor === "cpuUsage") return safeValue(first.cpuUsage) - safeValue(second.cpuUsage);
if (accessor === "memoryUsage") return safeValue(first.memoryUsage) - safeValue(second.memoryUsage);
const firstValue = first[accessor as "name" | "state" | "host"];
const secondValue = second[accessor as "name" | "state" | "host"];
return String(firstValue ?? "").localeCompare(String(secondValue ?? ""));
};
const useContainerAction = (action: ContainerAction) => {
const t = useScopedI18n("docker.action");
const utils = clientApi.useUtils();
return clientApi.docker[`${action}All`].useMutation({
async onSettled() {
await utils.docker.getContainers.invalidate();
},
onSuccess() {
showSuccessNotification({
title: t(`${action}.notification.success.title`),
message: t(`${action}.notification.success.message`),
});
},
onError() {
showErrorNotification({
title: t(`${action}.notification.error.title`),
message: t(`${action}.notification.error.message`),
});
},
});
};
export default function DockerWidget({
options,
width,
isEditMode,
boardId,
itemId,
setOptions,
}: WidgetComponentProps<"dockerContainers">) {
const t = useScopedI18n("docker");
const tWidget = useScopedI18n("widget.dockerContainers");
const { openModal } = useModalAction(AddDockerAppToHomarr);
const board = useOptionalBoard();
const { data: session } = useSession();
const hasChangeAccess = board ? constructBoardPermissions(board, session).hasChangeAccess : false;
const isTiny = width <= 256;
const { data, refetch, isFetching } = clientApi.docker.getContainers.useQuery();
const containers = data?.containers ?? [];
const containers = useMemo(() => data?.containers ?? [], [data?.containers]);
const timestamp = useMemo(() => data?.timestamp ?? new Date(), [data?.timestamp]);
const relativeTime = useTimeAgo(timestamp);
const totalContainers = containers.length;
const { mutate: startContainer } = useContainerAction("start");
const { mutate: stopContainer } = useContainerAction("stop");
const { mutate: restartContainer } = useContainerAction("restart");
const { mutate: removeContainer } = useContainerAction("remove");
const handleContainerAction = useCallback(
(action: ContainerAction, container: DockerContainer) => {
if (action === "start") startContainer({ ids: [container.id] });
else if (action === "stop") stopContainer({ ids: [container.id] });
else if (action === "restart") restartContainer({ ids: [container.id] });
else removeContainer({ ids: [container.id] });
},
[removeContainer, restartContainer, startContainer, stopContainer],
);
const handleOpenLogs = useCallback(
(container: DockerContainer) => window.location.assign(createContainerLogsPath(container)),
[],
);
const handleAddToHomarr = useCallback(
(container: DockerContainer) => openModal({ selectedContainers: [container] }),
[openModal],
);
const actionHandlers = useMemo<ContainerActionHandlers>(
() => ({
onAction: handleContainerAction,
onAddToHomarr: handleAddToHomarr,
onOpenLogs: handleOpenLogs,
}),
[handleAddToHomarr, handleContainerAction, handleOpenLogs],
);
const totals = useMemo(() => {
return containers.reduce(
(acc, container) => {
acc.cpu += safeValue(container.cpuUsage);
acc.memory += safeValue(container.memoryUsage);
return acc;
},
{ cpu: 0, memory: 0 },
);
}, [containers]);
const columns = useMemo(() => createColumns(t), [t]);
const columnVisibility: MRT_VisibilityState = {
name: options.columns.includes("name"),
state: options.columns.includes("state"),
host: options.columns.includes("host"),
cpuUsage: options.columns.includes("cpuUsage"),
memoryUsage: options.columns.includes("memoryUsage"),
actions: options.columns.includes("actions"),
};
const table = useTranslatedMantineReactTable({
columns,
data: containers,
enablePagination: false,
enableTopToolbar: false,
enableBottomToolbar: false,
enableColumnActions: false,
enableSorting: options.enableRowSorting && !isEditMode,
enableStickyHeader: false,
enableColumnOrdering: false,
enableRowSelection: false,
enableFullScreenToggle: false,
enableGlobalFilter: false,
enableDensityToggle: false,
enableFilters: false,
enableHiding: false,
initialState: {
sorting: [{ id: options.defaultSort, desc: options.descendingDefaultSort }],
density: "xs",
},
state: {
columnVisibility,
},
mantinePaperProps: {
flex: 1,
withBorder: false,
shadow: undefined,
},
mantineTableProps: {
className: "docker-widget-table",
style: {
tableLayout: "fixed",
},
},
mantineTableHeadProps: {
fz: "xs",
},
mantineTableHeadCellProps: {
p: 4,
},
mantineTableBodyCellProps: {
p: 4,
},
mantineTableContainerProps: {
style: {
height: "100%",
},
},
const { mutate: saveItemOptions } = clientApi.widget.options.saveItemOptions.useMutation({
onError: () =>
showErrorNotification({
title: tWidget("error.layoutSaveTitle"),
message: tWidget("error.layoutSaveMessage"),
}),
});
const persistLayout = useTableLayoutPersistence({
boardId,
hasChangeAccess,
itemId,
saveItemOptions,
setOptions,
});
if (options.columns.length === 0)
const defaultDirection = options.descendingDefaultSort ? "desc" : "asc";
const [sortStatus, setSortStatus] = useState<DataTableSortStatus<DockerContainer>>({
columnAccessor: options.defaultSort,
direction: defaultDirection,
});
useEffect(() => {
setSortStatus({ columnAccessor: options.defaultSort, direction: defaultDirection });
}, [defaultDirection, options.defaultSort]);
const sortedContainers = useMemo(() => {
const multiplier = sortStatus.direction === "desc" ? -1 : 1;
return [...containers].toSorted(
(first, second) => compareContainers(first, second, sortStatus.columnAccessor) * multiplier,
);
}, [containers, sortStatus]);
const totals = useMemo(
() =>
containers.reduce(
(acc, container) => {
acc.cpu += safeValue(container.cpuUsage);
acc.memory += safeValue(container.memoryUsage);
return acc;
},
{ cpu: 0, memory: 0 },
),
[containers],
);
const selectedColumns = useMemo(() => new Set<string>(options.columns), [options.columns]);
const columns = useMemo(() => {
const sortingEnabled = options.enableRowSorting && !isEditMode;
return createColumns(t, actionHandlers, sortingEnabled).filter(({ accessor }) =>
selectedColumns.has(String(accessor)),
);
}, [actionHandlers, isEditMode, options.enableRowSorting, selectedColumns, t]);
const { effectiveColumns, storeKey } = usePersistedTableLayout({
columns,
columnAccessors,
columnOrder: options.columnOrder,
columnWidths: options.columnWidths,
itemId,
storeKeyPrefix: "docker-containers",
onLayoutChange: persistLayout,
});
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
const closeContextMenu = useCallback(() => setContextMenu(null), []);
const handleContextMenu = useCallback(({ record, event }: { record: DockerContainer; event: React.MouseEvent }) => {
event.preventDefault();
setContextMenu({ x: event.clientX, y: event.clientY, container: record });
}, []);
if (options.columns.length === 0) {
return (
<Center h="100%">
<Text>{t("error.noColumns")}</Text>
</Center>
);
}
return (
<Stack gap={0} h="100%" display="flex">
<MantineReactTable table={table} />
<Stack gap={0} h="100%" style={{ overflow: "hidden" }}>
<Box style={{ flex: 1, minHeight: 0 }}>
<HomarrDataTable
isEditMode={isEditMode}
cellPadding={width < 400 ? "2px 8px" : "4px 8px"}
rowCursor="default"
fetching={isFetching && containers.length === 0}
fz={width < 400 ? "xs" : "sm"}
records={sortedContainers}
noRecordsText={tWidget("empty.noContainers")}
columns={effectiveColumns}
storeColumnsKey={storeKey}
sortStatus={sortStatus}
onSortStatusChange={options.enableRowSorting && !isEditMode ? setSortStatus : undefined}
idAccessor="id"
onRowContextMenu={isEditMode ? undefined : handleContextMenu}
onScroll={() => {
if (contextMenu) closeContextMenu();
}}
/>
</Box>
{!isTiny && (
<Group
justify="space-between"
style={{
borderTop: "0.0625rem solid var(--border-color)",
}}
p={4}
>
<Group justify="space-between" style={{ borderTop: "0.0625rem solid var(--border-color)" }} p={4} wrap="nowrap">
<Group gap={4} wrap="nowrap">
<IconBrandDocker size={20} style={{ flexShrink: 0 }} />
<Text size="sm" truncate="end">
{t("table.footer", { count: totalContainers.toString() })}
<Text size="sm" truncate>
{t("table.footer", { count: containers.length.toString() })}
</Text>
</Group>
<Group gap="sm" wrap="wrap" justify="flex-end" style={{ flex: 1, minWidth: 0 }}>
<Group gap="sm" wrap="nowrap" justify="flex-end" style={{ minWidth: 0 }}>
<Text size="sm" style={{ whiteSpace: "nowrap" }}>
{t("table.totalCpu", { cpu: totals.cpu.toFixed(2) })}
</Text>
<Text size="sm" style={{ whiteSpace: "nowrap" }}>
{t("table.totalMemory", { memory: formatBytes(totals.memory) })}
</Text>
<Tooltip label={t("table.refresh.lastUpdated", { when: relativeTime })}>
<ActionIcon
size="sm"
@@ -307,12 +359,161 @@ export default function DockerWidget({ options, width, isEditMode }: WidgetCompo
onClick={() => void refetch()}
aria-label={t("table.refresh.lastUpdated", { when: relativeTime })}
>
<IconRefresh style={actionIconIconStyle} />
<IconRefresh size={16} />
</ActionIcon>
</Tooltip>
</Group>
</Group>
)}
{contextMenu && <ContainerContextMenu state={contextMenu} handlers={actionHandlers} onClose={closeContextMenu} />}
</Stack>
);
}
function ContainerMenuButton({
container,
handlers,
}: {
container: DockerContainer;
handlers: ContainerActionHandlers;
}) {
const t = useScopedI18n("docker.action");
const [opened, setOpened] = useState(false);
const close = () => setOpened(false);
return (
<Menu opened={opened} onChange={setOpened} closeOnItemClick={false} withinPortal position="bottom-end" shadow="sm">
<Menu.Target>
<ActionIcon
variant="subtle"
color="gray"
size="sm"
aria-label={t("title")}
onClick={(event) => event.stopPropagation()}
>
<IconDots size={16} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown w={containerMenuWidth} miw={containerMenuWidth} maw={containerMenuWidth}>
<ContainerActionItems container={container} handlers={handlers} onClose={close} />
</Menu.Dropdown>
</Menu>
);
}
function ContainerActionItems({
container,
handlers,
onClose,
}: {
container: DockerContainer;
handlers: ContainerActionHandlers;
onClose: () => void;
}) {
const t = useScopedI18n("docker.action");
const [confirmRemove, setConfirmRemove] = useState(false);
const stateAction = container.state === "running" ? "stop" : "start";
const StateIcon = stateAction === "stop" ? IconPlayerStop : IconPlayerPlay;
const invokeAction = (action: ContainerAction) => {
handlers.onAction(action, container);
onClose();
};
return (
<>
<Menu.Label title={container.name} style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{container.name}
</Menu.Label>
<Menu.Item
leftSection={<IconFileText size={14} />}
onClick={() => {
handlers.onOpenLogs(container);
onClose();
}}
>
{t("logs.label")}
</Menu.Item>
<Menu.Divider />
<Menu.Item
color={stateAction === "start" ? "green" : "red"}
leftSection={<StateIcon size={14} />}
onClick={() => invokeAction(stateAction)}
>
{t(`${stateAction}.label`)}
</Menu.Item>
<Menu.Item color="orange" leftSection={<IconRotateClockwise size={14} />} onClick={() => invokeAction("restart")}>
{t("restart.label")}
</Menu.Item>
{!confirmRemove ? (
<Menu.Item color="red" leftSection={<IconTrash size={14} />} onClick={() => setConfirmRemove(true)}>
{t("remove.label")}
</Menu.Item>
) : (
<Menu.Item color="red" leftSection={<IconTrash size={14} />} onClick={() => invokeAction("remove")}>
{t("remove.confirm")}
</Menu.Item>
)}
<Menu.Divider />
<Menu.Item
leftSection={<IconCategoryPlus size={14} />}
onClick={() => {
handlers.onAddToHomarr(container);
onClose();
}}
>
{t("addToHomarr.label")}
</Menu.Item>
</>
);
}
function ContainerContextMenu({
state,
handlers,
onClose,
}: {
state: ContextMenuState;
handlers: ContainerActionHandlers;
onClose: () => void;
}) {
return (
<Portal>
<Box
pos="fixed"
top={0}
left={0}
w="100vw"
h="100vh"
style={{ zIndex: 1000 }}
onClick={onClose}
onContextMenu={(event) => {
event.preventDefault();
onClose();
}}
>
<Menu
opened
shadow="md"
position="bottom-start"
withinPortal={false}
closeOnItemClick={false}
onClose={onClose}
>
<Menu.Target>
<Box pos="fixed" style={{ left: state.x, top: state.y, width: 1, height: 1 }} />
</Menu.Target>
<Menu.Dropdown
w={containerMenuWidth}
miw={containerMenuWidth}
maw={containerMenuWidth}
onClick={(event) => event.stopPropagation()}
>
<ContainerActionItems container={state.container} handlers={handlers} onClose={onClose} />
</Menu.Dropdown>
</Menu>
</Box>
</Portal>
);
}
+30 -22
View File
@@ -27,29 +27,37 @@ export const { definition, componentLoader } = createWidgetDefinition("dockerCon
icon: IconBrandDocker,
queryKey: [["docker", "getContainers"]],
createOptions() {
return optionsBuilder.from((factory) => ({
columns: factory.multiSelect({
defaultValue: [...allColumnsList],
options: allColumnsList.map((value) => ({
value,
label: (t) => t(columnTranslationKeyMap[value]),
})),
searchable: true,
return optionsBuilder.from(
(factory) => ({
columns: factory.multiSelect({
defaultValue: [...allColumnsList],
options: allColumnsList.map((value) => ({
value,
label: (t) => t(columnTranslationKeyMap[value]),
})),
searchable: true,
}),
enableRowSorting: factory.switch({
defaultValue: false,
}),
defaultSort: factory.select({
defaultValue: "name",
options: columnsList.map((value) => ({
value,
label: (t) => t(`widget.dockerContainers.option.defaultSort.option.${value}`),
})),
}),
descendingDefaultSort: factory.switch({
defaultValue: false,
}),
columnOrder: factory.text({ defaultValue: "" }),
columnWidths: factory.text({ defaultValue: "" }),
}),
enableRowSorting: factory.switch({
defaultValue: false,
}),
defaultSort: factory.select({
defaultValue: "name",
options: columnsList.map((value) => ({
value,
label: (t) => t(`widget.dockerContainers.option.defaultSort.option.${value}`),
})),
}),
descendingDefaultSort: factory.switch({
defaultValue: false,
}),
}));
{
columnOrder: { shouldHide: () => true },
columnWidths: { shouldHide: () => true },
},
);
},
errors: {
INTERNAL_SERVER_ERROR: {
+45 -94
View File
@@ -3,7 +3,7 @@
import "../widgets-common.css";
import "./styles.css";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import {
ActionIcon,
Avatar,
@@ -41,10 +41,11 @@ import {
import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
import type { DataTableColumn, DataTableSortStatus } from "mantine-datatable";
import { DataTable, useDataTableColumns } from "mantine-datatable";
import { clientApi } from "@homarr/api/client";
import { useIntegrationsWithInteractAccess } from "@homarr/auth/client";
import { useIntegrationsWithInteractAccess, useSession } from "@homarr/auth/client";
import { constructBoardPermissions } from "@homarr/auth/shared";
import { useOptionalBoard } from "@homarr/boards/context";
import { formatByteRate, formatBytes, useIntegrationConnected } from "@homarr/common";
import { getIconUrl, getIntegrationKindsByCategory } from "@homarr/definitions";
import type { ExtendedClientStatus, ExtendedDownloadClientItem } from "@homarr/integrations";
@@ -52,6 +53,8 @@ import { showErrorNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition";
import { HomarrDataTable } from "../common/homarr-data-table";
import { usePersistedTableLayout, useTableLayoutPersistence } from "../common/use-persisted-table-layout";
dayjs.extend(relativeTime);
@@ -162,14 +165,23 @@ function progressColor(state: DownloadState, progress: number): string {
return "blue";
}
function parseJsonOption<T>(value: string | undefined, fallback: T): T {
if (!value) return fallback;
try {
return JSON.parse(value) as T;
} catch {
return fallback;
}
}
const columnAccessors = [
"name",
"progress",
"size",
"downSpeed",
"upSpeed",
"time",
"state",
"added",
"ratio",
"received",
"sent",
"category",
"integration",
"index",
"type",
] as const;
function toCategoryArray(category: string | string[]): string[] {
if (Array.isArray(category)) return category;
@@ -288,6 +300,9 @@ export default function DownloadClientsWidget({
width,
}: WidgetComponentProps<"downloads">) {
const allInteractAccess = useIntegrationsWithInteractAccess();
const board = useOptionalBoard();
const { data: session } = useSession();
const hasChangeAccess = board ? constructBoardPermissions(board, session).hasChangeAccess : false;
const integrationInteractSet = useMemo(
() => new Set(allInteractAccess.filter(({ id }) => integrationIds.includes(id)).map(({ id }) => id)),
[allInteractAccess, integrationIds],
@@ -323,19 +338,13 @@ export default function DownloadClientsWidget({
const { mutate: saveItemOptions } = clientApi.widget.options.saveItemOptions.useMutation({
onError: () => showErrorNotification({ title: t("errors.actionFailed"), message: t("errors.actionFailedMessage") }),
});
const persistOption = useCallback(
(newOptions: Partial<Record<string, string>>) => {
setOptions({ newOptions });
if (boardId && itemId) saveItemOptions({ boardId, itemId, newOptions });
},
[setOptions, boardId, itemId, saveItemOptions],
);
const savedOrder = useMemo(() => parseJsonOption(options.columnOrder, [] as string[]), [options.columnOrder]);
const savedWidths = useMemo(
() => parseJsonOption(options.columnWidths, {} as Record<string, number>),
[options.columnWidths],
);
const persistOption = useTableLayoutPersistence({
boardId,
hasChangeAccess,
itemId,
saveItemOptions,
setOptions,
});
let defaultSortDirection: DataTableSortStatus<ExtendedDownloadClientItem>["direction"] = "asc";
if (options.descendingDefaultSort) defaultSortDirection = "desc";
@@ -659,54 +668,15 @@ export default function DownloadClientsWidget({
return cols.filter(Boolean) as DataTableColumn<ExtendedDownloadClientItem>[];
}, [columnContext, t, size, progressColumnWidth]);
const storeKey = `downloads-${itemId ?? "preview"}-${[...options.columns].toSorted().join(",")}`;
const { effectiveColumns, columnsOrder, columnsWidth, setColumnsOrder, setMultipleColumnWidths } =
useDataTableColumns<ExtendedDownloadClientItem>({
key: storeKey,
columns,
});
const lastStoreKey = useRef(storeKey);
const hydrated = useRef(false);
useEffect(() => {
if (lastStoreKey.current !== storeKey) {
lastStoreKey.current = storeKey;
hydrated.current = false;
}
if (hydrated.current) return;
if (savedOrder.length > 0) setColumnsOrder(savedOrder);
if (Object.keys(savedWidths).length > 0) {
setMultipleColumnWidths(Object.entries(savedWidths).map(([accessor, w]) => ({ accessor, width: w })));
}
// ponytail: defer hydrated flag so the persist effect skips the restore echo
requestAnimationFrame(() => {
hydrated.current = true;
});
}, [storeKey, savedOrder, savedWidths, setColumnsOrder, setMultipleColumnWidths]);
const prevOrder = useRef(columnsOrder);
const prevWidths = useRef(columnsWidth);
useEffect(() => {
if (!hydrated.current) return;
const orderChanged = JSON.stringify(columnsOrder) !== JSON.stringify(prevOrder.current);
const widthsChanged = JSON.stringify(columnsWidth) !== JSON.stringify(prevWidths.current);
prevOrder.current = columnsOrder;
prevWidths.current = columnsWidth;
if (!orderChanged && !widthsChanged) return;
if (orderChanged) persistOption({ columnOrder: JSON.stringify(columnsOrder) });
if (widthsChanged) {
const widthMap: Record<string, number> = {};
for (const entry of columnsWidth) {
const key = Object.keys(entry)[0];
if (!key) continue;
const w = entry[key as keyof typeof entry];
if (typeof w === "number") widthMap[key] = w;
else if (typeof w === "string" && w.endsWith("px")) widthMap[key] = parseInt(w, 10);
}
persistOption({ columnWidths: JSON.stringify(widthMap) });
}
}, [columnsOrder, columnsWidth, persistOption]);
const { effectiveColumns, storeKey } = usePersistedTableLayout({
columns,
columnAccessors,
columnOrder: options.columnOrder,
columnWidths: options.columnWidths,
itemId,
storeKeyPrefix: "downloads",
onLayoutChange: persistOption,
});
const handleContextMenu = useCallback(
({ record, event }: { record: ExtendedDownloadClientItem; event: React.MouseEvent }) => {
@@ -761,9 +731,6 @@ export default function DownloadClientsWidget({
);
}
let tablePointerEvents: React.CSSProperties["pointerEvents"];
if (isEditMode) tablePointerEvents = "none";
let rowContextMenuHandler: typeof handleContextMenu | undefined = handleContextMenu;
if (isEditMode) rowContextMenuHandler = undefined;
@@ -781,35 +748,19 @@ export default function DownloadClientsWidget({
)}
<Box style={{ flex: 1, minHeight: 0, position: "relative" }}>
<DataTable
style={{ pointerEvents: tablePointerEvents }}
withTableBorder={false}
borderRadius={0}
highlightOnHover
striped="odd"
stripedColor={{ dark: "dark.7", light: "gray.0" }}
highlightOnHoverColor={{ dark: "dark.5", light: "gray.1" }}
verticalAlign="center"
<HomarrDataTable
isEditMode={isEditMode}
cellPadding={`${size.cellPadding}px 8px`}
fetching={isFetching && currentItems.length === 0}
loaderBackgroundBlur={2}
fz={size.fontSize}
records={sortedData}
columns={effectiveColumns}
storeColumnsKey={storeKey}
textSelectionDisabled
sortStatus={sortStatus}
onSortStatusChange={setSortStatus}
noRecordsText={t("errors.noItems")}
idAccessor={(record) => `${record.integration.id}:${record.id}`}
height="100%"
className="downloads-table"
defaultColumnProps={{
noWrap: true,
draggable: true,
resizable: true,
cellsStyle: () => ({ padding: `${size.cellPadding}px 8px` }),
}}
scrollAreaProps={{ type: "auto", scrollbarSize: 6 }}
customRowAttributes={(_, index) => ({ "data-row-index": index })}
onRowContextMenu={rowContextMenuHandler}
rowBackgroundColor={(record) => rowBgAlpha[record.state]}
-48
View File
@@ -1,51 +1,3 @@
/* Make the DataTable transparent so the board card's opacity-controlled
background shows through. The mantine-datatable defaults to
var(--mantine-color-body) which is opaque. */
.downloads-table,
.downloads-table .mantine-datatable-table,
.downloads-table .mantine-datatable-table thead,
.downloads-table .mantine-datatable-table tbody,
.downloads-table .mantine-datatable-table tfoot,
.downloads-table th,
.downloads-table td {
background-color: transparent !important;
}
.downloads-table .mantine-datatable-table tr {
background-color: transparent;
}
.downloads-table .mantine-datatable-table tbody tr:hover {
background-color: color-mix(in srgb, var(--mantine-color-default-hover) 40%, transparent) !important;
}
.downloads-table th {
white-space: nowrap;
background-color: color-mix(in srgb, var(--mantine-color-body) 60%, transparent) !important;
backdrop-filter: blur(8px);
}
.downloads-table td {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.downloads-table td .mantine-Text-root,
.downloads-table td .mantine-Group-root {
white-space: nowrap;
flex-wrap: nowrap;
}
.downloads-table .mantine-datatable-header-cell-sortable-icon {
opacity: 0;
transition: opacity 150ms;
}
.downloads-table th:hover .mantine-datatable-header-cell-sortable-icon {
opacity: 1;
}
/* Row expansion styling */
.downloads-table .mantine-datatable-row-expansion-cell {
background-color: color-mix(in srgb, var(--mantine-color-body) 40%, transparent) !important;
+78 -21
View File
@@ -26,6 +26,35 @@ import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
import type { WidgetComponentProps } from "../definition";
type TranscodingDecision = NonNullable<
NonNullable<StreamSession["currentlyPlaying"]>["metadata"]
>["transcoding"];
type PlaybackStatus = "directPlay" | "directStream" | "transcodeVideo" | "transcodeAudio" | "transcoding";
function getPlaybackStatus(transcoding: TranscodingDecision | undefined): PlaybackStatus {
if (!transcoding) return "directPlay";
const { isVideoDirect, isAudioDirect, containerChanged } = transcoding;
if (isVideoDirect && isAudioDirect) return containerChanged ? "directStream" : "directPlay";
if (!isVideoDirect && isAudioDirect) return "transcodeVideo";
if (isVideoDirect && !isAudioDirect) return "transcodeAudio";
return "transcoding";
}
const playbackStatusColorMap = {
directPlay: "green",
directStream: "teal",
transcodeVideo: "orange",
transcodeAudio: "yellow",
transcoding: "red",
} satisfies Record<PlaybackStatus, string>;
function formatBitrate(bitrateKbps: number | null | undefined): string | null {
if (!bitrateKbps || bitrateKbps <= 0) return null;
return bitrateKbps >= 1000 ? `${(bitrateKbps / 1000).toFixed(1)} Mbps` : `${Math.round(bitrateKbps)} kbps`;
}
export default function MediaServerWidget({ options, integrationIds }: WidgetComponentProps<"mediaServer">) {
const { data: currentStreams = [] } = clientApi.widget.mediaServer.getCurrentStreams.useQuery({
integrationIds,
@@ -122,23 +151,30 @@ export default function MediaServerWidget({ options, integrationIds }: WidgetCom
const currentlyPlaying = row.original.currentlyPlaying;
if (!currentlyPlaying) return null;
const isTranscoding = Boolean(
currentlyPlaying.metadata?.transcoding.target.videoCodec ??
currentlyPlaying.metadata?.transcoding.target.audioCodec ??
currentlyPlaying.metadata?.transcoding.container,
);
const status = getPlaybackStatus(currentlyPlaying.metadata?.transcoding);
const bitrateLabel = formatBitrate(currentlyPlaying.metadata?.bitrateKbps);
return (
<Stack gap={4} align="flex-start">
<Badge size="xs" variant="light" color={isTranscoding ? "orange" : "green"}>
{isTranscoding ? t("items.transcoding") : t("items.directPlay")}
<Badge size="xs" variant="light" color={playbackStatusColorMap[status]}>
{t(`items.${status}` as never)}
</Badge>
{currentlyPlaying.location && (
<Group gap={4} align="center">
{currentlyPlaying.location === "lan" ? <IconWifi size={12} /> : <IconWorld size={12} />}
<Text size="10px" c="dimmed" tt="uppercase">
{currentlyPlaying.location}
</Text>
{(currentlyPlaying.location ?? bitrateLabel) && (
<Group gap={4} align="center" justify="space-between" wrap="nowrap" w="100%">
<Group gap={4} align="center">
{currentlyPlaying.location &&
(currentlyPlaying.location === "lan" ? <IconWifi size={12} /> : <IconWorld size={12} />)}
{currentlyPlaying.location && (
<Text size="10px" c="dimmed" tt="uppercase">
{currentlyPlaying.location}
</Text>
)}
</Group>
{bitrateLabel && (
<Text size="10px" c="dimmed">
{bitrateLabel}
</Text>
)}
</Group>
)}
</Stack>
@@ -238,6 +274,12 @@ export default function MediaServerWidget({ options, integrationIds }: WidgetCom
};
});
const totalBitrateKbps = flatSessions.reduce(
(sum, session) => sum + (session.currentlyPlaying?.metadata?.bitrateKbps ?? 0),
0,
);
const totalBitrateLabel = options.showBitrate ? formatBitrate(totalBitrateKbps) : null;
return (
<Stack gap={0} h="100%" display="flex">
<MantineReactTable table={table} />
@@ -246,19 +288,34 @@ export default function MediaServerWidget({ options, integrationIds }: WidgetCom
h={30}
px="xs"
pr="md"
justify="flex-end"
justify="space-between"
style={{
borderTop: "1px solid var(--border-color)",
}}
>
{uniqueIntegrations.map((integration) => (
<Group key={integration.integrationKind} gap="xs" align="center">
<Avatar className="media-server-icon" src={integration.integrationIcon} radius={"xs"} size="xs" />
<Text className="media-server-name" size="sm">
{integration.integrationName}
<Group gap={4} wrap="nowrap">
<IconVideo size={16} style={{ flexShrink: 0 }} />
<Text size="sm" style={{ whiteSpace: "nowrap" }}>
{(t as unknown as (key: string, params?: { count: number }) => string)("footer.streams", {
count: flatSessions.length,
})}
</Text>
{totalBitrateLabel && (
<Text size="sm" c="dimmed" style={{ whiteSpace: "nowrap" }}>
{t("footer.totalBitrate", { bitrate: totalBitrateLabel })}
</Text>
</Group>
))}
)}
</Group>
<Group gap="xs">
{uniqueIntegrations.map((integration) => (
<Group key={integration.integrationKind} gap="xs" align="center">
<Avatar className="media-server-icon" src={integration.integrationIcon} radius={"xs"} size="xs" />
<Text className="media-server-name" size="sm">
{integration.integrationName}
</Text>
</Group>
))}
</Group>
</Group>
</Stack>
);
@@ -11,6 +11,8 @@ export const { componentLoader, definition } = createWidgetDefinition("mediaServ
createOptions() {
return optionsBuilder.from((factory) => ({
showOnlyPlaying: factory.switch({ defaultValue: true, withDescription: true }),
showBitrate: factory.switch({ defaultValue: true, withDescription: true }),
showLocation: factory.switch({ defaultValue: true, withDescription: true }),
}));
},
supportedIntegrations: getIntegrationKindsByCategory("mediaService"),
+1 -1
View File
@@ -1 +1 @@
[{"login":"ajnart","avatar_url":"https://avatars.githubusercontent.com/u/49837342?v=4"},{"login":"manuel-rw","avatar_url":"https://avatars.githubusercontent.com/u/30572287?v=4"},{"login":"Meierschlumpf","avatar_url":"https://avatars.githubusercontent.com/u/63781622?v=4"},{"login":"SeDemal","avatar_url":"https://avatars.githubusercontent.com/u/26098587?v=4"},{"login":"lammersbjorn","avatar_url":"https://avatars.githubusercontent.com/u/71191962?v=4"},{"login":"LarveyOfficial","avatar_url":"https://avatars.githubusercontent.com/u/39219859?v=4"},{"login":"c00ldude1oo","avatar_url":"https://avatars.githubusercontent.com/u/15677803?v=4"},{"login":"InterN0te","avatar_url":"https://avatars.githubusercontent.com/u/10882916?v=4"},{"login":"Aimsucks","avatar_url":"https://avatars.githubusercontent.com/u/1828486?v=4"},{"login":"JannesV","avatar_url":"https://avatars.githubusercontent.com/u/5984244?v=4"},{"login":"MauriceNino","avatar_url":"https://avatars.githubusercontent.com/u/18658092?v=4"},{"login":"hillaliy","avatar_url":"https://avatars.githubusercontent.com/u/950010?v=4"},{"login":"Skeeww","avatar_url":"https://avatars.githubusercontent.com/u/9639992?v=4"},{"login":"RichyHBM","avatar_url":"https://avatars.githubusercontent.com/u/5472275?v=4"},{"login":"Bonfire","avatar_url":"https://avatars.githubusercontent.com/u/5704760?v=4"},{"login":"wieli99","avatar_url":"https://avatars.githubusercontent.com/u/46794825?v=4"},{"login":"ishaanparlikar","avatar_url":"https://avatars.githubusercontent.com/u/43013132?v=4"},{"login":"Zareix","avatar_url":"https://avatars.githubusercontent.com/u/29818713?v=4"},{"login":"tuggan","avatar_url":"https://avatars.githubusercontent.com/u/4325608?v=4"},{"login":"Spillebulle","avatar_url":"https://avatars.githubusercontent.com/u/46653946?v=4"},{"login":"marektancak0106","avatar_url":"https://avatars.githubusercontent.com/u/25422667?v=4"},{"login":"spkesDE","avatar_url":"https://avatars.githubusercontent.com/u/1682314?v=4"},{"login":"j3lte","avatar_url":"https://avatars.githubusercontent.com/u/2557568?v=4"},{"login":"dslatt","avatar_url":"https://avatars.githubusercontent.com/u/5211001?v=4"},{"login":"Malong11-007","avatar_url":"https://avatars.githubusercontent.com/u/40298510?v=4"},{"login":"StefanB7","avatar_url":"https://avatars.githubusercontent.com/u/22056509?v=4"},{"login":"souravghosh01","avatar_url":"https://avatars.githubusercontent.com/u/37548139?v=4"},{"login":"SR3u","avatar_url":"https://avatars.githubusercontent.com/u/2380522?v=4"},{"login":"Rikpat","avatar_url":"https://avatars.githubusercontent.com/u/33869814?v=4"},{"login":"NormanJS","avatar_url":"https://avatars.githubusercontent.com/u/83409549?v=4"},{"login":"No-Maines-Land","avatar_url":"https://avatars.githubusercontent.com/u/20687725?v=4"},{"login":"stark1tty","avatar_url":"https://avatars.githubusercontent.com/u/69326376?v=4"},{"login":"Momcilo42","avatar_url":"https://avatars.githubusercontent.com/u/10118137?v=4"},{"login":"marius-arch","avatar_url":"https://avatars.githubusercontent.com/u/72494359?v=4"},{"login":"Lumilias","avatar_url":"https://avatars.githubusercontent.com/u/10852161?v=4"},{"login":"Havock94","avatar_url":"https://avatars.githubusercontent.com/u/7635248?v=4"},{"login":"Lollyde","avatar_url":"https://avatars.githubusercontent.com/u/11347524?v=4"},{"login":"krishnamuppaneni","avatar_url":"https://avatars.githubusercontent.com/u/4703366?v=4"},{"login":"devtobi","avatar_url":"https://avatars.githubusercontent.com/u/28538704?v=4"},{"login":"TyxTang","avatar_url":"https://avatars.githubusercontent.com/u/90897955?v=4"},{"login":"beanbag420","avatar_url":"https://avatars.githubusercontent.com/u/19630511?v=4"},{"login":"WillyJL","avatar_url":"https://avatars.githubusercontent.com/u/49810075?v=4"},{"login":"anonysoul","avatar_url":"https://avatars.githubusercontent.com/u/105715150?v=4"},{"login":"cospeedster","avatar_url":"https://avatars.githubusercontent.com/u/52749645?v=4"},{"login":"diederbert","avatar_url":"https://avatars.githubusercontent.com/u/162878798?v=4"},{"login":"digdilem","avatar_url":"https://avatars.githubusercontent.com/u/4052275?v=4"},{"login":"dinghino","avatar_url":"https://avatars.githubusercontent.com/u/14729459?v=4"},{"login":"gfomichev","avatar_url":"https://avatars.githubusercontent.com/u/1596146?v=4"},{"login":"gnattu","avatar_url":"https://avatars.githubusercontent.com/u/12995396?v=4"},{"login":"tuxpizza","avatar_url":"https://avatars.githubusercontent.com/u/84710786?v=4"},{"login":"xmarkurion","avatar_url":"https://avatars.githubusercontent.com/u/46654687?v=4"},{"login":"huanga","avatar_url":"https://avatars.githubusercontent.com/u/732970?v=4"},{"login":"taos15","avatar_url":"https://avatars.githubusercontent.com/u/122130728?v=4"},{"login":"arghyadipchak","avatar_url":"https://avatars.githubusercontent.com/u/32430374?v=4"},{"login":"AuthorShin","avatar_url":"https://avatars.githubusercontent.com/u/4959043?v=4"},{"login":"benniblot","avatar_url":"https://avatars.githubusercontentLine truncated
[{"login":"ajnart","avatar_url":"https://avatars.githubusercontent.com/u/49837342?v=4"},{"login":"manuel-rw","avatar_url":"https://avatars.githubusercontent.com/u/30572287?v=4"},{"login":"Meierschlumpf","avatar_url":"https://avatars.githubusercontent.com/u/63781622?v=4"},{"login":"SeDemal","avatar_url":"https://avatars.githubusercontent.com/u/26098587?v=4"},{"login":"lammersbjorn","avatar_url":"https://avatars.githubusercontent.com/u/71191962?v=4"},{"login":"LarveyOfficial","avatar_url":"https://avatars.githubusercontent.com/u/39219859?v=4"},{"login":"c00ldude1oo","avatar_url":"https://avatars.githubusercontent.com/u/15677803?v=4"},{"login":"InterN0te","avatar_url":"https://avatars.githubusercontent.com/u/10882916?v=4"},{"login":"Aimsucks","avatar_url":"https://avatars.githubusercontent.com/u/1828486?v=4"},{"login":"JannesV","avatar_url":"https://avatars.githubusercontent.com/u/5984244?v=4"},{"login":"MauriceNino","avatar_url":"https://avatars.githubusercontent.com/u/18658092?v=4"},{"login":"hillaliy","avatar_url":"https://avatars.githubusercontent.com/u/950010?v=4"},{"login":"Skeeww","avatar_url":"https://avatars.githubusercontent.com/u/9639992?v=4"},{"login":"RichyHBM","avatar_url":"https://avatars.githubusercontent.com/u/5472275?v=4"},{"login":"Bonfire","avatar_url":"https://avatars.githubusercontent.com/u/5704760?v=4"},{"login":"wieli99","avatar_url":"https://avatars.githubusercontent.com/u/46794825?v=4"},{"login":"ishaanparlikar","avatar_url":"https://avatars.githubusercontent.com/u/43013132?v=4"},{"login":"Zareix","avatar_url":"https://avatars.githubusercontent.com/u/29818713?v=4"},{"login":"tuggan","avatar_url":"https://avatars.githubusercontent.com/u/4325608?v=4"},{"login":"Spillebulle","avatar_url":"https://avatars.githubusercontent.com/u/46653946?v=4"},{"login":"marektancak0106","avatar_url":"https://avatars.githubusercontent.com/u/25422667?v=4"},{"login":"spkesDE","avatar_url":"https://avatars.githubusercontent.com/u/1682314?v=4"},{"login":"j3lte","avatar_url":"https://avatars.githubusercontent.com/u/2557568?v=4"},{"login":"dslatt","avatar_url":"https://avatars.githubusercontent.com/u/5211001?v=4"},{"login":"Malong11-007","avatar_url":"https://avatars.githubusercontent.com/u/40298510?v=4"},{"login":"StefanB7","avatar_url":"https://avatars.githubusercontent.com/u/22056509?v=4"},{"login":"souravghosh01","avatar_url":"https://avatars.githubusercontent.com/u/37548139?v=4"},{"login":"SR3u","avatar_url":"https://avatars.githubusercontent.com/u/2380522?v=4"},{"login":"Rikpat","avatar_url":"https://avatars.githubusercontent.com/u/33869814?v=4"},{"login":"NormanJS","avatar_url":"https://avatars.githubusercontent.com/u/83409549?v=4"},{"login":"No-Maines-Land","avatar_url":"https://avatars.githubusercontent.com/u/20687725?v=4"},{"login":"stark1tty","avatar_url":"https://avatars.githubusercontent.com/u/69326376?v=4"},{"login":"Momcilo42","avatar_url":"https://avatars.githubusercontent.com/u/10118137?v=4"},{"login":"marius-arch","avatar_url":"https://avatars.githubusercontent.com/u/72494359?v=4"},{"login":"Lumilias","avatar_url":"https://avatars.githubusercontent.com/u/10852161?v=4"},{"login":"Havock94","avatar_url":"https://avatars.githubusercontent.com/u/7635248?v=4"},{"login":"Lollyde","avatar_url":"https://avatars.githubusercontent.com/u/11347524?v=4"},{"login":"krishnamuppaneni","avatar_url":"https://avatars.githubusercontent.com/u/4703366?v=4"},{"login":"devtobi","avatar_url":"https://avatars.githubusercontent.com/u/28538704?v=4"},{"login":"TyxTang","avatar_url":"https://avatars.githubusercontent.com/u/90897955?v=4"},{"login":"beanbag420","avatar_url":"https://avatars.githubusercontent.com/u/19630511?v=4"},{"login":"WillyJL","avatar_url":"https://avatars.githubusercontent.com/u/49810075?v=4"},{"login":"anonysoul","avatar_url":"https://avatars.githubusercontent.com/u/105715150?v=4"},{"login":"cospeedster","avatar_url":"https://avatars.githubusercontent.com/u/52749645?v=4"},{"login":"diederbert","avatar_url":"https://avatars.githubusercontent.com/u/162878798?v=4"},{"login":"digdilem","avatar_url":"https://avatars.githubusercontent.com/u/4052275?v=4"},{"login":"dinghino","avatar_url":"https://avatars.githubusercontent.com/u/14729459?v=4"},{"login":"gfomichev","avatar_url":"https://avatars.githubusercontent.com/u/1596146?v=4"},{"login":"gnattu","avatar_url":"https://avatars.githubusercontent.com/u/12995396?v=4"},{"login":"tuxpizza","avatar_url":"https://avatars.githubusercontent.com/u/84710786?v=4"},{"login":"xmarkurion","avatar_url":"https://avatars.githubusercontent.com/u/46654687?v=4"},{"login":"huanga","avatar_url":"https://avatars.githubusercontent.com/u/732970?v=4"},{"login":"taos15","avatar_url":"https://avatars.githubusercontent.com/u/122130728?v=4"},{"login":"arghyadipchak","avatar_url":"https://avatars.githubusercontent.com/u/32430374?v=4"},{"login":"AuthorShin","avatar_url":"https://avatars.githubusercontent.com/u/4959043?v=4"},{"login":"benniblot","avatar_url":"https://avatars.githubusercontentLine truncated
+1 -1
View File
@@ -1 +1 @@
[{"id":"zaxon793-jy8gpl0v-d89pbrkd-emwl5v04","name":"Mike","slug":"guest-5da646e8","imageUrl":"https://images.opencollective.com/guest-5da646e8/avatar.png"},{"id":"ywz9j4av-god8pgwg-db36mr35-nxklb0e7","name":"Guest","slug":"guest-554ec68f","imageUrl":"https://images.opencollective.com/guest-554ec68f/avatar.png"},{"id":"mlo94zn7-x08dpo09-xlxpewga-3vjbrky5","name":"Robert Hermann","slug":"robert-hermann","imageUrl":"https://images.opencollective.com/robert-hermann/avatar.png"},{"id":"zaxon793-jy8gpl0n-8r3pbrkd-emwl5v04","name":"Sebastian Raubuch","slug":"sebastian-raubuch","imageUrl":"https://images.opencollective.com/sebastian-raubuch/avatar.png"},{"id":"nmlo94zn-7x08dpod-ykm6ewga-3vjbrky5","name":"Didier LUTHI","slug":"didier-luthi","imageUrl":"https://images.opencollective.com/didier-luthi/avatar.png"},{"id":"vedj9wro-z3a56dgk-z3z67blg-8x4m0ykn","name":"Guest","slug":"guest-b1459a68","imageUrl":"https://images.opencollective.com/guest-b1459a68/avatar.png"},{"id":"gm9bnk80-437xqry4-8vepvzeo-ljdayw5r","name":"Mohister","slug":"mohister","imageUrl":"https://images.opencollective.com/mohister/1d8a592/avatar.png"},{"id":"vrgbk357-l4x96e70-m0apomew-a0jdyzn8","name":"sidens","slug":"sidens","imageUrl":"https://images.opencollective.com/sidens/avatar.png"},{"id":"mlo94zn7-x08dpo0d-l7epewga-3vjbrky5","name":"Chris","slug":"guest-937065bb","imageUrl":"https://images.opencollective.com/guest-937065bb/avatar.png"},{"id":"k9mbw7y4-8r3zq3ol-7zlq0ej5-lavnodgx","name":"Nick W","slug":"guest-7501840a","imageUrl":"https://images.opencollective.com/guest-7501840a/avatar.png"},{"id":"e0adkjrv-8xwm69o8-5al67z5n-4l93bgoy","name":"Jeppedy","slug":"guest-ab78e46a","imageUrl":"https://images.opencollective.com/guest-ab78e46a/avatar.png"},{"id":"a47byg9n-xozdp8xo-jgm6mjlv-03rek5w8","name":"naru","slug":"guest-02fd264a","imageUrl":"https://images.opencollective.com/guest-02fd264a/avatar.png"},{"id":"3k0exgzn-m8yj64g3-ll865wao-9r7b4dlv","name":"Jared Hocutt","slug":"jared-hocutt","imageUrl":"https://images.opencollective.com/jared-hocutt/avatar.png"},{"id":"eng0kzdy-vor4pz8l-exopbma8-37xlw95j","name":"Incognito","slug":"user-e63f4a5b","imageUrl":"https://images.opencollective.com/user-e63f4a5b/avatar.png"},{"id":"8k03reyd-5agmq50v-8vyplbwo-z7j4nxv9","name":"dazi","slug":"guest-11663a46","imageUrl":"https://images.opencollective.com/guest-11663a46/avatar.png"},{"id":"vedj9wro-z3a56dgd-zwl67blg-8x4m0ykn","name":"ZoopaMario","slug":"zoopamario","imageUrl":"https://images.opencollective.com/zoopamario/bea900d/avatar.png"},{"id":"a47byg9n-xozdp8xe-xmb6mjlv-03rek5w8","name":"FlamebergUA","slug":"guest-a59fd500","imageUrl":"https://images.opencollective.com/guest-a59fd500/avatar.png"},{"id":"8k03reyd-5agmq50y-kzoplbwo-z7j4nxv9","name":"StLCards","slug":"guest-e1de33dc","imageUrl":"https://images.opencollective.com/guest-e1de33dc/avatar.png"},{"id":"ejoxl3az-45w9pa54-dk46y870-mgkbrvdn","name":"Varadha","slug":"guest-6f68eed9","imageUrl":"https://images.opencollective.com/guest-6f68eed9/avatar.png"},{"id":"jrkx5lmn-v904qjl0-3oz68bwa-7zdygoe3","name":"Guest","slug":"guest-6da419b5","imageUrl":"https://images.opencollective.com/guest-6da419b5/avatar.png"},{"id":"mywxoz34-09rl6k3z-lmlqvenb-dj7gk85a","name":"Guest","slug":"guest-122d7e0a","imageUrl":"https://images.opencollective.com/guest-122d7e0a/avatar.png"},{"id":"3z8arxve-ymko60vm-3eaqgl5n-bj9w704d","name":"Ricardo","slug":"ricardo34","imageUrl":"https://images.opencollective.com/ricardo34/avatar.png"},{"id":"e0adkjrv-8xwm69or-reo67z5n-4l93bgoy","name":"GoM","slug":"guest-3944feab","imageUrl":"https://images.opencollective.com/guest-3944feab/avatar.png"},{"id":"gm9bnk80-437xqrnr-r5o6vzeo-ljdayw5r","name":"Brent Maxwell","slug":"brentmaxwell","imageUrl":"https://images.opencollective.com/brentmaxwell/avatar.png"},{"id":"gnxdwzj3-le5mpwzd-ll0py8rv-bko04a97","name":"Eric G","slug":"guest-478270f1","imageUrl":"https://images.opencollective.com/guest-478270f1/avatar.png"},{"id":"v349mrwg-z75lpyjo-ez8pa08d-jeybknox","name":"ninja","slug":"guest-0bb6ba36","imageUrl":"https://images.opencollective.com/guest-0bb6ba36/avatar.png"},{"id":"ywz9j4av-god8pgw0-53y6mr35-nxklb0e7","name":"s11","slug":"guest-6e9a2588","imageUrl":"https://images.opencollective.com/guest-6e9a2588/avatar.png"},{"id":"mlo94zn7-x08dpo30-v73pewga-3vjbrky5","name":"Incognito","slug":"user-cbad11af","imageUrl":"https://images.opencollective.com/user-cbad11af/avatar.png"},{"id":"v349mrwg-z75lpy7l-yzgpa08d-jeybknox","name":"Ronald Simmons","slug":"ronald-simmons","imageUrl":"https://images.opencollective.com/ronald-simmons/avatar.png"},{"id":"7ywz9j4a-vgod8pgl-me9qmr35-nxklb0e7","name":"Joel","slug":"guest-3376ab02","imageUrl":"https://images.opencollective.com/guest-3376ab02/avatar.png"},{"id":"v349mrwg-z75lpyjw-kxzpa08d-jeybknox","name":"Gokay Yamankurt","slug":"gokay-yamankurt","imageUrl":"https://images.opencollective.com/gokay-yamankurt/avatar.png"},{"id":"v349mrwg-z75lpyjv-59rpa08d-jeybknox","name":"Rhiz3K","slug":"rhiz3k","imagLine truncated
[{"id":"zaxon793-jy8gpl0v-d89pbrkd-emwl5v04","name":"Mike","slug":"guest-5da646e8","imageUrl":"https://images.opencollective.com/guest-5da646e8/avatar.png"},{"id":"ywz9j4av-god8pgwg-db36mr35-nxklb0e7","name":"Guest","slug":"guest-554ec68f","imageUrl":"https://images.opencollective.com/guest-554ec68f/avatar.png"},{"id":"mlo94zn7-x08dpo09-xlxpewga-3vjbrky5","name":"Robert Hermann","slug":"robert-hermann","imageUrl":"https://images.opencollective.com/robert-hermann/avatar.png"},{"id":"zaxon793-jy8gpl0n-8r3pbrkd-emwl5v04","name":"Sebastian Raubuch","slug":"sebastian-raubuch","imageUrl":"https://images.opencollective.com/sebastian-raubuch/avatar.png"},{"id":"nmlo94zn-7x08dpod-ykm6ewga-3vjbrky5","name":"Didier LUTHI","slug":"didier-luthi","imageUrl":"https://images.opencollective.com/didier-luthi/avatar.png"},{"id":"vedj9wro-z3a56dgk-z3z67blg-8x4m0ykn","name":"Guest","slug":"guest-b1459a68","imageUrl":"https://images.opencollective.com/guest-b1459a68/avatar.png"},{"id":"gm9bnk80-437xqry4-8vepvzeo-ljdayw5r","name":"Mohister","slug":"mohister","imageUrl":"https://images.opencollective.com/mohister/1d8a592/avatar.png"},{"id":"vrgbk357-l4x96e70-m0apomew-a0jdyzn8","name":"sidens","slug":"sidens","imageUrl":"https://images.opencollective.com/sidens/avatar.png"},{"id":"mlo94zn7-x08dpo0d-l7epewga-3vjbrky5","name":"Chris","slug":"guest-937065bb","imageUrl":"https://images.opencollective.com/guest-937065bb/avatar.png"},{"id":"k9mbw7y4-8r3zq3ol-7zlq0ej5-lavnodgx","name":"Nick W","slug":"guest-7501840a","imageUrl":"https://images.opencollective.com/guest-7501840a/avatar.png"},{"id":"e0adkjrv-8xwm69o8-5al67z5n-4l93bgoy","name":"Jeppedy","slug":"guest-ab78e46a","imageUrl":"https://images.opencollective.com/guest-ab78e46a/avatar.png"},{"id":"a47byg9n-xozdp8xo-jgm6mjlv-03rek5w8","name":"naru","slug":"guest-02fd264a","imageUrl":"https://images.opencollective.com/guest-02fd264a/avatar.png"},{"id":"3k0exgzn-m8yj64g3-ll865wao-9r7b4dlv","name":"Jared Hocutt","slug":"jared-hocutt","imageUrl":"https://images.opencollective.com/jared-hocutt/avatar.png"},{"id":"eng0kzdy-vor4pz8l-exopbma8-37xlw95j","name":"Incognito","slug":"user-e63f4a5b","imageUrl":"https://images.opencollective.com/user-e63f4a5b/avatar.png"},{"id":"8k03reyd-5agmq50v-8vyplbwo-z7j4nxv9","name":"dazi","slug":"guest-11663a46","imageUrl":"https://images.opencollective.com/guest-11663a46/avatar.png"},{"id":"vedj9wro-z3a56dgd-zwl67blg-8x4m0ykn","name":"ZoopaMario","slug":"zoopamario","imageUrl":"https://images.opencollective.com/zoopamario/bea900d/avatar.png"},{"id":"a47byg9n-xozdp8xe-xmb6mjlv-03rek5w8","name":"FlamebergUA","slug":"guest-a59fd500","imageUrl":"https://images.opencollective.com/guest-a59fd500/avatar.png"},{"id":"8k03reyd-5agmq50y-kzoplbwo-z7j4nxv9","name":"StLCards","slug":"guest-e1de33dc","imageUrl":"https://images.opencollective.com/guest-e1de33dc/avatar.png"},{"id":"ejoxl3az-45w9pa54-dk46y870-mgkbrvdn","name":"Varadha","slug":"guest-6f68eed9","imageUrl":"https://images.opencollective.com/guest-6f68eed9/avatar.png"},{"id":"jrkx5lmn-v904qjl0-3oz68bwa-7zdygoe3","name":"Guest","slug":"guest-6da419b5","imageUrl":"https://images.opencollective.com/guest-6da419b5/avatar.png"},{"id":"mywxoz34-09rl6k3z-lmlqvenb-dj7gk85a","name":"Guest","slug":"guest-122d7e0a","imageUrl":"https://images.opencollective.com/guest-122d7e0a/avatar.png"},{"id":"3z8arxve-ymko60vm-3eaqgl5n-bj9w704d","name":"Ricardo","slug":"ricardo34","imageUrl":"https://images.opencollective.com/ricardo34/avatar.png"},{"id":"e0adkjrv-8xwm69or-reo67z5n-4l93bgoy","name":"GoM","slug":"guest-3944feab","imageUrl":"https://images.opencollective.com/guest-3944feab/avatar.png"},{"id":"gm9bnk80-437xqrnr-r5o6vzeo-ljdayw5r","name":"Brent Maxwell","slug":"brentmaxwell","imageUrl":"https://images.opencollective.com/brentmaxwell/avatar.png"},{"id":"gnxdwzj3-le5mpwzd-ll0py8rv-bko04a97","name":"Eric G","slug":"guest-478270f1","imageUrl":"https://images.opencollective.com/guest-478270f1/avatar.png"},{"id":"v349mrwg-z75lpyjo-ez8pa08d-jeybknox","name":"ninja","slug":"guest-0bb6ba36","imageUrl":"https://images.opencollective.com/guest-0bb6ba36/avatar.png"},{"id":"ywz9j4av-god8pgw0-53y6mr35-nxklb0e7","name":"s11","slug":"guest-6e9a2588","imageUrl":"https://images.opencollective.com/guest-6e9a2588/avatar.png"},{"id":"mlo94zn7-x08dpo30-v73pewga-3vjbrky5","name":"Incognito","slug":"user-cbad11af","imageUrl":"https://images.opencollective.com/user-cbad11af/avatar.png"},{"id":"v349mrwg-z75lpy7l-yzgpa08d-jeybknox","name":"Ronald Simmons","slug":"ronald-simmons","imageUrl":"https://images.opencollective.com/ronald-simmons/avatar.png"},{"id":"7ywz9j4a-vgod8pgl-me9qmr35-nxklb0e7","name":"Joel","slug":"guest-3376ab02","imageUrl":"https://images.opencollective.com/guest-3376ab02/avatar.png"},{"id":"v349mrwg-z75lpyjw-kxzpa08d-jeybknox","name":"Gokay Yamankurt","slug":"gokay-yamankurt","imageUrl":"https://images.opencollective.com/gokay-yamankurt/avatar.png"},{"id":"v349mrwg-z75lpyjv-59rpa08d-jeybknox","name":"Rhiz3K","slug":"rhiz3k","imagLine truncated
+1 -1
View File
@@ -1 +1 @@
[{"username":"carlchina","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/6697/medium/d22bbe7797bbeb30dbdc73a5648d329a_default.png"},{"username":"zielmann","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/7795/medium/ad22b8b8d5eb33e4154d53a454c862fd_default.png"},{"username":"Thalyn","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/12494028/medium/5faf5acc945a874ed116c4567edaa09f_default.png"},{"username":"magnushj","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/12503488/medium/68f32e33ea7b891960055c9f831f7344_default.png"},{"username":"almontegil","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/12580457/medium/f4136cacbdfdb4c28ae7f85dc5f840db_default.png"},{"username":"andibing","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/12664938/medium/b8be63e4dcb2e791ced1ffc9e3a049a5.jpg"},{"username":"gronare","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/12680911/medium/c6eb14d1504156f34a158e507f73aa3d_default.png"},{"username":"SmartPhoneLover","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/12701640/medium/ec95486662ec875cda080e778c3ff702.jpg"},{"username":"marcelotk_15","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/12773227/medium/ecf6bea16ab0c873c131df2154ee9718_default.png"},{"username":"S3OD177","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13051544/medium/11dd990837f61f7e8f7d36b041a7fddb_default.png"},{"username":"marcg","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13153301/medium/ebcf60fbf27deb148ec40a659566c6ff_default.png"},{"username":"BeersTeddy","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13185230/medium/2f1f4e1effe74a23422b195cbefb2a95_default.png"},{"username":"jamesmcmahon0","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13245578/medium/586aa873b4abddbd9abc6f3de99ab70e.jpeg"},{"username":"b1nar10","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13286752/medium/aaa1f083e04c572f953fde4563171e4a.jpeg"},{"username":"vannCN","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13330448/medium/9b8a9ee3611e51e951e22d5fd4eb7d8d.jpg"},{"username":"binswm","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13343482/medium/45dde0e6097b9b72705d2eba9dbbc276_default.png"},{"username":"SkewRam","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13496556/medium/188f5c2deb7938eda51eb786cc4539ca.jpeg"},{"username":"raphcatarino","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13547726/medium/b003511e67df13a4b4b5689488fa8099.jpg"},{"username":"shillos5","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13600557/medium/b17650b2b84f829d54fa548c44c1ab27.jpeg"},{"username":"wolong98","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13641407/medium/f4634edc58c7857a357e5293543c15cf.jpg"},{"username":"spair0039","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14012333/medium/72430e96027c09c19141cac38eae4617.png"},{"username":"MajorMarcin","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14072183/medium/0a197e64bb92b6ea94cecbb607512c45.jpg"},{"username":"KosmoMoustache","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14103535/medium/51c438cdd9f86e8811c75f4869de479c.png"},{"username":"Soochaehwa","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14118689/medium/496a1ce63111547bf455a1e0a7ac75f1_default.png"},{"username":"garryfield","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14187035/medium/520c964706676045f6eeec1b08edc880.jpg"},{"username":"droidenko","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14444264/medium/4aa3a8e824f72bc9e5ec0d8de307095e.jpeg"},{"username":"maathis","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14489950/medium/6eeddf13a757ee35f78f1763f94d95e1.jpeg"},{"username":"bukvam","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14546502/medium/a819dc40a13c57395a17dc46e847d3ce.jpg"},{"username":"gm.cinalli","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14670666/medium/2d466a0fdbda40764526be86c97c0ab4.jpeg"},{"username":"antoine2tt","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14722148/medium/f88d926900862dd59007ea4b3419cb9d.png"},{"username":"cretzen","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14799754/medium/3ba5c1805ec20c000d5810c07985afe9.jpg"},{"username":"ktKongTong","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14817246/medium/97cfc3c028dbdaf85ebd1102da71e58c.jpeg"},{"username":"marinkaberg","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14861042/medium/2d5c4e62613f03082f3e645fa92efd59.jpeg"},{"username":"djismgaming","avatarUrl":"https://crowLine truncated
[{"username":"carlchina","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/6697/medium/d22bbe7797bbeb30dbdc73a5648d329a_default.png"},{"username":"zielmann","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/7795/medium/ad22b8b8d5eb33e4154d53a454c862fd_default.png"},{"username":"Thalyn","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/12494028/medium/5faf5acc945a874ed116c4567edaa09f_default.png"},{"username":"magnushj","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/12503488/medium/68f32e33ea7b891960055c9f831f7344_default.png"},{"username":"almontegil","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/12580457/medium/f4136cacbdfdb4c28ae7f85dc5f840db_default.png"},{"username":"andibing","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/12664938/medium/b8be63e4dcb2e791ced1ffc9e3a049a5.jpg"},{"username":"gronare","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/12680911/medium/c6eb14d1504156f34a158e507f73aa3d_default.png"},{"username":"SmartPhoneLover","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/12701640/medium/ec95486662ec875cda080e778c3ff702.jpg"},{"username":"marcelotk_15","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/12773227/medium/ecf6bea16ab0c873c131df2154ee9718_default.png"},{"username":"S3OD177","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13051544/medium/11dd990837f61f7e8f7d36b041a7fddb_default.png"},{"username":"marcg","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13153301/medium/ebcf60fbf27deb148ec40a659566c6ff_default.png"},{"username":"BeersTeddy","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13185230/medium/2f1f4e1effe74a23422b195cbefb2a95_default.png"},{"username":"jamesmcmahon0","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13245578/medium/586aa873b4abddbd9abc6f3de99ab70e.jpeg"},{"username":"b1nar10","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13286752/medium/aaa1f083e04c572f953fde4563171e4a.jpeg"},{"username":"vannCN","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13330448/medium/9b8a9ee3611e51e951e22d5fd4eb7d8d.jpg"},{"username":"binswm","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13343482/medium/45dde0e6097b9b72705d2eba9dbbc276_default.png"},{"username":"SkewRam","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13496556/medium/188f5c2deb7938eda51eb786cc4539ca.jpeg"},{"username":"raphcatarino","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13547726/medium/b003511e67df13a4b4b5689488fa8099.jpg"},{"username":"shillos5","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13600557/medium/b17650b2b84f829d54fa548c44c1ab27.jpeg"},{"username":"wolong98","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13641407/medium/f4634edc58c7857a357e5293543c15cf.jpg"},{"username":"spair0039","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14012333/medium/72430e96027c09c19141cac38eae4617.png"},{"username":"MajorMarcin","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14072183/medium/0a197e64bb92b6ea94cecbb607512c45.jpg"},{"username":"KosmoMoustache","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14103535/medium/51c438cdd9f86e8811c75f4869de479c.png"},{"username":"Soochaehwa","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14118689/medium/496a1ce63111547bf455a1e0a7ac75f1_default.png"},{"username":"garryfield","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14187035/medium/520c964706676045f6eeec1b08edc880.jpg"},{"username":"droidenko","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14444264/medium/4aa3a8e824f72bc9e5ec0d8de307095e.jpeg"},{"username":"maathis","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14489950/medium/6eeddf13a757ee35f78f1763f94d95e1.jpeg"},{"username":"bukvam","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14546502/medium/a819dc40a13c57395a17dc46e847d3ce.jpg"},{"username":"gm.cinalli","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14670666/medium/2d466a0fdbda40764526be86c97c0ab4.jpeg"},{"username":"antoine2tt","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14722148/medium/f88d926900862dd59007ea4b3419cb9d.png"},{"username":"cretzen","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14799754/medium/3ba5c1805ec20c000d5810c07985afe9.jpg"},{"username":"ktKongTong","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14817246/medium/97cfc3c028dbdaf85ebd1102da71e58c.jpeg"},{"username":"marinkaberg","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14861042/medium/2d5c4e62613f03082f3e645fa92efd59.jpeg"},{"username":"djismgaming","avatarUrl":"https://crowLine truncated
+103
View File
@@ -0,0 +1,103 @@
# Homarr Developer CLI
Build and launch local Homarr images and GHCR pull-request builds, then manage them from responsive terminal dashboards.
## Requirements
- Docker or OrbStack
- Go 1.25+ to build from source
- GitHub CLI authenticated with `gh auth login` for pull-request features
## Setup
```sh
pnpm install
```
Create `.env` from `.env.example` and set `DB_URL` to an absolute path for the SQLite file before running the migration. In a POSIX shell, use `cp .env.example .env`; in PowerShell, use `Copy-Item .env.example .env`.
Then run `pnpm db:migration:sqlite:run` to create or update the database. The migration command also seeds a new database. Use `pnpm db:seed` explicitly when you need to seed an existing database. Start Redis separately with `pnpm docker:dev:up` when it is needed.
The examples below use `pnpm dev:cli -- ...`, which works from POSIX shells and PowerShell. If you prefer a global command, install it with `pnpm dev:cli:install` and add your Go bin directory to `PATH`.
## Build
```sh
pnpm dev:cli -- build feature
pnpm dev:cli -- build --pr 6441
pnpm dev:cli -- rebuild feature
```
Checkout builds are tagged as `homarr:<name>`. The source checkout and revision are recorded on the image so `homarr rebuild` and the development dashboard can rebuild it later. PR builds use a temporary checkout and default to `homarr:pr-<number>`.
## Launch
```sh
pnpm dev:cli -- run dev
pnpm dev:cli -- run --detach dev
pnpm dev:cli -- run --pr 6441
pnpm dev:cli -- run --pr 6441 --demo
pnpm dev:cli -- run --env FOO=bar --env FEATURE=true dev
```
PR launches always pull the latest GHCR tag before starting. Local tags remain local.
The first launch generates a per-user encryption key in the OS config directory and reuses it for later launches. Set `HOMARR_DEV_SECRET_ENCRYPTION_KEY` to a 64-character hexadecimal key to override it.
The command reference below assumes the optional `homarr` installation. Without it, prefix each command with `pnpm dev:cli --` (for example, `pnpm dev:cli -- dev`).
## Commands
```text
homarr Interactive local and remote image browser
homarr dev Explicit alias for the main browser
homarr dash Start on the instance dashboard
homarr build <name> Build this checkout as homarr:<name>
homarr build --pr <number> Build a PR locally from a temporary checkout
homarr rebuild <name> Rebuild from recorded provenance
homarr list Script-friendly instance list
homarr logs <container> Follow logs
homarr open <container> Open in browser
homarr stop <container> Stop
homarr restart <container> Restart
homarr remove <container> Force-remove
homarr doctor Check Docker and optional CLI integrations
```
## Development Browser
```text
/ Filter by PR number, title, author, branch, CI, image, state, or port
↑/↓ or j/k Select
Enter or Space Start the matching local build, otherwise pull remote; stop when running
R Build a remote PR locally in the background or rebuild a local image
p Pull a remote PR image and redeploy, preserving port and volume
m Toggle demo mode for the next launch
o Open PR on GitHub
a Open running app
l Toggle inline logs for the selected running PR
f Follow or pause inline logs
Page Up/Down Scroll inline logs
b Show or hide bot PRs
r Refresh
d Switch to the instance dashboard
q Quit; during a pull, cancel it
```
Pull progress, layer completion, local build provenance, selected PR details, CI state, image availability, inline logs, and the final port remain inside the TUI.
Local PR images are folded into their remote PR row only when the recorded revision exactly matches the current GitHub head SHA. They are marked `◆ local` and preferred by Enter; outdated builds remain separate local rows. Navigation and the `d` dashboard toggle remain available while a local build runs.
## Instance Dashboard
```text
/ Filter by name, image, state, health, port, or URL
↑/↓ or j/k Select
l Toggle inline logs
f Follow or pause logs
Page Up/Down Scroll logs
s Stop
R Restart
x Remove with confirmation
o Open in browser
c Copy URL
r Refresh
d Switch to the development browser
q Quit
```
+32
View File
@@ -0,0 +1,32 @@
module github.com/homarr-labs/homarr/tools/homarr-dev
go 1.25.0
require (
charm.land/bubbles/v2 v2.1.1
charm.land/bubbletea/v2 v2.0.8
charm.land/lipgloss/v2 v2.0.5
github.com/atotto/clipboard v0.1.4
github.com/spf13/cobra v1.10.2
)
require (
github.com/charmbracelet/colorprofile v0.4.3 // indirect
github.com/charmbracelet/harmonica v0.2.0 // indirect
github.com/charmbracelet/ultraviolet v0.0.0-20260703014108-f5a850f9c2b7 // indirect
github.com/charmbracelet/x/ansi v0.11.7 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/charmbracelet/x/termios v0.1.1 // indirect
github.com/charmbracelet/x/windows v0.2.2 // indirect
github.com/clipperhouse/displaywidth v0.11.0 // indirect
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/lucasb-eyer/go-colorful v1.4.0 // indirect
github.com/mattn/go-runewidth v0.0.24 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/spf13/pflag v1.0.9 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/sync v0.21.0 // indirect
golang.org/x/sys v0.46.0 // indirect
)
+56
View File
@@ -0,0 +1,56 @@
charm.land/bubbles/v2 v2.1.1 h1:7r55WzBxpo/R3z98hGmY7KKPd3ET6vsf0Fb9sDHOV60=
charm.land/bubbles/v2 v2.1.1/go.mod h1:GE6M31gaWZVXzGw73OeuTTgy4lX+OtkH0E5ymnNsHxo=
charm.land/bubbletea/v2 v2.0.8 h1:SxTJMhCAI3lbPmy4SgX5LWZ24AdINr4I6UEqzZvYJuY=
charm.land/bubbletea/v2 v2.0.8/go.mod h1:2SkdgoTXluXJHOUwAoRlRXF/28vklb1rFl6GcgV1/ss=
charm.land/lipgloss/v2 v2.0.5 h1:kbNxgeeUOYv5J0YdpxFjfvf3dFvqH8Aci4zB6xqFtrY=
charm.land/lipgloss/v2 v2.0.5/go.mod h1:9oqhxt4yxIMe6q5A4kHr44DremZk7J9UNh74GlWa5nc=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ=
github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
github.com/charmbracelet/ultraviolet v0.0.0-20260703014108-f5a850f9c2b7 h1:3FmWoGNWK4STvqg0O0Aeav2T7rodWJAPeF0QpH+8gFw=
github.com/charmbracelet/ultraviolet v0.0.0-20260703014108-f5a850f9c2b7/go.mod h1:f/jRa757WUmaOZrbPspXymbg/GnbF+rwe4OLsG7aXYo=
github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI=
github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ=
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA=
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I=
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM=
github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k=
github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4=
github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/ay2DU=
github.com/mattn/go-runewidth v0.0.24/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+427
View File
@@ -0,0 +1,427 @@
package docker
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"time"
)
const developmentEncryptionKeyEnv = "HOMARR_DEV_SECRET_ENCRYPTION_KEY"
type Container struct {
ID string
Name string
Image string
Status string
Ports string
State string
}
func (c Container) HostPort() string {
for _, mapping := range strings.Split(c.Ports, ", ") {
if strings.HasSuffix(mapping, "->7575/tcp") {
parts := strings.Split(mapping, "->")
left := parts[0]
if i := strings.LastIndex(left, ":"); i >= 0 {
return left[i+1:]
}
return left
}
}
return ""
}
func (c Container) Running() bool {
return c.State == "running"
}
func List() ([]Container, error) {
out, err := exec.Command("docker", "ps", "-a",
"--filter", "name=homarr",
"--format", "{{json .}}").Output()
if err != nil {
return nil, err
}
var containers []Container
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
if line == "" {
continue
}
var raw struct {
ID string
Names string
Image string
Status string
Ports string
State string
}
if err := json.Unmarshal([]byte(line), &raw); err != nil {
continue
}
containers = append(containers, Container{
ID: raw.ID,
Name: raw.Names,
Image: raw.Image,
Status: raw.Status,
Ports: raw.Ports,
State: raw.State,
})
}
filtered := containers[:0]
for _, c := range containers {
if c.Name == "homarr" || strings.HasPrefix(c.Name, "homarr_") {
filtered = append(filtered, c)
}
}
return filtered, nil
}
func FindFreePort(start int) int {
if start > 65535 {
return 0
}
used := usedDockerPorts()
start = max(start, 1)
for port := start; port <= 65535; port++ {
if used[port] {
continue
}
listener, err := net.Listen("tcp4", fmt.Sprintf("0.0.0.0:%d", port))
if err == nil {
_ = listener.Close()
return port
}
}
return 0
}
func usedDockerPorts() map[int]bool {
out, err := exec.Command("docker", "ps", "--format", "{{.Ports}}").Output()
if err != nil {
return make(map[int]bool)
}
return parseUsedDockerPorts(out)
}
func parseUsedDockerPorts(out []byte) map[int]bool {
used := make(map[int]bool)
for _, line := range strings.Split(string(out), "\n") {
for _, mapping := range strings.Split(line, ",") {
left, _, found := strings.Cut(mapping, "->")
if !found {
continue
}
colon := strings.LastIndex(left, ":")
if colon < 0 {
continue
}
ports := strings.SplitN(strings.TrimSpace(left[colon+1:]), "-", 2)
first, err := strconv.Atoi(ports[0])
if err != nil {
continue
}
last := first
if len(ports) == 2 {
if parsed, err := strconv.Atoi(ports[1]); err == nil {
last = parsed
}
}
for port := first; port <= last; port++ {
used[port] = true
}
}
}
return used
}
func IsRunning(name string) bool {
containers, _ := List()
for _, c := range containers {
if c.Name == name && c.Running() {
return true
}
}
return false
}
type StartOptions struct {
Name string
Image string
Volume string
HostPort int
Env []string
Platform string
PullAlways bool
Daemon bool
}
func Start(opts StartOptions) error {
encryptionKey, err := developmentEncryptionKey()
if err != nil {
return err
}
if opts.PullAlways {
cmd := PullCommand(opts.Image, opts.Platform)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("pull %s: %w", opts.Image, err)
}
}
_ = exec.Command("docker", "rm", "-f", opts.Name).Run()
args := []string{"run", "--rm", "--name", opts.Name,
"-e", "SECRET_ENCRYPTION_KEY=" + encryptionKey,
}
if opts.Platform != "" {
args = append(args, "--platform", opts.Platform)
}
if opts.Daemon {
args = append(args, "-d")
} else {
args = append(args, "-it")
}
for _, env := range opts.Env {
args = append(args, "-e", env)
}
args = append(args,
"-v", opts.Volume+":/appdata",
"-v", "/var/run/docker.sock:/var/run/docker.sock:ro",
"-p", fmt.Sprintf("%d:7575", opts.HostPort),
opts.Image,
)
cmd := exec.Command("docker", args...)
if opts.Daemon {
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("%w: %s", err, cleanOutput(out))
}
return nil
}
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
stderr := &tailBuffer{}
cmd.Stderr = io.MultiWriter(os.Stderr, stderr)
if err := cmd.Run(); err != nil {
return fmt.Errorf("%w: %s", err, cleanOutput(stderr.data))
}
return nil
}
func developmentEncryptionKey() (string, error) {
if key := os.Getenv(developmentEncryptionKeyEnv); key != "" {
return validateDevelopmentEncryptionKey(key)
}
configDir, err := os.UserConfigDir()
if err != nil {
return "", fmt.Errorf("locate user config directory: %w", err)
}
return persistedDevelopmentEncryptionKey(configDir)
}
func persistedDevelopmentEncryptionKey(configDir string) (string, error) {
dir := filepath.Join(configDir, "homarr-dev")
path := filepath.Join(dir, "secret-encryption-key")
if key, found, err := readDevelopmentEncryptionKey(path); found || err != nil {
return key, err
}
if err := os.MkdirAll(dir, 0o700); err != nil {
return "", fmt.Errorf("create development config directory: %w", err)
}
lockPath := path + ".lock"
for attempts := 0; ; attempts++ {
if err := os.Mkdir(lockPath, 0o700); err == nil {
break
} else if !os.IsExist(err) {
return "", fmt.Errorf("lock development encryption key: %w", err)
}
if key, found, err := readDevelopmentEncryptionKey(path); found || err != nil {
return key, err
}
if attempts == 99 {
return "", fmt.Errorf("timed out waiting for development encryption key lock %q", lockPath)
}
time.Sleep(10 * time.Millisecond)
}
defer func() { _ = os.Remove(lockPath) }()
if key, found, err := readDevelopmentEncryptionKey(path); found || err != nil {
return key, err
}
keyBytes := make([]byte, 32)
if _, err := rand.Read(keyBytes); err != nil {
return "", fmt.Errorf("generate development encryption key: %w", err)
}
key := hex.EncodeToString(keyBytes)
file, err := os.CreateTemp(dir, "secret-encryption-key-*.tmp")
if err != nil {
return "", fmt.Errorf("create development encryption key: %w", err)
}
tempPath := file.Name()
defer func() { _ = os.Remove(tempPath) }()
if _, err := file.WriteString(key + "\n"); err != nil {
_ = file.Close()
return "", fmt.Errorf("write development encryption key: %w", err)
}
if err := file.Close(); err != nil {
return "", fmt.Errorf("close development encryption key: %w", err)
}
if err := os.Chmod(tempPath, 0o600); err != nil {
return "", fmt.Errorf("secure development encryption key: %w", err)
}
if err := os.Rename(tempPath, path); err != nil {
return "", fmt.Errorf("persist development encryption key: %w", err)
}
return key, nil
}
func readDevelopmentEncryptionKey(path string) (string, bool, error) {
data, err := os.ReadFile(path)
if os.IsNotExist(err) {
return "", false, nil
}
if err != nil {
return "", false, fmt.Errorf("read development encryption key: %w", err)
}
key, err := validateDevelopmentEncryptionKey(strings.TrimSpace(string(data)))
return key, true, err
}
func validateDevelopmentEncryptionKey(key string) (string, error) {
decoded, err := hex.DecodeString(key)
if err != nil || len(decoded) != 32 {
return "", fmt.Errorf("development encryption key must contain exactly 64 hexadecimal characters")
}
return key, nil
}
type tailBuffer struct {
data []byte
}
func (b *tailBuffer) Write(p []byte) (int, error) {
const limit = 32 * 1024
b.data = append(b.data, p...)
if len(b.data) > limit {
b.data = b.data[len(b.data)-limit:]
}
return len(p), nil
}
func cleanOutput(out []byte) string {
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
cleaned := make([]string, 0, len(lines))
for _, line := range lines {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "WARNING: The requested image's platform") || isContainerID(line) {
continue
}
if line != "" {
cleaned = append(cleaned, line)
}
}
return strings.Join(cleaned, "\n")
}
func isContainerID(value string) bool {
if len(value) != 64 {
return false
}
for _, r := range value {
if !(r >= '0' && r <= '9') && !(r >= 'a' && r <= 'f') {
return false
}
}
return true
}
func PullCommand(image, platform string) *exec.Cmd {
return PullCommandContext(context.Background(), image, platform)
}
func PullCommandContext(ctx context.Context, image, platform string) *exec.Cmd {
args := []string{"pull"}
if platform != "" {
args = append(args, "--platform", platform)
}
args = append(args, image)
cmd := exec.CommandContext(ctx, "docker", args...)
if platform != "" {
cmd.Env = append(os.Environ(), "DOCKER_DEFAULT_PLATFORM="+platform)
}
return cmd
}
func IsPortConflict(err error) bool {
if err == nil {
return false
}
message := strings.ToLower(err.Error())
return strings.Contains(message, "port is already allocated") ||
strings.Contains(message, "address already in use") ||
strings.Contains(message, "failed programming external connectivity")
}
func Stop(name string) error {
cmd := exec.Command("docker", "stop", name)
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("%w: %s", err, strings.TrimSpace(string(out)))
}
return nil
}
func Restart(name string) error {
cmd := exec.Command("docker", "restart", name)
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("%w: %s", err, strings.TrimSpace(string(out)))
}
return nil
}
func Remove(name string) error {
cmd := exec.Command("docker", "rm", "-f", name)
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("%w: %s", err, strings.TrimSpace(string(out)))
}
return nil
}
func CheckImage(image string) (bool, error) {
cmd := exec.Command("docker", "manifest", "inspect", image)
out, err := cmd.CombinedOutput()
if err == nil {
return true, nil
}
message := strings.ToLower(string(out))
if strings.Contains(message, "manifest unknown") || strings.Contains(message, "no such manifest") {
return false, nil
}
return false, fmt.Errorf("check image %s: %w: %s", image, err, strings.TrimSpace(string(out)))
}
func FollowLogs(name string) error {
cmd := exec.Command("docker", "logs", "-f", "--tail", "100", name)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}
@@ -0,0 +1,125 @@
package docker
import (
"encoding/hex"
"errors"
"os"
"path/filepath"
"strings"
"testing"
)
func TestContainerHostPort(t *testing.T) {
c := Container{Ports: "0.0.0.0:7576->7575/tcp, [::]:7576->7575/tcp"}
if got := c.HostPort(); got != "7576" {
t.Fatalf("HostPort() = %q, want 7576", got)
}
}
func TestIsPortConflict(t *testing.T) {
for _, message := range []string{
"Bind for 0.0.0.0:7575 failed: port is already allocated",
"driver failed programming external connectivity on endpoint",
} {
if !IsPortConflict(errors.New(message)) {
t.Fatalf("expected port conflict for %q", message)
}
}
if IsPortConflict(errors.New("manifest unknown")) {
t.Fatal("manifest error classified as port conflict")
}
}
func TestParseUsedDockerPortsPreservesLineBoundaries(t *testing.T) {
ports := parseUsedDockerPorts([]byte("6379/tcp\n0.0.0.0:7575->7575/tcp, [::]:7575->7575/tcp\n0.0.0.0:8000-8002->8000-8002/tcp\n"))
for _, port := range []int{7575, 8000, 8001, 8002} {
if !ports[port] {
t.Fatalf("port %d was not detected", port)
}
}
}
func TestFindFreePortRejectsOutOfRangeStart(t *testing.T) {
if port := FindFreePort(65536); port != 0 {
t.Fatalf("port = %d, want 0", port)
}
}
func TestDevelopmentEncryptionKeyUsesEnvironmentOverride(t *testing.T) {
want := strings.Repeat("a", 64)
t.Setenv(developmentEncryptionKeyEnv, want)
got, err := developmentEncryptionKey()
if err != nil {
t.Fatal(err)
}
if got != want {
t.Fatalf("key = %q, want environment value", got)
}
}
func TestPersistedDevelopmentEncryptionKeyIsReused(t *testing.T) {
configDir := t.TempDir()
first, err := persistedDevelopmentEncryptionKey(configDir)
if err != nil {
t.Fatal(err)
}
second, err := persistedDevelopmentEncryptionKey(configDir)
if err != nil {
t.Fatal(err)
}
if first != second {
t.Fatal("persisted key changed between reads")
}
decoded, err := hex.DecodeString(first)
if err != nil || len(decoded) != 32 {
t.Fatalf("generated key is not 32 bytes: %q", first)
}
info, err := os.Stat(filepath.Join(configDir, "homarr-dev", "secret-encryption-key"))
if err != nil {
t.Fatal(err)
}
if info.Mode().Perm() != 0o600 {
t.Fatalf("key permissions = %o, want 600", info.Mode().Perm())
}
}
func TestPersistedDevelopmentEncryptionKeyRejectsInvalidValue(t *testing.T) {
configDir := t.TempDir()
dir := filepath.Join(configDir, "homarr-dev")
if err := os.Mkdir(dir, 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "secret-encryption-key"), []byte("invalid\n"), 0o600); err != nil {
t.Fatal(err)
}
if _, err := persistedDevelopmentEncryptionKey(configDir); err == nil {
t.Fatal("expected invalid persisted key to fail")
}
}
func TestPersistedDevelopmentEncryptionKeyConcurrentCreation(t *testing.T) {
configDir := t.TempDir()
type result struct {
key string
err error
}
results := make(chan result, 20)
for range 20 {
go func() {
key, err := persistedDevelopmentEncryptionKey(configDir)
results <- result{key: key, err: err}
}()
}
var first string
for range 20 {
result := <-results
if result.err != nil {
t.Fatal(result.err)
}
if first == "" {
first = result.key
} else if result.key != first {
t.Fatal("concurrent callers received different keys")
}
}
}
+149
View File
@@ -0,0 +1,149 @@
package docker
import (
"context"
"encoding/json"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"regexp"
"strconv"
"strings"
)
const (
SourceLabel = "org.homarr.dev.source"
RevisionLabel = "org.homarr.dev.revision"
PRLabel = "org.homarr.dev.pr"
)
var tagPattern = regexp.MustCompile(`^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$`)
type Image struct {
ID string
Tag string
Source string
Revision string
PRNumber int
}
func (image Image) Reference() string {
return "homarr:" + image.Tag
}
type imageListRow struct {
ID string
Repository string
Tag string
}
func parseImageList(output []byte) ([]Image, error) {
images := make([]Image, 0)
for _, line := range strings.Split(strings.TrimSpace(string(output)), "\n") {
if line == "" {
continue
}
var row imageListRow
if err := json.Unmarshal([]byte(line), &row); err != nil {
return nil, fmt.Errorf("parse local Docker image: %w", err)
}
if row.Repository == "homarr" && row.Tag != "<none>" {
images = append(images, Image{ID: row.ID, Tag: row.Tag})
}
}
return images, nil
}
func ListLocalImages(ctx context.Context) ([]Image, error) {
out, err := exec.CommandContext(ctx, "docker", "image", "ls", "--filter", "reference=homarr:*", "--format", "{{json .}}").Output()
if err != nil {
return nil, fmt.Errorf("list local Homarr images: %w", err)
}
images, err := parseImageList(out)
if err != nil {
return nil, err
}
labelsByImage, err := inspectImageLabels(ctx, images)
if err != nil {
return nil, err
}
for index, labels := range labelsByImage {
images[index].Source = labels[SourceLabel]
images[index].Revision = labels[RevisionLabel]
images[index].PRNumber, _ = strconv.Atoi(labels[PRLabel])
}
return images, nil
}
func inspectImageLabels(ctx context.Context, images []Image) ([]map[string]string, error) {
if len(images) == 0 {
return nil, nil
}
args := []string{"image", "inspect", "--format", "{{json .Config.Labels}}"}
for _, image := range images {
args = append(args, image.Reference())
}
out, err := exec.CommandContext(ctx, "docker", args...).Output()
if err != nil {
return nil, fmt.Errorf("inspect local Homarr images: %w", err)
}
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
if len(lines) != len(images) {
return nil, fmt.Errorf("inspect returned %d images, expected %d", len(lines), len(images))
}
labelsByImage := make([]map[string]string, len(images))
for index, line := range lines {
labelsByImage[index] = make(map[string]string)
if line == "null" {
continue
}
if err := json.Unmarshal([]byte(line), &labelsByImage[index]); err != nil {
return nil, fmt.Errorf("parse labels for %s: %w", images[index].Reference(), err)
}
}
return labelsByImage, nil
}
type BuildOptions struct {
Context string
Tag string
Source string
Revision string
PRNumber int
}
func BuildCommand(ctx context.Context, options BuildOptions) (*exec.Cmd, error) {
if !tagPattern.MatchString(options.Tag) {
return nil, fmt.Errorf("invalid image name %q", options.Tag)
}
contextPath, err := filepath.Abs(options.Context)
if err != nil {
return nil, fmt.Errorf("resolve build context: %w", err)
}
if info, err := os.Stat(filepath.Join(contextPath, "Dockerfile")); err != nil || info.IsDir() {
return nil, fmt.Errorf("%s does not contain a Dockerfile", contextPath)
}
args := []string{"build", "--label", SourceLabel + "=" + options.Source, "--label", RevisionLabel + "=" + options.Revision}
if options.PRNumber > 0 {
args = append(args, "--label", fmt.Sprintf("%s=%d", PRLabel, options.PRNumber))
}
args = append(args, "-t", "homarr:"+options.Tag, ".")
command := exec.CommandContext(ctx, "docker", args...)
command.Dir = contextPath
return command, nil
}
func BuildContext(ctx context.Context, options BuildOptions, stdout, stderr io.Writer) error {
command, err := BuildCommand(ctx, options)
if err != nil {
return err
}
command.Stdout = stdout
command.Stderr = stderr
if err := command.Run(); err != nil {
return fmt.Errorf("build homarr:%s: %w", options.Tag, err)
}
return nil
}
@@ -0,0 +1,69 @@
package docker
import (
"os"
"testing"
)
func TestParseImageListFiltersHomarrTags(t *testing.T) {
output := []byte("{\"ID\":\"sha256:1\",\"Repository\":\"homarr\",\"Tag\":\"dev\"}\n{\"ID\":\"sha256:2\",\"Repository\":\"other\",\"Tag\":\"dev\"}\n")
images, err := parseImageList(output)
if err != nil {
t.Fatal(err)
}
if len(images) != 1 || images[0].Reference() != "homarr:dev" {
t.Fatalf("images = %#v, want homarr:dev", images)
}
}
func TestInspectImageLabelsWithNoImages(t *testing.T) {
labels, err := inspectImageLabels(t.Context(), nil)
if err != nil {
t.Fatal(err)
}
if labels != nil {
t.Fatalf("labels = %#v, want nil", labels)
}
}
func TestBuildCommandIncludesProvenance(t *testing.T) {
command, err := BuildCommand(t.Context(), BuildOptions{
Context: "../../../..",
Tag: "feature",
Source: "/tmp/homarr",
Revision: "abc123",
PRNumber: 42,
})
if err != nil {
t.Fatal(err)
}
want := []string{"docker", "build", "--label", SourceLabel + "=/tmp/homarr", "--label", RevisionLabel + "=abc123", "--label", PRLabel + "=42", "-t", "homarr:feature", "."}
if len(command.Args) != len(want) {
t.Fatalf("args = %v, want %v", command.Args, want)
}
for index := range want {
if command.Args[index] != want[index] {
t.Fatalf("args = %v, want %v", command.Args, want)
}
}
}
func TestListLocalImagesIntegration(t *testing.T) {
wantTag := os.Getenv("HOMARR_TEST_LOCAL_IMAGE")
if wantTag == "" {
t.Skip("set HOMARR_TEST_LOCAL_IMAGE to a local homarr image tag")
}
images, err := ListLocalImages(t.Context())
if err != nil {
t.Fatal(err)
}
for _, image := range images {
if image.Tag == wantTag {
if image.Source == "" || image.Revision == "" {
t.Fatalf("image %#v is missing provenance", image)
}
return
}
}
t.Fatalf("homarr:%s was not discovered", wantTag)
}
+199
View File
@@ -0,0 +1,199 @@
package gh
import (
"context"
"encoding/json"
"fmt"
"os/exec"
"strings"
"sync"
"time"
)
const Repo = "homarr-labs/homarr"
const prCacheTTL = 5 * time.Minute
var prListCache = struct {
sync.Mutex
entries map[int]cachedPRs
}{entries: make(map[int]cachedPRs)}
type cachedPRs struct {
prs []PR
fetchedAt time.Time
}
func isBot(login string) bool {
return strings.HasSuffix(login, "[bot]") || strings.HasPrefix(login, "app/")
}
type PR struct {
Number int `json:"number"`
Title string `json:"title"`
Author string `json:"author"`
HeadRef string `json:"headRefName"`
HeadSHA string `json:"headRefOid"`
CIState string `json:"ciState"`
IsDraft bool `json:"isDraft"`
UpdatedAt string `json:"updatedAt"`
}
type rawPR struct {
Number int `json:"number"`
Title string `json:"title"`
IsDraft bool `json:"isDraft"`
Author struct {
Login string `json:"login"`
} `json:"author"`
HeadRefName string `json:"headRefName"`
HeadRefOID string `json:"headRefOid"`
UpdatedAt string `json:"updatedAt"`
StatusCheckRollup []rawCheck `json:"statusCheckRollup"`
}
type rawCheck struct {
Status string `json:"status"`
Conclusion string `json:"conclusion"`
State string `json:"state"`
}
func rollupState(checks []rawCheck) string {
if len(checks) == 0 {
return "NONE"
}
failed, pending := 0, 0
for _, c := range checks {
if c.State != "" {
switch c.State {
case "SUCCESS":
case "PENDING", "EXPECTED":
pending++
default:
failed++
}
continue
}
if c.Status != "COMPLETED" {
pending++
continue
}
switch c.Conclusion {
case "SUCCESS", "SKIPPED", "NEUTRAL":
default:
failed++
}
}
if failed > 0 {
return "FAILURE"
}
if pending > 0 {
return "PENDING"
}
return "SUCCESS"
}
func ListPRs(ctx context.Context, limit int, includeBots bool) ([]PR, error) {
return listPRs(ctx, limit, includeBots, false)
}
func RefreshPRs(ctx context.Context, limit int, includeBots bool) ([]PR, error) {
return listPRs(ctx, limit, includeBots, true)
}
func listPRs(ctx context.Context, limit int, includeBots, refresh bool) ([]PR, error) {
cached, fetchedAt := cachedPRList(limit)
if !refresh && !fetchedAt.IsZero() && time.Since(fetchedAt) < prCacheTTL {
return filterPRs(cached, includeBots), nil
}
out, err := exec.CommandContext(ctx, "gh", "pr", "list",
"--repo", Repo,
"--state", "open",
"--limit", fmt.Sprint(limit),
"--json", "number,title,author,headRefName,headRefOid,updatedAt,isDraft,statusCheckRollup",
).CombinedOutput()
if err != nil {
err = commandError(err, out)
if len(cached) > 0 {
return filterPRs(cached, includeBots), fmt.Errorf("%w; showing cached PRs from %s", err, fetchedAt.Format(time.Kitchen))
}
return nil, err
}
var raws []rawPR
if err := json.Unmarshal(out, &raws); err != nil {
return nil, err
}
prs := make([]PR, 0, len(raws))
for _, r := range raws {
prs = append(prs, PR{
Number: r.Number,
Title: r.Title,
Author: r.Author.Login,
HeadRef: r.HeadRefName,
HeadSHA: r.HeadRefOID,
CIState: rollupState(r.StatusCheckRollup),
IsDraft: r.IsDraft,
UpdatedAt: r.UpdatedAt,
})
}
storePRList(limit, prs)
return filterPRs(prs, includeBots), nil
}
func cachedPRList(limit int) ([]PR, time.Time) {
prListCache.Lock()
defer prListCache.Unlock()
entry := prListCache.entries[limit]
return append([]PR(nil), entry.prs...), entry.fetchedAt
}
func storePRList(limit int, prs []PR) {
prListCache.Lock()
defer prListCache.Unlock()
prListCache.entries[limit] = cachedPRs{prs: append([]PR(nil), prs...), fetchedAt: time.Now()}
}
func filterPRs(prs []PR, includeBots bool) []PR {
filtered := make([]PR, 0, len(prs))
for _, pr := range prs {
if includeBots || !isBot(pr.Author) {
filtered = append(filtered, pr)
}
}
return filtered
}
func commandError(err error, output []byte) error {
detail := strings.TrimSpace(string(output))
if detail == "" {
return err
}
return fmt.Errorf("%s: %w", detail, err)
}
func GetPR(ctx context.Context, number int) (*PR, error) {
out, err := exec.CommandContext(ctx, "gh", "pr", "view", fmt.Sprint(number),
"--repo", Repo,
"--json", "number,title,author,headRefName,headRefOid,updatedAt,isDraft,statusCheckRollup",
).CombinedOutput()
if err != nil {
return nil, commandError(err, out)
}
var r rawPR
if err := json.Unmarshal(out, &r); err != nil {
return nil, err
}
return &PR{
Number: r.Number,
Title: r.Title,
Author: r.Author.Login,
HeadRef: r.HeadRefName,
HeadSHA: r.HeadRefOID,
CIState: rollupState(r.StatusCheckRollup),
IsDraft: r.IsDraft,
UpdatedAt: r.UpdatedAt,
}, nil
}
+91
View File
@@ -0,0 +1,91 @@
package gh
import (
"errors"
"os"
"strings"
"testing"
"time"
)
func TestIsBot(t *testing.T) {
tests := []struct {
login string
want bool
}{
{login: "app/homarr-renovate", want: true},
{login: "dependabot[bot]", want: true},
{login: "ajnart", want: false},
}
for _, test := range tests {
if got := isBot(test.login); got != test.want {
t.Errorf("isBot(%q) = %v, want %v", test.login, got, test.want)
}
}
}
func TestRollupStateHandlesChecksAndStatuses(t *testing.T) {
tests := []struct {
name string
checks []rawCheck
want string
}{
{name: "check success", checks: []rawCheck{{Status: "COMPLETED", Conclusion: "SUCCESS"}}, want: "SUCCESS"},
{name: "status failure", checks: []rawCheck{{State: "FAILURE"}}, want: "FAILURE"},
{name: "status pending", checks: []rawCheck{{State: "PENDING"}}, want: "PENDING"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if got := rollupState(test.checks); got != test.want {
t.Fatalf("rollupState() = %q, want %q", got, test.want)
}
})
}
}
func TestCachedPRListFiltersBotsWithoutMutatingCache(t *testing.T) {
limit := 9876
storePRList(limit, []PR{{Number: 1, Author: "developer"}, {Number: 2, Author: "bot[bot]"}})
prs, fetchedAt := cachedPRList(limit)
if time.Since(fetchedAt) >= prCacheTTL {
t.Fatal("new cache entry is already expired")
}
if filtered := filterPRs(prs, false); len(filtered) != 1 || filtered[0].Number != 1 {
t.Fatalf("filtered PRs = %#v", filtered)
}
if unfiltered := filterPRs(prs, true); len(unfiltered) != 2 {
t.Fatalf("unfiltered PR count = %d, want 2", len(unfiltered))
}
}
func TestCommandErrorIncludesGitHubOutput(t *testing.T) {
err := commandError(errors.New("exit status 1"), []byte("GraphQL: API rate limit exceeded\n"))
if !strings.Contains(err.Error(), "API rate limit exceeded") {
t.Fatalf("error = %q", err)
}
}
func TestListPRsExcludesBotsIntegration(t *testing.T) {
if os.Getenv("HOMARR_INTEGRATION_TEST") != "1" {
t.Skip("set HOMARR_INTEGRATION_TEST=1 to query GitHub")
}
prs, err := ListPRs(t.Context(), 50, false)
if err != nil {
t.Fatal(err)
}
if len(prs) == 0 {
t.Fatal("expected at least one human-authored open PR")
}
for _, pr := range prs {
if isBot(pr.Author) {
t.Fatalf("bot PR was not filtered: #%d by %s", pr.Number, pr.Author)
}
if pr.HeadSHA == "" {
t.Fatalf("PR #%d has no head SHA", pr.Number)
}
}
}
@@ -0,0 +1,7 @@
package platform
import "github.com/atotto/clipboard"
func CopyText(value string) error {
return clipboard.WriteAll(value)
}
@@ -0,0 +1,28 @@
package platform
import (
"fmt"
"os/exec"
"runtime"
)
func OpenURL(url string) error {
name, args, err := openCommand(runtime.GOOS, url)
if err != nil {
return err
}
return exec.Command(name, args...).Start()
}
func openCommand(goos, url string) (string, []string, error) {
switch goos {
case "darwin":
return "open", []string{url}, nil
case "linux":
return "xdg-open", []string{url}, nil
case "windows":
return "rundll32", []string{"url.dll,FileProtocolHandler", url}, nil
default:
return "", nil, fmt.Errorf("opening URLs is not supported on %s", goos)
}
}
Loaded 100 of 112 files, more files were not shown because too many files have changed in this diff. Show more