fix: sync widget modernization with dev
This commit is contained in:
55 files changed
+1613
-679
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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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.
|
||||
|
||||

|
||||
|
||||
@@ -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.
|
||||
|
||||

|
||||

|
||||
@@ -15,9 +15,16 @@ import { beszelIntegration } from "@site/docs/integrations/beszel";
|
||||
|
||||
<WidgetHeader widget={beszelSystemTableWidget} 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.
|
||||
In compact mode, select a system row to open the complete statistics view. In advanced mode, selecting a row focuses an inline one-hour view with system, filesystem, network, and Docker 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 or focus system statistics** by selecting a row, depending on the display mode
|
||||
|
||||
### Supported Integrations
|
||||
|
||||
|
||||
@@ -42,11 +42,15 @@ 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.
|
||||
|
||||
Advanced mode exposes every available column regardless of the compact-mode column selection.
|
||||
|
||||
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
|
||||
|
||||
<WidgetInteractionGuide />
|
||||
@@ -54,3 +58,7 @@ Each container row has an action menu (accessible via the three-dot icon) with t
|
||||
### 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",
|
||||
|
||||
@@ -42,7 +42,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
|
||||
|
||||
@@ -31,6 +31,8 @@ import screen from "./img/screen.png";
|
||||
]}
|
||||
/>
|
||||
|
||||
In advanced mode, the widget also ranks the largest albums by asset count. To keep the dashboard response lightweight, it shows up to 50 albums.
|
||||
|
||||
<WidgetInteractionGuide />
|
||||
|
||||
### Adding the widget
|
||||
|
||||
@@ -22,7 +22,9 @@ import { plexIntegration } from "@site/docs/integrations/plex";
|
||||
<WidgetHeader widget={mediaServerWidget} categories={["Media server"]} />
|
||||
|
||||
The media server widget shows active sessions across your media servers.
|
||||
It also displays useful information for user support and troubleshooting.
|
||||
It shows playback progress, pause state, time remaining, and whether each session is direct play, direct stream, or transcoding audio or video. Location and bitrate can be toggled independently.
|
||||
|
||||
Select a session to inspect its media metadata. Advanced mode adds search, sorting, and a summary of playing and transcoding sessions while keeping separate integration instances identifiable.
|
||||
|
||||
### Screenshots
|
||||
|
||||
|
||||
@@ -169,7 +169,7 @@ describe("matchesWidgetItemQuery", () => {
|
||||
integrationIds: ["beszel-1"],
|
||||
systemId: "system-1",
|
||||
timePeriod: "1h",
|
||||
includeDocker: false,
|
||||
includeDocker: true,
|
||||
},
|
||||
};
|
||||
const scope = createScope({ integrationIds: ["beszel-1"], runtimeQueries: [activeQuery] });
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -2510,10 +2510,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": {
|
||||
@@ -2813,8 +2824,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",
|
||||
@@ -2822,6 +2845,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",
|
||||
@@ -3106,7 +3132,8 @@
|
||||
"users": "Users",
|
||||
"photos": "Photos",
|
||||
"videos": "Videos",
|
||||
"storage": "Used Storage"
|
||||
"storage": "Used Storage",
|
||||
"albumLimit": "Largest albums (up to {count} shown)"
|
||||
},
|
||||
"immich-albumCarousel": {
|
||||
"name": "Immich Album",
|
||||
@@ -4094,11 +4121,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",
|
||||
@@ -5504,6 +5539,7 @@
|
||||
},
|
||||
"remove": {
|
||||
"label": "Remove",
|
||||
"confirm": "Click again to remove permanently",
|
||||
"notification": {
|
||||
"success": {
|
||||
"title": "Containers removed",
|
||||
|
||||
@@ -73,6 +73,7 @@
|
||||
"@tiptap/extension-underline": "catalog:",
|
||||
"@tiptap/react": "catalog:",
|
||||
"@tiptap/starter-kit": "catalog:",
|
||||
"@trpc/react-query": "catalog:",
|
||||
"clsx": "catalog:",
|
||||
"dayjs": "catalog:",
|
||||
"jsonpath-plus": "catalog:",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { Badge, Group, ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core";
|
||||
import { getQueryKey } from "@trpc/react-query";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { formatBytes, formatDuration } from "@homarr/common";
|
||||
@@ -27,8 +28,8 @@ export default function AudioStatsWidget({
|
||||
enabled: streamsEnabled,
|
||||
});
|
||||
setWidgetRuntimeQueries(widgetStateRef, [
|
||||
{ path: ["widget", "audioStats", "getStats"], input: statsInput },
|
||||
...(streamsEnabled ? [{ path: ["widget", "mediaServer", "getCurrentStreams"], input: streamsInput }] : []),
|
||||
getQueryKey(clientApi.widget.audioStats.getStats, statsInput, "query"),
|
||||
...(streamsEnabled ? [getQueryKey(clientApi.widget.mediaServer.getCurrentStreams, streamsInput, "query")] : []),
|
||||
]);
|
||||
|
||||
if (!response) return <WidgetEmptyState />;
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { Box, Button, Center, Loader, Menu, ScrollArea, SegmentedControl, Select, Stack, Text } from "@mantine/core";
|
||||
import { IconQuestionMark, IconServer, IconServerOff } from "@tabler/icons-react";
|
||||
import { getQueryKey } from "@trpc/react-query";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
@@ -54,15 +55,16 @@ export default function BeszelSystemStatsWidget({
|
||||
widgetStateRef,
|
||||
selectedSystem && options.timePeriod !== "1m"
|
||||
? [
|
||||
{
|
||||
path: ["widget", "beszel", "getSystemStats"],
|
||||
input: {
|
||||
getQueryKey(
|
||||
clientApi.widget.beszel.getSystemStats,
|
||||
{
|
||||
integrationIds: [selectedSystem.integrationId],
|
||||
systemId: selectedSystem.systemId,
|
||||
timePeriod: options.timePeriod,
|
||||
timePeriod: options.timePeriod as BeszelTimePeriod,
|
||||
includeDocker,
|
||||
},
|
||||
},
|
||||
"query",
|
||||
),
|
||||
]
|
||||
: [],
|
||||
);
|
||||
|
||||
@@ -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,9 @@
|
||||
"use client";
|
||||
|
||||
import "./styles.css";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Center, Group, Indicator, Loader, Progress, ScrollArea, Stack, Text } from "@mantine/core";
|
||||
import type { DataTableColumn, DataTableSortStatus } from "mantine-datatable";
|
||||
import { DataTable } from "mantine-datatable";
|
||||
import { getQueryKey } from "@trpc/react-query";
|
||||
import {
|
||||
Activity,
|
||||
Battery,
|
||||
@@ -20,11 +18,17 @@ 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 { setWidgetRuntimeQueries } 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 {
|
||||
@@ -45,6 +49,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";
|
||||
@@ -79,15 +98,21 @@ export default function BeszelSystemTableWidget({
|
||||
width,
|
||||
displayMode = "compact",
|
||||
widgetStateRef,
|
||||
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 [selectedSystem, setSelectedSystem] = useState<SystemRowWithKey | null>(null);
|
||||
|
||||
const visibleMetricKeys = useMemo(() => {
|
||||
@@ -117,12 +142,33 @@ export default function BeszelSystemTableWidget({
|
||||
return new Set(enabled.slice(0, budget));
|
||||
}, [displayMode, options, 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);
|
||||
|
||||
const [sortStatus, setSortStatus] = useState<DataTableSortStatus<SystemRowWithKey>>({
|
||||
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;
|
||||
@@ -154,6 +200,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} />
|
||||
@@ -162,7 +210,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}
|
||||
@@ -172,6 +220,7 @@ export default function BeszelSystemTableWidget({
|
||||
},
|
||||
visibleMetricKeys.has("showCpu") && {
|
||||
accessor: "cpu",
|
||||
width: 140,
|
||||
title: (
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<Cpu size={size.iconSize} />
|
||||
@@ -183,6 +232,7 @@ export default function BeszelSystemTableWidget({
|
||||
},
|
||||
visibleMetricKeys.has("showMemory") && {
|
||||
accessor: "memory",
|
||||
width: 140,
|
||||
title: (
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<MemoryStick size={size.iconSize} />
|
||||
@@ -194,6 +244,7 @@ export default function BeszelSystemTableWidget({
|
||||
},
|
||||
visibleMetricKeys.has("showDisk") && {
|
||||
accessor: "disk",
|
||||
width: 160,
|
||||
title: (
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<HardDrive size={size.iconSize} />
|
||||
@@ -213,6 +264,7 @@ export default function BeszelSystemTableWidget({
|
||||
},
|
||||
visibleMetricKeys.has("showGpu") && {
|
||||
accessor: "gpu",
|
||||
width: 140,
|
||||
title: (
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<Monitor size={size.iconSize} />
|
||||
@@ -224,6 +276,7 @@ export default function BeszelSystemTableWidget({
|
||||
},
|
||||
visibleMetricKeys.has("showLoadAvg") && {
|
||||
accessor: "loadAvg",
|
||||
width: 100,
|
||||
title: (
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<Activity size={size.iconSize} />
|
||||
@@ -240,6 +293,7 @@ export default function BeszelSystemTableWidget({
|
||||
},
|
||||
visibleMetricKeys.has("showNet") && {
|
||||
accessor: "netBytes",
|
||||
width: 100,
|
||||
title: (
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<Network size={size.iconSize} />
|
||||
@@ -255,6 +309,7 @@ export default function BeszelSystemTableWidget({
|
||||
},
|
||||
visibleMetricKeys.has("showTemp") && {
|
||||
accessor: "temp",
|
||||
width: 80,
|
||||
title: (
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<Thermometer size={size.iconSize} />
|
||||
@@ -266,6 +321,7 @@ export default function BeszelSystemTableWidget({
|
||||
},
|
||||
visibleMetricKeys.has("showBattery") && {
|
||||
accessor: "battery",
|
||||
width: 70,
|
||||
title: (
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<Battery size={size.iconSize} />
|
||||
@@ -276,6 +332,7 @@ export default function BeszelSystemTableWidget({
|
||||
},
|
||||
visibleMetricKeys.has("showServices") && {
|
||||
accessor: "services",
|
||||
width: 80,
|
||||
title: (
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<Server size={size.iconSize} />
|
||||
@@ -287,6 +344,7 @@ export default function BeszelSystemTableWidget({
|
||||
},
|
||||
visibleMetricKeys.has("showUptime") && {
|
||||
accessor: "uptime",
|
||||
width: 90,
|
||||
title: (
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<Activity size={size.iconSize} />
|
||||
@@ -298,6 +356,7 @@ export default function BeszelSystemTableWidget({
|
||||
},
|
||||
visibleMetricKeys.has("showAgent") && {
|
||||
accessor: "agentVersion",
|
||||
width: 90,
|
||||
title: (
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<Wifi size={size.iconSize} />
|
||||
@@ -312,14 +371,27 @@ export default function BeszelSystemTableWidget({
|
||||
return cols.filter(Boolean) as DataTableColumn<SystemRowWithKey>[];
|
||||
}, [t, size, visibleMetricKeys]);
|
||||
|
||||
const handleRowClick = ({ record }: { record: SystemRowWithKey }) => {
|
||||
if (displayMode === "advanced") {
|
||||
setSelectedSystem(record);
|
||||
return;
|
||||
}
|
||||
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 }) => {
|
||||
if (displayMode === "advanced") {
|
||||
setSelectedSystem(record);
|
||||
return;
|
||||
}
|
||||
const integrationId = record._key.split(":")[0] ?? "";
|
||||
openModal({ integrationId, systemId: record.id }, { title: record.name });
|
||||
},
|
||||
[displayMode, openModal],
|
||||
);
|
||||
|
||||
const activeSystem = selectedSystem ?? sortedSystems[0];
|
||||
const activeIntegrationId = activeSystem?._key.split(":")[0] ?? "";
|
||||
@@ -327,15 +399,16 @@ export default function BeszelSystemTableWidget({
|
||||
widgetStateRef,
|
||||
displayMode === "advanced" && activeSystem
|
||||
? [
|
||||
{
|
||||
path: ["widget", "beszel", "getSystemStats"],
|
||||
input: {
|
||||
getQueryKey(
|
||||
clientApi.widget.beszel.getSystemStats,
|
||||
{
|
||||
integrationIds: [activeIntegrationId],
|
||||
systemId: activeSystem.id,
|
||||
timePeriod: "1h",
|
||||
includeDocker: false,
|
||||
includeDocker: true,
|
||||
},
|
||||
},
|
||||
"query",
|
||||
),
|
||||
]
|
||||
: [],
|
||||
);
|
||||
@@ -355,20 +428,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>
|
||||
@@ -402,9 +472,9 @@ export default function BeszelSystemTableWidget({
|
||||
disk: options.showDisk,
|
||||
diskIO: options.showDisk,
|
||||
network: options.showNet,
|
||||
dockerCpu: false,
|
||||
dockerMemory: false,
|
||||
dockerNetwork: false,
|
||||
dockerCpu: true,
|
||||
dockerMemory: true,
|
||||
dockerNetwork: true,
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
@@ -33,28 +33,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;
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { useParams } from "next/navigation";
|
||||
import { Box, Stack, Text, useMantineTheme } from "@mantine/core";
|
||||
import { Calendar } from "@mantine/dates";
|
||||
import { useElementSize } from "@mantine/hooks";
|
||||
import { getQueryKey } from "@trpc/react-query";
|
||||
import dayjs from "dayjs";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
@@ -51,7 +52,7 @@ const FetchCalendar = ({
|
||||
releaseType: options.releaseType,
|
||||
showUnmonitored: options.showUnmonitored,
|
||||
};
|
||||
setWidgetRuntimeQueries(widgetStateRef, [{ path: ["widget", "calendar", "findAllEvents"], input }]);
|
||||
setWidgetRuntimeQueries(widgetStateRef, [getQueryKey(clientApi.widget.calendar.findAllEvents, input, "query")]);
|
||||
const { data } = clientApi.widget.calendar.findAllEvents.useQuery(input);
|
||||
|
||||
const events = useMemo(() => data?.flatMap((item) => item.events) ?? [], [data]);
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Accordion, Anchor, Badge, Card, Group, Image, Text } from "@mantine/core";
|
||||
import { useLocalStorage } from "@mantine/hooks";
|
||||
|
||||
@@ -13,6 +12,7 @@ import { ServersSection } from "./servers-section";
|
||||
import { ServicesSection } from "./services-section";
|
||||
import type { CoolifyOptions, InstanceData } from "./types";
|
||||
import { COOLIFY_ICON_URL } from "./types";
|
||||
import { useAdvancedOpenSections } from "./use-advanced-open-sections";
|
||||
|
||||
interface InstanceCardProps {
|
||||
instance: InstanceData;
|
||||
@@ -33,27 +33,7 @@ export function InstanceCard({ instance, options, isTiny, widgetKey, isAdvanced
|
||||
key: `coolify-sections-${cardKey}`,
|
||||
defaultValue: ["applications"],
|
||||
});
|
||||
const visibleSections = useMemo(
|
||||
() =>
|
||||
[
|
||||
options.showServers ? "servers" : null,
|
||||
options.showApplications ? "applications" : null,
|
||||
options.showServices ? "services" : null,
|
||||
].filter((section): section is string => section !== null),
|
||||
[options.showApplications, options.showServers, options.showServices],
|
||||
);
|
||||
const [advancedOpenSections, setAdvancedOpenSections] = useState(visibleSections);
|
||||
const previousVisibleSectionsRef = useRef(visibleSections);
|
||||
|
||||
useEffect(() => {
|
||||
const newlyVisibleSections = visibleSections.filter(
|
||||
(section) => !previousVisibleSectionsRef.current.includes(section),
|
||||
);
|
||||
previousVisibleSectionsRef.current = visibleSections;
|
||||
if (newlyVisibleSections.length === 0) return;
|
||||
|
||||
setAdvancedOpenSections((current) => [...new Set([...current, ...newlyVisibleSections])]);
|
||||
}, [visibleSections]);
|
||||
const [advancedOpenSections, setAdvancedOpenSections] = useAdvancedOpenSections(options);
|
||||
|
||||
const serverResourceCounts = buildServerResourceCounts(
|
||||
instance.instanceInfo.servers,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Accordion, Anchor, Group, Image, ScrollArea, Stack, Text } from "@mantine/core";
|
||||
import { useLocalStorage } from "@mantine/hooks";
|
||||
|
||||
@@ -13,6 +12,7 @@ import { ServersSection } from "./servers-section";
|
||||
import { ServicesSection } from "./services-section";
|
||||
import type { CoolifyOptions, InstanceData } from "./types";
|
||||
import { COOLIFY_BRAND_COLOR, COOLIFY_ICON_URL } from "./types";
|
||||
import { useAdvancedOpenSections } from "./use-advanced-open-sections";
|
||||
|
||||
interface SingleInstanceLayoutProps {
|
||||
instance: InstanceData;
|
||||
@@ -32,27 +32,7 @@ export function SingleInstanceLayout({ instance, options, isTiny, widgetKey, isA
|
||||
key: `coolify-sections-${widgetKey}`,
|
||||
defaultValue: ["applications"],
|
||||
});
|
||||
const visibleSections = useMemo(
|
||||
() =>
|
||||
[
|
||||
options.showServers ? "servers" : null,
|
||||
options.showApplications ? "applications" : null,
|
||||
options.showServices ? "services" : null,
|
||||
].filter((section): section is string => section !== null),
|
||||
[options.showApplications, options.showServers, options.showServices],
|
||||
);
|
||||
const [advancedOpenSections, setAdvancedOpenSections] = useState(visibleSections);
|
||||
const previousVisibleSectionsRef = useRef(visibleSections);
|
||||
|
||||
useEffect(() => {
|
||||
const newlyVisibleSections = visibleSections.filter(
|
||||
(section) => !previousVisibleSectionsRef.current.includes(section),
|
||||
);
|
||||
previousVisibleSectionsRef.current = visibleSections;
|
||||
if (newlyVisibleSections.length === 0) return;
|
||||
|
||||
setAdvancedOpenSections((current) => [...new Set([...current, ...newlyVisibleSections])]);
|
||||
}, [visibleSections]);
|
||||
const [advancedOpenSections, setAdvancedOpenSections] = useAdvancedOpenSections(options);
|
||||
|
||||
const serverResourceCounts = buildServerResourceCounts(
|
||||
instance.instanceInfo.servers,
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import type { CoolifyOptions } from "./types";
|
||||
|
||||
export function useAdvancedOpenSections(options: CoolifyOptions) {
|
||||
const visibleSections = useMemo(
|
||||
() =>
|
||||
[
|
||||
options.showServers ? "servers" : null,
|
||||
options.showApplications ? "applications" : null,
|
||||
options.showServices ? "services" : null,
|
||||
].filter((section): section is string => section !== null),
|
||||
[options.showApplications, options.showServers, options.showServices],
|
||||
);
|
||||
const [openSections, setOpenSections] = useState(visibleSections);
|
||||
const previousVisibleSectionsRef = useRef(visibleSections);
|
||||
|
||||
useEffect(() => {
|
||||
const newlyVisibleSections = visibleSections.filter(
|
||||
(section) => !previousVisibleSectionsRef.current.includes(section),
|
||||
);
|
||||
previousVisibleSectionsRef.current = visibleSections;
|
||||
if (newlyVisibleSections.length === 0) return;
|
||||
|
||||
setOpenSections((current) => [...new Set([...current, ...newlyVisibleSections])]);
|
||||
}, [visibleSections]);
|
||||
|
||||
return [openSections, setOpenSections] as const;
|
||||
}
|
||||
@@ -1,4 +1,7 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { getQueryKey } from "@trpc/react-query";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
|
||||
import { getWidgetQueryKeys, getWidgetRuntimeQueries, setWidgetRuntimeQueries } from "./definition";
|
||||
|
||||
@@ -24,11 +27,12 @@ describe("getWidgetQueryKeys", () => {
|
||||
|
||||
test("registers runtime queries without replacing imperative widget actions", () => {
|
||||
const widgetStateRef = { current: { togglePolling } };
|
||||
const runtimeQueries = [{ path: ["widget", "calendar", "findAllEvents"], input: { month: 7 } }];
|
||||
const input = { integrationIds: [], month: 7, year: 2026, releaseType: [], showUnmonitored: false };
|
||||
const queryKey = getQueryKey(clientApi.widget.calendar.findAllEvents, input, "query");
|
||||
|
||||
setWidgetRuntimeQueries(widgetStateRef, runtimeQueries);
|
||||
setWidgetRuntimeQueries(widgetStateRef, [queryKey]);
|
||||
|
||||
expect(widgetStateRef.current.togglePolling).toBe(togglePolling);
|
||||
expect(getWidgetRuntimeQueries(widgetStateRef)).toEqual(runtimeQueries);
|
||||
expect(getWidgetRuntimeQueries(widgetStateRef)).toEqual([{ path: ["widget", "calendar", "findAllEvents"], input }]);
|
||||
});
|
||||
});
|
||||
@@ -122,11 +122,18 @@ const runtimeQueriesStateKey = "__homarrRuntimeQueries";
|
||||
|
||||
export const setWidgetRuntimeQueries = (
|
||||
widgetStateRef: React.MutableRefObject<Record<string, unknown> | null> | undefined,
|
||||
queries: readonly NormalizedWidgetQuery[],
|
||||
queryKeys: readonly QueryKey[],
|
||||
) => {
|
||||
if (!widgetStateRef) return;
|
||||
const state = widgetStateRef.current ?? {};
|
||||
state[runtimeQueriesStateKey] = queries;
|
||||
state[runtimeQueriesStateKey] = queryKeys.flatMap((queryKey) => {
|
||||
const path = queryKey[0];
|
||||
if (!Array.isArray(path) || !path.every((part): part is string => typeof part === "string")) return [];
|
||||
|
||||
const queryKeyOptions = queryKey[1];
|
||||
const input = isRecord(queryKeyOptions) && "input" in queryKeyOptions ? queryKeyOptions.input : undefined;
|
||||
return [{ path, input }];
|
||||
});
|
||||
widgetStateRef.current = state;
|
||||
};
|
||||
|
||||
|
||||
@@ -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,24 +1,59 @@
|
||||
"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";
|
||||
import { getDockerColumnVisibility } from "./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");
|
||||
|
||||
@@ -29,173 +64,142 @@ 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">>,
|
||||
isEditMode: boolean,
|
||||
): 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%"
|
||||
disabled={isEditMode}
|
||||
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%"
|
||||
disabled={isEditMode}
|
||||
onClick={() => handleActionAsync("restart")}
|
||||
>
|
||||
<IconRotateClockwise style={actionIconIconStyle} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
);
|
||||
},
|
||||
accessor: "actions",
|
||||
title: "",
|
||||
width: 44,
|
||||
textAlign: "right",
|
||||
render: (container) => <ContainerMenuButton container={container} handlers={handlers} />,
|
||||
},
|
||||
];
|
||||
|
||||
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,
|
||||
displayMode,
|
||||
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 isAdvanced = displayMode === "advanced";
|
||||
const isTiny = !isAdvanced && width <= 256;
|
||||
|
||||
@@ -204,84 +208,137 @@ export default function DockerWidget({
|
||||
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, isEditMode), [isEditMode, t]);
|
||||
|
||||
const columnVisibility: MRT_VisibilityState = getDockerColumnVisibility(options.columns, width, isAdvanced);
|
||||
|
||||
const table = useTranslatedMantineReactTable({
|
||||
columns,
|
||||
data: containers,
|
||||
enablePagination: false,
|
||||
enableTopToolbar: isAdvanced,
|
||||
enableBottomToolbar: false,
|
||||
enableColumnActions: isAdvanced,
|
||||
enableSorting: (isAdvanced || options.enableRowSorting) && !isEditMode,
|
||||
enableStickyHeader: isAdvanced,
|
||||
enableColumnOrdering: isAdvanced && !isEditMode,
|
||||
enableRowSelection: false,
|
||||
enableFullScreenToggle: false,
|
||||
enableGlobalFilter: isAdvanced,
|
||||
enableDensityToggle: false,
|
||||
enableFilters: false,
|
||||
enableHiding: isAdvanced,
|
||||
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%",
|
||||
pointerEvents: isEditMode ? "none" : undefined,
|
||||
},
|
||||
},
|
||||
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 (!isAdvanced && 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 columnVisibility = useMemo(
|
||||
() => getDockerColumnVisibility(options.columns, width, isAdvanced),
|
||||
[isAdvanced, options.columns, width],
|
||||
);
|
||||
const columns = useMemo(() => {
|
||||
const sortingEnabled = (isAdvanced || options.enableRowSorting) && !isEditMode;
|
||||
return createColumns(t, actionHandlers, sortingEnabled).filter(
|
||||
({ accessor }) => columnVisibility[String(accessor) as keyof typeof columnVisibility],
|
||||
);
|
||||
}, [actionHandlers, columnVisibility, isAdvanced, isEditMode, options.enableRowSorting, 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 (!isAdvanced && 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={(isAdvanced || options.enableRowSorting) && !isEditMode ? setSortStatus : undefined}
|
||||
idAccessor="id"
|
||||
onRowContextMenu={isEditMode ? undefined : handleContextMenu}
|
||||
onScroll={() => {
|
||||
if (contextMenu) closeContextMenu();
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{(isAdvanced || !isTiny) && (
|
||||
<Group
|
||||
@@ -290,23 +347,22 @@ export default function DockerWidget({
|
||||
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"
|
||||
@@ -316,12 +372,161 @@ export default function DockerWidget({
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
@@ -41,7 +41,6 @@ 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, useSession } from "@homarr/auth/client";
|
||||
@@ -54,12 +53,12 @@ import { showErrorNotification } from "@homarr/notifications";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { columnWidthsToRecord, filterDownloadItemsByStatus, getAvailableDownloadStates } from "./helpers";
|
||||
import { HomarrDataTable } from "../common/homarr-data-table";
|
||||
import { usePersistedTableLayout, useTableLayoutPersistence } from "../common/use-persisted-table-layout";
|
||||
import { filterDownloadItemsByStatus, getAvailableDownloadStates } from "./helpers";
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
const COLUMN_WIDTH_SAVE_DELAY_MS = 200;
|
||||
|
||||
type DownloadsT = ReturnType<typeof useScopedI18n<"widget.downloads">>;
|
||||
type DownloadState = ExtendedDownloadClientItem["state"];
|
||||
|
||||
@@ -170,14 +169,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;
|
||||
@@ -338,25 +346,13 @@ export default function DownloadClientsWidget({
|
||||
scope: { id: `downloads-options:${boardId ?? "preview"}:${itemId ?? "preview"}` },
|
||||
onError: () => showErrorNotification({ title: t("errors.actionFailed"), message: t("errors.actionFailedMessage") }),
|
||||
});
|
||||
const saveOptionToServer = useCallback(
|
||||
(newOptions: Partial<Record<string, string>>) => {
|
||||
if (hasChangeAccess && boardId && itemId) saveItemOptions({ boardId, itemId, newOptions });
|
||||
},
|
||||
[boardId, hasChangeAccess, itemId, saveItemOptions],
|
||||
);
|
||||
const persistOption = useCallback(
|
||||
(newOptions: Partial<Record<string, string>>) => {
|
||||
setOptions({ newOptions });
|
||||
saveOptionToServer(newOptions);
|
||||
},
|
||||
[saveOptionToServer, setOptions],
|
||||
);
|
||||
|
||||
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";
|
||||
@@ -682,72 +678,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, isResizing, 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);
|
||||
useEffect(() => {
|
||||
if (!hydrated.current) return;
|
||||
const orderChanged = JSON.stringify(columnsOrder) !== JSON.stringify(prevOrder.current);
|
||||
prevOrder.current = columnsOrder;
|
||||
if (orderChanged) persistOption({ columnOrder: JSON.stringify(columnsOrder) });
|
||||
}, [columnsOrder, persistOption]);
|
||||
|
||||
const prevWidths = useRef(columnsWidth);
|
||||
const pendingColumnWidths = useRef<string | null>(null);
|
||||
const columnWidthSaveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const saveOptionToServerRef = useRef(saveOptionToServer);
|
||||
useEffect(() => {
|
||||
saveOptionToServerRef.current = saveOptionToServer;
|
||||
}, [saveOptionToServer]);
|
||||
useEffect(() => {
|
||||
if (!hydrated.current || isResizing) return;
|
||||
const widthsChanged = JSON.stringify(columnsWidth) !== JSON.stringify(prevWidths.current);
|
||||
prevWidths.current = columnsWidth;
|
||||
if (!widthsChanged) return;
|
||||
|
||||
const serializedWidths = JSON.stringify(columnWidthsToRecord(columnsWidth));
|
||||
setOptions({ newOptions: { columnWidths: serializedWidths } });
|
||||
pendingColumnWidths.current = serializedWidths;
|
||||
if (columnWidthSaveTimer.current !== null) clearTimeout(columnWidthSaveTimer.current);
|
||||
columnWidthSaveTimer.current = setTimeout(() => {
|
||||
const value = pendingColumnWidths.current;
|
||||
pendingColumnWidths.current = null;
|
||||
columnWidthSaveTimer.current = null;
|
||||
if (value !== null) saveOptionToServerRef.current({ columnWidths: value });
|
||||
}, COLUMN_WIDTH_SAVE_DELAY_MS);
|
||||
}, [columnsWidth, isResizing, setOptions]);
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (columnWidthSaveTimer.current !== null) clearTimeout(columnWidthSaveTimer.current);
|
||||
const value = pendingColumnWidths.current;
|
||||
pendingColumnWidths.current = null;
|
||||
if (value !== null) saveOptionToServerRef.current({ columnWidths: value });
|
||||
},
|
||||
[],
|
||||
);
|
||||
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 }) => {
|
||||
@@ -802,9 +741,6 @@ export default function DownloadClientsWidget({
|
||||
);
|
||||
}
|
||||
|
||||
let tablePointerEvents: React.CSSProperties["pointerEvents"];
|
||||
if (isEditMode) tablePointerEvents = "none";
|
||||
|
||||
let rowContextMenuHandler: typeof handleContextMenu | undefined = handleContextMenu;
|
||||
if (isEditMode) rowContextMenuHandler = undefined;
|
||||
|
||||
@@ -822,35 +758,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,6 +1,6 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { columnWidthsToRecord, filterDownloadItemsByStatus, getAvailableDownloadStates } from "./helpers";
|
||||
import { filterDownloadItemsByStatus, getAvailableDownloadStates } from "./helpers";
|
||||
|
||||
describe("downloads filters", () => {
|
||||
const eligibleItems = [{ state: "downloading" as const }, { state: "seeding" as const }];
|
||||
@@ -17,12 +17,3 @@ describe("downloads filters", () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("downloads column widths", () => {
|
||||
test("serializes numeric and pixel widths while ignoring automatic widths", () => {
|
||||
expect(columnWidthsToRecord([{ name: 240 }, { progress: "120px" }, { state: "auto" }])).toEqual({
|
||||
name: 240,
|
||||
progress: 120,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -13,15 +13,3 @@ export function filterDownloadItemsByStatus<T extends { state: DownloadState }>(
|
||||
export function getAvailableDownloadStates(items: readonly { state: DownloadState }[]): DownloadState[] {
|
||||
return [...new Set(items.map(({ state }) => state))];
|
||||
}
|
||||
|
||||
export function columnWidthsToRecord(columnsWidth: readonly Record<string, string | number>[]): Record<string, number> {
|
||||
const result: Record<string, number> = {};
|
||||
for (const entry of columnsWidth) {
|
||||
const key = Object.keys(entry)[0];
|
||||
if (!key) continue;
|
||||
const width = entry[key];
|
||||
if (typeof width === "number") result[key] = width;
|
||||
else if (typeof width === "string" && width.endsWith("px")) result[key] = parseInt(width, 10);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -37,7 +37,7 @@ interface FirewallQueryState {
|
||||
isPending: boolean;
|
||||
isFetching: boolean;
|
||||
isError: boolean;
|
||||
data?: readonly { error?: string }[];
|
||||
data?: readonly { error?: string; integration?: { id: string } }[];
|
||||
}
|
||||
|
||||
export const hasTotalFirewallFailure = (queries: readonly FirewallQueryState[]) =>
|
||||
@@ -47,6 +47,13 @@ export const hasTotalFirewallFailure = (queries: readonly FirewallQueryState[])
|
||||
query.isError || (query.data !== undefined && query.data.length > 0 && query.data.every(({ error }) => error)),
|
||||
);
|
||||
|
||||
export const hasFirewallPartialFailure = (firewallId: string, queries: readonly FirewallQueryState[]) =>
|
||||
queries.some(
|
||||
(query) =>
|
||||
query.isError ||
|
||||
query.data?.some(({ error, integration }) => integration?.id === firewallId && Boolean(error)) === true,
|
||||
);
|
||||
|
||||
export default function FirewallWidget({
|
||||
integrationIds,
|
||||
width,
|
||||
@@ -97,6 +104,7 @@ export default function FirewallWidget({
|
||||
const initialSelectedFirewall = firewallIds[0] ?? "";
|
||||
const activeFirewall = firewallMetadata.has(selectedFirewall) ? selectedFirewall : initialSelectedFirewall;
|
||||
const displayedFirewallIds = isAdvanced ? firewallIds : activeFirewall ? [activeFirewall] : [];
|
||||
const firewallHasError = (firewallId: string) => hasFirewallPartialFailure(firewallId, queries);
|
||||
const dropdownItems = firewallIds.map((firewallId) => ({
|
||||
label: firewallMetadata.get(firewallId)?.name ?? firewallId,
|
||||
value: firewallId,
|
||||
@@ -113,11 +121,20 @@ export default function FirewallWidget({
|
||||
dropdownItems={dropdownItems}
|
||||
isTiny={isTiny}
|
||||
/>
|
||||
<FirewallVersion
|
||||
firewallsVersionData={firewallsVersionData.filter(({ error }) => !error)}
|
||||
selectedFirewall={activeFirewall}
|
||||
isTiny={isTiny}
|
||||
/>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
{activeFirewall && firewallHasError(activeFirewall) && (
|
||||
<Tooltip label={t("widget.firewall.error.internalServerError")}>
|
||||
<Badge color="red" variant="light" size="xs">
|
||||
{t("common.error")}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
<FirewallVersion
|
||||
firewallsVersionData={firewallsVersionData.filter(({ error }) => !error)}
|
||||
selectedFirewall={activeFirewall}
|
||||
isTiny={isTiny}
|
||||
/>
|
||||
</Group>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
@@ -127,9 +144,7 @@ export default function FirewallWidget({
|
||||
const memory = firewallsMemoryData.find(({ integration }) => integration.id === firewallId);
|
||||
const version = firewallsVersionData.find(({ integration }) => integration.id === firewallId);
|
||||
const interfaces = firewallsInterfacesData.find(({ integration }) => integration.id === firewallId);
|
||||
const hasError = Boolean(
|
||||
cpu?.error || memory?.error || version?.error || interfacesQuery.isError || interfaces?.error,
|
||||
);
|
||||
const hasError = firewallHasError(firewallId);
|
||||
const metadata = firewallMetadata.get(firewallId);
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { hasTotalFirewallFailure } from "./component";
|
||||
import { hasFirewallPartialFailure, hasTotalFirewallFailure } from "./component";
|
||||
|
||||
const failedQuery = {
|
||||
isPending: false,
|
||||
@@ -27,4 +27,21 @@ describe("firewall failure state", () => {
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("surfaces query-wide and integration-specific partial failures", () => {
|
||||
const successfulQuery = {
|
||||
isPending: false,
|
||||
isFetching: false,
|
||||
isError: false,
|
||||
data: [{ integration: { id: "firewall-1" }, error: undefined }],
|
||||
};
|
||||
|
||||
expect(hasFirewallPartialFailure("firewall-1", [successfulQuery])).toBe(false);
|
||||
expect(hasFirewallPartialFailure("firewall-1", [{ ...successfulQuery, isError: true }])).toBe(true);
|
||||
expect(
|
||||
hasFirewallPartialFailure("firewall-1", [
|
||||
{ ...successfulQuery, data: [{ integration: { id: "firewall-1" }, error: "offline" }] },
|
||||
]),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -8,12 +8,19 @@ const mocks = vi.hoisted(() => ({
|
||||
getAlbums: vi.fn(() => ({ data: [], isPending: false, isError: false })),
|
||||
}));
|
||||
|
||||
vi.mock("@trpc/react-query", () => ({
|
||||
getQueryKey: (procedure: { path: string[] }, input: unknown) => [procedure.path, { input, type: "query" }],
|
||||
}));
|
||||
|
||||
vi.mock("@homarr/api/client", () => ({
|
||||
clientApi: {
|
||||
widget: {
|
||||
immich: {
|
||||
getAlbums: { useQuery: mocks.getAlbums },
|
||||
getServerStats: { useQuery: () => ({ data: undefined }) },
|
||||
getAlbums: { path: ["widget", "immich", "getAlbums"], useQuery: mocks.getAlbums },
|
||||
getServerStats: {
|
||||
path: ["widget", "immich", "getServerStats"],
|
||||
useQuery: () => ({ data: undefined }),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import React from "react";
|
||||
import { Group, Progress, ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core";
|
||||
import { IconDatabase, IconPhoto, IconUsers, IconVideo } from "@tabler/icons-react";
|
||||
import { getQueryKey } from "@trpc/react-query";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { formatBytes } from "@homarr/common";
|
||||
@@ -33,8 +34,8 @@ export default function ImmichServerStatsWidget({
|
||||
staleTime: 15 * 60 * 1000,
|
||||
});
|
||||
setWidgetRuntimeQueries(widgetStateRef, [
|
||||
{ path: ["widget", "immich", "getServerStats"], input },
|
||||
...(albumsEnabled ? [{ path: ["widget", "immich", "getAlbums"], input: albumsInput }] : []),
|
||||
getQueryKey(clientApi.widget.immich.getServerStats, input, "query"),
|
||||
...(albumsEnabled ? [getQueryKey(clientApi.widget.immich.getAlbums, albumsInput, "query")] : []),
|
||||
]);
|
||||
|
||||
if (!stats) return <WidgetEmptyState />;
|
||||
@@ -81,6 +82,9 @@ export default function ImmichServerStatsWidget({
|
||||
return (
|
||||
<Stack gap="lg" h="100%" p="lg">
|
||||
{statsContent}
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("widget.immich-serverStats.albumLimit", { count: MAX_ADVANCED_ALBUMS })}
|
||||
</Text>
|
||||
<ScrollArea style={{ flex: 1, minHeight: 0 }}>
|
||||
<SimpleGrid cols={width >= 900 ? 2 : 1} spacing="xs">
|
||||
{sortedAlbums.map((album) => (
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { IconDownload, IconMovie, IconQuestionMark, IconVideo } from "@tabler/icons-react";
|
||||
import { getQueryKey } from "@trpc/react-query";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import type { MissingMediaItem, QueuedMediaItem } from "@homarr/integrations/types";
|
||||
@@ -44,7 +45,7 @@ export default function MediaMissingWidget({
|
||||
const isAdvanced = displayMode === "advanced";
|
||||
const pageSize = isAdvanced ? Math.max(Number(options.pageSize), 50) : Number(options.pageSize);
|
||||
const input = { integrationIds, pageSize };
|
||||
setWidgetRuntimeQueries(widgetStateRef, [{ path: ["widget", "mediaOrganizer", "getData"], input }]);
|
||||
setWidgetRuntimeQueries(widgetStateRef, [getQueryKey(clientApi.widget.mediaOrganizer.getData, input, "query")]);
|
||||
const { data } = clientApi.widget.mediaOrganizer.getData.useQuery(input, {
|
||||
staleTime: 60 * 1000,
|
||||
refetchOnWindowFocus: false,
|
||||
|
||||
@@ -26,6 +26,33 @@ 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,
|
||||
@@ -138,23 +165,30 @@ export default function MediaServerWidget({
|
||||
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 location = options.showLocation ? currentlyPlaying.location : null;
|
||||
const bitrateLabel = options.showBitrate ? formatBitrate(currentlyPlaying.metadata?.bitrateKbps) : null;
|
||||
|
||||
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>
|
||||
{(location ?? bitrateLabel) && (
|
||||
<Group gap={4} align="center" justify="space-between" wrap="nowrap" w="100%">
|
||||
<Group gap={4} align="center">
|
||||
{location && (location === "lan" ? <IconWifi size={12} /> : <IconWorld size={12} />)}
|
||||
{location && (
|
||||
<Text size="10px" c="dimmed" tt="uppercase">
|
||||
{location}
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
{bitrateLabel && (
|
||||
<Text size="10px" c="dimmed">
|
||||
{bitrateLabel}
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
@@ -165,7 +199,7 @@ export default function MediaServerWidget({
|
||||
: []),
|
||||
];
|
||||
return result;
|
||||
}, [isAdvanced, t, width]);
|
||||
}, [isAdvanced, options.showBitrate, options.showLocation, t, width]);
|
||||
|
||||
// Only render the flat list of sessions when the currentStreams change
|
||||
// Otherwise it will always create a new array reference and cause the table to re-render
|
||||
@@ -231,16 +265,18 @@ export default function MediaServerWidget({
|
||||
},
|
||||
},
|
||||
mantineTableBodyCellProps: ({ row }) => ({
|
||||
onClick: () => {
|
||||
openModal(
|
||||
{
|
||||
item: row.original,
|
||||
onClick: isEditMode
|
||||
? undefined
|
||||
: () => {
|
||||
openModal(
|
||||
{
|
||||
item: row.original,
|
||||
},
|
||||
{
|
||||
title: row.original.sessionName,
|
||||
},
|
||||
);
|
||||
},
|
||||
{
|
||||
title: row.original.sessionName,
|
||||
},
|
||||
);
|
||||
},
|
||||
py: 4,
|
||||
style: {
|
||||
overflowX: "hidden",
|
||||
@@ -259,13 +295,16 @@ export default function MediaServerWidget({
|
||||
const playingCount = flatSessions.filter(
|
||||
(session) => session.currentlyPlaying && session.currentlyPlaying.playback?.state !== "paused",
|
||||
).length;
|
||||
const transcodingCount = flatSessions.filter((session) =>
|
||||
Boolean(
|
||||
session.currentlyPlaying?.metadata?.transcoding.target.videoCodec ??
|
||||
session.currentlyPlaying?.metadata?.transcoding.target.audioCodec ??
|
||||
session.currentlyPlaying?.metadata?.transcoding.container,
|
||||
),
|
||||
).length;
|
||||
const transcodingCount = flatSessions.filter((session) => {
|
||||
const status = getPlaybackStatus(session.currentlyPlaying?.metadata?.transcoding);
|
||||
return status !== "directPlay" && status !== "directStream";
|
||||
}).length;
|
||||
|
||||
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">
|
||||
@@ -287,19 +326,34 @@ export default function MediaServerWidget({
|
||||
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.integrationId} 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.integrationId} 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>
|
||||
|
||||
@@ -17,6 +17,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"),
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { getQueryKey } from "@trpc/react-query";
|
||||
import {
|
||||
Center,
|
||||
Divider,
|
||||
@@ -60,7 +61,9 @@ export default function MediaTranscodingWidget({
|
||||
pageSize: queuePageSize,
|
||||
page: requestPagination.page,
|
||||
};
|
||||
setWidgetRuntimeQueries(widgetStateRef, [{ path: ["widget", "mediaTranscoding", "getDataAsync"], input }]);
|
||||
setWidgetRuntimeQueries(widgetStateRef, [
|
||||
getQueryKey(clientApi.widget.mediaTranscoding.getDataAsync, input, "query"),
|
||||
]);
|
||||
const { data: transcodingData } = clientApi.widget.mediaTranscoding.getDataAsync.useQuery(input);
|
||||
|
||||
const [view, setView] = useState<View>(options.defaultView);
|
||||
|
||||
@@ -40,6 +40,9 @@ export const toChartItem = (healthInfo: {
|
||||
export const appendBoundedHistory = (history: ChartItem[], item: ChartItem, limit: number): ChartItem[] =>
|
||||
[...history, item].slice(-limit);
|
||||
|
||||
export const getNetworkHistory = (items: readonly ChartItem[]) =>
|
||||
items.flatMap((item) => (item.network === null ? [] : [item.network]));
|
||||
|
||||
export default function SystemResources({
|
||||
integrationIds,
|
||||
options,
|
||||
@@ -135,8 +138,8 @@ const SystemCharts = ({
|
||||
isAdvanced,
|
||||
showTitle,
|
||||
}: SystemChartsProps) => {
|
||||
const showNetwork =
|
||||
items.length > 0 && items[items.length - 1]?.network !== null && options.visibleCharts.includes("network");
|
||||
const networkItems = getNetworkHistory(items);
|
||||
const showNetwork = networkItems.length > 0 && options.visibleCharts.includes("network");
|
||||
const chartCount = options.visibleCharts.filter((chart) => chart !== "network").length + Number(showNetwork);
|
||||
const chartColumns = isAdvanced && width >= 560 ? 2 : 1;
|
||||
const chartHeight = isAdvanced
|
||||
@@ -183,13 +186,13 @@ const SystemCharts = ({
|
||||
(width >= 300 ? (
|
||||
<Group h={chartHeight} gap="xs" grow wrap="nowrap">
|
||||
<NetworkTrafficChart
|
||||
usageOverTime={items.map((item) => item.network?.down ?? 0)}
|
||||
usageOverTime={networkItems.map((network) => network.down)}
|
||||
isUp={false}
|
||||
hasShadow={options.hasShadow}
|
||||
labelDisplayMode={options.labelDisplayMode}
|
||||
/>
|
||||
<NetworkTrafficChart
|
||||
usageOverTime={items.map((item) => item.network?.up ?? 0)}
|
||||
usageOverTime={networkItems.map((network) => network.up)}
|
||||
isUp
|
||||
hasShadow={options.hasShadow}
|
||||
labelDisplayMode={options.labelDisplayMode}
|
||||
@@ -198,7 +201,7 @@ const SystemCharts = ({
|
||||
) : (
|
||||
<Box h={chartHeight}>
|
||||
<CombinedNetworkTrafficChart
|
||||
usageOverTime={items.map((item) => item.network ?? { up: 0, down: 0 })}
|
||||
usageOverTime={networkItems}
|
||||
hasShadow={options.hasShadow}
|
||||
labelDisplayMode={options.labelDisplayMode}
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { appendBoundedHistory, toChartItem } from "./component";
|
||||
import { appendBoundedHistory, getNetworkHistory, toChartItem } from "./component";
|
||||
|
||||
describe("system resource history", () => {
|
||||
test("averages all GPU values", () => {
|
||||
@@ -18,4 +18,16 @@ describe("system resource history", () => {
|
||||
const item = toChartItem({ cpuUtilization: 1, memUsedInBytes: 2, gpu: [], network: null });
|
||||
expect(appendBoundedHistory([item, item], item, 2)).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("omits unavailable network samples instead of inventing zero traffic", () => {
|
||||
const missing = toChartItem({ cpuUtilization: 1, memUsedInBytes: 2, gpu: [], network: null });
|
||||
const available = toChartItem({
|
||||
cpuUtilization: 1,
|
||||
memUsedInBytes: 2,
|
||||
gpu: [],
|
||||
network: { up: 12, down: 34 },
|
||||
});
|
||||
|
||||
expect(getNetworkHistory([missing, available, missing])).toEqual([{ up: 12, down: 34 }]);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { Stack, Text } from "@mantine/core";
|
||||
import { getQueryKey } from "@trpc/react-query";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
@@ -28,37 +30,36 @@ export default function UmamiWidget({
|
||||
limit: options.topCount,
|
||||
};
|
||||
setWidgetRuntimeQueries(widgetStateRef, [
|
||||
{
|
||||
path: ["widget", "umami", "getVisitorStats"],
|
||||
input: {
|
||||
getQueryKey(
|
||||
clientApi.widget.umami.getVisitorStats,
|
||||
{
|
||||
integrationIds,
|
||||
websiteId: options.websiteId,
|
||||
timeFrame: options.timeFrame,
|
||||
eventName: options.eventName || undefined,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: ["widget", "umami", "getActiveVisitors"],
|
||||
input: { integrationId, websiteId: options.websiteId },
|
||||
},
|
||||
"query",
|
||||
),
|
||||
getQueryKey(clientApi.widget.umami.getActiveVisitors, { integrationId, websiteId: options.websiteId }, "query"),
|
||||
...(options.viewMode === "events"
|
||||
? [
|
||||
{
|
||||
path: ["widget", "umami", "getMultiEventTimeSeries"],
|
||||
input: {
|
||||
getQueryKey(
|
||||
clientApi.widget.umami.getMultiEventTimeSeries,
|
||||
{
|
||||
integrationId,
|
||||
websiteId: options.websiteId,
|
||||
timeFrame: options.timeFrame,
|
||||
eventNames: [...options.eventNames].toSorted(),
|
||||
},
|
||||
},
|
||||
"query",
|
||||
),
|
||||
]
|
||||
: []),
|
||||
...(displayMode === "advanced" || options.viewMode === "topPages"
|
||||
? [{ path: ["widget", "umami", "getTopPages"], input: commonTopInput }]
|
||||
? [getQueryKey(clientApi.widget.umami.getTopPages, commonTopInput, "query")]
|
||||
: []),
|
||||
...(displayMode === "advanced" || options.viewMode === "topReferrers"
|
||||
? [{ path: ["widget", "umami", "getTopReferrers"], input: commonTopInput }]
|
||||
? [getQueryKey(clientApi.widget.umami.getTopReferrers, commonTopInput, "query")]
|
||||
: []),
|
||||
]);
|
||||
|
||||
|
||||
Generated
+3
@@ -2644,6 +2644,9 @@ importers:
|
||||
'@tiptap/starter-kit':
|
||||
specifier: 'catalog:'
|
||||
version: 3.27.3
|
||||
'@trpc/react-query':
|
||||
specifier: 'catalog:'
|
||||
version: 11.18.0(@tanstack/react-query@5.101.2(react@19.2.7))(@trpc/client@11.18.0(@trpc/server@11.18.0(@typescript/typescript6@6.0.2))(@typescript/typescript6@6.0.2))(@trpc/server@11.18.0(@typescript/typescript6@6.0.2))(@typescript/typescript6@6.0.2)(react@19.2.7)
|
||||
clsx:
|
||||
specifier: 'catalog:'
|
||||
version: 2.1.1
|
||||
|
||||
Reference in new issue
Block a user