merge: stack assistant on mobile boards

This commit is contained in:
Thomas Camlong committed 2026-08-08 21:27:56 +02:00
commit 9fd7a8a05b
1326 files changed
+131355 -46380

No files matched your search

+1
View File
@@ -9,6 +9,7 @@ export interface CommonWidgetInputProps<TKey extends WidgetOptionType> {
options: Omit<WidgetOptionOfType<TKey>, "defaultValue" | "type">;
initialOptions: Record<string, unknown>;
itemId?: string;
boardId?: string;
}
type UseWidgetInputTranslationReturnType = (key: "label" | "description" | "placeholder") => string;
+4
View File
@@ -2,6 +2,7 @@ import type { WidgetOptionType } from "../options";
import { WidgetAnchorNoteInput } from "./widget-anchor-note-input";
import { WidgetAppInput } from "./widget-app-input";
import { WidgetCustomWidgetSelectInput } from "./widget-custom-widget-select-input";
import { WidgetCustomWidgetConfigurationInput } from "./widget-custom-widget-configuration-input";
import { WidgetDynamicSelectInput } from "./widget-dynamic-select-input";
import { WidgetLocationInput } from "./widget-location-input";
import { WidgetMultiTextInput } from "./widget-multi-text-input";
@@ -18,8 +19,10 @@ import { WidgetUmamiEventNamesInput } from "./widget-umami-event-names-input";
import { WidgetUmamiWebsiteInput } from "./widget-umami-website-input";
import { WidgetIntegrationSelectInput } from "./widget-integration-select-input";
import { WidgetIntegrationMultiSelectInput } from "./widget-integration-multi-select-input";
import { WidgetInternalInput } from "./widget-internal-input";
const mapping = {
internal: WidgetInternalInput,
anchorNote: WidgetAnchorNoteInput,
text: WidgetTextInput,
location: WidgetLocationInput,
@@ -37,6 +40,7 @@ const mapping = {
umamiEventNames: WidgetUmamiEventNamesInput,
umamiWebsite: WidgetUmamiWebsiteInput,
customWidgetSelect: WidgetCustomWidgetSelectInput,
customWidgetConfiguration: WidgetCustomWidgetConfigurationInput,
integrationSelect: WidgetIntegrationSelectInput,
integrationMultiSelect: WidgetIntegrationMultiSelectInput,
} satisfies Record<WidgetOptionType, unknown>;
@@ -0,0 +1,87 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot } from "react-dom/client";
import type { Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { JsonOption } from "./widget-custom-widget-configuration-input";
vi.mock("@homarr/custom-widgets/workbench", () => ({
CustomWidgetCodeEditor: (props: {
id: string;
label: string;
value: string;
error?: string;
onChange(value: string): void;
}) => (
<textarea
id={props.id}
aria-label={props.label}
aria-invalid={Boolean(props.error)}
value={props.value}
onChange={(event) => props.onChange(event.currentTarget.value)}
/>
),
}));
let root: Root;
let host: HTMLDivElement;
beforeEach(() => {
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
host = document.createElement("div");
document.body.append(host);
root = createRoot(host);
});
afterEach(async () => {
await act(() => root.unmount());
host.remove();
});
async function renderJsonOption(identity: string, value: unknown) {
await act(async () => {
root.render(
<JsonOption
identity={identity}
editorId="settings-json-option"
label="Settings"
value={value}
onChange={vi.fn()}
/>,
);
});
}
function enterEditorValue(editor: HTMLTextAreaElement, value: string) {
const valueSetter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")?.set;
valueSetter?.call(editor, value);
editor.dispatchEvent(new Event("input", { bubbles: true }));
}
describe("custom widget JSON option", () => {
it("preserves an invalid active draft until its definition or external value changes", async () => {
await renderJsonOption("definition-a:settings", { owner: "Alice" });
const editor = host.querySelector("textarea");
if (!editor) throw new Error("JSON editor was not rendered");
expect(editor?.value).toContain("Alice");
await act(async () => enterEditorValue(editor, '{"owner":'));
expect(editor?.value).toBe('{"owner":');
await renderJsonOption("definition-a:settings", { owner: "Alice" });
expect(editor?.value).toBe('{"owner":');
await renderJsonOption("definition-b:settings", { owner: "Bob" });
expect(editor?.value).toContain("Bob");
expect(editor?.value).not.toContain("Alice");
});
it("synchronizes a valid external configuration update", async () => {
await renderJsonOption("definition-a:settings", { page: 1 });
const editor = host.querySelector("textarea");
await renderJsonOption("definition-a:settings", { page: 2 });
expect(editor?.value).toContain('"page": 2');
});
});
@@ -0,0 +1,348 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import {
Accordion,
Alert,
ColorInput,
MultiSelect,
NumberInput,
Select,
Slider,
Stack,
Switch,
Text,
Textarea,
TextInput,
} from "@mantine/core";
import { DateInput, TimeInput } from "@mantine/dates";
import { IconAlertTriangle } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useOptionalBoard } from "@homarr/boards/context";
import type { CustomWidgetOption } from "@homarr/custom-widgets/core";
import { validateCustomWidgetOptions } from "@homarr/custom-widgets/core";
import { CustomWidgetCodeEditor } from "@homarr/custom-widgets/workbench";
import type { CustomWidgetEditorMessages } from "@homarr/custom-widgets/workbench";
import { IconPicker } from "@homarr/forms-collection";
import { useScopedI18n } from "@homarr/translation/client";
import type { CommonWidgetInputProps } from "./common";
import { useFormContext } from "./form";
export const WidgetCustomWidgetConfigurationInput = ({
property,
}: CommonWidgetInputProps<"customWidgetConfiguration">) => {
const form = useFormContext();
const labels = useScopedI18n("widget.customApi.configuration");
const board = useOptionalBoard();
const definitionId = typeof form.values.options.definitionId === "string" ? form.values.options.definitionId : "";
const configurationValue = form.values.options[property];
const configuration = useMemo(() => (isRecord(configurationValue) ? configurationValue : {}), [configurationValue]);
const available = clientApi.customWidget.available.useQuery(
{ boardId: board?.id ?? "", currentId: definitionId || undefined },
{ enabled: board !== null && Boolean(definitionId) },
);
const definition = available.data?.find((candidate) => candidate.id === definitionId);
const options = isRecord(definition?.options) ? (definition.options as Record<string, CustomWidgetOption>) : null;
const issues = useMemo(
() => (options ? validateCustomWidgetOptions(options, configuration) : []),
[configuration, options],
);
useEffect(() => {
const path = `options.${property}`;
if (issues[0]) form.setFieldError(path, `${issues[0].path}: ${issues[0].message}`);
else form.clearFieldError(path);
}, [form, issues, property]);
if (!definitionId || !options) return null;
const entries = Object.entries(options);
const regular = entries.filter(([, option]) => !option.advanced);
const advanced = entries.filter(([, option]) => option.advanced);
return (
<Stack gap="md">
{issues[0] && (
<Alert color="yellow" variant="light" icon={<IconAlertTriangle size={16} />}>
<Text size="sm">{labels("needsAttention", { message: issues[0].message })}</Text>
</Alert>
)}
{regular.map(([name, option]) => (
<OptionField
key={`${definitionId}:${name}`}
option={option}
path={`options.${property}.${name}`}
configuration={configuration}
definitionId={definitionId}
/>
))}
{advanced.length > 0 && (
<Accordion variant="contained">
<Accordion.Item value="advanced-options">
<Accordion.Control>{labels("advancedOptions")}</Accordion.Control>
<Accordion.Panel>
<Stack gap="sm">
{advanced.map(([name, option]) => (
<OptionField
key={`${definitionId}:${name}`}
option={option}
path={`options.${property}.${name}`}
configuration={configuration}
definitionId={definitionId}
/>
))}
</Stack>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
)}
</Stack>
);
};
function OptionField({
option,
path,
configuration,
definitionId,
}: {
option: CustomWidgetOption;
path: string;
configuration: Record<string, unknown>;
definitionId: string;
}) {
const form = useFormContext();
const input = form.getInputProps(path, { type: option.control === "switch" ? "checkbox" : "input" });
const common = { label: option.label, description: option.description };
if (option.choicesFrom)
return <DynamicOptionField option={option} path={path} configuration={configuration} definitionId={definitionId} />;
if (option.control === "switch") return <Switch {...common} {...input} />;
if (["number", "duration"].includes(option.control))
return <NumberInput {...common} min={option.min} max={option.max} step={option.step} {...input} />;
if (option.control === "slider")
return (
<Stack gap={4}>
<Text size="sm" fw={500}>
{option.label}
</Text>
{option.description && (
<Text size="xs" c="dimmed">
{option.description}
</Text>
)}
<Slider min={option.min ?? 0} max={option.max ?? 100} step={option.step ?? 1} {...input} />
</Stack>
);
const choices = option.choices?.map((choice) => ({ label: choice.label, value: String(choice.value) })) ?? [];
if (option.control === "select")
return (
<Select
{...common}
data={choices}
value={input.value == null ? null : String(input.value)}
onChange={(value) => form.setFieldValue(path, coerceChoice(value, option))}
/>
);
if (option.control === "multiSelect")
return (
<MultiSelect
{...common}
data={choices}
value={Array.isArray(input.value) ? input.value.map(String) : []}
onChange={(value) =>
form.setFieldValue(
path,
value.map((entry) => coerceChoice(entry, option)),
)
}
/>
);
if (option.control === "textarea") return <Textarea {...common} autosize minRows={3} {...input} />;
if (option.control === "color") return <ColorInput {...common} {...input} />;
if (option.control === "date") return <DateInput {...common} valueFormat="YYYY-MM-DD" {...input} />;
if (option.control === "time") return <TimeInput {...common} {...input} />;
if (option.control === "icon") return <IconPicker withAsterisk={false} {...input} />;
if (option.control === "timeZone")
return <Select {...common} searchable data={Intl.supportedValuesOf("timeZone")} {...input} />;
if (option.control === "json")
return (
<JsonOption
identity={`${definitionId}:${path}`}
editorId={`${definitionId}-${path}-json-option`}
label={option.label}
description={option.description}
value={input.value}
onChange={(value) => form.setFieldValue(path, value)}
/>
);
return <TextInput {...common} type={option.control === "url" ? "url" : "text"} {...input} />;
}
function DynamicOptionField({
option,
path,
configuration,
definitionId,
}: {
option: CustomWidgetOption;
path: string;
configuration: Record<string, unknown>;
definitionId: string;
}) {
const form = useFormContext();
const board = useOptionalBoard();
const source = option.choicesFrom;
if (!source) return null;
const query = clientApi.customWidget.optionRequest.useQuery(
{
boardId: board?.id ?? "",
definitionId,
requestId: source.request,
params: primitiveConfiguration(configuration),
},
{ enabled: Boolean(board?.id && definitionId && source.request) },
);
const collection = source.itemsPath ? getByPath(query.data?.data, source.itemsPath) : query.data?.data;
const data = (Array.isArray(collection) ? collection : []).flatMap((row) => {
const value = getByPath(row, source.valuePath);
const label = getByPath(row, source.labelPath);
return typeof value === "string" || typeof value === "number"
? [{ value: String(value), label: String(label ?? value) }]
: [];
});
const input = form.getInputProps(path);
const common = {
label: option.label,
description: option.description,
data,
searchable: true,
disabled: query.isError,
rightSection: query.isFetching ? "…" : undefined,
};
if (option.control === "multiSelect")
return (
<MultiSelect
{...common}
value={Array.isArray(input.value) ? input.value.map(String) : []}
onChange={(value) =>
form.setFieldValue(
path,
value.map((entry) => coerceChoice(entry, option)),
)
}
/>
);
return (
<Select
{...common}
value={input.value == null ? null : String(input.value)}
onChange={(value) => form.setFieldValue(path, coerceChoice(value, option))}
/>
);
}
const editorMessages: CustomWidgetEditorMessages = {
languageJsx: "JSX",
languageJson: "JSON",
undo: "Undo",
redo: "Redo",
components: "Components",
componentSearch: "Search components",
componentEmpty: "No components",
componentCount: (count) => `${count} components`,
insertStarter: "Insert starter",
format: "Format",
copy: "Copy",
copied: "Copied",
schema: "Schema",
schemaTab: "JSON Schema",
minimalTab: "Minimal",
fullTab: "Full",
errors: (count) => `${count} errors`,
warnings: (count) => `${count} warnings`,
ready: "Ready",
position: ({ line, column }) => `Ln ${line}, Col ${column}`,
characters: (count, limit) => (limit ? `${count} / ${limit}` : `${count} characters`),
diagnosticsTitle: "Diagnostics",
diagnostic: (diagnostic) => diagnostic.value ?? diagnostic.code,
};
export function JsonOption({
identity,
editorId,
label,
description,
value,
onChange,
}: {
identity: string;
editorId: string;
label: string;
description?: string;
value: unknown;
onChange(value: unknown): void;
}) {
const serializedValue = JSON.stringify(value ?? null, null, 2);
const [draft, setDraft] = useState(serializedValue);
const [error, setError] = useState<string>();
const synchronizedValueRef = useRef(serializedValue);
const identityRef = useRef(identity);
useEffect(() => {
if (identityRef.current === identity && synchronizedValueRef.current === serializedValue) return;
identityRef.current = identity;
synchronizedValueRef.current = serializedValue;
setDraft(serializedValue);
setError(undefined);
}, [identity, serializedValue]);
return (
<CustomWidgetCodeEditor
id={editorId}
label={label}
description={description}
language="json"
value={draft}
height="160px"
error={error}
onChange={(next) => {
setDraft(next);
try {
const parsed = JSON.parse(next) as unknown;
synchronizedValueRef.current = JSON.stringify(parsed ?? null, null, 2);
onChange(parsed);
setError(undefined);
} catch (cause) {
setError(cause instanceof Error ? cause.message : "Invalid JSON");
}
}}
messages={editorMessages}
/>
);
}
function coerceChoice(value: string | null, option: CustomWidgetOption) {
if (value === null) return null;
return typeof option.default === "number" ||
option.choices?.some((choice) => typeof choice.value === "number" && String(choice.value) === value)
? Number(value)
: value;
}
function primitiveConfiguration(value: Record<string, unknown>) {
return Object.fromEntries(
Object.entries(value).filter((entry): entry is [string, string | number | boolean] =>
["string", "number", "boolean"].includes(typeof entry[1]),
),
);
}
function getByPath(value: unknown, path: string): unknown {
return path
.replace(/^\$\.?/u, "")
.split(".")
.filter(Boolean)
.reduce<unknown>((current, segment) => (isRecord(current) ? current[segment] : undefined), value);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
@@ -0,0 +1,169 @@
// @vitest-environment jsdom
import { act } from "react";
import type { ReactNode } from "react";
import { createRoot } from "react-dom/client";
import type { Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { WidgetCustomWidgetSelectInput } from "./widget-custom-widget-select-input";
const mocks = vi.hoisted(() => ({
available: [] as Array<Record<string, unknown>>,
permissions: ["admin"] as string[],
setFieldValue: vi.fn(),
}));
vi.mock("@mantine/core", () => {
// oxlint-disable-next-line unicorn/consistent-function-scoping -- the mock factory is hoisted by Vitest
const Container = ({ children }: { children?: ReactNode }) => <div>{children}</div>;
const Combobox = Object.assign(Container, {
Target: Container,
Dropdown: Container,
Options: Container,
Group: Container,
Option: Container,
Empty: Container,
Chevron: () => null,
});
return {
Alert: ({ children, title }: { children?: ReactNode; title?: ReactNode }) => (
<div role="alert">
<strong>{title}</strong>
{children}
</div>
),
Anchor: ({ children, href }: { children?: ReactNode; href?: string }) => <a href={href}>{children}</a>,
Avatar: Container,
Badge: Container,
Combobox,
Group: Container,
InputBase: ({ value }: { value?: string }) => <input aria-label="Custom widget" value={value} readOnly />,
Loader: () => null,
Stack: Container,
Text: Container,
useCombobox: () => ({
dropdownOpened: false,
resetSelectedOption: vi.fn(),
closeDropdown: vi.fn(),
openDropdown: vi.fn(),
updateSelectedOptionIndex: vi.fn(),
}),
};
});
vi.mock("@homarr/api/client", () => ({
clientApi: {
customWidget: {
available: {
useQuery: () => ({ data: mocks.available, isLoading: false }),
},
},
},
}));
vi.mock("@homarr/auth/client", () => ({
useSession: () => ({ data: { user: { permissions: mocks.permissions } } }),
}));
vi.mock("@homarr/boards/context", () => ({
useOptionalBoard: () => ({ id: "board-1" }),
}));
vi.mock("@homarr/translation/client", () => ({
useScopedI18n: () => (key: string) =>
({
migrationRequired: "Migration required",
migrationDescription: "Copy its redacted prompt to an LLM, then paste the single JSON response into Homarr.",
manageMigration: "Open Manage custom widgets",
contactAdmin: "Contact an administrator.",
})[key] ?? key,
}));
vi.mock("@homarr/ui", () => ({ Link: "a" }));
vi.mock("./form", () => ({
useFormContext: () => ({
values: { options: { definitionId: "legacy-widget" } },
setFieldValue: mocks.setFieldValue,
}),
}));
let root: Root;
let host: HTMLDivElement;
beforeEach(() => {
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
mocks.available = [];
mocks.permissions = ["admin"];
mocks.setFieldValue.mockReset();
host = document.createElement("div");
document.body.append(host);
root = createRoot(host);
});
afterEach(async () => {
await act(() => root.unmount());
host.remove();
});
async function renderInput() {
await act(async () => {
root.render(
<WidgetCustomWidgetSelectInput
kind="customApi"
property="definitionId"
options={{ withDescription: false }}
initialOptions={{}}
/>,
);
});
}
describe("custom widget picker migration status", () => {
it("keeps an existing legacy selection visible with migration guidance", async () => {
mocks.available = [
{
id: "legacy-widget",
name: "Legacy status",
description: "Old API widget",
iconUrl: null,
sources: [],
requestCapabilities: [],
defaultOptions: {},
updatedAt: new Date(0),
migrationRequired: true,
},
];
await renderInput();
expect(host.querySelector("input")?.value).toBe("Legacy status");
expect(host.textContent).toContain("Migration required");
expect(host.textContent).toContain("Copy its redacted prompt to an LLM");
expect(host.querySelector('a[href="/manage/custom-widgets"]')?.textContent).toBe("Open Manage custom widgets");
});
it("tells non-admins to contact an administrator without linking management", async () => {
mocks.permissions = ["board-modify-all"];
mocks.available = [
{
id: "legacy-widget",
name: "Legacy status",
description: "Old API widget",
iconUrl: null,
sources: [],
requestCapabilities: [],
defaultOptions: {},
updatedAt: new Date(0),
migrationRequired: true,
},
];
await renderInput();
expect(host.textContent).toContain("Contact an administrator.");
expect(host.querySelector('a[href="/manage/custom-widgets"]')).toBeNull();
});
});
@@ -1,9 +1,26 @@
"use client";
import { useMemo, useState } from "react";
import { Combobox, InputBase, Loader, useCombobox } from "@mantine/core";
import {
Alert,
Anchor,
Avatar,
Badge,
Combobox,
Group,
InputBase,
Loader,
Stack,
Text,
useCombobox,
} from "@mantine/core";
import { IconAlertTriangle, IconApi } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import { useOptionalBoard } from "@homarr/boards/context";
import { useScopedI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui";
import type { CommonWidgetInputProps } from "./common";
import { useWidgetInputTranslation } from "./common";
@@ -15,18 +32,21 @@ export const WidgetCustomWidgetSelectInput = ({
options,
}: CommonWidgetInputProps<"customWidgetSelect">) => {
const t = useWidgetInputTranslation(kind, property);
const labels = useScopedI18n("widget.customApi.picker");
const { data: session } = useSession();
const isAdmin = session?.user.permissions.includes("admin") ?? false;
const form = useFormContext();
const { data, isLoading } = clientApi.customWidget.all.useQuery();
const board = useOptionalBoard();
const currentValue = form.values.options[property] as string;
const { data, isLoading } = clientApi.customWidget.available.useQuery(
{ boardId: board?.id ?? "", currentId: currentValue || undefined },
{ enabled: board !== null },
);
const [search, setSearch] = useState("");
const currentValue = form.values.options[property] as string;
const definitions = useMemo(
() =>
(data ?? [])
.filter((def) => def.enabled || def.id === currentValue)
.map((def) => ({ value: def.id, label: def.name })),
[data, currentValue],
() => (data ?? []).map((definition) => ({ ...definition, value: definition.id, label: definition.name })),
[data],
);
const filteredOptions = useMemo(
@@ -39,8 +59,8 @@ export const WidgetCustomWidgetSelectInput = ({
[definitions, search],
);
const selectedLabel = definitions.find((d) => d.value === currentValue)?.label;
const selectedDefinition = definitions.find((definition) => definition.value === currentValue);
const selectedLabel = selectedDefinition?.label;
const combobox = useCombobox({
onDropdownClose: () => {
combobox.resetSelectedOption();
@@ -49,43 +69,104 @@ export const WidgetCustomWidgetSelectInput = ({
});
return (
<Combobox
store={combobox}
onOptionSubmit={(val) => {
form.setFieldValue(`options.${property}`, val);
combobox.closeDropdown();
}}
>
<Combobox.Target>
<InputBase
label={t("label")}
description={options.withDescription ? t("description") : undefined}
rightSection={isLoading ? <Loader size="xs" /> : <Combobox.Chevron />}
rightSectionPointerEvents="none"
value={combobox.dropdownOpened ? search : (selectedLabel ?? currentValue)}
onChange={(event) => {
setSearch(event.currentTarget.value);
form.setFieldValue(`options.${property}`, event.currentTarget.value);
combobox.openDropdown();
combobox.updateSelectedOptionIndex();
}}
onClick={() => combobox.openDropdown()}
onFocus={() => combobox.openDropdown()}
onBlur={() => combobox.closeDropdown()}
placeholder={t("label")}
/>
</Combobox.Target>
<Stack gap="sm">
<Combobox
store={combobox}
onOptionSubmit={(val) => {
const definition = definitions.find((candidate) => candidate.value === val);
if (!definition || definition.migrationRequired) return;
form.setFieldValue(`options.${property}`, val);
form.setFieldValue("options.configuration", definition?.defaultOptions ?? {});
form.setFieldValue(
"options.configurationVersion",
definition?.updatedAt instanceof Date ? definition.updatedAt.getTime() : Date.now(),
);
combobox.closeDropdown();
}}
>
<Combobox.Target>
<InputBase
label={t("label")}
description={options.withDescription ? t("description") : undefined}
rightSection={isLoading ? <Loader size="xs" /> : <Combobox.Chevron />}
rightSectionPointerEvents="none"
value={combobox.dropdownOpened ? search : (selectedLabel ?? currentValue)}
onChange={(event) => {
setSearch(event.currentTarget.value);
combobox.openDropdown();
combobox.updateSelectedOptionIndex();
}}
onClick={() => combobox.openDropdown()}
onFocus={() => combobox.openDropdown()}
onBlur={() => combobox.closeDropdown()}
placeholder={t("label")}
/>
</Combobox.Target>
<Combobox.Dropdown>
<Combobox.Options>
{filteredOptions.map((def) => (
<Combobox.Option key={def.value} value={def.value} active={def.value === currentValue}>
{def.label}
</Combobox.Option>
))}
{filteredOptions.length === 0 && !isLoading && <Combobox.Empty>No definitions found</Combobox.Empty>}
</Combobox.Options>
</Combobox.Dropdown>
</Combobox>
<Combobox.Dropdown>
<Combobox.Options>
<Combobox.Group label={labels("group")}>
{filteredOptions.map((def) => (
<Combobox.Option
key={def.value}
value={def.value}
active={def.value === currentValue}
disabled={def.migrationRequired}
>
<Group wrap="nowrap" gap="sm">
<Avatar src={def.iconUrl} size={36} radius="md" color="blue">
<IconApi size={18} />
</Avatar>
<Stack gap={1} style={{ minWidth: 0 }}>
<Text size="sm" fw={600} truncate>
{def.label}
</Text>
{def.description && (
<Text size="xs" c="dimmed" lineClamp={1}>
{def.description}
</Text>
)}
<Group gap={4} mt={2}>
{def.migrationRequired && (
<Badge size="xs" variant="light" color="yellow">
{labels("migrationRequired")}
</Badge>
)}
<Badge size="xs" variant="light">
{labels("sources", { count: def.sources.length })}
</Badge>
<Badge size="xs" variant="light" color="gray">
{labels("requests", { count: def.requestCapabilities.length })}
</Badge>
</Group>
</Stack>
</Group>
</Combobox.Option>
))}
</Combobox.Group>
{filteredOptions.length === 0 && !isLoading && <Combobox.Empty>{labels("empty")}</Combobox.Empty>}
</Combobox.Options>
</Combobox.Dropdown>
</Combobox>
{selectedDefinition?.migrationRequired && (
<Alert
color="yellow"
variant="light"
icon={<IconAlertTriangle size={18} />}
title={labels("migrationRequired")}
>
<Text size="xs">{labels("migrationDescription")}</Text>
{isAdmin ? (
<Anchor component={Link} href="/manage/custom-widgets" size="xs" fw={600}>
{labels("manageMigration")}
</Anchor>
) : (
<Text size="xs" fw={600}>
{labels("contactAdmin")}
</Text>
)}
</Alert>
)}
</Stack>
);
};
@@ -17,7 +17,13 @@ export interface DynamicSelectOption {
label: string;
}
export const WidgetDynamicSelectInput = ({ property, kind, options }: CommonWidgetInputProps<"dynamicSelect">) => {
export const WidgetDynamicSelectInput = ({
property,
kind,
options,
itemId,
boardId,
}: CommonWidgetInputProps<"dynamicSelect">) => {
const t = useI18n();
const tSelect = useScopedI18n("widget.dynamicSelect");
const tWidget = useWidgetInputTranslation(kind, property);
@@ -26,10 +32,10 @@ export const WidgetDynamicSelectInput = ({ property, kind, options }: CommonWidg
const [search, setSearch] = useState("");
const [debouncedSearch] = useDebouncedValue(search, 300);
const {
error: optionsError,
isPending,
isError = false,
options: selectOptions,
} = options.useOptions(debouncedSearch, form.values.integrationIds, form.values.options);
} = options.useOptions(debouncedSearch, form.values.integrationIds, form.values.options, itemId, boardId);
const currentOption = inputProps.value as DynamicSelectOption | null;
const onChange = inputProps.onChange as (value: DynamicSelectOption | null) => void;
@@ -93,8 +99,7 @@ export const WidgetDynamicSelectInput = ({ property, kind, options }: CommonWidg
description={options.withDescription ? tWidget("description") : undefined}
searchable
{...inputProps}
disabled={isError}
error={isError ? tSelect("loadError") : inputProps.error}
error={inputProps.error ?? optionsError}
value={currentOption === null ? null : currentOption.value}
onChange={(selectedValue: string | null) => {
if (selectedValue === null) {
@@ -0,0 +1,3 @@
import type { CommonWidgetInputProps } from "./common";
export const WidgetInternalInput = (_props: CommonWidgetInputProps<"internal">) => null;
@@ -21,8 +21,9 @@ import { IconClick, IconListSearch } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { createModal, useModalAction } from "@homarr/modals";
import { useScopedI18n } from "@homarr/translation/client";
import { useCurrentIntlLocale, useScopedI18n } from "@homarr/translation/client";
import { formatLocalizedCompactNumber } from "../common/locale";
import type { OptionLocation } from "../options";
import type { CommonWidgetInputProps } from "./common";
import { useWidgetInputTranslation } from "./common";
@@ -128,7 +129,7 @@ const LocationSearchModal = createModal<LocationSearchInnerProps>(({ actions, in
if (error) {
return (
<Alert title={tCommon("error")} color="red">
{error.message}
{tCommon("action.tryAgain")}
</Alert>
);
}
@@ -187,6 +188,7 @@ interface LocationSearchTableRowProps {
const LocationSelectTableRow = ({ city, onLocationSelect, closeModal }: LocationSearchTableRowProps) => {
const t = useScopedI18n("widget.common.location.table");
const locale = useCurrentIntlLocale();
const onSelect = useCallback(() => {
onLocationSelect({
name: city.name,
@@ -196,8 +198,6 @@ const LocationSelectTableRow = ({ city, onLocationSelect, closeModal }: Location
closeModal();
}, [city, onLocationSelect, closeModal]);
const formatter = Intl.NumberFormat("en", { notation: "compact" });
return (
<Table.Tr>
<Table.Td>
@@ -215,7 +215,7 @@ const LocationSelectTableRow = ({ city, onLocationSelect, closeModal }: Location
</Table.Td>
<Table.Td>
{city.population ? (
<Text style={{ whiteSpace: "nowrap" }}>{formatter.format(city.population)}</Text>
<Text style={{ whiteSpace: "nowrap" }}>{formatLocalizedCompactNumber(city.population, locale)}</Text>
) : (
<Text c="gray"> {t("population.fallback")}</Text>
)}
@@ -272,19 +272,19 @@ const ProviderTokensSection = ({ itemId, repositories }: { itemId: string; repos
const { data: configuredKinds = [], refetch } = clientApi.widget.secrets.getConfiguredKinds.useQuery({ itemId });
const setSecret = clientApi.widget.secrets.setSecret.useMutation({
onSuccess: () => refetch(),
onError: (error) => {
onError: () => {
showErrorNotification({
title: "Failed to save token",
message: error.message,
title: tRepository("tokens.label"),
message: tRepository("tokens.notConfigured"),
});
},
});
const deleteSecret = clientApi.widget.secrets.deleteSecret.useMutation({
onSuccess: () => refetch(),
onError: (error) => {
onError: () => {
showErrorNotification({
title: "Failed to delete token",
message: error.message,
title: tRepository("tokens.label"),
message: tRepository("tokens.notConfigured"),
});
},
});
@@ -0,0 +1,43 @@
import { describe, expect, test } from "vitest";
import { supportsAdvancedFocus } from "./definition";
import { widgetImports } from ".";
const compactOnlyWidgetKinds = [
"app",
"bazarr",
"beszelAlerts",
"beszelSystemGrid",
"beszelSystemStats",
"beszelSystemTable",
"coolify",
"dnsHoleControls",
"dnsHoleSummary",
"iframe",
"indexerManager",
"mediaRequests-requestList",
"mediaRequests-requestStats",
"minecraftServerStatus",
"networkControllerSummary",
"notebook",
"notifications",
"paperlessNgx",
"patchmon",
"releases",
"rssFeed",
"stockPrice",
"systemDisks",
"video",
"vpn",
] as const;
describe("advanced focus support", () => {
test("is reserved for widgets with a distinct advanced experience", () => {
const actualCompactOnlyKinds = Object.entries(widgetImports)
.filter(([, widgetImport]) => !supportsAdvancedFocus(widgetImport.definition))
.map(([kind]) => kind)
.toSorted();
expect(actualCompactOnlyKinds).toEqual([...compactOnlyWidgetKinds].toSorted());
});
});
@@ -4,6 +4,34 @@
height: 100%;
}
.homarr-anchor-actions {
opacity: 0;
transform: translateY(-2px);
transition:
opacity 120ms ease,
transform 120ms ease;
}
.homarr-anchor-note:hover .homarr-anchor-actions,
.homarr-anchor-note:focus-within .homarr-anchor-actions,
.homarr-anchor-actions[data-visible="true"] {
opacity: 1;
transform: none;
}
@media (any-pointer: coarse) {
.homarr-anchor-actions {
opacity: 1;
transform: none;
}
}
@media (prefers-reduced-motion: reduce) {
.homarr-anchor-actions {
transition: none;
}
}
.homarr-anchor-quill .ql-toolbar.ql-snow,
.homarr-anchor-quill .ql-container.ql-snow {
border: 0;
+120 -34
View File
@@ -3,19 +3,23 @@
import { startTransition, useCallback, useEffect, useMemo, useState } from "react";
import type { ComponentProps } from "react";
import dynamic from "next/dynamic";
import { Button, Center, Group, Stack, Text, TextInput } from "@mantine/core";
import { ActionIcon, Badge, Button, Center, Group, Stack, Text, TextInput, Tooltip } from "@mantine/core";
import { IconEdit } from "@tabler/icons-react";
import { Quill } from "react-quill-new";
import type { DeltaStatic } from "react-quill-new";
import type ReactQuillComponent from "react-quill-new";
import { z } from "zod/v4";
import { clientApi } from "@homarr/api/client";
import { useIntegrationsWithInteractAccess } from "@homarr/auth/client";
import { useTimeAgo } from "@homarr/common";
import type { AnchorNotePermission } from "@homarr/integrations";
import { useScopedI18n } from "@homarr/translation/client";
import { useCurrentIntlLocale, useScopedI18n } from "@homarr/translation/client";
import { WidgetEmptyState } from "../common/empty-state";
import type { WidgetComponentProps } from "../definition";
import { getUsableWidgetQueryData } from "../common/query-state";
import actionTargetClasses from "../common/action-target.module.css";
import "react-quill-new/dist/quill.snow.css";
import "./anchor-note.css";
@@ -106,6 +110,13 @@ const stringifyDelta = (delta: unknown): string => {
return JSON.stringify(emptyDelta());
};
export const storedContentToPlainText = (content?: string | null): string => {
return parseStoredContent(content)
.ops.map(({ insert }) => (typeof insert === "string" ? insert : ""))
.join("")
.trim();
};
const canEditPermission = (permission: AnchorNotePermission) => {
return permission === "owner" || permission === "editor";
};
@@ -119,7 +130,13 @@ const isForbiddenError = (error: unknown): boolean => {
return (data as { code?: unknown }).code === "FORBIDDEN";
};
export default function AnchorNoteWidget({ options, integrationIds }: WidgetComponentProps<"anchorNote">) {
export default function AnchorNoteWidget({
options,
integrationIds,
width,
height,
displayMode,
}: WidgetComponentProps<"anchorNote">) {
const t = useScopedI18n("widget.anchorNote");
const noteId = options.noteId.trim();
if (!noteId) {
@@ -134,21 +151,43 @@ export default function AnchorNoteWidget({ options, integrationIds }: WidgetComp
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const integrationId = integrationIds[0]!;
return <AnchorNoteWidgetContent options={options} integrationId={integrationId} noteId={noteId} />;
return (
<AnchorNoteWidgetContent
options={options}
integrationId={integrationId}
noteId={noteId}
width={width}
height={height}
isAdvanced={displayMode === "advanced"}
/>
);
}
interface AnchorNoteWidgetContentProps {
options: WidgetComponentProps<"anchorNote">["options"];
integrationId: string;
noteId: string;
width: number;
height: number;
isAdvanced: boolean;
}
const AnchorNoteWidgetContent = ({ options, integrationId, noteId }: AnchorNoteWidgetContentProps) => {
const AnchorNoteWidgetContent = ({
options,
integrationId,
noteId,
width,
height,
isAdvanced,
}: AnchorNoteWidgetContentProps) => {
const t = useScopedI18n("widget.anchorNote");
const { data: note, refetch } = clientApi.widget.anchorNotes.getNote.useQuery({
const locale = useCurrentIntlLocale();
const noteQuery = clientApi.widget.anchorNotes.getNote.useQuery({
integrationId,
noteId,
});
const note = getUsableWidgetQueryData(noteQuery);
const { refetch } = noteQuery;
const { mutateAsync: updateNoteAsync, isPending: isUpdating } = clientApi.widget.anchorNotes.updateNote.useMutation();
const [isEditing, setIsEditing] = useState(false);
@@ -165,7 +204,8 @@ const AnchorNoteWidgetContent = ({ options, integrationId, noteId }: AnchorNoteW
});
}, [isEditing, note]);
const canEdit = canEditPermission(note?.permission ?? "viewer");
const hasInteractAccess = useIntegrationsWithInteractAccess().some(({ id }) => id === integrationId);
const canEdit = canEditPermission(note?.permission ?? "viewer") && hasInteractAccess;
const isViewer = !note || note.permission === "viewer";
const updatedAt = useMemo(() => (note ? new Date(note.updatedAt) : new Date()), [note]);
const updatedAtRelative = useTimeAgo(updatedAt, 30000);
@@ -178,6 +218,7 @@ const AnchorNoteWidgetContent = ({ options, integrationId, noteId }: AnchorNoteW
const editorValue = useMemo(() => parseStoredContent(draftContent), [draftContent]);
const readOnlyValue = useMemo(() => parseStoredContent(note?.content), [note?.content]);
const plainText = useMemo(() => storedContentToPlainText(note?.content), [note?.content]);
const handleEditorChange = useCallback<ReactQuillOnChange>(
(_html, _delta, source, editor) => {
if (!isEditing || source !== "user") return;
@@ -260,7 +301,7 @@ const AnchorNoteWidgetContent = ({ options, integrationId, noteId }: AnchorNoteW
if (!note) return <WidgetEmptyState />;
return (
<Stack h="100%" gap="xs" p="sm">
<Stack className="homarr-anchor-note" h="100%" gap="xs" p={height < 120 ? "xs" : "sm"}>
<Group justify="space-between" align="flex-start">
<Stack gap={2} style={{ flex: 1 }}>
{isEditing ? (
@@ -278,40 +319,85 @@ const AnchorNoteWidgetContent = ({ options, integrationId, noteId }: AnchorNoteW
{t("readOnlyViewer")}
</Text>
)}
{!isEditing && isAdvanced && (
<Group gap={4}>
<Badge size="xs" variant="light">
{t(`permission.${note.permission}`)}
</Badge>
{note.isPinned && <Badge size="xs">{t("status.pinned")}</Badge>}
{note.isArchived && (
<Badge size="xs" color="gray">
{t("status.archived")}
</Badge>
)}
<Text size="xs" c="dimmed">
{t("createdAt", { date: new Date(note.createdAt).toLocaleDateString(locale) })}
</Text>
</Group>
)}
{saveError && (
<Text size="xs" c="red">
{saveError}
</Text>
)}
</Stack>
<Group gap="xs">
{isEditing ? (
<>
<Button size="xs" onClick={handleSave} loading={isUpdating} disabled={!hasChanges || !canEdit}>
{t("save")}
{(isEditing || canEdit) && (
<Group className="homarr-anchor-actions" data-visible={isEditing || isAdvanced || undefined} gap="xs">
{isEditing ? (
<>
<Button size="xs" onClick={handleSave} loading={isUpdating} disabled={!hasChanges || !canEdit}>
{t("save")}
</Button>
<Button size="xs" variant="subtle" onClick={handleCancel} disabled={isUpdating}>
{t("cancel")}
</Button>
</>
) : isAdvanced ? (
<Button size="xs" variant="light" onClick={handleEdit} disabled={isUpdating}>
{t("edit")}
</Button>
<Button size="xs" variant="subtle" onClick={handleCancel} disabled={isUpdating}>
{t("cancel")}
</Button>
</>
) : (
<Button size="xs" variant="light" onClick={handleEdit} disabled={!canEdit || isUpdating}>
{t("edit")}
</Button>
)}
</Group>
) : (
<Tooltip label={t("edit")}>
<ActionIcon
className={actionTargetClasses.root}
aria-label={t("edit")}
size="md"
variant="light"
onClick={handleEdit}
disabled={isUpdating}
>
<IconEdit size={16} />
</ActionIcon>
</Tooltip>
)}
</Group>
)}
</Group>
<div className={`homarr-anchor-quill${isEditing ? "" : " homarr-anchor-quill--readonly"}`} style={{ flex: 1 }}>
<ReactQuill
theme="snow"
readOnly={!isEditing}
value={isEditing ? editorValue : readOnlyValue}
onChange={handleEditorChange}
modules={isEditing ? quillModules : readOnlyModules}
formats={quillFormats}
placeholder={t("emptyContent")}
/>
</div>
{isEditing || isAdvanced ? (
<div
className={`homarr-anchor-quill${isEditing ? "" : " homarr-anchor-quill--readonly"}`}
style={{ flex: 1, minHeight: 0 }}
>
<ReactQuill
theme="snow"
readOnly={!isEditing}
value={isEditing ? editorValue : readOnlyValue}
onChange={handleEditorChange}
modules={isEditing ? quillModules : readOnlyModules}
formats={quillFormats}
placeholder={t("emptyContent")}
/>
</div>
) : (
<Text
size={width < 180 || height < 100 ? "xs" : "sm"}
c={plainText ? undefined : "dimmed"}
style={{ whiteSpace: "pre-wrap", overflowWrap: "anywhere" }}
lineClamp={Math.max(2, Math.floor((height - 70) / 18))}
>
{plainText || t("emptyContent")}
</Text>
)}
</Stack>
);
};
@@ -5,6 +5,8 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("anchorNote", {
icon: IconNotes,
queryKey: [["widget", "anchorNotes"]],
maxIntegrations: 1,
supportedIntegrations: ["anchor"],
createOptions() {
return optionsBuilder.from((factory) => ({
+90 -84
View File
@@ -2,7 +2,7 @@
import type { PropsWithChildren } from "react";
import { Fragment, Suspense } from "react";
import { Flex, rem, Stack, Text, Tooltip, UnstyledButton } from "@mantine/core";
import { Box, Flex, Stack, Text, Tooltip, UnstyledButton } from "@mantine/core";
import { IconMinus } from "@tabler/icons-react";
import combineClasses from "clsx";
@@ -14,6 +14,9 @@ import { useI18n } from "@homarr/translation/client";
import { MaskedOrNormalImage } from "@homarr/ui";
import { WidgetEmptyState } from "../common/empty-state";
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url";
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state";
import { WidgetQueryErrorIndicator, WidgetQueryLoadingState } from "../common/query-state-indicator";
import type { WidgetComponentProps } from "../definition";
import classes from "./app.module.css";
import { PingDot } from "./ping/ping-dot";
@@ -23,12 +26,12 @@ export default function AppWidget({ options, isEditMode, height, width }: Widget
const t = useI18n();
const settings = useSettings();
const board = useRequiredBoard();
const { data: app } = clientApi.app.byId.useQuery({
id: options.appId,
});
const appQuery = clientApi.app.byId.useQuery({ id: options.appId });
const app = getUsableWidgetQueryData(appQuery);
const href = getSafeApplicationUrl(app?.href);
useRegisterSpotlightContextResults(
`app-${app?.id ?? options.appId}`,
app?.href
app && href
? [
{
id: app.id,
@@ -37,99 +40,102 @@ export default function AppWidget({ options, isEditMode, height, width }: Widget
interaction() {
return {
type: "link",
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
href: app.href!,
href,
newTab: options.openInNewTab,
};
},
},
]
: [],
[app, options.openInNewTab],
[app, href, options.openInNewTab],
);
if (isInitialWidgetQueryPending(appQuery)) return <WidgetQueryLoadingState />;
if (!app) return <WidgetEmptyState />;
const isTiny = height < 100 || width < 100;
const isColumnLayout = options.layout.startsWith("column");
return (
<AppLink
href={app.href ?? undefined}
openInNewTab={options.openInNewTab}
enabled={Boolean(app.href) && !isEditMode}
>
<Tooltip.Floating
label={app.description?.split("\n").map((line, index) => (
<Fragment key={index}>
{line}
<br />
</Fragment>
))}
position="right-start"
multiline
disabled={options.descriptionDisplayMode !== "tooltip" || !app.description || isEditMode}
styles={{ tooltip: { maxWidth: 300 } }}
>
<Flex
p={isTiny ? 4 : "sm"}
className={combineClasses("app-flex-wrapper", app.name, app.id, app.href && classes.appWithUrl)}
h="100%"
w="100%"
direction={options.layout}
justify="center"
align="center"
gap={isColumnLayout ? 0 : "sm"}
onContextMenu={isEditMode ? (e) => e.preventDefault() : undefined}
<Box h="100%" w="100%" pos="relative">
<AppLink href={href} openInNewTab={options.openInNewTab} enabled={Boolean(href) && !isEditMode}>
<Tooltip.Floating
label={app.description?.split("\n").map((line, index) => (
<Fragment key={index}>
{line}
<br />
</Fragment>
))}
position="right-start"
multiline
disabled={options.descriptionDisplayMode !== "tooltip" || !app.description || isEditMode}
styles={{ tooltip: { maxWidth: 300 } }}
>
<Stack gap={0}>
{options.showTitle && (
<Text
className="app-title"
fw={700}
size={isTiny ? rem(8) : "sm"}
ta={isColumnLayout ? "center" : undefined}
>
{app.name}
</Text>
)}
{options.descriptionDisplayMode === "normal" && (
<Text
className="app-description"
size={isTiny ? rem(8) : "sm"}
ta={isColumnLayout ? "center" : undefined}
c="dimmed"
lineClamp={4}
>
{app.description?.split("\n").map((line, index) => (
<Fragment key={index}>
{line}
<br />
</Fragment>
))}
</Text>
)}
</Stack>
<MaskedOrNormalImage
imageUrl={app.iconUrl}
hasColor={board.iconColor !== null}
alt={app.name}
className={combineClasses(classes.appIcon, "app-icon")}
style={{
height: "100%",
width: "100%",
minWidth: "20%",
maxWidth: isColumnLayout ? undefined : "50%",
}}
/>
</Flex>
</Tooltip.Floating>
{options.pingEnabled && !settings.forceDisableStatus && !board.disableStatus && app.href ? (
<Suspense fallback={<PingDot icon={IconMinus} color="gray" tooltip={`${t("common.action.loading")}…`} />}>
<PingIndicator appId={app.id} />
</Suspense>
) : null}
</AppLink>
<Flex
p={isTiny ? 4 : "sm"}
className={combineClasses("app-flex-wrapper", app.name, app.id, href && classes.appWithUrl)}
h="100%"
w="100%"
direction={options.layout}
justify="center"
align="center"
gap={isColumnLayout ? 0 : "sm"}
onContextMenu={isEditMode ? (e) => e.preventDefault() : undefined}
>
<Stack gap={0}>
{options.showTitle && (
<Text
className="app-title"
fw={700}
size={isTiny ? "xs" : "sm"}
ta={isColumnLayout ? "center" : undefined}
>
{app.name}
</Text>
)}
{options.descriptionDisplayMode === "normal" && !isTiny && (
<Text
className="app-description"
size="sm"
ta={isColumnLayout ? "center" : undefined}
c="dimmed"
lineClamp={Math.max(1, Math.floor((height - 48) / 18))}
>
{app.description?.split("\n").map((line, index) => (
<Fragment key={index}>
{line}
<br />
</Fragment>
))}
</Text>
)}
</Stack>
<MaskedOrNormalImage
imageUrl={app.iconUrl}
hasColor={board.iconColor !== null}
alt={app.name}
className={combineClasses(classes.appIcon, "app-icon")}
style={{
height: "100%",
width: "100%",
minWidth: "20%",
maxWidth: isColumnLayout ? undefined : isTiny ? "38%" : "50%",
}}
/>
</Flex>
</Tooltip.Floating>
{options.pingEnabled && !settings.forceDisableStatus && !board.disableStatus && app.href ? (
<Suspense fallback={<PingDot icon={IconMinus} color="gray" tooltip={`${t("common.action.loading")}…`} />}>
<PingIndicator appId={app.id} />
</Suspense>
) : null}
</AppLink>
{appQuery.error && (
<Box pos="absolute" top={4} right={4}>
<WidgetQueryErrorIndicator error={appQuery.error} label={t("widget.app.name")} />
</Box>
)}
</Box>
);
}
@@ -145,7 +151,7 @@ const AppLink = ({ href, openInNewTab, enabled, children }: PropsWithChildren<Ap
component="a"
href={href}
target={openInNewTab ? "_blank" : undefined}
rel="noreferrer"
rel={openInNewTab ? SAFE_NEW_TAB_REL : undefined}
h="100%"
w="100%"
>
+2
View File
@@ -15,6 +15,8 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("app", {
icon: IconApps,
supportsAdvancedFocus: false,
queryKeys: [[["app", "byId"]], [["widget", "app", "ping"]]],
createOptions(settings) {
return optionsBuilder.from(
(factory) => ({
@@ -11,7 +11,9 @@ interface PingIndicatorProps {
export const PingIndicator = ({ appId }: PingIndicatorProps) => {
const t = useI18n();
const { data: pingResult } = clientApi.widget.app.ping.useQuery({ id: appId }, { refetchOnMount: false });
const { data: pingResult, error } = clientApi.widget.app.ping.useQuery({ id: appId }, { refetchOnMount: false });
if (error) return <PingDot icon={IconX} color="red" tooltip={t("common.error")} />;
if (!pingResult) return <PingDot icon={IconMinus} color="gray" tooltip={`${t("common.action.loading")}…`} />;
+2 -2
View File
@@ -1,9 +1,9 @@
import { trpc } from "@homarr/api/server";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { db, inArray } from "@homarr/db";
import { apps } from "@homarr/db/schema";
import type { Prefetch } from "../definition";
import { createTrpcQueryKey } from "../trpc-query-key";
const logger = createLogger({ module: "appWidgetPrefetch" });
@@ -16,7 +16,7 @@ const prefetchAllAsync: Prefetch<"app"> = async (queryClient, items) => {
});
for (const app of dbApps) {
queryClient.setQueryData(trpc.app.byId.queryKey({ id: app.id }), app);
queryClient.setQueryData(createTrpcQueryKey("app.byId", { id: app.id }), app);
}
logger.info("Successfully prefetched apps for app widget", { count: dbApps.length });
@@ -1,16 +1,20 @@
"use client";
import { Avatar, Badge, Card, Group, SimpleGrid, Stack, Text } from "@mantine/core";
import { Avatar, Badge, Card, Group, ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { formatByteRate } from "@homarr/common";
import { getIconUrl } from "@homarr/definitions";
import { useCurrentIntlLocale, useScopedI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition";
export default function ArchiveTeamWarriorWidget({
integrationIds,
options,
width,
height,
displayMode,
}: WidgetComponentProps<"archiveTeamWarrior">) {
const integrationId = integrationIds[0];
@@ -18,23 +22,47 @@ export default function ArchiveTeamWarriorWidget({
return null;
}
return <ArchiveTeamWarriorWidgetContent integrationId={integrationId} options={options} />;
return (
<ArchiveTeamWarriorWidgetContent
integrationId={integrationId}
options={options}
width={width}
height={height}
isAdvanced={displayMode === "advanced"}
/>
);
}
const ArchiveTeamWarriorWidgetContent = ({
integrationId,
options,
width,
height,
isAdvanced,
}: {
integrationId: string;
options: WidgetComponentProps<"archiveTeamWarrior">["options"];
width: number;
height: number;
isAdvanced: boolean;
}) => {
const t = useScopedI18n("widget.archiveTeamWarrior");
const locale = useCurrentIntlLocale();
const [data] = clientApi.widget.archiveTeamWarrior.getStatus.useSuspenseQuery({ integrationId });
const status = data.status;
const projectName = status.project?.title ?? status.selectedProject ?? "No project selected";
const projectName = status.project?.title ?? status.selectedProject ?? t("noProjectSelected");
const statusKey = getStatusKey(status.status);
const layout = getArchiveCompactLayout(width, height, isAdvanced);
const countMetrics = [
{ label: t("metric.running"), value: status.counts.running },
...(layout.showSecondaryCounts ? [{ label: t("metric.completed"), value: status.counts.completed }] : []),
{ label: t("metric.failed"), value: status.counts.failed },
...(layout.showSecondaryCounts ? [{ label: t("metric.canceled"), value: status.counts.canceled }] : []),
];
return (
<Stack p="xs" gap="xs" h="100%">
<Stack p="xs" gap={layout.gap} h="100%" style={{ overflow: "hidden" }}>
<Group justify="space-between" align="center" wrap="nowrap">
<Group gap="xs" wrap="nowrap" miw={0}>
<Avatar size="sm" radius="md" src={getIconUrl("archiveTeamWarrior")} />
@@ -43,12 +71,12 @@ const ArchiveTeamWarriorWidgetContent = ({
</Text>
</Group>
<Badge size="sm" color={status.status === "Running" ? "green" : "gray"}>
{status.status}
<Badge size="sm" color={getStatusColor(statusKey)}>
{t(`status.${statusKey}`)}
</Badge>
</Group>
{options.showBroadcastMessage && status.broadcastMessage && (
{options.showBroadcastMessage && status.broadcastMessage && layout.showBroadcast && (
<Card withBorder p="xs">
<Text size="xs" lineClamp={3}>
{status.broadcastMessage}
@@ -56,32 +84,112 @@ const ArchiveTeamWarriorWidgetContent = ({
</Card>
)}
<SimpleGrid cols={2} spacing="xs">
<Metric label="Running" value={status.counts.running} />
<Metric label="Done" value={status.counts.completed} />
<Metric label="Failed" value={status.counts.failed} />
<Metric label="Canceled" value={status.counts.canceled} />
<SimpleGrid cols={layout.metricColumns} spacing={layout.gap}>
{countMetrics.map((metric) => (
<Metric key={metric.label} {...metric} dense={layout.dense} />
))}
</SimpleGrid>
{status.bandwidth && (
<SimpleGrid cols={2} spacing="xs">
<Metric label="Download" value={formatBandwidth(status.bandwidth.receiving)} />
<Metric label="Upload" value={formatBandwidth(status.bandwidth.sending)} />
{status.bandwidth && layout.showBandwidth && (
<SimpleGrid cols={2} spacing={layout.gap}>
<Metric label={t("metric.download")} value={formatBandwidth(status.bandwidth.receiving)} dense />
<Metric label={t("metric.upload")} value={formatBandwidth(status.bandwidth.sending)} dense />
</SimpleGrid>
)}
{isAdvanced && (
<>
<Group justify="space-between" gap="xs">
<Text size="xs" c="dimmed">
{t("runnerStatus", { status: t(`status.${getStatusKey(status.runnerStatus ?? status.status)}`) })}
</Text>
<Text size="xs" c="dimmed">
{t("updatedAt", { date: new Date(status.updatedAt).toLocaleString(locale) })}
</Text>
</Group>
<ScrollArea style={{ flex: 1 }}>
<Stack gap={4}>
{status.items.map((item) => {
const itemStatusKey = getStatusKey(item.status);
return (
<Card key={item.id} withBorder p="xs">
<Group justify="space-between" wrap="nowrap">
<Stack gap={0} miw={0}>
<Text size="sm" fw={600} lineClamp={1}>
{item.name}
</Text>
<Text size="xs" c="dimmed" lineClamp={1}>
{item.project ?? projectName}
</Text>
</Stack>
<Badge size="xs" color={getStatusColor(itemStatusKey)}>
{t(`status.${itemStatusKey}`)}
</Badge>
</Group>
</Card>
);
})}
</Stack>
</ScrollArea>
</>
)}
</Stack>
);
};
const Metric = ({ label, value }: { label: string; value: number | string }) => (
const Metric = ({ label, value, dense = false }: { label: string; value: number | string; dense?: boolean }) => (
<Stack gap={0} miw={0}>
<Text size="md" fw={700} lineClamp={1}>
<Text size={dense ? "sm" : "md"} fw={700} lineClamp={1} title={String(value)}>
{value}
</Text>
<Text size="xs" c="dimmed" lineClamp={1}>
<Text size="xs" c="dimmed" lineClamp={1} title={label}>
{label}
</Text>
</Stack>
);
export const getArchiveCompactLayout = (width: number, height: number, isAdvanced = false) => {
const dense = !isAdvanced && (width < 240 || height < 160);
const showSecondaryCounts = isAdvanced || (width >= 200 && height >= 120);
const metricCount = showSecondaryCounts ? 4 : 2;
return {
dense,
gap: dense ? 4 : "xs",
metricColumns: height < 160 ? metricCount : Math.min(metricCount, width >= 260 ? 4 : 2),
showBandwidth: isAdvanced || height >= 190,
showBroadcast: isAdvanced || height >= 240,
showSecondaryCounts,
} as const;
};
const formatBandwidth = (value?: number) => formatByteRate(Math.round(value ?? 0));
type WarriorStatusKey = "running" | "completed" | "failed" | "canceled" | "stopped" | "idle" | "unknown";
const getStatusKey = (status?: string | null): WarriorStatusKey => {
switch (status?.trim().toLowerCase()) {
case "running":
return "running";
case "done":
case "completed":
return "completed";
case "failed":
return "failed";
case "canceled":
case "cancelled":
return "canceled";
case "stopped":
return "stopped";
case "idle":
return "idle";
default:
return "unknown";
}
};
const getStatusColor = (status: WarriorStatusKey): string => {
if (status === "running") return "green";
if (status === "failed") return "red";
if (status === "canceled" || status === "stopped") return "yellow";
return "gray";
};
@@ -5,6 +5,7 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("archiveTeamWarrior", {
icon: IconArchive,
maxIntegrations: 1,
createOptions() {
return optionsBuilder.from((factory) => ({
showBroadcastMessage: factory.switch({
@@ -0,0 +1,23 @@
import { describe, expect, test } from "vitest";
import { getArchiveCompactLayout } from "./component";
describe("archive team warrior compact layout", () => {
test("keeps only primary counters in a minimum-size widget", () => {
expect(getArchiveCompactLayout(180, 100)).toMatchObject({
metricColumns: 2,
showBandwidth: false,
showBroadcast: false,
showSecondaryCounts: false,
});
});
test("adds secondary information as height becomes available", () => {
expect(getArchiveCompactLayout(320, 260)).toMatchObject({
metricColumns: 4,
showBandwidth: true,
showBroadcast: true,
showSecondaryCounts: true,
});
});
});
@@ -8,7 +8,14 @@ import { useScopedI18n } from "@homarr/translation/client";
import classes from "./component.module.css";
import type { AudioStatsBackend, AudioStatsDisplayOptions } from "./shared";
import { AUDIO_STATS_TRANSLATION_SCOPE, getGridCols, getIconSize, getVisibleStats } from "./shared";
import {
AUDIO_STATS_TRANSLATION_SCOPE,
getGridCols,
getIconSize,
prioritizeVisibleStats,
getVisibleStatLimit,
getVisibleStats,
} from "./shared";
const rootClassByCompact = {
true: classes.rootCompact,
@@ -30,14 +37,17 @@ interface AudioStatsContentProps {
stats: NavidromeDashboardData | AudiobookshelfDashboardData;
options: AudioStatsDisplayOptions;
width: number;
height: number;
}
export function AudioStatsContent({ backend, stats, options, width }: AudioStatsContentProps) {
export function AudioStatsContent({ backend, stats, options, width, height }: AudioStatsContentProps) {
const t = useScopedI18n(AUDIO_STATS_TRANSLATION_SCOPE);
const visibleStats = getVisibleStats(backend, options, stats);
const compactKey = String(options.compactMode ?? false) as keyof typeof rootClassByCompact;
const gridCols = getGridCols(width, visibleStats.length, options.compactMode ?? false);
const iconSize = getIconSize(width, options.compactMode ?? false);
const compact = options.compactMode ?? false;
const enabledStats = prioritizeVisibleStats(getVisibleStats(backend, options, stats), compact);
const visibleStats = enabledStats.slice(0, getVisibleStatLimit(width, height, enabledStats.length, compact));
const compactKey = String(compact) as keyof typeof rootClassByCompact;
const gridCols = getGridCols(width, visibleStats.length, compact);
const iconSize = getIconSize(width, compact);
return (
<div className={`${classes.root} ${rootClassByCompact[compactKey]}`}>
@@ -2,7 +2,7 @@ import { describe, expect, test } from "vitest";
import type { AudiobookshelfDashboardData, NavidromeDashboardData } from "@homarr/integrations/types";
import { getGridCols, getIconSize, getVisibleStats } from "./shared";
import { getGridCols, getIconSize, getVisibleStatLimit, getVisibleStats, prioritizeVisibleStats } from "./shared";
describe("getGridCols", () => {
test("caps columns to visible stat count", () => {
@@ -37,6 +37,17 @@ describe("getIconSize", () => {
});
});
describe("getVisibleStatLimit", () => {
test("keeps compact metrics legible when height is constrained", () => {
expect(getVisibleStatLimit(100, 80, 5, true)).toBe(1);
expect(getVisibleStatLimit(180, 50, 5, true)).toBe(3);
});
test("preserves every configured metric outside compact mode", () => {
expect(getVisibleStatLimit(100, 80, 5, false)).toBe(5);
});
});
describe("getVisibleStats", () => {
const navidromeStats: NavidromeDashboardData = {
artistCount: 100,
@@ -91,3 +102,21 @@ describe("getVisibleStats", () => {
expect(typeof result[0]?.value).toBe("string");
});
});
describe("prioritizeVisibleStats", () => {
test("puts an active session first only on compact surfaces", () => {
const stats = getVisibleStats(
"audiobookshelf",
{ showLibraryCount: true, showActiveSessions: true },
{
libraryCount: 3,
totalAudiobooks: 150,
totalPodcasts: 20,
totalListeningTimeSeconds: 86400,
activeSessions: 2,
},
);
expect(prioritizeVisibleStats(stats, true)[0]?.statKey).toBe("activeSessions");
expect(prioritizeVisibleStats(stats, false)[0]?.statKey).toBe("libraryCount");
});
});
+108 -5
View File
@@ -1,17 +1,120 @@
"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";
import type { AudiobookshelfDashboardData } from "@homarr/integrations/types";
import { WidgetEmptyState } from "../common/empty-state";
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
import type { WidgetComponentProps } from "../definition";
import { useWidgetRuntimeQueries } from "../runtime-hooks";
import { AudioStatsContent } from "./audio-stats-content";
export default function AudioStatsWidget({ integrationIds, options, width }: WidgetComponentProps<"audioStats">) {
const { data: response } = clientApi.widget.audioStats.getStats.useQuery({
integrationId: integrationIds[0] ?? "",
});
export default function AudioStatsWidget({
integrationIds,
options,
width,
height,
displayMode = "compact",
widgetRuntimeRef,
}: WidgetComponentProps<"audioStats">) {
const statsInput = { integrationId: integrationIds[0] ?? "" };
const { data: response, error: statsError } = clientApi.widget.audioStats.getStats.useQuery(statsInput);
const streamsInput = { integrationIds: [statsInput.integrationId], showOnlyPlaying: true };
const streamsEnabled = displayMode === "advanced" && response?.kind === "navidrome";
const { data: streamResults, error: streamsError } = clientApi.widget.mediaServer.getCurrentStreams.useQuery(
streamsInput,
{ enabled: streamsEnabled },
);
useWidgetRuntimeQueries(widgetRuntimeRef, [
getQueryKey(clientApi.widget.audioStats.getStats, statsInput, "query"),
...(streamsEnabled ? [getQueryKey(clientApi.widget.mediaServer.getCurrentStreams, streamsInput, "query")] : []),
]);
if (statsError && response === undefined) throw statsError;
if (streamsEnabled && streamsError && streamResults === undefined) throw streamsError;
const currentStreams = streamResults ?? [];
if (!response) return <WidgetEmptyState />;
return <AudioStatsContent backend={response.kind} stats={response.data} options={options} width={width} />;
const summary = (
<AudioStatsContent
backend={response.kind}
stats={response.data}
options={{ ...options, compactMode: displayMode === "compact" && (options.compactMode || height < 160) }}
width={width}
height={height}
/>
);
if (displayMode === "compact") return summary;
const sessions = currentStreams.flatMap((result) =>
result.sessions.map((session) => ({ integrationId: result.integrationId, session })),
);
const audiobookStats = response.kind === "audiobookshelf" ? (response.data as AudiobookshelfDashboardData) : null;
const advancedGridColumns = width >= 760 ? 2 : 1;
const hasStreamErrors = currentStreams.some(({ error }) => Boolean(error));
return (
<Stack h="100%" gap="lg" p="md">
<div style={{ minHeight: 150 }}>{summary}</div>
{hasStreamErrors && (
<Group justify="flex-end">
<IntegrationErrorIndicator results={currentStreams} />
</Group>
)}
<ScrollArea style={{ flex: 1, minHeight: 0 }}>
{response.kind === "navidrome" ? (
<SimpleGrid cols={advancedGridColumns} spacing="xs">
{sessions.map(({ integrationId, session }) => {
const currentlyPlaying = session.currentlyPlaying;
if (!currentlyPlaying) return null;
return (
<Group key={`${integrationId}:${session.sessionId}`} justify="space-between" wrap="nowrap" p="sm">
<Stack gap={0} style={{ minWidth: 0, flex: 1 }}>
<Text size="sm" fw={600} truncate>
{currentlyPlaying.name}
</Text>
<Text size="xs" c="dimmed" truncate>
{currentlyPlaying.seasonName ?? currentlyPlaying.albumName ?? session.user?.username ?? "—"}
</Text>
</Stack>
<Badge
variant="light"
maw="40%"
h="auto"
py={4}
style={{ flexShrink: 0 }}
styles={{ label: { whiteSpace: "normal", overflowWrap: "anywhere", lineHeight: 1.2 } }}
>
{session.sessionName}
</Badge>
</Group>
);
})}
</SimpleGrid>
) : (
<SimpleGrid cols={advancedGridColumns} spacing="xs">
{(audiobookStats?.libraries ?? []).map((library) => (
<Stack key={library.id} gap={2} p="sm">
<Group justify="space-between" wrap="nowrap">
<Text size="sm" fw={600} truncate>
{library.name}
</Text>
<Badge size="xs" variant="light">
{library.totalItems}
</Badge>
</Group>
<Text size="xs" c="dimmed">
{library.totalDurationSeconds === null ? "—" : formatDuration(library.totalDurationSeconds * 1000)} ·{" "}
{library.totalSizeBytes === null ? "—" : formatBytes(library.totalSizeBytes)}
</Text>
</Stack>
))}
</SimpleGrid>
)}
</ScrollArea>
</Stack>
);
}
+3 -1
View File
@@ -1,6 +1,6 @@
import { IconHeadphones, IconServerOff } from "@tabler/icons-react";
import { createWidgetDefinition } from "../definition";
import { createWidgetDefinition, matchesWidgetRuntimeQuery } from "../definition";
import { optionsBuilder } from "../options";
const hideUnlessNavidrome = {
@@ -13,6 +13,8 @@ const hideUnlessAudiobookshelf = {
export const { definition, componentLoader } = createWidgetDefinition("audioStats", {
icon: IconHeadphones,
queryKeys: [[["widget", "audioStats", "getStats"]], [["widget", "mediaServer", "getCurrentStreams"]]],
queryMatcher: matchesWidgetRuntimeQuery,
supportedIntegrations: ["navidrome", "audiobookshelf"],
integrationsRequired: true,
maxIntegrations: 1,
@@ -155,6 +155,23 @@ export const getGridCols = (width: number, visibleCount: number, compact: boolea
return Math.min(maxCols, Math.max(visibleCount, 1));
};
export const getVisibleStatLimit = (width: number, height: number, visibleCount: number, compact: boolean): number => {
if (!compact) return visibleCount;
const columns = getGridCols(width, visibleCount, true);
const rows = Math.max(1, Math.floor(Math.max(0, height - 8) / 44));
return Math.min(visibleCount, columns * rows);
};
export const prioritizeVisibleStats = (stats: VisibleStat[], compact: boolean): VisibleStat[] => {
if (!compact) return stats;
return stats.toSorted(
(left, right) =>
Number(right.statKey === "activeSessions" && Number(right.value) > 0) -
Number(left.statKey === "activeSessions" && Number(left.value) > 0),
);
};
const fallbackIconSize = { true: 14, false: 16 } as const;
export const getIconSize = (width: number, compact: boolean): number => {
@@ -33,6 +33,16 @@
overflow: hidden;
}
.grid[data-short] .statTile {
flex-direction: row;
gap: 3px;
padding: 3px 4px;
}
.grid[data-short] .statLabel {
text-align: left;
}
.statTileWarning {
background-color: light-dark(var(--mantine-color-orange-0), var(--mantine-color-dark-5));
}
+23 -10
View File
@@ -26,6 +26,7 @@ const statIcons = {
} as const;
const gridColsByWidth = [
{ minWidth: 640, cols: 4 },
{ minWidth: 380, cols: 2 },
{ minWidth: 0, cols: 1 },
] as const;
@@ -36,13 +37,14 @@ const iconSizeByWidth = [
{ minWidth: 0, size: 16 },
] as const;
export default function BazarrWidget({ integrationIds, options, width }: WidgetComponentProps<"bazarr">) {
export default function BazarrWidget({ integrationIds, options, width, height }: WidgetComponentProps<"bazarr">) {
const t = useScopedI18n("widget.bazarr");
const { data: badges } = clientApi.widget.bazarr.getBadges.useQuery(
const { data: badges, error } = clientApi.widget.bazarr.getBadges.useQuery(
{ integrationId: integrationIds[0] ?? "" },
{ enabled: Boolean(integrationIds[0]) },
);
if (error && !badges) throw error;
if (!badges) return <WidgetEmptyState />;
const statValues = {
@@ -56,8 +58,8 @@ export default function BazarrWidget({ integrationIds, options, width }: WidgetC
.filter(([optionKey]) => options[optionKey as keyof typeof options])
.map(([, statKey]) => statKey);
const gridCols = getGridCols(width);
const iconSize = getIconSize(width);
const gridCols = getGridCols(width, height, visibleStatKeys.length);
const iconSize = getIconSize(Math.min(width, height));
if (visibleStatKeys.length === 0) {
return (
@@ -73,7 +75,11 @@ export default function BazarrWidget({ integrationIds, options, width }: WidgetC
return (
<div className={classes.root}>
<div className={classes.grid} style={{ "--stat-cols": gridCols } as CSSProperties}>
<div
className={classes.grid}
data-short={height < 120 || undefined}
style={{ "--stat-cols": gridCols } as CSSProperties}
>
{visibleStatKeys.map((statKey) => {
const Icon = statIcons[statKey];
const value = statValues[statKey];
@@ -83,7 +89,9 @@ export default function BazarrWidget({ integrationIds, options, width }: WidgetC
<div key={statKey} className={`${classes.statTile} ${isWarning ? classes.statTileWarning : ""}`}>
<Icon className={classes.statIcon} size={iconSize} stroke={1.5} />
<span className={`${classes.statValue} ${isWarning ? classes.statValueWarning : ""}`}>{value}</span>
<span className={classes.statLabel}>{t(statKey)}</span>
<span className={classes.statLabel} title={t(statKey)}>
{t(statKey)}
</span>
</div>
);
})}
@@ -92,12 +100,17 @@ export default function BazarrWidget({ integrationIds, options, width }: WidgetC
);
}
function getGridCols(width: number): number {
const match = gridColsByWidth.find(({ minWidth }) => width >= minWidth);
return match?.cols ?? 1;
export function getGridCols(width: number, height: number, itemCount: number): number {
if (itemCount <= 1) return 1;
const preferredColumns = gridColsByWidth.find(({ minWidth }) => width >= minWidth)?.cols ?? 1;
const maxColumnsByWidth = Math.max(1, Math.floor(width / 100));
const maxRowsByHeight = Math.max(1, Math.floor(height / 72));
const columnsNeededToFit = Math.ceil(itemCount / maxRowsByHeight);
return Math.min(itemCount, maxColumnsByWidth, Math.max(preferredColumns, columnsNeededToFit));
}
function getIconSize(width: number): number {
export function getIconSize(width: number): number {
const match = iconSizeByWidth.find(({ minWidth }) => width >= minWidth);
return match?.size ?? 16;
}
+2
View File
@@ -4,9 +4,11 @@ import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("bazarr", {
supportsAdvancedFocus: false,
icon: IconBadgeCc,
supportedIntegrations: ["bazarr"],
integrationsRequired: true,
maxIntegrations: 1,
createOptions() {
return optionsBuilder.from((factory) => ({
showMissingEpisodes: factory.switch({
@@ -0,0 +1,18 @@
import { describe, expect, test } from "vitest";
import { getGridCols, getIconSize } from "./component";
describe("Bazarr responsive layout", () => {
test("uses two columns for a short widget so all stats remain visible", () => {
expect(getGridCols(220, 100, 4)).toBe(2);
});
test("uses four columns in a wide advanced surface", () => {
expect(getGridCols(800, 400, 4)).toBe(4);
});
test("sizes icons from the constrained dimension", () => {
expect(getIconSize(180)).toBe(16);
expect(getIconSize(340)).toBe(22);
});
});
@@ -1,7 +1,20 @@
"use client";
import { useMemo } from "react";
import { Badge, Box, Center, Divider, Group, Loader, ScrollArea, Stack, Text, ThemeIcon } from "@mantine/core";
import {
Badge,
Box,
Center,
Divider,
Group,
Loader,
ScrollArea,
Stack,
Text,
ThemeIcon,
Tooltip,
VisuallyHidden,
} from "@mantine/core";
import { IconBellOff, IconCircleCheck, IconFlame, IconHistory } from "@tabler/icons-react";
import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
@@ -12,7 +25,8 @@ import { clientApi } from "@homarr/api/client";
import { useScopedI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition";
import { BeszelIntegrationErrorIndicator } from "../beszel/_shared/error-indicator";
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
import { getUsableWidgetQueryData } from "../common/query-state";
const alertIconMap: Record<string, LucideIcon> = {
CPU: Cpu,
@@ -35,13 +49,16 @@ export default function BeszelAlertsWidget({
options,
integrationIds,
isEditMode,
height,
}: WidgetComponentProps<"beszelAlerts">) {
const t = useScopedI18n("widget.beszelAlerts");
const alertsInput = useMemo(
() => ({ integrationIds, includeHistory: options.showHistory, maxHistoryItems: options.maxHistoryItems }),
[integrationIds, options.showHistory, options.maxHistoryItems],
);
const { data: results = [], error: alertsError, isPending } = clientApi.widget.beszel.getAlerts.useQuery(alertsInput);
const alertsQuery = clientApi.widget.beszel.getAlerts.useQuery(alertsInput);
const results = getUsableWidgetQueryData(alertsQuery) ?? [];
const { isPending } = alertsQuery;
const systemNameMap = useMemo(() => {
const map: Record<string, string> = {};
@@ -67,8 +84,9 @@ export default function BeszelAlertsWidget({
const triggeredAlerts = alerts.filter((a) => a.triggered);
const okAlerts = alerts.filter((a) => !a.triggered);
if (alertsError) throw alertsError;
const showOkAlerts = triggeredAlerts.length === 0 || height >= 260;
const showHistory = options.showHistory && height >= 360;
const showAlertDescriptions = height >= 190;
if (isPending) {
return (
@@ -81,7 +99,7 @@ export default function BeszelAlertsWidget({
return (
<Box h="100%" pos="relative">
<Box pos="absolute" top={4} right={8} style={{ zIndex: 1 }}>
<BeszelIntegrationErrorIndicator results={results} />
<IntegrationErrorIndicator results={results} />
</Box>
<ScrollArea h="100%" style={{ pointerEvents: isEditMode ? "none" : undefined }}>
<Stack gap="sm" p="sm">
@@ -100,7 +118,7 @@ export default function BeszelAlertsWidget({
<Stack gap={6}>
<Group gap={6}>
<IconFlame size={14} color="var(--mantine-color-red-6)" />
<Text size="xs" fw={600} tt="uppercase" c="red">
<Text size="xs" fw={600} c="red">
{t("status.triggered")} ({triggeredAlerts.length})
</Text>
</Group>
@@ -112,18 +130,19 @@ export default function BeszelAlertsWidget({
min={alert.min}
systemName={systemNameMap[alert.system] ?? alert.system}
triggered
showDescription={showAlertDescriptions}
/>
))}
</Stack>
)}
{triggeredAlerts.length > 0 && okAlerts.length > 0 && <Divider />}
{triggeredAlerts.length > 0 && showOkAlerts && okAlerts.length > 0 && <Divider />}
{okAlerts.length > 0 && (
{showOkAlerts && okAlerts.length > 0 && (
<Stack gap={6}>
<Group gap={6}>
<IconCircleCheck size={14} color="var(--mantine-color-green-6)" />
<Text size="xs" fw={600} tt="uppercase" c="dimmed">
<Text size="xs" fw={600} c="dimmed">
{t("status.ok")} ({okAlerts.length})
</Text>
</Group>
@@ -135,18 +154,19 @@ export default function BeszelAlertsWidget({
min={alert.min}
systemName={systemNameMap[alert.system] ?? alert.system}
triggered={false}
showDescription={showAlertDescriptions}
/>
))}
</Stack>
)}
{options.showHistory && history.length > 0 && (
{showHistory && history.length > 0 && (
<>
<Divider />
<Stack gap={6}>
<Group gap={6}>
<IconHistory size={14} opacity={0.5} />
<Text size="xs" fw={600} tt="uppercase" c="dimmed">
<Text size="xs" fw={600} c="dimmed">
{t("history")}
</Text>
</Group>
@@ -199,6 +219,7 @@ interface AlertRowProps {
min: number;
systemName: string;
triggered: boolean;
showDescription: boolean;
}
const unitSuffixMap: Record<string, string> = {
@@ -222,38 +243,43 @@ function formatAlertDescription(name: string, value: number, min: number): strin
return `exceeds ${value}${suffix} over ${min} min`;
}
function AlertRow({ name, value, min, systemName, triggered }: AlertRowProps) {
function AlertRow({ name, value, min, systemName, triggered, showDescription }: AlertRowProps) {
const Icon = alertIconMap[name] ?? Server;
const description = formatAlertDescription(name, value, min);
return (
<Group
wrap="nowrap"
gap="xs"
py={4}
pr={8}
pl={12}
style={{
borderRadius: "var(--mantine-radius-sm)",
borderLeft: `3px solid var(--mantine-color-${triggered ? "red" : "green"}-6)`,
backgroundColor: triggered ? "var(--mantine-color-red-light)" : "var(--mantine-color-default-hover)",
}}
>
<Icon size={14} opacity={0.7} style={{ flexShrink: 0 }} />
<Stack gap={0} style={{ flex: 1, minWidth: 0 }}>
<Group gap={6} wrap="nowrap">
<Text size="xs" fw={600} truncate>
{systemName}
</Text>
<Text size="xs" c="dimmed">
·
</Text>
<Text size="xs" c="dimmed" style={{ whiteSpace: "nowrap" }}>
{name}
</Text>
</Group>
<Text size="xs" c="dimmed" truncate>
{formatAlertDescription(name, value, min)}
</Text>
</Stack>
</Group>
<Tooltip label={description} disabled={showDescription} withArrow>
<Group
wrap="nowrap"
gap="xs"
py={showDescription ? 4 : 2}
px={8}
style={{
borderRadius: "var(--mantine-radius-sm)",
border: `1px solid var(--mantine-color-${triggered ? "red" : "gray"}-4)`,
backgroundColor: triggered ? "var(--mantine-color-red-light)" : "var(--mantine-color-default-hover)",
}}
>
<Icon size={14} opacity={0.7} style={{ flexShrink: 0 }} />
<Stack gap={0} style={{ flex: 1, minWidth: 0 }}>
<Group gap={6} wrap="nowrap">
<Text size="xs" fw={600} truncate>
{systemName}
</Text>
<Text size="xs" c="dimmed">
·
</Text>
<Text size="xs" c="dimmed" style={{ whiteSpace: "nowrap" }}>
{name}
</Text>
</Group>
{showDescription && (
<Text size="xs" c="dimmed" truncate>
{description}
</Text>
)}
{!showDescription && <VisuallyHidden>{description}</VisuallyHidden>}
</Stack>
</Group>
</Tooltip>
);
}
+9 -1
View File
@@ -1,11 +1,19 @@
import { IconBell, IconServerOff } from "@tabler/icons-react";
import { z } from "zod/v4";
import { createWidgetDefinition } from "../definition";
import { createWidgetDefinition, widgetQueryInputMatches } from "../definition";
import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("beszelAlerts", {
icon: IconBell,
supportsAdvancedFocus: false,
queryKey: [["widget", "beszel", "getAlerts"]],
queryMatcher: ({ input }, scope) =>
widgetQueryInputMatches(input, {
integrationIds: scope.integrationIds,
includeHistory: scope.options.showHistory,
maxHistoryItems: scope.options.maxHistoryItems,
}),
supportedIntegrations: ["beszel", "mock"],
integrationsRequired: true,
createOptions() {
@@ -1,7 +1,20 @@
.clickableCard {
transition: background-color 150ms;
cursor: pointer;
}
.clickableCard:hover {
.clickableCard:hover,
.clickableCard:focus-visible {
background-color: color-mix(in srgb, var(--mantine-color-default-hover) 40%, transparent) !important;
}
.clickableCard:focus-visible {
outline: 2px solid var(--mantine-primary-color-filled);
outline-offset: -2px;
}
@media (prefers-reduced-motion: reduce) {
.clickableCard {
transition: none;
}
}
@@ -36,7 +36,8 @@ import {
getProgressTrackSize,
} from "../beszel/_shared/format";
import { useBeszelFilteredSystems } from "../beszel/_shared/hooks";
import { BeszelIntegrationErrorIndicator } from "../beszel/_shared/error-indicator";
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
import { getUsableWidgetQueryData } from "../common/query-state";
import { BeszelSystemStatsModal } from "../beszel/_shared/system-stats-modal";
import { DiskUsage } from "../beszel/_shared/disk-usage";
@@ -330,10 +331,15 @@ const metricRenderers = [
] as const;
const SystemCard = ({ system, options, t, size, maxMetrics, itemRadius, onClick }: SystemCardProps) => {
const visibleMetrics = metricRenderers.filter((m) => m.visible(system, options)).slice(0, maxMetrics);
const enabledMetrics = metricRenderers.filter((metric) => metric.visible(system, options));
const visibleMetrics = enabledMetrics.slice(0, maxMetrics);
const hiddenMetricCount = enabledMetrics.length - visibleMetrics.length;
return (
<Card
component="button"
type="button"
disabled={!onClick}
padding={size.cardPadding}
radius={itemRadius}
bg="transparent"
@@ -345,7 +351,10 @@ const SystemCard = ({ system, options, t, size, maxMetrics, itemRadius, onClick
display: "flex",
flexDirection: "column" as const,
border: "0.0625rem solid var(--border-color)",
cursor: onClick ? "pointer" : undefined,
color: "inherit",
font: "inherit",
textAlign: "left",
width: "100%",
}}
>
<Group gap="xs" mb={2}>
@@ -362,6 +371,11 @@ const SystemCard = ({ system, options, t, size, maxMetrics, itemRadius, onClick
<Stack gap={0} style={{ flex: 1 }} justify="space-evenly">
{visibleMetrics.map((m) => m.render(system, t, size))}
</Stack>
{hiddenMetricCount > 0 && (
<Text size="10px" c="dimmed" ta="right">
+{hiddenMetricCount}
</Text>
)}
</Card>
);
};
@@ -377,16 +391,12 @@ export default function BeszelSystemGridWidget({
const board = useRequiredBoard();
const { openModal } = useModalAction(BeszelSystemStatsModal);
const {
data: results = [],
error: systemsError,
isPending,
} = clientApi.widget.beszel.getSystems.useQuery({ integrationIds });
const systemsQuery = clientApi.widget.beszel.getSystems.useQuery({ integrationIds });
const results = getUsableWidgetQueryData(systemsQuery) ?? [];
const { isPending } = systemsQuery;
const filteredSystems = useBeszelFilteredSystems(results, options.statusFilter);
if (systemsError) throw systemsError;
if (isPending) {
return (
<Center h="100%">
@@ -398,7 +408,7 @@ export default function BeszelSystemGridWidget({
if (filteredSystems.length === 0) {
return (
<Box h="100%" pos="relative" style={{ pointerEvents: isEditMode ? "none" : undefined }}>
<BeszelIntegrationErrorIndicator results={results} />
<IntegrationErrorIndicator results={results} />
<Center h="100%">
<Stack align="center" gap="xs">
<IconServerOff size={28} opacity={0.5} />
@@ -414,21 +424,22 @@ export default function BeszelSystemGridWidget({
const cols = getColCount(width, height, filteredSystems.length);
const rows = Math.ceil(filteredSystems.length / cols) || 1;
const rawCellHeight = height / rows;
const scrollEnabled = rawCellHeight < MIN_CELL_HEIGHT;
const effectiveCellHeight = scrollEnabled ? MIN_CELL_HEIGHT : rawCellHeight;
const minimumCellHeight = MIN_CELL_HEIGHT;
const scrollEnabled = rawCellHeight < minimumCellHeight;
const effectiveCellHeight = scrollEnabled ? minimumCellHeight : rawCellHeight;
const cellWidth = width / cols;
const size = getSizeConfig(cellWidth, effectiveCellHeight);
const maxMetrics = getMaxVisibleMetrics(effectiveCellHeight, size);
return (
<Box h="100%" pos="relative" style={{ pointerEvents: isEditMode ? "none" : undefined }}>
<BeszelIntegrationErrorIndicator results={results} />
<IntegrationErrorIndicator results={results} />
<Box
h="100%"
style={{
display: "grid",
gridTemplateColumns: `repeat(${cols}, 1fr)`,
gridTemplateRows: scrollEnabled ? `repeat(${rows}, ${MIN_CELL_HEIGHT}px)` : `repeat(${rows}, 1fr)`,
gridTemplateRows: scrollEnabled ? `repeat(${rows}, ${minimumCellHeight}px)` : `repeat(${rows}, 1fr)`,
gap: size.gap,
overflow: scrollEnabled ? "auto" : "hidden",
}}
@@ -6,7 +6,8 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("beszelSystemGrid", {
icon: IconLayoutGrid,
queryKey: [["widget", "beszel"]],
supportsAdvancedFocus: false,
queryKey: [["widget", "beszel", "getSystems"]],
supportedIntegrations: ["beszel", "mock"],
integrationsRequired: true,
createOptions() {
@@ -8,7 +8,21 @@
pointer-events: none;
}
.beszelStatsContainer:hover .beszelStatsSystemToggle {
.beszelStatsContainer:hover .beszelStatsSystemToggle,
.beszelStatsContainer:focus-within .beszelStatsSystemToggle {
opacity: 1;
pointer-events: auto;
}
@media (any-pointer: coarse) {
.beszelStatsSystemToggle {
opacity: 1;
pointer-events: auto;
}
}
@media (prefers-reduced-motion: reduce) {
.beszelStatsSystemToggle {
transition: none;
}
}
@@ -1,21 +1,26 @@
"use client";
import { useCallback, useMemo } from "react";
import { Box, Button, Center, Loader, Menu, ScrollArea, Select, Stack, Text } from "@mantine/core";
import { useCallback, useMemo, useRef } 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";
import { constructBoardPermissions } from "@homarr/auth/shared";
import { useOptionalBoard } from "@homarr/boards/context";
import { showErrorNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import classes from "./component.module.css";
import type { WidgetComponentProps } from "../definition";
import { useWidgetRuntimeQueries } from "../runtime-hooks";
import type { BeszelTimePeriod } from "../beszel/_shared/chart";
import { BeszelIntegrationErrorIndicator } from "../beszel/_shared/error-indicator";
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
import { getUsableWidgetQueryData } from "../common/query-state";
import { BeszelStatsView } from "../beszel/_shared/stats-view";
import { createBeszelSystemChoices, resolveBeszelSystemChoice } from "./selection";
export default function BeszelSystemStatsWidget({
options,
@@ -25,37 +30,106 @@ export default function BeszelSystemStatsWidget({
boardId,
itemId,
setOptions,
widgetRuntimeRef,
}: WidgetComponentProps<"beszelSystemStats">) {
const t = useScopedI18n("widget.beszelSystemStats");
const board = useOptionalBoard();
const { data: session } = useSession();
const hasChangeAccess = board ? constructBoardPermissions(board, session).hasChangeAccess : false;
const { mutate: saveItemOptions } = clientApi.widget.options.saveItemOptions.useMutation();
const {
data: systemsResult = [],
isPending: systemsPending,
error: systemsError,
} = clientApi.widget.beszel.getSystems.useQuery({ integrationIds });
const selectionSavePendingRef = useRef(false);
const { mutate: saveItemOptions, isPending: isSelectionSavePending } =
clientApi.widget.options.saveItemOptions.useMutation({
onError: () =>
showErrorNotification({
title: t("error.selectionSaveTitle"),
message: t("error.selectionSaveMessage"),
}),
});
const systemsQuery = clientApi.widget.beszel.getSystems.useQuery({ integrationIds });
const systemsResult = getUsableWidgetQueryData(systemsQuery) ?? [];
const { isPending: systemsPending } = systemsQuery;
const systems = useMemo(
() => systemsResult.flatMap((result) => result.systems.map((system) => ({ value: system.id, label: system.name }))),
[systemsResult],
const systems = useMemo(() => createBeszelSystemChoices(systemsResult), [systemsResult]);
const selectedSystem = resolveBeszelSystemChoice(systems, options.systemId);
const selectedValue = selectedSystem?.value ?? "";
const selectedLabel = selectedSystem?.label;
const systemExists = selectedSystem !== undefined;
const includeDocker = options.showDockerCpu || options.showDockerMemory || options.showDockerNetwork;
const periodOptions = [
{ value: "1m", label: t("period.live") },
{ value: "1h", label: t("period.oneHour") },
{ value: "12h", label: t("period.twelveHours") },
{ value: "24h", label: t("period.oneDay") },
{ value: "1w", label: t("period.oneWeek") },
{ value: "30d", label: t("period.thirtyDays") },
];
useWidgetRuntimeQueries(
widgetRuntimeRef,
selectedSystem && options.timePeriod !== "1m"
? [
getQueryKey(
clientApi.widget.beszel.getSystemStats,
{
integrationIds: [selectedSystem.integrationId],
systemId: selectedSystem.systemId,
timePeriod: options.timePeriod as BeszelTimePeriod,
includeDocker,
},
"query",
),
]
: [],
);
const selectedSystem = options.systemId || systems[0]?.value || "";
const selectedLabel = systems.find((system) => system.value === selectedSystem)?.label;
const systemExists = systems.some((system) => system.value === selectedSystem);
const handleSelectSystem = useCallback(
(value: string) => {
if (selectionSavePendingRef.current) return;
const previousValue = options.systemId;
setOptions({ newOptions: { systemId: value } });
if (hasChangeAccess && boardId && itemId) {
saveItemOptions({ boardId, itemId, newOptions: { systemId: value } });
selectionSavePendingRef.current = true;
saveItemOptions(
{ boardId, itemId, newOptions: { systemId: value } },
{
onError: () => {
setOptions({ newOptions: { systemId: previousValue } });
},
onSettled: () => {
selectionSavePendingRef.current = false;
},
},
);
}
},
[setOptions, hasChangeAccess, boardId, itemId, saveItemOptions],
[boardId, hasChangeAccess, itemId, options.systemId, saveItemOptions, setOptions],
);
const handleTimePeriod = useCallback(
(value: string) => {
if (selectionSavePendingRef.current) return;
const timePeriod = value as BeszelTimePeriod;
const previousValue = options.timePeriod;
setOptions({ newOptions: { timePeriod } });
if (hasChangeAccess && boardId && itemId) {
selectionSavePendingRef.current = true;
saveItemOptions(
{ boardId, itemId, newOptions: { timePeriod } },
{
onError: () => {
setOptions({ newOptions: { timePeriod: previousValue } });
},
onSettled: () => {
selectionSavePendingRef.current = false;
},
},
);
}
},
[boardId, hasChangeAccess, itemId, options.timePeriod, saveItemOptions, setOptions],
);
if (systemsError) throw systemsError;
if (systemsPending)
return (
<Center h="100%">
@@ -66,7 +140,7 @@ export default function BeszelSystemStatsWidget({
if (systems.length === 0) {
return (
<Box h="100%" pos="relative">
<BeszelIntegrationErrorIndicator results={systemsResult} />
<IntegrationErrorIndicator results={systemsResult} />
<Center h="100%">
<Stack align="center" gap="xs">
<IconServerOff size={28} opacity={0.5} />
@@ -90,8 +164,9 @@ export default function BeszelSystemStatsWidget({
<Select
size="xs"
data={systems}
value={selectedSystem}
value={selectedValue}
placeholder={t("selectSystem")}
disabled={isSelectionSavePending}
onChange={(value) => value && handleSelectSystem(value)}
/>
</Stack>
@@ -102,7 +177,7 @@ export default function BeszelSystemStatsWidget({
return (
<Box h="100%" pos="relative">
<Box pos="absolute" top={4} right={8} style={{ zIndex: 1 }}>
<BeszelIntegrationErrorIndicator results={systemsResult} />
<IntegrationErrorIndicator results={systemsResult} />
</Box>
<ScrollArea
h="100%"
@@ -118,6 +193,7 @@ export default function BeszelSystemStatsWidget({
size="compact-xs"
leftSection={<IconServer size={14} />}
className={classes.beszelStatsSystemToggle}
disabled={isSelectionSavePending}
>
{selectedLabel}
</Button>
@@ -127,8 +203,9 @@ export default function BeszelSystemStatsWidget({
<Menu.Item
key={system.value}
fz="xs"
fw={system.value === selectedSystem ? 600 : 400}
c={system.value !== selectedSystem ? "dimmed" : undefined}
fw={system.value === selectedValue ? 600 : 400}
c={system.value !== selectedValue ? "dimmed" : undefined}
disabled={isSelectionSavePending}
onClick={() => handleSelectSystem(system.value)}
>
{system.label}
@@ -137,9 +214,28 @@ export default function BeszelSystemStatsWidget({
</Menu.Dropdown>
</Menu>
)}
{!isEditMode &&
(width >= 560 ? (
<SegmentedControl
size="xs"
fullWidth
value={options.timePeriod}
onChange={handleTimePeriod}
disabled={isSelectionSavePending}
data={periodOptions}
/>
) : (
<Select
size="xs"
value={options.timePeriod}
onChange={(value) => value && handleTimePeriod(value)}
disabled={isSelectionSavePending}
data={periodOptions}
/>
))}
<BeszelStatsView
integrationIds={integrationIds}
systemId={selectedSystem}
integrationIds={selectedSystem ? [selectedSystem.integrationId] : []}
systemId={selectedSystem?.systemId ?? ""}
timePeriod={options.timePeriod as BeszelTimePeriod}
columns={width > 600 ? 2 : 1}
visibility={{
@@ -152,7 +248,7 @@ export default function BeszelSystemStatsWidget({
dockerMemory: options.showDockerMemory,
dockerNetwork: options.showDockerNetwork,
}}
onSwitchToHistorical={() => setOptions({ newOptions: { timePeriod: "1h" } })}
onSwitchToHistorical={() => handleTimePeriod("1h")}
/>
</Stack>
</ScrollArea>
@@ -2,8 +2,9 @@ import { IconChartAreaLine, IconServerOff } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { createWidgetDefinition } from "../definition";
import { createWidgetDefinition, matchesWidgetRuntimeQuery, widgetQueryInputMatches } from "../definition";
import { optionsBuilder } from "../options";
import { createBeszelSystemChoices, resolveStoredBeszelQuerySelection } from "./selection";
const timePeriodOptions = [
{ value: "1m", label: "Live" },
@@ -15,8 +16,31 @@ const timePeriodOptions = [
];
export const { definition, componentLoader } = createWidgetDefinition("beszelSystemStats", {
supportsAdvancedFocus: false,
icon: IconChartAreaLine,
queryKey: [["widget", "beszel"]],
queryKeys: [[["widget", "beszel", "getSystems"]], [["widget", "beszel", "getSystemStats"]]],
queryMatcher(query, scope) {
if (query.path.at(-1) === "getSystems") {
return widgetQueryInputMatches(query.input, { integrationIds: scope.integrationIds });
}
const hasRuntimeStatsQuery = scope.runtimeQueries.some(({ path }) => path.at(-1) === "getSystemStats");
if (hasRuntimeStatsQuery) return matchesWidgetRuntimeQuery(query, scope);
const selection = resolveStoredBeszelQuerySelection(String(scope.options.systemId ?? ""), scope.integrationIds);
if (!selection) return false;
const dockerEnabled = ["showDockerCpu", "showDockerMemory", "showDockerNetwork"].some(
(key) => scope.options[key] === true,
);
const expected = {
...selection,
timePeriod: scope.options.timePeriod,
};
return (
widgetQueryInputMatches(query.input, { ...expected, includeDocker: false }) ||
(dockerEnabled && widgetQueryInputMatches(query.input, { ...expected, includeDocker: true }))
);
},
supportedIntegrations: ["beszel", "mock"],
integrationsRequired: true,
createOptions() {
@@ -30,7 +54,7 @@ export const { definition, componentLoader } = createWidgetDefinition("beszelSys
isPending,
isError,
} = clientApi.widget.beszel.getSystems.useQuery({ integrationIds }, { enabled: integrationIds.length > 0 });
const selectData = data.flatMap((r) => r.systems.map((s) => ({ value: s.id, label: s.name })));
const selectData = createBeszelSystemChoices(data).map(({ value, label }) => ({ value, label }));
return { data: selectData, isPending, isError };
},
}),
@@ -0,0 +1,46 @@
import { describe, expect, it } from "vitest";
import { createBeszelSystemChoices, resolveBeszelSystemChoice, resolveStoredBeszelQuerySelection } from "./selection";
const choices = createBeszelSystemChoices([
{ integrationId: "one", integrationName: "Primary", systems: [{ id: "same", name: "Host A" }] },
{ integrationId: "two", integrationName: "Backup", systems: [{ id: "same", name: "Host B" }] },
]);
describe("Beszel system selection", () => {
it("uses a composite integration and system identity", () => {
expect(choices.map((choice) => choice.value)).toEqual(["one:same", "two:same"]);
expect(resolveBeszelSystemChoice(choices, "two:same")?.integrationId).toBe("two");
});
it("keeps unique legacy plain system ids working", () => {
expect(resolveBeszelSystemChoice(choices.slice(0, 1), "same")?.integrationId).toBe("one");
});
it("defaults an empty selection to the first system", () => {
expect(resolveBeszelSystemChoice(choices, "")?.integrationId).toBe("one");
});
it("does not guess when a legacy plain system id is ambiguous", () => {
expect(resolveBeszelSystemChoice(choices, "same")).toBeUndefined();
});
it("does not silently replace an unresolved stored system", () => {
expect(resolveBeszelSystemChoice(choices, "missing")).toBeUndefined();
});
it("derives the queried integration and system from a composite selection", () => {
expect(resolveStoredBeszelQuerySelection("two:same", ["one", "two"])).toEqual({
integrationIds: ["two"],
systemId: "same",
});
});
it("supports a legacy plain system id only when the integration is unambiguous", () => {
expect(resolveStoredBeszelQuerySelection("same", ["one"])).toEqual({
integrationIds: ["one"],
systemId: "same",
});
expect(resolveStoredBeszelQuerySelection("same", ["one", "two"])).toBeNull();
});
});
@@ -0,0 +1,48 @@
export interface BeszelSystemChoice {
value: string;
label: string;
integrationId: string;
systemId: string;
}
interface BeszelSystemsResult {
integrationId: string;
integrationName: string;
systems: { id: string; name: string }[];
}
export function createBeszelSystemChoices(results: BeszelSystemsResult[]): BeszelSystemChoice[] {
const includeIntegrationName = results.length > 1;
return results.flatMap((result) =>
result.systems.map((system) => ({
value: `${result.integrationId}:${system.id}`,
label: includeIntegrationName ? `${result.integrationName} · ${system.name}` : system.name,
integrationId: result.integrationId,
systemId: system.id,
})),
);
}
export function resolveBeszelSystemChoice(choices: BeszelSystemChoice[], storedValue: string) {
const exactMatch = choices.find((choice) => choice.value === storedValue);
if (exactMatch) return exactMatch;
if (storedValue === "") return choices[0];
const legacyMatches = choices.filter((choice) => choice.systemId === storedValue);
return legacyMatches.length === 1 ? legacyMatches[0] : undefined;
}
export function resolveStoredBeszelQuerySelection(storedValue: string, integrationIds: readonly string[]) {
for (const integrationId of integrationIds) {
const prefix = `${integrationId}:`;
if (storedValue.startsWith(prefix)) {
return { integrationIds: [integrationId], systemId: storedValue.slice(prefix.length) };
}
}
if (storedValue !== "" && integrationIds.length === 1) {
return { integrationIds: [integrationIds[0] as string], systemId: storedValue };
}
return null;
}
@@ -26,6 +26,7 @@ import { useScopedI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition";
import { HomarrDataTable } from "../common/homarr-data-table";
import { getUsableWidgetQueryData } from "../common/query-state";
import { usePersistedTableLayout, useTableLayoutPersistence } from "../common/use-persisted-table-layout";
import type { BeszelSystemRow } from "../beszel/_shared/types";
import { loadAvgColor, statusColorMap, thresholdColor } from "../beszel/_shared/colors";
@@ -38,7 +39,7 @@ import {
getProgressTrackSize,
} from "../beszel/_shared/format";
import { useBeszelFilteredSystems } from "../beszel/_shared/hooks";
import { BeszelIntegrationErrorIndicator } from "../beszel/_shared/error-indicator";
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
import { BeszelSystemStatsModal } from "../beszel/_shared/system-stats-modal";
import { DiskUsage } from "../beszel/_shared/disk-usage";
@@ -102,12 +103,27 @@ export default function BeszelSystemTableWidget({
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 systemsQuery = clientApi.widget.beszel.getSystems.useQuery({ integrationIds });
const results = getUsableWidgetQueryData(systemsQuery) ?? [];
const { isPending } = systemsQuery;
const size = useMemo(() => getSizeConfig(width), [width]);
const visibleMetricKeys = useMemo(() => {
const enabled = [
"showCpu",
"showMemory",
"showDisk",
"showGpu",
"showLoadAvg",
"showNet",
"showTemp",
"showBattery",
"showServices",
"showUptime",
"showAgent",
].filter((key) => options[key as keyof typeof options]) as string[];
const budget = width < 360 ? 1 : width < 560 ? 2 : width < 760 ? 4 : enabled.length;
return new Set(enabled.slice(0, budget));
}, [options, width]);
const { mutate: saveItemOptions } = clientApi.widget.options.saveItemOptions.useMutation({
onError: () =>
@@ -185,7 +201,7 @@ export default function BeszelSystemTableWidget({
</Group>
),
},
options.showCpu && {
visibleMetricKeys.has("showCpu") && {
accessor: "cpu",
width: 140,
title: (
@@ -197,7 +213,7 @@ export default function BeszelSystemTableWidget({
sortable: true,
render: (record) => <PercentCell value={record.cpu} />,
},
options.showMemory && {
visibleMetricKeys.has("showMemory") && {
accessor: "memory",
width: 140,
title: (
@@ -209,7 +225,7 @@ export default function BeszelSystemTableWidget({
sortable: true,
render: (record) => <PercentCell value={record.memory} />,
},
options.showDisk && {
visibleMetricKeys.has("showDisk") && {
accessor: "disk",
width: 160,
title: (
@@ -229,7 +245,7 @@ export default function BeszelSystemTableWidget({
/>
),
},
options.showGpu && {
visibleMetricKeys.has("showGpu") && {
accessor: "gpu",
width: 140,
title: (
@@ -241,7 +257,7 @@ export default function BeszelSystemTableWidget({
sortable: true,
render: (record) => <PercentCell value={record.gpu} />,
},
options.showLoadAvg && {
visibleMetricKeys.has("showLoadAvg") && {
accessor: "loadAvg",
width: 100,
title: (
@@ -258,7 +274,7 @@ export default function BeszelSystemTableWidget({
</Group>
),
},
options.showNet && {
visibleMetricKeys.has("showNet") && {
accessor: "netBytes",
width: 100,
title: (
@@ -274,7 +290,7 @@ export default function BeszelSystemTableWidget({
</Text>
),
},
options.showTemp && {
visibleMetricKeys.has("showTemp") && {
accessor: "temp",
width: 80,
title: (
@@ -286,7 +302,7 @@ export default function BeszelSystemTableWidget({
sortable: true,
render: (record) => <Text size={size.fontSize}>{formatTemp(record.temp, false)}</Text>,
},
options.showBattery && {
visibleMetricKeys.has("showBattery") && {
accessor: "battery",
width: 70,
title: (
@@ -297,7 +313,7 @@ export default function BeszelSystemTableWidget({
),
render: (record) => <Text size={size.fontSize}>{record.battery ? `${record.battery[0]}%` : "—"}</Text>,
},
options.showServices && {
visibleMetricKeys.has("showServices") && {
accessor: "services",
width: 80,
title: (
@@ -309,7 +325,7 @@ export default function BeszelSystemTableWidget({
sortable: true,
render: (record) => <Text size={size.fontSize}>{record.services}</Text>,
},
options.showUptime && {
visibleMetricKeys.has("showUptime") && {
accessor: "uptime",
width: 90,
title: (
@@ -321,7 +337,7 @@ export default function BeszelSystemTableWidget({
sortable: true,
render: (record) => <Text size={size.fontSize}>{formatUptime(record.uptime)}</Text>,
},
options.showAgent && {
visibleMetricKeys.has("showAgent") && {
accessor: "agentVersion",
width: 90,
title: (
@@ -336,21 +352,7 @@ export default function BeszelSystemTableWidget({
];
return cols.filter(Boolean) as DataTableColumn<SystemRowWithKey>[];
}, [
options.showAgent,
options.showBattery,
options.showCpu,
options.showDisk,
options.showGpu,
options.showLoadAvg,
options.showMemory,
options.showNet,
options.showServices,
options.showTemp,
options.showUptime,
size,
t,
]);
}, [t, size, visibleMetricKeys]);
const { effectiveColumns, storeKey } = usePersistedTableLayout({
columns,
@@ -370,8 +372,6 @@ export default function BeszelSystemTableWidget({
[openModal],
);
if (systemsError) throw systemsError;
if (isPending) {
return (
<Center h="100%">
@@ -380,10 +380,10 @@ export default function BeszelSystemTableWidget({
);
}
return (
const table = (
<div style={{ position: "relative", height: "100%" }}>
<div style={{ position: "absolute", top: 4, right: 8, zIndex: 1 }}>
<BeszelIntegrationErrorIndicator results={results} />
<IntegrationErrorIndicator results={results} />
</div>
<HomarrDataTable
isEditMode={isEditMode}
@@ -400,4 +400,6 @@ export default function BeszelSystemTableWidget({
/>
</div>
);
return table;
}
@@ -1,6 +1,6 @@
import { IconServerOff, IconTable } from "@tabler/icons-react";
import { createWidgetDefinition } from "../definition";
import { createWidgetDefinition, matchesWidgetRuntimeQuery, widgetQueryInputMatches } from "../definition";
import { statusOptions } from "../beszel/_shared/options";
import { optionsBuilder } from "../options";
@@ -25,7 +25,12 @@ const sortDirectionOptions = [
export const { definition, componentLoader } = createWidgetDefinition("beszelSystemTable", {
icon: IconTable,
queryKey: [["widget", "beszel"]],
supportsAdvancedFocus: false,
queryKeys: [[["widget", "beszel", "getSystems"]], [["widget", "beszel", "getSystemStats"]]],
queryMatcher: (query, scope) =>
query.path.at(-1) === "getSystems"
? widgetQueryInputMatches(query.input, { integrationIds: scope.integrationIds })
: matchesWidgetRuntimeQuery(query, scope),
supportedIntegrations: ["beszel", "mock"],
integrationsRequired: true,
createOptions() {
+33 -24
View File
@@ -8,33 +8,38 @@ import dayjs from "dayjs";
import type { BeszelContainerStatsRecord, BeszelSystemStatsRecord } from "@homarr/integrations/types";
import { formatLocalizedDate } from "../../common/locale";
export type BeszelTimePeriod = "1m" | "1h" | "12h" | "24h" | "1w" | "30d";
const timeFormats: Record<BeszelTimePeriod, string> = {
"1m": "HH:mm:ss",
"1h": "HH:mm",
"12h": "HH:mm",
"24h": "HH:mm",
"1w": "MMM D",
"30d": "MMM D",
const timeFormatOptions: Record<BeszelTimePeriod, Intl.DateTimeFormatOptions> = {
"1m": { hour: "numeric", minute: "2-digit", second: "2-digit" },
"1h": { hour: "numeric", minute: "2-digit" },
"12h": { hour: "numeric", minute: "2-digit" },
"24h": { hour: "numeric", minute: "2-digit" },
"1w": { month: "short", day: "numeric" },
"30d": { month: "short", day: "numeric" },
};
const periodDays: Partial<Record<BeszelTimePeriod, number>> = { "1w": 7, "30d": 30 };
function prepareRecords<T>(records: T[], timePeriod: BeszelTimePeriod) {
const fmt = (timestamp: string) => dayjs(timestamp).format(timeFormats[timePeriod]);
export const formatBeszelChartTimestamp = (timestamp: string, timePeriod: BeszelTimePeriod, locale: string): string =>
formatLocalizedDate(timestamp, locale, timeFormatOptions[timePeriod]);
function prepareRecords<T>(records: T[], timePeriod: BeszelTimePeriod, locale: string) {
const fmt = (timestamp: string) => formatBeszelChartTimestamp(timestamp, timePeriod, locale);
if (timePeriod === "1m") {
return { fmt, ordered: records };
}
return { fmt, ordered: [...records].toReversed() };
}
function padTimeGrid(data: Record<string, unknown>[], timePeriod: BeszelTimePeriod) {
if (timePeriod === "1m") return padLiveTimeGrid(data);
function padTimeGrid(data: Record<string, unknown>[], timePeriod: BeszelTimePeriod, locale: string) {
if (timePeriod === "1m") return padLiveTimeGrid(data, 60, locale);
const days = periodDays[timePeriod];
if (!days) return data;
const fmt = (ts: string) => dayjs(ts).format(timeFormats[timePeriod]);
const fmt = (timestamp: string) => formatBeszelChartTimestamp(timestamp, timePeriod, locale);
const now = dayjs();
const existingDays = new Set(data.map((d) => dayjs(d.rawTime as string).format("YYYY-MM-DD")));
const result = [...data];
@@ -51,7 +56,7 @@ function padTimeGrid(data: Record<string, unknown>[], timePeriod: BeszelTimePeri
return result.toSorted((a, b) => new Date(a.rawTime as string).getTime() - new Date(b.rawTime as string).getTime());
}
export function padLiveTimeGrid(data: Record<string, unknown>[], pointCount = 60) {
export function padLiveTimeGrid(data: Record<string, unknown>[], pointCount = 60, locale = "en-US") {
if (data.length === 0) return data;
const end = dayjs(
@@ -76,7 +81,7 @@ export function padLiveTimeGrid(data: Record<string, unknown>[], pointCount = 60
const timestamp = end.subtract(pointCount - index - 1, "second");
return (
pointsBySecond.get(timestamp.valueOf()) ?? {
time: timestamp.format(timeFormats["1m"]),
time: formatBeszelChartTimestamp(timestamp.toISOString(), "1m", locale),
rawTime: timestamp.toISOString(),
}
);
@@ -162,17 +167,18 @@ export const useSystemChartData = (
systemStats: BeszelSystemStatsRecord[] | undefined,
mapFn: (stats: BeszelSystemStatsRecord["stats"]) => Record<string, unknown>,
timePeriod: BeszelTimePeriod = "1h",
locale = "en-US",
) =>
useMemo(() => {
if (!systemStats) return [];
const { fmt, ordered } = prepareRecords(systemStats, timePeriod);
const { fmt, ordered } = prepareRecords(systemStats, timePeriod, locale);
const mapped = ordered.map((r) => ({
time: fmt(r.created),
rawTime: r.created,
...mapFn(r.stats),
}));
return padTimeGrid(mapped, timePeriod);
}, [systemStats, mapFn, timePeriod]);
return padTimeGrid(mapped, timePeriod, locale);
}, [systemStats, mapFn, timePeriod, locale]);
export const useContainerNames = (containerStats: BeszelContainerStatsRecord[] | undefined, max = 15) => {
const prevRef = useRef<string[]>([]);
@@ -212,10 +218,11 @@ export const useDiskChartData = (
efsPaths: string[],
rootSeriesName: string,
timePeriod: BeszelTimePeriod = "1h",
locale = "en-US",
) =>
useMemo(
() => buildDiskChartData(systemStats, efsPaths, rootSeriesName, timePeriod),
[systemStats, efsPaths, rootSeriesName, timePeriod],
() => buildDiskChartData(systemStats, efsPaths, rootSeriesName, timePeriod, locale),
[systemStats, efsPaths, rootSeriesName, timePeriod, locale],
);
export const buildDiskChartData = (
@@ -223,9 +230,10 @@ export const buildDiskChartData = (
efsPaths: string[],
rootSeriesName: string,
timePeriod: BeszelTimePeriod = "1h",
locale = "en-US",
) => {
if (!systemStats?.length) return [];
const { fmt, ordered } = prepareRecords(systemStats, timePeriod);
const { fmt, ordered } = prepareRecords(systemStats, timePeriod, locale);
const mapped = ordered.map((record) => {
const point: Record<string, unknown> = {
time: fmt(record.created),
@@ -238,7 +246,7 @@ export const buildDiskChartData = (
}
return point;
});
return padTimeGrid(mapped, timePeriod);
return padTimeGrid(mapped, timePeriod, locale);
};
export const useDockerChartData = (
@@ -246,12 +254,13 @@ export const useDockerChartData = (
containerNames: string[],
metric: "cpu" | "memory" | "network",
timePeriod: BeszelTimePeriod = "1h",
locale = "en-US",
) =>
useMemo(() => {
if (!containerStats?.length) return [];
const extract = defaultContainerExtractors[metric];
if (!extract) return [];
const { fmt, ordered } = prepareRecords(containerStats, timePeriod);
const { fmt, ordered } = prepareRecords(containerStats, timePeriod, locale);
const mapped = ordered.map((record) => {
const point: Record<string, unknown> = { time: fmt(record.created), rawTime: record.created };
const byName = new Map(record.stats.map((c) => [c.n, c]));
@@ -260,5 +269,5 @@ export const useDockerChartData = (
}
return point;
});
return padTimeGrid(mapped, timePeriod);
}, [containerStats, containerNames, metric, timePeriod]);
return padTimeGrid(mapped, timePeriod, locale);
}, [containerStats, containerNames, metric, timePeriod, locale]);
@@ -0,0 +1,11 @@
.target {
min-width: 24px;
min-height: 24px;
}
@media (pointer: coarse) {
.target {
min-width: 44px;
min-height: 44px;
}
}
@@ -1,8 +1,12 @@
import { Box, Group, HoverCard, Progress, Stack, Text, UnstyledButton } from "@mantine/core";
import { useEffect, useState } from "react";
import { Box, Group, Popover, Progress, Stack, Text, UnstyledButton } from "@mantine/core";
import { useScopedI18n } from "@homarr/translation/client";
import type { BeszelSystemRow } from "./types";
import { thresholdColor } from "./colors";
import { formatPercent, getProgressTrackSize } from "./format";
import classes from "./disk-usage.module.css";
interface DiskUsageProps {
system: BeszelSystemRow;
@@ -17,33 +21,44 @@ const mountLabel = (path: string) =>
const severityColor = (value: number) => `var(--mantine-color-${thresholdColor(value)}-6)`;
export const DiskUsage = ({ system, fontSize, progressSize, valueMiw, valueGap = 6 }: DiskUsageProps) => {
const t = useScopedI18n("common");
const [opened, setOpened] = useState(false);
const filesystems = Object.entries(system.extraFilesystems).filter(([path]) => path !== "/");
const filesystemUsageLabel = t("filesystemUsage", { count: filesystems.length + 1 });
const trackSize = getProgressTrackSize(progressSize);
const dotSize = progressSize === "xs" ? 2 : 3;
const dotGap = 2;
useEffect(() => {
if (filesystems.length === 0) setOpened(false);
}, [filesystems.length]);
return (
<Group gap={valueGap} wrap="nowrap" style={{ flex: 1, minWidth: 0, marginLeft: "auto" }}>
<Text size={fontSize} fw={500} w={valueMiw} ta="left" style={{ whiteSpace: "nowrap", flexShrink: 0 }}>
{formatPercent(system.disk)}
</Text>
<HoverCard
position="right"
withArrow
shadow="md"
openDelay={200}
closeDelay={100}
disabled={filesystems.length === 0}
>
<HoverCard.Target>
{filesystems.length > 0 ? (
{filesystems.length > 0 ? (
<Popover
opened={opened}
onChange={setOpened}
zIndex="var(--homarr-z-index-widget-background-tooltip)"
position="right"
withArrow
shadow="md"
>
<Popover.Target>
<UnstyledButton
aria-label={`Show usage for ${filesystems.length + 1} filesystems`}
aria-label={filesystemUsageLabel}
className={classes.target}
onMouseEnter={() => setOpened(true)}
onMouseLeave={() => setOpened(false)}
onBlur={() => setOpened(false)}
onClick={() => setOpened(true)}
style={{
display: "flex",
alignItems: "center",
flex: 1,
minWidth: 24,
cursor: "pointer",
}}
>
@@ -74,32 +89,32 @@ export const DiskUsage = ({ system, fontSize, progressSize, valueMiw, valueGap =
</Group>
</Box>
</UnstyledButton>
) : (
<Box style={{ display: "flex", alignItems: "center", flex: 1, minWidth: 24 }}>
<Box pos="relative" style={{ flex: 1, minWidth: 24 }}>
<Progress value={system.disk} color={thresholdColor(system.disk)} size={trackSize} />
</Box>
</Box>
)}
</HoverCard.Target>
<HoverCard.Dropdown p={8}>
<Stack gap={6} miw={145}>
{[["/", system.disk] as const, ...filesystems].map(([path, value]) => (
<Stack key={path} gap={1}>
<Text size="10px" c="dimmed" fw={500} lh={1.2}>
{mountLabel(path)}
</Text>
<Group gap={8} wrap="nowrap">
<Text size="sm" fw={600} miw={48} lh={1.25}>
{formatPercent(value)}
</Popover.Target>
<Popover.Dropdown aria-label={filesystemUsageLabel} p={8}>
<Stack gap={6} miw={145}>
{[["/", system.disk] as const, ...filesystems].map(([path, value]) => (
<Stack key={path} gap={1}>
<Text size="10px" c="dimmed" fw={500} lh={1.2}>
{mountLabel(path)}
</Text>
<Progress value={value} color={thresholdColor(value)} size="xs" style={{ flex: 1 }} />
</Group>
</Stack>
))}
</Stack>
</HoverCard.Dropdown>
</HoverCard>
<Group gap={8} wrap="nowrap">
<Text size="sm" fw={600} miw={48} lh={1.25}>
{formatPercent(value)}
</Text>
<Progress value={value} color={thresholdColor(value)} size="xs" style={{ flex: 1 }} />
</Group>
</Stack>
))}
</Stack>
</Popover.Dropdown>
</Popover>
) : (
<Box style={{ display: "flex", alignItems: "center", flex: 1, minWidth: 24 }}>
<Box pos="relative" style={{ flex: 1, minWidth: 24 }}>
<Progress value={system.disk} color={thresholdColor(system.disk)} size={trackSize} />
</Box>
</Box>
)}
</Group>
);
};
@@ -1,25 +0,0 @@
"use client";
import { Tooltip } from "@mantine/core";
import { IconAlertTriangle } from "@tabler/icons-react";
interface IntegrationResult {
integrationId: string;
integrationName?: string;
error?: string;
}
interface BeszelIntegrationErrorIndicatorProps {
results: IntegrationResult[];
}
export function BeszelIntegrationErrorIndicator({ results }: BeszelIntegrationErrorIndicatorProps) {
const failed = results.filter((r) => "error" in r && r.error);
if (failed.length === 0) return null;
const label = failed.map((r) => r.integrationName ?? r.integrationId).join(", ");
return (
<Tooltip label={label} position="left" withArrow>
<IconAlertTriangle size={14} color="var(--mantine-color-orange-6)" style={{ cursor: "help", flexShrink: 0 }} />
</Tooltip>
);
}
@@ -6,8 +6,9 @@ import { IconPlugConnectedX, IconServerOff } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import type { BeszelContainerStatsRecord, BeszelSystemStatsRecord } from "@homarr/integrations/types";
import { useScopedI18n } from "@homarr/translation/client";
import { useCurrentIntlLocale, useScopedI18n } from "@homarr/translation/client";
import { getUsableWidgetQueryData } from "../../common/query-state";
import { containerColors } from "./colors";
import type { BeszelTimePeriod } from "./chart";
import {
@@ -19,16 +20,23 @@ import {
useSystemChartData,
} from "./chart";
import { chartAxisFormatters, formatByteRate, formatGB, formatPercent, formatStorageBytes } from "./format";
import type { BeszelTooltipLayer } from "./tooltip";
import { makeTooltipProps } from "./tooltip";
import { useLiveStats } from "./use-live-stats";
const CHART_HEIGHT = 180;
const MB = 1024 * 1024;
const tooltipPercent = makeTooltipProps(formatPercent);
const tooltipGB = makeTooltipProps(formatGB, true);
const tooltipRate = makeTooltipProps(formatByteRate, true);
const tooltipPercentTotal = makeTooltipProps(formatPercent, true);
const tooltipBytesTotal = makeTooltipProps(formatStorageBytes, true);
const makeTooltipSet = (layer: BeszelTooltipLayer) => ({
percent: makeTooltipProps(formatPercent, false, layer),
gigabytes: makeTooltipProps(formatGB, true, layer),
rate: makeTooltipProps(formatByteRate, true, layer),
percentTotal: makeTooltipProps(formatPercent, true, layer),
bytesTotal: makeTooltipProps(formatStorageBytes, true, layer),
});
const tooltipSets = {
background: makeTooltipSet("background"),
modal: makeTooltipSet("modal"),
};
const ChartSkeleton = () => (
<Stack gap={4} style={{ minWidth: 0 }}>
@@ -58,6 +66,7 @@ interface BeszelStatsViewProps {
visibility: BeszelStatsVisibility;
columns: 1 | 2;
onSwitchToHistorical?: () => void;
tooltipLayer?: BeszelTooltipLayer;
}
const whenVisible = <T,>(visible: boolean, data: T | undefined) => (visible ? data : undefined);
@@ -69,15 +78,19 @@ export function BeszelStatsView({
visibility,
columns,
onSwitchToHistorical,
tooltipLayer = "background",
}: BeszelStatsViewProps) {
const t = useScopedI18n("widget.beszelSystemStats");
const locale = useCurrentIntlLocale();
const showDocker = visibility.dockerCpu || visibility.dockerMemory || visibility.dockerNetwork;
const isLive = timePeriod === "1m";
const tooltips = tooltipSets[tooltipLayer];
const { data: historicalData, error: historicalError } = clientApi.widget.beszel.getSystemStats.useQuery(
const historicalQuery = clientApi.widget.beszel.getSystemStats.useQuery(
{ integrationIds, systemId, timePeriod, includeDocker: showDocker },
{ refetchInterval: isLive ? false : 5_000, enabled: !isLive && systemId !== "" },
);
const historicalData = isLive ? historicalQuery.data : getUsableWidgetQueryData(historicalQuery);
const { data: liveData, error: liveError } = useLiveStats(integrationIds, systemId, isLive && systemId !== "");
const activeStats:
@@ -125,10 +138,25 @@ export function BeszelStatsView({
const systemStats = activeStats?.systemStats;
const containerStats = activeStats?.containerStats;
const cpuData = useSystemChartData(whenVisible(visibility.cpu, systemStats), mappers.cpu, timePeriod);
const memoryData = useSystemChartData(whenVisible(visibility.memory, systemStats), mappers.memory, timePeriod);
const diskIOData = useSystemChartData(whenVisible(visibility.diskIO, systemStats), mappers.diskIO, timePeriod);
const networkData = useSystemChartData(whenVisible(visibility.network, systemStats), mappers.network, timePeriod);
const cpuData = useSystemChartData(whenVisible(visibility.cpu, systemStats), mappers.cpu, timePeriod, locale);
const memoryData = useSystemChartData(
whenVisible(visibility.memory, systemStats),
mappers.memory,
timePeriod,
locale,
);
const diskIOData = useSystemChartData(
whenVisible(visibility.diskIO, systemStats),
mappers.diskIO,
timePeriod,
locale,
);
const networkData = useSystemChartData(
whenVisible(visibility.network, systemStats),
mappers.network,
timePeriod,
locale,
);
const efsPaths = useMemo(() => {
const paths = new Set<string>();
@@ -138,7 +166,13 @@ export function BeszelStatsView({
return [...paths];
}, [systemStats]);
const rootSeriesName = t("chart.disk.series");
const diskData = useDiskChartData(whenVisible(visibility.disk, systemStats), efsPaths, rootSeriesName, timePeriod);
const diskData = useDiskChartData(
whenVisible(visibility.disk, systemStats),
efsPaths,
rootSeriesName,
timePeriod,
locale,
);
const diskSeries = useMemo(
() => [
{ name: rootSeriesName, color: "grape.6" },
@@ -161,22 +195,23 @@ export function BeszelStatsView({
containerNames,
"cpu",
timePeriod,
locale,
);
const dockerMemoryData = useDockerChartData(
whenVisible(visibility.dockerMemory, containerStats),
containerNames,
"memory",
timePeriod,
locale,
);
const dockerNetworkData = useDockerChartData(
whenVisible(visibility.dockerNetwork, containerStats),
containerNames,
"network",
timePeriod,
locale,
);
if (historicalError) throw historicalError;
if (isLive && liveError) {
return (
<Center h={CHART_HEIGHT}>
@@ -198,7 +233,7 @@ export function BeszelStatsView({
if (!activeStats) {
const visibleChartCount = Object.values(visibility).filter(Boolean).length;
return (
<SimpleGrid cols={columns} spacing="md" aria-label="Loading system statistics">
<SimpleGrid cols={columns} spacing="md" aria-label={t("name")}>
{Array.from({ length: visibleChartCount }, (_, index) => (
<ChartSkeleton key={index} />
))}
@@ -212,7 +247,7 @@ export function BeszelStatsView({
<Stack align="center" gap="xs">
<IconServerOff size={24} opacity={0.5} />
<Text size="sm" c="dimmed">
{activeStats.error}
{t("error.internalServerError")}
</Text>
</Stack>
</Center>
@@ -231,7 +266,7 @@ export function BeszelStatsView({
series: series.cpu,
yAxisFormatter: chartAxisFormatters.percent,
yAxisDomain: CPU_Y_AXIS_DOMAIN,
tooltipProps: tooltipPercent,
tooltipProps: tooltips.percent,
}}
/>
)}
@@ -245,7 +280,7 @@ export function BeszelStatsView({
type: "stacked",
series: series.memory,
yAxisFormatter: chartAxisFormatters.gb,
tooltipProps: tooltipGB,
tooltipProps: tooltips.gigabytes,
}}
/>
)}
@@ -259,7 +294,7 @@ export function BeszelStatsView({
type: "stacked",
series: diskSeries,
yAxisFormatter: chartAxisFormatters.gb,
tooltipProps: tooltipGB,
tooltipProps: tooltips.gigabytes,
}}
/>
)}
@@ -272,7 +307,7 @@ export function BeszelStatsView({
data: diskIOData,
series: series.diskIO,
yAxisFormatter: chartAxisFormatters.rate,
tooltipProps: tooltipRate,
tooltipProps: tooltips.rate,
}}
/>
)}
@@ -285,7 +320,7 @@ export function BeszelStatsView({
data: networkData,
series: series.network,
yAxisFormatter: chartAxisFormatters.rate,
tooltipProps: tooltipRate,
tooltipProps: tooltips.rate,
}}
/>
)}
@@ -301,7 +336,7 @@ export function BeszelStatsView({
type: "stacked",
series: containerSeries,
yAxisFormatter: chartAxisFormatters.percent,
tooltipProps: tooltipPercentTotal,
tooltipProps: tooltips.percentTotal,
}}
/>
)}
@@ -315,7 +350,7 @@ export function BeszelStatsView({
type: "stacked",
series: containerSeries,
yAxisFormatter: chartAxisFormatters.bytes,
tooltipProps: tooltipBytesTotal,
tooltipProps: tooltips.bytesTotal,
}}
/>
)}
@@ -328,7 +363,7 @@ export function BeszelStatsView({
data: dockerNetworkData,
series: containerSeries,
yAxisFormatter: chartAxisFormatters.rate,
tooltipProps: tooltipRate,
tooltipProps: tooltips.rate,
}}
/>
)}
@@ -50,6 +50,7 @@ export const BeszelSystemStatsModal = createModal<BeszelSystemStatsModalProps>((
timePeriod={timePeriod}
visibility={allCharts}
columns={2}
tooltipLayer="modal"
onSwitchToHistorical={() => setTimePeriod("1h")}
/>
</Stack>
+20 -10
View File
@@ -3,10 +3,9 @@
import type { CSSProperties } from "react";
import { createPortal } from "react-dom";
import { useCallback, useEffect, useRef, useState } from "react";
import dayjs from "dayjs";
import localizedFormat from "dayjs/plugin/localizedFormat";
import { useCurrentIntlLocale, useScopedI18n } from "@homarr/translation/client";
dayjs.extend(localizedFormat);
import { formatLocalizedDate } from "../../common/locale";
const styles: Record<string, CSSProperties> = {
wrapper: {
@@ -53,11 +52,15 @@ interface PortalTooltipProps {
payload?: TooltipPayloadItem[];
formatter: (value: number) => string;
showTotal?: boolean;
layer: BeszelTooltipLayer;
}
const MARGIN = 14;
export type BeszelTooltipLayer = "background" | "modal";
const PortalTooltipContent = ({ active, label, payload, formatter, showTotal }: PortalTooltipProps) => {
const MARGIN = 14;
const PortalTooltipContent = ({ active, label, payload, formatter, showTotal, layer }: PortalTooltipProps) => {
const locale = useCurrentIntlLocale();
const t = useScopedI18n("common");
const mouseRef = useRef({ x: 0, y: 0 });
const [pos, setPos] = useState({ x: 0, y: 0 });
const rafRef = useRef(0);
@@ -70,7 +73,6 @@ const PortalTooltipContent = ({ active, label, payload, formatter, showTotal }:
const el = tooltipRef.current;
const mx = mouseRef.current.x;
const my = mouseRef.current.y;
if (!el) {
setPos({ x: mx + MARGIN, y: my });
return;
@@ -113,14 +115,17 @@ const PortalTooltipContent = ({ active, label, payload, formatter, showTotal }:
if (!sorted.length) return null;
const rawTime = sorted[0]?.payload?.rawTime as string | undefined;
const tooltipLabel = rawTime ? dayjs(rawTime).format("MMM D, LT") : label;
const tooltipLabel = rawTime
? formatLocalizedDate(rawTime, locale, { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" })
: label;
const total = sorted.reduce((sum, p) => sum + p.value, 0);
const portalStyle: CSSProperties = {
position: "fixed",
left: pos.x,
top: pos.y,
zIndex: 10000,
zIndex:
layer === "modal" ? "var(--homarr-z-index-modal-tooltip)" : "var(--homarr-z-index-widget-background-tooltip)",
pointerEvents: "none",
};
@@ -137,7 +142,7 @@ const PortalTooltipContent = ({ active, label, payload, formatter, showTotal }:
))}
{showTotal && sorted.length > 1 && (
<div style={{ ...styles.row, ...styles.separator }}>
<span style={styles.name}>Total</span>
<span style={styles.name}>{t("total")}</span>
<span style={styles.value}>{formatter(total)}</span>
</div>
)}
@@ -148,7 +153,11 @@ const PortalTooltipContent = ({ active, label, payload, formatter, showTotal }:
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const makeTooltipProps = (formatter: (v: number) => string, showTotal = false) => ({
export const makeTooltipProps = (
formatter: (v: number) => string,
showTotal = false,
layer: BeszelTooltipLayer = "background",
) => ({
content: (props: any) => (
<PortalTooltipContent
active={props.active}
@@ -156,6 +165,7 @@ export const makeTooltipProps = (formatter: (v: number) => string, showTotal = f
payload={props.payload}
formatter={formatter}
showTotal={showTotal}
layer={layer}
/>
),
wrapperStyle: { visibility: "hidden" as const, position: "absolute" as const, width: 0, height: 0 },
@@ -2,6 +2,12 @@
background-color: var(--mantine-color-primaryColor-light-hover);
}
.bookmarkButton:focus-visible {
outline: 2px solid var(--mantine-primary-color-filled);
outline-offset: 2px;
border-radius: var(--mantine-radius-sm);
}
[data-mantine-color-scheme="light"] .card-grid {
background-color: var(--mantine-color-gray-1);
}
@@ -13,3 +19,7 @@
.card:hover > div > div.bookmarkIcon {
background-color: var(--mantine-color-iconColor-filled-hover);
}
.bookmarkButton:focus-visible .bookmarkIcon {
background-color: var(--mantine-color-iconColor-filled-hover);
}
+299 -123
View File
@@ -1,6 +1,19 @@
"use client";
import { Anchor, Card, Flex, Group, Stack, Text, Title, UnstyledButton } from "@mantine/core";
import { useMemo } from "react";
import {
Anchor,
Box,
Card,
Flex,
Group,
ScrollArea,
SimpleGrid,
Stack,
Text,
Title,
UnstyledButton,
} from "@mantine/core";
import combineClasses from "clsx";
import type { RouterOutputs } from "@homarr/api";
@@ -10,67 +23,138 @@ import { useRegisterSpotlightContextResults } from "@homarr/spotlight";
import { MaskedOrNormalImage } from "@homarr/ui";
import type { WidgetComponentProps } from "../definition";
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url";
import { getUsableWidgetQueryData } from "../common/query-state";
import classes from "./bookmark.module.css";
export default function BookmarksWidget({ options, itemId }: WidgetComponentProps<"bookmarks">) {
type BookmarkLayout = WidgetComponentProps<"bookmarks">["options"]["layout"];
export interface CompactBookmarkLayout {
columns: number;
hideHostname: boolean;
hideTitle: boolean;
minimumItemSize: number;
}
export function getCompactBookmarkLayout(
width: number,
height: number,
itemCount: number,
layout: BookmarkLayout,
): CompactBookmarkLayout {
const count = Math.max(1, itemCount);
const isGrid = layout === "grid" || layout === "gridHorizontal";
const targetWidth = layout === "gridHorizontal" ? 180 : 104;
const columns = isGrid ? Math.max(1, Math.min(count, Math.floor(width / targetWidth))) : layout === "row" ? count : 1;
const rows = Math.max(1, Math.ceil(count / columns));
const cellWidth = width / columns;
const cellHeight = height / rows;
const usesHorizontalItems = layout === "column" || layout === "gridHorizontal";
return {
columns,
hideHostname: cellWidth < 120 || cellHeight < (usesHorizontalItems ? 52 : 96),
hideTitle: cellWidth < 64 || cellHeight < 40,
minimumItemSize: usesHorizontalItems ? 48 : 96,
};
}
export default function BookmarksWidget({
options,
itemId,
width,
height,
displayMode,
}: WidgetComponentProps<"bookmarks">) {
const board = useRequiredBoard();
const { data = [] } = clientApi.app.byIds.useQuery(options.items, {
select(data) {
return data.toSorted((appA, appB) => options.items.indexOf(appA.id) - options.items.indexOf(appB.id));
},
});
const data =
getUsableWidgetQueryData(
clientApi.app.byIds.useQuery(options.items, {
select(apps) {
return apps.toSorted((appA, appB) => options.items.indexOf(appA.id) - options.items.indexOf(appB.id));
},
}),
) ?? [];
useRegisterSpotlightContextResults(
`bookmark-${itemId}`,
data
.filter((app) => app.href !== null)
.map((app) => ({
id: app.id,
name: app.name,
icon: app.iconUrl,
interaction() {
return {
type: "link",
// We checked above that app.href is defined
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
href: app.href!,
newTab: false,
};
},
})),
data.flatMap((app) => {
const href = getSafeApplicationUrl(app.href);
return href
? [
{
id: app.id,
name: app.name,
icon: app.iconUrl,
interaction() {
return {
type: "link",
href,
newTab: false,
};
},
},
]
: [];
}),
[data],
);
const compactLayout = useMemo(
() => getCompactBookmarkLayout(width, height, data.length, options.layout),
[width, height, data.length, options.layout],
);
const compactHideTitle = options.hideTitle || (compactLayout.hideTitle && !options.hideIcon);
const compactHideHostname =
options.hideHostname || (compactLayout.hideHostname && !(options.hideTitle && options.hideIcon));
return (
<Stack h="100%" gap="sm" p="sm">
<Stack h="100%" mih={0} gap="sm" p={height < 120 ? "xs" : "sm"}>
{options.title.length > 0 && (
<Title order={4} px="0.25rem">
<Title order={4} px="0.25rem" lineClamp={1}>
{options.title}
</Title>
)}
{(options.layout === "grid" || options.layout === "gridHorizontal") && (
<GridLayout
{displayMode === "advanced" && (
<AdvancedBookmarksLayout
data={data}
itemDirection={options.layout === "gridHorizontal" ? "horizontal" : "vertical"}
hideTitle={options.hideTitle}
hideIcon={options.hideIcon}
hideHostname={options.hideHostname}
width={width}
openNewTab={options.openNewTab}
withBorder={options.withBorder}
hasIconColor={board.iconColor !== null}
/>
)}
{options.layout !== "grid" && options.layout !== "gridHorizontal" && (
<FlexLayout
data={data}
direction={options.layout}
hideTitle={options.hideTitle}
hideIcon={options.hideIcon}
hideHostname={options.hideHostname}
openNewTab={options.openNewTab}
withBorder={options.withBorder}
hasIconColor={board.iconColor !== null}
/>
{displayMode !== "advanced" && (
<ScrollArea type="auto" scrollbarSize={6} offsetScrollbars style={{ flex: 1, minHeight: 0 }}>
<Box mih="100%">
{options.layout === "grid" || options.layout === "gridHorizontal" ? (
<GridLayout
data={data}
columns={compactLayout.columns}
minimumItemHeight={compactLayout.minimumItemSize}
itemDirection={options.layout === "gridHorizontal" ? "horizontal" : "vertical"}
hideTitle={compactHideTitle}
hideIcon={options.hideIcon}
hideHostname={compactHideHostname}
openNewTab={options.openNewTab}
withBorder={options.withBorder}
hasIconColor={board.iconColor !== null}
/>
) : (
<FlexLayout
data={data}
direction={options.layout}
minimumItemSize={compactLayout.minimumItemSize}
hideTitle={compactHideTitle}
hideIcon={options.hideIcon}
hideHostname={compactHideHostname}
openNewTab={options.openNewTab}
withBorder={options.withBorder}
hasIconColor={board.iconColor !== null}
/>
)}
</Box>
</ScrollArea>
)}
</Stack>
);
@@ -85,6 +169,7 @@ interface FlexLayoutProps {
openNewTab: boolean;
withBorder: boolean;
hasIconColor: boolean;
minimumItemSize: number;
}
const FlexLayout = ({
@@ -96,54 +181,130 @@ const FlexLayout = ({
openNewTab,
withBorder,
hasIconColor,
minimumItemSize,
}: FlexLayoutProps) => {
const board = useRequiredBoard();
return (
<Flex direction={direction} gap="0" w="100%">
{data.map((app) => (
<div key={app.id} style={{ display: "flex", flex: "1", flexDirection: direction }}>
<UnstyledButton
component="a"
href={app.href ?? undefined}
target={openNewTab ? "_blank" : "_self"}
rel="noopener noreferrer"
key={app.id}
w="100%"
>
<Card
radius={board.itemRadius}
className={classes.card}
<Flex direction={direction} gap={4} w="100%" mih="100%" wrap="nowrap">
{data.map((app) => {
const href = getSafeApplicationUrl(app.href);
return (
<div key={app.id} style={{ display: "flex", flex: `1 0 ${minimumItemSize}px`, flexDirection: direction }}>
<UnstyledButton
className={classes.bookmarkButton}
component={href ? "a" : "div"}
href={href}
target={href ? (openNewTab ? "_blank" : "_self") : undefined}
rel={href && openNewTab ? SAFE_NEW_TAB_REL : undefined}
key={app.id}
w="100%"
display="flex"
p={4}
h="100%"
withBorder={withBorder}
>
{direction === "row" ? (
<VerticalItem
app={app}
hideTitle={hideTitle}
hideIcon={hideIcon}
hideHostname={hideHostname}
hasIconColor={hasIconColor}
/>
) : (
<HorizontalItem
app={app}
hideTitle={hideTitle}
hideIcon={hideIcon}
hideHostname={hideHostname}
hasIconColor={hasIconColor}
/>
)}
</Card>
</UnstyledButton>
</div>
))}
<Card
radius={board.itemRadius}
className={classes.card}
w="100%"
display="flex"
p={4}
h="100%"
withBorder={withBorder}
>
{direction === "row" ? (
<VerticalItem
app={app}
hideTitle={hideTitle}
hideIcon={hideIcon}
hideHostname={hideHostname}
hasIconColor={hasIconColor}
/>
) : (
<HorizontalItem
app={app}
hideTitle={hideTitle}
hideIcon={hideIcon}
hideHostname={hideHostname}
hasIconColor={hasIconColor}
/>
)}
</Card>
</UnstyledButton>
</div>
);
})}
</Flex>
);
};
interface AdvancedBookmarksLayoutProps {
data: RouterOutputs["app"]["byIds"];
width: number;
openNewTab: boolean;
withBorder: boolean;
hasIconColor: boolean;
}
export const getAdvancedBookmarkColumns = (width: number, itemCount: number): number =>
Math.max(1, Math.min(itemCount || 1, Math.floor(width / 280)));
export const getBookmarkHostname = (href: string | null): string | undefined => {
const safeHref = getSafeApplicationUrl(href);
return safeHref ? new URL(safeHref).hostname : undefined;
};
const AdvancedBookmarksLayout = ({
data,
width,
openNewTab,
withBorder,
hasIconColor,
}: AdvancedBookmarksLayoutProps) => {
const board = useRequiredBoard();
return (
<ScrollArea h="100%" style={{ flex: 1 }}>
<SimpleGrid cols={getAdvancedBookmarkColumns(width, data.length)} spacing="sm">
{data.map((app) => {
const href = getSafeApplicationUrl(app.href);
return (
<UnstyledButton
key={app.id}
className={classes.bookmarkButton}
component={href ? "a" : "div"}
href={href}
target={href ? (openNewTab ? "_blank" : "_self") : undefined}
rel={href && openNewTab ? SAFE_NEW_TAB_REL : undefined}
>
<Card radius={board.itemRadius} className={classes.card} withBorder={withBorder} p="md" h="100%">
<Group align="flex-start" wrap="nowrap">
<MaskedOrNormalImage
imageUrl={app.iconUrl}
hasColor={hasIconColor}
alt={app.name}
className={classes.bookmarkIcon}
style={{ width: 40, height: 40, flex: "0 0 auto" }}
/>
<Stack gap={3} miw={0}>
<Text fw={700} size="sm" truncate="end">
{app.name}
</Text>
<Anchor component="span" size="xs" truncate="end">
{getBookmarkHostname(app.href)}
</Anchor>
{app.description && (
<Text size="xs" c="dimmed" lineClamp={3}>
{app.description}
</Text>
)}
</Stack>
</Group>
</Card>
</UnstyledButton>
);
})}
</SimpleGrid>
</ScrollArea>
);
};
interface GridLayoutProps {
data: RouterOutputs["app"]["byIds"];
hideTitle: boolean;
@@ -153,6 +314,8 @@ interface GridLayoutProps {
withBorder: boolean;
itemDirection: "horizontal" | "vertical";
hasIconColor: boolean;
columns: number;
minimumItemHeight: number;
}
const GridLayout = ({
@@ -164,48 +327,61 @@ const GridLayout = ({
withBorder,
itemDirection,
hasIconColor,
columns,
minimumItemHeight,
}: GridLayoutProps) => {
const board = useRequiredBoard();
return (
<Flex miw="100%" gap={4} wrap="wrap" style={{ flex: 1 }}>
{data.map((app) => (
<UnstyledButton
component="a"
href={app.href ?? undefined}
target={openNewTab ? "_blank" : "_self"}
rel="noopener noreferrer"
key={app.id}
flex="1"
>
<Card
<SimpleGrid
cols={columns}
spacing={4}
verticalSpacing={4}
miw="100%"
mih="100%"
style={{ gridAutoRows: `minmax(${minimumItemHeight}px, 1fr)` }}
>
{data.map((app) => {
const href = getSafeApplicationUrl(app.href);
return (
<UnstyledButton
className={classes.bookmarkButton}
component={href ? "a" : "div"}
href={href}
target={href ? (openNewTab ? "_blank" : "_self") : undefined}
rel={href && openNewTab ? SAFE_NEW_TAB_REL : undefined}
key={app.id}
h="100%"
className={combineClasses(classes.card, classes["card-grid"])}
radius={board.itemRadius}
withBorder={withBorder}
p="xs"
>
{itemDirection === "horizontal" ? (
<HorizontalItem
app={app}
hideTitle={hideTitle}
hideIcon={hideIcon}
hideHostname={hideHostname}
hasIconColor={hasIconColor}
/>
) : (
<VerticalItem
app={app}
hideTitle={hideTitle}
hideIcon={hideIcon}
hideHostname={hideHostname}
hasIconColor={hasIconColor}
/>
)}
</Card>
</UnstyledButton>
))}
</Flex>
<Card
h="100%"
className={combineClasses(classes.card, classes["card-grid"])}
radius={board.itemRadius}
withBorder={withBorder}
p="xs"
>
{itemDirection === "horizontal" ? (
<HorizontalItem
app={app}
hideTitle={hideTitle}
hideIcon={hideIcon}
hideHostname={hideHostname}
hasIconColor={hasIconColor}
/>
) : (
<VerticalItem
app={app}
hideTitle={hideTitle}
hideIcon={hideIcon}
hideHostname={hideHostname}
hasIconColor={hasIconColor}
/>
)}
</Card>
</UnstyledButton>
);
})}
</SimpleGrid>
);
};
@@ -225,7 +401,7 @@ const VerticalItem = ({
return (
<Stack h="100%" miw={16} gap="sm" justify={"center"}>
{!hideTitle && (
<Text fw={700} ta="center" size="xs">
<Text fw={700} ta="center" size="xs" lineClamp={2}>
{app.name}
</Text>
)}
@@ -246,8 +422,8 @@ const VerticalItem = ({
/>
)}
{!hideHostname && (
<Anchor ta="center" component="span" size="xs">
{app.href ? new URL(app.href).hostname : undefined}
<Anchor ta="center" component="span" size="xs" truncate="end" w="100%">
{getBookmarkHostname(app.href)}
</Anchor>
)}
</Stack>
@@ -285,7 +461,7 @@ const HorizontalItem = ({
)}
{!(hideTitle && hideHostname) && (
<>
<Stack justify="space-between" gap={0}>
<Stack justify="space-between" gap={0} miw={0} style={{ flex: 1 }}>
{!hideTitle && (
<Text fw={700} size="xs" lineClamp={hideHostname ? 2 : 1}>
{app.name}
@@ -293,8 +469,8 @@ const HorizontalItem = ({
)}
{!hideHostname && (
<Anchor component="span" size="xs">
{app.href ? new URL(app.href).hostname : undefined}
<Anchor component="span" size="xs" truncate="end">
{getBookmarkHostname(app.href)}
</Anchor>
)}
</Stack>
+1
View File
@@ -10,6 +10,7 @@ import { BookmarkAddButton } from "./add-button";
export const { definition, componentLoader } = createWidgetDefinition("bookmarks", {
icon: IconBookmark,
queryKey: [["app", "byIds"]],
createOptions() {
return optionsBuilder.from((factory) => ({
title: factory.text(),
@@ -0,0 +1,33 @@
import { describe, expect, test } from "vitest";
import { getAdvancedBookmarkColumns, getBookmarkHostname, getCompactBookmarkLayout } from "./component";
describe("bookmark display helpers", () => {
test("uses bounded advanced columns", () => {
expect(getAdvancedBookmarkColumns(900, 8)).toBe(3);
expect(getAdvancedBookmarkColumns(120, 8)).toBe(1);
expect(getAdvancedBookmarkColumns(900, 0)).toBe(1);
});
test("uses container geometry to keep compact cards legible", () => {
expect(getCompactBookmarkLayout(420, 220, 4, "gridHorizontal")).toMatchObject({
columns: 2,
hideHostname: false,
hideTitle: false,
minimumItemSize: 48,
});
expect(getCompactBookmarkLayout(180, 80, 4, "row")).toMatchObject({
columns: 4,
hideHostname: true,
hideTitle: true,
minimumItemSize: 96,
});
});
test("does not crash on malformed bookmark URLs", () => {
expect(getBookmarkHostname("https://homarr.dev/docs")).toBe("homarr.dev");
expect(getBookmarkHostname("not a URL")).toBeUndefined();
expect(getBookmarkHostname(null)).toBeUndefined();
});
});
+2 -2
View File
@@ -1,9 +1,9 @@
import { trpc } from "@homarr/api/server";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { db, inArray } from "@homarr/db";
import { apps } from "@homarr/db/schema";
import type { Prefetch } from "../definition";
import { createTrpcQueryKey } from "../trpc-query-key";
const logger = createLogger({ module: "bookmarksWidgetPrefetch" });
@@ -21,7 +21,7 @@ const prefetchAllAsync: Prefetch<"bookmarks"> = async (queryClient, items) => {
}
queryClient.setQueryData(
trpc.app.byIds.queryKey(item.options.items),
createTrpcQueryKey("app.byIds", item.options.items),
dbApps.filter((app) => item.options.items.includes(app.id)),
);
}
@@ -1,3 +1,4 @@
import { useId } from "react";
import {
Badge,
Box,
@@ -16,122 +17,173 @@ import dayjs from "dayjs";
import { isNullOrWhitespace } from "@homarr/common";
import type { CalendarEvent } from "@homarr/integrations/types";
import { useI18n } from "@homarr/translation/client";
import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
import { groupEventsByDate } from "./calendar-events";
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url";
import { formatLocalizedTime } from "../common/locale";
import classes from "./calendar-event-list.module.css";
interface CalendarEventListProps {
events: CalendarEvent[];
fillHeight?: boolean;
groupByDate?: boolean;
locale?: string;
}
export const CalendarEventList = ({ events }: CalendarEventListProps) => {
const { colorScheme } = useMantineColorScheme();
const t = useI18n();
export const CalendarEventList = ({
events,
fillHeight = false,
groupByDate = false,
locale,
}: CalendarEventListProps) => {
const headingIdPrefix = useId();
const currentLocale = useCurrentIntlLocale();
const effectiveLocale = locale ?? currentLocale;
const dateFormatter = new Intl.DateTimeFormat(effectiveLocale, {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
});
const groups = groupByDate ? [...groupEventsByDate(events).entries()] : null;
return (
<ScrollArea
offsetScrollbars
pt={5}
w="100%"
h={fillHeight ? "100%" : undefined}
styles={{
viewport: {
maxHeight: 450,
maxHeight: fillHeight ? undefined : 450,
},
}}
>
<Stack>
{events.map((event, eventIndex) => (
<Group key={`event-${eventIndex}`} align={"stretch"} wrap="nowrap">
{event.image !== null && (
<Box pos="relative">
<Image
src={event.image.src}
w={70}
mah={150}
style={{
aspectRatio: event.image.aspectRatio
? `${event.image.aspectRatio.width} / ${event.image.aspectRatio.height}`
: "1/1",
}}
radius="sm"
fallbackSrc="https://placehold.co/400x400?text=No%20image"
/>
{event.image.badge !== undefined && (
<Badge pos="absolute" bottom={-6} left="50%" w="90%" className={classes.badge}>
{event.image.badge.content}
</Badge>
)}
</Box>
)}
<Stack style={{ flexGrow: 1 }} gap={0}>
<Group justify="space-between" align="start" mb="xs" wrap="nowrap">
<Stack gap={0}>
{event.subTitle !== null && (
<Text lineClamp={1} size="sm">
{event.subTitle}
</Text>
)}
<Text fw={"bold"} lineClamp={1} size="sm">
{event.title}
</Text>
</Stack>
{event.metadata?.type === "radarr" && (
<Group wrap="nowrap">
<Text c="dimmed" size="sm">
{t(`widget.calendar.option.releaseType.options.${event.metadata.releaseType}`)}
</Text>
</Group>
)}
<Group gap={3} wrap="nowrap" align={"center"}>
<IconClock opacity={0.7} size={"1rem"} />
{isAllDay(event) ? (
<Text c={"dimmed"} size={"sm"}>
{t("widget.calendar.duration.allDay")}
</Text>
) : (
<>
<Text c={"dimmed"} size={"sm"}>
{dayjs(event.startDate).format("HH:mm")}
</Text>
{event.endDate !== null && (
<>
-{" "}
<Text c={"dimmed"} size={"sm"}>
{dayjs(event.endDate).format("HH:mm")}
</Text>
</>
)}
</>
)}
</Group>
</Group>
{event.location !== null && (
<Group gap={4} mb={isNullOrWhitespace(event.description) ? 0 : "sm"}>
<IconPin opacity={0.7} size={"1rem"} />
<Text size={"xs"} c={"dimmed"} lineClamp={1}>
{event.location}
</Text>
</Group>
)}
{!isNullOrWhitespace(event.description) && (
<Text size={"xs"} c={"dimmed"} lineClamp={2}>
{event.description}
<Stack gap="md">
{groups ? (
groups.map(([date, groupedEvents]) => {
const headingId = `${headingIdPrefix}-${date}`;
return (
<Box key={date} component="section" aria-labelledby={headingId}>
<Text id={headingId} component="h3" size="sm" fw={600} tt="capitalize">
{dateFormatter.format(groupedEvents[0]?.startDate)}
</Text>
<Stack mt="xs">
<CalendarEventRows events={groupedEvents} locale={effectiveLocale} />
</Stack>
</Box>
);
})
) : (
<CalendarEventRows events={events} locale={effectiveLocale} />
)}
</Stack>
</ScrollArea>
);
};
const CalendarEventRows = ({ events, locale }: Pick<CalendarEventListProps, "events"> & { locale: string }) => {
const { colorScheme } = useMantineColorScheme();
const t = useI18n();
return (
<>
{events.map((event, eventIndex) => (
<Group key={`event-${eventIndex}`} align={"stretch"} wrap="nowrap">
{event.image !== null && (
<Box pos="relative">
<Image
src={event.image.src}
w={70}
mah={150}
style={{
aspectRatio: event.image.aspectRatio
? `${event.image.aspectRatio.width} / ${event.image.aspectRatio.height}`
: "1/1",
}}
radius="sm"
fallbackSrc="https://placehold.co/400x400?text=No%20image"
/>
{event.image.badge !== undefined && (
<Badge pos="absolute" bottom={-6} left="50%" w="90%" className={classes.badge}>
{event.image.badge.content}
</Badge>
)}
</Box>
)}
<Stack style={{ flexGrow: 1, minWidth: 0 }} gap={0}>
<Group justify="space-between" align="start" mb="xs" wrap="wrap">
<Stack gap={0} style={{ flex: "1 1 12rem", minWidth: 0 }}>
{event.subTitle !== null && (
<Text lineClamp={1} size="sm">
{event.subTitle}
</Text>
)}
<Text fw={"bold"} lineClamp={1} size="sm">
{event.title}
</Text>
</Stack>
{event.metadata?.type === "radarr" && (
<Group wrap="nowrap">
<Text c="dimmed" size="sm">
{t(`widget.calendar.option.releaseType.options.${event.metadata.releaseType}`)}
</Text>
</Group>
)}
{event.links.length > 0 && (
<Group pt={5} gap={5} mt={"auto"} wrap="nowrap">
{event.links
.filter((link) => link.href)
.map((link) => (
<Group gap={3} wrap="nowrap" align={"center"} ml="auto">
<IconClock opacity={0.7} size={"1rem"} />
{isAllDay(event) ? (
<Text c={"dimmed"} size={"sm"}>
{t("widget.calendar.duration.allDay")}
</Text>
) : (
<>
<Text c={"dimmed"} size={"sm"}>
{formatLocalizedTime(event.startDate, locale)}
</Text>
{event.endDate !== null && (
<>
-{" "}
<Text c={"dimmed"} size={"sm"}>
{formatLocalizedTime(event.endDate, locale)}
</Text>
</>
)}
</>
)}
</Group>
</Group>
{event.location !== null && (
<Group gap={4} mb={isNullOrWhitespace(event.description) ? 0 : "sm"}>
<IconPin opacity={0.7} size={"1rem"} />
<Text size={"xs"} c={"dimmed"} lineClamp={1}>
{event.location}
</Text>
</Group>
)}
{!isNullOrWhitespace(event.description) && (
<Text size={"xs"} c={"dimmed"} lineClamp={2}>
{event.description}
</Text>
)}
{event.links.length > 0 && (
<Group pt={5} gap={5} mt={"auto"} wrap="nowrap">
{event.links
.filter((link) => link.href)
.map((link) => {
const href = getSafeApplicationUrl(link.href.toString());
return (
<Button
key={link.href}
component={"a"}
href={link.href.toString()}
target={"_blank"}
component={href ? "a" : "div"}
href={href}
target={href ? "_blank" : undefined}
rel={href ? SAFE_NEW_TAB_REL : undefined}
size={"xs"}
radius={"xl"}
variant={link.color ? undefined : "default"}
@@ -150,14 +202,14 @@ export const CalendarEventList = ({ events }: CalendarEventListProps) => {
>
<Text>{link.name}</Text>
</Button>
))}
</Group>
)}
</Stack>
</Group>
))}
</Stack>
</ScrollArea>
);
})}
</Group>
)}
</Stack>
</Group>
))}
</>
);
};
@@ -0,0 +1,80 @@
import dayjs from "dayjs";
import type { CalendarEvent } from "@homarr/integrations/types";
export const toCalendarDateKey = (date: Date) => dayjs(date).format("YYYY-MM-DD");
export const moveCalendarMonth = (month: Date, amount: number): Date =>
dayjs(month).startOf("month").add(amount, "month").toDate();
const matchesReleaseType = (event: CalendarEvent, releaseTypes: readonly string[]) =>
event.metadata?.type !== "radarr" || releaseTypes.includes(event.metadata.releaseType);
export const getCalendarAgendaEvents = (
events: CalendarEvent[],
viewedMonth: Date,
releaseTypes: readonly string[],
): CalendarEvent[] => {
const monthStart = dayjs(viewedMonth).startOf("month");
const monthEnd = monthStart.add(1, "month");
return events
.filter((event) => {
if (!matchesReleaseType(event, releaseTypes)) return false;
const start = dayjs(event.startDate);
if (!event.endDate) return !start.isBefore(monthStart) && start.isBefore(monthEnd);
// Calendar intervals are half-open: an all-day event ending at midnight does not occupy the next day.
return start.isBefore(monthEnd) && dayjs(event.endDate).isAfter(monthStart);
})
.toSorted((eventA, eventB) => eventA.startDate.getTime() - eventB.startDate.getTime());
};
export const groupEventsByDate = (events: CalendarEvent[]): Map<string, CalendarEvent[]> => {
const result = new Map<string, CalendarEvent[]>();
for (const event of events) {
const key = toCalendarDateKey(event.startDate);
const entries = result.get(key) ?? [];
entries.push(event);
result.set(key, entries);
}
return new Map(
[...result.entries()]
.toSorted(([dateA], [dateB]) => dateA.localeCompare(dateB))
.map(([date, entries]) => [
date,
entries.toSorted((eventA, eventB) => eventA.startDate.getTime() - eventB.startDate.getTime()),
]),
);
};
/** Splits multi-day events into single-day calendar tiles. */
export const splitEvents = (events: CalendarEvent[]): CalendarEvent[] => {
const splitEventEntries: CalendarEvent[] = [];
for (const event of events) {
if (!event.endDate) {
splitEventEntries.push(event);
continue;
}
if (dayjs(event.startDate).isSame(event.endDate, "day")) {
splitEventEntries.push(event);
continue;
}
if (dayjs(event.startDate).isAfter(event.endDate)) continue;
let currentStart = dayjs(event.startDate);
while (currentStart.isBefore(event.endDate)) {
splitEventEntries.push({
...event,
startDate: currentStart.toDate(),
endDate: currentStart.endOf("day").isAfter(event.endDate) ? event.endDate : currentStart.endOf("day").toDate(),
});
currentStart = currentStart.add(1, "day").startOf("day");
}
}
return splitEventEntries;
};
+47 -1
View File
@@ -2,7 +2,7 @@ import { describe, expect, test } from "vitest";
import type { CalendarEvent } from "@homarr/integrations/types";
import { splitEvents } from "./component";
import { getCalendarAgendaEvents, groupEventsByDate, moveCalendarMonth, splitEvents } from "./calendar-events";
describe("splitEvents should split multi-day events into multiple single-day events", () => {
test("2 day all-day event should be split up into two all-day events", () => {
@@ -53,6 +53,52 @@ describe("splitEvents should split multi-day events into multiple single-day eve
});
});
describe("groupEventsByDate", () => {
test("groups once per day and sorts each bucket", () => {
const later = createEvent(new Date(2025, 0, 1, 18));
const earlier = createEvent(new Date(2025, 0, 1, 9));
const nextDay = createEvent(new Date(2025, 0, 2, 9));
const result = groupEventsByDate([later, nextDay, earlier]);
expect(result.size).toBe(2);
expect([...result.values()][0]).toEqual([earlier, later]);
});
});
describe("getCalendarAgendaEvents", () => {
test("keeps overlapping original events once and excludes adjacent-month events", () => {
const spansIntoMonth = createEvent(new Date(2024, 11, 31, 22), new Date(2025, 0, 2, 8));
const inMonth = createEvent(new Date(2025, 0, 20, 9));
const endsAtMonthStart = createEvent(new Date(2024, 11, 31), new Date(2025, 0, 1));
const nextMonth = createEvent(new Date(2025, 1, 1));
const result = getCalendarAgendaEvents(
[nextMonth, spansIntoMonth, endsAtMonthStart, inMonth],
new Date(2025, 0, 15),
[],
);
expect(result).toEqual([spansIntoMonth, inMonth]);
});
test("applies Radarr release filters without splitting multi-day events", () => {
const cinema = createEvent(new Date(2025, 0, 5), new Date(2025, 0, 8));
cinema.metadata = { type: "radarr", releaseType: "inCinemas" };
const digital = createEvent(new Date(2025, 0, 7));
digital.metadata = { type: "radarr", releaseType: "digitalRelease" };
expect(getCalendarAgendaEvents([cinema, digital], new Date(2025, 0, 1), ["inCinemas"])).toEqual([cinema]);
});
});
describe("moveCalendarMonth", () => {
test("uses the first day so short months do not skip", () => {
expect(moveCalendarMonth(new Date(2025, 0, 31), 1)).toEqual(new Date(2025, 1, 1));
expect(moveCalendarMonth(new Date(2025, 2, 31), -1)).toEqual(new Date(2025, 1, 1));
});
});
const createEvent = (startDate: Date, endDate: Date | null = null): CalendarEvent => ({
title: "Test",
subTitle: null,
+258 -122
View File
@@ -1,26 +1,62 @@
"use client";
import { useMemo, useState } from "react";
import { useParams } from "next/navigation";
import { useMantineTheme } from "@mantine/core";
import { ActionIcon, Box, Center, Group, Loader, Stack, Text, Tooltip, useMantineTheme } from "@mantine/core";
import { Calendar } from "@mantine/dates";
import { useElementSize } from "@mantine/hooks";
import { IconChevronLeft, IconChevronRight } from "@tabler/icons-react";
import { getQueryKey } from "@trpc/react-query";
import dayjs from "dayjs";
import { clientApi } from "@homarr/api/client";
import { useRequiredBoard } from "@homarr/boards/context";
import type { CalendarEvent } from "@homarr/integrations/types";
import { useSettings } from "@homarr/settings";
import { useCurrentIntlLocale, useScopedI18n } from "@homarr/translation/client";
import actionTargetClasses from "../common/action-target.module.css";
import type { WidgetComponentProps } from "../definition";
import { getUsableWidgetQueryData } from "../common/query-state";
import { useWidgetRuntimeQueries } from "../runtime-hooks";
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
import { CalendarDay } from "./calender-day";
import {
getCalendarAgendaEvents,
groupEventsByDate,
moveCalendarMonth,
splitEvents,
toCalendarDateKey,
} from "./calendar-events";
import { CalendarEventList } from "./calendar-event-list";
import classes from "./component.module.css";
export default function CalendarWidget(props: WidgetComponentProps<"calendar">) {
const [month, setMonth] = useState(new Date());
const [month, setMonth] = useState(() => new Date());
const runtimeInput = {
integrationIds: props.integrationIds,
month: month.getMonth(),
year: month.getFullYear(),
releaseType: props.options.releaseType,
showUnmonitored: props.options.showUnmonitored,
};
useWidgetRuntimeQueries(
props.widgetRuntimeRef,
props.integrationIds.length > 0
? [getQueryKey(clientApi.widget.calendar.findAllEvents, runtimeInput, "query")]
: [],
);
if (props.integrationIds.length === 0) {
return <CalendarBase {...props} events={[]} month={month} setMonth={setMonth} />;
return (
<CalendarBase
{...props}
events={[]}
failedIntegrations={[]}
isPending={false}
month={month}
setMonth={setMonth}
/>
);
}
return <FetchCalendar month={month} setMonth={setMonth} {...props} />;
@@ -31,7 +67,7 @@ interface FetchCalendarProps extends WidgetComponentProps<"calendar"> {
setMonth: (date: Date) => void;
}
const FetchCalendar = ({ month, setMonth, isEditMode, integrationIds, options }: FetchCalendarProps) => {
const FetchCalendar = ({ month, setMonth, isEditMode, integrationIds, options, displayMode }: FetchCalendarProps) => {
const input = {
integrationIds,
month: month.getMonth() + 1,
@@ -39,24 +75,52 @@ const FetchCalendar = ({ month, setMonth, isEditMode, integrationIds, options }:
releaseType: options.releaseType,
showUnmonitored: options.showUnmonitored,
};
const { data } = clientApi.widget.calendar.findAllEvents.useQuery(input);
const calendarQuery = clientApi.widget.calendar.findAllEvents.useQuery(input);
const data = getUsableWidgetQueryData(calendarQuery);
const { isPending } = calendarQuery;
const events = useMemo(() => data?.flatMap((item) => item.events) ?? [], [data]);
const failedIntegrations =
data?.flatMap(({ integration, error }) =>
error ? [{ integrationId: integration.id, integrationName: integration.name, error }] : [],
) ?? [];
return <CalendarBase isEditMode={isEditMode} events={events} month={month} setMonth={setMonth} options={options} />;
return (
<CalendarBase
isEditMode={isEditMode}
events={events}
failedIntegrations={failedIntegrations}
isPending={isPending}
month={month}
setMonth={setMonth}
options={options}
displayMode={displayMode}
/>
);
};
interface CalendarBaseProps {
isEditMode: boolean;
events: CalendarEvent[];
failedIntegrations: { integrationId: string; integrationName: string; error: string }[];
isPending: boolean;
month: Date;
setMonth: (date: Date) => void;
options: WidgetComponentProps<"calendar">["options"];
displayMode?: WidgetComponentProps<"calendar">["displayMode"];
}
const CalendarBase = ({ isEditMode, events, month, setMonth, options }: CalendarBaseProps) => {
const params = useParams();
const locale = params.locale as string;
const CalendarBase = ({
isEditMode,
events,
failedIntegrations,
isPending,
month,
setMonth,
options,
displayMode,
}: CalendarBaseProps) => {
const locale = useCurrentIntlLocale();
const { firstDayOfWeek } = useSettings();
const board = useRequiredBoard();
const mantineTheme = useMantineTheme();
@@ -64,125 +128,197 @@ const CalendarBase = ({ isEditMode, events, month, setMonth, options }: Calendar
const { ref, width, height } = useElementSize();
const isSmall = width < 256;
const normalizedEvents = useMemo(() => splitEvents(events), [events]);
const normalizedEvents = useMemo(
() => (displayMode === "advanced" ? [] : splitEvents(events)),
[displayMode, events],
);
const visibleEvents = useMemo(
() =>
normalizedEvents.filter(
(event) => event.metadata?.type !== "radarr" || options.releaseType.includes(event.metadata.releaseType),
),
[normalizedEvents, options.releaseType],
);
const eventsByDate = useMemo(() => groupEventsByDate(visibleEvents), [visibleEvents]);
const agendaEvents = useMemo(
() => (displayMode === "advanced" ? getCalendarAgendaEvents(events, month, options.releaseType) : []),
[displayMode, events, month, options.releaseType],
);
if (displayMode === "advanced") {
return (
<CalendarAgenda
events={agendaEvents}
failedIntegrations={failedIntegrations}
isPending={isPending}
isEditMode={isEditMode}
locale={locale}
month={month}
setMonth={setMonth}
/>
);
}
return (
<Calendar
defaultDate={new Date()}
onPreviousMonth={(month) => setMonth(new Date(month))}
onNextMonth={(month) => setMonth(new Date(month))}
highlightToday
locale={locale}
hideWeekdays={false}
date={month}
maxLevel="month"
firstDayOfWeek={firstDayOfWeek}
static={isEditMode}
className={classes.calendar}
w="100%"
h="100%"
ref={ref}
styles={{
calendarHeaderControl: {
pointerEvents: isEditMode ? "none" : undefined,
borderRadius: "md",
height: isSmall ? "1.5rem" : undefined,
width: isSmall ? "1.5rem" : undefined,
},
calendarHeaderLevel: {
pointerEvents: "none",
fontSize: isSmall ? "0.75rem" : undefined,
height: "100%",
},
levelsGroup: {
height: "100%",
padding: "md",
},
calendarHeader: {
maxWidth: "unset",
marginBottom: 0,
},
monthCell: {
textAlign: "center",
position: "relative",
},
day: {
borderRadius: actualItemRadius,
width: "100%",
height: "100%",
position: "absolute",
top: 0,
left: 0,
bottom: 0,
right: 0,
},
month: {
height: "100%",
},
weekday: {
padding: 0,
},
weekdaysRow: {
height: 22,
},
}}
renderDay={(tileDate) => {
const eventsForDate = normalizedEvents
.filter((event) => dayjs(event.startDate).isSame(tileDate, "day"))
.filter(
(event) => event.metadata?.type !== "radarr" || options.releaseType.includes(event.metadata.releaseType),
)
.toSorted((eventA, eventB) => eventA.startDate.getTime() - eventB.startDate.getTime());
<Stack ref={ref} h="100%" w="100%" gap="xs" style={{ overflow: "hidden" }}>
{failedIntegrations.length > 0 && (
<Group px="xs" justify="flex-end">
<IntegrationErrorIndicator results={failedIntegrations} />
</Group>
)}
<Calendar
defaultDate={new Date()}
onPreviousMonth={(previousMonth) => setMonth(new Date(previousMonth))}
onNextMonth={(nextMonth) => setMonth(new Date(nextMonth))}
highlightToday
locale={locale}
hideWeekdays={false}
date={month}
maxLevel="month"
firstDayOfWeek={firstDayOfWeek}
static={isEditMode}
className={classes.calendar}
w="100%"
h="100%"
styles={{
calendarHeaderControl: {
pointerEvents: isEditMode ? "none" : undefined,
borderRadius: "md",
height: isSmall ? "1.5rem" : undefined,
width: isSmall ? "1.5rem" : undefined,
},
calendarHeaderLevel: {
pointerEvents: "none",
fontSize: isSmall ? "0.75rem" : undefined,
height: "100%",
},
levelsGroup: {
height: "100%",
padding: "md",
},
calendarHeader: {
maxWidth: "unset",
marginBottom: 0,
},
monthCell: {
textAlign: "center",
position: "relative",
},
day: {
borderRadius: actualItemRadius,
width: "100%",
height: "100%",
position: "absolute",
top: 0,
left: 0,
bottom: 0,
right: 0,
},
month: {
height: "100%",
},
weekday: {
padding: 0,
},
weekdaysRow: {
height: 22,
},
}}
renderDay={(tileDate) => {
const eventsForDate = eventsByDate.get(toCalendarDateKey(dayjs(tileDate).toDate())) ?? [];
return (
<CalendarDay
date={dayjs(tileDate).toDate()}
events={eventsForDate}
disabled={isEditMode || eventsForDate.length === 0}
rootWidth={width}
rootHeight={height}
/>
);
}}
/>
return (
<CalendarDay
date={dayjs(tileDate).toDate()}
events={eventsForDate}
disabled={isEditMode || eventsForDate.length === 0}
rootWidth={width}
rootHeight={height}
/>
);
}}
/>
</Stack>
);
};
/**
* Splits multi-day events into multiple single-day events.
* @param events The events to split.
* @returns The split events.
*/
export const splitEvents = (events: CalendarEvent[]): CalendarEvent[] => {
const splitEvents: CalendarEvent[] = [];
for (const event of events) {
if (!event.endDate) {
splitEvents.push(event);
continue;
}
interface CalendarAgendaProps {
events: CalendarEvent[];
failedIntegrations: CalendarBaseProps["failedIntegrations"];
isPending: boolean;
isEditMode: boolean;
locale: string;
month: Date;
setMonth: (date: Date) => void;
}
if (dayjs(event.startDate).isSame(event.endDate, "day")) {
splitEvents.push(event);
continue;
}
const CalendarAgenda = ({
events,
failedIntegrations,
isPending,
isEditMode,
locale,
month,
setMonth,
}: CalendarAgendaProps) => {
const t = useScopedI18n("widget.calendar.advanced");
const monthLabel = new Intl.DateTimeFormat(locale, { month: "long", year: "numeric" }).format(month);
if (dayjs(event.startDate).isAfter(event.endDate)) {
// Invalid event, skip it
continue;
}
return (
<Stack h="100%" w="100%" gap="sm" p="md" style={{ overflow: "hidden" }}>
<Group justify="space-between" align="center" wrap="nowrap">
<Group gap="xs" wrap="nowrap">
<Tooltip label={t("previousMonth")} events={{ hover: true, focus: true, touch: false }}>
<ActionIcon
className={actionTargetClasses.root}
variant="subtle"
size="lg"
aria-label={t("previousMonth")}
disabled={isEditMode}
onClick={() => setMonth(moveCalendarMonth(month, -1))}
>
<IconChevronLeft size={18} aria-hidden />
</ActionIcon>
</Tooltip>
<Stack gap={0} miw={0}>
<Text component="h3" size="sm" fw={600} tt="capitalize" lineClamp={1}>
{monthLabel}
</Text>
<Text size="xs" c="dimmed">
{isPending ? t("loading") : t("eventCount", { count: events.length })}
</Text>
</Stack>
<Tooltip label={t("nextMonth")} events={{ hover: true, focus: true, touch: false }}>
<ActionIcon
className={actionTargetClasses.root}
variant="subtle"
size="lg"
aria-label={t("nextMonth")}
disabled={isEditMode}
onClick={() => setMonth(moveCalendarMonth(month, 1))}
>
<IconChevronRight size={18} aria-hidden />
</ActionIcon>
</Tooltip>
</Group>
{failedIntegrations.length > 0 && <IntegrationErrorIndicator results={failedIntegrations} />}
</Group>
// Event spans multiple days, split it
let currentStart = dayjs(event.startDate);
while (currentStart.isBefore(event.endDate)) {
splitEvents.push({
...event,
startDate: currentStart.toDate(),
endDate: currentStart.endOf("day").isAfter(event.endDate) ? event.endDate : currentStart.endOf("day").toDate(),
});
currentStart = currentStart.add(1, "day").startOf("day");
}
}
return splitEvents;
<Box style={{ flex: 1, minHeight: 0 }}>
{isPending ? (
<Center component="output" h="100%" aria-label={t("loading")}>
<Loader size="sm" />
</Center>
) : events.length > 0 ? (
<CalendarEventList events={events} groupByDate locale={locale} fillHeight />
) : (
<Stack h="100%" align="center" justify="center" p="md">
<Text c="dimmed" size="sm" ta="center">
{t("noEvents")}
</Text>
</Stack>
)}
</Box>
</Stack>
);
};
+3 -1
View File
@@ -4,11 +4,13 @@ import { z } from "zod/v4";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { radarrReleaseTypes } from "@homarr/integrations/types";
import { createWidgetDefinition } from "../definition";
import { createWidgetDefinition, matchesWidgetRuntimeQuery } from "../definition";
import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("calendar", {
icon: IconCalendar,
queryKey: [["widget", "calendar", "findAllEvents"]],
queryMatcher: matchesWidgetRuntimeQuery,
createOptions() {
return optionsBuilder.from((factory) => ({
releaseType: factory.multiSelect({
+112
View File
@@ -0,0 +1,112 @@
import {
IconActivityHeartbeat,
IconAd,
IconApi,
IconApps,
IconArchive,
IconBadgeCc,
IconBatteryCharging,
IconBell,
IconBinaryTree,
IconBookmark,
IconBrandDocker,
IconBrandMinecraft,
IconBrowser,
IconBuildingBank,
IconBusStop,
IconCalendar,
IconChartAreaLine,
IconChartBar,
IconClock,
IconCloud,
IconDeviceCctv,
IconDeviceGamepad,
IconDownload,
IconFileText,
IconGraphFilled,
IconHeadphones,
IconHeartRateMonitor,
IconHeartbeat,
IconLayoutGrid,
IconMessage,
IconMovie,
IconNotes,
IconPhoto,
IconReportSearch,
IconRocket,
IconRobot,
IconRoute,
IconRss,
IconServer2,
IconShieldCheck,
IconShieldLock,
IconSpeedboat,
IconTable,
IconTicket,
IconTopologyFull,
IconTransform,
IconVideo,
IconWall,
IconZoomQuestion,
} from "@tabler/icons-react";
import type { WidgetKind } from "@homarr/definitions";
import type { TablerIcon } from "@homarr/ui";
export const widgetCatalogIcons: Record<WidgetKind, TablerIcon> = {
clock: IconClock,
weather: IconCloud,
app: IconApps,
assistant: IconRobot,
iframe: IconBrowser,
video: IconDeviceCctv,
notebook: IconNotes,
anchorNote: IconNotes,
dnsHoleSummary: IconAd,
dnsHoleControls: IconDeviceGamepad,
"smartHome-entityState": IconBinaryTree,
"smartHome-executeAutomation": IconBinaryTree,
stockPrice: IconBuildingBank,
mediaServer: IconVideo,
calendar: IconCalendar,
downloads: IconDownload,
"mediaRequests-requestList": IconZoomQuestion,
"mediaRequests-requestStats": IconChartBar,
mediaTranscoding: IconTransform,
mediaMissing: IconMovie,
minecraftServerStatus: IconBrandMinecraft,
networkControllerSummary: IconTopologyFull,
networkControllerStatus: IconTopologyFull,
rssFeed: IconRss,
bookmarks: IconBookmark,
indexerManager: IconReportSearch,
healthMonitoring: IconHeartRateMonitor,
releases: IconRocket,
mediaReleases: IconTicket,
dockerContainers: IconBrandDocker,
firewall: IconWall,
notifications: IconMessage,
systemResources: IconGraphFilled,
coolify: IconCloud,
systemDisks: IconServer2,
timetable: IconBusStop,
"immich-serverStats": IconGraphFilled,
"immich-albumCarousel": IconPhoto,
paperlessNgx: IconFileText,
patchmon: IconShieldCheck,
bazarr: IconBadgeCc,
tracearr: IconActivityHeartbeat,
speedtestTracker: IconSpeedboat,
uptimeKuma: IconHeartbeat,
audioStats: IconHeadphones,
umami: IconChartBar,
vpn: IconShieldLock,
archiveTeamWarrior: IconArchive,
ups: IconBatteryCharging,
beszelSystemTable: IconTable,
beszelSystemGrid: IconLayoutGrid,
beszelAlerts: IconBell,
beszelSystemStats: IconChartAreaLine,
traefik: IconRoute,
customApi: IconApi,
};
+43 -9
View File
@@ -1,14 +1,18 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { Box, Stack, Text, Title } from "@mantine/core";
import { Box, Loader, Stack, Text, Title } from "@mantine/core";
import dayjs from "dayjs";
import advancedFormat from "dayjs/plugin/advancedFormat";
import timezones from "dayjs/plugin/timezone";
import utc from "dayjs/plugin/utc";
import { clientApi } from "@homarr/api/client";
import { metricToImperial } from "@homarr/common";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { isInitialWidgetQueryPending } from "../common/query-state";
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
import type { WidgetComponentProps } from "../definition";
import { AnimatedWeatherIcon } from "../weather/animated-icon";
@@ -16,7 +20,7 @@ dayjs.extend(advancedFormat);
dayjs.extend(utc);
dayjs.extend(timezones);
export default function ClockWidget({ options, width }: WidgetComponentProps<"clock">) {
export default function ClockWidget({ options, width, height, displayMode }: WidgetComponentProps<"clock">) {
const secondsFormat = options.showSeconds ? ":ss" : "";
const timeFormat = options.is24HourFormat ? `HH:mm${secondsFormat}` : `hh:mm${secondsFormat} A`;
const dateFormat = options.dateFormat;
@@ -25,7 +29,8 @@ export default function ClockWidget({ options, width }: WidgetComponentProps<"cl
const timezone = options.useCustomTimezone ? options.timezone : Intl.DateTimeFormat().resolvedOptions().timeZone;
const time = useCurrentTime(options);
const sizing = width < 128 ? "xs" : width < 196 ? "sm" : "md";
const minimumAxis = Math.min(width, height * 1.5);
const sizing = minimumAxis < 128 ? "xs" : minimumAxis < 196 ? "sm" : "md";
const showWeatherCorner = options.showWeather && sizing !== "xs";
return (
@@ -35,6 +40,7 @@ export default function ClockWidget({ options, width }: WidgetComponentProps<"cl
latitude={options.weatherLocation.latitude}
longitude={options.weatherLocation.longitude}
isFahrenheit={options.isWeatherFormatFahrenheit}
showDetails={displayMode === "advanced"}
/>
)}
<Stack className="clock-text-stack" h="100%" align="center" justify="center" gap={sizing}>
@@ -44,9 +50,11 @@ export default function ClockWidget({ options, width }: WidgetComponentProps<"cl
</Text>
)}
<Title className="clock-time-text" fw={700} order={sizing === "md" ? 2 : sizing === "sm" ? 4 : 6} lh="1">
{options.customTimeFormat
? dayjs(time).tz(timezone).format(customTimeFormat)
: dayjs(time).tz(timezone).format(timeFormat)}
<time dateTime={dayjs(time).tz(timezone).toISOString()}>
{options.customTimeFormat
? dayjs(time).tz(timezone).format(customTimeFormat)
: dayjs(time).tz(timezone).format(timeFormat)}
</time>
</Title>
{options.showDate && (
<Text className="clock-date-text" size={sizing} lineClamp={1}>
@@ -55,6 +63,11 @@ export default function ClockWidget({ options, width }: WidgetComponentProps<"cl
: dayjs(time).tz(timezone).format(dateFormat)}
</Text>
)}
{displayMode === "advanced" && (
<Text size="xs" c="dimmed" lineClamp={1}>
{timezone} · UTC{dayjs(time).tz(timezone).format("Z")}
</Text>
)}
</Stack>
</Box>
);
@@ -64,12 +77,26 @@ interface ClockWeatherCornerProps {
latitude: number;
longitude: number;
isFahrenheit: boolean;
showDetails: boolean;
}
const ClockWeatherCorner = ({ latitude, longitude, isFahrenheit }: ClockWeatherCornerProps) => {
const { data: weather } = clientApi.widget.weather.atLocation.useQuery({ latitude, longitude });
const ClockWeatherCorner = ({ latitude, longitude, isFahrenheit, showDetails }: ClockWeatherCornerProps) => {
const t = useI18n();
const tCommon = useScopedI18n("common");
const weatherQuery = clientApi.widget.weather.atLocation.useQuery({ latitude, longitude });
const weather = weatherQuery.data;
if (!weather) return null;
if (!weather) {
return (
<Box pos="absolute" top={4} left={4}>
{isInitialWidgetQueryPending(weatherQuery) ? (
<Loader size="xs" aria-label={tCommon("action.loading")} />
) : (
<WidgetQueryErrorIndicator error={weatherQuery.error} label={t("widget.weather.name")} />
)}
</Box>
);
}
const temp = isFahrenheit ? weather.current.temperature * (9 / 5) + 32 : weather.current.temperature;
const unit = isFahrenheit ? "°F" : "°C";
@@ -81,6 +108,13 @@ const ClockWeatherCorner = ({ latitude, longitude, isFahrenheit }: ClockWeatherC
{Math.round(temp)}
{unit}
</Text>
{showDetails && (
<Text size="xs" c="dimmed">
{Math.round(isFahrenheit ? metricToImperial(weather.current.windspeed) : weather.current.windspeed)}{" "}
{isFahrenheit ? tCommon("unit.speed.milesPerHour") : tCommon("unit.speed.kilometersPerHour")}
</Text>
)}
<WidgetQueryErrorIndicator error={weatherQuery.error} label={t("widget.weather.name")} />
</Stack>
);
};
+18 -1
View File
@@ -1,11 +1,28 @@
import { IconClock } from "@tabler/icons-react";
import dayjs from "dayjs";
import { createWidgetDefinition } from "../definition";
import { createWidgetDefinition, widgetQueryInputMatches } from "../definition";
import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("clock", {
icon: IconClock,
queryKey: [["widget", "weather", "atLocation"]],
queryMatcher: ({ input }, scope) => {
const location = scope.options.weatherLocation;
if (
scope.options.showWeather !== true ||
location === null ||
typeof location !== "object" ||
!("latitude" in location) ||
!("longitude" in location)
) {
return false;
}
return widgetQueryInputMatches(input, {
latitude: location.latitude,
longitude: location.longitude,
});
},
createOptions() {
return optionsBuilder.from(
(factory) => ({
@@ -0,0 +1,11 @@
.root {
min-width: 24px;
min-height: 24px;
}
@media (any-pointer: coarse) {
.root {
min-width: 44px;
min-height: 44px;
}
}
@@ -0,0 +1,30 @@
import { describe, expect, test } from "vitest";
import { getSafeApplicationUrl } from "./application-url";
describe("getSafeApplicationUrl", () => {
test.each(["javascript:alert(1)", "data:text/html,test", "not a URL", "/relative", "./relative"])(
"rejects unsafe or non-absolute URL %s",
(value) => expect(getSafeApplicationUrl(value)).toBeUndefined(),
);
test("rejects credential-bearing URLs", () => {
expect(getSafeApplicationUrl("https://user:password@example.com/path")).toBeUndefined();
expect(getSafeApplicationUrl("/path", { baseUrl: "https://user:password@example.com" })).toBeUndefined();
});
test("allows absolute HTTP and HTTPS URLs", () => {
expect(getSafeApplicationUrl("http://example.com/path")).toBe("http://example.com/path");
expect(getSafeApplicationUrl("https://example.com/path?q=1#result")).toBe("https://example.com/path?q=1#result");
});
test("resolves feed-relative URLs only against a safe HTTP(S) base", () => {
expect(getSafeApplicationUrl("/story", { baseUrl: "https://example.com/feed.xml?token=secret" })).toBe(
"https://example.com/story",
);
});
test.each([undefined, null, ""])("rejects missing URL %s", (value) => {
expect(getSafeApplicationUrl(value)).toBeUndefined();
});
});
@@ -0,0 +1 @@
export { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "@homarr/common";
@@ -0,0 +1,32 @@
"use client";
import { ActionIcon, Tooltip } from "@mantine/core";
import { IconAlertTriangle } from "@tabler/icons-react";
import { useScopedI18n } from "@homarr/translation/client";
interface IntegrationResult {
integrationId: string;
integrationName?: string;
error?: string;
}
interface IntegrationErrorIndicatorProps {
results: readonly IntegrationResult[];
}
export function IntegrationErrorIndicator({ results }: IntegrationErrorIndicatorProps) {
const t = useScopedI18n("common");
const failed = results.filter((result) => Boolean(result.error));
if (failed.length === 0) return null;
const names = failed.map((result) => result.integrationName ?? result.integrationId).join(", ");
const label = `${t("error")}: ${names}`;
return (
<Tooltip label={label} position="left" withArrow>
<ActionIcon variant="transparent" color="orange" size={28} aria-label={label} style={{ cursor: "help" }}>
<IconAlertTriangle aria-hidden size={14} />
</ActionIcon>
</Tooltip>
);
}
@@ -0,0 +1,39 @@
import { describe, expect, test } from "vitest";
import { formatLocalizedCompactNumber, formatLocalizedDate, formatLocalizedTime, getBinaryStatusKey } from "./locale";
const sample = new Date("2026-01-02T13:05:09.000Z");
describe("localized widget formatting", () => {
test.each([
["en-US", /Jan/],
["fr-FR", /janv/i],
["de-DE", /Jan/],
["he-IL", /[\u0590-\u05ff]/],
])("formats dates for %s", (locale, expected) => {
expect(
formatLocalizedDate(sample, locale, { weekday: "short", month: "short", day: "numeric", timeZone: "UTC" }),
).toMatch(expected);
});
test("supports explicit 12-hour and 24-hour clocks", () => {
expect(formatLocalizedTime(sample, "en-US", { hour12: true, timeZone: "UTC" })).toBe("1:05 PM");
expect(formatLocalizedTime(sample, "en-US", { hour12: false, timeZone: "UTC" })).toBe("13:05");
});
test("keeps date-only values on the requested calendar day", () => {
expect(formatLocalizedDate("2026-08-03", "en-US", { month: "long", day: "numeric" })).toBe("August 3");
});
test("localizes compact numbers", () => {
expect(formatLocalizedCompactNumber(1_234_567, "en-US")).toBe("1.2M");
expect(formatLocalizedCompactNumber(1_234_567, "fr-FR")).toContain("1,2");
expect(formatLocalizedCompactNumber(1_234_567, "de-DE")).toContain("Mio.");
});
test("normalizes binary status for translated accessibility labels", () => {
expect(getBinaryStatusKey("enabled")).toBe("enabled");
expect(getBinaryStatusKey("disabled")).toBe("disabled");
expect(getBinaryStatusKey()).toBe("unknown");
});
});
+72
View File
@@ -0,0 +1,72 @@
export type LocalizedDateValue = Date | number | string;
const dateOnlyPattern = /^\d{4}-\d{2}-\d{2}$/;
interface LocalizedTimeOptions {
hour12?: boolean;
includeSeconds?: boolean;
timeZone?: string;
}
const toValidDate = (value: LocalizedDateValue): Date | undefined => {
const date =
value instanceof Date
? value
: new Date(typeof value === "string" && dateOnlyPattern.test(value) ? `${value}T00:00:00Z` : value);
return Number.isNaN(date.getTime()) ? undefined : date;
};
export const formatLocalizedDate = (
value: LocalizedDateValue,
locale: string,
options: Intl.DateTimeFormatOptions,
fallback = "?",
): string => {
const date = toValidDate(value);
if (!date) return fallback;
const normalizedOptions =
typeof value === "string" && dateOnlyPattern.test(value) && options.timeZone === undefined
? { ...options, timeZone: "UTC" }
: options;
return new Intl.DateTimeFormat(locale, normalizedOptions).format(date);
};
export const formatLocalizedTime = (
value: LocalizedDateValue,
locale: string,
{ hour12, includeSeconds = false, timeZone }: LocalizedTimeOptions = {},
fallback = "?",
): string =>
formatLocalizedDate(
value,
locale,
{
hour: "numeric",
minute: "2-digit",
second: includeSeconds ? "2-digit" : undefined,
hour12,
timeZone,
},
fallback,
);
export const formatLocalizedDateTime = (
value: LocalizedDateValue,
locale: string,
options: Pick<LocalizedTimeOptions, "hour12" | "timeZone"> = {},
fallback = "?",
): string =>
formatLocalizedDate(
value,
locale,
{ dateStyle: "medium", timeStyle: "short", hour12: options.hour12, timeZone: options.timeZone },
fallback,
);
export const formatLocalizedCompactNumber = (value: number, locale: string): string =>
new Intl.NumberFormat(locale, { notation: "compact" }).format(value);
export type BinaryStatusKey = "enabled" | "disabled" | "unknown";
export const getBinaryStatusKey = (status?: "enabled" | "disabled"): BinaryStatusKey => status ?? "unknown";
@@ -0,0 +1,72 @@
// @vitest-environment jsdom
import { act, createElement } from "react";
import { createRoot } from "react-dom/client";
import { MantineProvider } from "@mantine/core";
import { afterEach, describe, expect, test, vi } from "vitest";
import { WidgetQueryErrorIndicator, WidgetQueryLoadingState } from "./query-state-indicator";
vi.mock("@homarr/translation/client", () => ({
useScopedI18n: () => (key: string, values?: { widget?: string }) => {
if (key === "stale") return `Could not refresh ${values?.widget}; showing saved data`;
if (key === "loading") return "Loading widget data";
return key;
},
}));
const matchMedia = (query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: () => undefined,
removeListener: () => undefined,
addEventListener: () => undefined,
removeEventListener: () => undefined,
dispatchEvent: () => false,
});
describe("WidgetQueryErrorIndicator", () => {
let host: HTMLDivElement | undefined;
let root: ReturnType<typeof createRoot> | undefined;
afterEach(async () => {
if (root) await act(async () => root?.unmount());
host?.remove();
vi.unstubAllGlobals();
});
test("uses a localized label without exposing the raw failure", async () => {
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
vi.stubGlobal("matchMedia", matchMedia);
host = document.createElement("div");
document.body.append(host);
root = createRoot(host);
const secret = "http://admin:password@private.local/path?token=secret";
await act(async () =>
root?.render(
createElement(
MantineProvider,
null,
createElement(WidgetQueryErrorIndicator, { error: new Error(secret), label: "Weather" }),
),
),
);
expect(host.querySelector("[aria-label='Could not refresh Weather; showing saved data']")).not.toBeNull();
expect(host.textContent).not.toMatch(/password|private\.local|token=secret/);
});
test("announces the loading state to assistive technology", async () => {
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
vi.stubGlobal("matchMedia", matchMedia);
host = document.createElement("div");
document.body.append(host);
root = createRoot(host);
await act(async () => root?.render(createElement(MantineProvider, null, createElement(WidgetQueryLoadingState))));
expect(host.querySelector("output")?.textContent).toContain("Loading widget data");
});
});
@@ -0,0 +1,45 @@
"use client";
import { ActionIcon, Center, Loader, Tooltip, VisuallyHidden } from "@mantine/core";
import { IconAlertTriangle } from "@tabler/icons-react";
import { useScopedI18n } from "@homarr/translation/client";
interface WidgetQueryErrorIndicatorProps {
error: unknown;
label: string;
}
/** Localized warning for stale or optional-query failures. Never renders the raw error. */
export function WidgetQueryErrorIndicator({ error, label }: WidgetQueryErrorIndicatorProps) {
const t = useScopedI18n("common.widgetQuery");
if (!error) return null;
const accessibleLabel = t("stale", { widget: label });
return (
<Tooltip label={accessibleLabel} position="left" withArrow>
<ActionIcon
component="span"
color="orange"
variant="subtle"
size="sm"
role="note"
aria-label={accessibleLabel}
tabIndex={0}
style={{ cursor: "help" }}
>
<IconAlertTriangle aria-hidden size={14} />
</ActionIcon>
</Tooltip>
);
}
export function WidgetQueryLoadingState() {
const t = useScopedI18n("common.widgetQuery");
return (
<Center component="output" h="100%" w="100%" p="sm" aria-live="polite">
<Loader size="sm" />
<VisuallyHidden>{t("loading")}</VisuallyHidden>
</Center>
);
}
@@ -0,0 +1,31 @@
import { describe, expect, test } from "vitest";
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "./query-state";
describe("getUsableWidgetQueryData", () => {
test.each([[], null, 0, false, ""])("preserves successful empty value %#", (data) => {
expect(getUsableWidgetQueryData({ data, error: null })).toBe(data);
});
test("preserves stale cached data when a refetch fails", () => {
const data = [{ id: "healthy" }];
expect(getUsableWidgetQueryData({ data, error: new Error("offline") })).toBe(data);
});
test("throws a terminal failure when no usable data exists", () => {
const error = new Error("offline");
expect(() => getUsableWidgetQueryData({ data: undefined, error })).toThrow(error);
});
test("keeps the initial pending state when neither data nor an error exists", () => {
expect(getUsableWidgetQueryData({ data: undefined, error: null })).toBeUndefined();
});
});
describe("isInitialWidgetQueryPending", () => {
test("only marks the unresolved first request as initial pending", () => {
expect(isInitialWidgetQueryPending({ data: undefined, error: null, isPending: true })).toBe(true);
expect(isInitialWidgetQueryPending({ data: [], error: null, isPending: true })).toBe(false);
expect(isInitialWidgetQueryPending({ data: undefined, error: new Error("offline"), isPending: true })).toBe(false);
});
});
@@ -0,0 +1,18 @@
interface WidgetQueryState {
data: unknown;
error: unknown;
}
interface PendingWidgetQueryState extends WidgetQueryState {
isPending: boolean;
}
/** Throws terminal query failures while preserving successful empty values and stale cached data. */
export const getUsableWidgetQueryData = <TQuery extends WidgetQueryState>(query: TQuery): TQuery["data"] => {
if (query.error && query.data === undefined) throw query.error;
return query.data;
};
/** True only before the first result; successful empty values and cached data are both ready to render. */
export const isInitialWidgetQueryPending = ({ data, error, isPending }: PendingWidgetQueryState): boolean =>
isPending && data === undefined && !error;
+36 -9
View File
@@ -1,16 +1,17 @@
"use client";
import { ScrollArea, Stack, Text } from "@mantine/core";
import { ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { useScopedI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition";
import { getUsableWidgetQueryData } from "../common/query-state";
import { createWidgetKey } from "./coolify-utils";
import { InstanceCard } from "./instance-card";
import { SingleInstanceLayout } from "./single-instance-layout";
export default function CoolifyWidget({ options, integrationIds, width }: WidgetComponentProps<"coolify">) {
export default function CoolifyWidget({ options, integrationIds, width, height }: WidgetComponentProps<"coolify">) {
const t = useScopedI18n("widget.coolify");
if (integrationIds.length === 0) {
@@ -21,29 +22,54 @@ export default function CoolifyWidget({ options, integrationIds, width }: Widget
);
}
return <CoolifyContent integrationIds={integrationIds} options={options} width={width} />;
return <CoolifyContent integrationIds={integrationIds} options={options} width={width} height={height} />;
}
interface CoolifyContentProps {
integrationIds: string[];
options: WidgetComponentProps<"coolify">["options"];
width: number;
height: number;
}
function CoolifyContent({ integrationIds, options, width }: CoolifyContentProps) {
const { data: instancesData = [] } = clientApi.widget.coolify.getInstancesInfo.useQuery({ integrationIds });
function CoolifyContent({ integrationIds, options, width, height }: CoolifyContentProps) {
const t = useScopedI18n("common");
const instancesQuery = clientApi.widget.coolify.getInstancesInfo.useQuery({
integrationIds,
});
const instancesData = getUsableWidgetQueryData(instancesQuery) ?? [];
const { isPending } = instancesQuery;
const isTiny = width < 256;
const isTiny = width < 256 || height < 144;
const hideFooter = height < 112;
const [firstInstance] = instancesData;
const widgetKey = createWidgetKey(integrationIds);
if (isPending) {
return (
<Stack align="center" justify="center" h="100%">
<Text c="dimmed" size="sm">
{t("action.loading")}
</Text>
</Stack>
);
}
if (instancesData.length === 1 && firstInstance) {
return <SingleInstanceLayout instance={firstInstance} options={options} isTiny={isTiny} widgetKey={widgetKey} />;
return (
<SingleInstanceLayout
instance={firstInstance}
options={options}
isTiny={isTiny}
widgetKey={widgetKey}
hideFooter={hideFooter}
/>
);
}
return (
<ScrollArea h="100%">
<Stack gap="xs" p="xs">
<SimpleGrid cols={width >= 760 ? 2 : 1} spacing="sm" p="xs">
{instancesData.map((instance) => (
<InstanceCard
key={instance.integrationId}
@@ -51,9 +77,10 @@ function CoolifyContent({ integrationIds, options, width }: CoolifyContentProps)
options={options}
isTiny={isTiny}
widgetKey={widgetKey}
hideFooter={hideFooter}
/>
))}
</Stack>
</SimpleGrid>
</ScrollArea>
);
}
@@ -0,0 +1,29 @@
import dayjs from "dayjs";
import frenchLocale from "dayjs/locale/fr";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { formatRelativeTime, getResourceTimestamp } from "./coolify-utils";
describe("Coolify time formatting", () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-08-02T12:00:00Z"));
dayjs.locale(frenchLocale);
});
afterEach(() => {
dayjs.locale("en");
vi.useRealTimers();
});
it("uses the active Day.js locale for past and future timestamps", () => {
expect(formatRelativeTime("2026-08-02T11:00:00Z")).toBe("il y a une heure");
expect(formatRelativeTime("2026-08-02T13:00:00Z")).toBe("dans une heure");
});
it("omits missing, invalid, and currently running timestamps", () => {
expect(formatRelativeTime(undefined)).toBeUndefined();
expect(formatRelativeTime("invalid")).toBeUndefined();
expect(getResourceTimestamp({ status: "running", updated_at: "2026-08-02T11:00:00Z" }, "service")).toBeUndefined();
});
});
+8 -21
View File
@@ -1,9 +1,14 @@
import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
import type {
CoolifyApplicationWithContext,
CoolifyServer,
CoolifyServiceWithContext,
} from "@homarr/integrations/types";
dayjs.extend(relativeTime);
export function parseStatus(status: string): string {
const firstPart = status.split(":")[0]?.toLowerCase();
if (!firstPart) return "unknown";
@@ -42,26 +47,8 @@ export function getBadgeColor(running: number, total: number): string {
export function formatRelativeTime(dateString: string | null | undefined): string | undefined {
if (!dateString) return undefined;
const date = new Date(dateString);
if (isNaN(date.getTime())) return undefined;
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffSec = Math.floor(diffMs / 1000);
const diffMin = Math.floor(diffSec / 60);
const diffHour = Math.floor(diffMin / 60);
const diffDay = Math.floor(diffHour / 24);
if (diffDay > 0) {
const remainingHours = diffHour % 24;
return remainingHours > 0 ? `${diffDay}d ${remainingHours}h ago` : `${diffDay}d ago`;
}
if (diffHour > 0) {
const remainingMin = diffMin % 60;
return remainingMin > 0 ? `${diffHour}h ${remainingMin}m ago` : `${diffHour}h ago`;
}
if (diffMin > 0) return `${diffMin}m ago`;
return "just now";
const date = dayjs(dateString);
return date.isValid() ? date.fromNow() : undefined;
}
export function getResourceTimestamp(
@@ -116,7 +103,7 @@ export function buildServerResourceCounts(
}
export function createWidgetKey(integrationIds: string[]): string {
return integrationIds.slice().sort().join("-");
return integrationIds.toSorted().join("-");
}
export function createStorageKey(widgetKey: string, integrationId: string, type: "sections" | "show-ip"): string {
+1
View File
@@ -4,6 +4,7 @@ import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("coolify", {
supportsAdvancedFocus: false,
icon: IconCloud,
createOptions() {
return optionsBuilder.from((factory) => ({
+57 -16
View File
@@ -1,12 +1,13 @@
"use client";
import { Accordion, Anchor, Badge, Card, Group, Image, Text } from "@mantine/core";
import { Accordion, Anchor, Badge, Card, Group, Image, Text, Tooltip } from "@mantine/core";
import { useLocalStorage } from "@mantine/hooks";
import { useTimeAgo } from "@homarr/common";
import { useScopedI18n } from "@homarr/translation/client";
import { ApplicationsSection } from "./applications-section";
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url";
import { buildServerResourceCounts, getBadgeColor, parseStatus } from "./coolify-utils";
import { ServersSection } from "./servers-section";
import { ServicesSection } from "./services-section";
@@ -18,10 +19,12 @@ interface InstanceCardProps {
options: CoolifyOptions;
isTiny: boolean;
widgetKey: string;
hideFooter: boolean;
}
export function InstanceCard({ instance, options, isTiny, widgetKey }: InstanceCardProps) {
export function InstanceCard({ instance, options, isTiny, widgetKey, hideFooter }: InstanceCardProps) {
const t = useScopedI18n("widget.coolify");
const tCommon = useScopedI18n("common");
const cardKey = `${widgetKey}-${instance.integrationId}`;
const [showIp, setShowIp] = useLocalStorage({
key: `coolify-show-ip-${cardKey}`,
@@ -31,14 +34,13 @@ export function InstanceCard({ instance, options, isTiny, widgetKey }: InstanceC
key: `coolify-sections-${cardKey}`,
defaultValue: ["applications"],
});
const serverResourceCounts = buildServerResourceCounts(
instance.instanceInfo.servers,
instance.instanceInfo.applications,
instance.instanceInfo.services,
);
const baseUrl = instance.integrationUrl.replace(/\/+$/, "");
const baseUrl = getSafeApplicationUrl(instance.integrationUrl)?.replace(/\/+$/, "") ?? "";
const relativeTime = useTimeAgo(instance.updatedAt);
const onlineServers = instance.instanceInfo.servers.filter((s) => s.is_reachable !== false).length;
@@ -48,6 +50,23 @@ export function InstanceCard({ instance, options, isTiny, widgetKey }: InstanceC
const runningServices = instance.instanceInfo.services.filter(
(s) => parseStatus(s.status ?? "") === "running",
).length;
const healthyResources =
(options.showServers ? onlineServers : 0) +
(options.showApplications ? runningApps : 0) +
(options.showServices ? runningServices : 0);
const totalResources =
(options.showServers ? instance.instanceInfo.servers.length : 0) +
(options.showApplications ? instance.instanceInfo.applications.length : 0) +
(options.showServices ? instance.instanceInfo.services.length : 0);
const resourceSummary = [
options.showServers ? `${tCommon("servers")}: ${onlineServers}/${instance.instanceInfo.servers.length}` : null,
options.showApplications
? `${tCommon("applications")}: ${runningApps}/${instance.instanceInfo.applications.length}`
: null,
options.showServices ? `${tCommon("services")}: ${runningServices}/${instance.instanceInfo.services.length}` : null,
]
.filter(Boolean)
.join(" · ");
return (
<Card p={0} radius="sm">
@@ -59,17 +78,33 @@ export function InstanceCard({ instance, options, isTiny, widgetKey }: InstanceC
>
<Group gap={4} wrap="nowrap">
<Image src={COOLIFY_ICON_URL} alt="Coolify" w={16} h={16} />
<Anchor href={baseUrl} target="_blank" fz={isTiny ? "10px" : "xs"} fw={600} c="inherit" lineClamp={1}>
<Anchor
component={baseUrl ? "a" : "span"}
href={baseUrl}
target={baseUrl ? "_blank" : undefined}
rel={baseUrl ? SAFE_NEW_TAB_REL : undefined}
fz={isTiny ? "10px" : "xs"}
fw={600}
c="inherit"
lineClamp={1}
>
{instance.integrationName}
</Anchor>
</Group>
<Group gap={4} wrap="nowrap">
{options.showServers && (
{isTiny && totalResources > 0 ? (
<Tooltip label={resourceSummary}>
<Badge variant="dot" color={getBadgeColor(healthyResources, totalResources)} size="xs">
{healthyResources}/{totalResources}
</Badge>
</Tooltip>
) : null}
{!isTiny && options.showServers && (
<Badge variant="dot" color={getBadgeColor(onlineServers, instance.instanceInfo.servers.length)} size="xs">
{onlineServers}/{instance.instanceInfo.servers.length}
</Badge>
)}
{options.showApplications && (
{!isTiny && options.showApplications && (
<Badge
variant="dot"
color={getBadgeColor(runningApps, instance.instanceInfo.applications.length)}
@@ -78,7 +113,7 @@ export function InstanceCard({ instance, options, isTiny, widgetKey }: InstanceC
{runningApps}/{instance.instanceInfo.applications.length}
</Badge>
)}
{options.showServices && (
{!isTiny && options.showServices && (
<Badge
variant="dot"
color={getBadgeColor(runningServices, instance.instanceInfo.services.length)}
@@ -109,14 +144,20 @@ export function InstanceCard({ instance, options, isTiny, widgetKey }: InstanceC
)}
</Accordion>
<Group justify="space-between" p={4} style={{ borderTop: "1px solid var(--mantine-color-dark-4)" }}>
<Text size="10px" c="dimmed">
v{instance.instanceInfo.version}
</Text>
<Text size="10px" c="dimmed">
{t("footer.updated", { when: relativeTime })}
</Text>
</Group>
{!hideFooter && (
<Group
justify="space-between"
p={4}
style={{ borderTop: "1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4))" }}
>
<Text size="10px" c="dimmed">
v{instance.instanceInfo.version}
</Text>
<Text size="10px" c="dimmed">
{t("footer.updated", { when: relativeTime })}
</Text>
</Group>
)}
</Card>
);
}
+84 -22
View File
@@ -1,8 +1,19 @@
"use client";
import { ActionIcon, Anchor, Group, Indicator, Stack, Text } from "@mantine/core";
import { IconExternalLink, IconFileText, IconLink } from "@tabler/icons-react";
import { ActionIcon, Anchor, Group, Stack, Text } from "@mantine/core";
import {
IconCircleCheckFilled,
IconCircleXFilled,
IconFileText,
IconHelpCircleFilled,
IconLink,
IconLoader2,
} from "@tabler/icons-react";
import { useScopedI18n } from "@homarr/translation/client";
import actionTargetClasses from "../common/action-target.module.css";
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url";
import { cleanFqdn, getResourceTimestamp, getStatusColor, parseStatus } from "./coolify-utils";
interface ResourceRowProps {
@@ -24,52 +35,103 @@ interface ResourceRowProps {
}
export function ResourceRow({ item, baseUrl, isTiny, resourceType }: ResourceRowProps) {
const t = useScopedI18n("widget.coolify");
const status = parseStatus(item.status ?? "");
const statusColor = getStatusColor(status);
const isTransitioning = status === "starting" || status === "restarting";
const StatusIcon =
status === "running"
? IconCircleCheckFilled
: isTransitioning
? IconLoader2
: status === "unknown"
? IconHelpCircleFilled
: IconCircleXFilled;
const statusLabel =
status === "running"
? t("status.running")
: status === "stopped"
? t("status.stopped")
: status === "exited"
? t("status.exited")
: status === "starting"
? t("status.starting")
: status === "restarting"
? t("status.restarting")
: t("status.unknown");
const resourceUrl =
const resourceUrl = getSafeApplicationUrl(
item.projectUuid && item.environmentUuid
? `${baseUrl}/project/${item.projectUuid}/environment/${item.environmentUuid}/${resourceType}/${item.uuid}`
: undefined;
: undefined,
);
const logsUrl = resourceUrl ? `${resourceUrl}/logs` : undefined;
const logsUrl = getSafeApplicationUrl(resourceUrl ? `${resourceUrl}/logs` : undefined);
const publicUrl = getSafeApplicationUrl(cleanFqdn(item.fqdn));
const resourceTimestamp = getResourceTimestamp(item, resourceType);
return (
<Stack gap={0}>
<Group wrap="nowrap" gap={isTiny ? 4 : "xs"}>
<Indicator size={isTiny ? 4 : 8} color={statusColor} />
<StatusIcon aria-label={statusLabel} size={isTiny ? 12 : 16} color={`var(--mantine-color-${statusColor}-6)`} />
{resourceUrl ? (
<Anchor href={resourceUrl} target="_blank" fz={isTiny ? "8px" : "xs"} c="inherit" lineClamp={1}>
<Anchor
className={actionTargetClasses.root}
href={resourceUrl}
target="_blank"
rel={SAFE_NEW_TAB_REL}
fz="xs"
c="inherit"
truncate="end"
style={{ display: "inline-flex", alignItems: "center", overflow: "hidden" }}
>
{item.name}
</Anchor>
) : (
<Text lineClamp={1} fz={isTiny ? "8px" : "xs"}>
<Text lineClamp={1} fz="xs">
{item.name}
</Text>
)}
</Group>
<Group wrap="nowrap" gap={4} ml={16}>
{cleanFqdn(item.fqdn) && (
<ActionIcon component="a" href={cleanFqdn(item.fqdn)} target="_blank" size="xs" variant="subtle" c="dimmed">
{publicUrl && (
<ActionIcon
className={actionTargetClasses.root}
component="a"
href={publicUrl}
target="_blank"
rel={SAFE_NEW_TAB_REL}
aria-label={t("action.openResource", { name: item.name })}
size="xs"
variant="subtle"
c="dimmed"
>
<IconLink size={12} />
</ActionIcon>
)}
{resourceUrl && (
<ActionIcon component="a" href={resourceUrl} target="_blank" size="xs" variant="subtle" c="dimmed">
<IconExternalLink size={12} />
</ActionIcon>
)}
{logsUrl && (
<ActionIcon component="a" href={logsUrl} target="_blank" size="xs" variant="subtle" c="dimmed">
{!isTiny && logsUrl && (
<ActionIcon
className={actionTargetClasses.root}
component="a"
href={logsUrl}
target="_blank"
rel={SAFE_NEW_TAB_REL}
aria-label={t("action.openLogs", { name: item.name })}
size="xs"
variant="subtle"
c="dimmed"
>
<IconFileText size={12} />
</ActionIcon>
)}
<Text fz="10px" c="dimmed" lineClamp={1}>
{item.projectName ?? "-"} / {item.environmentName ?? "-"}
</Text>
{getResourceTimestamp(item, resourceType) && (
{!isTiny && (
<Text fz="10px" c="dimmed" lineClamp={1}>
{item.projectName ?? "-"} / {item.environmentName ?? "-"}
</Text>
)}
{!isTiny && resourceTimestamp && (
<Text fz="10px" c="dimmed" ml="auto">
{getResourceTimestamp(item, resourceType)}
{resourceTimestamp}
</Text>
)}
</Group>
@@ -1,11 +1,13 @@
"use client";
import { Accordion, ActionIcon, Anchor, Badge, Group, Indicator, Stack, Text } from "@mantine/core";
import { IconExternalLink, IconEye, IconEyeOff, IconServer } from "@tabler/icons-react";
import { Accordion, ActionIcon, Anchor, Badge, Group, Stack, Text } from "@mantine/core";
import { IconCircleCheckFilled, IconCircleXFilled, IconEye, IconEyeOff, IconServer } from "@tabler/icons-react";
import type { CoolifyServer } from "@homarr/integrations/types";
import { useScopedI18n } from "@homarr/translation/client";
import actionTargetClasses from "../common/action-target.module.css";
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url";
import { getBadgeColor } from "./coolify-utils";
interface ServersSectionProps {
@@ -31,27 +33,28 @@ export function ServersSection({
return (
<Accordion.Item value="servers">
<Accordion.Control icon={isTiny ? null : <IconServer size={16} />}>
<Group gap="xs" justify="space-between" wrap="nowrap" style={{ flex: 1 }}>
<Group gap={0} wrap="nowrap">
<Accordion.Control icon={isTiny ? null : <IconServer size={16} />} style={{ flex: 1, minWidth: 0 }}>
<Group gap="xs">
<Text size="xs">{tCommon("servers")}</Text>
<Badge variant="dot" color={getBadgeColor(onlineServers, servers.length)} size="xs">
{onlineServers} / {servers.length}
</Badge>
</Group>
<ActionIcon
size="xs"
variant="subtle"
c="dimmed"
onClick={(event) => {
event.stopPropagation();
onToggleIp();
}}
>
{showIp ? <IconEye size={12} /> : <IconEyeOff size={12} />}
</ActionIcon>
</Group>
</Accordion.Control>
</Accordion.Control>
<ActionIcon
className={actionTargetClasses.root}
aria-label={t(showIp ? "action.hideIp" : "action.showIp")}
aria-pressed={showIp}
size="xs"
variant="subtle"
c="dimmed"
mr="xs"
onClick={onToggleIp}
>
{showIp ? <IconEye size={12} /> : <IconEyeOff size={12} />}
</ActionIcon>
</Group>
<Accordion.Panel p={4}>
{servers.length > 0 ? (
<Stack gap={4}>
@@ -90,13 +93,28 @@ function ServerRow({ server, counts, baseUrl, isTiny, showIp }: ServerRowProps)
const t = useScopedI18n("widget.coolify");
const isBuildServer = server.settings?.is_build_server === true;
const isOnline = server.is_reachable !== false;
const serverUrl = `${baseUrl}/server/${server.uuid}`;
const serverUrl = getSafeApplicationUrl(`${baseUrl}/server/${server.uuid}`);
const StatusIcon = isOnline ? IconCircleCheckFilled : IconCircleXFilled;
return (
<Stack gap={0}>
<Group wrap="nowrap" gap={isTiny ? 4 : "xs"}>
<Indicator size={isTiny ? 4 : 8} color={isOnline ? "green" : "red"} />
<Anchor href={serverUrl} target="_blank" fz={isTiny ? "8px" : "xs"} c="inherit" lineClamp={1}>
<StatusIcon
aria-label={t(isOnline ? "status.online" : "status.offline")}
size={isTiny ? 12 : 16}
color={`var(--mantine-color-${isOnline ? "green" : "red"}-6)`}
/>
<Anchor
className={actionTargetClasses.root}
component={serverUrl ? "a" : "span"}
href={serverUrl}
target={serverUrl ? "_blank" : undefined}
rel={serverUrl ? SAFE_NEW_TAB_REL : undefined}
fz="xs"
c="inherit"
truncate="end"
style={{ display: "inline-flex", alignItems: "center", overflow: "hidden" }}
>
{server.name}
</Anchor>
{isBuildServer ? (
@@ -105,14 +123,11 @@ function ServerRow({ server, counts, baseUrl, isTiny, showIp }: ServerRowProps)
</Badge>
) : (
<Text fz="10px" c="dimmed">
({counts.apps} apps / {counts.services} svcs)
({t("server.resourceCounts", { applications: counts.apps, services: counts.services })})
</Text>
)}
</Group>
<Group wrap="nowrap" gap={4} ml={16}>
<ActionIcon component="a" href={serverUrl} target="_blank" size="xs" variant="subtle" c="dimmed">
<IconExternalLink size={12} />
</ActionIcon>
<Text fz="10px" c="dimmed">
{showIp ? server.ip : "***.***.***.***"}
</Text>
@@ -7,6 +7,7 @@ import { useTimeAgo } from "@homarr/common";
import { useScopedI18n } from "@homarr/translation/client";
import { ApplicationsSection } from "./applications-section";
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url";
import { buildServerResourceCounts } from "./coolify-utils";
import { ServersSection } from "./servers-section";
import { ServicesSection } from "./services-section";
@@ -18,9 +19,10 @@ interface SingleInstanceLayoutProps {
options: CoolifyOptions;
isTiny: boolean;
widgetKey: string;
hideFooter: boolean;
}
export function SingleInstanceLayout({ instance, options, isTiny, widgetKey }: SingleInstanceLayoutProps) {
export function SingleInstanceLayout({ instance, options, isTiny, widgetKey, hideFooter }: SingleInstanceLayoutProps) {
const t = useScopedI18n("widget.coolify");
const [showIp, setShowIp] = useLocalStorage({
key: `coolify-show-ip-${widgetKey}`,
@@ -30,15 +32,14 @@ export function SingleInstanceLayout({ instance, options, isTiny, widgetKey }: S
key: `coolify-sections-${widgetKey}`,
defaultValue: ["applications"],
});
const serverResourceCounts = buildServerResourceCounts(
instance.instanceInfo.servers,
instance.instanceInfo.applications,
instance.instanceInfo.services,
);
const baseUrl = instance.integrationUrl.replace(/\/+$/, "");
const displayUrl = baseUrl.replace(/^https?:\/\//, "");
const baseUrl = getSafeApplicationUrl(instance.integrationUrl)?.replace(/\/+$/, "") ?? "";
const displayUrl = baseUrl ? baseUrl.replace(/^https?:\/\//, "") : "—";
const relativeTime = useTimeAgo(instance.updatedAt);
return (
@@ -51,7 +52,16 @@ export function SingleInstanceLayout({ instance, options, isTiny, widgetKey }: S
oolify
</Text>
</Group>
<Anchor href={baseUrl} target="_blank" fz={isTiny ? "xs" : "sm"} fw={500} c="dimmed" lineClamp={1}>
<Anchor
component={baseUrl ? "a" : "span"}
href={baseUrl}
target={baseUrl ? "_blank" : undefined}
rel={baseUrl ? SAFE_NEW_TAB_REL : undefined}
fz={isTiny ? "xs" : "sm"}
fw={500}
c="dimmed"
lineClamp={1}
>
{displayUrl}
</Anchor>
</Group>
@@ -75,21 +85,23 @@ export function SingleInstanceLayout({ instance, options, isTiny, widgetKey }: S
)}
</Accordion>
<Group
justify="space-between"
p={4}
style={{ borderTop: "1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4))" }}
>
<Group gap={2}>
<Image src={COOLIFY_ICON_URL} alt="Coolify" w={16} h={16} />
{!hideFooter && (
<Group
justify="space-between"
p={4}
style={{ borderTop: "1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4))" }}
>
<Group gap={2}>
<Image src={COOLIFY_ICON_URL} alt="Coolify" w={16} h={16} />
<Text size="xs" c="dimmed">
v{instance.instanceInfo.version}
</Text>
</Group>
<Text size="xs" c="dimmed">
v{instance.instanceInfo.version}
{t("footer.updated", { when: relativeTime })}
</Text>
</Group>
<Text size="xs" c="dimmed">
{t("footer.updated", { when: relativeTime })}
</Text>
</Group>
)}
</Stack>
</ScrollArea>
);
+104 -465
View File
@@ -1,493 +1,132 @@
"use client";
import dynamic from "next/dynamic";
import { useState } from "react";
import type { ComponentType } from "react";
import {
ActionIcon,
Button,
Card,
Center,
Code,
Flex,
Group,
Loader,
Progress,
ScrollArea,
SimpleGrid,
Stack,
Table,
Text,
Title,
Tooltip,
} from "@mantine/core";
import { useElementSize } from "@mantine/hooks";
import { IconAlertTriangle, IconCheck, IconExternalLink, IconPlayerPlay } from "@tabler/icons-react";
import { Button, Center, Loader, Stack, Text } from "@mantine/core";
import { IconAlertTriangle } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useConfirmModal } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useSession } from "@homarr/auth/client";
import { useScopedI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui";
import type { WidgetComponentProps } from "../definition";
import { isLegacyCustomWidgetMigrationError, isTerminalCustomWidgetDefinitionError } from "./migration-state";
const CustomJsxDisplay = dynamic(() => import("./custom-jsx-display"), { ssr: false });
const valueSizeMap: Record<string, string> = { sm: "sm", md: "md", lg: "lg", xl: "xl" };
function SingleValueDisplay({ data }: { data: Record<string, unknown> }) {
const size = valueSizeMap[(data.valueSize as string) ?? "lg"] ?? "lg";
const labelAbove = (data.labelPosition as string) === "above";
const label = data.label ? (
<Text c="dimmed" size="sm">
{String(data.label)}
</Text>
) : null;
return (
<Stack h="100%" align="center" justify="center" gap="xs">
{labelAbove && label}
<Title order={size === "xl" ? 1 : size === "lg" ? 2 : size === "md" ? 3 : 4}>
{String(data.value ?? "—")}
{data.unit ? ` ${data.unit}` : ""}
</Title>
{!labelAbove && label}
</Stack>
);
}
function KeyValueDisplay({ data }: { data: Record<string, unknown> }) {
const entries = (data.entries as Array<{ label: string; unit: string; value: unknown }>) ?? [];
const layout = (data.layout as string) ?? "list";
const columns = (data.columns as number) ?? 2;
if (layout === "grid") {
return (
<SimpleGrid cols={columns} spacing="xs" p="sm" h="100%">
{entries.map((entry, i) => (
<Stack key={i} align="center" gap={2}>
<Text size="sm" fw={600}>
{String(entry.value ?? "—")}
{entry.unit ? ` ${entry.unit}` : ""}
</Text>
<Text size="xs" c="dimmed">
{entry.label}
</Text>
</Stack>
))}
</SimpleGrid>
);
}
return (
<Stack h="100%" justify="center" gap="xs" p="sm">
{entries.map((entry, i) => (
<Group key={i} justify="space-between" wrap="nowrap">
<Text size="sm" c="dimmed">
{entry.label}
</Text>
<Text size="sm" fw={600}>
{String(entry.value ?? "—")}
{entry.unit ? ` ${entry.unit}` : ""}
</Text>
</Group>
))}
</Stack>
);
}
function TableDisplay({ data }: { data: Record<string, unknown> }) {
const columns = (data.columns as string[]) ?? [];
const rows = (data.rows as unknown[][]) ?? [];
const striped = (data.striped as boolean) ?? true;
const compact = (data.compact as boolean) ?? false;
return (
<ScrollArea>
<Table striped={striped} highlightOnHover withTableBorder>
<Table.Thead>
<Table.Tr>
{columns.map((col, i) => (
<Table.Th key={i} py={compact ? 4 : undefined}>
{col}
</Table.Th>
))}
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{rows.map((row, i) => (
<Table.Tr key={i}>
{row.map((cell, j) => (
<Table.Td key={j} py={compact ? 2 : undefined}>
{String(cell ?? "—")}
</Table.Td>
))}
</Table.Tr>
))}
</Table.Tbody>
</Table>
</ScrollArea>
);
}
function StatGridCard({
item,
cardStyle,
}: {
item: { label: string; unit: string; color: string; value: unknown };
cardStyle: string;
}) {
const { ref, height, width } = useElementSize();
const isWide = width > height + 20;
const hideLabel = height > 0 && height <= 38;
const bgMap: Record<string, string> = {
filled: `var(--mantine-color-${item.color}-light)`,
outline: "transparent",
subtle: `var(--mantine-color-${item.color}-light)`,
};
return (
<Card
ref={ref}
p="sm"
radius="md"
bg={bgMap[cardStyle] ?? bgMap.filled}
withBorder={cardStyle === "outline"}
h="100%"
style={{ flex: 1 }}
>
<Flex
h="100%"
w="100%"
align="center"
justify="center"
direction={isWide ? "row" : "column"}
gap={isWide ? 8 : 4}
>
<Flex direction="column" align={isWide ? "flex-start" : "center"} gap={0}>
<Text size="md" fw={700} ta="center" lh={1.1}>
{String(item.value ?? "—")}
{item.unit ? ` ${item.unit}` : ""}
</Text>
{!hideLabel && (
<Text size="xs" c="dimmed" ta="center" tt="uppercase" lh={1.3} style={{ letterSpacing: "0.03em" }}>
{item.label}
</Text>
)}
</Flex>
</Flex>
</Card>
);
}
function StatGridDisplay({ data }: { data: Record<string, unknown> }) {
const items = (data.items as Array<{ label: string; unit: string; color: string; value: unknown }>) ?? [];
const columns = (data.columns as number) ?? 2;
const cardStyle = (data.cardStyle as string) ?? "filled";
return (
<SimpleGrid
cols={columns}
spacing="xs"
p="xs"
h="100%"
style={{ gridTemplateRows: `repeat(${Math.ceil(items.length / columns)}, 1fr)` }}
>
{items.map((item, i) => (
<StatGridCard key={i} item={item} cardStyle={cardStyle} />
))}
</SimpleGrid>
);
}
function ProgressBarsDisplay({ data }: { data: Record<string, unknown> }) {
const bars = (data.bars as Array<{ label: string; unit: string; color: string; value: number; max?: number }>) ?? [];
const showPercentage = (data.showPercentage as boolean) ?? true;
const barSize = (data.barSize as string) ?? "md";
const sizeMap: Record<string, number> = { sm: 8, md: 14, lg: 22 };
return (
<Stack h="100%" justify="center" gap="sm" p="sm">
{bars.map((bar, i) => {
const max = bar.max ?? 100;
const pct = max > 0 ? Math.min((bar.value / max) * 100, 100) : 0;
return (
<Stack key={i} gap={4}>
<Group justify="space-between" wrap="nowrap">
<Text size="xs" fw={500}>
{bar.label}
</Text>
<Text size="xs" c="dimmed">
{showPercentage ? `${pct.toFixed(0)}%` : `${bar.value}${bar.unit ? ` ${bar.unit}` : ""}`}
{bar.max !== undefined && showPercentage
? ` (${bar.value}/${max}${bar.unit ? ` ${bar.unit}` : ""})`
: ""}
</Text>
</Group>
<Progress value={pct} size={sizeMap[barSize] ?? 14} color={bar.color} radius="sm" />
</Stack>
);
})}
</Stack>
);
}
function StatusIndicatorDisplay({ data }: { data: Record<string, unknown> }) {
const items = (data.items as Array<{ label: string; value: string; isGood: boolean }>) ?? [];
const layout = (data.layout as string) ?? "list";
const dotSize = (data.dotSize as string) ?? "md";
const dotSizeMap: Record<string, number> = { sm: 8, md: 10, lg: 14 };
const size = dotSizeMap[dotSize] ?? 10;
const renderItem = (item: { label: string; value: string; isGood: boolean }, i: number) => (
<Group key={i} gap="xs" wrap="nowrap">
<div
style={{
width: size,
height: size,
borderRadius: "50%",
backgroundColor: item.isGood ? "var(--mantine-color-green-6)" : "var(--mantine-color-red-6)",
flexShrink: 0,
}}
/>
<Text size="sm" fw={500}>
{item.label}
</Text>
<Text size="xs" c="dimmed" ml="auto">
{item.value}
</Text>
</Group>
);
if (layout === "grid") {
return (
<SimpleGrid cols={2} spacing="xs" p="sm" h="100%">
{items.map(renderItem)}
</SimpleGrid>
);
}
return (
<Stack h="100%" justify="center" gap="xs" p="sm">
{items.map(renderItem)}
</Stack>
);
}
function CountGridDisplay({ data }: { data: Record<string, unknown> }) {
const items = (data.items as Array<{ label: string; unit: string; value: unknown }>) ?? [];
const columns = (data.columns as number) ?? 2;
const valueSize = (data.valueSize as string) ?? "md";
const sizeMap: Record<string, string> = { sm: "sm", md: "md", lg: "lg" };
return (
<SimpleGrid cols={columns} spacing="xs" p="sm" h="100%">
{items.map((item, i) => (
<Stack key={i} align="center" justify="center" gap={0}>
<Text size={sizeMap[valueSize] ?? "md"} fw={700} lh={1.2}>
{String(item.value ?? "—")}
{item.unit ? ` ${item.unit}` : ""}
</Text>
<Text size="xs" c="dimmed" tt="uppercase" ta="center" lh={1.3} style={{ letterSpacing: "0.03em" }}>
{item.label}
</Text>
</Stack>
))}
</SimpleGrid>
);
}
function openJsonInBrowser(json: unknown) {
const blob = new Blob([JSON.stringify(json, null, 2)], { type: "application/json" });
const blobUrl = URL.createObjectURL(blob);
window.open(blobUrl);
setTimeout(() => URL.revokeObjectURL(blobUrl), 60_000);
}
function RawDisplay({ data }: { data: Record<string, unknown> }) {
const maxHeight = (data.maxHeight as number) ?? 300;
const jsonString = JSON.stringify(data.data, null, 2);
return (
<Stack gap={4} p="xs">
<Group justify="flex-end">
<Tooltip label="Open in browser JSON viewer">
<ActionIcon variant="subtle" size="sm" onClick={() => openJsonInBrowser(data.data)}>
<IconExternalLink size={14} />
</ActionIcon>
</Tooltip>
</Group>
<ScrollArea mah={maxHeight}>
<Code block style={{ fontSize: 12 }}>
{jsonString}
</Code>
</ScrollArea>
</Stack>
);
}
function ActionButtonDisplay({ data }: { data: Record<string, unknown> }) {
export default function CustomApiWidget({
options,
itemId,
isEditMode,
removeItem,
}: WidgetComponentProps<"customApi">) {
const t = useScopedI18n("widget.customApi");
const { openConfirmModal } = useConfirmModal();
const executeMutation = clientApi.customWidget.execute.useMutation();
const [lastSuccess, setLastSuccess] = useState(false);
const buttonLabel = (data.buttonLabel as string) ?? "Execute";
const buttonColor = (data.buttonColor as string) ?? "blue";
const confirmText = (data.confirmText as string) || "";
const successMessage = (data.successMessage as string) || t("executeSuccess");
const definitionId = data.widgetDefinitionId as string | undefined;
const handleExecute = async () => {
if (!definitionId) return;
setLastSuccess(false);
try {
const result = await executeMutation.mutateAsync({ definitionId });
if (result.success) {
setLastSuccess(true);
showSuccessNotification({ title: buttonLabel, message: successMessage });
setTimeout(() => setLastSuccess(false), 3000);
} else {
showErrorNotification({ title: buttonLabel, message: result.error ?? t("executeFailed") });
}
} catch {
showErrorNotification({ title: buttonLabel, message: t("executeFailed") });
}
};
const handleClick = () => {
if (confirmText) {
openConfirmModal({
title: buttonLabel,
children: confirmText,
onConfirm: () => void handleExecute(),
});
} else {
void handleExecute();
}
};
return (
<Center h="100%">
<Button
size="lg"
color={buttonColor}
onClick={handleClick}
loading={executeMutation.isPending}
leftSection={lastSuccess ? <IconCheck size={20} /> : <IconPlayerPlay size={20} />}
variant={lastSuccess ? "light" : "filled"}
>
{executeMutation.isPending ? t("executing") : buttonLabel}
</Button>
</Center>
);
}
export const displayComponents: Record<string, ComponentType<{ data: Record<string, unknown> }>> = {
singleValue: SingleValueDisplay,
keyValue: KeyValueDisplay,
table: TableDisplay,
statGrid: StatGridDisplay,
progressBars: ProgressBarsDisplay,
statusIndicator: StatusIndicatorDisplay,
countGrid: CountGridDisplay,
raw: RawDisplay,
actionButton: ActionButtonDisplay,
customJsx: CustomJsxDisplay,
};
export default function CustomApiWidget({ options }: WidgetComponentProps<"customApi">) {
const t = useScopedI18n("widget.customApi");
const { definitionId, refreshInterval } = options;
if (!definitionId) {
return (
<Center h="100%">
<Stack align="center" gap="xs">
<IconAlertTriangle size={32} color="var(--mantine-color-yellow-6)" />
<Text c="dimmed" size="sm">
{t("noDefinition")}
</Text>
</Stack>
</Center>
);
}
return <CustomApiWidgetInner definitionId={definitionId} refreshInterval={refreshInterval as number} />;
}
function CustomApiWidgetInner({ definitionId, refreshInterval }: { definitionId: string; refreshInterval: number }) {
const t = useScopedI18n("widget.customApi");
const tCustomWidget = useScopedI18n("customWidget");
const safeInterval = Number.isFinite(refreshInterval) ? refreshInterval : 30;
const intervalMs = Math.max(1000, safeInterval * 1000);
const { data, isLoading, error } = clientApi.widget.customApi.getData.useQuery(
{ definitionId },
const tCustomJsx = useScopedI18n("widget.customApi.customJsx");
const { data: session } = useSession();
const isAdmin = session?.user.permissions.includes("admin") ?? false;
const definitionId = options.definitionId;
const safeInterval = Number.isFinite(options.refreshInterval) ? options.refreshInterval : 30;
const intervalMs = Math.max(1_000, safeInterval * 1_000);
const query = clientApi.widget.customApi.getData.useQuery(
{ itemId: itemId ?? "" },
{
refetchInterval: (query) => {
const result = query.state.data as Record<string, unknown> | undefined;
if (result?.type === "actionButton" || result?.type === "disabled") return false;
return intervalMs;
},
retry: (failureCount, err) => {
if (err.data?.code === "NOT_FOUND") return false;
return failureCount < 3;
},
enabled: Boolean(itemId) && Boolean(definitionId),
refetchInterval: (currentQuery) =>
isTerminalCustomWidgetDefinitionError(currentQuery.state.error) ? false : intervalMs,
retry: (failureCount, error) => !isTerminalCustomWidgetDefinitionError(error) && failureCount < 3,
},
);
if (isLoading) {
if (!definitionId) {
return <Unavailable message={t("definitionNotFound")} removeLabel={t("removeFromBoard")} onRemove={removeItem} />;
}
if (isEditMode && !itemId) return <Unavailable message={t("editModePending")} />;
if (query.isLoading)
return (
<Center h="100%">
<Loader size="sm" />
</Center>
);
}
if (error) {
const isNotFound = error.data?.code === "NOT_FOUND";
if (query.error) {
const errorCode = query.error.data?.code;
const migrationRequired = isLegacyCustomWidgetMigrationError(query.error);
if (isEditMode && errorCode === "NOT_FOUND") {
return <Unavailable message={t("editModePending")} />;
}
const isUnavailable = errorCode === "NOT_FOUND" || errorCode === "FORBIDDEN";
return (
<Center h="100%">
<Stack align="center" gap="xs">
<IconAlertTriangle size={32} color={`var(--mantine-color-${isNotFound ? "yellow" : "red"}-6)`} />
<Text c="dimmed" size="sm" ta="center">
{isNotFound ? t("definitionNotFound") : error.message}
</Text>
</Stack>
</Center>
<Unavailable
message={
migrationRequired
? t("migrationRequired")
: isUnavailable
? t("definitionNotFound")
: errorCode === "PRECONDITION_FAILED"
? t("configurationNeedsRepair")
: tCustomJsx("requestFailed")
}
danger={!isUnavailable && !migrationRequired}
removeLabel={isUnavailable ? t("removeFromBoard") : undefined}
actionLabel={migrationRequired && isAdmin ? t("manageMigration") : undefined}
actionHref={migrationRequired && isAdmin ? "/manage/custom-widgets" : undefined}
supplementaryMessage={migrationRequired && !isAdmin ? t("contactAdmin") : undefined}
onRemove={removeItem}
/>
);
}
if (!data) return null;
const widgetData = data as Record<string, unknown>;
const dataType = widgetData.type as string | undefined;
if (dataType === "disabled") {
return (
<Center h="100%">
<Text c="dimmed" size="sm">
{tCustomWidget("widget.disabled")}
</Text>
</Center>
);
}
const Component = dataType ? displayComponents[dataType] : undefined;
if (Component) {
const enrichedData = dataType === "actionButton" ? { ...widgetData, widgetDefinitionId: definitionId } : widgetData;
return <Component data={enrichedData} />;
}
if (!query.data) return null;
return (
<ScrollArea h="100%" p="xs">
<Code block style={{ fontSize: 11 }}>
{JSON.stringify(data, null, 2)}
</Code>
</ScrollArea>
<CustomJsxDisplay
data={{
...query.data,
widgetDefinitionId: definitionId,
widgetItemId: itemId,
isEditMode,
}}
/>
);
}
function Unavailable({
message,
danger = false,
removeLabel,
actionLabel,
actionHref,
supplementaryMessage,
onRemove,
}: {
message: string;
danger?: boolean;
removeLabel?: string;
actionLabel?: string;
actionHref?: string;
supplementaryMessage?: string;
onRemove?: () => void;
}) {
return (
<Center h="100%" p="sm">
<Stack align="center" gap="xs">
<IconAlertTriangle size={32} color={`var(--mantine-color-${danger ? "red" : "yellow"}-6)`} />
<Text c="dimmed" size="sm" ta="center">
{message}
</Text>
{supplementaryMessage && (
<Text size="sm" fw={600} ta="center">
{supplementaryMessage}
</Text>
)}
{actionLabel && actionHref && (
<Button component={Link} href={actionHref} size="compact-sm" color="yellow" variant="light">
{actionLabel}
</Button>
)}
{removeLabel && onRemove && (
<Button size="compact-sm" color="red" variant="light" onClick={onRemove}>
{removeLabel}
</Button>
)}
</Stack>
</Center>
);
}
@@ -1,14 +1,47 @@
import { MantineProvider } from "@mantine/core";
import * as MantineCharts from "@mantine/charts";
import * as MantineCore from "@mantine/core";
import * as MantineDates from "@mantine/dates";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { act, createElement } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeAll, describe, expect, it } from "vitest";
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import type { Root } from "react-dom/client";
import { customJsxComponentRegistry, enabledCustomJsxComponents } from "@homarr/custom-widgets/catalog";
import {
BUNDLED_CUSTOM_WIDGETS,
customJsxExamples,
customJsxTablerIconNames,
getCustomWidgetDefaultOptions,
} from "@homarr/custom-widgets/core";
import { renderSafeJsx, SafeJsxError, sanitizeCustomJsxProps } from "@homarr/custom-widgets/jsx";
import CustomJsxDisplay from "./custom-jsx-display";
import { SAFE_BINDINGS } from "./jsx-whitelist";
import { createCustomWidgetComponents, SAFE_BINDINGS } from "./jsx-components";
import { SAFE_TABLER_ICON_NAMES } from "./jsx-icon-adapter";
const CUSTOM_WIDGET_COMPONENTS = createCustomWidgetComponents({ copy: "Copy", copied: "Copied" });
vi.mock("@homarr/translation/client", () => ({
useScopedI18n: () => (key: string, params?: Record<string, string>) =>
key === "templateWarnings" ? `${params?.count ?? "0"} template warning(s):` : key,
}));
const isComponentLikeExport = ([name, value]: [string, unknown]): boolean => {
if (!/^[A-Z][A-Za-z0-9]*$/.test(name) || name.endsWith("Context")) return false;
if (typeof value === "function") return true;
if (!value || typeof value !== "object") return false;
const reactType = (value as { $$typeof?: unknown }).$$typeof;
return reactType === Symbol.for("react.forward_ref") || reactType === Symbol.for("react.memo");
};
describe("CustomJsxDisplay", () => {
it("keeps safe Tabler icon metadata synchronized with the runtime", () => {
expect(SAFE_TABLER_ICON_NAMES).toEqual(customJsxTablerIconNames);
});
let container: HTMLDivElement;
let root: Root;
@@ -27,10 +60,18 @@ describe("CustomJsxDisplay", () => {
dispatchEvent: () => false,
}),
});
Object.defineProperty(globalThis, "ResizeObserver", {
writable: true,
value: class {
observe() {}
unobserve() {}
disconnect() {}
},
});
});
afterEach(() => {
root?.unmount();
afterEach(async () => {
await act(async () => root?.unmount());
container?.remove();
});
@@ -38,8 +79,15 @@ describe("CustomJsxDisplay", () => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
await act(async () => {
root.render(createElement(MantineProvider, null, createElement(CustomJsxDisplay, { data })));
root.render(
createElement(
MantineProvider,
null,
createElement(QueryClientProvider, { client: queryClient }, createElement(CustomJsxDisplay, { data })),
),
);
});
};
@@ -53,6 +101,65 @@ describe("CustomJsxDisplay", () => {
expect(container.textContent).toContain("3");
});
it("keeps runtime components inside an inert provider before a preview session exists", async () => {
await renderDisplay({ template: '<RefreshButton label="Refresh" />', data: {} });
expect(container.querySelector('button[aria-label="Refresh"]')).not.toBeNull();
expect(container.textContent).not.toContain("RUNTIME_RENDER_ERROR");
expect(container.querySelector("button")?.disabled).toBe(true);
});
it("renders the bundled Pokédex list without a widget-wide runtime failure", async () => {
const pokedex = BUNDLED_CUSTOM_WIDGETS.find(({ id }) => id === "seed-pokedex")?.widget;
expect(pokedex).toBeDefined();
await renderDisplay({
template: pokedex?.template,
data: {
pokemon: {
count: 2,
results: [
{ name: "bulbasaur", url: "https://pokeapi.co/api/v2/pokemon/1/" },
{ name: "pikachu", url: "https://pokeapi.co/api/v2/pokemon/25/" },
],
},
},
status: { pokemon: { loading: false, ok: true, status: 200 } },
options: getCustomWidgetDefaultOptions(pokedex?.options ?? {}),
});
expect(container.textContent).toContain("bulbasaur");
expect(container.textContent).toContain("pikachu");
expect(
container.querySelector('img[src="https://cdn.jsdelivr.net/gh/PokeAPI/sprites@master/sprites/pokemon/1.png"]'),
).not.toBeNull();
expect(
container.querySelector('img[src="https://cdn.jsdelivr.net/gh/PokeAPI/sprites@master/sprites/pokemon/25.png"]'),
).not.toBeNull();
expect(container.querySelectorAll("img.mantine-Image-root")).toHaveLength(2);
expect(container.querySelectorAll(".mantine-Card-root")).toHaveLength(2);
expect(container.textContent).not.toContain("RUNTIME_RENDER_ERROR");
});
it("recovers when preview data arrives after an initial render error", async () => {
const template = "<Stack>{data.items.map((item) => <Text key={item}>{item}</Text>)}</Stack>";
await renderDisplay({ template, data: {} });
expect(container.textContent).toContain("RUNTIME_RENDER_ERROR");
await act(async () => {
root.render(
createElement(
MantineProvider,
null,
createElement(CustomJsxDisplay, { data: { template, data: { items: ["Bulbasaur", "Pikachu"] } } }),
),
);
});
expect(container.textContent).toContain("Bulbasaur");
expect(container.textContent).toContain("Pikachu");
expect(container.textContent).not.toContain("RUNTIME_RENDER_ERROR");
});
it("blocks script tags and event handlers", async () => {
await renderDisplay({
template: '<script>document.title = "hacked"</script><button onClick={() => {}}>Click</button><Text>Safe</Text>',
@@ -64,6 +171,127 @@ describe("CustomJsxDisplay", () => {
expect(container.textContent).toContain("Safe");
});
it("supports bounded collection callbacks and safe string helpers", async () => {
await renderDisplay({
template:
'<Stack>{data.items.filter((item) => item.enabled).slice(0, 2).map((item, i) => <Text key={i}>{String(i + 1) + ". " + item.name.toUpperCase()}</Text>)}</Stack>',
data: {
items: [
{ name: "alpha", enabled: true },
{ name: "hidden", enabled: false },
{ name: "beta", enabled: true },
{ name: "gamma", enabled: true },
],
},
});
expect(container.textContent).toContain("1. ALPHA");
expect(container.textContent).toContain("2. BETA");
expect(container.textContent).not.toContain("GAMMA");
});
it("blocks reflective properties assembled with string concatenation", async () => {
await renderDisplay({
template: '<Text>{data["con" + "structor"]}</Text>',
data: { value: 1 },
});
expect(container.textContent).toContain("reflective property");
});
it.each([
'<Text>{data["constr\\u0075ctor"]}</Text>',
'<Text>{data["__pro" + "to__"]}</Text>',
'<Text>{String["b" + "ind"](null)}</Text>',
"<Text>{data.items.map.call(null, (item) => item)}</Text>",
"<Text>{data.items.map.apply(null, [])}</Text>",
])("blocks Unicode, computed, and call/apply reflective escapes", (template) => {
expect(() =>
renderSafeJsx({
template,
components: { Text: (() => null) as never },
bindings: SAFE_BINDINGS({ items: [1] }),
}),
).toThrow(/reflective property/);
});
it("never resolves inherited data properties", () => {
const inherited = Object.create({ secret: "leak" }) as Record<string, unknown>;
inherited.visible = "safe";
const bindings = SAFE_BINDINGS(inherited);
const data = bindings.data as Record<string, unknown>;
expect(data.visible).toBe("safe");
expect(data.secret).toBeUndefined();
expect(Object.getPrototypeOf(data)).toBeNull();
});
it("blocks direct global calls but allows benign text containing fetch", async () => {
await renderDisplay({ template: "<Text>fetch is available through controlled requests</Text>", data: {} });
expect(container.textContent).toContain("fetch is available through controlled requests");
await act(async () => {
root.render(
createElement(
MantineProvider,
null,
createElement(CustomJsxDisplay, { data: { template: '<Text>{fetch("/private")}</Text>', data: {} } }),
),
);
});
expect(container.textContent).toContain("Unknown binding: fetch");
});
it("strips polymorphic, callback, unsafe URL, and escaping style props", () => {
const props = sanitizeCustomJsxProps({
component: "iframe",
renderRoot: "iframe",
withinPortal: true,
onClick: () => undefined,
href: "javascript:alert(1)",
style: { position: "fixed", zIndex: 999_999, flex: 1, color: "red" },
});
expect(props).toEqual({ style: { flex: 1, color: "red" } });
});
it("enforces collection and template length budgets", () => {
const bindings = SAFE_BINDINGS({ items: [1, 2, 3] });
expect(() =>
renderSafeJsx({
template: "<Stack>{data.items.map((item) => <Text>{item}</Text>)}</Stack>",
components: {
Text: ((props: { children?: unknown }) => props.children) as never,
Stack: (() => null) as never,
},
bindings,
budgets: { maxCollectionItems: 2 },
}),
).toThrow(/collection limit/);
expect(() => renderSafeJsx({ template: " ".repeat(50_001), components: {}, bindings: {} })).toThrow(
new SafeJsxError("Template exceeds the 50000 character limit"),
);
expect(() =>
renderSafeJsx({
template: '<Text>{"x".padStart(1000, "x")}</Text>',
components: { Text: (() => null) as never },
bindings: {},
budgets: { maxStringLength: 10 },
}),
).toThrow(/string length limit/);
expect(() =>
renderSafeJsx({
template: "<B/>".repeat(10_000),
components: { B: (() => null) as never },
bindings: {},
}),
).toThrow("Template exceeded the rendered node limit (10000)");
});
describe("safe bindings security", () => {
it("exposes wrapper functions, not native constructors", () => {
const bindings = SAFE_BINDINGS({ value: 7 });
@@ -153,6 +381,17 @@ describe("CustomJsxDisplay", () => {
expect(math.abs(-5)).toBe(5);
});
it("Date helpers only return primitives and getDay returns the weekday", () => {
const bindings = SAFE_BINDINGS({});
const date = bindings.Date as {
create: (value: string) => number;
getDay: (value: string) => number;
};
expect(typeof date.create("2024-01-07T12:00:00Z")).toBe("number");
expect(date.getDay("2024-01-07T12:00:00Z")).toBe(0);
});
it("JSON.stringify works correctly", () => {
const bindings = SAFE_BINDINGS({});
const json = bindings.JSON as { stringify: (v: unknown) => string };
@@ -176,3 +415,209 @@ describe("CustomJsxDisplay", () => {
});
});
});
describe("Custom JSX component registry", () => {
it.each([
["@mantine/core", MantineCore],
["@mantine/charts", MantineCharts],
["@mantine/dates", MantineDates],
] as const)("classifies every component-like %s export", (packageName, moduleExports) => {
const classified = new Set(
customJsxComponentRegistry
.filter((component) => component.package === packageName)
.flatMap(({ name }) => [name, name.replaceAll(".", "")]),
);
const unclassified = Object.entries(moduleExports)
.filter(isComponentLikeExport)
.map(([name]) => name)
.filter((name) => !classified.has(name));
expect(unclassified, `Unclassified ${packageName} component exports`).toEqual([]);
});
it("has a runtime implementation for every enabled registry component", () => {
const missing = enabledCustomJsxComponents
.map(({ name }) => name)
.filter((name) => !Object.hasOwn(CUSTOM_WIDGET_COMPONENTS, name));
expect(missing, "Enabled components without a runtime implementation").toEqual([]);
});
});
const renderTemplate = (template: string, data: Record<string, unknown> = {}) =>
renderSafeJsx({ template, components: CUSTOM_WIDGET_COMPONENTS, bindings: SAFE_BINDINGS(data) });
describe("new collection/string methods", () => {
let container: HTMLDivElement;
let root: Root;
afterEach(async () => {
await act(async () => root?.unmount());
container?.remove();
});
const renderAndGetText = async (template: string, data: Record<string, unknown> = {}) => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root.render(createElement(MantineProvider, null, createElement(CustomJsxDisplay, { data: { template, data } })));
});
return container.textContent ?? "";
};
it("find returns the first matching item", async () => {
const text = await renderAndGetText(`<Text>{data.items.find((x) => x.id === 2).name}</Text>`, {
items: [
{ id: 1, name: "a" },
{ id: 2, name: "b" },
],
});
expect(text).toContain("b");
});
it("findIndex returns correct index", async () => {
const text = await renderAndGetText(`<Text>{data.items.findIndex((x) => x > 5)}</Text>`, { items: [1, 3, 7, 9] });
expect(text).toContain("2");
});
it("some returns boolean", async () => {
const text = await renderAndGetText(`<Text>{data.items.some((x) => x > 5) ? "yes" : "no"}</Text>`, {
items: [1, 2, 3],
});
expect(text).toContain("no");
});
it("every returns boolean", async () => {
const text = await renderAndGetText(`<Text>{data.items.every((x) => x > 0) ? "all" : "nope"}</Text>`, {
items: [1, 2, 3],
});
expect(text).toContain("all");
});
it("sort with comparator returns stable order on NaN", async () => {
const text = await renderAndGetText(
`<Text>{data.items.sort((a, b) => a.v - b.v).map((x) => x.n).join(",")}</Text>`,
{ items: [{ n: "b", v: 2 }, { n: "a", v: 1 }, { n: "c" }] },
);
expect(text).toContain("a,");
expect(text).toContain("b");
});
it("reduce accumulates values", async () => {
const text = await renderAndGetText(`<Text>{data.nums.reduce((acc, n) => acc + n, 0)}</Text>`, {
nums: [1, 2, 3],
});
expect(text).toContain("6");
});
it("at returns element by negative index", async () => {
const text = await renderAndGetText(`<Text>{data.items.at(-1)}</Text>`, { items: ["a", "b", "c"] });
expect(text).toContain("c");
});
it("flat(2) throws SafeJsxError", () => {
expect(() => renderTemplate(`<Text>{data.items.flat(2)}</Text>`, { items: [[1, [2]]] })).toThrow(SafeJsxError);
});
it("flat(1) flattens one level", async () => {
const text = await renderAndGetText(`<Text>{data.items.flat().join(",")}</Text>`, { items: [[1, 2], [3]] });
expect(text).toContain("1,2,3");
});
it("reverse returns reversed copy", async () => {
const text = await renderAndGetText(`<Text>{data.items.reverse().join(",")}</Text>`, { items: [1, 2, 3] });
expect(text).toContain("3,2,1");
});
it("string replaceAll works", async () => {
const text = await renderAndGetText(`<Text>{data.s.replaceAll("o", "0")}</Text>`, { s: "foobar" });
expect(text).toContain("f00bar");
});
it("string replaceAll with empty search is a no-op", async () => {
const text = await renderAndGetText(`<Text>{data.s.replaceAll("", "x")}</Text>`, { s: "abc" });
expect(text).toContain("abc");
});
it("string repeat caps at budget", async () => {
const text = await renderAndGetText(`<Text>{data.s.repeat(3)}</Text>`, { s: "hi" });
expect(text).toContain("hihihi");
});
it("string indexOf and lastIndexOf return positions", async () => {
const text = await renderAndGetText(`<Text>{data.s.indexOf("l")}-{data.s.lastIndexOf("l")}</Text>`, {
s: "hello",
});
expect(text).toContain("2-3");
});
it("string trimStart and trimEnd work", async () => {
const text = await renderAndGetText(`<Text>{data.s.trimStart().trimEnd()}</Text>`, { s: " hi " });
expect(text).toContain("hi");
});
it("filter(Boolean) removes falsy values", async () => {
const text = await renderAndGetText(`<Text>{data.items.filter(Boolean).join(",")}</Text>`, {
items: ["a", "", "b", null, "c"],
});
expect(text).toContain("a,b,c");
});
it("pop returns last element", async () => {
const text = await renderAndGetText(`<Text>{data.url.split("/").filter(Boolean).pop()}</Text>`, {
url: "https://pokeapi.co/api/v2/pokemon/1/",
});
expect(text).toContain("1");
});
it("String(n).padStart works for number formatting", async () => {
const text = await renderAndGetText(`<Text>{String(data.n + 1).padStart(4, "0")}</Text>`, { n: 5 });
expect(text).toContain("0006");
});
it("number arithmetic in string concatenation", async () => {
const text = await renderAndGetText(`<Text>{"https://example.com/" + (data.index + 1) + ".png"}</Text>`, {
index: 3,
});
expect(text).toContain("https://example.com/4.png");
});
});
describe("shared Custom JSX examples", () => {
const sampleData: Record<string, unknown> = {
name: "Server A",
status: "online",
value: 42,
count: 1351,
results: [{ name: "bulbasaur" }, { name: "ivysaur" }],
holdings: [{ symbol: "HOMR", price: 42.5, change: 1.2, history: [38, 40, 42.5] }],
timeline: [
{ date: "2026-07-11", value: 40 },
{ date: "2026-07-12", value: 42.5 },
],
albumArt: "https://example.com/cover.jpg",
album: "Album",
title: "Track",
artist: "Artist",
progress: 55,
playing: true,
shuffle: false,
state: "on",
};
it.each(customJsxExamples)("parses and creates the $id renderer tree", (example) => {
const rendered = renderSafeJsx({
template: example.widget.template,
components: CUSTOM_WIDGET_COMPONENTS,
bindings: {
...SAFE_BINDINGS(sampleData),
status: {},
options: getCustomWidgetDefaultOptions(example.widget.options ?? {}),
inputs: {},
},
});
expect(rendered.warnings).toEqual([]);
expect(rendered.node).not.toBeNull();
});
});
@@ -1,74 +1,92 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import JsxParser from "react-jsx-parser";
import { Alert, Stack, Text } from "@mantine/core";
import { IconAlertTriangle } from "@tabler/icons-react";
import { WHITELISTED_COMPONENTS, SAFE_BINDINGS } from "./jsx-whitelist";
import { useScopedI18n } from "@homarr/translation/client";
import type { CustomWidgetPublishedQueryState } from "@homarr/custom-widgets/runtime";
import { CustomJsxRenderer, parseRequestCapabilities } from "@homarr/custom-widgets/runtime";
const MAX_PARSE_ERRORS = 5;
import { createCustomWidgetComponents, SAFE_BINDINGS } from "./jsx-components";
import { InactiveWidgetDefinitionProvider, WidgetDefinitionProvider } from "./widget-definition-context";
function appendParseError(prev: string[], message: string): string[] {
if (prev.length >= MAX_PARSE_ERRORS) return prev;
if (prev.includes(message)) return prev;
return [...prev, message];
}
export { CUSTOM_JSX_METHOD_COLORS } from "@homarr/custom-widgets/runtime";
export default function CustomJsxDisplay({ data }: { data: Record<string, unknown> }) {
const template = String(data.template ?? "");
const apiData = data.data;
const [parseErrors, setParseErrors] = useState<string[]>([]);
const bindings = useMemo(() => SAFE_BINDINGS(apiData), [apiData]);
useEffect(() => {
setParseErrors([]);
}, [template, bindings]);
const handleError = useCallback((error: Error) => {
setParseErrors((prev) => appendParseError(prev, error.message));
const t = useScopedI18n("widget.customApi.customJsx");
const capabilities = useMemo(() => parseRequestCapabilities(data.requestCapabilities), [data.requestCapabilities]);
const components = useMemo(() => createCustomWidgetComponents({ copy: t("copy"), copied: t("copied") }), [t]);
const [queryState, setQueryState] = useState<Record<string, CustomWidgetPublishedQueryState>>({});
useEffect(
() => setQueryState({}),
[data.widgetDefinitionId, data.widgetItemId, data.previewSessionId, data.template],
);
const publishQueryState = useCallback((requestId: string, value: CustomWidgetPublishedQueryState | null) => {
setQueryState((current) => {
if (!value) {
if (!Object.hasOwn(current, requestId)) return current;
const next = { ...current };
delete next[requestId];
return next;
}
const previous = current[requestId];
if (previous && previous.data === value.data && sameStatus(previous.status, value.status)) return current;
return { ...current, [requestId]: value };
});
}, []);
if (!template.trim()) {
const baseData = isRecord(data.data) ? data.data : {};
const baseStatus = isRecord(data.status) ? data.status : {};
const renderer = (
<CustomJsxRenderer
template={String(data.template ?? "")}
data={{ ...baseData, ...Object.fromEntries(Object.entries(queryState).map(([id, value]) => [id, value.data])) }}
status={{
...baseStatus,
...Object.fromEntries(Object.entries(queryState).map(([id, value]) => [id, value.status])),
}}
options={isRecord(data.options) ? data.options : {}}
components={components}
createBindings={SAFE_BINDINGS}
messages={{
noTemplate: t("noTemplate"),
templateWarnings: (count) => t("templateWarnings", { count: String(count) }),
}}
/>
);
const definitionId = typeof data.widgetDefinitionId === "string" ? data.widgetDefinitionId : undefined;
const itemId = typeof data.widgetItemId === "string" ? data.widgetItemId : undefined;
const previewSessionId = typeof data.previewSessionId === "string" ? data.previewSessionId : undefined;
if (!itemId && !previewSessionId) {
return (
<Alert color="gray" variant="light" p="xs">
<Text size="xs" c="dimmed">
No JSX template configured
</Text>
</Alert>
<InactiveWidgetDefinitionProvider definitionId={definitionId} isEditMode={data.isEditMode === true}>
{renderer}
</InactiveWidgetDefinitionProvider>
);
}
return (
<Stack gap={0} h="100%">
<JsxParser
jsx={template}
components={WHITELISTED_COMPONENTS as never}
bindings={bindings}
disableKeyGeneration
componentsOnly
allowUnknownElements={false}
blacklistedAttrs={[/^on.+/i, /^dangerously/i]}
blacklistedTags={["script", "iframe", "object", "embed", "form", "style", "link", "meta", "base"]}
onError={handleError}
renderError={({ error }) => (
<Alert color="red" variant="light" icon={<IconAlertTriangle size={16} />} p="xs">
<Text size="xs">{String(error)}</Text>
</Alert>
)}
/>
{parseErrors.length > 0 && (
<Alert color="yellow" variant="light" p="xs" mt="xs">
<Text size="xs" c="dimmed">
{parseErrors.length} template warning(s):
</Text>
{parseErrors.map((msg) => (
<Text key={msg} size="xs" c="dimmed" style={{ fontFamily: "monospace" }}>
{msg}
</Text>
))}
</Alert>
)}
</Stack>
<WidgetDefinitionProvider
definitionId={definitionId}
itemId={itemId}
previewSessionId={previewSessionId}
previewLiveActions={data.previewLiveActions === true}
queriesDisabled={data.queriesDisabled === true}
isEditMode={data.isEditMode === true}
requestCapabilities={capabilities}
setQueryState={publishQueryState}
>
{renderer}
</WidgetDefinitionProvider>
);
}
function sameStatus(left: CustomWidgetPublishedQueryState["status"], right: CustomWidgetPublishedQueryState["status"]) {
return (
left.loading === right.loading &&
left.ok === right.ok &&
left.status === right.status &&
left.statusText === right.statusText &&
left.error === right.error
);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
@@ -1 +0,0 @@
export { extractDisplayData } from "@homarr/api/custom-widget/display-data";
+2
View File
@@ -9,6 +9,8 @@ export const { definition, componentLoader } = createWidgetDefinition("customApi
createOptions() {
return optionsBuilder.from((factory) => ({
definitionId: factory.customWidgetSelect({ defaultValue: "" }),
configuration: factory.customWidgetConfiguration(),
configurationVersion: factory.internal({ defaultValue: 1 }),
refreshInterval: factory.slider({
defaultValue: 30,
validate: z.number().min(1).max(3600),
@@ -0,0 +1,12 @@
import type { ComponentType } from "react";
import { createCustomJsxBindings, createCustomJsxComponents } from "@homarr/custom-widgets/jsx";
import { SafeTablerIcon } from "./jsx-icon-adapter";
// Labels are replaced by the localized runtime adapter in the renderer. These
// fallbacks are used only in isolated component-map tests.
export const createCustomWidgetComponents = (copyLabels: { copy: string; copied: string }) =>
createCustomJsxComponents({ TablerIcon: SafeTablerIcon as ComponentType<never>, copyLabels });
export const SAFE_BINDINGS = createCustomJsxBindings;
@@ -0,0 +1,248 @@
import {
IconActivity,
IconAlertCircle,
IconAlertTriangle,
IconApi,
IconArrowDown,
IconArrowLeft,
IconArrowRight,
IconArrowUp,
IconBell,
IconBluetooth,
IconBolt,
IconBookmark,
IconBrandDocker,
IconBug,
IconCalendar,
IconCamera,
IconChartBar,
IconChartLine,
IconChartPie,
IconCheck,
IconChevronDown,
IconChevronLeft,
IconChevronRight,
IconChevronUp,
IconCircleCheck,
IconCircleX,
IconClipboard,
IconClock,
IconCloud,
IconCloudOff,
IconCode,
IconCompass,
IconCopy,
IconCpu,
IconDatabase,
IconDeviceDesktop,
IconDeviceMobile,
IconDownload,
IconDroplet,
IconEdit,
IconExternalLink,
IconEye,
IconEyeOff,
IconFile,
IconFlag,
IconFlame,
IconFolder,
IconGauge,
IconGitBranch,
IconHeart,
IconHistory,
IconHome,
IconInfoCircle,
IconKey,
IconLink,
IconLoader,
IconLock,
IconLockOpen,
IconMail,
IconMap,
IconMapPin,
IconMinus,
IconMoon,
IconMusic,
IconNetwork,
IconPhoto,
IconPokeball,
IconPin,
IconPlayerPause,
IconPlayerPlay,
IconPlayerSkipBack,
IconPlayerSkipForward,
IconPlayerStop,
IconPlus,
IconPower,
IconProgress,
IconHeartbeat,
IconRefresh,
IconReload,
IconSearch,
IconServer,
IconSettings,
IconShield,
IconStar,
IconSun,
IconTerminal,
IconThermometer,
IconTrash,
IconTrendingDown,
IconTrendingUp,
IconUpload,
IconUser,
IconUsers,
IconVideo,
IconVolume,
IconVolume2,
IconVolumeOff,
IconWifi,
IconWifiOff,
IconWorld,
IconX,
IconSpeedboat,
} from "@tabler/icons-react";
function tablerIconExportToKey(exportName: string): string {
return exportName
.slice(4)
.replace(/([a-z0-9])([A-Z])/g, "$1-$2")
.replace(/([A-Z]+)([A-Z][a-z])/g, "$1-$2")
.replace(/([A-Za-z])(\d)/g, "$1-$2")
.toLowerCase();
}
const TABLER_ICON_IMPORTS = {
IconCheck,
IconX,
IconPlus,
IconMinus,
IconArrowUp,
IconArrowDown,
IconArrowLeft,
IconArrowRight,
IconChevronUp,
IconChevronDown,
IconChevronLeft,
IconChevronRight,
IconHome,
IconSettings,
IconUser,
IconUsers,
IconSearch,
IconBell,
IconMail,
IconHeart,
IconStar,
IconBookmark,
IconBrandDocker,
IconFlag,
IconPin,
IconPlayerPlay,
IconPlayerPause,
IconPlayerStop,
IconPlayerSkipForward,
IconPlayerSkipBack,
IconVolume,
IconVolumeOff,
IconVolume2,
IconWifi,
IconWifiOff,
IconBluetooth,
IconCloud,
IconCloudOff,
IconServer,
IconDatabase,
IconCpu,
IconDeviceDesktop,
IconDeviceMobile,
IconFolder,
IconFile,
IconDownload,
IconUpload,
IconRefresh,
IconReload,
IconPower,
IconBolt,
IconFlame,
IconDroplet,
IconSun,
IconMoon,
IconEye,
IconEyeOff,
IconLock,
IconLockOpen,
IconShield,
IconKey,
IconAlertTriangle,
IconAlertCircle,
IconInfoCircle,
IconCircleCheck,
IconCircleX,
IconTrash,
IconEdit,
IconCopy,
IconClipboard,
IconExternalLink,
IconLink,
IconPhoto,
IconPokeball,
IconCamera,
IconMusic,
IconVideo,
IconCalendar,
IconClock,
IconHistory,
IconMap,
IconMapPin,
IconCompass,
IconTerminal,
IconCode,
IconBug,
IconGitBranch,
IconNetwork,
IconWorld,
IconApi,
IconChartBar,
IconChartLine,
IconChartPie,
IconTrendingUp,
IconTrendingDown,
IconActivity,
IconProgress,
IconLoader,
IconThermometer,
IconGauge,
IconSpeedboat,
IconHeartbeat,
} as const;
const TABLER_ICON_MAP: Record<string, ComponentType<never>> = {
...Object.fromEntries(
Object.entries(TABLER_ICON_IMPORTS).map(([exportName, IconComponent]) => [
tablerIconExportToKey(exportName),
IconComponent,
]),
),
pulse: IconHeartbeat,
};
export const SAFE_TABLER_ICON_NAMES = Object.freeze(Object.keys(TABLER_ICON_MAP).toSorted());
function isValidIconProp(value: unknown): value is string | number {
return typeof value === "number" || typeof value === "string";
}
export function SafeTablerIcon(props: Record<string, unknown>) {
const name = props.name;
if (typeof name !== "string") return null;
const IconComponent = TABLER_ICON_MAP[name];
if (!IconComponent) return null;
const iconProps: Record<string, unknown> = {};
if (isValidIconProp(props.size)) iconProps.size = props.size;
if (typeof props.color === "string") iconProps.color = props.color;
if (isValidIconProp(props.stroke)) iconProps.stroke = props.stroke;
return createElement(IconComponent as never, iconProps);
}
import { createElement } from "react";
import type { ComponentType } from "react";
@@ -1,228 +0,0 @@
"use client";
import type { ReactNode } from "react";
import { Children, isValidElement, useEffect, useRef, useState } from "react";
import { ActionIcon, Badge, Collapse, Group, Stack, Tabs, Text, UnstyledButton } from "@mantine/core";
import { IconChevronDown, IconChevronLeft, IconChevronRight, IconChevronUp } from "@tabler/icons-react";
interface PaginatedListProps {
children: ReactNode;
pageSize?: number;
}
export function PaginatedList({ children, pageSize = 6 }: PaginatedListProps) {
const items = Children.toArray(children);
const totalPages = Math.max(1, Math.ceil(items.length / pageSize));
const [page, setPage] = useState(0);
const prevCountRef = useRef(items.length);
useEffect(() => {
if (items.length !== prevCountRef.current) {
setPage(0);
prevCountRef.current = items.length;
}
}, [items.length]);
const clampedPage = Math.min(page, totalPages - 1);
const start = clampedPage * pageSize;
const visible = items.slice(start, start + pageSize);
return (
<Stack gap="xs">
<div>{visible}</div>
{totalPages > 1 && (
<Group justify="center" gap="xs">
<ActionIcon
variant="subtle"
size="sm"
disabled={clampedPage === 0}
onClick={() => setPage((p) => Math.max(0, p - 1))}
>
<IconChevronLeft size={14} />
</ActionIcon>
<Text size="xs" c="dimmed">
{clampedPage + 1} / {totalPages}
</Text>
<ActionIcon
variant="subtle"
size="sm"
disabled={clampedPage >= totalPages - 1}
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
>
<IconChevronRight size={14} />
</ActionIcon>
</Group>
)}
</Stack>
);
}
interface TabsContainerProps {
children: ReactNode;
defaultTab?: string;
}
function isTabPanel(child: ReactNode): child is React.ReactElement<TabPanelProps> {
return isValidElement<TabPanelProps>(child) && child.type === TabPanel && typeof child.props.value === "string";
}
export function TabsContainer({ children, defaultTab }: TabsContainerProps) {
const panels = Children.toArray(children).filter(isTabPanel);
const firstPanel = panels[0];
const panelValues = new Set(panels.map((p) => p.props.value));
const resolvedDefault = defaultTab && panelValues.has(defaultTab) && defaultTab;
const initialTab = resolvedDefault || firstPanel?.props.value || null;
const [activeTab, setActiveTab] = useState<string | null>(initialTab);
return (
<Tabs value={activeTab} onChange={setActiveTab}>
<Tabs.List>
{panels.map((panel) => (
<Tabs.Tab key={panel.props.value} value={panel.props.value}>
{panel.props.label ?? panel.props.value}
</Tabs.Tab>
))}
</Tabs.List>
{panels.map((panel) => (
<Tabs.Panel key={panel.props.value} value={panel.props.value} pt="sm">
{panel.props.children}
</Tabs.Panel>
))}
</Tabs>
);
}
interface TabPanelProps {
children: ReactNode;
value: string;
label?: string;
}
export function TabPanel({ children }: TabPanelProps) {
return <>{children}</>;
}
interface CollapsibleProps {
children: ReactNode;
title: string;
defaultOpen?: boolean;
}
const COLLAPSE_CHEVRONS = {
closed: IconChevronDown,
open: IconChevronUp,
} as const;
const COLLAPSE_STATE: Record<0 | 1, keyof typeof COLLAPSE_CHEVRONS> = {
0: "closed",
1: "open",
};
export function Collapsible({ children, title, defaultOpen = false }: CollapsibleProps) {
const [opened, setOpened] = useState(defaultOpen);
const Chevron = COLLAPSE_CHEVRONS[COLLAPSE_STATE[Number(opened) as 0 | 1]];
return (
<Stack gap={0}>
<UnstyledButton onClick={() => setOpened((o) => !o)} py={4}>
<Group gap="xs" wrap="nowrap">
<Chevron size={14} />
<Text size="sm" fw={600}>
{title}
</Text>
</Group>
</UnstyledButton>
<Collapse expanded={opened}>{children}</Collapse>
</Stack>
);
}
interface StatBarProps {
value: number;
max?: number;
label?: string;
color?: string;
}
function calcPct(value: number, max: number): number {
if (max <= 0) return 0;
return Math.min((value / max) * 100, 100);
}
export function StatBar({ value, max = 100, label, color = "blue" }: StatBarProps) {
const pct = calcPct(value, max);
return (
<Group gap="xs" wrap="nowrap">
{label && (
<Text size="xs" w={80} ta="right" c="dimmed">
{label}
</Text>
)}
<div
style={{
flex: 1,
height: 8,
borderRadius: 4,
background: "var(--mantine-color-default-border)",
overflow: "hidden",
}}
>
<div
style={{
width: `${pct}%`,
height: "100%",
borderRadius: 4,
background: `var(--mantine-color-${color}-6)`,
transition: "width 0.3s ease",
}}
/>
</div>
<Text size="xs" w={30} c="dimmed">
{value}
</Text>
</Group>
);
}
interface TypeBadgeProps {
type: string;
size?: "xs" | "sm" | "md" | "lg" | "xl";
}
const TYPE_COLORS: Record<string, string> = {
normal: "gray",
fire: "red",
water: "blue",
electric: "yellow",
grass: "green",
ice: "cyan",
fighting: "orange",
poison: "grape",
ground: "yellow",
flying: "indigo",
psychic: "pink",
bug: "lime",
rock: "orange",
ghost: "violet",
dragon: "indigo",
dark: "dark",
steel: "gray",
fairy: "pink",
};
function normalizeType(type: unknown): string {
if (typeof type === "string") return type;
if (type == null) return "";
return String(type);
}
export function TypeBadge({ type, size = "sm" }: TypeBadgeProps) {
const typeStr = normalizeType(type);
const color = TYPE_COLORS[typeStr.toLowerCase()] ?? "gray";
return (
<Badge color={color} size={size} variant="filled" tt="capitalize">
{typeStr}
</Badge>
);
}
@@ -1,192 +0,0 @@
import type { ComponentType } from "react";
import { createElement } from "react";
import {
Stack,
Group,
Flex,
Grid,
SimpleGrid,
Center,
Space,
Container,
AspectRatio,
Text,
Title,
Code,
Highlight,
Mark,
Kbd,
Blockquote,
Anchor,
NumberFormatter,
Badge,
Card,
Paper,
Alert,
ThemeIcon,
ColorSwatch,
Table,
List,
Timeline,
Accordion,
Indicator,
Pill,
Spoiler,
Progress,
RingProgress,
Skeleton,
Loader,
Image,
Avatar,
BackgroundImage,
Tooltip,
Divider,
ScrollArea,
} from "@mantine/core";
import {
AreaChart,
BarChart,
LineChart,
DonutChart,
PieChart,
RadarChart,
RadialBarChart,
Sparkline,
} from "@mantine/charts";
import { PaginatedList, TabsContainer, TabPanel, Collapsible, StatBar, TypeBadge } from "./jsx-interactive-components";
const SAFE_URL_PATTERN = /^https?:\/\//i;
function isSafeUrl(url: unknown): boolean {
if (typeof url !== "string") return false;
return SAFE_URL_PATTERN.test(url) || url.startsWith("/") || url.startsWith("#");
}
function SafeAnchor(props: Record<string, unknown>) {
const href = props.href;
const safeHref = href && isSafeUrl(href) ? href : undefined;
return createElement(Anchor as never, { ...props, href: safeHref });
}
export const WHITELISTED_COMPONENTS: Record<string, ComponentType<never>> = {
Stack,
Group,
Flex,
Grid,
"Grid.Col": Grid.Col,
SimpleGrid,
Center,
Space,
Container,
AspectRatio,
Text,
Title,
Code,
Highlight,
Mark,
Kbd,
Blockquote,
Anchor: SafeAnchor,
NumberFormatter,
Badge,
Card,
"Card.Section": Card.Section,
Paper,
Alert,
ThemeIcon,
ColorSwatch,
Table,
"Table.Thead": Table.Thead,
"Table.Tbody": Table.Tbody,
"Table.Tr": Table.Tr,
"Table.Th": Table.Th,
"Table.Td": Table.Td,
List,
"List.Item": List.Item,
Timeline,
"Timeline.Item": Timeline.Item,
Accordion,
"Accordion.Item": Accordion.Item,
"Accordion.Control": Accordion.Control,
"Accordion.Panel": Accordion.Panel,
Indicator,
Pill,
Spoiler,
Progress,
"Progress.Section": Progress.Section,
RingProgress,
Skeleton,
Loader,
Image,
Avatar,
"Avatar.Group": Avatar.Group,
BackgroundImage,
Tooltip,
Divider,
ScrollArea,
AreaChart,
BarChart,
LineChart,
DonutChart,
PieChart,
RadarChart,
RadialBarChart,
Sparkline,
PaginatedList,
TabsContainer,
TabPanel,
Collapsible,
StatBar,
TypeBadge,
};
function sanitizeData(obj: unknown): unknown {
if (obj === null || obj === undefined) return obj;
if (typeof obj !== "object") return obj;
if (Array.isArray(obj)) return obj.map(sanitizeData);
const safe: Record<string, unknown> = Object.create(null);
for (const [key, value] of Object.entries(obj as Record<string, unknown>)) {
if (key === "constructor" || key === "__proto__" || key === "prototype") continue;
safe[key] = sanitizeData(value);
}
return safe;
}
const safeMath: Record<string, unknown> = Object.create(null);
safeMath.round = (v: number) => Math.round(v);
safeMath.floor = (v: number) => Math.floor(v);
safeMath.ceil = (v: number) => Math.ceil(v);
safeMath.abs = (v: number) => Math.abs(v);
safeMath.min = (...args: number[]) => Math.min(...args);
safeMath.max = (...args: number[]) => Math.max(...args);
safeMath.pow = (b: number, e: number) => Math.pow(b, e);
safeMath.sqrt = (v: number) => Math.sqrt(v);
safeMath.PI = Math.PI;
Object.freeze(safeMath);
const safeJSON: Record<string, unknown> = Object.create(null);
safeJSON.stringify = (v: unknown) => JSON.stringify(v);
Object.freeze(safeJSON);
const safeArray: Record<string, unknown> = Object.create(null);
safeArray.isArray = (v: unknown) => Array.isArray(v);
Object.freeze(safeArray);
const safeObject: Record<string, unknown> = Object.create(null);
safeObject.keys = (v: object) => Object.keys(v);
safeObject.values = (v: object) => Object.values(v);
safeObject.entries = (v: object) => Object.entries(v);
Object.freeze(safeObject);
export const SAFE_BINDINGS = (apiData: unknown) => ({
data: sanitizeData(apiData),
String: (v: unknown) => String(v),
Number: (v: unknown) => Number(v),
Boolean: (v: unknown) => Boolean(v),
Math: safeMath,
JSON: safeJSON,
Array: safeArray,
Object: safeObject,
});
@@ -0,0 +1,32 @@
import { describe, expect, it } from "vitest";
import { isLegacyCustomWidgetMigrationError, isTerminalCustomWidgetDefinitionError } from "./migration-state";
describe("custom widget migration state", () => {
it("recognizes the preserved legacy definition error and stops retries", () => {
const error = {
message: "LEGACY_CUSTOM_WIDGET_MIGRATION_REQUIRED",
data: { code: "PRECONDITION_FAILED" },
};
expect(isLegacyCustomWidgetMigrationError(error)).toBe(true);
expect(isTerminalCustomWidgetDefinitionError(error)).toBe(true);
});
it("does not label an invalid v2 configuration as a legacy migration", () => {
const error = {
message: "Custom widget configuration needs repair",
data: { code: "PRECONDITION_FAILED" },
};
expect(isLegacyCustomWidgetMigrationError(error)).toBe(false);
expect(isTerminalCustomWidgetDefinitionError(error)).toBe(true);
});
it("allows transient request failures to retry", () => {
const error = { message: "Request failed", data: { code: "INTERNAL_SERVER_ERROR" } };
expect(isLegacyCustomWidgetMigrationError(error)).toBe(false);
expect(isTerminalCustomWidgetDefinitionError(error)).toBe(false);
});
});
@@ -0,0 +1,13 @@
const LEGACY_MIGRATION_ERROR = "LEGACY_CUSTOM_WIDGET_MIGRATION_REQUIRED";
const TERMINAL_DEFINITION_CODES = new Set(["NOT_FOUND", "FORBIDDEN", "PRECONDITION_FAILED"]);
export function isLegacyCustomWidgetMigrationError(error: unknown): boolean {
return Boolean(error && typeof error === "object" && "message" in error && error.message === LEGACY_MIGRATION_ERROR);
}
export function isTerminalCustomWidgetDefinitionError(error: unknown): boolean {
if (!error || typeof error !== "object" || !("data" in error)) return false;
const data = error.data;
if (!data || typeof data !== "object" || !("code" in data) || typeof data.code !== "string") return false;
return TERMINAL_DEFINITION_CODES.has(data.code);
}
@@ -0,0 +1,166 @@
"use client";
import type { ReactNode } from "react";
import { fetchApi, clientApi } from "@homarr/api/client";
import type {
CustomWidgetPublishedQueryState,
CustomJsxRequestCapability,
CustomWidgetRuntimeMessages,
CustomWidgetRuntimePort,
} from "@homarr/custom-widgets/runtime";
import { CustomWidgetRuntimeProvider } from "@homarr/custom-widgets/runtime";
import { useConfirmModal } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import { useQueryClient } from "@tanstack/react-query";
interface WidgetDefinitionProviderProps {
itemId?: string;
definitionId?: string;
previewSessionId?: string;
previewLiveActions?: boolean;
queriesDisabled?: boolean;
isEditMode?: boolean;
requestCapabilities?: readonly CustomJsxRequestCapability[];
setQueryState?(requestId: string, value: CustomWidgetPublishedQueryState | null): void;
children: ReactNode;
}
const INACTIVE_PORT: CustomWidgetRuntimePort = {
query: async () => ({ ok: false, status: 0, data: null, error: "Custom widget unavailable" }),
executeAction: async () => ({ ok: false, status: 0, data: null, error: "Custom widget unavailable" }),
invalidate: async () => undefined,
confirm: async () => false,
notify: () => undefined,
};
const useRuntimeMessages = (): CustomWidgetRuntimeMessages => {
const t = useScopedI18n("widget.customApi.customJsx");
return {
requestIdRequired: t("requestIdRequired"),
unsavedPreview: t("unsavedPreview"),
invalidParams: t("invalidParams"),
loadRequest: t("loadRequest"),
requestFailed: t("requestFailed"),
loading: t("loading"),
retry: t("retry"),
widgetItemUnavailable: t("widgetItemUnavailable"),
actionsDisabledEditMode: t("actionsDisabledEditMode"),
actionSimulated: t("actionSimulated"),
actionCompleted: t("actionCompleted"),
confirmDelete: t("confirmDelete"),
toggle: t("toggle"),
refresh: t("refresh"),
};
};
export function InactiveWidgetDefinitionProvider({
definitionId,
isEditMode,
children,
}: Pick<WidgetDefinitionProviderProps, "definitionId" | "isEditMode" | "children">) {
const messages = useRuntimeMessages();
return (
<CustomWidgetRuntimeProvider
definitionId={definitionId}
isEditMode={isEditMode ?? false}
requestCapabilities={[]}
port={INACTIVE_PORT}
messages={messages}
>
{children}
</CustomWidgetRuntimeProvider>
);
}
export function WidgetDefinitionProvider(props: WidgetDefinitionProviderProps) {
const utils = clientApi.useUtils();
const queryClient = useQueryClient();
const { openConfirmModal } = useConfirmModal();
const messages = useRuntimeMessages();
const port: CustomWidgetRuntimePort = {
query: async (input, signal) => {
if (input.itemId) {
return fetchApi.widget.customApi.queryRequest.query(
{ itemId: input.itemId, requestId: input.requestId, params: input.params },
{ signal },
);
}
return fetchApi.customWidget.previewQuery.query(
{ sessionId: input.previewSessionId ?? "", requestId: input.requestId, params: input.params },
{ signal },
);
},
executeAction: (input) =>
input.itemId
? fetchApi.widget.customApi.executeAction.mutate({
itemId: input.itemId,
requestId: input.requestId,
params: input.params,
confirmed: input.confirmed,
})
: fetchApi.customWidget.previewAction.mutate({
sessionId: input.previewSessionId ?? "",
requestId: input.requestId,
params: input.params,
confirmed: input.confirmed,
}),
invalidate: async ({ itemId, previewSessionId, targets }) => {
if (previewSessionId) {
await queryClient.invalidateQueries({
predicate: (query) => {
const key = query.queryKey;
return key[0] === "custom-widget" && key[1] === previewSessionId && targets.includes(String(key[2]));
},
});
return;
}
if (!itemId) return;
if (targets.length === 0) return;
const tasks: Promise<unknown>[] = [
queryClient.invalidateQueries({
predicate: (query) => {
const key = query.queryKey;
return key[0] === "custom-widget" && key[1] === itemId && targets.includes(String(key[2]));
},
}),
];
if (
(props.requestCapabilities ?? []).some((request) => request.trigger === "load" && targets.includes(request.id))
) {
tasks.push(utils.widget.customApi.getData.invalidate({ itemId }));
}
await Promise.all(tasks);
},
confirm: ({ title, message, confirmLabel, destructive }) =>
new Promise((resolve) => {
openConfirmModal({
title,
children: message,
labels: { confirm: confirmLabel },
confirmProps: { color: destructive ? "red.9" : "blue" },
onConfirm: () => resolve(true),
onCancel: () => resolve(false),
});
}),
notify: ({ kind, ...notification }) =>
kind === "success" ? showSuccessNotification(notification) : showErrorNotification(notification),
};
return (
<CustomWidgetRuntimeProvider
itemId={props.itemId}
definitionId={props.definitionId}
previewSessionId={props.previewSessionId}
previewLiveActions={props.previewLiveActions}
queriesDisabled={props.queriesDisabled}
isEditMode={props.isEditMode ?? false}
requestCapabilities={props.requestCapabilities ?? []}
port={port}
messages={messages}
setQueryState={props.setQueryState}
>
{props.children}
</CustomWidgetRuntimeProvider>
);
}
+33
View File
@@ -0,0 +1,33 @@
import { describe, expect, test } from "vitest";
import { definition as appDefinition } from "./app";
import { definition as beszelSystemGridDefinition } from "./beszel-system-grid";
import { definition as beszelSystemTableDefinition } from "./beszel-system-table";
import { getWidgetQueryKeys, supportsAdvancedFocus } from "./definition";
describe("getWidgetQueryKeys", () => {
test("falls back to the widget router namespace", () => {
expect(getWidgetQueryKeys({ kind: "example" })).toEqual([[["widget", "example"]]]);
});
test("wraps a singular explicit key", () => {
expect(getWidgetQueryKeys({ kind: "example", queryKey: [["app", "byId"]] })).toEqual([[["app", "byId"]]]);
});
test("preserves every explicit key for multi-root widgets", () => {
const queryKeys = [
[["widget", "audioStats", "getStats"]],
[["widget", "mediaServer", "getCurrentStreams"]],
] as const;
expect(getWidgetQueryKeys({ kind: "audioStats", queryKeys })).toBe(queryKeys);
});
});
describe("supportsAdvancedFocus", () => {
test.each([appDefinition, beszelSystemGridDefinition, beszelSystemTableDefinition])(
"keeps non-enhanced widgets compact",
(widgetDefinition) => {
expect(supportsAdvancedFocus(widgetDefinition)).toBe(false);
},
);
});
+90 -10
View File
@@ -1,6 +1,7 @@
import type React from "react";
import type { LoaderComponent } from "next/dynamic";
import type { QueryClient, QueryKey } from "@tanstack/react-query";
import { hashKey } from "@tanstack/react-query";
import type { DefaultErrorData } from "@trpc/server/unstable-core-do-not-import";
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
@@ -10,7 +11,7 @@ import type { stringOrTranslation } from "@homarr/translation";
import type { TablerIcon } from "@homarr/ui";
import type { WidgetImports } from ".";
import type { inferOptionsFromCreator, inferOptionsFromDefinition, WidgetOptionsRecord } from "./options";
import type { inferOptionsFromCreator, WidgetOptionsRecord } from "./options";
export interface WidgetContextMenuAction {
key: string;
@@ -26,18 +27,50 @@ export interface WidgetContextMenuContext {
isEditMode: boolean;
boardId: string | undefined;
itemId: string | undefined;
canInteractWithSelectedIntegrations: boolean;
}
export interface WidgetContextActionProps<
TKind extends WidgetKind,
TOptions extends WidgetOptionsRecord = WidgetOptionsRecord,
> {
kind: TKind;
options: inferOptionsFromDefinition<TOptions>;
setOptions: (partial: Partial<inferOptionsFromDefinition<TOptions>>) => void;
export interface WidgetRuntimeActions {
togglePolling?: () => void;
testAllIndexers?: () => void;
previousPhoto?: () => void;
nextPhoto?: () => void;
toggleSlideshow?: () => void;
}
export interface WidgetRuntimeState {
queries: readonly NormalizedWidgetQuery[];
actions: WidgetRuntimeActions;
}
export type WidgetRuntimeRef = React.MutableRefObject<WidgetRuntimeState>;
export const createWidgetRuntimeState = (): WidgetRuntimeState => ({
queries: [],
actions: {},
});
export interface NormalizedWidgetQuery {
path: readonly string[];
input: unknown;
}
export interface WidgetQueryMatcherScope {
itemId: string;
boardId: string | undefined;
integrationIds: readonly string[];
options: Record<string, unknown>;
runtimeQueries: readonly NormalizedWidgetQuery[];
}
export type WidgetQueryMatcher = (query: NormalizedWidgetQuery, scope: WidgetQueryMatcherScope) => boolean;
export interface WidgetContextActionProps {
options: Record<string, unknown>;
setOptions: (partial: Record<string, unknown>) => void;
integrationIds: string[];
context: WidgetContextMenuContext;
widgetStateRef: React.MutableRefObject<Record<string, unknown> | null>;
widgetRuntimeRef: WidgetRuntimeRef;
}
const createWithDynamicImport =
@@ -69,7 +102,10 @@ export const createWidgetDefinition = <TKind extends WidgetKind, TDefinition ext
export interface WidgetDefinition {
icon: TablerIcon;
supportsAdvancedFocus?: boolean;
queryKey?: QueryKey;
queryKeys?: readonly QueryKey[];
queryMatcher?: WidgetQueryMatcher;
refetchInterval?: number | null;
supportedIntegrations?: IntegrationKind[];
integrationsRequired?: boolean;
@@ -87,9 +123,50 @@ export interface WidgetDefinition {
}
>
>;
contextActions?: (props: Omit<WidgetContextActionProps<WidgetKind>, "kind">) => WidgetContextMenuAction[];
contextActions?: (props: WidgetContextActionProps) => WidgetContextMenuAction[];
}
export const supportsAdvancedFocus = (definition: object) =>
!("supportsAdvancedFocus" in definition) || definition.supportsAdvancedFocus !== false;
export const getWidgetQueryKeys = (
definition: {
kind?: string;
queryKey?: QueryKey;
queryKeys?: readonly QueryKey[];
},
kind?: WidgetKind,
): readonly QueryKey[] => {
if (definition.queryKeys && definition.queryKeys.length > 0) return definition.queryKeys;
return [definition.queryKey ?? [["widget", definition.kind ?? kind]]];
};
export const normalizeWidgetQuery = (queryKey: QueryKey): NormalizedWidgetQuery | null => {
const path = queryKey[0];
if (!Array.isArray(path) || !path.every((part): part is string => typeof part === "string")) return null;
const queryKeyOptions = queryKey[1];
const input = isRecord(queryKeyOptions) && "input" in queryKeyOptions ? queryKeyOptions.input : undefined;
return { path, input };
};
export const getWidgetRuntimeQueries = (widgetRuntimeRef: WidgetRuntimeRef): readonly NormalizedWidgetQuery[] =>
widgetRuntimeRef.current.queries;
export const matchesWidgetRuntimeQuery: WidgetQueryMatcher = (query, scope) =>
scope.runtimeQueries.some(
(runtimeQuery) =>
widgetQueryValueEquals(runtimeQuery.path, query.path) && widgetQueryValueEquals(runtimeQuery.input, query.input),
);
export const widgetQueryInputMatches = (input: unknown, expected: Record<string, unknown>) =>
isRecord(input) && Object.entries(expected).every(([key, value]) => widgetQueryValueEquals(input[key], value));
export const widgetQueryValueEquals = (left: unknown, right: unknown) => hashKey([left]) === hashKey([right]);
const isRecord = (value: unknown): value is Record<string, unknown> =>
value !== null && typeof value === "object" && !Array.isArray(value);
export interface WidgetProps<TKind extends WidgetKind> {
options: inferOptionsFromCreator<WidgetOptionsRecordOf<TKind>>;
integrationIds: string[];
@@ -99,10 +176,13 @@ export interface WidgetProps<TKind extends WidgetKind> {
export type WidgetComponentProps<TKind extends WidgetKind> = WidgetProps<TKind> & {
boardId: string | undefined; // undefined when in preview mode
isEditMode: boolean;
displayMode?: "compact" | "advanced";
setOptions: ({ newOptions }: { newOptions: Partial<inferOptionsFromCreator<WidgetOptionsRecordOf<TKind>>> }) => void;
width: number;
height: number;
widgetRuntimeRef?: WidgetRuntimeRef;
widgetStateRef?: React.MutableRefObject<Record<string, unknown> | null>;
removeItem?: () => void;
};
export type WidgetOptionsRecordOf<TKind extends WidgetKind> = WidgetImports[TKind]["definition"]["createOptions"];
@@ -33,6 +33,8 @@ import { MaskedOrNormalImage } from "@homarr/ui";
import type { widgetKind } from ".";
import type { WidgetComponentProps } from "../../definition";
import { getUsableWidgetQueryData } from "../../common/query-state";
import actionTargetClasses from "../../common/action-target.module.css";
import classes from "./component.module.css";
import TimerModal from "./TimerModal";
@@ -44,6 +46,7 @@ export default function DnsHoleControlsWidget({
integrationIds,
isEditMode,
width,
height,
}: WidgetComponentProps<typeof widgetKind>) {
const board = useRequiredBoard();
// DnsHole integrations with interaction permissions
@@ -51,18 +54,26 @@ export default function DnsHoleControlsWidget({
.map(({ id }) => id)
.filter((id) => integrationIds.includes(id));
const { data: summaries = [] } = clientApi.widget.dnsHole.summary.useQuery({
integrationIds,
});
const summaryQuery = clientApi.widget.dnsHole.summary.useQuery({ integrationIds });
const summaries = getUsableWidgetQueryData(summaryQuery) ?? [];
const { isPending: isSummaryPending } = summaryQuery;
const utils = clientApi.useUtils();
const { mutate: enableDns } = clientApi.widget.dnsHole.enable.useMutation({
const {
mutateAsync: enableDns,
isPending: isEnabling,
error: enableError,
} = clientApi.widget.dnsHole.enable.useMutation({
onSettled: () => void utils.widget.dnsHole.summary.invalidate(),
});
const { mutate: disableDns } = clientApi.widget.dnsHole.disable.useMutation({
const {
mutateAsync: disableDns,
isPending: isDisabling,
error: disableError,
} = clientApi.widget.dnsHole.disable.useMutation({
onSettled: () => void utils.widget.dnsHole.summary.invalidate(),
});
const toggleDns = (integrationId: string) => {
const toggleDns = async (integrationId: string) => {
const integrationStatus = summaries.find(({ integration }) => integration.id === integrationId);
if (!integrationStatus?.summary.status) return;
utils.widget.dnsHole.summary.setData(
@@ -86,9 +97,9 @@ export default function DnsHoleControlsWidget({
},
);
if (integrationStatus.summary.status === "enabled") {
disableDns({ integrationId, duration: 0 });
await disableDns({ integrationId, duration: 0 });
} else {
enableDns({ integrationId });
await enableDns({ integrationId });
}
};
@@ -103,9 +114,30 @@ export default function DnsHoleControlsWidget({
// Timer modal setup
const [selectedIntegrationIds, setSelectedIntegrationIds] = useState<string[]>([]);
const [bulkPending, setBulkPending] = useState(false);
const [bulkFailureCount, setBulkFailureCount] = useState(0);
const [opened, { close, open }] = useDisclosure(false);
const controlAllButtonsVisible = options.showToggleAllButtons && integrationsWithInteractions.length > 0;
const actionsPending = bulkPending || isEnabling || isDisabling;
const actionError = enableError ?? disableError;
const runBulkToggle = async (integrationIdsToToggle: string[]) => {
setBulkPending(true);
setBulkFailureCount(0);
const results = await Promise.allSettled(integrationIdsToToggle.map(toggleDns));
setBulkFailureCount(results.filter(({ status }) => status === "rejected").length);
setBulkPending(false);
};
if (isSummaryPending) {
return (
<Stack h="100%" justify="center" align="center">
<Text c="dimmed" size="sm">
{t("common.action.loading")}
</Text>
</Stack>
);
}
return (
<Stack
@@ -120,11 +152,12 @@ export default function DnsHoleControlsWidget({
<Flex className="dns-hole-controls-buttons" gap="sm">
<Tooltip label={t("widget.dnsHoleControls.controls.enableAll")}>
<Button
aria-label={t("widget.dnsHoleControls.controls.enableAll")}
size="xs"
p={0}
className="dns-hole-controls-enable-all-button"
onClick={() => integrationsSummaries.disabled.forEach((integrationId) => toggleDns(integrationId))}
disabled={integrationsSummaries.disabled.length === 0}
onClick={() => void runBulkToggle(integrationsSummaries.disabled)}
disabled={integrationsSummaries.disabled.length === 0 || actionsPending}
variant="light"
color="green"
bd={0}
@@ -137,6 +170,7 @@ export default function DnsHoleControlsWidget({
<Tooltip label={t("widget.dnsHoleControls.controls.setTimer")}>
<Button
aria-label={t("widget.dnsHoleControls.controls.setTimer")}
size="xs"
p={0}
className="dns-hole-controls-timer-all-button"
@@ -144,7 +178,7 @@ export default function DnsHoleControlsWidget({
setSelectedIntegrationIds(integrationsSummaries.enabled);
open();
}}
disabled={integrationsSummaries.enabled.length === 0}
disabled={integrationsSummaries.enabled.length === 0 || actionsPending}
variant="light"
color="yellow"
bd={0}
@@ -157,11 +191,12 @@ export default function DnsHoleControlsWidget({
<Tooltip label={t("widget.dnsHoleControls.controls.disableAll")}>
<Button
aria-label={t("widget.dnsHoleControls.controls.disableAll")}
size="xs"
p={0}
className="dns-hole-controls-disable-all-button"
onClick={() => integrationsSummaries.enabled.forEach((integrationId) => toggleDns(integrationId))}
disabled={integrationsSummaries.enabled.length === 0}
onClick={() => void runBulkToggle(integrationsSummaries.enabled)}
disabled={integrationsSummaries.enabled.length === 0 || actionsPending}
variant="light"
color="red"
bd={0}
@@ -192,16 +227,31 @@ export default function DnsHoleControlsWidget({
t={t}
hasIconColor={board.iconColor !== null}
rootWidth={width}
rootHeight={height}
actionsPending={actionsPending}
/>
))}
</Stack>
</ScrollArea.Autosize>
{bulkFailureCount > 0 && (
<Text size="xs" c="red" ta="center">
{t("widget.dnsHoleControls.error.bulkActionsFailed", { count: bulkFailureCount })}
</Text>
)}
{actionError && (
<Tooltip label={t("widget.dnsHoleControls.error.internalServerError")}>
<Text size="xs" c="red" ta="center" lineClamp={2} tabIndex={0}>
{t("widget.dnsHoleControls.error.internalServerError")}
</Text>
</Tooltip>
)}
<TimerModal
opened={opened}
close={close}
selectedIntegrationIds={selectedIntegrationIds}
disableDns={disableDns}
disableDns={(input) => void disableDns(input).catch(() => undefined)}
/>
</Stack>
);
@@ -209,13 +259,15 @@ export default function DnsHoleControlsWidget({
interface ControlsCardProps {
integrationsWithInteractions: string[];
toggleDns: (integrationId: string) => void;
toggleDns: (integrationId: string) => Promise<void>;
data: RouterOutputs["widget"]["dnsHole"]["summary"][number];
setSelectedIntegrationIds: (integrationId: string[]) => void;
open: () => void;
t: TranslationFunction;
hasIconColor: boolean;
rootWidth: number;
rootHeight: number;
actionsPending: boolean;
}
const ControlsCard: React.FC<ControlsCardProps> = ({
@@ -227,16 +279,18 @@ const ControlsCard: React.FC<ControlsCardProps> = ({
t,
hasIconColor,
rootWidth,
rootHeight,
actionsPending,
}) => {
const isConnected = useIntegrationConnected(data.integration.updatedAt, { timeout: 30000 });
const isEnabled = data.summary.status ? data.summary.status === "enabled" : undefined;
const isInteractPermitted = integrationsWithInteractions.includes(data.integration.id);
// Use all factors to infer the state of the action buttons
const controlEnabled = isInteractPermitted && isEnabled !== undefined && isConnected;
const controlEnabled = isInteractPermitted && isEnabled !== undefined && isConnected && !actionsPending;
const board = useRequiredBoard();
const iconUrl = integrationDefs[data.integration.kind].iconUrl;
const layout = rootWidth < 256 ? "sm" : "md";
const layout = rootWidth < 256 || rootHeight < 112 ? "sm" : "md";
return (
<Indicator
@@ -296,7 +350,9 @@ const ControlsCard: React.FC<ControlsCardProps> = ({
<Group gap="xs" grow wrap="nowrap" w="100%">
{!isEnabled ? (
<ActionIcon
onClick={() => toggleDns(data.integration.id)}
className={actionTargetClasses.root}
aria-label={`${t("widget.dnsHoleControls.controls.enableAll")}: ${data.integration.name}`}
onClick={() => void toggleDns(data.integration.id).catch(() => undefined)}
disabled={!controlEnabled}
size="sm"
color="green"
@@ -306,7 +362,9 @@ const ControlsCard: React.FC<ControlsCardProps> = ({
</ActionIcon>
) : (
<ActionIcon
onClick={() => toggleDns(data.integration.id)}
className={actionTargetClasses.root}
aria-label={`${t("widget.dnsHoleControls.controls.disableAll")}: ${data.integration.name}`}
onClick={() => void toggleDns(data.integration.id).catch(() => undefined)}
disabled={!controlEnabled}
size="sm"
color="red"
@@ -316,6 +374,8 @@ const ControlsCard: React.FC<ControlsCardProps> = ({
</ActionIcon>
)}
<ActionIcon
className={actionTargetClasses.root}
aria-label={`${t("widget.dnsHoleControls.controls.setTimer")}: ${data.integration.name}`}
onClick={() => {
setSelectedIntegrationIds([data.integration.id]);
open();
@@ -323,6 +383,8 @@ const ControlsCard: React.FC<ControlsCardProps> = ({
size="sm"
color="yellow"
variant="light"
display={isInteractPermitted ? undefined : "none"}
disabled={!controlEnabled || !isEnabled}
>
<IconClockPause size={12} />
</ActionIcon>
@@ -330,11 +392,16 @@ const ControlsCard: React.FC<ControlsCardProps> = ({
)}
{layout === "md" && (
<UnstyledButton
aria-label={`${
isEnabled
? t("widget.dnsHoleControls.controls.disableAll")
: t("widget.dnsHoleControls.controls.enableAll")
}: ${data.integration.name}`}
className="dns-hole-controls-item-toggle-button"
disabled={!controlEnabled}
display="contents"
style={{ cursor: controlEnabled ? "pointer" : "default" }}
onClick={() => toggleDns(data.integration.id)}
onClick={() => void toggleDns(data.integration.id).catch(() => undefined)}
>
<Badge
className={`dns-hole-controls-item-toggle-button-styling${controlEnabled ? " hoverable-component clickable-component" : ""}`}
@@ -372,7 +439,8 @@ const ControlsCard: React.FC<ControlsCardProps> = ({
</Flex>
{layout === "md" && (
<ActionIcon
className="dns-hole-controls-item-timer-button"
aria-label={`${t("widget.dnsHoleControls.controls.setTimer")}: ${data.integration.name}`}
className={combineClasses("dns-hole-controls-item-timer-button", actionTargetClasses.root)}
display={isInteractPermitted ? undefined : "none"}
disabled={!controlEnabled || !isEnabled}
color="yellow"
@@ -8,6 +8,7 @@ import { optionsBuilder } from "../../options";
export const widgetKind = "dnsHoleControls";
export const { definition, componentLoader } = createWidgetDefinition(widgetKind, {
supportsAdvancedFocus: false,
icon: IconDeviceGamepad,
queryKey: [["widget", "dnsHole"]],
refetchInterval: 5,
@@ -1,8 +1,19 @@
"use client";
import { useMemo } from "react";
import type { BoxProps } from "@mantine/core";
import { Avatar, AvatarGroup, Card, Flex, SimpleGrid, Stack, Text, Tooltip, TooltipFloating } from "@mantine/core";
import {
Avatar,
AvatarGroup,
Badge,
Card,
Flex,
Group,
ScrollArea,
SimpleGrid,
Stack,
Text,
Tooltip,
} from "@mantine/core";
import { useElementSize } from "@mantine/hooks";
import { IconBarrierBlock, IconPercentage, IconSearch, IconWorldWww } from "@tabler/icons-react";
@@ -18,37 +29,84 @@ import type { TablerIcon } from "@homarr/ui";
import type { widgetKind } from ".";
import type { WidgetComponentProps, WidgetProps } from "../../definition";
import { getUsableWidgetQueryData } from "../../common/query-state";
export default function DnsHoleSummaryWidget({ options, integrationIds }: WidgetComponentProps<typeof widgetKind>) {
const { data: summaries = [] } = clientApi.widget.dnsHole.summary.useQuery({
export default function DnsHoleSummaryWidget({
options,
integrationIds,
width,
height,
}: WidgetComponentProps<typeof widgetKind>) {
const summaryQuery = clientApi.widget.dnsHole.summary.useQuery({
integrationIds,
});
const summaries = getUsableWidgetQueryData(summaryQuery) ?? [];
const { isPending } = summaryQuery;
const t = useI18n();
const data = useMemo(() => summaries.flatMap(({ summary }) => summary), [summaries]);
const data = summaries.flatMap(({ summary }) => summary);
const layoutProps = boxPropsByLayout(options.layout);
const showSourceStatuses = summaries.length > 1 && width >= 240 && height >= 220;
if (isPending) {
return (
<Stack h="100%" justify="center" align="center">
<Text c="dimmed" size="sm">
{t("common.action.loading")}
</Text>
</Stack>
);
}
return (
<SimpleGrid cols={2} spacing="xs" h="100%" p={"xs"} {...boxPropsByLayout(options.layout)}>
{data.length > 0 ? (
stats.map((item) => (
<StatCard key={item.color} item={item} usePiHoleColors={options.usePiHoleColors} data={data} t={t} />
))
) : (
<Stack h="100%" w="100%" justify="center" align="center" gap="sm" p="sm">
<AvatarGroup spacing="md">
{summaries.map(({ integration }) => (
<Tooltip key={integration.id} label={integration.name}>
<Avatar h={30} w={30} src={integrationDefs[integration.kind].iconUrl} />
</Tooltip>
<Stack h="100%" gap={0}>
<SimpleGrid cols={2} spacing="xs" p="xs" {...layoutProps} style={{ ...layoutProps.style, flex: 1, minHeight: 0 }}>
{data.length > 0 ? (
stats.map((item) => (
<StatCard key={item.color} item={item} usePiHoleColors={options.usePiHoleColors} data={data} t={t} />
))
) : (
<Stack
aria-live="polite"
h="100%"
w="100%"
justify="center"
align="center"
gap="sm"
p="sm"
style={{ gridColumn: "1 / -1" }}
>
<AvatarGroup spacing="md">
{summaries.map(({ integration }) => (
<Tooltip key={integration.id} label={integration.name}>
<Avatar h={30} w={30} src={integrationDefs[integration.kind].iconUrl} />
</Tooltip>
))}
</AvatarGroup>
<Text fz="md" ta="center">
{t("widget.dnsHoleSummary.error.integrationsDisconnected")}
</Text>
</Stack>
)}
</SimpleGrid>
{showSourceStatuses && (
<ScrollArea type="auto" scrollbarSize={4} px="xs" pb="xs">
<Group gap="xs" wrap="nowrap">
{summaries.map(({ integration, summary }) => (
<Badge
key={integration.id}
variant="light"
color={summary.status === "enabled" ? "green" : summary.status === "disabled" ? "red" : "gray"}
style={{ flexShrink: 0 }}
>
{integration.name}: {t(`widget.dnsHoleSummary.status.${summary.status ?? "unknown"}` as never)}
</Badge>
))}
</AvatarGroup>
<Text fz="md" ta="center">
{t("widget.dnsHoleSummary.error.integrationsDisconnected")}
</Text>
</Stack>
</Group>
</ScrollArea>
)}
</SimpleGrid>
</Stack>
);
}
@@ -61,7 +119,7 @@ const stats = [
size === "sm" ? 0 : 2,
),
label: (t) => t("widget.dnsHoleSummary.data.adsBlockedToday"),
color: "rgba(240, 82, 60, 0.4)", // RED
color: "var(--mantine-color-red-light)",
},
{
icon: IconPercentage,
@@ -71,7 +129,7 @@ const stats = [
return `${formatNumber(totalCount === 0 ? 0 : (blocked / totalCount) * 100, size === "sm" ? 0 : 2)}%`;
},
label: (t) => t("widget.dnsHoleSummary.data.adsBlockedTodayPercentage"),
color: "rgba(255, 165, 20, 0.4)", // YELLOW
color: "var(--mantine-color-yellow-light)",
},
{
icon: IconSearch,
@@ -81,7 +139,7 @@ const stats = [
size === "sm" ? 0 : 2,
),
label: (t) => t("widget.dnsHoleSummary.data.dnsQueriesToday"),
color: "rgba(0, 175, 218, 0.4)", // BLUE
color: "var(--mantine-color-cyan-light)",
},
{
icon: IconWorldWww,
@@ -97,7 +155,7 @@ const stats = [
},
tooltip: (data, t) => (data.length >= 2 ? t("widget.dnsHoleSummary.domainsTooltip") : undefined),
label: (t) => t("widget.dnsHoleSummary.data.domainsBeingBlocked"),
color: "rgba(0, 176, 96, 0.4)", // GREEN
color: "var(--mantine-color-green-light)",
},
] satisfies StatItem[];
@@ -122,15 +180,20 @@ const StatCard = ({ item, data, usePiHoleColors, t }: StatCardProps) => {
const hideLabel = (height <= 32 && width <= 256) || (height <= 64 && width <= 92);
const tooltip = item.tooltip?.(data, t);
const board = useRequiredBoard();
const label = translateIfNecessary(t, item.label);
const value = item.value(data, width <= 64 ? "sm" : "md");
return (
<TooltipFloating label={tooltip} disabled={!tooltip} w={250} multiline>
<Tooltip label={tooltip} disabled={!tooltip} w={250} multiline events={{ hover: true, focus: true, touch: true }}>
<Card
ref={ref}
component="section"
tabIndex={tooltip ? 0 : undefined}
aria-label={`${label}: ${value}`}
className="summary-card"
p="sm"
radius={board.itemRadius}
bg={usePiHoleColors ? item.color : "rgba(96, 96, 96, 0.1)"}
bg={usePiHoleColors ? item.color : "var(--mantine-color-default-hover)"}
style={{
flex: 1,
}}
@@ -158,17 +221,17 @@ const StatCard = ({ item, data, usePiHoleColors, t }: StatCardProps) => {
wrap="wrap"
>
<Text className="summary-card-value text-flash" ta="center" size="lg" fw="bold" maw="100%">
{item.value(data, width <= 64 ? "sm" : "md")}
{value}
</Text>
{!hideLabel && (
<Text className="summary-card-label" ta="center" size="xs" maw="100%">
{translateIfNecessary(t, item.label)}
{label}
</Text>
)}
</Flex>
</Flex>
</Card>
</TooltipFloating>
</Tooltip>
);
};
@@ -8,6 +8,7 @@ import { optionsBuilder } from "../../options";
export const widgetKind = "dnsHoleSummary";
export const { definition, componentLoader } = createWidgetDefinition(widgetKind, {
supportsAdvancedFocus: false,
icon: IconAd,
queryKey: [["widget", "dnsHole"]],
refetchInterval: 5,
+82 -19
View File
@@ -1,7 +1,21 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { ActionIcon, Avatar, Badge, Box, Center, Group, Menu, Portal, Stack, Text, Tooltip } from "@mantine/core";
import {
ActionIcon,
Avatar,
Badge,
Box,
Center,
getDefaultZIndex,
Group,
Menu,
Portal,
Stack,
Text,
Tooltip,
VisuallyHidden,
} from "@mantine/core";
import {
IconBrandDocker,
IconCategoryPlus,
@@ -29,8 +43,11 @@ import { showErrorNotification, showSuccessNotification } from "@homarr/notifica
import { useScopedI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition";
import { getUsableWidgetQueryData } from "../common/query-state";
import actionTargetClasses from "../common/action-target.module.css";
import { HomarrDataTable } from "../common/homarr-data-table";
import { usePersistedTableLayout, useTableLayoutPersistence } from "../common/use-persisted-table-layout";
import { getDockerColumnVisibility, getDockerFooterVisibility } from "./layout";
type DockerContainer = RouterOutputs["docker"]["getContainers"]["containers"][number];
type ContainerAction = "start" | "stop" | "restart" | "remove";
@@ -63,10 +80,32 @@ const ContainerStateBadge = ({ state }: { state: ContainerState }) => {
);
};
const ContainerStateDot = ({ state }: { state: ContainerState }) => {
const t = useScopedI18n("docker.field.state.option");
const label = t(state);
return (
<Tooltip label={label} withArrow>
<Box component="span" style={{ display: "inline-flex", flexShrink: 0 }}>
<Box
component="span"
aria-hidden="true"
w={8}
h={8}
bg={`${containerStateColorMap[state]}.6`}
style={{ borderRadius: "50%" }}
/>
<VisuallyHidden>{label}</VisuallyHidden>
</Box>
</Tooltip>
);
};
const createColumns = (
t: ReturnType<typeof useScopedI18n<"docker">>,
handlers: ContainerActionHandlers,
sortingEnabled: boolean,
inlineState: boolean,
): DataTableColumn<DockerContainer>[] => [
{
accessor: "name",
@@ -76,6 +115,7 @@ const createColumns = (
sortable: sortingEnabled,
render: (container) => (
<Group gap="xs" wrap="nowrap" style={{ overflow: "hidden" }}>
{inlineState && <ContainerStateDot state={container.state} />}
<Avatar
variant="outline"
radius="sm"
@@ -188,6 +228,7 @@ export default function DockerWidget({
options,
width,
isEditMode,
displayMode,
boardId,
itemId,
setOptions,
@@ -198,9 +239,11 @@ export default function DockerWidget({
const board = useOptionalBoard();
const { data: session } = useSession();
const hasChangeAccess = board ? constructBoardPermissions(board, session).hasChangeAccess : false;
const isTiny = width <= 256;
const isAdvanced = displayMode === "advanced";
const { data, refetch, isFetching } = clientApi.docker.getContainers.useQuery();
const containersQuery = clientApi.docker.getContainers.useQuery();
const data = getUsableWidgetQueryData(containersQuery);
const { refetch, isFetching } = containersQuery;
const containers = useMemo(() => data?.containers ?? [], [data?.containers]);
const timestamp = useMemo(() => data?.timestamp ?? new Date(), [data?.timestamp]);
const relativeTime = useTimeAgo(timestamp);
@@ -279,13 +322,18 @@ export default function DockerWidget({
[containers],
);
const selectedColumns = useMemo(() => new Set<string>(options.columns), [options.columns]);
const columnVisibility = useMemo(
() => getDockerColumnVisibility(options.columns, width, isAdvanced),
[isAdvanced, options.columns, width],
);
const inlineState =
!isAdvanced && width < 340 && options.columns.includes("name") && options.columns.includes("state");
const columns = useMemo(() => {
const sortingEnabled = options.enableRowSorting && !isEditMode;
return createColumns(t, actionHandlers, sortingEnabled).filter(({ accessor }) =>
selectedColumns.has(String(accessor)),
const sortingEnabled = (isAdvanced || options.enableRowSorting) && !isEditMode;
return createColumns(t, actionHandlers, sortingEnabled, inlineState).filter(
({ accessor }) => columnVisibility[String(accessor) as keyof typeof columnVisibility],
);
}, [actionHandlers, isEditMode, options.enableRowSorting, selectedColumns, t]);
}, [actionHandlers, columnVisibility, inlineState, isAdvanced, isEditMode, options.enableRowSorting, t]);
const { effectiveColumns, storeKey } = usePersistedTableLayout({
columns,
columnAccessors,
@@ -303,7 +351,7 @@ export default function DockerWidget({
setContextMenu({ x: event.clientX, y: event.clientY, container: record });
}, []);
if (options.columns.length === 0) {
if (!isAdvanced && options.columns.length === 0) {
return (
<Center h="100%">
<Text>{t("error.noColumns")}</Text>
@@ -311,6 +359,8 @@ export default function DockerWidget({
);
}
const footerVisibility = getDockerFooterVisibility(width, isAdvanced);
return (
<Stack gap={0} h="100%" style={{ overflow: "hidden" }}>
<Box style={{ flex: 1, minHeight: 0 }}>
@@ -325,7 +375,7 @@ export default function DockerWidget({
columns={effectiveColumns}
storeColumnsKey={storeKey}
sortStatus={sortStatus}
onSortStatusChange={options.enableRowSorting && !isEditMode ? setSortStatus : undefined}
onSortStatusChange={(isAdvanced || options.enableRowSorting) && !isEditMode ? setSortStatus : undefined}
idAccessor="id"
onRowContextMenu={isEditMode ? undefined : handleContextMenu}
onScroll={() => {
@@ -334,8 +384,15 @@ export default function DockerWidget({
/>
</Box>
{!isTiny && (
<Group justify="space-between" style={{ borderTop: "0.0625rem solid var(--border-color)" }} p={4} wrap="nowrap">
{footerVisibility.footer && (
<Group
justify="space-between"
style={{
borderTop: "0.0625rem solid var(--border-color)",
}}
p={4}
wrap="nowrap"
>
<Group gap={4} wrap="nowrap">
<IconBrandDocker size={20} style={{ flexShrink: 0 }} />
<Text size="sm" truncate>
@@ -344,14 +401,19 @@ export default function DockerWidget({
</Group>
<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>
{footerVisibility.cpu && (
<Text size="sm" style={{ whiteSpace: "nowrap" }}>
{t("table.totalCpu", { cpu: totals.cpu.toFixed(2) })}
</Text>
)}
{footerVisibility.memory && (
<Text size="sm" style={{ whiteSpace: "nowrap" }}>
{t("table.totalMemory", { memory: formatBytes(totals.memory) })}
</Text>
)}
<Tooltip label={t("table.refresh.lastUpdated", { when: relativeTime })}>
<ActionIcon
className={actionTargetClasses.root}
size="sm"
variant="transparent"
c="var(--mantine-color-text)"
@@ -386,6 +448,7 @@ function ContainerMenuButton({
<Menu opened={opened} onChange={setOpened} closeOnItemClick={false} withinPortal position="bottom-end" shadow="sm">
<Menu.Target>
<ActionIcon
className={actionTargetClasses.root}
variant="subtle"
color="gray"
size="sm"
@@ -486,7 +549,7 @@ function ContainerContextMenu({
left={0}
w="100vw"
h="100vh"
style={{ zIndex: 1000 }}
style={{ zIndex: getDefaultZIndex("popover") }}
onClick={onClose}
onContextMenu={(event) => {
event.preventDefault();
@@ -0,0 +1,52 @@
import { describe, expect, test } from "vitest";
import { getDockerColumnVisibility, getDockerFooterVisibility } from "./layout";
const columns = ["name", "state", "host", "cpuUsage", "memoryUsage", "actions"] as const;
describe("getDockerColumnVisibility", () => {
test("keeps only essential columns in a narrow compact widget", () => {
expect(getDockerColumnVisibility(columns, 240, false)).toEqual({
name: true,
state: false,
host: false,
cpuUsage: false,
memoryUsage: false,
actions: true,
});
});
test("reveals compact metrics as width becomes available", () => {
expect(getDockerColumnVisibility(columns, 440, false)).toMatchObject({
host: false,
cpuUsage: true,
memoryUsage: false,
});
});
test("keeps a configured state column when there is no name column for the inline dot", () => {
expect(getDockerColumnVisibility(["state"], 240, false).state).toBe(true);
});
test("uses every expert column in advanced mode", () => {
expect(getDockerColumnVisibility([], 240, true)).toEqual({
name: true,
state: true,
host: true,
cpuUsage: true,
memoryUsage: true,
actions: true,
});
});
});
describe("getDockerFooterVisibility", () => {
test("reveals totals only when they fit", () => {
expect(getDockerFooterVisibility(300, false)).toEqual({ footer: true, cpu: false, memory: false });
expect(getDockerFooterVisibility(400, false)).toEqual({ footer: true, cpu: true, memory: false });
});
test("keeps every total in advanced mode", () => {
expect(getDockerFooterVisibility(240, true)).toEqual({ footer: true, cpu: true, memory: true });
});
});
Loaded 100 of 1326 files, more files were not shown because too many files have changed in this diff. Show more