From bd669a1a8f6e9ae10a516bd35b63d716554ef96e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 31 Jul 2026 19:35:37 +0000 Subject: [PATCH 1/6] docs(helm): update chart installation page --- apps/docs/docs/getting-started/installation/helm.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/docs/docs/getting-started/installation/helm.md b/apps/docs/docs/getting-started/installation/helm.md index 0d897ffa9..051c38955 100644 --- a/apps/docs/docs/getting-started/installation/helm.md +++ b/apps/docs/docs/getting-started/installation/helm.md @@ -2,9 +2,9 @@ homarr logo -![Version: 8.24.0](https://img.shields.io/badge/Version-8.24.0-informational?style=flat) +![Version: 8.25.0](https://img.shields.io/badge/Version-8.25.0-informational?style=flat) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat) -![AppVersion: v1.72.0](https://img.shields.io/badge/AppVersion-v1.72.0-informational?style=flat) +![AppVersion: v1.73.0](https://img.shields.io/badge/AppVersion-v1.73.0-informational?style=flat) A Helm chart to deploy homarr for Kubernetes @@ -429,7 +429,7 @@ All available values are listed on the [artifacthub](https://artifacthub.io/pack | httproute.rules[0].matches[0].path.value | string | `"/"` | Path value to match | | image.pullPolicy | string | `"IfNotPresent"` | Image pull policy | | image.repository | string | `"ghcr.io/homarr-labs/homarr"` | Image repository | -| image.tag | string | `"v1.72.0"` | Overrides the image tag whose default is the chart appVersion | +| image.tag | string | `"v1.73.0"` | Overrides the image tag whose default is the chart appVersion | | imagePullSecrets | list | `[]` | Secrets for Docker registry | | ingress.annotations | object | `{}` | Ingress annotations | | ingress.enabled | bool | `false` | Enable ingress | From edb56f3fcdea0b0ec6494d432dda80878681b4e6 Mon Sep 17 00:00:00 2001 From: Thomas Date: Sat, 1 Aug 2026 23:06:26 +0200 Subject: [PATCH 2/6] fix: accept direct icon URLs Use valid direct HTTP(S) icon URLs without repository search. --- apps/docs/docs/advanced/icons/index.mdx | 2 +- apps/docs/docs/management/apps/index.mdx | 4 ++-- .../src/icon-picker/icon-picker.tsx | 14 ++++++++++++-- 3 files changed, 15 insertions(+), 5 deletions(-) diff --git a/apps/docs/docs/advanced/icons/index.mdx b/apps/docs/docs/advanced/icons/index.mdx index 2876559ac..b73d49e02 100644 --- a/apps/docs/docs/advanced/icons/index.mdx +++ b/apps/docs/docs/advanced/icons/index.mdx @@ -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. diff --git a/apps/docs/docs/management/apps/index.mdx b/apps/docs/docs/management/apps/index.mdx index c5c0a2ad7..35c02a4fc 100644 --- a/apps/docs/docs/management/apps/index.mdx +++ b/apps/docs/docs/management/apps/index.mdx @@ -28,7 +28,7 @@ The name must be at least one character long and can be chosen freely. ### Icon URL This is the URL to the icon that will be displayed on the app widget. -It can be a url to an image in the internet, one from the included icon repositories or a local file uploaded through the upload button. +You can paste a direct HTTP(S) URL to an image, search the included icon repositories by name, or upload a local file through the upload button. Direct image URLs are used as entered and are not searched in the icon repositories. ![Icon URL](./img/app-icon-url.png) @@ -50,4 +50,4 @@ It's useful when you want to ping the service internally for example with a cust On the apps management page you find all apps that exist in your Homarr instance. You can edit and delete them. -![Apps list](./img/app-list.png) \ No newline at end of file +![Apps list](./img/app-list.png) diff --git a/packages/forms-collection/src/icon-picker/icon-picker.tsx b/packages/forms-collection/src/icon-picker/icon-picker.tsx index 058e75bd0..4af6f1bd0 100644 --- a/packages/forms-collection/src/icon-picker/icon-picker.tsx +++ b/packages/forms-collection/src/icon-picker/icon-picker.tsx @@ -165,8 +165,18 @@ export const IconPicker = ({ leftSection={leftSection} value={inputValue} onChange={(event) => { - combobox.openDropdown(); - setQuery(event.currentTarget.value); + const nextValue = event.currentTarget.value; + const trimmedValue = nextValue.trim(); + if (/^https?:\/\/[^\s/]+(?:\/\S*)?$/i.test(trimmedValue)) { + startTransition(() => { + setValue(trimmedValue); + setQuery(""); + combobox.closeDropdown(); + }); + } else { + combobox.openDropdown(); + setQuery(nextValue); + } }} onClick={() => combobox.openDropdown()} onFocus={(event) => { From f4449f9581a181885cdaddbc4bb1f69da581ce72 Mon Sep 17 00:00:00 2001 From: "homarr-releases[bot]" <175486441+homarr-releases[bot]@users.noreply.github.com> Date: Sat, 1 Aug 2026 23:08:14 +0200 Subject: [PATCH 3/6] chore: update bug report template (#6498) --- .github/ISSUE_TEMPLATE/bug_report.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index e41e2f3c0..0d8b33a33 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -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 From 27ca05808858aaba024ecb9fc277b95d4e0a043c Mon Sep 17 00:00:00 2001 From: Thomas Date: Sat, 1 Aug 2026 23:20:09 +0200 Subject: [PATCH 4/6] feat(widgets): persist Beszel and Docker table layouts (#6490) --- .../widgets/beszel-system-table/index.mdx | 9 +- .../docs/widgets/docker-containers/index.mdx | 11 +- .../docs/widgets/docker-containers/index.ts | 6 + apps/docs/docs/widgets/downloads/index.mdx | 2 +- .../test/navidrome-integration.spec.ts | 2 +- packages/translation/src/lang/en.json | 24 +- .../src/beszel-system-table/component.spec.ts | 34 + .../src/beszel-system-table/component.tsx | 120 +++- .../widgets/src/beszel-system-table/index.ts | 50 +- .../src/beszel-system-table/styles.css | 47 -- .../widgets/src/common/homarr-data-table.css | 52 ++ .../widgets/src/common/homarr-data-table.tsx | 57 ++ .../common/use-persisted-table-layout.spec.ts | 83 +++ .../src/common/use-persisted-table-layout.ts | 233 +++++++ packages/widgets/src/docker/component.spec.ts | 33 + packages/widgets/src/docker/component.tsx | 653 ++++++++++++------ packages/widgets/src/docker/index.ts | 52 +- packages/widgets/src/downloads/component.tsx | 139 ++-- packages/widgets/src/downloads/styles.css | 48 -- 19 files changed, 1171 insertions(+), 484 deletions(-) create mode 100644 packages/widgets/src/beszel-system-table/component.spec.ts delete mode 100644 packages/widgets/src/beszel-system-table/styles.css create mode 100644 packages/widgets/src/common/homarr-data-table.css create mode 100644 packages/widgets/src/common/homarr-data-table.tsx create mode 100644 packages/widgets/src/common/use-persisted-table-layout.spec.ts create mode 100644 packages/widgets/src/common/use-persisted-table-layout.ts create mode 100644 packages/widgets/src/docker/component.spec.ts diff --git a/apps/docs/docs/widgets/beszel-system-table/index.mdx b/apps/docs/docs/widgets/beszel-system-table/index.mdx index df68fcc1b..163d14a2f 100644 --- a/apps/docs/docs/widgets/beszel-system-table/index.mdx +++ b/apps/docs/docs/widgets/beszel-system-table/index.mdx @@ -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 + +### Configuration + + diff --git a/apps/docs/docs/widgets/docker-containers/index.ts b/apps/docs/docs/widgets/docker-containers/index.ts index 3e4a17ab6..0517938d3 100644 --- a/apps/docs/docs/widgets/docker-containers/index.ts +++ b/apps/docs/docs/widgets/docker-containers/index.ts @@ -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", diff --git a/apps/docs/docs/widgets/downloads/index.mdx b/apps/docs/docs/widgets/downloads/index.mdx index 000fd066b..d73ad02bb 100644 --- a/apps/docs/docs/widgets/downloads/index.mdx +++ b/apps/docs/docs/widgets/downloads/index.mdx @@ -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 diff --git a/packages/integrations/src/navidrome/test/navidrome-integration.spec.ts b/packages/integrations/src/navidrome/test/navidrome-integration.spec.ts index 08c66ad2a..662292a70 100644 --- a/packages/integrations/src/navidrome/test/navidrome-integration.spec.ts +++ b/packages/integrations/src/navidrome/test/navidrome-integration.spec.ts @@ -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); diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index de211bf66..3a1253be5 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -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": { @@ -3940,11 +3951,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 +5361,7 @@ }, "remove": { "label": "Remove", + "confirm": "Click again to remove permanently", "notification": { "success": { "title": "Containers removed", diff --git a/packages/widgets/src/beszel-system-table/component.spec.ts b/packages/widgets/src/beszel-system-table/component.spec.ts new file mode 100644 index 000000000..0dd3e655d --- /dev/null +++ b/packages/widgets/src/beszel-system-table/component.spec.ts @@ -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 }); + }); +}); diff --git a/packages/widgets/src/beszel-system-table/component.tsx b/packages/widgets/src/beszel-system-table/component.tsx index 5352eb170..8286335a6 100644 --- a/packages/widgets/src/beszel-system-table/component.tsx +++ b/packages/widgets/src/beszel-system-table/component.tsx @@ -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 = { 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 | false)[] = [ { accessor: "name", + width: 160, + ellipsis: true, title: ( @@ -130,7 +177,7 @@ export default function BeszelSystemTableWidget({ ), sortable: true, render: (record) => ( - + {record.name} @@ -140,6 +187,7 @@ export default function BeszelSystemTableWidget({ }, options.showCpu && { accessor: "cpu", + width: 140, title: ( @@ -151,6 +199,7 @@ export default function BeszelSystemTableWidget({ }, options.showMemory && { accessor: "memory", + width: 140, title: ( @@ -162,6 +211,7 @@ export default function BeszelSystemTableWidget({ }, options.showDisk && { accessor: "disk", + width: 160, title: ( @@ -181,6 +231,7 @@ export default function BeszelSystemTableWidget({ }, options.showGpu && { accessor: "gpu", + width: 140, title: ( @@ -192,6 +243,7 @@ export default function BeszelSystemTableWidget({ }, options.showLoadAvg && { accessor: "loadAvg", + width: 100, title: ( @@ -208,6 +260,7 @@ export default function BeszelSystemTableWidget({ }, options.showNet && { accessor: "netBytes", + width: 100, title: ( @@ -223,6 +276,7 @@ export default function BeszelSystemTableWidget({ }, options.showTemp && { accessor: "temp", + width: 80, title: ( @@ -234,6 +288,7 @@ export default function BeszelSystemTableWidget({ }, options.showBattery && { accessor: "battery", + width: 70, title: ( @@ -244,6 +299,7 @@ export default function BeszelSystemTableWidget({ }, options.showServices && { accessor: "services", + width: 80, title: ( @@ -255,6 +311,7 @@ export default function BeszelSystemTableWidget({ }, options.showUptime && { accessor: "uptime", + width: 90, title: ( @@ -266,6 +323,7 @@ export default function BeszelSystemTableWidget({ }, options.showAgent && { accessor: "agentVersion", + width: 90, title: ( @@ -278,12 +336,39 @@ export default function BeszelSystemTableWidget({ ]; return cols.filter(Boolean) as DataTableColumn[]; - }, [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({
- diff --git a/packages/widgets/src/beszel-system-table/index.ts b/packages/widgets/src/beszel-system-table/index.ts index ba90cb5e9..e31bbc597 100644 --- a/packages/widgets/src/beszel-system-table/index.ts +++ b/packages/widgets/src/beszel-system-table/index.ts @@ -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: { diff --git a/packages/widgets/src/beszel-system-table/styles.css b/packages/widgets/src/beszel-system-table/styles.css deleted file mode 100644 index cdfd682f9..000000000 --- a/packages/widgets/src/beszel-system-table/styles.css +++ /dev/null @@ -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; -} diff --git a/packages/widgets/src/common/homarr-data-table.css b/packages/widgets/src/common/homarr-data-table.css new file mode 100644 index 000000000..0e9d7d7c5 --- /dev/null +++ b/packages/widgets/src/common/homarr-data-table.css @@ -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; +} diff --git a/packages/widgets/src/common/homarr-data-table.tsx b/packages/widgets/src/common/homarr-data-table.tsx new file mode 100644 index 000000000..0941a9305 --- /dev/null +++ b/packages/widgets/src/common/homarr-data-table.tsx @@ -0,0 +1,57 @@ +"use client"; + +import "./homarr-data-table.css"; + +import type { DataTableProps } from "mantine-datatable"; +import { DataTable } from "mantine-datatable"; + +type HomarrDataTableProps = DataTableProps & { + isEditMode: boolean; + cellPadding: string; + rowCursor?: "auto" | "default"; +}; + +export function HomarrDataTable({ + isEditMode, + cellPadding, + rowCursor = "auto", + className, + defaultColumnProps, + scrollAreaProps, + style, + ...props +}: HomarrDataTableProps) { + const mergedClassName = [ + "homarr-data-table", + rowCursor === "default" ? "homarr-data-table-row-cursor-default" : undefined, + className, + ] + .filter(Boolean) + .join(" "); + + return ( + + 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 }} + /> + ); +} diff --git a/packages/widgets/src/common/use-persisted-table-layout.spec.ts b/packages/widgets/src/common/use-persisted-table-layout.spec.ts new file mode 100644 index 000000000..0e1297b88 --- /dev/null +++ b/packages/widgets/src/common/use-persisted-table-layout.spec.ts @@ -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[], +})); + +vi.mock("mantine-datatable", () => ({ + useDataTableColumns: ({ columns }: { columns: DataTableColumn[] }) => ({ + 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(); + }); +}); diff --git a/packages/widgets/src/common/use-persisted-table-layout.ts b/packages/widgets/src/common/use-persisted-table-layout.ts new file mode 100644 index 000000000..9a126f076 --- /dev/null +++ b/packages/widgets/src/common/use-persisted-table-layout.ts @@ -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 { + 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 { + columns: DataTableColumn[]; + 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[], + visibleAccessorSet: ReadonlySet, +): Record => { + const widths: Record = {}; + 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 => { + 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 = ({ + columns, + columnAccessors, + columnOrder, + columnWidths, + itemId, + storeKeyPrefix, + onLayoutChange, +}: UsePersistedTableLayoutProps) => { + 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({ + 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(undefined); + const saveTimeout = useRef(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 = { ...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 }; +}; diff --git a/packages/widgets/src/docker/component.spec.ts b/packages/widgets/src/docker/component.spec.ts new file mode 100644 index 000000000..4be915c5a --- /dev/null +++ b/packages/widgets/src/docker/component.spec.ts @@ -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 }); + }); +}); diff --git a/packages/widgets/src/docker/component.tsx b/packages/widgets/src/docker/component.tsx index e56cd5cf7..576a80d9d 100644 --- a/packages/widgets/src/docker/component.tsx +++ b/packages/widgets/src/docker/component.tsx @@ -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) => + `/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>, -): MRT_ColumnDef[] => [ + handlers: ContainerActionHandlers, + sortingEnabled: boolean, +): DataTableColumn[] => [ { - id: "name", - accessorKey: "name", - header: t("field.name.label"), - Cell({ renderedCellValue, row }) { - return ( - - - - {renderedCellValue} - - - ); - }, - }, - { - id: "state", - accessorKey: "state", - size: 100, - header: t("field.state.label"), - Cell({ row }) { - return ; - }, - }, - { - id: "host", - accessorKey: "host", - size: 100, - header: t("field.host.label"), - Cell({ row }) { - return ( - - {row.original.host} + accessor: "name", + title: t("field.name.label"), + width: 180, + ellipsis: true, + sortable: sortingEnabled, + render: (container) => ( + + + {container.name.at(0)?.toUpperCase()} + + + {container.name} - ); - }, + + ), }, { - 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) => , + }, + { + accessor: "host", + title: t("field.host.label"), + width: 120, + sortable: sortingEnabled, + ellipsis: true, + render: (container) => ( + + {container.host} + + ), + }, + { + accessor: "cpuUsage", + title: t("field.stats.cpu.label"), + width: 80, + sortable: sortingEnabled, + render: (container) => { + const cpuUsage = safeValue(container.cpuUsage); return ( - + {cpuUsage.toFixed(2)}% ); }, }, { - 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 ( - - {formatBytes(bytesUsage)} + + {formatBytes(memoryUsage)} ); }, }, { - 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 ( - - - handleActionAsync(row.original.state === "running" ? "stop" : "start")} - > - {row.original.state === "running" ? ( - - ) : ( - - )} - - - - handleActionAsync("restart")}> - - - - - ); - }, + accessor: "actions", + title: "", + width: 44, + textAlign: "right", + render: (container) => , }, ]; -export default function DockerWidget({ options, width, isEditMode }: WidgetComponentProps<"dockerContainers">) { +const compareContainers = ( + first: DockerContainer, + second: DockerContainer, + accessor: DataTableSortStatus["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( + () => ({ + 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>({ + 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(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(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 (
{t("error.noColumns")}
); + } return ( - - + + + { + if (contextMenu) closeContextMenu(); + }} + /> + {!isTiny && ( - + - - {t("table.footer", { count: totalContainers.toString() })} + + {t("table.footer", { count: containers.length.toString() })} - + {t("table.totalCpu", { cpu: totals.cpu.toFixed(2) })} - {t("table.totalMemory", { memory: formatBytes(totals.memory) })} - void refetch()} aria-label={t("table.refresh.lastUpdated", { when: relativeTime })} > - + )} + + {contextMenu && } ); } + +function ContainerMenuButton({ + container, + handlers, +}: { + container: DockerContainer; + handlers: ContainerActionHandlers; +}) { + const t = useScopedI18n("docker.action"); + const [opened, setOpened] = useState(false); + const close = () => setOpened(false); + + return ( + + + event.stopPropagation()} + > + + + + + + + + ); +} + +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 ( + <> + + {container.name} + + } + onClick={() => { + handlers.onOpenLogs(container); + onClose(); + }} + > + {t("logs.label")} + + + } + onClick={() => invokeAction(stateAction)} + > + {t(`${stateAction}.label`)} + + } onClick={() => invokeAction("restart")}> + {t("restart.label")} + + {!confirmRemove ? ( + } onClick={() => setConfirmRemove(true)}> + {t("remove.label")} + + ) : ( + } onClick={() => invokeAction("remove")}> + {t("remove.confirm")} + + )} + + } + onClick={() => { + handlers.onAddToHomarr(container); + onClose(); + }} + > + {t("addToHomarr.label")} + + + ); +} + +function ContainerContextMenu({ + state, + handlers, + onClose, +}: { + state: ContextMenuState; + handlers: ContainerActionHandlers; + onClose: () => void; +}) { + return ( + + { + event.preventDefault(); + onClose(); + }} + > + + + + + event.stopPropagation()} + > + + + + + + ); +} diff --git a/packages/widgets/src/docker/index.ts b/packages/widgets/src/docker/index.ts index 4a988c779..03c6e990d 100644 --- a/packages/widgets/src/docker/index.ts +++ b/packages/widgets/src/docker/index.ts @@ -27,29 +27,37 @@ export const { definition, componentLoader } = createWidgetDefinition("dockerCon icon: IconBrandDocker, queryKey: [["docker", "getContainers"]], createOptions() { - return optionsBuilder.from((factory) => ({ - columns: factory.multiSelect({ - defaultValue: [...allColumnsList], - options: allColumnsList.map((value) => ({ - value, - label: (t) => t(columnTranslationKeyMap[value]), - })), - searchable: true, + return optionsBuilder.from( + (factory) => ({ + columns: factory.multiSelect({ + defaultValue: [...allColumnsList], + options: allColumnsList.map((value) => ({ + value, + label: (t) => t(columnTranslationKeyMap[value]), + })), + searchable: true, + }), + enableRowSorting: factory.switch({ + defaultValue: false, + }), + defaultSort: factory.select({ + defaultValue: "name", + options: columnsList.map((value) => ({ + value, + label: (t) => t(`widget.dockerContainers.option.defaultSort.option.${value}`), + })), + }), + descendingDefaultSort: factory.switch({ + defaultValue: false, + }), + columnOrder: factory.text({ defaultValue: "" }), + columnWidths: factory.text({ defaultValue: "" }), }), - enableRowSorting: factory.switch({ - defaultValue: false, - }), - defaultSort: factory.select({ - defaultValue: "name", - options: columnsList.map((value) => ({ - value, - label: (t) => t(`widget.dockerContainers.option.defaultSort.option.${value}`), - })), - }), - descendingDefaultSort: factory.switch({ - defaultValue: false, - }), - })); + { + columnOrder: { shouldHide: () => true }, + columnWidths: { shouldHide: () => true }, + }, + ); }, errors: { INTERNAL_SERVER_ERROR: { diff --git a/packages/widgets/src/downloads/component.tsx b/packages/widgets/src/downloads/component.tsx index e03c937bf..939293ed5 100644 --- a/packages/widgets/src/downloads/component.tsx +++ b/packages/widgets/src/downloads/component.tsx @@ -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(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>) => { - 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), - [options.columnWidths], - ); + const persistOption = useTableLayoutPersistence({ + boardId, + hasChangeAccess, + itemId, + saveItemOptions, + setOptions, + }); let defaultSortDirection: DataTableSortStatus["direction"] = "asc"; if (options.descendingDefaultSort) defaultSortDirection = "desc"; @@ -659,54 +668,15 @@ export default function DownloadClientsWidget({ return cols.filter(Boolean) as DataTableColumn[]; }, [columnContext, t, size, progressColumnWidth]); - const storeKey = `downloads-${itemId ?? "preview"}-${[...options.columns].toSorted().join(",")}`; - const { effectiveColumns, columnsOrder, columnsWidth, setColumnsOrder, setMultipleColumnWidths } = - useDataTableColumns({ - 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 = {}; - 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({ )} - `${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]} diff --git a/packages/widgets/src/downloads/styles.css b/packages/widgets/src/downloads/styles.css index 98991c9f2..0caffa099 100644 --- a/packages/widgets/src/downloads/styles.css +++ b/packages/widgets/src/downloads/styles.css @@ -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; From 49314533bfced37783c0b1446cfc0d6959964dc6 Mon Sep 17 00:00:00 2001 From: Bergasha Date: Sun, 2 Aug 2026 07:21:13 +1000 Subject: [PATCH 5/6] feat(Media player widget): expands on Direct Play and Transcoding badges (#6489) --- .../media-server/media-server-types.ts | 4 + .../src/jellyfin/jellyfin-integration.ts | 7 ++ .../integrations/src/plex/plex-integration.ts | 20 ++++ packages/translation/src/lang/en.json | 15 +++ .../widgets/src/media-server/component.tsx | 99 +++++++++++++++---- packages/widgets/src/media-server/index.ts | 2 + 6 files changed, 126 insertions(+), 21 deletions(-) diff --git a/packages/integrations/src/interfaces/media-server/media-server-types.ts b/packages/integrations/src/interfaces/media-server/media-server-types.ts index 5390a2d5d..c9a287155 100644 --- a/packages/integrations/src/interfaces/media-server/media-server-types.ts +++ b/packages/integrations/src/interfaces/media-server/media-server-types.ts @@ -41,7 +41,11 @@ export interface StreamSession { audioCodec: string | null; videoCodec: string | null; }; + isVideoDirect: boolean; + isAudioDirect: boolean; + containerChanged: boolean; }; + bitrateKbps: number | null; } | null; } | null; } diff --git a/packages/integrations/src/jellyfin/jellyfin-integration.ts b/packages/integrations/src/jellyfin/jellyfin-integration.ts index 4c3370020..a7be2d409 100644 --- a/packages/integrations/src/jellyfin/jellyfin-integration.ts +++ b/packages/integrations/src/jellyfin/jellyfin-integration.ts @@ -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, }, }; } diff --git a/packages/integrations/src/plex/plex-integration.ts b/packages/integrations/src/plex/plex-integration.ts index 9f8439828..6b772a589 100644 --- a/packages/integrations/src/plex/plex-integration.ts +++ b/packages/integrations/src/plex/plex-integration.ts @@ -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 { 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, }, }, }; diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index 3a1253be5..0c7b91dd9 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -2736,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", @@ -2745,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", diff --git a/packages/widgets/src/media-server/component.tsx b/packages/widgets/src/media-server/component.tsx index 439857a8f..2b127a234 100644 --- a/packages/widgets/src/media-server/component.tsx +++ b/packages/widgets/src/media-server/component.tsx @@ -26,6 +26,35 @@ import { useTranslatedMantineReactTable } from "@homarr/ui/hooks"; import type { WidgetComponentProps } from "../definition"; +type TranscodingDecision = NonNullable< + NonNullable["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; + +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 ( - - {isTranscoding ? t("items.transcoding") : t("items.directPlay")} + + {t(`items.${status}` as never)} - {currentlyPlaying.location && ( - - {currentlyPlaying.location === "lan" ? : } - - {currentlyPlaying.location} - + {(currentlyPlaying.location ?? bitrateLabel) && ( + + + {currentlyPlaying.location && + (currentlyPlaying.location === "lan" ? : )} + {currentlyPlaying.location && ( + + {currentlyPlaying.location} + + )} + + {bitrateLabel && ( + + {bitrateLabel} + + )} )} @@ -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 ( @@ -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) => ( - - - - {integration.integrationName} + + + + {(t as unknown as (key: string, params?: { count: number }) => string)("footer.streams", { + count: flatSessions.length, + })} + + {totalBitrateLabel && ( + + {t("footer.totalBitrate", { bitrate: totalBitrateLabel })} - - ))} + )} + + + {uniqueIntegrations.map((integration) => ( + + + + {integration.integrationName} + + + ))} +
); diff --git a/packages/widgets/src/media-server/index.ts b/packages/widgets/src/media-server/index.ts index 54f00476c..ef746e921 100644 --- a/packages/widgets/src/media-server/index.ts +++ b/packages/widgets/src/media-server/index.ts @@ -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"), From 797bdd001f13dee771f9d486fcdc6251a45ed00b Mon Sep 17 00:00:00 2001 From: Thomas Date: Sat, 1 Aug 2026 23:28:18 +0200 Subject: [PATCH 6/6] fix: keep direct icon URLs visible (#6504) --- .../src/icon-picker/icon-picker.spec.ts | 109 ++++++++++++++++++ .../src/icon-picker/icon-picker.tsx | 22 ++-- 2 files changed, 123 insertions(+), 8 deletions(-) create mode 100644 packages/forms-collection/src/icon-picker/icon-picker.spec.ts diff --git a/packages/forms-collection/src/icon-picker/icon-picker.spec.ts b/packages/forms-collection/src/icon-picker/icon-picker.spec.ts new file mode 100644 index 000000000..1046b341a --- /dev/null +++ b/packages/forms-collection/src/icon-picker/icon-picker.spec.ts @@ -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); + }); +}); diff --git a/packages/forms-collection/src/icon-picker/icon-picker.tsx b/packages/forms-collection/src/icon-picker/icon-picker.tsx index 4af6f1bd0..e3e31c5be 100644 --- a/packages/forms-collection/src/icon-picker/icon-picker.tsx +++ b/packages/forms-collection/src/icon-picker/icon-picker.tsx @@ -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 }, @@ -167,15 +167,12 @@ export const IconPicker = ({ onChange={(event) => { const nextValue = event.currentTarget.value; const trimmedValue = nextValue.trim(); - if (/^https?:\/\/[^\s/]+(?:\/\S*)?$/i.test(trimmedValue)) { - startTransition(() => { - setValue(trimmedValue); - setQuery(""); - combobox.closeDropdown(); - }); + setQuery(nextValue); + if (isDirectIconUrl(trimmedValue)) { + setValue(trimmedValue); + combobox.closeDropdown(); } else { combobox.openDropdown(); - setQuery(nextValue); } }} onClick={() => combobox.openDropdown()} @@ -230,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; + } +};