Compare commits
22
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bff3383ac9 | ||
|
|
d865733838 | ||
|
|
85fcea3837 | ||
|
|
f76a0f240a | ||
|
|
637f03e107 | ||
|
|
55214ccbc5 | ||
|
|
778533a62e | ||
|
|
b4fba47a23 | ||
|
|
76ddfceb13 | ||
|
|
2f6b96e5ba | ||
|
|
f1b7a5146a | ||
|
|
05292ae1f5 | ||
|
|
48fc8db2b4 | ||
|
|
018f84f80d | ||
|
|
d58a47b776 | ||
|
|
797bdd001f | ||
|
|
49314533bf | ||
|
|
27ca058088 | ||
|
|
f4449f9581 | ||
|
|
b52bc03c8d | ||
|
|
edb56f3fcd | ||
|
|
bd669a1a8f |
No files matched your search
@@ -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
|
||||
|
||||
@@ -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 ./...`.
|
||||
@@ -1,3 +1,5 @@
|
||||
## [1.74.0](https://github.com/homarr-labs/homarr/compare/v1.73.0...v1.74.0) (2026-08-07)
|
||||
|
||||
## [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)
|
||||
|
||||
@@ -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.
|
||||
@@ -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">
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
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 |
|
||||
|
||||
@@ -7,8 +7,8 @@ tags:
|
||||
- Ping
|
||||
---
|
||||
|
||||
Apps are the building blocks of your Homarr board.
|
||||
Most of the time they are a simple link to a website, but used on your board they also support a simple ping to check if your service is up.
|
||||
Apps are the building blocks of your Homarr board.
|
||||
Most of the time they are a simple link to a website, but used on your board they also support a simple ping to check if your service is up.
|
||||
Apps are used in the bookmark and app widget.
|
||||
|
||||
## Create an app
|
||||
@@ -27,8 +27,8 @@ 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.
|
||||
This is the URL to the icon that will be displayed on the app widget.
|
||||
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.
|
||||
|
||||

|
||||
|
||||
@@ -40,14 +40,17 @@ An optional description that can be shown as tooltip on the app widget.
|
||||
|
||||
The URL to the website you want to link to. Clicking on the app widget or bookmark will open this URL.
|
||||
|
||||
You can also use custom URI schemes such as `vscode://file/path`, `microsoft-edge:https://example.com`, or `myapp://open` to link to local applications or third-party programs. We trust that you know what you are doing — only `javascript:` URIs are blocked for security reasons.
|
||||
|
||||
### Ping URL
|
||||
|
||||
When you want to use another url for the simple status check you can set it here.
|
||||
When you want to use another url for the simple status check you can set it here.
|
||||
This will allow you to set a different URL for the ping than the URL that is opened when clicking on the app widget.
|
||||
It's useful when you want to ping the service internally for example with a custom ip but open the website externally.
|
||||
Note that the ping URL only accepts `http://` and `https://` URLs — custom URI schemes are not supported for pings.
|
||||
|
||||
## List of apps
|
||||
|
||||
On the apps management page you find all apps that exist in your Homarr instance. You can edit and delete them.
|
||||
|
||||

|
||||

|
||||
@@ -9,97 +9,125 @@ tags:
|
||||
---
|
||||
|
||||
# Settings
|
||||
The settings page has a few options that will be applied to the entire Homarr application.
|
||||
They may require a full page reload for changes to apply.
|
||||
|
||||
The settings page applies options to the whole Homarr application.
|
||||
Some changes need a full page reload before they take effect.
|
||||
|
||||
## Analytics
|
||||
By default, Homarr is collecting anonymous analytics to identify issues and prioritize bugfixes and improvements.
|
||||
The data is never shared with any third-parties and is fully GDPR & CCPA compliant.
|
||||
The software being used is [Umami Analytics](https://umami.is/) which is fully open source.
|
||||
The main server software can be found [here](https://github.com/umami-software/umami) and [this node library](https://github.com/umami-software/node) to perform server side calls to the API.
|
||||
|
||||
A full list of the collected data by default can be found here: https://umami.is/docs/metric-definitions.
|
||||
Homarr uses [PostHog](https://posthog.com/) to collect anonymous usage data.
|
||||
We collect this data to see what features people ACTUALLY use.
|
||||
We use this data to decide where we spend our development time.
|
||||
|
||||
We understand that you might not want Homarr to communicate with our services.
|
||||
By unchecking the ``Send anonymous analytics`` checkbox and reloading the full page, all communication by Umami will be disabled and no data will be collected.
|
||||
If you are paranoid, you can permanently block ``umami.homarr.dev`` from your DNS / firewall.
|
||||
We do not sell the data. We do not share the data with third parties.
|
||||
The data does not contain personal information and does not identify you or your installation.
|
||||
|
||||
Additionally, you opt in to more data collection to help us provide more useful data.
|
||||
Homarr sends the data to our own PostHog server at `https://hog.homarr.dev`.
|
||||
The server sends the data once a week and when the server starts.
|
||||
|
||||
### Integration data
|
||||
Enabling this optional data will send the types of integrations (e.g. SABnzbd, Sonarr, ...) and how many you have configured of them.
|
||||
This will not include any URLs, names, secrets, errors and others. This is disabled by default.
|
||||
### What we collect
|
||||
|
||||
### Widget data
|
||||
Enabling this optional data will send the types of widgets (e.g. downloads, media streams, iframe, ...) and how many you have configured of them.
|
||||
This will not include any URLs, names, secrets, errors and others. This is disabled by default.
|
||||
Homarr collects technical data about your installation:
|
||||
|
||||
### Users data
|
||||
Enabling this optional data will send the count of users and whether you activated SSO.
|
||||
This will not include any usernames, passwords, emails, URLs, authentication flows or client IDs.
|
||||
This is disabled by default.
|
||||
- Your Homarr version
|
||||
- Your database type (SQLite, MySQL, or PostgreSQL)
|
||||
- Whether Docker or Kubernetes is enabled
|
||||
- Your enabled authentication providers
|
||||
- Your operating system and CPU architecture
|
||||
- Your default language
|
||||
- The number of boards, apps, sections, layouts, users, groups, search engines, icon repositories, cron jobs, API keys, invites, sessions, and accounts
|
||||
- The integration types you use and how many you have of each (for example, Sonarr or SABnzbd)
|
||||
- The widget types you use and how many you have of each
|
||||
|
||||
Homarr does NOT collect:
|
||||
|
||||
- URLs
|
||||
- Names
|
||||
- Usernames, emails, or passwords
|
||||
- Integration secrets or API keys
|
||||
- Error messages or logs
|
||||
- Any data that can identify you
|
||||
|
||||
Each installation gets a random ID. We use this ID only to count unique installations. We cannot use this ID to identify you.
|
||||
|
||||
Homarr does not use cookies for analytics. Homarr does not track page views automatically. Homarr only sends the data listed above.
|
||||
|
||||
### Disable analytics
|
||||
|
||||
Analytics are enabled by default. To disable analytics:
|
||||
|
||||
1. Open the Settings page.
|
||||
2. Uncheck the "Help us improve Homarr" checkbox.
|
||||
3. Reload the full page.
|
||||
|
||||
When analytics are disabled, Homarr does not send any data to PostHog.
|
||||
|
||||
You can also set the `NO_EXTERNAL_CONNECTION` environment variable to `true`. This stops all external requests, including analytics. See the [environment variables](/docs/advanced/environment-variables) page for more information.
|
||||
|
||||
You can block `hog.homarr.dev` in your DNS or firewall. Homarr cannot send data when this address is blocked.
|
||||
|
||||
## Search Engine Optimization (SEO)
|
||||
Big companies [like Google, Microsoft, Baidu and Yandex](https://de.wikipedia.org/wiki/Web-Index) will use [web crawlers](https://de.wikipedia.org/wiki/Webcrawler) to index the Internet for new websites & pages.
|
||||
This is desirable in many cases (for example when selling products in an online shop) but can lead to unwanted traffic and attention to your personal dashboard.
|
||||
Homarr is requesting the crawlers to not index Homarr instances by default for this reason.
|
||||
If you use Homarr as a public website and want it to be searchable on these search engines, you can disable these settings.
|
||||
Please note that Homarr doesn't have control over what the crawlers do; it is a mere request to them to not perform these actions but will not guarantee that your website doesn't land on some index.
|
||||
|
||||
Search engines use web crawlers to index websites. This can cause unwanted traffic to your dashboard.
|
||||
Homarr asks crawlers to not index your instance by default. Use these settings to change this request.
|
||||
|
||||
Homarr cannot control what the crawlers do. A crawler can ignore the request. Your website can still appear in an index.
|
||||
|
||||
### No index
|
||||
This will ask crawlers to not index your Homarr instance on any indexes.
|
||||
It is up to the crawler to properly respect this request.
|
||||
By default, this setting is enabled. Disabling it will allow indexing.
|
||||
|
||||
This setting asks crawlers to not index your instance.
|
||||
This setting is enabled by default. Disable this setting to allow indexing.
|
||||
|
||||
### No follow
|
||||
If indexing is allowed, crawlers will also attempt to follow any links on your page to further "discover" new parts of the Internet and on your website.
|
||||
By default, this setting is enabled and will ask crawlers to not follow any links.
|
||||
Crawler of different companies may behave differently when indexing is prohibited and following is allowed.
|
||||
|
||||
Crawlers follow links to discover new pages.
|
||||
This setting asks crawlers to not follow links. This setting is enabled by default.
|
||||
Crawler behavior can differ between search engines.
|
||||
|
||||
### No translate
|
||||
Google and Chrome on Android will prompt users to translate pages that are not in their device language.
|
||||
This can lead to some shifting and can be undesired in some cases. By enabling this,
|
||||
you ask crawlers to never translate and never prompt users for translation.
|
||||
It should be noted that some browsers will ignore this and translate regardless.
|
||||
|
||||
Some browsers offer to translate pages.
|
||||
This setting asks crawlers to not offer translation. Some browsers ignore this setting.
|
||||
|
||||
### No site links search box
|
||||
This setting is exclusive to Google.
|
||||
[Google will create a box below your domain](https://developers.google.com/search/docs/appearance/structured-data/sitelinks-searchbox) that lists the paths that were discovered by the "folowing".
|
||||
The user can search them directly in Google.
|
||||
You can ask the crawlers to not allow searches by enabling this setting.
|
||||
This setting is disabled by default.
|
||||
|
||||
This setting applies to Google only.
|
||||
Google can show a search box below your domain.
|
||||
This setting asks Google to not show this box. This setting is disabled by default.
|
||||
|
||||
## Boards
|
||||
|
||||
### Global home board
|
||||
You can set a global home board that will be used for all users that have not set a home board themselves.
|
||||
It will also be used for anonymous users.
|
||||
Note that the board must be public to be used as a global home board.
|
||||
|
||||
You can set a global home board.
|
||||
This board is the fallback for users without their own home board. It is also the fallback for anonymous users.
|
||||
The board must be public.
|
||||
|
||||
### Global mobile board
|
||||
|
||||
You can set a global mobile board. It will be shown instead of the global home board on mobile devices that can be detected by Homarr. (Using the User-Agent header).
|
||||
It will similar to the global home board be as fallback for normal users and for any anonymous users.
|
||||
It also has to be public.
|
||||
You can set a global mobile board.
|
||||
Homarr shows this board on mobile devices instead of the global home board.
|
||||
Homarr detects mobile devices with the User-Agent header.
|
||||
The board must be public.
|
||||
|
||||
### App status
|
||||
|
||||
#### Enable status by default
|
||||
|
||||
You can decide if the app status should be enabled by default when adding a new app to the board.
|
||||
You can decide if app status is enabled when you add a new app.
|
||||
|
||||
#### Force disable status
|
||||
|
||||
You can decide if the app status should be disabled for all boards.
|
||||
|
||||
###
|
||||
You can decide if app status is disabled on all boards.
|
||||
|
||||
## Appearance
|
||||
|
||||
### Default color scheme
|
||||
You can set the default color scheme for all users that have not set a color scheme themselves.
|
||||
|
||||
You can set the default color scheme for users without their own color scheme.
|
||||
|
||||
## Culture
|
||||
|
||||
### Default language
|
||||
You can set the default language for all users that have not set a language themselves.
|
||||
|
||||
You can set the default language for users without their own language.
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
+5
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "homarr",
|
||||
"version": "1.73.0",
|
||||
"version": "1.74.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",
|
||||
|
||||
@@ -173,7 +173,7 @@ export const appRouter = createTRPCRouter({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"Create a new app (bookmark/shortcut to a service). REQUIRED: name (string), iconUrl (icon URL string), href (app URL, http/https or blank). OPTIONAL: description (string or null), pingUrl (URL to check reachability, or empty string)",
|
||||
"Create a new app (bookmark/shortcut to a service). REQUIRED: name (string), iconUrl (icon URL string), href (app URL or custom URI scheme such as vscode://, or blank). OPTIONAL: description (string or null), pingUrl (URL to check reachability, or empty string)",
|
||||
},
|
||||
})
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
|
||||
@@ -119,6 +119,7 @@ export type HomarrDocumentationPath =
|
||||
| "/docs/tags/variables"
|
||||
| "/docs/tags/widgets"
|
||||
| "/docs/advanced/command-line"
|
||||
| "/docs/advanced/command-line/development"
|
||||
| "/docs/advanced/command-line/fix-usernames"
|
||||
| "/docs/advanced/command-line/integrations"
|
||||
| "/docs/advanced/command-line/password-recovery"
|
||||
|
||||
@@ -442,7 +442,7 @@ export const integrationDefs = {
|
||||
name: "Beszel",
|
||||
secretKinds: [["username", "password"]],
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/beszel.svg",
|
||||
category: ["healthMonitoring"],
|
||||
category: ["beszel"],
|
||||
documentationUrl: createDocumentationLink("/docs/integrations/beszel"),
|
||||
defaultPort: 8090,
|
||||
},
|
||||
@@ -565,6 +565,7 @@ export const integrationCategories = [
|
||||
"smartHomeServer",
|
||||
"indexerManager",
|
||||
"healthMonitoring",
|
||||
"beszel",
|
||||
"search",
|
||||
"mediaTranscoding",
|
||||
"networkController",
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -259,7 +259,7 @@ describe("NavidromeIntegration.getCurrentSessionsAsync", () => {
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
test("throws on subsonic auth failure", async () => {
|
||||
test("normalizes subsonic auth failures", async () => {
|
||||
mockFetch.mockImplementation((url) => {
|
||||
const urlStr = toUrlString(url);
|
||||
|
||||
|
||||
@@ -185,9 +185,15 @@ export class OverseerrIntegration
|
||||
}),
|
||||
);
|
||||
|
||||
return settled
|
||||
const fulfilled = settled
|
||||
.filter((result): result is PromiseFulfilledResult<MediaRequest> => result.status === "fulfilled")
|
||||
.map((result) => result.value);
|
||||
|
||||
if (fulfilled.length === 0) {
|
||||
throw new Error("Failed to resolve any media request information");
|
||||
}
|
||||
|
||||
return fulfilled;
|
||||
}
|
||||
|
||||
protected mapRequestStatus(status: UpstreamMediaRequestStatus): MediaRequestStatus {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -148,6 +148,8 @@ const optionMapping: OptionMapping = {
|
||||
},
|
||||
mediaServer: {
|
||||
showOnlyPlaying: () => undefined,
|
||||
showBitrate: () => undefined,
|
||||
showLocation: () => undefined,
|
||||
},
|
||||
indexerManager: {
|
||||
openIndexerSiteInNewTab: (oldOptions) => oldOptions.openIndexerSiteInNewTab,
|
||||
|
||||
@@ -2428,10 +2428,21 @@
|
||||
},
|
||||
"descendingDefaultSort": {
|
||||
"label": ""
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": ""
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": ""
|
||||
"internalServerError": "",
|
||||
"layoutSaveTitle": "",
|
||||
"layoutSaveMessage": ""
|
||||
},
|
||||
"empty": {
|
||||
"noContainers": ""
|
||||
}
|
||||
},
|
||||
"coolify": {
|
||||
@@ -2725,8 +2736,20 @@
|
||||
"showOnlyPlaying": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showBitrate": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showLocation": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"footer": {
|
||||
"streams": "",
|
||||
"totalBitrate": ""
|
||||
},
|
||||
"items": {
|
||||
"currentlyPlaying": "S'està reproduint",
|
||||
"user": "Usuari",
|
||||
@@ -2734,6 +2757,9 @@
|
||||
"id": "ID",
|
||||
"status": "",
|
||||
"directPlay": "",
|
||||
"directStream": "",
|
||||
"transcodeVideo": "",
|
||||
"transcodeAudio": "",
|
||||
"transcoding": "",
|
||||
"paused": "",
|
||||
"remaining": "",
|
||||
@@ -3768,18 +3794,6 @@
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showNowPlaying": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showNowPlayingList": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"maxNowPlayingItems": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showLibraryCount": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
@@ -3813,8 +3827,6 @@
|
||||
"podcasts": "",
|
||||
"listeningTime": "",
|
||||
"activeSessions": "",
|
||||
"nowPlaying": "",
|
||||
"nowPlayingCount": "",
|
||||
"error": {
|
||||
"internalServerError": ""
|
||||
}
|
||||
@@ -3954,11 +3966,19 @@
|
||||
},
|
||||
"showAgent": {
|
||||
"label": ""
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": ""
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"noRecords": "",
|
||||
"error": {
|
||||
"internalServerError": ""
|
||||
"internalServerError": "",
|
||||
"layoutSaveTitle": "",
|
||||
"layoutSaveMessage": ""
|
||||
},
|
||||
"column": {
|
||||
"system": "",
|
||||
@@ -5356,6 +5376,7 @@
|
||||
},
|
||||
"remove": {
|
||||
"label": "Elimina",
|
||||
"confirm": "",
|
||||
"notification": {
|
||||
"success": {
|
||||
"title": "Contenidors eliminats",
|
||||
|
||||
@@ -2428,10 +2428,21 @@
|
||||
},
|
||||
"descendingDefaultSort": {
|
||||
"label": "倒序"
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": ""
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": "获取容器统计信息失败"
|
||||
"internalServerError": "获取容器统计信息失败",
|
||||
"layoutSaveTitle": "",
|
||||
"layoutSaveMessage": ""
|
||||
},
|
||||
"empty": {
|
||||
"noContainers": ""
|
||||
}
|
||||
},
|
||||
"coolify": {
|
||||
@@ -2725,8 +2736,20 @@
|
||||
"showOnlyPlaying": {
|
||||
"label": "只显示当前正在播放",
|
||||
"description": "禁用此功能对plex不起作用"
|
||||
},
|
||||
"showBitrate": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showLocation": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"footer": {
|
||||
"streams": "",
|
||||
"totalBitrate": ""
|
||||
},
|
||||
"items": {
|
||||
"currentlyPlaying": "正在播放",
|
||||
"user": "用户",
|
||||
@@ -2734,6 +2757,9 @@
|
||||
"id": "ID",
|
||||
"status": "",
|
||||
"directPlay": "",
|
||||
"directStream": "",
|
||||
"transcodeVideo": "",
|
||||
"transcodeAudio": "",
|
||||
"transcoding": "",
|
||||
"paused": "",
|
||||
"remaining": "",
|
||||
@@ -3768,18 +3794,6 @@
|
||||
"label": "显示歌曲",
|
||||
"description": "显示音乐库中歌曲的总数 (Navidrome)"
|
||||
},
|
||||
"showNowPlaying": {
|
||||
"label": "显示正在播放",
|
||||
"description": "显示当前播放的曲目 (Navidrome)"
|
||||
},
|
||||
"showNowPlayingList": {
|
||||
"label": "显示正在播放列表",
|
||||
"description": "显示单个曲目而不仅仅是活跃流计数 (Navidrome)"
|
||||
},
|
||||
"maxNowPlayingItems": {
|
||||
"label": "最大正在播放项目数",
|
||||
"description": "正在播放列表中显示的最大曲目数 (Navidrome)"
|
||||
},
|
||||
"showLibraryCount": {
|
||||
"label": "显示库数量",
|
||||
"description": "显示实例中的库数量 (Audiobookshelf)"
|
||||
@@ -3813,8 +3827,6 @@
|
||||
"podcasts": "播客",
|
||||
"listeningTime": "收听时长",
|
||||
"activeSessions": "活跃会话",
|
||||
"nowPlaying": "正在播放",
|
||||
"nowPlayingCount": "{count, plural, other {# 个流}}",
|
||||
"error": {
|
||||
"internalServerError": "获取音频统计数据失败"
|
||||
}
|
||||
@@ -3954,11 +3966,19 @@
|
||||
},
|
||||
"showAgent": {
|
||||
"label": "显示代理版本"
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": ""
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"noRecords": "未找到系统",
|
||||
"error": {
|
||||
"internalServerError": ""
|
||||
"internalServerError": "",
|
||||
"layoutSaveTitle": "",
|
||||
"layoutSaveMessage": ""
|
||||
},
|
||||
"column": {
|
||||
"system": "系统",
|
||||
@@ -5356,6 +5376,7 @@
|
||||
},
|
||||
"remove": {
|
||||
"label": "删除",
|
||||
"confirm": "",
|
||||
"notification": {
|
||||
"success": {
|
||||
"title": "容器已删除",
|
||||
|
||||
@@ -2428,10 +2428,21 @@
|
||||
},
|
||||
"descendingDefaultSort": {
|
||||
"label": "crwdns3820:0crwdne3820:0"
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": "crwdns6155:0crwdne6155:0"
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": "crwdns6157:0crwdne6157:0"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": "crwdns3428:0crwdne3428:0"
|
||||
"internalServerError": "crwdns3428:0crwdne3428:0",
|
||||
"layoutSaveTitle": "crwdns6159:0crwdne6159:0",
|
||||
"layoutSaveMessage": "crwdns6161:0crwdne6161:0"
|
||||
},
|
||||
"empty": {
|
||||
"noContainers": "crwdns6163:0crwdne6163:0"
|
||||
}
|
||||
},
|
||||
"coolify": {
|
||||
@@ -2725,8 +2736,20 @@
|
||||
"showOnlyPlaying": {
|
||||
"label": "crwdns3176:0crwdne3176:0",
|
||||
"description": "crwdns3178:0crwdne3178:0"
|
||||
},
|
||||
"showBitrate": {
|
||||
"label": "crwdns6175:0crwdne6175:0",
|
||||
"description": "crwdns6177:0crwdne6177:0"
|
||||
},
|
||||
"showLocation": {
|
||||
"label": "crwdns6179:0crwdne6179:0",
|
||||
"description": "crwdns6181:0crwdne6181:0"
|
||||
}
|
||||
},
|
||||
"footer": {
|
||||
"streams": "crwdns6183:0count={count}crwdne6183:0",
|
||||
"totalBitrate": "crwdns6185:0{bitrate}crwdne6185:0"
|
||||
},
|
||||
"items": {
|
||||
"currentlyPlaying": "crwdns2847:0crwdne2847:0",
|
||||
"user": "crwdns2476:0crwdne2476:0",
|
||||
@@ -2734,6 +2757,9 @@
|
||||
"id": "crwdns2480:0crwdne2480:0",
|
||||
"status": "crwdns6085:0crwdne6085:0",
|
||||
"directPlay": "crwdns6087:0crwdne6087:0",
|
||||
"directStream": "crwdns6187:0crwdne6187:0",
|
||||
"transcodeVideo": "crwdns6189:0crwdne6189:0",
|
||||
"transcodeAudio": "crwdns6191:0crwdne6191:0",
|
||||
"transcoding": "crwdns6089:0crwdne6089:0",
|
||||
"paused": "crwdns6091:0crwdne6091:0",
|
||||
"remaining": "crwdns6093:0{minutes}crwdne6093:0",
|
||||
@@ -3768,18 +3794,6 @@
|
||||
"label": "crwdns5109:0crwdne5109:0",
|
||||
"description": "crwdns5111:0crwdne5111:0"
|
||||
},
|
||||
"showNowPlaying": {
|
||||
"label": "crwdns5113:0crwdne5113:0",
|
||||
"description": "crwdns5115:0crwdne5115:0"
|
||||
},
|
||||
"showNowPlayingList": {
|
||||
"label": "crwdns5117:0crwdne5117:0",
|
||||
"description": "crwdns5119:0crwdne5119:0"
|
||||
},
|
||||
"maxNowPlayingItems": {
|
||||
"label": "crwdns5121:0crwdne5121:0",
|
||||
"description": "crwdns5123:0crwdne5123:0"
|
||||
},
|
||||
"showLibraryCount": {
|
||||
"label": "crwdns5125:0crwdne5125:0",
|
||||
"description": "crwdns5127:0crwdne5127:0"
|
||||
@@ -3813,8 +3827,6 @@
|
||||
"podcasts": "crwdns5159:0crwdne5159:0",
|
||||
"listeningTime": "crwdns5161:0crwdne5161:0",
|
||||
"activeSessions": "crwdns5163:0crwdne5163:0",
|
||||
"nowPlaying": "crwdns5165:0crwdne5165:0",
|
||||
"nowPlayingCount": "crwdns5167:0count={count}crwdne5167:0",
|
||||
"error": {
|
||||
"internalServerError": "crwdns5169:0crwdne5169:0"
|
||||
}
|
||||
@@ -3954,11 +3966,19 @@
|
||||
},
|
||||
"showAgent": {
|
||||
"label": "crwdns4809:0crwdne4809:0"
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": "crwdns6165:0crwdne6165:0"
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": "crwdns6167:0crwdne6167:0"
|
||||
}
|
||||
},
|
||||
"noRecords": "crwdns4811:0crwdne4811:0",
|
||||
"error": {
|
||||
"internalServerError": "crwdns5651:0crwdne5651:0"
|
||||
"internalServerError": "crwdns5651:0crwdne5651:0",
|
||||
"layoutSaveTitle": "crwdns6169:0crwdne6169:0",
|
||||
"layoutSaveMessage": "crwdns6171:0crwdne6171:0"
|
||||
},
|
||||
"column": {
|
||||
"system": "crwdns4813:0crwdne4813:0",
|
||||
@@ -5356,6 +5376,7 @@
|
||||
},
|
||||
"remove": {
|
||||
"label": "crwdns1952:0crwdne1952:0",
|
||||
"confirm": "crwdns6173:0crwdne6173:0",
|
||||
"notification": {
|
||||
"success": {
|
||||
"title": "crwdns1954:0crwdne1954:0",
|
||||
|
||||
@@ -2428,10 +2428,21 @@
|
||||
},
|
||||
"descendingDefaultSort": {
|
||||
"label": ""
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": ""
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": ""
|
||||
"internalServerError": "",
|
||||
"layoutSaveTitle": "",
|
||||
"layoutSaveMessage": ""
|
||||
},
|
||||
"empty": {
|
||||
"noContainers": ""
|
||||
}
|
||||
},
|
||||
"coolify": {
|
||||
@@ -2725,8 +2736,20 @@
|
||||
"showOnlyPlaying": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showBitrate": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showLocation": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"footer": {
|
||||
"streams": "",
|
||||
"totalBitrate": ""
|
||||
},
|
||||
"items": {
|
||||
"currentlyPlaying": "",
|
||||
"user": "Uživatel",
|
||||
@@ -2734,6 +2757,9 @@
|
||||
"id": "Id",
|
||||
"status": "",
|
||||
"directPlay": "",
|
||||
"directStream": "",
|
||||
"transcodeVideo": "",
|
||||
"transcodeAudio": "",
|
||||
"transcoding": "",
|
||||
"paused": "",
|
||||
"remaining": "",
|
||||
@@ -3768,18 +3794,6 @@
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showNowPlaying": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showNowPlayingList": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"maxNowPlayingItems": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showLibraryCount": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
@@ -3813,8 +3827,6 @@
|
||||
"podcasts": "",
|
||||
"listeningTime": "",
|
||||
"activeSessions": "",
|
||||
"nowPlaying": "",
|
||||
"nowPlayingCount": "",
|
||||
"error": {
|
||||
"internalServerError": ""
|
||||
}
|
||||
@@ -3954,11 +3966,19 @@
|
||||
},
|
||||
"showAgent": {
|
||||
"label": "Zobrazit verzi agenta"
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": ""
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"noRecords": "Nenalezeny žádné systémy",
|
||||
"error": {
|
||||
"internalServerError": ""
|
||||
"internalServerError": "",
|
||||
"layoutSaveTitle": "",
|
||||
"layoutSaveMessage": ""
|
||||
},
|
||||
"column": {
|
||||
"system": "Systém",
|
||||
@@ -5356,6 +5376,7 @@
|
||||
},
|
||||
"remove": {
|
||||
"label": "Odstranit",
|
||||
"confirm": "",
|
||||
"notification": {
|
||||
"success": {
|
||||
"title": "",
|
||||
|
||||
@@ -2428,10 +2428,21 @@
|
||||
},
|
||||
"descendingDefaultSort": {
|
||||
"label": "Inverter sortering"
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": ""
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": "Kunne ikke hente containerstatistik"
|
||||
"internalServerError": "Kunne ikke hente containerstatistik",
|
||||
"layoutSaveTitle": "",
|
||||
"layoutSaveMessage": ""
|
||||
},
|
||||
"empty": {
|
||||
"noContainers": ""
|
||||
}
|
||||
},
|
||||
"coolify": {
|
||||
@@ -2725,8 +2736,20 @@
|
||||
"showOnlyPlaying": {
|
||||
"label": "Vis kun hvad der aktuelt afspilles",
|
||||
"description": "Deaktivering af dette vil ikke virke for plex"
|
||||
},
|
||||
"showBitrate": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showLocation": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"footer": {
|
||||
"streams": "",
|
||||
"totalBitrate": ""
|
||||
},
|
||||
"items": {
|
||||
"currentlyPlaying": "Afspiller lige nu",
|
||||
"user": "Bruger",
|
||||
@@ -2734,6 +2757,9 @@
|
||||
"id": "Id",
|
||||
"status": "",
|
||||
"directPlay": "",
|
||||
"directStream": "",
|
||||
"transcodeVideo": "",
|
||||
"transcodeAudio": "",
|
||||
"transcoding": "",
|
||||
"paused": "",
|
||||
"remaining": "",
|
||||
@@ -3768,18 +3794,6 @@
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showNowPlaying": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showNowPlayingList": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"maxNowPlayingItems": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showLibraryCount": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
@@ -3813,8 +3827,6 @@
|
||||
"podcasts": "",
|
||||
"listeningTime": "",
|
||||
"activeSessions": "",
|
||||
"nowPlaying": "",
|
||||
"nowPlayingCount": "",
|
||||
"error": {
|
||||
"internalServerError": ""
|
||||
}
|
||||
@@ -3954,11 +3966,19 @@
|
||||
},
|
||||
"showAgent": {
|
||||
"label": "Vis agentversion"
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": ""
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"noRecords": "Ingen systemer fundet",
|
||||
"error": {
|
||||
"internalServerError": ""
|
||||
"internalServerError": "",
|
||||
"layoutSaveTitle": "",
|
||||
"layoutSaveMessage": ""
|
||||
},
|
||||
"column": {
|
||||
"system": "System",
|
||||
@@ -5356,6 +5376,7 @@
|
||||
},
|
||||
"remove": {
|
||||
"label": "Fjern",
|
||||
"confirm": "",
|
||||
"notification": {
|
||||
"success": {
|
||||
"title": "Containere fjernet",
|
||||
|
||||
@@ -2428,10 +2428,21 @@
|
||||
},
|
||||
"descendingDefaultSort": {
|
||||
"label": ""
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": ""
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": ""
|
||||
"internalServerError": "",
|
||||
"layoutSaveTitle": "",
|
||||
"layoutSaveMessage": ""
|
||||
},
|
||||
"empty": {
|
||||
"noContainers": ""
|
||||
}
|
||||
},
|
||||
"coolify": {
|
||||
@@ -2725,8 +2736,20 @@
|
||||
"showOnlyPlaying": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showBitrate": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showLocation": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"footer": {
|
||||
"streams": "",
|
||||
"totalBitrate": ""
|
||||
},
|
||||
"items": {
|
||||
"currentlyPlaying": "Aktuelle Wiedergabe",
|
||||
"user": "Benutzer",
|
||||
@@ -2734,6 +2757,9 @@
|
||||
"id": "",
|
||||
"status": "",
|
||||
"directPlay": "",
|
||||
"directStream": "",
|
||||
"transcodeVideo": "",
|
||||
"transcodeAudio": "",
|
||||
"transcoding": "",
|
||||
"paused": "",
|
||||
"remaining": "",
|
||||
@@ -3768,18 +3794,6 @@
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showNowPlaying": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showNowPlayingList": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"maxNowPlayingItems": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showLibraryCount": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
@@ -3813,8 +3827,6 @@
|
||||
"podcasts": "",
|
||||
"listeningTime": "",
|
||||
"activeSessions": "",
|
||||
"nowPlaying": "",
|
||||
"nowPlayingCount": "",
|
||||
"error": {
|
||||
"internalServerError": ""
|
||||
}
|
||||
@@ -3954,11 +3966,19 @@
|
||||
},
|
||||
"showAgent": {
|
||||
"label": ""
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": ""
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"noRecords": "",
|
||||
"error": {
|
||||
"internalServerError": ""
|
||||
"internalServerError": "",
|
||||
"layoutSaveTitle": "",
|
||||
"layoutSaveMessage": ""
|
||||
},
|
||||
"column": {
|
||||
"system": "",
|
||||
@@ -5356,6 +5376,7 @@
|
||||
},
|
||||
"remove": {
|
||||
"label": "Entfernen",
|
||||
"confirm": "",
|
||||
"notification": {
|
||||
"success": {
|
||||
"title": "Container entfernt",
|
||||
|
||||
@@ -1342,19 +1342,19 @@
|
||||
},
|
||||
"traefik": {
|
||||
"name": "Traefik",
|
||||
"description": ""
|
||||
"description": "Cloud-nativer Reverse-Proxy und Lastverteiler"
|
||||
},
|
||||
"paperlessNgx": {
|
||||
"name": "Paperless-ngx",
|
||||
"description": "Dokumentenverwaltungssystem zum Archivieren und Organisieren gescannter Dokumente"
|
||||
},
|
||||
"patchmon": {
|
||||
"name": "",
|
||||
"description": ""
|
||||
"name": "PatchMon",
|
||||
"description": "Patch-Verwaltung und Systemüberwachung für Linux-Hosts"
|
||||
},
|
||||
"bazarr": {
|
||||
"name": "Bazarr",
|
||||
"description": ""
|
||||
"description": "Schwesteranwendung zu Sonarr und Radarr, welche Untertitel und Downloads verwaltet"
|
||||
}
|
||||
},
|
||||
"media": {
|
||||
@@ -2328,8 +2328,8 @@
|
||||
"label": "Dateisystem Info anzeigen"
|
||||
},
|
||||
"visibleStorageVolumes": {
|
||||
"label": "",
|
||||
"description": "",
|
||||
"label": "Sichtbare Speichervolumes",
|
||||
"description": "Nur verfügbar, wenn alle Integrationen Synology betreffen. Nicht ausfüllen, um alle erkannten Speichervolumes zu zeigen. ",
|
||||
"placeholder": "Speichervolumen auswählen"
|
||||
},
|
||||
"defaultTab": {
|
||||
@@ -2428,10 +2428,21 @@
|
||||
},
|
||||
"descendingDefaultSort": {
|
||||
"label": "Sortierung umkehren"
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": "Spaltenordnung"
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": "Spaltenbreite"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": "Fehler beim Abrufen der Container Statistiken"
|
||||
"internalServerError": "Fehler beim Abrufen der Container Statistiken",
|
||||
"layoutSaveTitle": "Tabellenlayout nicht gespeichert",
|
||||
"layoutSaveMessage": "Die Docker-Tabelle konnte nicht gespeichert werden. Versuche die Spalte zu verschieben oder eine andere Spaltengröße zu verwenden. "
|
||||
},
|
||||
"empty": {
|
||||
"noContainers": "Keine Docker-Container gefunden"
|
||||
}
|
||||
},
|
||||
"coolify": {
|
||||
@@ -2725,8 +2736,20 @@
|
||||
"showOnlyPlaying": {
|
||||
"label": "Nur aktuelle Wiedergabe anzeigen",
|
||||
"description": "Diese Einstellung zu deaktivieren wird für Plex nicht funktionieren"
|
||||
},
|
||||
"showBitrate": {
|
||||
"label": "Zeigt die Bitrate an",
|
||||
"description": ""
|
||||
},
|
||||
"showLocation": {
|
||||
"label": "",
|
||||
"description": "Zeigt an, ob der Stream über das LAN oder WAN läuft"
|
||||
}
|
||||
},
|
||||
"footer": {
|
||||
"streams": "",
|
||||
"totalBitrate": "Gesamtbitrate: {bitrate}"
|
||||
},
|
||||
"items": {
|
||||
"currentlyPlaying": "Aktuelle Wiedergabe",
|
||||
"user": "Benutzer",
|
||||
@@ -2734,6 +2757,9 @@
|
||||
"id": "Id",
|
||||
"status": "Status",
|
||||
"directPlay": "Direkte Wiedergabe",
|
||||
"directStream": "Direkter Stream",
|
||||
"transcodeVideo": "",
|
||||
"transcodeAudio": "",
|
||||
"transcoding": "Transkodierung",
|
||||
"paused": "Pausiert",
|
||||
"remaining": "{minutes} Min. verbleibend",
|
||||
@@ -2803,21 +2829,21 @@
|
||||
}
|
||||
},
|
||||
"patchmon": {
|
||||
"name": "",
|
||||
"name": "PatchMon",
|
||||
"description": "",
|
||||
"title": "",
|
||||
"title": "PatchMon-Statistiken",
|
||||
"totalHosts": "Hosts gesamt",
|
||||
"hostsNeedingUpdates": "Update erforderlich",
|
||||
"securityUpdates": "Sicherheits-Updates",
|
||||
"upToDateHosts": "",
|
||||
"upToDateHosts": "Aktualisierte Hosts",
|
||||
"hostsWithSecurityUpdates": "Hosts mit Sicherheits-Updates",
|
||||
"recentUpdates24h": "Updates (24h)",
|
||||
"totalOutdatedPackages": "Veraltete Pakete",
|
||||
"totalRepos": "Repositories",
|
||||
"osDistribution": "",
|
||||
"compliance": "",
|
||||
"complianceRatio": "",
|
||||
"lastUpdated": "",
|
||||
"osDistribution": "Betriebssystem-Distribution",
|
||||
"compliance": "Patch-Kompatibilität",
|
||||
"complianceRatio": "{upToDate} / {total}",
|
||||
"lastUpdated": "Zuletzt aktualisiert: {time}",
|
||||
"option": {
|
||||
"showTotalHosts": {
|
||||
"label": "Alle Hosts anzeigen",
|
||||
@@ -2833,30 +2859,30 @@
|
||||
},
|
||||
"showUpToDateHosts": {
|
||||
"label": "Zeige aktuelle Hosts",
|
||||
"description": ""
|
||||
"description": "Zeigt Hosts mit keinen veralteten Paketen an"
|
||||
},
|
||||
"showHostsWithSecurityUpdates": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
"label": "Zeigt Hosts mit Sicherheitsupdates an",
|
||||
"description": "Zeigt Hosts an, welche Sicherheitsupdates benötigen"
|
||||
},
|
||||
"showRecentUpdates24h": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
"label": "Zeigt vergangene Updates in den letzten 24 Stunden an",
|
||||
"description": "Zeigt die Anzahl der angewendeten Updates in den letzten 24 Stunden an"
|
||||
},
|
||||
"showTotalOutdatedPackages": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
"label": "Zeigt veraltete Pakete an",
|
||||
"description": "Zeigt die Gesamtanzahl der veralteten Pakete über mehrere Hosts verteilt an"
|
||||
},
|
||||
"showTotalRepos": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
"label": "Zeigt Repositorys an",
|
||||
"description": "Zeigt die Anzahl der zu überwachenden aktiven Repositorys an"
|
||||
},
|
||||
"enableThresholdColors": {
|
||||
"label": "",
|
||||
"label": "Aktiviert die Schwellwert-Farben",
|
||||
"description": ""
|
||||
},
|
||||
"useCustomThresholds": {
|
||||
"label": "",
|
||||
"label": "Erweiterte Schwellwerte",
|
||||
"description": ""
|
||||
},
|
||||
"thresholdMode": {
|
||||
@@ -3768,18 +3794,6 @@
|
||||
"label": "Songs anzeigen",
|
||||
"description": "Zeigt die Gesamtzahl der Titel in deiner Musikbibliothek an (Navidrome)"
|
||||
},
|
||||
"showNowPlaying": {
|
||||
"label": "\"Sie hören\" anzeigen",
|
||||
"description": "Zeige aktuell gespielte Titel an (Navidrome)"
|
||||
},
|
||||
"showNowPlayingList": {
|
||||
"label": "\"Sie hören jetzt\" Wiedergabeliste anzeigen",
|
||||
"description": "Einzelne Tracks anstelle der aktiven Streamanzahl anzeigen (Navidrome)"
|
||||
},
|
||||
"maxNowPlayingItems": {
|
||||
"label": "Maximale jetzt abgespielte Elemente",
|
||||
"description": "Maximale Anzahl an Titeln, die in der \"Sie hören jetzt\" Wiedergabeliste angezeigt werden (Navidrome)"
|
||||
},
|
||||
"showLibraryCount": {
|
||||
"label": "Anzahl der Bibliotheken anzeigen",
|
||||
"description": "Zeige die Anzahl der Bibliotheken in deiner Instanz (Hörbücherregal)"
|
||||
@@ -3813,8 +3827,6 @@
|
||||
"podcasts": "Podcasts",
|
||||
"listeningTime": "Hördauer",
|
||||
"activeSessions": "Aktive Sitzungen",
|
||||
"nowPlaying": "Aktuelle Wiedergabe",
|
||||
"nowPlayingCount": "{count, plural, one {# stream} other {# streams}}",
|
||||
"error": {
|
||||
"internalServerError": "Fehler beim Abrufen der Audio-Statistikdaten"
|
||||
}
|
||||
@@ -3954,11 +3966,19 @@
|
||||
},
|
||||
"showAgent": {
|
||||
"label": "Agent-Version anzeigen"
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": ""
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"noRecords": "Keine Systeme gefunden",
|
||||
"error": {
|
||||
"internalServerError": "Keine Verbindung zu Beszel. Überprüfen Sie Ihre Integrationseinstellungen."
|
||||
"internalServerError": "Keine Verbindung zu Beszel. Überprüfen Sie Ihre Integrationseinstellungen.",
|
||||
"layoutSaveTitle": "",
|
||||
"layoutSaveMessage": ""
|
||||
},
|
||||
"column": {
|
||||
"system": "System",
|
||||
@@ -5356,6 +5376,7 @@
|
||||
},
|
||||
"remove": {
|
||||
"label": "Entfernen",
|
||||
"confirm": "",
|
||||
"notification": {
|
||||
"success": {
|
||||
"title": "Container entfernt",
|
||||
|
||||
@@ -2428,10 +2428,21 @@
|
||||
},
|
||||
"descendingDefaultSort": {
|
||||
"label": ""
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": ""
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": ""
|
||||
"internalServerError": "",
|
||||
"layoutSaveTitle": "",
|
||||
"layoutSaveMessage": ""
|
||||
},
|
||||
"empty": {
|
||||
"noContainers": ""
|
||||
}
|
||||
},
|
||||
"coolify": {
|
||||
@@ -2725,8 +2736,20 @@
|
||||
"showOnlyPlaying": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showBitrate": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showLocation": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"footer": {
|
||||
"streams": "",
|
||||
"totalBitrate": ""
|
||||
},
|
||||
"items": {
|
||||
"currentlyPlaying": "",
|
||||
"user": "",
|
||||
@@ -2734,6 +2757,9 @@
|
||||
"id": "",
|
||||
"status": "",
|
||||
"directPlay": "",
|
||||
"directStream": "",
|
||||
"transcodeVideo": "",
|
||||
"transcodeAudio": "",
|
||||
"transcoding": "",
|
||||
"paused": "",
|
||||
"remaining": "",
|
||||
@@ -3768,18 +3794,6 @@
|
||||
"label": "Εμφάνιση τραγουδιών",
|
||||
"description": "Εμφάνιση του συνολικού αριθμού τραγουδιών στη βιβλιοθήκη μουσικής σας (Navidrome)"
|
||||
},
|
||||
"showNowPlaying": {
|
||||
"label": "Εμφάνιση τρέχουσας αναπαραγωγής",
|
||||
"description": "Εμφάνιση κομματιών που αναπαράγονται αυτήν τη στιγμή (Navidrome)"
|
||||
},
|
||||
"showNowPlayingList": {
|
||||
"label": "Εμφάνιση λίστας τρέχουσας αναπαραγωγής",
|
||||
"description": "Εμφάνιση μεμονωμένων κομματιών αντί μόνο του αριθμού ενεργών ροών (Navidrome)"
|
||||
},
|
||||
"maxNowPlayingItems": {
|
||||
"label": "Μέγιστα στοιχεία τρέχουσας αναπαραγωγής",
|
||||
"description": "Μέγιστος αριθμός κομματιών προς εμφάνιση στη λίστα τρέχουσας αναπαραγωγής (Navidrome)"
|
||||
},
|
||||
"showLibraryCount": {
|
||||
"label": "Εμφάνιση αριθμού βιβλιοθηκών",
|
||||
"description": "Εμφάνιση του αριθμού βιβλιοθηκών στην εγκατάστασή σας (Audiobookshelf)"
|
||||
@@ -3813,8 +3827,6 @@
|
||||
"podcasts": "Ποντκαστ",
|
||||
"listeningTime": "Χρόνος Ακρόασης",
|
||||
"activeSessions": "Ενεργές Συνεδρίες",
|
||||
"nowPlaying": "Τρέχουσα Αναπαραγωγή",
|
||||
"nowPlayingCount": "{count, plural, one {# ροή} other {# ροές}}",
|
||||
"error": {
|
||||
"internalServerError": "Αποτυχία λήψης δεδομένων στατιστικών ήχου"
|
||||
}
|
||||
@@ -3954,11 +3966,19 @@
|
||||
},
|
||||
"showAgent": {
|
||||
"label": "Εμφάνιση έκδοσης agent"
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": ""
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"noRecords": "Δεν βρέθηκαν συστήματα",
|
||||
"error": {
|
||||
"internalServerError": ""
|
||||
"internalServerError": "",
|
||||
"layoutSaveTitle": "",
|
||||
"layoutSaveMessage": ""
|
||||
},
|
||||
"column": {
|
||||
"system": "Σύστημα",
|
||||
@@ -5356,6 +5376,7 @@
|
||||
},
|
||||
"remove": {
|
||||
"label": "Αφαίρεση",
|
||||
"confirm": "",
|
||||
"notification": {
|
||||
"success": {
|
||||
"title": "",
|
||||
|
||||
@@ -2428,10 +2428,21 @@
|
||||
},
|
||||
"descendingDefaultSort": {
|
||||
"label": "Invert sorting"
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": ""
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": "Failed to fetch containers stats"
|
||||
"internalServerError": "Failed to fetch containers stats",
|
||||
"layoutSaveTitle": "",
|
||||
"layoutSaveMessage": ""
|
||||
},
|
||||
"empty": {
|
||||
"noContainers": ""
|
||||
}
|
||||
},
|
||||
"coolify": {
|
||||
@@ -2564,26 +2575,26 @@
|
||||
"description": "Display visitor stats from your Umami analytics instance",
|
||||
"visitors": "visitors",
|
||||
"active": "active",
|
||||
"pageviews": "",
|
||||
"visits": "",
|
||||
"bounceRate": "",
|
||||
"avgDuration": "",
|
||||
"pageviews": "pageviews",
|
||||
"visits": "visits",
|
||||
"bounceRate": "bounce",
|
||||
"avgDuration": "avg",
|
||||
"option": {
|
||||
"websiteId": {
|
||||
"label": "",
|
||||
"description": "",
|
||||
"placeholder": "",
|
||||
"loading": "",
|
||||
"noWebsites": "",
|
||||
"configureIntegrationFirst": ""
|
||||
"label": "Website",
|
||||
"description": "Select a website tracked by your Umami instance",
|
||||
"placeholder": "Select a website",
|
||||
"loading": "Loading websites...",
|
||||
"noWebsites": "No websites found",
|
||||
"configureIntegrationFirst": "Connect a Umami integration first"
|
||||
},
|
||||
"timeFrame": {
|
||||
"label": "",
|
||||
"description": "",
|
||||
"label": "Time frame",
|
||||
"description": "The time period to display visitor data for",
|
||||
"option": {
|
||||
"today": "",
|
||||
"24h": "",
|
||||
"7d": "",
|
||||
"today": "Today",
|
||||
"24h": "Last 24 hours",
|
||||
"7d": "Last 7 days",
|
||||
"30d": "Last 30 days",
|
||||
"month": "This month",
|
||||
"lastMonth": "Last month"
|
||||
@@ -2725,8 +2736,20 @@
|
||||
"showOnlyPlaying": {
|
||||
"label": "Show only currently playing",
|
||||
"description": "Disabling this will not work for Plex"
|
||||
},
|
||||
"showBitrate": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showLocation": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"footer": {
|
||||
"streams": "",
|
||||
"totalBitrate": ""
|
||||
},
|
||||
"items": {
|
||||
"currentlyPlaying": "Currently playing",
|
||||
"user": "User",
|
||||
@@ -2734,6 +2757,9 @@
|
||||
"id": "Id",
|
||||
"status": "Status",
|
||||
"directPlay": "Direct play",
|
||||
"directStream": "",
|
||||
"transcodeVideo": "",
|
||||
"transcodeAudio": "",
|
||||
"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": ""
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"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": "",
|
||||
"layoutSaveMessage": ""
|
||||
},
|
||||
"column": {
|
||||
"system": "System",
|
||||
@@ -5356,6 +5376,7 @@
|
||||
},
|
||||
"remove": {
|
||||
"label": "",
|
||||
"confirm": "",
|
||||
"notification": {
|
||||
"success": {
|
||||
"title": "",
|
||||
|
||||
@@ -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",
|
||||
@@ -3940,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",
|
||||
@@ -5342,6 +5376,7 @@
|
||||
},
|
||||
"remove": {
|
||||
"label": "Remove",
|
||||
"confirm": "Click again to remove permanently",
|
||||
"notification": {
|
||||
"success": {
|
||||
"title": "Containers removed",
|
||||
|
||||
@@ -2428,10 +2428,21 @@
|
||||
},
|
||||
"descendingDefaultSort": {
|
||||
"label": "Invertir orden"
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": ""
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": "No se pudo obtener las estadísticas de los contenedores"
|
||||
"internalServerError": "No se pudo obtener las estadísticas de los contenedores",
|
||||
"layoutSaveTitle": "",
|
||||
"layoutSaveMessage": ""
|
||||
},
|
||||
"empty": {
|
||||
"noContainers": ""
|
||||
}
|
||||
},
|
||||
"coolify": {
|
||||
@@ -2725,8 +2736,20 @@
|
||||
"showOnlyPlaying": {
|
||||
"label": "Mostrar sólo reproducción actual",
|
||||
"description": "Desactivar esta opción no funcionará para plex"
|
||||
},
|
||||
"showBitrate": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showLocation": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"footer": {
|
||||
"streams": "",
|
||||
"totalBitrate": ""
|
||||
},
|
||||
"items": {
|
||||
"currentlyPlaying": "Reproducción en curso",
|
||||
"user": "Usuario",
|
||||
@@ -2734,6 +2757,9 @@
|
||||
"id": "ID",
|
||||
"status": "",
|
||||
"directPlay": "",
|
||||
"directStream": "",
|
||||
"transcodeVideo": "",
|
||||
"transcodeAudio": "",
|
||||
"transcoding": "",
|
||||
"paused": "",
|
||||
"remaining": "",
|
||||
@@ -3768,18 +3794,6 @@
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showNowPlaying": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showNowPlayingList": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"maxNowPlayingItems": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showLibraryCount": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
@@ -3813,8 +3827,6 @@
|
||||
"podcasts": "",
|
||||
"listeningTime": "",
|
||||
"activeSessions": "",
|
||||
"nowPlaying": "",
|
||||
"nowPlayingCount": "",
|
||||
"error": {
|
||||
"internalServerError": ""
|
||||
}
|
||||
@@ -3954,11 +3966,19 @@
|
||||
},
|
||||
"showAgent": {
|
||||
"label": ""
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": ""
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"noRecords": "",
|
||||
"error": {
|
||||
"internalServerError": ""
|
||||
"internalServerError": "",
|
||||
"layoutSaveTitle": "",
|
||||
"layoutSaveMessage": ""
|
||||
},
|
||||
"column": {
|
||||
"system": "",
|
||||
@@ -5356,6 +5376,7 @@
|
||||
},
|
||||
"remove": {
|
||||
"label": "Eliminar",
|
||||
"confirm": "",
|
||||
"notification": {
|
||||
"success": {
|
||||
"title": "Contenedores eliminados",
|
||||
|
||||
@@ -2428,10 +2428,21 @@
|
||||
},
|
||||
"descendingDefaultSort": {
|
||||
"label": ""
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": ""
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": ""
|
||||
"internalServerError": "",
|
||||
"layoutSaveTitle": "",
|
||||
"layoutSaveMessage": ""
|
||||
},
|
||||
"empty": {
|
||||
"noContainers": ""
|
||||
}
|
||||
},
|
||||
"coolify": {
|
||||
@@ -2725,8 +2736,20 @@
|
||||
"showOnlyPlaying": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showBitrate": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showLocation": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"footer": {
|
||||
"streams": "",
|
||||
"totalBitrate": ""
|
||||
},
|
||||
"items": {
|
||||
"currentlyPlaying": "",
|
||||
"user": "",
|
||||
@@ -2734,6 +2757,9 @@
|
||||
"id": "",
|
||||
"status": "",
|
||||
"directPlay": "",
|
||||
"directStream": "",
|
||||
"transcodeVideo": "",
|
||||
"transcodeAudio": "",
|
||||
"transcoding": "",
|
||||
"paused": "",
|
||||
"remaining": "",
|
||||
@@ -3768,18 +3794,6 @@
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showNowPlaying": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showNowPlayingList": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"maxNowPlayingItems": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showLibraryCount": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
@@ -3813,8 +3827,6 @@
|
||||
"podcasts": "",
|
||||
"listeningTime": "",
|
||||
"activeSessions": "",
|
||||
"nowPlaying": "",
|
||||
"nowPlayingCount": "",
|
||||
"error": {
|
||||
"internalServerError": ""
|
||||
}
|
||||
@@ -3954,11 +3966,19 @@
|
||||
},
|
||||
"showAgent": {
|
||||
"label": ""
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": ""
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"noRecords": "",
|
||||
"error": {
|
||||
"internalServerError": ""
|
||||
"internalServerError": "",
|
||||
"layoutSaveTitle": "",
|
||||
"layoutSaveMessage": ""
|
||||
},
|
||||
"column": {
|
||||
"system": "",
|
||||
@@ -5356,6 +5376,7 @@
|
||||
},
|
||||
"remove": {
|
||||
"label": "",
|
||||
"confirm": "",
|
||||
"notification": {
|
||||
"success": {
|
||||
"title": "",
|
||||
|
||||
@@ -2428,10 +2428,21 @@
|
||||
},
|
||||
"descendingDefaultSort": {
|
||||
"label": "Käänteinen järjestys"
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": ""
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": ""
|
||||
"internalServerError": "",
|
||||
"layoutSaveTitle": "",
|
||||
"layoutSaveMessage": ""
|
||||
},
|
||||
"empty": {
|
||||
"noContainers": ""
|
||||
}
|
||||
},
|
||||
"coolify": {
|
||||
@@ -2725,8 +2736,20 @@
|
||||
"showOnlyPlaying": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showBitrate": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showLocation": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"footer": {
|
||||
"streams": "",
|
||||
"totalBitrate": ""
|
||||
},
|
||||
"items": {
|
||||
"currentlyPlaying": "",
|
||||
"user": "",
|
||||
@@ -2734,6 +2757,9 @@
|
||||
"id": "",
|
||||
"status": "Tila",
|
||||
"directPlay": "Muuntamaton toisto",
|
||||
"directStream": "",
|
||||
"transcodeVideo": "",
|
||||
"transcodeAudio": "",
|
||||
"transcoding": "Transkoodaus",
|
||||
"paused": "Keskeytetty",
|
||||
"remaining": "{minutes} min jäljellä",
|
||||
@@ -3768,18 +3794,6 @@
|
||||
"label": "Näytä kappaleet",
|
||||
"description": "Näytä musiikkikirjastosi kappaleiden kokonaismäärä (Navidrome)"
|
||||
},
|
||||
"showNowPlaying": {
|
||||
"label": "Näytä nyt soi",
|
||||
"description": "Näytä tällä hetkellä toistettavat kappaleet (Navidrome)"
|
||||
},
|
||||
"showNowPlayingList": {
|
||||
"label": "Näytä nyt soiva lista",
|
||||
"description": "Näytä yksittäiset kappaleet pelkän aktiivisten striimien määrän sijaan (Navidrome)"
|
||||
},
|
||||
"maxNowPlayingItems": {
|
||||
"label": "Nyt soivien kohteiden enimmäismäärä",
|
||||
"description": "Nyt soivassa listassa näytettävien kappaleiden enimmäismäärä (Navidrome)"
|
||||
},
|
||||
"showLibraryCount": {
|
||||
"label": "Näytä kirjastojen määrä",
|
||||
"description": "Näytä instanssisi kirjastojen lukumäärä (Audiobookshelf)"
|
||||
@@ -3813,8 +3827,6 @@
|
||||
"podcasts": "Podcastit",
|
||||
"listeningTime": "Kuunteluaika",
|
||||
"activeSessions": "Aktiiviset istunnot",
|
||||
"nowPlaying": "Nyt soitetaan",
|
||||
"nowPlayingCount": "{count, plural, one {# striimi} other {# striimiä}}",
|
||||
"error": {
|
||||
"internalServerError": "Audiotilastojen nouto epäonnistui"
|
||||
}
|
||||
@@ -3954,11 +3966,19 @@
|
||||
},
|
||||
"showAgent": {
|
||||
"label": "Näytä agentin versio"
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": ""
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"noRecords": "Järjestelmiä ei löytynyt",
|
||||
"error": {
|
||||
"internalServerError": "Beszel-yhteyden muodostaminen epäonnistui. Tarkista integraatioasetuksesi."
|
||||
"internalServerError": "Beszel-yhteyden muodostaminen epäonnistui. Tarkista integraatioasetuksesi.",
|
||||
"layoutSaveTitle": "",
|
||||
"layoutSaveMessage": ""
|
||||
},
|
||||
"column": {
|
||||
"system": "Järjestelmä",
|
||||
@@ -5356,6 +5376,7 @@
|
||||
},
|
||||
"remove": {
|
||||
"label": "Poista",
|
||||
"confirm": "",
|
||||
"notification": {
|
||||
"success": {
|
||||
"title": "",
|
||||
|
||||
@@ -2428,10 +2428,21 @@
|
||||
},
|
||||
"descendingDefaultSort": {
|
||||
"label": "Inverser le tri"
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": ""
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": "Impossible de récupérer les statistiques des conteneurs"
|
||||
"internalServerError": "Impossible de récupérer les statistiques des conteneurs",
|
||||
"layoutSaveTitle": "",
|
||||
"layoutSaveMessage": ""
|
||||
},
|
||||
"empty": {
|
||||
"noContainers": ""
|
||||
}
|
||||
},
|
||||
"coolify": {
|
||||
@@ -2725,8 +2736,20 @@
|
||||
"showOnlyPlaying": {
|
||||
"label": "Afficher seulement les médias en cours de lecture",
|
||||
"description": "Désactiver cette option ne fonctionnera pas pour Plex"
|
||||
},
|
||||
"showBitrate": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showLocation": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"footer": {
|
||||
"streams": "",
|
||||
"totalBitrate": ""
|
||||
},
|
||||
"items": {
|
||||
"currentlyPlaying": "En cours de lecture",
|
||||
"user": "Utilisateur",
|
||||
@@ -2734,6 +2757,9 @@
|
||||
"id": "Id",
|
||||
"status": "",
|
||||
"directPlay": "",
|
||||
"directStream": "",
|
||||
"transcodeVideo": "",
|
||||
"transcodeAudio": "",
|
||||
"transcoding": "",
|
||||
"paused": "",
|
||||
"remaining": "",
|
||||
@@ -3768,18 +3794,6 @@
|
||||
"label": "Afficher les titres",
|
||||
"description": ""
|
||||
},
|
||||
"showNowPlaying": {
|
||||
"label": "Afficher la lecture en cours",
|
||||
"description": ""
|
||||
},
|
||||
"showNowPlayingList": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"maxNowPlayingItems": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showLibraryCount": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
@@ -3813,8 +3827,6 @@
|
||||
"podcasts": "",
|
||||
"listeningTime": "",
|
||||
"activeSessions": "",
|
||||
"nowPlaying": "",
|
||||
"nowPlayingCount": "",
|
||||
"error": {
|
||||
"internalServerError": ""
|
||||
}
|
||||
@@ -3954,11 +3966,19 @@
|
||||
},
|
||||
"showAgent": {
|
||||
"label": ""
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": ""
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"noRecords": "",
|
||||
"error": {
|
||||
"internalServerError": ""
|
||||
"internalServerError": "",
|
||||
"layoutSaveTitle": "",
|
||||
"layoutSaveMessage": ""
|
||||
},
|
||||
"column": {
|
||||
"system": "",
|
||||
@@ -5356,6 +5376,7 @@
|
||||
},
|
||||
"remove": {
|
||||
"label": "Supprimer",
|
||||
"confirm": "",
|
||||
"notification": {
|
||||
"success": {
|
||||
"title": "Conteneurs supprimés",
|
||||
|
||||
@@ -2428,10 +2428,21 @@
|
||||
},
|
||||
"descendingDefaultSort": {
|
||||
"label": "מיון הפוך"
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": ""
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": "נכשל באחזור סטטיסטיקות המכולות"
|
||||
"internalServerError": "נכשל באחזור סטטיסטיקות המכולות",
|
||||
"layoutSaveTitle": "",
|
||||
"layoutSaveMessage": ""
|
||||
},
|
||||
"empty": {
|
||||
"noContainers": ""
|
||||
}
|
||||
},
|
||||
"coolify": {
|
||||
@@ -2725,8 +2736,20 @@
|
||||
"showOnlyPlaying": {
|
||||
"label": "הצג רק את ההפעלה הנוכחית",
|
||||
"description": "השבתת אפשרות זו לא תעבוד עבור פלקס"
|
||||
},
|
||||
"showBitrate": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showLocation": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"footer": {
|
||||
"streams": "",
|
||||
"totalBitrate": ""
|
||||
},
|
||||
"items": {
|
||||
"currentlyPlaying": "מתנגן כרגע",
|
||||
"user": "משתמש",
|
||||
@@ -2734,6 +2757,9 @@
|
||||
"id": "מזהה",
|
||||
"status": "",
|
||||
"directPlay": "",
|
||||
"directStream": "",
|
||||
"transcodeVideo": "",
|
||||
"transcodeAudio": "",
|
||||
"transcoding": "",
|
||||
"paused": "",
|
||||
"remaining": "",
|
||||
@@ -3768,18 +3794,6 @@
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showNowPlaying": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showNowPlayingList": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"maxNowPlayingItems": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showLibraryCount": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
@@ -3813,8 +3827,6 @@
|
||||
"podcasts": "",
|
||||
"listeningTime": "",
|
||||
"activeSessions": "",
|
||||
"nowPlaying": "",
|
||||
"nowPlayingCount": "",
|
||||
"error": {
|
||||
"internalServerError": ""
|
||||
}
|
||||
@@ -3954,11 +3966,19 @@
|
||||
},
|
||||
"showAgent": {
|
||||
"label": ""
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": ""
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"noRecords": "",
|
||||
"error": {
|
||||
"internalServerError": ""
|
||||
"internalServerError": "",
|
||||
"layoutSaveTitle": "",
|
||||
"layoutSaveMessage": ""
|
||||
},
|
||||
"column": {
|
||||
"system": "",
|
||||
@@ -5356,6 +5376,7 @@
|
||||
},
|
||||
"remove": {
|
||||
"label": "הסר",
|
||||
"confirm": "",
|
||||
"notification": {
|
||||
"success": {
|
||||
"title": "הקונטיינרים הוסרו",
|
||||
|
||||
@@ -2428,10 +2428,21 @@
|
||||
},
|
||||
"descendingDefaultSort": {
|
||||
"label": ""
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": ""
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": ""
|
||||
"internalServerError": "",
|
||||
"layoutSaveTitle": "",
|
||||
"layoutSaveMessage": ""
|
||||
},
|
||||
"empty": {
|
||||
"noContainers": ""
|
||||
}
|
||||
},
|
||||
"coolify": {
|
||||
@@ -2725,8 +2736,20 @@
|
||||
"showOnlyPlaying": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showBitrate": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showLocation": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"footer": {
|
||||
"streams": "",
|
||||
"totalBitrate": ""
|
||||
},
|
||||
"items": {
|
||||
"currentlyPlaying": "",
|
||||
"user": "",
|
||||
@@ -2734,6 +2757,9 @@
|
||||
"id": "",
|
||||
"status": "",
|
||||
"directPlay": "",
|
||||
"directStream": "",
|
||||
"transcodeVideo": "",
|
||||
"transcodeAudio": "",
|
||||
"transcoding": "",
|
||||
"paused": "",
|
||||
"remaining": "",
|
||||
@@ -3768,18 +3794,6 @@
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showNowPlaying": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showNowPlayingList": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"maxNowPlayingItems": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showLibraryCount": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
@@ -3813,8 +3827,6 @@
|
||||
"podcasts": "",
|
||||
"listeningTime": "",
|
||||
"activeSessions": "",
|
||||
"nowPlaying": "",
|
||||
"nowPlayingCount": "",
|
||||
"error": {
|
||||
"internalServerError": ""
|
||||
}
|
||||
@@ -3954,11 +3966,19 @@
|
||||
},
|
||||
"showAgent": {
|
||||
"label": "Prikaži verziju agenta"
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": ""
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"noRecords": "Nisu pronađeni sustavi",
|
||||
"error": {
|
||||
"internalServerError": ""
|
||||
"internalServerError": "",
|
||||
"layoutSaveTitle": "",
|
||||
"layoutSaveMessage": ""
|
||||
},
|
||||
"column": {
|
||||
"system": "Sustav",
|
||||
@@ -5356,6 +5376,7 @@
|
||||
},
|
||||
"remove": {
|
||||
"label": "Ukloni",
|
||||
"confirm": "",
|
||||
"notification": {
|
||||
"success": {
|
||||
"title": "Kontejneri su uklonjeni",
|
||||
|
||||
@@ -2428,10 +2428,21 @@
|
||||
},
|
||||
"descendingDefaultSort": {
|
||||
"label": "Fordított rendezés"
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": ""
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": "A konténerek statisztikáinak lekérése sikertelen"
|
||||
"internalServerError": "A konténerek statisztikáinak lekérése sikertelen",
|
||||
"layoutSaveTitle": "",
|
||||
"layoutSaveMessage": ""
|
||||
},
|
||||
"empty": {
|
||||
"noContainers": ""
|
||||
}
|
||||
},
|
||||
"coolify": {
|
||||
@@ -2725,8 +2736,20 @@
|
||||
"showOnlyPlaying": {
|
||||
"label": "Csak a jelenleg lejátszottakat mutasd",
|
||||
"description": "Ennek letiltása a Plex esetében nem fog működni"
|
||||
},
|
||||
"showBitrate": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showLocation": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"footer": {
|
||||
"streams": "",
|
||||
"totalBitrate": ""
|
||||
},
|
||||
"items": {
|
||||
"currentlyPlaying": "Jelenleg lejátszás alatt",
|
||||
"user": "Felhasználó",
|
||||
@@ -2734,6 +2757,9 @@
|
||||
"id": "Azonosító",
|
||||
"status": "",
|
||||
"directPlay": "",
|
||||
"directStream": "",
|
||||
"transcodeVideo": "",
|
||||
"transcodeAudio": "",
|
||||
"transcoding": "",
|
||||
"paused": "",
|
||||
"remaining": "",
|
||||
@@ -3768,18 +3794,6 @@
|
||||
"label": "Zeneszámok megjelenítése",
|
||||
"description": "A zeneszámok teljes számának megjelenítése a zenekönyvtárban (Navidrome)"
|
||||
},
|
||||
"showNowPlaying": {
|
||||
"label": "Most játszott megjelenítése",
|
||||
"description": "A jelenleg játszott sávok megjelenítése (Navidrome)"
|
||||
},
|
||||
"showNowPlayingList": {
|
||||
"label": "Most játszott lista megjelenítése",
|
||||
"description": "Egyes sávok megjelenítése a csak aktív streamszám helyett (Navidrome)"
|
||||
},
|
||||
"maxNowPlayingItems": {
|
||||
"label": "Maximális most játszott elemek",
|
||||
"description": "A most játszott listában megjelenítendő sávok maximális száma (Navidrome)"
|
||||
},
|
||||
"showLibraryCount": {
|
||||
"label": "Könyvtárak számának megjelenítése",
|
||||
"description": "A példányodon lévő könyvtárak számának megjelenítése (Audiobookshelf)"
|
||||
@@ -3813,8 +3827,6 @@
|
||||
"podcasts": "Podcastek",
|
||||
"listeningTime": "Hallgatási idő",
|
||||
"activeSessions": "Aktív munkamenetek",
|
||||
"nowPlaying": "Most játszott",
|
||||
"nowPlayingCount": "{count, plural, one {# stream} other {# stream}}",
|
||||
"error": {
|
||||
"internalServerError": "Az audió statisztikai adatok letöltése nem sikerült"
|
||||
}
|
||||
@@ -3954,11 +3966,19 @@
|
||||
},
|
||||
"showAgent": {
|
||||
"label": "Agent verzió mutatása"
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": ""
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"noRecords": "Nincsenek rendszerek",
|
||||
"error": {
|
||||
"internalServerError": ""
|
||||
"internalServerError": "",
|
||||
"layoutSaveTitle": "",
|
||||
"layoutSaveMessage": ""
|
||||
},
|
||||
"column": {
|
||||
"system": "Rendszer",
|
||||
@@ -5356,6 +5376,7 @@
|
||||
},
|
||||
"remove": {
|
||||
"label": "Eltávolítás",
|
||||
"confirm": "",
|
||||
"notification": {
|
||||
"success": {
|
||||
"title": "Konténerek eltávolítva",
|
||||
|
||||
@@ -2428,10 +2428,21 @@
|
||||
},
|
||||
"descendingDefaultSort": {
|
||||
"label": "Inverti ordinamento"
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": "Ordine delle colonne"
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": "Larghezza delle colonne"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": "Recupero statistiche container non riuscito"
|
||||
"internalServerError": "Recupero statistiche container non riuscito",
|
||||
"layoutSaveTitle": "Layout della tabella non salvato",
|
||||
"layoutSaveMessage": "Impossibile salvare il layout della tabella Docker. Prova a spostare o ridimensionare nuovamente la colonna."
|
||||
},
|
||||
"empty": {
|
||||
"noContainers": "Nessun container trovato"
|
||||
}
|
||||
},
|
||||
"coolify": {
|
||||
@@ -2725,8 +2736,20 @@
|
||||
"showOnlyPlaying": {
|
||||
"label": "Mostra solo attualmente in riproduzione",
|
||||
"description": "La disabilitazione non funzionerà per Plex"
|
||||
},
|
||||
"showBitrate": {
|
||||
"label": "Mostra bitrate",
|
||||
"description": "Mostra il bitrate di riproduzione corrente accanto alla posizione"
|
||||
},
|
||||
"showLocation": {
|
||||
"label": "Mostra posizione",
|
||||
"description": "Mostra se lo stream si trova sulla tua LAN o WAN"
|
||||
}
|
||||
},
|
||||
"footer": {
|
||||
"streams": "{count, plural, one {# stream attivo} other {# stream attivi}}",
|
||||
"totalBitrate": "Totale: {bitrate}"
|
||||
},
|
||||
"items": {
|
||||
"currentlyPlaying": "Attualmente in riproduzione",
|
||||
"user": "Utente",
|
||||
@@ -2734,6 +2757,9 @@
|
||||
"id": "Id",
|
||||
"status": "Stato",
|
||||
"directPlay": "Riproduzione diretta",
|
||||
"directStream": "Streaming diretto",
|
||||
"transcodeVideo": "Transcodifica video",
|
||||
"transcodeAudio": "Transcodifica audio",
|
||||
"transcoding": "Transcodifica",
|
||||
"paused": "In pausa",
|
||||
"remaining": "{minutes} min rimanenti",
|
||||
@@ -3768,18 +3794,6 @@
|
||||
"label": "Mostra brani",
|
||||
"description": "Mostra il numero totale di brani nella tua libreria musicale (Navidrome)"
|
||||
},
|
||||
"showNowPlaying": {
|
||||
"label": "Mostra in riproduzione",
|
||||
"description": "Mostra le tracce attualmente in riproduzione (Navidrome)"
|
||||
},
|
||||
"showNowPlayingList": {
|
||||
"label": "Mostra elenco in riproduzione",
|
||||
"description": "Mostra le singole tracce invece del solo conteggio degli stream attivi (Navidrome)"
|
||||
},
|
||||
"maxNowPlayingItems": {
|
||||
"label": "Numero massimo elementi in riproduzione",
|
||||
"description": "Numero massimo di tracce da mostrare nell'elenco in riproduzione (Navidrome)"
|
||||
},
|
||||
"showLibraryCount": {
|
||||
"label": "Mostra conteggio librerie",
|
||||
"description": "Mostra il numero di librerie sulla tua istanza (Audiobookshelf)"
|
||||
@@ -3813,8 +3827,6 @@
|
||||
"podcasts": "Podcast",
|
||||
"listeningTime": "Tempo di Ascolto",
|
||||
"activeSessions": "Sessioni Attive",
|
||||
"nowPlaying": "In Riproduzione",
|
||||
"nowPlayingCount": "{count, plural, one {# stream} other {# streams}}",
|
||||
"error": {
|
||||
"internalServerError": "Impossibile recuperare i dati delle statistiche audio"
|
||||
}
|
||||
@@ -3954,11 +3966,19 @@
|
||||
},
|
||||
"showAgent": {
|
||||
"label": "Mostra versione agente"
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": "Ordine delle colonne"
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": "Larghezza delle colonne"
|
||||
}
|
||||
},
|
||||
"noRecords": "Nessun sistema trovato",
|
||||
"error": {
|
||||
"internalServerError": "Impossibile connettersi a Beszel. Controlla le tue impostazioni di integrazione."
|
||||
"internalServerError": "Impossibile connettersi a Beszel. Controlla le tue impostazioni di integrazione.",
|
||||
"layoutSaveTitle": "Impossibile salvare il layout della tabella",
|
||||
"layoutSaveMessage": "Impossibile salvare l'ordine o la larghezza delle colonne. Riprova."
|
||||
},
|
||||
"column": {
|
||||
"system": "Sistema",
|
||||
@@ -5356,6 +5376,7 @@
|
||||
},
|
||||
"remove": {
|
||||
"label": "Rimuovi",
|
||||
"confirm": "Fare clic di nuovo per rimuovere definitivamente",
|
||||
"notification": {
|
||||
"success": {
|
||||
"title": "Containers rimossi",
|
||||
|
||||
@@ -2428,10 +2428,21 @@
|
||||
},
|
||||
"descendingDefaultSort": {
|
||||
"label": "並び替えを反転"
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": ""
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": "コンテナステータスの取得に失敗しました"
|
||||
"internalServerError": "コンテナステータスの取得に失敗しました",
|
||||
"layoutSaveTitle": "",
|
||||
"layoutSaveMessage": ""
|
||||
},
|
||||
"empty": {
|
||||
"noContainers": ""
|
||||
}
|
||||
},
|
||||
"coolify": {
|
||||
@@ -2725,8 +2736,20 @@
|
||||
"showOnlyPlaying": {
|
||||
"label": "現在再生中のみ表示",
|
||||
"description": "これを無効にすると plex では動作しません"
|
||||
},
|
||||
"showBitrate": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showLocation": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"footer": {
|
||||
"streams": "",
|
||||
"totalBitrate": ""
|
||||
},
|
||||
"items": {
|
||||
"currentlyPlaying": "再生中の曲",
|
||||
"user": "ユーザー",
|
||||
@@ -2734,6 +2757,9 @@
|
||||
"id": "Id",
|
||||
"status": "",
|
||||
"directPlay": "",
|
||||
"directStream": "",
|
||||
"transcodeVideo": "",
|
||||
"transcodeAudio": "",
|
||||
"transcoding": "",
|
||||
"paused": "",
|
||||
"remaining": "",
|
||||
@@ -3768,18 +3794,6 @@
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showNowPlaying": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showNowPlayingList": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"maxNowPlayingItems": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showLibraryCount": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
@@ -3813,8 +3827,6 @@
|
||||
"podcasts": "",
|
||||
"listeningTime": "",
|
||||
"activeSessions": "",
|
||||
"nowPlaying": "",
|
||||
"nowPlayingCount": "",
|
||||
"error": {
|
||||
"internalServerError": ""
|
||||
}
|
||||
@@ -3954,11 +3966,19 @@
|
||||
},
|
||||
"showAgent": {
|
||||
"label": ""
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": ""
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"noRecords": "",
|
||||
"error": {
|
||||
"internalServerError": ""
|
||||
"internalServerError": "",
|
||||
"layoutSaveTitle": "",
|
||||
"layoutSaveMessage": ""
|
||||
},
|
||||
"column": {
|
||||
"system": "",
|
||||
@@ -5356,6 +5376,7 @@
|
||||
},
|
||||
"remove": {
|
||||
"label": "削除",
|
||||
"confirm": "",
|
||||
"notification": {
|
||||
"success": {
|
||||
"title": "コンテナー削除",
|
||||
|
||||
@@ -2428,10 +2428,21 @@
|
||||
},
|
||||
"descendingDefaultSort": {
|
||||
"label": "역순 정렬"
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": ""
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": "컨테이너 상태 불러오기 실패"
|
||||
"internalServerError": "컨테이너 상태 불러오기 실패",
|
||||
"layoutSaveTitle": "",
|
||||
"layoutSaveMessage": ""
|
||||
},
|
||||
"empty": {
|
||||
"noContainers": ""
|
||||
}
|
||||
},
|
||||
"coolify": {
|
||||
@@ -2725,8 +2736,20 @@
|
||||
"showOnlyPlaying": {
|
||||
"label": "현재 재생 중 항목만 표시",
|
||||
"description": "비활성화 옵션은 plex에서는 지원되지 않음"
|
||||
},
|
||||
"showBitrate": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showLocation": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"footer": {
|
||||
"streams": "",
|
||||
"totalBitrate": ""
|
||||
},
|
||||
"items": {
|
||||
"currentlyPlaying": "현재 재생 중",
|
||||
"user": "사용자",
|
||||
@@ -2734,6 +2757,9 @@
|
||||
"id": "아이디",
|
||||
"status": "상태",
|
||||
"directPlay": "직접 재생",
|
||||
"directStream": "",
|
||||
"transcodeVideo": "",
|
||||
"transcodeAudio": "",
|
||||
"transcoding": "트랜스코딩",
|
||||
"paused": "일시 중지됨",
|
||||
"remaining": "남은 시간 {minutes}분",
|
||||
@@ -3768,18 +3794,6 @@
|
||||
"label": "음원 표시",
|
||||
"description": "음악 라이브러리에 있는 음원 총 수를 표시 (Navidrome)"
|
||||
},
|
||||
"showNowPlaying": {
|
||||
"label": "현재 재생 중 표시",
|
||||
"description": "현재 재생 중인 트랙을 표시 (Navidrome)"
|
||||
},
|
||||
"showNowPlayingList": {
|
||||
"label": "현재 재생 목록 표시",
|
||||
"description": "활성 스트림 수 대신 개별 트랙 표시 (Navidrome)"
|
||||
},
|
||||
"maxNowPlayingItems": {
|
||||
"label": "최대 현재 재생 항목 수",
|
||||
"description": "현재 재생 목록에 표시할 최대 트랙 수 (Navidrome)"
|
||||
},
|
||||
"showLibraryCount": {
|
||||
"label": "라이브러리 수 표시",
|
||||
"description": "인스턴스에 있는 라이브러리 수 표시 (Audiobookshelf)"
|
||||
@@ -3813,8 +3827,6 @@
|
||||
"podcasts": "팟캐스트",
|
||||
"listeningTime": "청취 시간",
|
||||
"activeSessions": "활성 세션",
|
||||
"nowPlaying": "현재 재생 중",
|
||||
"nowPlayingCount": "{count, plural, one {# 스트림} other {# 스트림}}",
|
||||
"error": {
|
||||
"internalServerError": "오디오 정보 가져오기 실패"
|
||||
}
|
||||
@@ -3954,11 +3966,19 @@
|
||||
},
|
||||
"showAgent": {
|
||||
"label": "에이전트 버전 표시"
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": ""
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"noRecords": "시스템을 찾을 수 없음",
|
||||
"error": {
|
||||
"internalServerError": "Beszel에 연결할 수 없습니다. 통합 설정을 확인하십시오."
|
||||
"internalServerError": "Beszel에 연결할 수 없습니다. 통합 설정을 확인하십시오.",
|
||||
"layoutSaveTitle": "",
|
||||
"layoutSaveMessage": ""
|
||||
},
|
||||
"column": {
|
||||
"system": "시스템",
|
||||
@@ -5356,6 +5376,7 @@
|
||||
},
|
||||
"remove": {
|
||||
"label": "제거",
|
||||
"confirm": "",
|
||||
"notification": {
|
||||
"success": {
|
||||
"title": "컨테이너 제거됨",
|
||||
|
||||
@@ -2428,10 +2428,21 @@
|
||||
},
|
||||
"descendingDefaultSort": {
|
||||
"label": ""
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": ""
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": ""
|
||||
"internalServerError": "",
|
||||
"layoutSaveTitle": "",
|
||||
"layoutSaveMessage": ""
|
||||
},
|
||||
"empty": {
|
||||
"noContainers": ""
|
||||
}
|
||||
},
|
||||
"coolify": {
|
||||
@@ -2725,8 +2736,20 @@
|
||||
"showOnlyPlaying": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showBitrate": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showLocation": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"footer": {
|
||||
"streams": "",
|
||||
"totalBitrate": ""
|
||||
},
|
||||
"items": {
|
||||
"currentlyPlaying": "",
|
||||
"user": "",
|
||||
@@ -2734,6 +2757,9 @@
|
||||
"id": "",
|
||||
"status": "",
|
||||
"directPlay": "",
|
||||
"directStream": "",
|
||||
"transcodeVideo": "",
|
||||
"transcodeAudio": "",
|
||||
"transcoding": "",
|
||||
"paused": "",
|
||||
"remaining": "",
|
||||
@@ -3768,18 +3794,6 @@
|
||||
"label": "Rodyti dainas",
|
||||
"description": "Rodyti bendrą dainų skaičių savo muzikos bibliotekoje (Navidrome)"
|
||||
},
|
||||
"showNowPlaying": {
|
||||
"label": "Rodyti dabar grojamą",
|
||||
"description": "Rodyti šiuo metu grojamas dainas (Navidrome)"
|
||||
},
|
||||
"showNowPlayingList": {
|
||||
"label": "Rodyti dabar grojamų sąrašą",
|
||||
"description": "Rodyti atskiras dainas vietoj tik aktyvių srautų skaičiaus (Navidrome)"
|
||||
},
|
||||
"maxNowPlayingItems": {
|
||||
"label": "Maks. dabar grojamų elementų",
|
||||
"description": "Didžiausias rodomų dainų skaičius dabar grojamų sąraše (Navidrome)"
|
||||
},
|
||||
"showLibraryCount": {
|
||||
"label": "Rodyti bibliotekų skaičių",
|
||||
"description": "Rodyti bibliotekų skaičių savo egzemplioriuje (Audiobookshelf)"
|
||||
@@ -3813,8 +3827,6 @@
|
||||
"podcasts": "",
|
||||
"listeningTime": "",
|
||||
"activeSessions": "",
|
||||
"nowPlaying": "",
|
||||
"nowPlayingCount": "",
|
||||
"error": {
|
||||
"internalServerError": ""
|
||||
}
|
||||
@@ -3954,11 +3966,19 @@
|
||||
},
|
||||
"showAgent": {
|
||||
"label": "Rodyti agento versiją"
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": ""
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"noRecords": "Sistemų nerasta",
|
||||
"error": {
|
||||
"internalServerError": ""
|
||||
"internalServerError": "",
|
||||
"layoutSaveTitle": "",
|
||||
"layoutSaveMessage": ""
|
||||
},
|
||||
"column": {
|
||||
"system": "Sistema",
|
||||
@@ -5356,6 +5376,7 @@
|
||||
},
|
||||
"remove": {
|
||||
"label": "Pašalinti",
|
||||
"confirm": "",
|
||||
"notification": {
|
||||
"success": {
|
||||
"title": "",
|
||||
|
||||
@@ -2428,10 +2428,21 @@
|
||||
},
|
||||
"descendingDefaultSort": {
|
||||
"label": ""
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": ""
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": ""
|
||||
"internalServerError": "",
|
||||
"layoutSaveTitle": "",
|
||||
"layoutSaveMessage": ""
|
||||
},
|
||||
"empty": {
|
||||
"noContainers": ""
|
||||
}
|
||||
},
|
||||
"coolify": {
|
||||
@@ -2725,8 +2736,20 @@
|
||||
"showOnlyPlaying": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showBitrate": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showLocation": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"footer": {
|
||||
"streams": "",
|
||||
"totalBitrate": ""
|
||||
},
|
||||
"items": {
|
||||
"currentlyPlaying": "",
|
||||
"user": "",
|
||||
@@ -2734,6 +2757,9 @@
|
||||
"id": "",
|
||||
"status": "",
|
||||
"directPlay": "",
|
||||
"directStream": "",
|
||||
"transcodeVideo": "",
|
||||
"transcodeAudio": "",
|
||||
"transcoding": "",
|
||||
"paused": "",
|
||||
"remaining": "",
|
||||
@@ -3768,18 +3794,6 @@
|
||||
"label": "Rādīt dziesmas",
|
||||
"description": "Parādīt kopējo dziesmu skaitu mūzikas bibliotēkā (Navidrome)"
|
||||
},
|
||||
"showNowPlaying": {
|
||||
"label": "Rādīt pašlaik atskaņoto",
|
||||
"description": "Parādīt šobrīd atskaņotās dziesmas (Navidrome)"
|
||||
},
|
||||
"showNowPlayingList": {
|
||||
"label": "Rādīt pašlaik atskaņoto sarakstu",
|
||||
"description": "Rādīt atsevišķas dziesmas, nevis tikai aktīvo straumju skaitu (Navidrome)"
|
||||
},
|
||||
"maxNowPlayingItems": {
|
||||
"label": "Maks. pašlaik atskaņoto vienumu skaits",
|
||||
"description": "Maksimālais dziesmu skaits, ko rādīt pašlaik atskaņotajā sarakstā (Navidrome)"
|
||||
},
|
||||
"showLibraryCount": {
|
||||
"label": "Rādīt bibliotēku skaitu",
|
||||
"description": "Parādīt bibliotēku skaitu jūsu instancē (Audiobookshelf)"
|
||||
@@ -3813,8 +3827,6 @@
|
||||
"podcasts": "Podkāsti",
|
||||
"listeningTime": "Klausīšanās laiks",
|
||||
"activeSessions": "Aktīvās sesijas",
|
||||
"nowPlaying": "Atskaņo pašlaik",
|
||||
"nowPlayingCount": "{count, plural, zero {# straumju} one {# straume} other {# straumes}}",
|
||||
"error": {
|
||||
"internalServerError": "Neizdevās iegūt audio statistikas datus"
|
||||
}
|
||||
@@ -3954,11 +3966,19 @@
|
||||
},
|
||||
"showAgent": {
|
||||
"label": "Rādīt aģenta versiju"
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": ""
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"noRecords": "Nav atrasta neviena sistēma",
|
||||
"error": {
|
||||
"internalServerError": ""
|
||||
"internalServerError": "",
|
||||
"layoutSaveTitle": "",
|
||||
"layoutSaveMessage": ""
|
||||
},
|
||||
"column": {
|
||||
"system": "Sistēma",
|
||||
@@ -5356,6 +5376,7 @@
|
||||
},
|
||||
"remove": {
|
||||
"label": "Noņemt",
|
||||
"confirm": "",
|
||||
"notification": {
|
||||
"success": {
|
||||
"title": "",
|
||||
|
||||
@@ -2428,10 +2428,21 @@
|
||||
},
|
||||
"descendingDefaultSort": {
|
||||
"label": "Sorteren omkeren"
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": ""
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": "Ophalen van containerstatistieken mislukt"
|
||||
"internalServerError": "Ophalen van containerstatistieken mislukt",
|
||||
"layoutSaveTitle": "",
|
||||
"layoutSaveMessage": ""
|
||||
},
|
||||
"empty": {
|
||||
"noContainers": ""
|
||||
}
|
||||
},
|
||||
"coolify": {
|
||||
@@ -2725,8 +2736,20 @@
|
||||
"showOnlyPlaying": {
|
||||
"label": "Alleen huidig spelende weergeven",
|
||||
"description": "Dit uitschakelen werkt niet voor Plex"
|
||||
},
|
||||
"showBitrate": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showLocation": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"footer": {
|
||||
"streams": "",
|
||||
"totalBitrate": ""
|
||||
},
|
||||
"items": {
|
||||
"currentlyPlaying": "Momenteel aan het afspelen",
|
||||
"user": "Gebruiker",
|
||||
@@ -2734,6 +2757,9 @@
|
||||
"id": "Id",
|
||||
"status": "",
|
||||
"directPlay": "",
|
||||
"directStream": "",
|
||||
"transcodeVideo": "",
|
||||
"transcodeAudio": "",
|
||||
"transcoding": "",
|
||||
"paused": "",
|
||||
"remaining": "",
|
||||
@@ -3768,18 +3794,6 @@
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showNowPlaying": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showNowPlayingList": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"maxNowPlayingItems": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showLibraryCount": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
@@ -3813,8 +3827,6 @@
|
||||
"podcasts": "",
|
||||
"listeningTime": "",
|
||||
"activeSessions": "",
|
||||
"nowPlaying": "",
|
||||
"nowPlayingCount": "",
|
||||
"error": {
|
||||
"internalServerError": ""
|
||||
}
|
||||
@@ -3954,11 +3966,19 @@
|
||||
},
|
||||
"showAgent": {
|
||||
"label": "Toon agentversie"
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": ""
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"noRecords": "Geen systemen gevonden",
|
||||
"error": {
|
||||
"internalServerError": ""
|
||||
"internalServerError": "",
|
||||
"layoutSaveTitle": "",
|
||||
"layoutSaveMessage": ""
|
||||
},
|
||||
"column": {
|
||||
"system": "Systeem",
|
||||
@@ -5356,6 +5376,7 @@
|
||||
},
|
||||
"remove": {
|
||||
"label": "Verwijderen",
|
||||
"confirm": "",
|
||||
"notification": {
|
||||
"success": {
|
||||
"title": "Containers verwijderd",
|
||||
|
||||
@@ -2428,10 +2428,21 @@
|
||||
},
|
||||
"descendingDefaultSort": {
|
||||
"label": "Reverser sortering"
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": ""
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": "Kunne ikke hente containerstatistikk"
|
||||
"internalServerError": "Kunne ikke hente containerstatistikk",
|
||||
"layoutSaveTitle": "",
|
||||
"layoutSaveMessage": ""
|
||||
},
|
||||
"empty": {
|
||||
"noContainers": ""
|
||||
}
|
||||
},
|
||||
"coolify": {
|
||||
@@ -2725,8 +2736,20 @@
|
||||
"showOnlyPlaying": {
|
||||
"label": "Vis kun det som spiller nå",
|
||||
"description": "Å deaktivere dette fungerer ikke for Plex"
|
||||
},
|
||||
"showBitrate": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showLocation": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"footer": {
|
||||
"streams": "",
|
||||
"totalBitrate": ""
|
||||
},
|
||||
"items": {
|
||||
"currentlyPlaying": "Spilles nå",
|
||||
"user": "Bruker",
|
||||
@@ -2734,6 +2757,9 @@
|
||||
"id": "Id",
|
||||
"status": "",
|
||||
"directPlay": "",
|
||||
"directStream": "",
|
||||
"transcodeVideo": "",
|
||||
"transcodeAudio": "",
|
||||
"transcoding": "",
|
||||
"paused": "",
|
||||
"remaining": "",
|
||||
@@ -3768,18 +3794,6 @@
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showNowPlaying": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showNowPlayingList": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"maxNowPlayingItems": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showLibraryCount": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
@@ -3813,8 +3827,6 @@
|
||||
"podcasts": "",
|
||||
"listeningTime": "",
|
||||
"activeSessions": "",
|
||||
"nowPlaying": "",
|
||||
"nowPlayingCount": "",
|
||||
"error": {
|
||||
"internalServerError": ""
|
||||
}
|
||||
@@ -3954,11 +3966,19 @@
|
||||
},
|
||||
"showAgent": {
|
||||
"label": ""
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": ""
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"noRecords": "",
|
||||
"error": {
|
||||
"internalServerError": ""
|
||||
"internalServerError": "",
|
||||
"layoutSaveTitle": "",
|
||||
"layoutSaveMessage": ""
|
||||
},
|
||||
"column": {
|
||||
"system": "",
|
||||
@@ -5356,6 +5376,7 @@
|
||||
},
|
||||
"remove": {
|
||||
"label": "Fjern",
|
||||
"confirm": "",
|
||||
"notification": {
|
||||
"success": {
|
||||
"title": "Containere fjernet",
|
||||
|
||||
@@ -2428,10 +2428,21 @@
|
||||
},
|
||||
"descendingDefaultSort": {
|
||||
"label": "Odwróć sortowanie"
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": ""
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": "Nie udało się pobrać statystyk kontenerów"
|
||||
"internalServerError": "Nie udało się pobrać statystyk kontenerów",
|
||||
"layoutSaveTitle": "",
|
||||
"layoutSaveMessage": ""
|
||||
},
|
||||
"empty": {
|
||||
"noContainers": ""
|
||||
}
|
||||
},
|
||||
"coolify": {
|
||||
@@ -2725,8 +2736,20 @@
|
||||
"showOnlyPlaying": {
|
||||
"label": "Pokaż tylko aktualnie odtwarzane",
|
||||
"description": "Wyłączenie tej opcji nie zadziała w przypadku Plex"
|
||||
},
|
||||
"showBitrate": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showLocation": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"footer": {
|
||||
"streams": "",
|
||||
"totalBitrate": ""
|
||||
},
|
||||
"items": {
|
||||
"currentlyPlaying": "Obecnie odtwarzane",
|
||||
"user": "Użytkownik",
|
||||
@@ -2734,6 +2757,9 @@
|
||||
"id": "ID",
|
||||
"status": "Status",
|
||||
"directPlay": "Odtwarzanie bezpośrednie",
|
||||
"directStream": "",
|
||||
"transcodeVideo": "",
|
||||
"transcodeAudio": "",
|
||||
"transcoding": "Transkodowanie",
|
||||
"paused": "Zatrzymane",
|
||||
"remaining": "{minutes} min pozostało",
|
||||
@@ -3768,18 +3794,6 @@
|
||||
"label": "Pokaż utwory",
|
||||
"description": "Pokaż całkowitą liczbę utworów w twojej bibliotece muzycznej (Navidrome)"
|
||||
},
|
||||
"showNowPlaying": {
|
||||
"label": "Pokaż teraz odtwarzane",
|
||||
"description": "Pokaż aktualnie odtwarzane utwory (Navidrome)"
|
||||
},
|
||||
"showNowPlayingList": {
|
||||
"label": "Pokaż listę teraz odtwarzanych",
|
||||
"description": "Pokaż poszczególne utwory zamiast tylko aktywnej liczby strumieni (Navidrome)"
|
||||
},
|
||||
"maxNowPlayingItems": {
|
||||
"label": "Maksymalna ilość teraz odtwarzanych elementów",
|
||||
"description": "Maksymalna liczba elementów wyświetlanych na liście teraz odtwarzanych (Navidrome)"
|
||||
},
|
||||
"showLibraryCount": {
|
||||
"label": "Pokaż ilość bibliotek",
|
||||
"description": "Pokaż liczbę bibliotek na twojej instancji (Audiobookshelf)"
|
||||
@@ -3813,8 +3827,6 @@
|
||||
"podcasts": "Podcasty",
|
||||
"listeningTime": "Czas Słuchania",
|
||||
"activeSessions": "Aktywne Sesje",
|
||||
"nowPlaying": "Teraz Odtwarzane",
|
||||
"nowPlayingCount": "{count, plural, one {# stream} other {# streams}}",
|
||||
"error": {
|
||||
"internalServerError": "Nie udało się pobrać danych audio stats"
|
||||
}
|
||||
@@ -3954,11 +3966,19 @@
|
||||
},
|
||||
"showAgent": {
|
||||
"label": "Pokaż wersję agenta"
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": ""
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"noRecords": "Nie znaleziono systemów",
|
||||
"error": {
|
||||
"internalServerError": "Nie można połączyć się z Beszel. Sprawdź ustawienia integracji."
|
||||
"internalServerError": "Nie można połączyć się z Beszel. Sprawdź ustawienia integracji.",
|
||||
"layoutSaveTitle": "",
|
||||
"layoutSaveMessage": ""
|
||||
},
|
||||
"column": {
|
||||
"system": "System",
|
||||
@@ -5356,6 +5376,7 @@
|
||||
},
|
||||
"remove": {
|
||||
"label": "Usuń",
|
||||
"confirm": "",
|
||||
"notification": {
|
||||
"success": {
|
||||
"title": "Usunięto kontenery",
|
||||
|
||||
@@ -2428,10 +2428,21 @@
|
||||
},
|
||||
"descendingDefaultSort": {
|
||||
"label": "Inverter ordenação"
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": ""
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": "Erro ao obter as estatísticas dos contêineres"
|
||||
"internalServerError": "Erro ao obter as estatísticas dos contêineres",
|
||||
"layoutSaveTitle": "",
|
||||
"layoutSaveMessage": ""
|
||||
},
|
||||
"empty": {
|
||||
"noContainers": ""
|
||||
}
|
||||
},
|
||||
"coolify": {
|
||||
@@ -2725,8 +2736,20 @@
|
||||
"showOnlyPlaying": {
|
||||
"label": "Exibir apenas o que está sendo reproduzido",
|
||||
"description": "Desabilitar isso não funcionará para o Plex"
|
||||
},
|
||||
"showBitrate": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showLocation": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"footer": {
|
||||
"streams": "",
|
||||
"totalBitrate": ""
|
||||
},
|
||||
"items": {
|
||||
"currentlyPlaying": "Reproduzindo agora",
|
||||
"user": "Usuário",
|
||||
@@ -2734,6 +2757,9 @@
|
||||
"id": "Id",
|
||||
"status": "",
|
||||
"directPlay": "",
|
||||
"directStream": "",
|
||||
"transcodeVideo": "",
|
||||
"transcodeAudio": "",
|
||||
"transcoding": "",
|
||||
"paused": "",
|
||||
"remaining": "",
|
||||
@@ -3768,18 +3794,6 @@
|
||||
"label": "Mostrar músicas",
|
||||
"description": "Mostrar o número total de músicas na sua biblioteca de música (Navidrome)"
|
||||
},
|
||||
"showNowPlaying": {
|
||||
"label": "Mostrar tocando agora",
|
||||
"description": "Mostrar as faixas que estão sendo reproduzidas no momento (Navidrome)"
|
||||
},
|
||||
"showNowPlayingList": {
|
||||
"label": "Mostrar lista de reprodução atual",
|
||||
"description": "Mostrar faixas individuais em vez de apenas a contagem de streams ativos (Navidrome)"
|
||||
},
|
||||
"maxNowPlayingItems": {
|
||||
"label": "Máximo de itens em reprodução",
|
||||
"description": "Número máximo de faixas para mostrar na lista de reprodução atual (Navidrome)"
|
||||
},
|
||||
"showLibraryCount": {
|
||||
"label": "Mostrar contagem de bibliotecas",
|
||||
"description": "Mostrar o número de bibliotecas na sua instância (Audiobookshelf)"
|
||||
@@ -3813,8 +3827,6 @@
|
||||
"podcasts": "Podcasts",
|
||||
"listeningTime": "Tempo de audição",
|
||||
"activeSessions": "Sessões ativas",
|
||||
"nowPlaying": "Tocando agora",
|
||||
"nowPlayingCount": "{count, plural, one {# transmissão} other {# transmissões}}",
|
||||
"error": {
|
||||
"internalServerError": "Falha ao buscar dados de estatísticas de áudio"
|
||||
}
|
||||
@@ -3954,11 +3966,19 @@
|
||||
},
|
||||
"showAgent": {
|
||||
"label": "Mostrar versão do agente"
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": ""
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"noRecords": "Nenhum sistema encontrado",
|
||||
"error": {
|
||||
"internalServerError": "Não foi possível conectar ao Beszel. Verifique as configurações de integração."
|
||||
"internalServerError": "Não foi possível conectar ao Beszel. Verifique as configurações de integração.",
|
||||
"layoutSaveTitle": "",
|
||||
"layoutSaveMessage": ""
|
||||
},
|
||||
"column": {
|
||||
"system": "Sistema",
|
||||
@@ -5356,6 +5376,7 @@
|
||||
},
|
||||
"remove": {
|
||||
"label": "Excluir",
|
||||
"confirm": "",
|
||||
"notification": {
|
||||
"success": {
|
||||
"title": "Contêineres removidos",
|
||||
|
||||
@@ -2428,10 +2428,21 @@
|
||||
},
|
||||
"descendingDefaultSort": {
|
||||
"label": ""
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": ""
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": ""
|
||||
"internalServerError": "",
|
||||
"layoutSaveTitle": "",
|
||||
"layoutSaveMessage": ""
|
||||
},
|
||||
"empty": {
|
||||
"noContainers": ""
|
||||
}
|
||||
},
|
||||
"coolify": {
|
||||
@@ -2725,8 +2736,20 @@
|
||||
"showOnlyPlaying": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showBitrate": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showLocation": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"footer": {
|
||||
"streams": "",
|
||||
"totalBitrate": ""
|
||||
},
|
||||
"items": {
|
||||
"currentlyPlaying": "",
|
||||
"user": "",
|
||||
@@ -2734,6 +2757,9 @@
|
||||
"id": "",
|
||||
"status": "",
|
||||
"directPlay": "",
|
||||
"directStream": "",
|
||||
"transcodeVideo": "",
|
||||
"transcodeAudio": "",
|
||||
"transcoding": "",
|
||||
"paused": "",
|
||||
"remaining": "",
|
||||
@@ -3768,18 +3794,6 @@
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showNowPlaying": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showNowPlayingList": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"maxNowPlayingItems": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showLibraryCount": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
@@ -3813,8 +3827,6 @@
|
||||
"podcasts": "",
|
||||
"listeningTime": "",
|
||||
"activeSessions": "",
|
||||
"nowPlaying": "",
|
||||
"nowPlayingCount": "",
|
||||
"error": {
|
||||
"internalServerError": ""
|
||||
}
|
||||
@@ -3954,11 +3966,19 @@
|
||||
},
|
||||
"showAgent": {
|
||||
"label": "Exibir versão do agente"
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": ""
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"noRecords": "Nenhum sistema encontrado",
|
||||
"error": {
|
||||
"internalServerError": ""
|
||||
"internalServerError": "",
|
||||
"layoutSaveTitle": "",
|
||||
"layoutSaveMessage": ""
|
||||
},
|
||||
"column": {
|
||||
"system": "Sistema",
|
||||
@@ -5356,6 +5376,7 @@
|
||||
},
|
||||
"remove": {
|
||||
"label": "Remover",
|
||||
"confirm": "",
|
||||
"notification": {
|
||||
"success": {
|
||||
"title": "Contêineres removidos",
|
||||
|
||||
@@ -2428,10 +2428,21 @@
|
||||
},
|
||||
"descendingDefaultSort": {
|
||||
"label": ""
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": ""
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": ""
|
||||
"internalServerError": "",
|
||||
"layoutSaveTitle": "",
|
||||
"layoutSaveMessage": ""
|
||||
},
|
||||
"empty": {
|
||||
"noContainers": ""
|
||||
}
|
||||
},
|
||||
"coolify": {
|
||||
@@ -2725,8 +2736,20 @@
|
||||
"showOnlyPlaying": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showBitrate": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showLocation": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"footer": {
|
||||
"streams": "",
|
||||
"totalBitrate": ""
|
||||
},
|
||||
"items": {
|
||||
"currentlyPlaying": "",
|
||||
"user": "",
|
||||
@@ -2734,6 +2757,9 @@
|
||||
"id": "",
|
||||
"status": "",
|
||||
"directPlay": "",
|
||||
"directStream": "",
|
||||
"transcodeVideo": "",
|
||||
"transcodeAudio": "",
|
||||
"transcoding": "",
|
||||
"paused": "",
|
||||
"remaining": "",
|
||||
@@ -3768,18 +3794,6 @@
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showNowPlaying": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showNowPlayingList": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"maxNowPlayingItems": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showLibraryCount": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
@@ -3813,8 +3827,6 @@
|
||||
"podcasts": "",
|
||||
"listeningTime": "",
|
||||
"activeSessions": "",
|
||||
"nowPlaying": "",
|
||||
"nowPlayingCount": "",
|
||||
"error": {
|
||||
"internalServerError": ""
|
||||
}
|
||||
@@ -3954,11 +3966,19 @@
|
||||
},
|
||||
"showAgent": {
|
||||
"label": "Arată versiunea agentului"
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": ""
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"noRecords": "Niciun sistem găsit",
|
||||
"error": {
|
||||
"internalServerError": ""
|
||||
"internalServerError": "",
|
||||
"layoutSaveTitle": "",
|
||||
"layoutSaveMessage": ""
|
||||
},
|
||||
"column": {
|
||||
"system": "Sistem",
|
||||
@@ -5356,6 +5376,7 @@
|
||||
},
|
||||
"remove": {
|
||||
"label": "Elimină",
|
||||
"confirm": "",
|
||||
"notification": {
|
||||
"success": {
|
||||
"title": "",
|
||||
|
||||
@@ -2428,10 +2428,21 @@
|
||||
},
|
||||
"descendingDefaultSort": {
|
||||
"label": "Обратная сортировка"
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": ""
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": "Не удалось получить статистику контейнеров"
|
||||
"internalServerError": "Не удалось получить статистику контейнеров",
|
||||
"layoutSaveTitle": "",
|
||||
"layoutSaveMessage": ""
|
||||
},
|
||||
"empty": {
|
||||
"noContainers": ""
|
||||
}
|
||||
},
|
||||
"coolify": {
|
||||
@@ -2725,8 +2736,20 @@
|
||||
"showOnlyPlaying": {
|
||||
"label": "Показывать только воспроизводимое",
|
||||
"description": "Отключение этой опции не работает для Plex"
|
||||
},
|
||||
"showBitrate": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showLocation": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"footer": {
|
||||
"streams": "",
|
||||
"totalBitrate": ""
|
||||
},
|
||||
"items": {
|
||||
"currentlyPlaying": "Сейчас воспроизводится",
|
||||
"user": "Пользователь",
|
||||
@@ -2734,6 +2757,9 @@
|
||||
"id": "Id",
|
||||
"status": "",
|
||||
"directPlay": "",
|
||||
"directStream": "",
|
||||
"transcodeVideo": "",
|
||||
"transcodeAudio": "",
|
||||
"transcoding": "",
|
||||
"paused": "",
|
||||
"remaining": "",
|
||||
@@ -3768,18 +3794,6 @@
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showNowPlaying": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showNowPlayingList": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"maxNowPlayingItems": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showLibraryCount": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
@@ -3813,8 +3827,6 @@
|
||||
"podcasts": "",
|
||||
"listeningTime": "",
|
||||
"activeSessions": "",
|
||||
"nowPlaying": "",
|
||||
"nowPlayingCount": "",
|
||||
"error": {
|
||||
"internalServerError": ""
|
||||
}
|
||||
@@ -3954,11 +3966,19 @@
|
||||
},
|
||||
"showAgent": {
|
||||
"label": "Показать версию агента"
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": ""
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"noRecords": "Системы не найдены",
|
||||
"error": {
|
||||
"internalServerError": ""
|
||||
"internalServerError": "",
|
||||
"layoutSaveTitle": "",
|
||||
"layoutSaveMessage": ""
|
||||
},
|
||||
"column": {
|
||||
"system": "Система",
|
||||
@@ -5356,6 +5376,7 @@
|
||||
},
|
||||
"remove": {
|
||||
"label": "Удалить",
|
||||
"confirm": "",
|
||||
"notification": {
|
||||
"success": {
|
||||
"title": "Контейнеры удалены",
|
||||
|
||||
@@ -2428,10 +2428,21 @@
|
||||
},
|
||||
"descendingDefaultSort": {
|
||||
"label": ""
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": ""
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": "Nepodarilo sa načítať štatistiky kontajnerov"
|
||||
"internalServerError": "Nepodarilo sa načítať štatistiky kontajnerov",
|
||||
"layoutSaveTitle": "",
|
||||
"layoutSaveMessage": ""
|
||||
},
|
||||
"empty": {
|
||||
"noContainers": ""
|
||||
}
|
||||
},
|
||||
"coolify": {
|
||||
@@ -2725,8 +2736,20 @@
|
||||
"showOnlyPlaying": {
|
||||
"label": "Zobraziť len aktuálne prehrávanú",
|
||||
"description": "Zakázanie tohto nebude fungovať pre plex"
|
||||
},
|
||||
"showBitrate": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showLocation": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"footer": {
|
||||
"streams": "",
|
||||
"totalBitrate": ""
|
||||
},
|
||||
"items": {
|
||||
"currentlyPlaying": "Momentálne sa prehráva",
|
||||
"user": "Používateľ",
|
||||
@@ -2734,6 +2757,9 @@
|
||||
"id": "Id",
|
||||
"status": "",
|
||||
"directPlay": "",
|
||||
"directStream": "",
|
||||
"transcodeVideo": "",
|
||||
"transcodeAudio": "",
|
||||
"transcoding": "",
|
||||
"paused": "",
|
||||
"remaining": "",
|
||||
@@ -3768,18 +3794,6 @@
|
||||
"label": "Zobraziť skladby",
|
||||
"description": "Zobraziť celkový počet skladieb vo vašej hudobnej knižnici (Navidrome)"
|
||||
},
|
||||
"showNowPlaying": {
|
||||
"label": "Zobraziť práve hrané",
|
||||
"description": "Zobraziť skladby, ktoré sa práve prehrávajú (Navidrome)"
|
||||
},
|
||||
"showNowPlayingList": {
|
||||
"label": "Zobraziť zoznam práve hraných",
|
||||
"description": "Zobraziť jednotlivé skladby namiesto len počtu aktívnych streamov (Navidrome)"
|
||||
},
|
||||
"maxNowPlayingItems": {
|
||||
"label": "Max. počet položiek práve hraných",
|
||||
"description": "Maximálny počet skladieb zobrazených v zozname práve hraných (Navidrome)"
|
||||
},
|
||||
"showLibraryCount": {
|
||||
"label": "Zobraziť počet knižníc",
|
||||
"description": "Zobraziť počet knižníc na vašej inštancii (Audiobookshelf)"
|
||||
@@ -3813,8 +3827,6 @@
|
||||
"podcasts": "Podcasty",
|
||||
"listeningTime": "Čas počúvania",
|
||||
"activeSessions": "Aktívne relácie",
|
||||
"nowPlaying": "Práve hrané",
|
||||
"nowPlayingCount": "{count, plural, one {# stream} few {# streamy} many {# streamov} other {# streamov}}",
|
||||
"error": {
|
||||
"internalServerError": "Nepodarilo sa načítať údaje audio štatistík"
|
||||
}
|
||||
@@ -3954,11 +3966,19 @@
|
||||
},
|
||||
"showAgent": {
|
||||
"label": "Zobraziť verziu agenta"
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": ""
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"noRecords": "Nenašli sa žiadne systémy",
|
||||
"error": {
|
||||
"internalServerError": ""
|
||||
"internalServerError": "",
|
||||
"layoutSaveTitle": "",
|
||||
"layoutSaveMessage": ""
|
||||
},
|
||||
"column": {
|
||||
"system": "Systém",
|
||||
@@ -5356,6 +5376,7 @@
|
||||
},
|
||||
"remove": {
|
||||
"label": "Odstrániť",
|
||||
"confirm": "",
|
||||
"notification": {
|
||||
"success": {
|
||||
"title": "Kontajnery boli odstránené",
|
||||
|
||||
@@ -2428,10 +2428,21 @@
|
||||
},
|
||||
"descendingDefaultSort": {
|
||||
"label": ""
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": ""
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": ""
|
||||
"internalServerError": "",
|
||||
"layoutSaveTitle": "",
|
||||
"layoutSaveMessage": ""
|
||||
},
|
||||
"empty": {
|
||||
"noContainers": ""
|
||||
}
|
||||
},
|
||||
"coolify": {
|
||||
@@ -2725,8 +2736,20 @@
|
||||
"showOnlyPlaying": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showBitrate": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showLocation": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"footer": {
|
||||
"streams": "",
|
||||
"totalBitrate": ""
|
||||
},
|
||||
"items": {
|
||||
"currentlyPlaying": "",
|
||||
"user": "",
|
||||
@@ -2734,6 +2757,9 @@
|
||||
"id": "",
|
||||
"status": "",
|
||||
"directPlay": "",
|
||||
"directStream": "",
|
||||
"transcodeVideo": "",
|
||||
"transcodeAudio": "",
|
||||
"transcoding": "",
|
||||
"paused": "",
|
||||
"remaining": "",
|
||||
@@ -3768,18 +3794,6 @@
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showNowPlaying": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showNowPlayingList": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"maxNowPlayingItems": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showLibraryCount": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
@@ -3813,8 +3827,6 @@
|
||||
"podcasts": "",
|
||||
"listeningTime": "",
|
||||
"activeSessions": "",
|
||||
"nowPlaying": "",
|
||||
"nowPlayingCount": "",
|
||||
"error": {
|
||||
"internalServerError": ""
|
||||
}
|
||||
@@ -3954,11 +3966,19 @@
|
||||
},
|
||||
"showAgent": {
|
||||
"label": "Pokaži različico agenta"
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": ""
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"noRecords": "Ni najdenih sistemov",
|
||||
"error": {
|
||||
"internalServerError": ""
|
||||
"internalServerError": "",
|
||||
"layoutSaveTitle": "",
|
||||
"layoutSaveMessage": ""
|
||||
},
|
||||
"column": {
|
||||
"system": "Sistem",
|
||||
@@ -5356,6 +5376,7 @@
|
||||
},
|
||||
"remove": {
|
||||
"label": "Odstrani",
|
||||
"confirm": "",
|
||||
"notification": {
|
||||
"success": {
|
||||
"title": "",
|
||||
|
||||
@@ -2428,10 +2428,21 @@
|
||||
},
|
||||
"descendingDefaultSort": {
|
||||
"label": ""
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": ""
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": ""
|
||||
"internalServerError": "",
|
||||
"layoutSaveTitle": "",
|
||||
"layoutSaveMessage": ""
|
||||
},
|
||||
"empty": {
|
||||
"noContainers": ""
|
||||
}
|
||||
},
|
||||
"coolify": {
|
||||
@@ -2725,8 +2736,20 @@
|
||||
"showOnlyPlaying": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showBitrate": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showLocation": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"footer": {
|
||||
"streams": "",
|
||||
"totalBitrate": ""
|
||||
},
|
||||
"items": {
|
||||
"currentlyPlaying": "",
|
||||
"user": "",
|
||||
@@ -2734,6 +2757,9 @@
|
||||
"id": "",
|
||||
"status": "",
|
||||
"directPlay": "",
|
||||
"directStream": "",
|
||||
"transcodeVideo": "",
|
||||
"transcodeAudio": "",
|
||||
"transcoding": "",
|
||||
"paused": "",
|
||||
"remaining": "",
|
||||
@@ -3768,18 +3794,6 @@
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showNowPlaying": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showNowPlayingList": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"maxNowPlayingItems": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showLibraryCount": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
@@ -3813,8 +3827,6 @@
|
||||
"podcasts": "",
|
||||
"listeningTime": "",
|
||||
"activeSessions": "",
|
||||
"nowPlaying": "",
|
||||
"nowPlayingCount": "",
|
||||
"error": {
|
||||
"internalServerError": ""
|
||||
}
|
||||
@@ -3954,11 +3966,19 @@
|
||||
},
|
||||
"showAgent": {
|
||||
"label": "Visa agentversion"
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": ""
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"noRecords": "Inga system hittades",
|
||||
"error": {
|
||||
"internalServerError": ""
|
||||
"internalServerError": "",
|
||||
"layoutSaveTitle": "",
|
||||
"layoutSaveMessage": ""
|
||||
},
|
||||
"column": {
|
||||
"system": "System",
|
||||
@@ -5356,6 +5376,7 @@
|
||||
},
|
||||
"remove": {
|
||||
"label": "Ta bort",
|
||||
"confirm": "",
|
||||
"notification": {
|
||||
"success": {
|
||||
"title": "",
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -2428,10 +2428,21 @@
|
||||
},
|
||||
"descendingDefaultSort": {
|
||||
"label": "Інверсія сортування"
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": ""
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": "Не вдалося отримати статистику контейнерів"
|
||||
"internalServerError": "Не вдалося отримати статистику контейнерів",
|
||||
"layoutSaveTitle": "",
|
||||
"layoutSaveMessage": ""
|
||||
},
|
||||
"empty": {
|
||||
"noContainers": ""
|
||||
}
|
||||
},
|
||||
"coolify": {
|
||||
@@ -2725,8 +2736,20 @@
|
||||
"showOnlyPlaying": {
|
||||
"label": "Показувати тільки поточне відтворення",
|
||||
"description": "Вимкнення цієї опції не працюватиме для Plex."
|
||||
},
|
||||
"showBitrate": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showLocation": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"footer": {
|
||||
"streams": "",
|
||||
"totalBitrate": ""
|
||||
},
|
||||
"items": {
|
||||
"currentlyPlaying": "Зараз відтворюється",
|
||||
"user": "Користувач",
|
||||
@@ -2734,6 +2757,9 @@
|
||||
"id": "Id",
|
||||
"status": "Статус",
|
||||
"directPlay": "Пряме відтворення",
|
||||
"directStream": "",
|
||||
"transcodeVideo": "",
|
||||
"transcodeAudio": "",
|
||||
"transcoding": "Транскодування",
|
||||
"paused": "Призупинено",
|
||||
"remaining": "Залишилося {minutes} хв",
|
||||
@@ -3768,18 +3794,6 @@
|
||||
"label": "Показувати пісні",
|
||||
"description": "Показати загальну кількість пісень у вашій музичній бібліотеці (Navidrome)"
|
||||
},
|
||||
"showNowPlaying": {
|
||||
"label": "Показати, що зараз відтворюються",
|
||||
"description": "Показувати пісні, що зараз відтворюються (Navidrome)"
|
||||
},
|
||||
"showNowPlayingList": {
|
||||
"label": "Показати список відтворення зараз",
|
||||
"description": "Відображати окремі треки замість лише кількості активних потоків (Navidrome)"
|
||||
},
|
||||
"maxNowPlayingItems": {
|
||||
"label": "Максимум елементів для відтворення",
|
||||
"description": "Максимальна кількість треків для відображення у списку відтворення (Navidrome)"
|
||||
},
|
||||
"showLibraryCount": {
|
||||
"label": "Показувати кількість бібліотек",
|
||||
"description": "Показати кількість бібліотек на вашому екземплярі (Audiobookshelf)"
|
||||
@@ -3813,8 +3827,6 @@
|
||||
"podcasts": "Подкасти",
|
||||
"listeningTime": "Час прослуховування",
|
||||
"activeSessions": "Активні Сесії",
|
||||
"nowPlaying": "Зараз програється",
|
||||
"nowPlayingCount": "{count, plural, one {# трансляція} few {# трансляції} many {# трансляцій} other {# трансляцій}}",
|
||||
"error": {
|
||||
"internalServerError": "Не вдалося отримати статистику аудіо"
|
||||
}
|
||||
@@ -3954,11 +3966,19 @@
|
||||
},
|
||||
"showAgent": {
|
||||
"label": "Показати версію агента"
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": ""
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"noRecords": "Системи не знайдені",
|
||||
"error": {
|
||||
"internalServerError": "Не вдається підключитися до Beszel. Перевірте параметри інтеграції."
|
||||
"internalServerError": "Не вдається підключитися до Beszel. Перевірте параметри інтеграції.",
|
||||
"layoutSaveTitle": "",
|
||||
"layoutSaveMessage": ""
|
||||
},
|
||||
"column": {
|
||||
"system": "Система",
|
||||
@@ -5356,6 +5376,7 @@
|
||||
},
|
||||
"remove": {
|
||||
"label": "Видалити",
|
||||
"confirm": "",
|
||||
"notification": {
|
||||
"success": {
|
||||
"title": "Контейнери видалено",
|
||||
|
||||
@@ -2428,10 +2428,21 @@
|
||||
},
|
||||
"descendingDefaultSort": {
|
||||
"label": ""
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": ""
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": ""
|
||||
"internalServerError": "",
|
||||
"layoutSaveTitle": "",
|
||||
"layoutSaveMessage": ""
|
||||
},
|
||||
"empty": {
|
||||
"noContainers": ""
|
||||
}
|
||||
},
|
||||
"coolify": {
|
||||
@@ -2725,8 +2736,20 @@
|
||||
"showOnlyPlaying": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showBitrate": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showLocation": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"footer": {
|
||||
"streams": "",
|
||||
"totalBitrate": ""
|
||||
},
|
||||
"items": {
|
||||
"currentlyPlaying": "",
|
||||
"user": "",
|
||||
@@ -2734,6 +2757,9 @@
|
||||
"id": "",
|
||||
"status": "",
|
||||
"directPlay": "",
|
||||
"directStream": "",
|
||||
"transcodeVideo": "",
|
||||
"transcodeAudio": "",
|
||||
"transcoding": "",
|
||||
"paused": "",
|
||||
"remaining": "",
|
||||
@@ -3768,18 +3794,6 @@
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showNowPlaying": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showNowPlayingList": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"maxNowPlayingItems": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showLibraryCount": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
@@ -3813,8 +3827,6 @@
|
||||
"podcasts": "",
|
||||
"listeningTime": "",
|
||||
"activeSessions": "",
|
||||
"nowPlaying": "",
|
||||
"nowPlayingCount": "",
|
||||
"error": {
|
||||
"internalServerError": ""
|
||||
}
|
||||
@@ -3954,11 +3966,19 @@
|
||||
},
|
||||
"showAgent": {
|
||||
"label": "Hiển thị phiên bản tác tử"
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": ""
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"noRecords": "Không tìm thấy hệ thống",
|
||||
"error": {
|
||||
"internalServerError": ""
|
||||
"internalServerError": "",
|
||||
"layoutSaveTitle": "",
|
||||
"layoutSaveMessage": ""
|
||||
},
|
||||
"column": {
|
||||
"system": "Hệ thống",
|
||||
@@ -5356,6 +5376,7 @@
|
||||
},
|
||||
"remove": {
|
||||
"label": "Xóa",
|
||||
"confirm": "",
|
||||
"notification": {
|
||||
"success": {
|
||||
"title": "",
|
||||
|
||||
@@ -2428,10 +2428,21 @@
|
||||
},
|
||||
"descendingDefaultSort": {
|
||||
"label": ""
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": ""
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": ""
|
||||
"internalServerError": "",
|
||||
"layoutSaveTitle": "",
|
||||
"layoutSaveMessage": ""
|
||||
},
|
||||
"empty": {
|
||||
"noContainers": ""
|
||||
}
|
||||
},
|
||||
"coolify": {
|
||||
@@ -2725,8 +2736,20 @@
|
||||
"showOnlyPlaying": {
|
||||
"label": "只顯示目前播放中的項目",
|
||||
"description": "停用此功能將無法在 Plex 上運作"
|
||||
},
|
||||
"showBitrate": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"showLocation": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"footer": {
|
||||
"streams": "",
|
||||
"totalBitrate": ""
|
||||
},
|
||||
"items": {
|
||||
"currentlyPlaying": "目前播放中",
|
||||
"user": "使用者",
|
||||
@@ -2734,6 +2757,9 @@
|
||||
"id": "ID",
|
||||
"status": "狀態",
|
||||
"directPlay": "直接播放",
|
||||
"directStream": "",
|
||||
"transcodeVideo": "",
|
||||
"transcodeAudio": "",
|
||||
"transcoding": "轉碼中",
|
||||
"paused": "已暫停",
|
||||
"remaining": "剩餘 {minutes} 分鐘",
|
||||
@@ -3768,18 +3794,6 @@
|
||||
"label": "顯示歌曲",
|
||||
"description": "顯示音樂庫中的歌曲總數 (Navidrome)"
|
||||
},
|
||||
"showNowPlaying": {
|
||||
"label": "顯示正在播放",
|
||||
"description": "顯示目前播放中的曲目 (Navidrome)"
|
||||
},
|
||||
"showNowPlayingList": {
|
||||
"label": "顯示正在播放清單",
|
||||
"description": "顯示個別曲目,而非僅顯示活動串流計數 (Navidrome)"
|
||||
},
|
||||
"maxNowPlayingItems": {
|
||||
"label": "最大正在播放項目數",
|
||||
"description": "在正在播放清單中顯示的最大曲目數量 (Navidrome)"
|
||||
},
|
||||
"showLibraryCount": {
|
||||
"label": "顯示庫數量",
|
||||
"description": "顯示你的執行個體上的庫數量 (Audiobookshelf)"
|
||||
@@ -3813,8 +3827,6 @@
|
||||
"podcasts": "播客",
|
||||
"listeningTime": "聆聽時間",
|
||||
"activeSessions": "活躍會話",
|
||||
"nowPlaying": "正在播放",
|
||||
"nowPlayingCount": "",
|
||||
"error": {
|
||||
"internalServerError": "無法擷取音訊統計資料"
|
||||
}
|
||||
@@ -3954,11 +3966,19 @@
|
||||
},
|
||||
"showAgent": {
|
||||
"label": "顯示代理版本"
|
||||
},
|
||||
"columnOrder": {
|
||||
"label": ""
|
||||
},
|
||||
"columnWidths": {
|
||||
"label": ""
|
||||
}
|
||||
},
|
||||
"noRecords": "找不到系統",
|
||||
"error": {
|
||||
"internalServerError": "無法連線到 Beszel。請檢查整合設定。"
|
||||
"internalServerError": "無法連線到 Beszel。請檢查整合設定。",
|
||||
"layoutSaveTitle": "",
|
||||
"layoutSaveMessage": ""
|
||||
},
|
||||
"column": {
|
||||
"system": "系統",
|
||||
@@ -5356,6 +5376,7 @@
|
||||
},
|
||||
"remove": {
|
||||
"label": "移除",
|
||||
"confirm": "",
|
||||
"notification": {
|
||||
"success": {
|
||||
"title": "容器已移除",
|
||||
|
||||
@@ -3,8 +3,7 @@ import { z } from "zod/v4";
|
||||
export const appHrefSchema = z
|
||||
.string()
|
||||
.trim()
|
||||
.url()
|
||||
.regex(/^(?!javascript)[a-zA-Z]*:\/\//i) // javascript: is not allowed, i for case insensitive (so Javascript: is also not allowed)
|
||||
.refine((val) => !/^javascript:/i.test(val), "JavaScript URIs are not permitted")
|
||||
.or(z.literal(""))
|
||||
.transform((value) => (value.length === 0 ? null : value))
|
||||
.nullable();
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -26,30 +26,39 @@ const columnTranslationKeyMap = {
|
||||
export const { definition, componentLoader } = createWidgetDefinition("dockerContainers", {
|
||||
icon: IconBrandDocker,
|
||||
queryKey: [["docker", "getContainers"]],
|
||||
refetchInterval: 30,
|
||||
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: {
|
||||
|
||||
@@ -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]}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -7,7 +7,6 @@ import { optionsBuilder } from "../options";
|
||||
|
||||
export const { componentLoader, definition } = createWidgetDefinition("notifications", {
|
||||
icon: IconMessage,
|
||||
refetchInterval: null,
|
||||
createOptions() {
|
||||
return optionsBuilder.from((factory) => ({
|
||||
hideLogos: factory.switch({ defaultValue: false }),
|
||||
|
||||
Generated
+995
-1057
File diff suppressed because it is too large.
Load diff
+2
-2
@@ -37,9 +37,9 @@ catalog:
|
||||
"@octokit/auth-app": ^8.2.0
|
||||
"@paralleldrive/cuid2": ^3.3.0
|
||||
"@playwright/test": ^1.61.1
|
||||
"@semantic-release/changelog": ^6.0.3
|
||||
"@semantic-release/changelog": ^7.0.0
|
||||
"@semantic-release/commit-analyzer": ^13.0.1
|
||||
"@semantic-release/git": ^10.0.1
|
||||
"@semantic-release/git": ^11.0.1
|
||||
"@semantic-release/github": ^12.0.9
|
||||
"@semantic-release/npm": ^13.1.5
|
||||
"@semantic-release/release-notes-generator": ^14.1.1
|
||||
|
||||
@@ -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
|
||||
```
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package platform
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestOpenCommand(t *testing.T) {
|
||||
tests := []struct {
|
||||
goos string
|
||||
name string
|
||||
}{
|
||||
{goos: "darwin", name: "open"},
|
||||
{goos: "linux", name: "xdg-open"},
|
||||
{goos: "windows", name: "rundll32"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.goos, func(t *testing.T) {
|
||||
name, args, err := openCommand(test.goos, "https://example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if name != test.name || len(args) == 0 {
|
||||
t.Fatalf("command = %s %v", name, args)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package run
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/homarr-labs/homarr/tools/homarr-dev/internal/docker"
|
||||
"github.com/homarr-labs/homarr/tools/homarr-dev/internal/gh"
|
||||
)
|
||||
|
||||
func BuildCurrentImage(tag, directory string) error {
|
||||
return buildCurrentImage(context.Background(), tag, directory, os.Stdout, os.Stderr)
|
||||
}
|
||||
|
||||
func buildCurrentImage(ctx context.Context, tag, directory string, stdout, stderr io.Writer) error {
|
||||
root, revision, err := checkoutDetails(ctx, directory)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return docker.BuildContext(ctx, docker.BuildOptions{Context: root, Tag: tag, Source: root, Revision: revision}, stdout, stderr)
|
||||
}
|
||||
|
||||
func BuildPRImage(number int, tag string) error {
|
||||
return buildPRImage(context.Background(), number, tag, os.Stdout, os.Stderr)
|
||||
}
|
||||
|
||||
func buildPRImage(ctx context.Context, number int, tag string, stdout, stderr io.Writer) error {
|
||||
if number <= 0 {
|
||||
return fmt.Errorf("PR number must be positive")
|
||||
}
|
||||
temporaryRoot, err := os.MkdirTemp("", "homarr-pr-*")
|
||||
if err != nil {
|
||||
return fmt.Errorf("create temporary checkout: %w", err)
|
||||
}
|
||||
defer os.RemoveAll(temporaryRoot)
|
||||
|
||||
checkout := filepath.Join(temporaryRoot, "homarr")
|
||||
clone := exec.CommandContext(ctx, "gh", prCloneArgs(checkout)...)
|
||||
clone.Stdout = stdout
|
||||
clone.Stderr = stderr
|
||||
if err := clone.Run(); err != nil {
|
||||
return fmt.Errorf("clone %s: %w", gh.Repo, err)
|
||||
}
|
||||
command := exec.CommandContext(ctx, "gh", "pr", "checkout", strconv.Itoa(number), "--detach")
|
||||
command.Dir = checkout
|
||||
command.Stdout = stdout
|
||||
command.Stderr = stderr
|
||||
if err := command.Run(); err != nil {
|
||||
return fmt.Errorf("checkout PR #%d: %w", number, err)
|
||||
}
|
||||
_, revision, err := checkoutDetails(ctx, checkout)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return docker.BuildContext(ctx, docker.BuildOptions{
|
||||
Context: checkout,
|
||||
Tag: tag,
|
||||
Source: fmt.Sprintf("https://github.com/%s/pull/%d", gh.Repo, number),
|
||||
Revision: revision,
|
||||
PRNumber: number,
|
||||
}, stdout, stderr)
|
||||
}
|
||||
|
||||
func prCloneArgs(checkout string) []string {
|
||||
return []string{"repo", "clone", gh.Repo, checkout, "--", "--depth=1", "--filter=blob:none"}
|
||||
}
|
||||
|
||||
func RebuildImage(image docker.Image) error {
|
||||
return RebuildImageContext(context.Background(), image, os.Stdout, os.Stderr)
|
||||
}
|
||||
|
||||
func RebuildImageContext(ctx context.Context, image docker.Image, stdout, stderr io.Writer) error {
|
||||
if image.PRNumber > 0 {
|
||||
return buildPRImage(ctx, image.PRNumber, image.Tag, stdout, stderr)
|
||||
}
|
||||
if image.Source == "" {
|
||||
return fmt.Errorf("homarr:%s has no build source metadata", image.Tag)
|
||||
}
|
||||
if info, err := os.Stat(image.Source); err != nil || !info.IsDir() {
|
||||
return fmt.Errorf("build source %s is no longer available", image.Source)
|
||||
}
|
||||
return buildCurrentImage(ctx, image.Tag, image.Source, stdout, stderr)
|
||||
}
|
||||
|
||||
func checkoutDetails(ctx context.Context, directory string) (root string, revision string, err error) {
|
||||
rootOutput, err := exec.CommandContext(ctx, "git", "-C", directory, "rev-parse", "--show-toplevel").CombinedOutput()
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("find Homarr checkout: %w: %s", err, strings.TrimSpace(string(rootOutput)))
|
||||
}
|
||||
root = strings.TrimSpace(string(rootOutput))
|
||||
if _, err := os.Stat(filepath.Join(root, "Dockerfile")); err != nil {
|
||||
return "", "", fmt.Errorf("%s is not a Homarr checkout", root)
|
||||
}
|
||||
revisionOutput, err := exec.CommandContext(ctx, "git", "-C", root, "rev-parse", "HEAD").CombinedOutput()
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("read checkout revision: %w: %s", err, strings.TrimSpace(string(revisionOutput)))
|
||||
}
|
||||
return root, strings.TrimSpace(string(revisionOutput)), nil
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package run
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/homarr-labs/homarr/tools/homarr-dev/internal/docker"
|
||||
"github.com/homarr-labs/homarr/tools/homarr-dev/internal/gh"
|
||||
)
|
||||
|
||||
type Options struct {
|
||||
Context context.Context
|
||||
PR int
|
||||
Tag string
|
||||
Demo bool
|
||||
Env []string
|
||||
FetchTitle bool
|
||||
FindPort func(int) int
|
||||
}
|
||||
|
||||
type Plan struct {
|
||||
Image string
|
||||
Name string
|
||||
Volume string
|
||||
Label string
|
||||
HostPort int
|
||||
Env []string
|
||||
Platform string
|
||||
Pull bool
|
||||
PRNumber int
|
||||
}
|
||||
|
||||
func BuildPlan(opts Options) (*Plan, error) {
|
||||
p := &Plan{}
|
||||
ctx := opts.Context
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
findPort := opts.FindPort
|
||||
if findPort == nil {
|
||||
findPort = docker.FindFreePort
|
||||
}
|
||||
|
||||
if opts.PR > 0 {
|
||||
p.PRNumber = opts.PR
|
||||
p.Image = fmt.Sprintf("ghcr.io/homarr-labs/homarr-test:pr-%d", opts.PR)
|
||||
p.Name = fmt.Sprintf("homarr_pr_%d", opts.PR)
|
||||
p.Volume = fmt.Sprintf("homarr_pr_%d_data", opts.PR)
|
||||
p.Label = fmt.Sprintf("PR #%d", opts.PR)
|
||||
p.Pull = true
|
||||
p.Platform = "linux/amd64"
|
||||
if opts.FetchTitle {
|
||||
if pr, err := gh.GetPR(ctx, opts.PR); err == nil && pr.Title != "" {
|
||||
p.Label = fmt.Sprintf("PR #%d — %s", opts.PR, pr.Title)
|
||||
}
|
||||
}
|
||||
} else if opts.Tag != "" {
|
||||
p.Image = "homarr:" + opts.Tag
|
||||
p.Name = "homarr_" + opts.Tag
|
||||
p.Volume = "homarr_" + opts.Tag + "_data"
|
||||
p.Label = "tag: " + opts.Tag
|
||||
} else {
|
||||
return nil, fmt.Errorf("need a tag or --pr")
|
||||
}
|
||||
|
||||
p.HostPort = findPort(7575)
|
||||
if p.HostPort == 0 {
|
||||
return nil, fmt.Errorf("no free host port is available")
|
||||
}
|
||||
p.Env = append(p.Env, opts.Env...)
|
||||
if opts.Demo {
|
||||
p.Env = append(p.Env, "DEMO_MODE=true", "UNSAFE_ENABLE_MOCK_INTEGRATION=true")
|
||||
p.Label += " (demo)"
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func StartDetached(p *Plan) error {
|
||||
return start(p, true)
|
||||
}
|
||||
|
||||
func StartForeground(p *Plan) error {
|
||||
return start(p, false)
|
||||
}
|
||||
|
||||
func start(p *Plan, daemon bool) error {
|
||||
const attempts = 5
|
||||
for attempt := 0; attempt < attempts; attempt++ {
|
||||
err := docker.Start(docker.StartOptions{
|
||||
Name: p.Name,
|
||||
Image: p.Image,
|
||||
Volume: p.Volume,
|
||||
HostPort: p.HostPort,
|
||||
Env: p.Env,
|
||||
Platform: p.Platform,
|
||||
PullAlways: p.Pull && attempt == 0,
|
||||
Daemon: daemon,
|
||||
})
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if !docker.IsPortConflict(err) || attempt == attempts-1 {
|
||||
if docker.IsPortConflict(err) {
|
||||
return fmt.Errorf("unable to bind a host port after %d attempts; restart Docker or OrbStack: %w", attempts, err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
previousPort := p.HostPort
|
||||
p.HostPort = docker.FindFreePort(p.HostPort + 1)
|
||||
if p.HostPort == 0 {
|
||||
return fmt.Errorf("no free host port is available: %w", err)
|
||||
}
|
||||
if !daemon {
|
||||
fmt.Fprintf(os.Stderr, "Port %d unavailable; retrying on %d.\n", previousPort, p.HostPort)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package run
|
||||
|
||||
import (
|
||||
"os"
|
||||
"slices"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"github.com/homarr-labs/homarr/tools/homarr-dev/internal/docker"
|
||||
)
|
||||
|
||||
func TestPRPlanUsesAMD64(t *testing.T) {
|
||||
plan, err := BuildPlan(Options{PR: 1, FindPort: func(int) int { return 7575 }})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if plan.Platform != "linux/amd64" {
|
||||
t.Fatalf("platform = %q, want linux/amd64", plan.Platform)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPRCloneIsShallow(t *testing.T) {
|
||||
args := prCloneArgs("/tmp/checkout")
|
||||
if !slices.Contains(args, "--depth=1") || !slices.Contains(args, "--filter=blob:none") {
|
||||
t.Fatalf("clone args = %v", args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPlanFailsWhenNoPortIsAvailable(t *testing.T) {
|
||||
_, err := BuildPlan(Options{Tag: "dev", FindPort: func(int) int { return 0 }})
|
||||
if err == nil {
|
||||
t.Fatal("expected no-free-port error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartPRIntegration(t *testing.T) {
|
||||
if os.Getenv("HOMARR_DOCKER_INTEGRATION_TEST") != "1" {
|
||||
t.Skip("set HOMARR_DOCKER_INTEGRATION_TEST=1 and HOMARR_TEST_PR=<number>")
|
||||
}
|
||||
pr, err := strconv.Atoi(os.Getenv("HOMARR_TEST_PR"))
|
||||
if err != nil || pr <= 0 {
|
||||
t.Fatal("HOMARR_TEST_PR must be a positive PR number")
|
||||
}
|
||||
|
||||
plan, err := BuildPlan(Options{PR: pr})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
plan.Name += "_integration"
|
||||
plan.Volume += "_integration"
|
||||
defer docker.Remove(plan.Name)
|
||||
|
||||
if err := StartDetached(plan); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !docker.IsRunning(plan.Name) {
|
||||
t.Fatalf("container %s did not remain running", plan.Name)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"charm.land/bubbles/v2/spinner"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
)
|
||||
|
||||
type appModel struct {
|
||||
dev prsModel
|
||||
dashboard dashModel
|
||||
showDashboard bool
|
||||
width int
|
||||
height int
|
||||
}
|
||||
|
||||
func newAppModel(showDashboard bool) appModel {
|
||||
return appModel{dev: newPRsModel(), dashboard: newDashModel(), showDashboard: showDashboard}
|
||||
}
|
||||
|
||||
func (m appModel) Init() tea.Cmd {
|
||||
if m.showDashboard {
|
||||
return m.dashboard.Init()
|
||||
}
|
||||
return m.dev.Init()
|
||||
}
|
||||
|
||||
func (m appModel) canSwitch() bool {
|
||||
if m.showDashboard {
|
||||
return !m.dashboard.filtering
|
||||
}
|
||||
return !m.dev.filtering && !m.dev.pulling
|
||||
}
|
||||
|
||||
func (m appModel) switchScreen() (tea.Model, tea.Cmd) {
|
||||
m.showDashboard = !m.showDashboard
|
||||
commands := make([]tea.Cmd, 0, 2)
|
||||
if m.width > 0 && m.height > 0 {
|
||||
resize := tea.WindowSizeMsg{Width: m.width, Height: m.height}
|
||||
if m.showDashboard {
|
||||
updated, command := m.dashboard.Update(resize)
|
||||
m.dashboard = updated.(dashModel)
|
||||
commands = append(commands, command)
|
||||
} else {
|
||||
updated, command := m.dev.Update(resize)
|
||||
m.dev = updated.(prsModel)
|
||||
commands = append(commands, command)
|
||||
}
|
||||
}
|
||||
if m.showDashboard {
|
||||
commands = append(commands, m.dashboard.Init())
|
||||
} else {
|
||||
commands = append(commands, m.dev.Init())
|
||||
}
|
||||
return m, tea.Batch(commands...)
|
||||
}
|
||||
|
||||
func (m appModel) updateDev(msg tea.Msg) (appModel, tea.Cmd) {
|
||||
updated, command := m.dev.Update(msg)
|
||||
m.dev = updated.(prsModel)
|
||||
return m, command
|
||||
}
|
||||
|
||||
func (m appModel) updateDashboard(msg tea.Msg) (appModel, tea.Cmd) {
|
||||
updated, command := m.dashboard.Update(msg)
|
||||
m.dashboard = updated.(dashModel)
|
||||
return m, command
|
||||
}
|
||||
|
||||
func (m appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if window, ok := msg.(tea.WindowSizeMsg); ok {
|
||||
m.width = window.Width
|
||||
m.height = window.Height
|
||||
m, devCommand := m.updateDev(msg)
|
||||
m, dashboardCommand := m.updateDashboard(msg)
|
||||
return m, tea.Batch(devCommand, dashboardCommand)
|
||||
}
|
||||
if key, ok := msg.(tea.KeyPressMsg); ok {
|
||||
if key.String() == "d" && m.canSwitch() {
|
||||
return m.switchScreen()
|
||||
}
|
||||
if m.showDashboard {
|
||||
return m.updateDashboard(msg)
|
||||
}
|
||||
return m.updateDev(msg)
|
||||
}
|
||||
|
||||
switch msg.(type) {
|
||||
case prsLoadedMsg, imageCheckedMsg, prActionMsg, rebuildDoneMsg, runningRefreshedMsg, pullReadyMsg, pullEvent, spinner.TickMsg:
|
||||
return m.updateDev(msg)
|
||||
case tickMsg, actionMsg, rowsMsg:
|
||||
if m.showDashboard {
|
||||
return m.updateDashboard(msg)
|
||||
}
|
||||
return m, nil
|
||||
case prLogTickMsg:
|
||||
if !m.showDashboard {
|
||||
return m.updateDev(msg)
|
||||
}
|
||||
return m, nil
|
||||
case logsMsg:
|
||||
m, devCommand := m.updateDev(msg)
|
||||
m, dashboardCommand := m.updateDashboard(msg)
|
||||
return m, tea.Batch(devCommand, dashboardCommand)
|
||||
}
|
||||
if m.showDashboard {
|
||||
return m.updateDashboard(msg)
|
||||
}
|
||||
return m.updateDev(msg)
|
||||
}
|
||||
|
||||
func (m appModel) View() tea.View {
|
||||
if m.showDashboard {
|
||||
return m.dashboard.View()
|
||||
}
|
||||
return m.dev.View()
|
||||
}
|
||||
|
||||
func runApp(showDashboard bool) error {
|
||||
program := tea.NewProgram(newAppModel(showDashboard))
|
||||
_, err := program.Run()
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
|
||||
"github.com/homarr-labs/homarr/tools/homarr-dev/internal/gh"
|
||||
)
|
||||
|
||||
func TestAppTogglePreservesDevelopmentState(t *testing.T) {
|
||||
m := newAppModel(false)
|
||||
m.dev.rows = []prRow{{kind: "remote", pr: gh.PR{Number: 6502}}}
|
||||
|
||||
updated, _ := m.Update(tea.KeyPressMsg(tea.Key{Code: 'd', Text: "d"}))
|
||||
dashboard := updated.(appModel)
|
||||
if !dashboard.showDashboard {
|
||||
t.Fatal("d did not open the instance dashboard")
|
||||
}
|
||||
updated, _ = dashboard.Update(tea.KeyPressMsg(tea.Key{Code: 'd', Text: "d"}))
|
||||
development := updated.(appModel)
|
||||
if development.showDashboard || len(development.dev.rows) != 1 || development.dev.rows[0].pr.Number != 6502 {
|
||||
t.Fatalf("development state was not preserved: %#v", development.dev.rows)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppToggleDoesNotInterceptFilterInput(t *testing.T) {
|
||||
m := newAppModel(false)
|
||||
m.dev.filtering = true
|
||||
m.dev.filter.Focus()
|
||||
|
||||
updated, _ := m.Update(tea.KeyPressMsg(tea.Key{Code: 'd', Text: "d"}))
|
||||
got := updated.(appModel)
|
||||
if got.showDashboard || got.dev.filter.Value() != "d" {
|
||||
t.Fatalf("filter input was intercepted: dashboard=%v filter=%q", got.showDashboard, got.dev.filter.Value())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppCanOpenInstancesDuringBuild(t *testing.T) {
|
||||
m := newAppModel(false)
|
||||
m.dev.rebuilding = true
|
||||
|
||||
updated, _ := m.Update(tea.KeyPressMsg(tea.Key{Code: 'd', Text: "d"}))
|
||||
if !updated.(appModel).showDashboard {
|
||||
t.Fatal("background build blocked dashboard navigation")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,415 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"charm.land/bubbles/v2/table"
|
||||
"charm.land/bubbles/v2/textinput"
|
||||
"charm.land/bubbles/v2/viewport"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
|
||||
"github.com/homarr-labs/homarr/tools/homarr-dev/internal/docker"
|
||||
"github.com/homarr-labs/homarr/tools/homarr-dev/internal/platform"
|
||||
)
|
||||
|
||||
var (
|
||||
titleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("212"))
|
||||
helpStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("241"))
|
||||
logHeadSt = lipgloss.NewStyle().Foreground(lipgloss.Color("241"))
|
||||
runningLegend = lipgloss.NewStyle().Foreground(lipgloss.Color("42")).Render("● running")
|
||||
stoppedLegend = lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render("○ stopped")
|
||||
)
|
||||
|
||||
type dashModel struct {
|
||||
table table.Model
|
||||
allRows []table.Row
|
||||
filter textinput.Model
|
||||
filtering bool
|
||||
logs viewport.Model
|
||||
showLogs bool
|
||||
follow bool
|
||||
status string
|
||||
confirmRemove string
|
||||
width int
|
||||
height int
|
||||
}
|
||||
|
||||
type tickMsg time.Time
|
||||
type actionMsg string
|
||||
type rowsMsg []table.Row
|
||||
type logsMsg struct {
|
||||
name string
|
||||
content string
|
||||
}
|
||||
|
||||
func tick() tea.Cmd {
|
||||
return tea.Tick(3*time.Second, func(t time.Time) tea.Msg { return tickMsg(t) })
|
||||
}
|
||||
|
||||
func fetchLogs(name string) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
if name == "" {
|
||||
return logsMsg{}
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
out, err := exec.CommandContext(ctx, "docker", "logs", "--tail", "200", name).CombinedOutput()
|
||||
if err != nil {
|
||||
return logsMsg{name: name, content: "logs unavailable: " + err.Error()}
|
||||
}
|
||||
return logsMsg{name: name, content: string(out)}
|
||||
}
|
||||
}
|
||||
|
||||
func loadRows() tea.Cmd {
|
||||
return func() tea.Msg { return rowsMsg(refreshRows()) }
|
||||
}
|
||||
|
||||
func refreshRows() []table.Row {
|
||||
containers, _ := docker.List()
|
||||
rows := make([]table.Row, 0, len(containers))
|
||||
for _, c := range containers {
|
||||
port := c.HostPort()
|
||||
url := ""
|
||||
if port != "" && c.Running() {
|
||||
url = "http://localhost:" + port
|
||||
}
|
||||
state := "● " + c.State
|
||||
if !c.Running() {
|
||||
state = "○ " + c.State
|
||||
}
|
||||
img := strings.TrimPrefix(c.Image, "ghcr.io/homarr-labs/")
|
||||
rows = append(rows, table.Row{c.Name, state, img, port, url, c.Status})
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
func dashboardColumns(width int) []table.Column {
|
||||
nameWidth := 26
|
||||
imageWidth := 30
|
||||
urlWidth := 24
|
||||
if width < 100 {
|
||||
nameWidth = 22
|
||||
imageWidth = max(width-54, 18)
|
||||
urlWidth = 0
|
||||
}
|
||||
return []table.Column{
|
||||
{Title: "NAME", Width: nameWidth},
|
||||
{Title: "STATE", Width: 14},
|
||||
{Title: "IMAGE", Width: imageWidth},
|
||||
{Title: "PORT", Width: 6},
|
||||
{Title: "URL", Width: urlWidth},
|
||||
{Title: "STATUS", Width: 0},
|
||||
}
|
||||
}
|
||||
|
||||
func newDashModel() dashModel {
|
||||
t := table.New(
|
||||
table.WithColumns(dashboardColumns(104)),
|
||||
table.WithFocused(true),
|
||||
table.WithHeight(10),
|
||||
table.WithWidth(104),
|
||||
)
|
||||
s := table.DefaultStyles()
|
||||
s.Header = s.Header.Bold(true)
|
||||
s.Selected = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("230")).Background(lipgloss.Color("57"))
|
||||
t.SetStyles(s)
|
||||
logs := viewport.New(viewport.WithWidth(104), viewport.WithHeight(12))
|
||||
logs.SoftWrap = true
|
||||
filter := textinput.New()
|
||||
filter.Prompt = "/ "
|
||||
filter.Placeholder = "filter name, image, port, state…"
|
||||
filter.SetWidth(50)
|
||||
styles := textinput.DefaultDarkStyles()
|
||||
styles.Focused.Prompt = styles.Focused.Prompt.Foreground(lipgloss.Color("212")).Bold(true)
|
||||
styles.Focused.Text = styles.Focused.Text.Foreground(lipgloss.Color("229"))
|
||||
filter.SetStyles(styles)
|
||||
return dashModel{table: t, filter: filter, logs: logs, showLogs: true, follow: true, width: 104, height: 30}
|
||||
}
|
||||
|
||||
func (m *dashModel) applyFilter() {
|
||||
query := strings.ToLower(strings.TrimSpace(m.filter.Value()))
|
||||
if query == "" {
|
||||
m.table.SetRows(m.allRows)
|
||||
return
|
||||
}
|
||||
rows := make([]table.Row, 0, len(m.allRows))
|
||||
for _, row := range m.allRows {
|
||||
if strings.Contains(strings.ToLower(strings.Join(row, " ")), query) {
|
||||
rows = append(rows, row)
|
||||
}
|
||||
}
|
||||
m.table.SetRows(rows)
|
||||
}
|
||||
|
||||
func (m dashModel) selected() string {
|
||||
row := m.table.SelectedRow()
|
||||
if len(row) == 0 {
|
||||
return ""
|
||||
}
|
||||
return row[0]
|
||||
}
|
||||
|
||||
func (m dashModel) selectedPort() string {
|
||||
row := m.table.SelectedRow()
|
||||
if len(row) < 4 {
|
||||
return ""
|
||||
}
|
||||
return row[3]
|
||||
}
|
||||
|
||||
func (m dashModel) Init() tea.Cmd {
|
||||
return tea.Batch(tick(), loadRows(), fetchLogs(m.selected()))
|
||||
}
|
||||
|
||||
func (m dashModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
m.width = msg.Width
|
||||
m.height = msg.Height
|
||||
m.table.SetColumns(dashboardColumns(msg.Width))
|
||||
m.table.SetWidth(msg.Width)
|
||||
m.logs.SetWidth(msg.Width)
|
||||
m.filter.SetWidth(max(min(msg.Width-4, 60), 10))
|
||||
m.layout()
|
||||
return m, nil
|
||||
case tickMsg:
|
||||
return m, tea.Batch(tick(), loadRows())
|
||||
case rowsMsg:
|
||||
m.allRows = []table.Row(msg)
|
||||
m.applyFilter()
|
||||
return m, fetchLogs(m.selected())
|
||||
case logsMsg:
|
||||
if msg.name != "" && msg.name != m.selected() {
|
||||
return m, nil
|
||||
}
|
||||
m.logs.SetContent(strings.TrimRight(msg.content, "\n"))
|
||||
if m.follow {
|
||||
m.logs.GotoBottom()
|
||||
}
|
||||
return m, nil
|
||||
case actionMsg:
|
||||
m.status = string(msg)
|
||||
m.confirmRemove = ""
|
||||
return m, loadRows()
|
||||
case tea.KeyPressMsg:
|
||||
if m.filtering {
|
||||
switch msg.Key().Code {
|
||||
case tea.KeyEscape:
|
||||
m.filtering = false
|
||||
m.filter.Reset()
|
||||
m.filter.Blur()
|
||||
m.applyFilter()
|
||||
return m, fetchLogs(m.selected())
|
||||
case tea.KeyEnter:
|
||||
m.filtering = false
|
||||
m.filter.Blur()
|
||||
return m, fetchLogs(m.selected())
|
||||
}
|
||||
var cmd tea.Cmd
|
||||
m.filter, cmd = m.filter.Update(msg)
|
||||
m.applyFilter()
|
||||
return m, tea.Batch(cmd, fetchLogs(m.selected()))
|
||||
}
|
||||
name := m.selected()
|
||||
switch msg.Key().Code {
|
||||
case tea.KeyUp, tea.KeyKpUp:
|
||||
m.table.MoveUp(1)
|
||||
return m, fetchLogs(m.selected())
|
||||
case tea.KeyDown, tea.KeyKpDown:
|
||||
m.table.MoveDown(1)
|
||||
return m, fetchLogs(m.selected())
|
||||
case tea.KeyPgUp, tea.KeyKpPgUp:
|
||||
m.follow = false
|
||||
m.logs.PageUp()
|
||||
return m, nil
|
||||
case tea.KeyPgDown, tea.KeyKpPgDown:
|
||||
m.logs.PageDown()
|
||||
m.follow = m.logs.AtBottom()
|
||||
return m, nil
|
||||
}
|
||||
switch msg.String() {
|
||||
case "q", "ctrl+c", "esc":
|
||||
return m, tea.Quit
|
||||
case "l":
|
||||
m.showLogs = !m.showLogs
|
||||
m.layout()
|
||||
if m.showLogs {
|
||||
return m, fetchLogs(name)
|
||||
}
|
||||
return m, nil
|
||||
case "/":
|
||||
m.filtering = true
|
||||
return m, m.filter.Focus()
|
||||
case "f":
|
||||
m.follow = !m.follow
|
||||
if m.follow {
|
||||
m.logs.GotoBottom()
|
||||
}
|
||||
return m, nil
|
||||
case "s":
|
||||
if name != "" {
|
||||
m.status = "stopping " + name + "…"
|
||||
return m, func() tea.Msg {
|
||||
if err := docker.Stop(name); err != nil {
|
||||
return actionMsg("stop failed: " + err.Error())
|
||||
}
|
||||
return actionMsg("stopped " + name)
|
||||
}
|
||||
}
|
||||
case "x":
|
||||
if name != "" {
|
||||
if m.confirmRemove != name {
|
||||
m.confirmRemove = name
|
||||
m.status = "press x again to remove " + name
|
||||
return m, nil
|
||||
}
|
||||
m.status = "removing " + name + "…"
|
||||
return m, func() tea.Msg {
|
||||
if err := docker.Remove(name); err != nil {
|
||||
return actionMsg("remove failed: " + err.Error())
|
||||
}
|
||||
return actionMsg("removed " + name)
|
||||
}
|
||||
}
|
||||
case "R":
|
||||
if name != "" {
|
||||
m.status = "restarting " + name + "…"
|
||||
return m, func() tea.Msg {
|
||||
if err := docker.Restart(name); err != nil {
|
||||
return actionMsg("restart failed: " + err.Error())
|
||||
}
|
||||
return actionMsg("restarted " + name)
|
||||
}
|
||||
}
|
||||
case "o":
|
||||
if name != "" {
|
||||
if port := m.selectedPort(); port != "" {
|
||||
if err := platform.OpenURL("http://localhost:" + port); err != nil {
|
||||
m.status = "open failed: " + err.Error()
|
||||
}
|
||||
}
|
||||
}
|
||||
case "c":
|
||||
if name != "" {
|
||||
if port := m.selectedPort(); port != "" {
|
||||
url := "http://localhost:" + port
|
||||
if err := platform.CopyText(url); err != nil {
|
||||
m.status = "copy failed: " + err.Error()
|
||||
} else {
|
||||
m.status = "copied " + url
|
||||
}
|
||||
}
|
||||
}
|
||||
case "r":
|
||||
return m, loadRows()
|
||||
case "k":
|
||||
m.table.MoveUp(1)
|
||||
return m, fetchLogs(m.selected())
|
||||
case "j":
|
||||
m.table.MoveDown(1)
|
||||
return m, fetchLogs(m.selected())
|
||||
}
|
||||
}
|
||||
var cmd tea.Cmd
|
||||
m.table, cmd = m.table.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
func (m *dashModel) layout() {
|
||||
available := m.height - 11
|
||||
if m.filtering || m.filter.Value() != "" {
|
||||
available -= 2
|
||||
}
|
||||
if !m.showLogs {
|
||||
m.table.SetHeight(max(available, 5))
|
||||
return
|
||||
}
|
||||
tableH := min(12, max(available/2, 5))
|
||||
m.table.SetHeight(tableH)
|
||||
logsH := available - tableH
|
||||
if logsH < 3 {
|
||||
logsH = 3
|
||||
}
|
||||
m.logs.SetHeight(logsH)
|
||||
}
|
||||
|
||||
func (m dashModel) selectedDetailView() string {
|
||||
row := m.table.SelectedRow()
|
||||
if len(row) < 6 {
|
||||
return ""
|
||||
}
|
||||
title := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("229")).Render(row[0])
|
||||
meta := row[2] + " · " + row[1]
|
||||
if row[3] != "" {
|
||||
meta += " · port " + row[3]
|
||||
}
|
||||
if row[4] != "" {
|
||||
meta += " · " + row[4]
|
||||
}
|
||||
if row[5] != "" {
|
||||
meta += " · " + row[5]
|
||||
}
|
||||
return title + "\n" + helpStyle.Render(meta)
|
||||
}
|
||||
|
||||
func (m dashModel) View() tea.View {
|
||||
var b strings.Builder
|
||||
running := 0
|
||||
for _, row := range m.allRows {
|
||||
if len(row) > 1 && strings.Contains(row[1], "running") {
|
||||
running++
|
||||
}
|
||||
}
|
||||
b.WriteString(titleStyle.Render("🦞 homarr instances") + " " + helpStyle.Render(fmt.Sprintf("%d total · %d running · %d shown", len(m.allRows), running, len(m.table.Rows()))))
|
||||
b.WriteString("\n\n")
|
||||
if m.filtering || m.filter.Value() != "" {
|
||||
b.WriteString(m.filter.View() + "\n\n")
|
||||
}
|
||||
if len(m.table.Rows()) == 0 {
|
||||
message := "no homarr containers — start one with `homarr run <tag>` or `homarr run --pr N`"
|
||||
if len(m.allRows) > 0 {
|
||||
message = "no instances match " + m.filter.Value()
|
||||
}
|
||||
b.WriteString(helpStyle.Render(" " + message + "\n"))
|
||||
} else {
|
||||
b.WriteString(m.table.View())
|
||||
b.WriteString("\n")
|
||||
}
|
||||
if detail := m.selectedDetailView(); detail != "" {
|
||||
b.WriteString("\n" + detail + "\n")
|
||||
}
|
||||
if m.showLogs && len(m.table.Rows()) > 0 {
|
||||
mode := "paused"
|
||||
if m.follow {
|
||||
mode = "following"
|
||||
}
|
||||
b.WriteString("\n" + logHeadSt.Render("── logs: "+m.selected()+" ("+mode+") ──") + "\n")
|
||||
b.WriteString(m.logs.View())
|
||||
}
|
||||
if m.status != "" {
|
||||
statusStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("42"))
|
||||
lower := strings.ToLower(m.status)
|
||||
if strings.Contains(lower, "fail") || strings.Contains(lower, "error") {
|
||||
statusStyle = statusStyle.Foreground(lipgloss.Color("196"))
|
||||
} else if strings.Contains(lower, "press x") {
|
||||
statusStyle = statusStyle.Foreground(lipgloss.Color("214"))
|
||||
}
|
||||
b.WriteString("\n" + statusStyle.Render(m.status))
|
||||
}
|
||||
b.WriteString("\n\n" + helpStyle.Render("/ filter · ↑/↓ select · l logs · f follow · pgup/pgdn scroll · s stop · R restart · x remove · o open · c copy URL · r refresh · d development · q quit"))
|
||||
b.WriteString("\n" + helpStyle.Render(runningLegend+" · "+stoppedLegend))
|
||||
v := tea.NewView(b.String())
|
||||
v.AltScreen = true
|
||||
v.WindowTitle = "🦞 Homarr instances"
|
||||
return v
|
||||
}
|
||||
|
||||
func RunDashboard() error {
|
||||
return runApp(true)
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"charm.land/bubbles/v2/table"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
)
|
||||
|
||||
func TestDashboardRendersRowsAndLogs(t *testing.T) {
|
||||
m := newDashModel()
|
||||
m.table.SetRows([]table.Row{{
|
||||
"homarr_pr_6441",
|
||||
"running",
|
||||
"homarr-test:pr-6441",
|
||||
"7575",
|
||||
"http://localhost:7575",
|
||||
"Up 2 minutes",
|
||||
}})
|
||||
m.logs.SetWidth(104)
|
||||
m.logs.SetHeight(5)
|
||||
m.logs.SetContent("server ready")
|
||||
|
||||
got := m.View().Content
|
||||
for _, want := range []string{"homarr_pr_6441", "server ready"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("dashboard omitted %q: %q", want, got)
|
||||
}
|
||||
}
|
||||
if !m.View().AltScreen {
|
||||
t.Fatal("dashboard must use the alternate screen")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDashboardArrowNavigation(t *testing.T) {
|
||||
m := newDashModel()
|
||||
m.table.SetRows([]table.Row{
|
||||
{"homarr_first", "running", "homarr:first", "7575", "", "Up 1 minute"},
|
||||
{"homarr_second", "running", "homarr:second", "7576", "", "Up 1 minute"},
|
||||
})
|
||||
|
||||
updated, _ := m.Update(tea.KeyPressMsg(tea.Key{Code: tea.KeyDown}))
|
||||
got := updated.(dashModel).selected()
|
||||
if got != "homarr_second" {
|
||||
t.Fatalf("down arrow selected %q, want homarr_second", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDashboardIgnoresStaleLogs(t *testing.T) {
|
||||
m := newDashModel()
|
||||
m.table.SetRows([]table.Row{{"homarr_current", "running", "homarr:current", "7575", "", "Up 1 minute"}})
|
||||
m.logs.SetWidth(104)
|
||||
m.logs.SetHeight(5)
|
||||
m.logs.SetContent("current logs")
|
||||
|
||||
updated, _ := m.Update(logsMsg{name: "homarr_old", content: "stale logs"})
|
||||
got := updated.(dashModel).View().Content
|
||||
if strings.Contains(got, "stale logs") || !strings.Contains(got, "current logs") {
|
||||
t.Fatalf("stale log result replaced current content: %q", got)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
Loaded 100 of 103 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user