feat!: release v2 (#6545)

Co-authored-by: Artur <145776561+arturict@users.noreply.github.com>
Co-authored-by: arturict <arturict@users.noreply.github.com>
Co-authored-by: Thomas <thomas@ajnart.fr>
Co-authored-by: Afonso Jorge Ramos <afonsojorgeramos@gmail.com>
Co-authored-by: Danura30082 <danura30082+github@gmail.com>
Co-authored-by: Kushie <122986913+kushiemoon-dev@users.noreply.github.com>
Co-authored-by: ajnart <thomas@ajnart.fr>
Co-authored-by: ajnart <thomas@ajnart.dev>
Co-authored-by: chenjun <chenjun@foxmail.com>
Co-authored-by: Wikibear <wikibear@users.noreply.github.com>
Co-authored-by: Bergasha <bergasha@gmail.com>
Co-authored-by: leafyelin <43306196+leafyelin@users.noreply.github.com>
Co-authored-by: Lucas Araujo <lucas.lra@gmail.com>
Co-authored-by: GreenMonito <86085570+GreenMonito@users.noreply.github.com>
Co-authored-by: Nerdy-Technician <roffo@nerdy-technician.social>
Co-authored-by: Jun <34545896+jcstrong@users.noreply.github.com>
Co-authored-by: Edmond Abraham <54119952+gitedmond@users.noreply.github.com>
Co-authored-by: thrawnx <tight.bulb9384@fastmail.com>
Co-authored-by: Chris <58918656+nordada@users.noreply.github.com>
Co-authored-by: yi111 <153097222+Yi-111-a@users.noreply.github.com>
Co-authored-by: Yanis <naamaneyanis@gmail.com>
Co-authored-by: Wikibear <me@sej.de>
Co-authored-by: alessiottavo <93441886+alessiottavo@users.noreply.github.com>
This commit is contained in:
3286 files changed
+284386 -139176

No files matched your search

+20 -5
View File
@@ -1,17 +1,22 @@
import type { WidgetKind } from "@homarr/definitions";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import type { WidgetOptionOfType, WidgetOptionType } from "../options";
import {
getWidgetOptionDescriptionTranslationNamespace,
getWidgetOptionTranslationNamespace,
} from "../option-translation";
export interface CommonWidgetInputProps<TKey extends WidgetOptionType> {
kind: WidgetKind;
property: string;
options: Omit<WidgetOptionOfType<TKey>, "defaultValue" | "type">;
options: Omit<WidgetOptionOfType<TKey>, "type">;
initialOptions: Record<string, unknown>;
itemId?: string;
boardId?: string;
}
type UseWidgetInputTranslationReturnType = (key: "label" | "description" | "placeholder") => string;
type UseWidgetInputTranslationReturnType = (key: "label" | "description" | "placeholder" | "remove") => string;
/**
* Short description why as and unknown convertions are used below:
@@ -24,7 +29,17 @@ type UseWidgetInputTranslationReturnType = (key: "label" | "description" | "plac
* is defined for the option. The method does sadly not reconize issues with those definitions. So it does not yell at you when you somewhere show the label without having it defined in the translations.
*/
export const useWidgetInputTranslation = (kind: WidgetKind, property: string): UseWidgetInputTranslationReturnType => {
return useScopedI18n(
`widget.${kind}.option.${property}` as never, // Because the type is complex and not recognized by typescript, we need to cast it to never to make it work.
const inputNamespace = getWidgetOptionTranslationNamespace(kind, property);
const descriptionNamespace = getWidgetOptionDescriptionTranslationNamespace(kind, property);
const tInput = useI18n(
inputNamespace as never, // Because the type is complex and not recognized by typescript, we need to cast it to never to make it work.
) as unknown as UseWidgetInputTranslationReturnType;
const tDescription = useI18n(
(descriptionNamespace ?? inputNamespace) as never,
) as unknown as UseWidgetInputTranslationReturnType;
return (key) => {
if (key === "description" && descriptionNamespace) return tDescription("description");
return tInput(key);
};
};
+1 -2
View File
@@ -4,5 +4,4 @@ import { createFormContext } from "@homarr/form";
import type { WidgetEditModalState } from "../modals/widget-edit-modal";
export const [FormProvider, useFormContext, useForm] =
createFormContext<Omit<WidgetEditModalState, "advancedOptions">>();
export const [FormProvider, useFormContext, useForm] = createFormContext<WidgetEditModalState>();
+13
View File
@@ -2,7 +2,10 @@ 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 { WidgetDateTimeEventListInput } from "./widget-date-time-event-list-input";
import { WidgetDynamicSelectInput } from "./widget-dynamic-select-input";
import { WidgetDynamicMultiSelectInput } from "./widget-dynamic-multiselect-input";
import { WidgetLocationInput } from "./widget-location-input";
import { WidgetMultiTextInput } from "./widget-multi-text-input";
import { WidgetMultiReleasesRepositoriesInput } from "./widget-multiReleasesRepositories-input";
@@ -13,15 +16,23 @@ import { WidgetSliderInput } from "./widget-slider-input";
import { WidgetSortedItemListInput } from "./widget-sortable-item-list-input";
import { WidgetSwitchInput } from "./widget-switch-input";
import { WidgetTextInput } from "./widget-text-input";
import { WidgetTimezoneListInput } from "./widget-timezone-list-input";
import { WidgetUmamiEventInput } from "./widget-umami-event-input";
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";
import { WidgetStatsEntriesInput } from "./widget-stats-entries-input";
const mapping = {
statsEntries: WidgetStatsEntriesInput,
internal: WidgetInternalInput,
anchorNote: WidgetAnchorNoteInput,
text: WidgetTextInput,
dateTimeEventList: WidgetDateTimeEventListInput,
timezoneList: WidgetTimezoneListInput,
location: WidgetLocationInput,
multiSelect: WidgetMultiSelectInput,
multiText: WidgetMultiTextInput,
@@ -33,10 +44,12 @@ const mapping = {
sortableItemList: WidgetSortedItemListInput,
multiReleasesRepositories: WidgetMultiReleasesRepositoriesInput,
dynamicSelect: WidgetDynamicSelectInput,
dynamicMultiSelect: WidgetDynamicMultiSelectInput,
umamiEventName: WidgetUmamiEventInput,
umamiEventNames: WidgetUmamiEventNamesInput,
umamiWebsite: WidgetUmamiWebsiteInput,
customWidgetSelect: WidgetCustomWidgetSelectInput,
customWidgetConfiguration: WidgetCustomWidgetConfigurationInput,
integrationSelect: WidgetIntegrationSelectInput,
integrationMultiSelect: WidgetIntegrationMultiSelectInput,
} satisfies Record<WidgetOptionType, unknown>;
@@ -314,7 +314,12 @@ export function JsonPathTreePicker({
{hasData && (
<Combobox.Header>
<Group gap="xs" px="xs" pt="xs" pb={4}>
<Badge size="sm" color="teal" variant="light" leftSection={<IconCircleCheck size={12} />}>
<Badge
size="sm"
color="teal"
variant="light"
leftSection={<IconCircleCheck size="var(--mantine-font-size-xs)" />}
>
{loadedHint}
</Badge>
<Text size="xs" c="dimmed">
@@ -324,7 +329,7 @@ export function JsonPathTreePicker({
</Combobox.Header>
)}
<Combobox.Options>
<ScrollArea.Autosize mah={420} type="scroll">
<ScrollArea.Autosize mah={420}>
{!hasData && <Combobox.Empty>{noDataHint}</Combobox.Empty>}
{hasData && visibleNodes.length === 0 && <Combobox.Empty>No matching paths</Combobox.Empty>}
{visibleNodes.map((node) => (
@@ -353,7 +358,7 @@ function TreeNodeOption({ node, selected, isSearching }: { node: FlatNode; selec
<Group gap={4} wrap="nowrap" style={{ minWidth: 0 }}>
{node.hasChildren && (
<IconChevronDown
size={14}
size="var(--mantine-font-size-sm)"
style={{
flexShrink: 0,
color: "var(--mantine-color-dimmed)",
@@ -362,7 +367,7 @@ function TreeNodeOption({ node, selected, isSearching }: { node: FlatNode; selec
}}
/>
)}
<TypeIcon size={14} style={{ flexShrink: 0, opacity: 0.5 }} />
<TypeIcon size="var(--mantine-font-size-sm)" style={{ flexShrink: 0, opacity: 0.5 }} />
<Text
size="sm"
fw={selected ? 600 : 400}
@@ -5,16 +5,16 @@ import { Loader, Select } from "@mantine/core";
import { useDebouncedValue } from "@mantine/hooks";
import { clientApi } from "@homarr/api/client";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import type { CommonWidgetInputProps } from "./common";
import { useWidgetInputTranslation } from "./common";
import { useFormContext } from "./form";
export const WidgetAnchorNoteInput = ({ property, kind, options }: CommonWidgetInputProps<"anchorNote">) => {
const t = useI18n();
const tCommon = useI18n("common");
const tWidget = useWidgetInputTranslation(kind, property);
const tAnchor = useScopedI18n("widget.anchorNote");
const tAnchor = useI18n("widget.anchorNote");
const form = useFormContext();
const integrationId = form.values.integrationIds[0];
@@ -46,7 +46,7 @@ export const WidgetAnchorNoteInput = ({ property, kind, options }: CommonWidgetI
<Select
label={tWidget("label")}
description={options.withDescription ? tWidget("description") : undefined}
placeholder={integrationId ? t("common.select.placeholder") : tAnchor("integrationRequired")}
placeholder={integrationId ? tCommon("select.placeholder") : tAnchor("integrationRequired")}
nothingFoundMessage={integrationId ? tAnchor("noNotes") : tAnchor("integrationRequired")}
searchable
searchValue={search}
@@ -17,8 +17,15 @@ import type { CommonWidgetInputProps } from "./common";
import { useWidgetInputTranslation } from "./common";
import { useFormContext } from "./form";
const renderHereLink = (children: React.ReactNode) => (
<Anchor size="xs" component={Link} target="_blank" href="/manage/apps/new">
{children}
</Anchor>
);
export const WidgetAppInput = ({ property, kind }: CommonWidgetInputProps<"app">) => {
const t = useI18n();
const tCommon = useI18n("common");
const tApp = useI18n("widget.common.app");
const tInput = useWidgetInputTranslation(kind, property);
const form = useFormContext();
const { data: apps, isPending, refetch } = clientApi.app.selectable.useQuery();
@@ -38,7 +45,7 @@ export const WidgetAppInput = ({ property, kind }: CommonWidgetInputProps<"app">
label={tInput("label")}
searchable
leftSection={<MemoizedLeftSection isPending={isPending} currentApp={currentApp} />}
nothingFoundMessage={t("widget.common.app.noData")}
nothingFoundMessage={tApp("noData")}
renderOption={renderSelectOption}
data={
apps?.map((app) => ({
@@ -49,13 +56,9 @@ export const WidgetAppInput = ({ property, kind }: CommonWidgetInputProps<"app">
}
inputWrapperOrder={["label", "input", "description", "error"]}
description={
<Text size="xs">
{t.rich("widget.common.app.description", {
here: () => (
<Anchor size="xs" component={Link} target="_blank" href="/manage/apps/new">
{t("common.here")}
</Anchor>
),
<Text component="span" size="xs">
{tApp.rich("description", {
here: () => renderHereLink(tCommon("here")),
})}
</Text>
}
@@ -77,7 +80,7 @@ export const WidgetAppInput = ({ property, kind }: CommonWidgetInputProps<"app">
})
}
>
{t("widget.common.app.quickCreate")}
{tApp("quickCreate")}
</Button>
)}
</SimpleGrid>
@@ -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,358 @@
"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 { isRecord } from "@homarr/common";
import type { CustomWidgetOption } from "@homarr/custom-widgets/core";
import { normalizeCustomWidgetOptions, 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 { useI18n } from "@homarr/translation/client";
import type { CommonWidgetInputProps } from "./common";
import { useFormContext } from "./form";
export const WidgetCustomWidgetConfigurationInput = ({
property,
}: CommonWidgetInputProps<"customWidgetConfiguration">) => {
const form = useFormContext();
const labels = useI18n("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 definitionVersion = definition?.updatedAt instanceof Date ? definition.updatedAt.getTime() : undefined;
const configurationVersion = form.values.options.configurationVersion;
const needsDefinitionRepair = definitionVersion !== undefined && configurationVersion !== definitionVersion;
const effectiveConfiguration = useMemo(
() => (options && needsDefinitionRepair ? normalizeCustomWidgetOptions(options, configuration) : configuration),
[configuration, needsDefinitionRepair, options],
);
const issues = useMemo(
() => (options ? validateCustomWidgetOptions(options, effectiveConfiguration) : []),
[effectiveConfiguration, options],
);
useEffect(() => {
if (!needsDefinitionRepair || definitionVersion === undefined) return;
form.setFieldValue(`options.${property}`, effectiveConfiguration);
form.setFieldValue("options.configurationVersion", definitionVersion);
}, [definitionVersion, effectiveConfiguration, form, needsDefinitionRepair, property]);
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="var(--mantine-font-size-md)" />}>
<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={effectiveConfiguration}
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={effectiveConfiguration}
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);
}
@@ -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", () => ({
useI18n: () => (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={{ defaultValue: "", 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 { useI18n } 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 = useI18n("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="var(--mantine-font-size-lg)" />
</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="var(--mantine-font-size-lg)" />}
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>
);
};
@@ -0,0 +1,317 @@
"use client";
import { useEffect, useState } from "react";
import {
closestCenter,
DndContext,
KeyboardSensor,
MouseSensor,
TouchSensor,
useSensor,
useSensors,
} from "@dnd-kit/core";
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { ActionIcon, Button, Card, Fieldset, Group, Select, Stack, Switch, Text, TextInput } from "@mantine/core";
import { DateTimePicker } from "@mantine/dates";
import { IconGripVertical, IconPlus, IconTrash } from "@tabler/icons-react";
import dayjs from "dayjs";
import { createId } from "@homarr/common";
import type { WidgetKind } from "@homarr/definitions";
import { useI18n } from "@homarr/translation/client";
import { formatWallDateTime, resolveWallDateTimeString } from "../countdown/recurrence";
import type { DateTimeEventRecurrence, OptionDateTimeEvent } from "../options";
import type { CommonWidgetInputProps } from "./common";
import { useWidgetInputTranslation } from "./common";
import { useFormContext } from "./form";
export const WidgetDateTimeEventListInput = ({
property,
kind,
options,
}: CommonWidgetInputProps<"dateTimeEventList">) => {
const t = useWidgetInputTranslation(kind, property);
const tEditor = useEventListTranslation(kind);
const form = useFormContext();
const fieldPath = `options.${property}`;
const currentValue = form.values.options[property];
const values = Array.isArray(currentValue) ? (currentValue as OptionDateTimeEvent[]) : options.defaultValue;
const sensors = useSensors(
useSensor(MouseSensor),
useSensor(TouchSensor),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
);
useEffect(() => {
if (Array.isArray(currentValue)) return;
form.setFieldValue(fieldPath, options.defaultValue);
}, [currentValue, fieldPath, form, options.defaultValue]);
const setValues = (nextValues: OptionDateTimeEvent[]) => form.setFieldValue(fieldPath, nextValues);
const addEvent = () => {
if (values.length >= options.maxValues) return;
const localTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
const target = dayjs().add(1, "day").minute(0).second(0).millisecond(0);
setValues([
...values,
{
id: createId(),
label: tEditor("newEvent"),
targetUtc: target.toISOString(),
timeZone: localTimeZone,
recurrence: "none",
},
]);
};
return (
<Fieldset legend={t("label")}>
<Stack gap="sm">
{options.withDescription && (
<Text size="sm" c="dimmed">
{t("description")}
</Text>
)}
<Button
type="button"
variant="light"
leftSection={<IconPlus size="var(--mantine-font-size-md)" />}
onClick={addEvent}
disabled={values.length >= options.maxValues}
>
{tEditor("add")}
</Button>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={({ active, over }) => {
if (!over || active.id === over.id) return;
const activeIndex = values.findIndex(({ id }) => id === active.id);
const overIndex = values.findIndex(({ id }) => id === over.id);
if (activeIndex < 0 || overIndex < 0) return;
setValues(arrayMove(values, activeIndex, overIndex));
}}
>
<SortableContext items={values.map(({ id }) => id)} strategy={verticalListSortingStrategy}>
<Stack gap="xs">
{values.map((event) => (
<SortableEvent
key={event.id}
kind={kind}
event={event}
timeZoneOptions={options.timeZoneOptions}
onChange={(nextEvent) =>
setValues(values.map((candidate) => (candidate.id === event.id ? nextEvent : candidate)))
}
onRemove={() => setValues(values.filter((candidate) => candidate.id !== event.id))}
/>
))}
</Stack>
</SortableContext>
</DndContext>
<Text size="xs" c="dimmed">
{tEditor("limit", { count: values.length, maximum: options.maxValues })}
</Text>
</Stack>
</Fieldset>
);
};
interface SortableEventProps {
kind: WidgetKind;
event: OptionDateTimeEvent;
timeZoneOptions: readonly { value: string; label: string }[];
onChange: (event: OptionDateTimeEvent) => void;
onRemove: () => void;
}
const SortableEvent = ({ kind, event, timeZoneOptions, onChange, onRemove }: SortableEventProps) => {
const t = useEventListTranslation(kind);
const { attributes, listeners, setActivatorNodeRef, setNodeRef, transform, transition, isDragging } = useSortable({
id: event.id,
});
const style = {
transform: transform ? `translate3d(${Math.round(transform.x)}px, ${Math.round(transform.y)}px, 0)` : undefined,
transition,
opacity: isDragging ? 0.6 : undefined,
zIndex: isDragging ? 1 : undefined,
};
const hasCurrentTimeZone = timeZoneOptions.some(({ value }) => value === event.timeZone);
const selectableTimeZones = hasCurrentTimeZone
? timeZoneOptions
: [{ value: event.timeZone, label: t("unsupportedOption", { timeZone: event.timeZone }) }, ...timeZoneOptions];
const targetValue = formatWallDateTime(event.targetUtc, event.timeZone);
const startValue = event.startUtc ? formatWallDateTime(event.startUtc, event.timeZone) : null;
const changeTimeZone = (timeZone: string) => {
const target = resolveWallDateTimeString(targetValue, timeZone);
const start = startValue ? resolveWallDateTimeString(startValue, timeZone) : null;
onChange({
...event,
timeZone,
targetUtc: target?.toISOString() ?? event.targetUtc,
startUtc: event.startUtc ? (start?.toISOString() ?? event.startUtc) : undefined,
});
};
const updateWallDateTime = (value: string | null, field: "targetUtc" | "startUtc") => {
if (!value) return;
const resolved = resolveWallDateTimeString(value, event.timeZone);
if (!resolved) return;
onChange({ ...event, [field]: resolved.toISOString() });
};
return (
<Card ref={setNodeRef} withBorder p="sm" style={style}>
<Stack gap="xs">
<Group gap="xs" wrap="nowrap" align="end">
<ActionIcon
ref={setActivatorNodeRef}
{...attributes}
{...listeners}
variant="subtle"
color="gray"
size="lg"
aria-label={t("reorder", { event: event.label })}
style={{ cursor: "grab" }}
>
<IconGripVertical size="var(--mantine-font-size-lg)" />
</ActionIcon>
<TextInput
flex={1}
label={t("eventLabel")}
value={event.label}
maxLength={64}
onChange={(changeEvent) => onChange({ ...event, label: changeEvent.currentTarget.value })}
/>
<ActionIcon
color="red"
variant="subtle"
size="lg"
aria-label={t("remove", { event: event.label })}
onClick={onRemove}
>
<IconTrash size="var(--mantine-font-size-lg)" />
</ActionIcon>
</Group>
<Group align="end" grow>
<Select
label={t("timezone")}
searchable
data={selectableTimeZones}
value={event.timeZone}
onChange={(timeZone) => {
if (timeZone) changeTimeZone(timeZone);
}}
/>
<Select
label={t("recurrence")}
value={event.recurrence}
data={(["none", "yearly"] as const).map((value) => ({ value, label: t(`recurrenceOption.${value}`) }))}
onChange={(value) => {
if (value) onChange({ ...event, recurrence: value as DateTimeEventRecurrence });
}}
/>
</Group>
<ConfirmedDateTimePicker
label={t("target")}
value={targetValue || null}
onChange={(value) => updateWallDateTime(value, "targetUtc")}
confirmLabel={t("confirmDateTime")}
/>
{event.recurrence === "none" && (
<>
<Switch
label={t("trackProgress")}
checked={event.startUtc !== undefined}
onChange={(changeEvent) => {
if (!changeEvent.currentTarget.checked) {
const nextEvent = { ...event };
delete nextEvent.startUtc;
onChange(nextEvent);
return;
}
onChange({ ...event, startUtc: new Date().toISOString() });
}}
/>
{event.startUtc && (
<ConfirmedDateTimePicker
label={t("start")}
value={startValue}
onChange={(value) => updateWallDateTime(value, "startUtc")}
confirmLabel={t("confirmDateTime")}
/>
)}
</>
)}
</Stack>
</Card>
);
};
interface ConfirmedDateTimePickerProps {
label: string;
value: string | null;
confirmLabel: string;
onChange: (value: string | null) => void;
}
const ConfirmedDateTimePicker = ({ label, value, confirmLabel, onChange }: ConfirmedDateTimePickerProps) => {
const [draftValue, setDraftValue] = useState(value);
useEffect(() => setDraftValue(value), [value]);
const resetDraft = () => setDraftValue(value);
const confirmDraft = () => {
if (!draftValue) return;
onChange(draftValue);
};
return (
<DateTimePicker
label={label}
value={draftValue}
valueFormat="YYYY-MM-DD HH:mm"
timePickerProps={{ withDropdown: true, popoverProps: { withinPortal: false } }}
popoverProps={{ withinPortal: true }}
submitButtonProps={{ "aria-label": confirmLabel, onClick: confirmDraft }}
onChange={setDraftValue}
onDropdownClose={resetDraft}
/>
);
};
type EventListTranslationKey =
| "newEvent"
| "add"
| "remove"
| "reorder"
| "eventLabel"
| "timezone"
| "unsupportedOption"
| "recurrence"
| "recurrenceOption.none"
| "recurrenceOption.yearly"
| "target"
| "trackProgress"
| "start"
| "confirmDateTime"
| "limit";
type EventListTranslation = (key: EventListTranslationKey, values?: Record<string, string | number>) => string;
const useEventListTranslation = (kind: WidgetKind) =>
useI18n(`widget.${kind}.dateTimeEventList` as never) as unknown as EventListTranslation;
@@ -0,0 +1,46 @@
"use client";
import { Loader, MultiSelect } from "@mantine/core";
import { useI18n } from "@homarr/translation/client";
import type { CommonWidgetInputProps } from "./common";
import { useWidgetInputTranslation } from "./common";
import { useFormContext } from "./form";
export const WidgetDynamicMultiSelectInput = ({
property,
kind,
options,
}: CommonWidgetInputProps<"dynamicMultiSelect">) => {
const t = useI18n("widget.common.select");
const tCommon = useI18n("common");
const tInput = useWidgetInputTranslation(kind, property);
const form = useFormContext();
const inputProps = form.getInputProps(`options.${property}`);
const selectedValues = (inputProps.value as string[] | undefined) ?? [];
const { data: selectData, isPending, isError } = options.useOptions();
const data = [
...selectData,
...selectedValues
.filter((selectedValue) => !selectData.some((option) => option.value === selectedValue))
.map((selectedValue) => ({ value: selectedValue, label: selectedValue })),
];
return (
<MultiSelect
{...inputProps}
label={tInput("label")}
description={options.withDescription ? tInput("description") : undefined}
placeholder={isPending ? t("loading") : tInput("placeholder")}
leftSection={isPending ? <Loader size="xs" /> : undefined}
searchable
clearable
maxValues={options.maxValues}
nothingFoundMessage={tCommon("noResults")}
data={data}
disabled={isError}
error={isError ? t("loadError") : inputProps.error}
/>
);
};
@@ -17,18 +17,25 @@ 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 tCommon = useI18n("common");
const tWidget = useWidgetInputTranslation(kind, property);
const form = useFormContext();
const inputProps = form.getInputProps(`options.${property}`);
const [search, setSearch] = useState("");
const [debouncedSearch] = useDebouncedValue(search, 300);
const { isPending, options: selectOptions } = options.useOptions(
debouncedSearch,
form.values.integrationIds,
form.values.options,
);
const {
error: optionsError,
isPending,
options: selectOptions,
} = 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;
@@ -70,6 +77,8 @@ export const WidgetDynamicSelectInput = ({ property, kind, options }: CommonWidg
)}
searchValue={search}
onSearchChange={setSearch}
placeholder={tWidget("placeholder")}
nothingFoundMessage={tCommon("noResults")}
leftSection={isPending && <Loader size="xs" />}
renderOption={({ option, checked }) => {
return (
@@ -80,7 +89,7 @@ export const WidgetDynamicSelectInput = ({ property, kind, options }: CommonWidg
style={{ marginInlineStart: "auto" }}
color="currentColor"
opacity={0.6}
size={18}
size="var(--mantine-font-size-lg)"
stroke={1.5}
/>
)}
@@ -90,6 +99,7 @@ export const WidgetDynamicSelectInput = ({ property, kind, options }: CommonWidg
description={options.withDescription ? tWidget("description") : undefined}
searchable
{...inputProps}
error={inputProps.error ?? optionsError}
value={currentOption === null ? null : currentOption.value}
onChange={(selectedValue: string | null) => {
if (selectedValue === null) {
@@ -2,20 +2,20 @@
import { MultiSelect, Text } from "@mantine/core";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import type { CommonWidgetInputProps } from "./common";
import { useWidgetInputTranslation } from "./common";
import { useFormContext } from "./form";
const useIntegrationSelectTranslation = () => useScopedI18n("widget.integrationSelect");
export const WidgetIntegrationMultiSelectInput = ({
property,
kind,
options,
}: CommonWidgetInputProps<"integrationMultiSelect">) => {
const t = useIntegrationSelectTranslation();
const t = useI18n("widget.integrationSelect");
const tSelect = useI18n("widget.common.select");
const tCommon = useI18n("common");
const tInput = useWidgetInputTranslation(kind, property);
const form = useFormContext();
@@ -40,8 +40,8 @@ export const WidgetIntegrationMultiSelectInput = ({
}
const description = options.withDescription ? tInput("description") : undefined;
const placeholder = isPending ? t("loading") : tInput("placeholder");
const loadError = isError ? t("loadError") : undefined;
const placeholder = isPending ? tSelect("loading") : tInput("placeholder");
const loadError = isError ? tSelect("loadError") : undefined;
return (
<MultiSelect
@@ -51,7 +51,7 @@ export const WidgetIntegrationMultiSelectInput = ({
placeholder={placeholder}
searchable
clearable
nothingFoundMessage={t("noResults")}
nothingFoundMessage={tCommon("noResults")}
data={data}
disabled={isError}
error={loadError ?? inputProps.error}
@@ -2,20 +2,20 @@
import { Select, Text } from "@mantine/core";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import type { CommonWidgetInputProps } from "./common";
import { useWidgetInputTranslation } from "./common";
import { useFormContext } from "./form";
const useIntegrationSelectTranslation = () => useScopedI18n("widget.integrationSelect");
export const WidgetIntegrationSelectInput = ({
property,
kind,
options,
}: CommonWidgetInputProps<"integrationSelect">) => {
const t = useIntegrationSelectTranslation();
const t = useI18n("widget.integrationSelect");
const tSelect = useI18n("widget.common.select");
const tCommon = useI18n("common");
const tInput = useWidgetInputTranslation(kind, property);
const form = useFormContext();
@@ -35,8 +35,8 @@ export const WidgetIntegrationSelectInput = ({
}
const description = options.withDescription ? tInput("description") : undefined;
const placeholder = isPending ? t("loading") : t("placeholder");
const error = isError ? t("loadError") : undefined;
const placeholder = isPending ? tSelect("loading") : t("placeholder");
const error = isError ? tSelect("loadError") : undefined;
return (
<Select
@@ -45,7 +45,7 @@ export const WidgetIntegrationSelectInput = ({
placeholder={placeholder}
clearable={options.clearable}
searchable={options.searchable}
nothingFoundMessage={t("noResults")}
nothingFoundMessage={tCommon("noResults")}
data={data}
disabled={isError}
error={error}
@@ -0,0 +1,3 @@
import type { CommonWidgetInputProps } from "./common";
export const WidgetInternalInput = (_props: CommonWidgetInputProps<"internal">) => null;
@@ -0,0 +1,115 @@
// @vitest-environment jsdom
/* eslint-disable unicorn/consistent-function-scoping -- Component mocks are intentionally colocated with their module factory. */
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 { WidgetLocationInput } from "./widget-location-input";
import { optionsBuilder } from "../options";
const defaultLocation = { name: "Paris", latitude: 48.8566, longitude: 2.3522 };
const locationOptions = optionsBuilder.from((factory) => ({
location: factory.location({ defaultValue: defaultLocation }),
})).location;
const mocks = vi.hoisted(() => ({
values: {} as Record<string, unknown>,
setFieldValue: vi.fn(),
}));
vi.mock("@mantine/core", () => {
const Container = ({ children }: { children?: ReactNode }) => <div>{children}</div>;
const Input = ({ label, value }: { label?: ReactNode; value?: string | number }) => (
<label>
{label}
<input value={value ?? ""} readOnly />
</label>
);
return {
Button: Container,
Collapse: Container,
Fieldset: Container,
Group: Container,
NumberInput: Input,
Stack: Container,
TextInput: Input,
Tooltip: Container,
};
});
vi.mock("@homarr/translation/client", () => ({
useCurrentIntlLocale: () => "en",
useI18n: () => (key: string) => key,
}));
vi.mock("./common", async (importOriginal) => ({
...(await importOriginal()),
useWidgetInputTranslation: () => (key: string) => key,
}));
vi.mock("./form", () => ({
useFormContext: () => ({
getInputProps: (path: string) => ({ value: mocks.values[path], onChange: vi.fn() }),
setFieldValue: mocks.setFieldValue,
clearFieldError: vi.fn(),
watch: vi.fn(),
}),
}));
let root: Root;
let host: HTMLDivElement;
beforeEach(() => {
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
mocks.values = {};
mocks.setFieldValue.mockReset();
host = document.createElement("div");
document.body.append(host);
root = createRoot(host);
});
afterEach(async () => {
await act(() => root.unmount());
host.remove();
});
const renderInput = async () => {
await act(async () => {
root.render(
<WidgetLocationInput kind="weather" property="location" options={locationOptions} initialOptions={{}} />,
);
});
};
describe("location input initialization", () => {
it("hydrates a missing location with the default value", async () => {
await renderInput();
expect(mocks.setFieldValue).toHaveBeenCalledOnce();
expect(mocks.setFieldValue).toHaveBeenCalledWith("options.location", defaultLocation);
});
it("does not reset a transient empty coordinate after initialization", async () => {
mocks.values = {
"options.location": defaultLocation,
"options.location.name": defaultLocation.name,
"options.location.latitude": defaultLocation.latitude,
"options.location.longitude": defaultLocation.longitude,
};
await renderInput();
mocks.values = {
...mocks.values,
"options.location": { ...defaultLocation, latitude: "" },
"options.location.latitude": "",
};
await renderInput();
expect(mocks.setFieldValue).not.toHaveBeenCalled();
});
});
@@ -1,17 +1,18 @@
"use client";
import { useCallback } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import {
ActionIcon,
Alert,
Anchor,
Button,
Collapse,
Fieldset,
Group,
Loader,
NumberInput,
Paper,
Stack,
Table,
Text,
TextInput,
Tooltip,
@@ -20,22 +21,34 @@ 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, useI18n } from "@homarr/translation/client";
import { formatLocalizedCompactNumber } from "../common/locale";
import type { OptionLocation } from "../options";
import type { CommonWidgetInputProps } from "./common";
import { useWidgetInputTranslation } from "./common";
import { useFormContext } from "./form";
export const WidgetLocationInput = ({ property, kind }: CommonWidgetInputProps<"location">) => {
export const WidgetLocationInput = ({ property, kind, options }: CommonWidgetInputProps<"location">) => {
const t = useWidgetInputTranslation(kind, property);
const tLocation = useScopedI18n("widget.common.location");
const tLocation = useI18n("widget.common.location");
const form = useFormContext();
const { openModal } = useModalAction(LocationSearchModal);
const inputProps = form.getInputProps(`options.${property}`);
const value = inputProps.value as OptionLocation;
const [searchQuery, setSearchQuery] = useState<string | null>(null);
const fieldPath = `options.${property}`;
const inputProps = form.getInputProps(fieldPath);
const value = isOptionLocation(inputProps.value) ? inputProps.value : options.defaultValue;
const nameInputProps = form.getInputProps(`options.${property}.name`);
const latitudeInputProps = form.getInputProps(`options.${property}.latitude`);
const longitudeInputProps = form.getInputProps(`options.${property}.longitude`);
const selectionEnabled = value.name.length > 1;
const initializedFieldPathRef = useRef<string | null>(null);
useEffect(() => {
if (initializedFieldPathRef.current === fieldPath) return;
initializedFieldPathRef.current = fieldPath;
if (isOptionLocation(inputProps.value)) return;
form.setFieldValue(fieldPath, options.defaultValue);
}, [fieldPath, form, inputProps.value, options.defaultValue]);
const handleChange = inputProps.onChange as LocationOnChange;
const unknownLocation = tLocation("unknownLocation");
@@ -45,26 +58,23 @@ export const WidgetLocationInput = ({ property, kind }: CommonWidgetInputProps<"
handleChange(location);
form.clearFieldError(`options.${property}.latitude`);
form.clearFieldError(`options.${property}.longitude`);
setSearchQuery(null);
},
[form, handleChange, property],
);
const onSearch = useCallback(() => {
if (!selectionEnabled) return;
setSearchQuery(value.name);
}, [selectionEnabled, value.name]);
openModal({
query: value.name,
onLocationSelect,
});
}, [selectionEnabled, value.name, onLocationSelect, openModal]);
form.watch(`options.${property}.latitude`, ({ value }) => {
if (typeof value !== "number") return;
form.watch(`options.${property}.latitude`, ({ value: nextValue }) => {
if (typeof nextValue !== "number") return;
form.setFieldValue(`options.${property}.name`, unknownLocation);
});
form.watch(`options.${property}.longitude`, ({ value }) => {
if (typeof value !== "number") return;
form.watch(`options.${property}.longitude`, ({ value: nextValue }) => {
if (typeof nextValue !== "number") return;
form.setFieldValue(`options.${property}.name`, unknownLocation);
});
@@ -72,14 +82,19 @@ export const WidgetLocationInput = ({ property, kind }: CommonWidgetInputProps<"
<Fieldset legend={t("label")}>
<Stack gap="xs">
<Group wrap="nowrap" align="end">
<TextInput w="100%" label={tLocation("query")} {...form.getInputProps(`options.${property}.name`)} />
<TextInput
w="100%"
label={tLocation("query")}
{...nameInputProps}
value={nameInputProps.value ?? value.name}
/>
<Tooltip hidden={selectionEnabled} label={tLocation("disabledTooltip")}>
<div>
<Button
disabled={!selectionEnabled}
onClick={onSearch}
variant="light"
leftSection={<IconListSearch size={16} />}
leftSection={<IconListSearch size="var(--mantine-font-size-md)" />}
>
{tLocation("search")}
</Button>
@@ -92,20 +107,42 @@ export const WidgetLocationInput = ({ property, kind }: CommonWidgetInputProps<"
decimalScale={5}
label={tLocation("latitude")}
hideControls
{...form.getInputProps(`options.${property}.latitude`)}
{...latitudeInputProps}
value={latitudeInputProps.value ?? value.latitude}
/>
<NumberInput
decimalScale={5}
label={tLocation("longitude")}
hideControls
{...form.getInputProps(`options.${property}.longitude`)}
{...longitudeInputProps}
value={longitudeInputProps.value ?? value.longitude}
/>
</Group>
<Collapse expanded={searchQuery !== null}>
{searchQuery && (
<LocationSearchResults
query={searchQuery}
onLocationSelect={onLocationSelect}
onClose={() => setSearchQuery(null)}
/>
)}
</Collapse>
</Stack>
</Fieldset>
);
};
const isOptionLocation = (value: unknown): value is OptionLocation =>
typeof value === "object" &&
value !== null &&
"name" in value &&
typeof value.name === "string" &&
"latitude" in value &&
typeof value.latitude === "number" &&
"longitude" in value &&
typeof value.longitude === "number";
type LocationOnChange = (
location: Pick<OptionLocation, "name"> & {
latitude: OptionLocation["latitude"] | "";
@@ -113,125 +150,123 @@ type LocationOnChange = (
},
) => void;
interface LocationSearchInnerProps {
interface LocationSearchResultsProps {
query: string;
onLocationSelect: (location: OptionLocation) => void;
onClose: () => void;
}
const LocationSearchModal = createModal<LocationSearchInnerProps>(({ actions, innerProps }) => {
const t = useScopedI18n("widget.common.location.table");
const tCommon = useScopedI18n("common");
const { data, isPending, error } = clientApi.location.searchCity.useQuery({
query: innerProps.query,
});
const LocationSearchResults = ({ query, onLocationSelect, onClose }: LocationSearchResultsProps) => {
const tLocation = useI18n("widget.common.location");
const tCommon = useI18n("common");
const { data, isPending, error, refetch } = clientApi.location.searchCity.useQuery({ query });
if (error) {
return (
<Alert title={tCommon("error")} color="red">
{error.message}
</Alert>
<Stack gap="xs">
<Alert title={tCommon("error")} color="red">
<Button variant="light" color="red" size="compact-sm" onClick={() => void refetch()}>
{tCommon("action.tryAgain")}
</Button>
</Alert>
<Group justify="right">
<Button variant="default" size="compact-sm" onClick={onClose}>
{tCommon("action.close")}
</Button>
</Group>
</Stack>
);
}
return (
<Stack>
<Table striped>
<Table.Thead>
<Table.Tr>
<Table.Th style={{ width: "70%" }}>{t("header.city")}</Table.Th>
<Table.Th style={{ width: "50%" }}>{t("header.country")}</Table.Th>
<Table.Th>{t("header.coordinates")}</Table.Th>
<Table.Th>{t("header.population")}</Table.Th>
<Table.Th style={{ width: 40 }} />
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{isPending && (
<Table.Tr>
<Table.Td colSpan={5}>
<Group justify="center">
<Loader />
</Group>
</Table.Td>
</Table.Tr>
)}
{data?.results.map((city) => (
<LocationSelectTableRow
key={city.id}
city={city}
onLocationSelect={innerProps.onLocationSelect}
closeModal={actions.closeModal}
/>
))}
</Table.Tbody>
</Table>
<Stack component="section" aria-label={tLocation("search")} gap="xs">
<Stack gap={4} mah={320} style={{ overflow: "hidden auto" }}>
{isPending && (
<Group justify="center" py="md">
<Loader />
</Group>
)}
{!isPending && data?.results.length === 0 && (
<Text ta="center" c="dimmed" py="md">
{tCommon("noResults")}
</Text>
)}
{data?.results.map((city) => (
<LocationSelectResult key={city.id} city={city} onLocationSelect={onLocationSelect} />
))}
</Stack>
<Group justify="right">
<Button variant="light" onClick={actions.closeModal}>
{tCommon("action.cancel")}
<Button variant="default" size="compact-sm" onClick={onClose}>
{tCommon("action.close")}
</Button>
</Group>
</Stack>
);
}).withOptions({
defaultTitle(t) {
return t("widget.common.location.search");
},
size: "xl",
});
};
interface LocationSearchTableRowProps {
interface LocationSelectResultProps {
city: RouterOutputs["location"]["searchCity"]["results"][number];
onLocationSelect: (location: OptionLocation) => void;
closeModal: () => void;
}
const LocationSelectTableRow = ({ city, onLocationSelect, closeModal }: LocationSearchTableRowProps) => {
const t = useScopedI18n("widget.common.location.table");
const LocationSelectResult = ({ city, onLocationSelect }: LocationSelectResultProps) => {
const t = useI18n("widget.common.location.table");
const locale = useCurrentIntlLocale();
let populationLabel = t("population.fallback");
if (city.population) {
populationLabel = `${t("header.population")}: ${formatLocalizedCompactNumber(city.population, locale)}`;
}
const onSelect = useCallback(() => {
onLocationSelect({
name: city.name,
latitude: city.latitude,
longitude: city.longitude,
});
closeModal();
}, [city, onLocationSelect, closeModal]);
const formatter = Intl.NumberFormat("en", { notation: "compact" });
}, [city, onLocationSelect]);
return (
<Table.Tr>
<Table.Td>
<Text style={{ whiteSpace: "nowrap" }}>{city.name}</Text>
</Table.Td>
<Table.Td>
<Text style={{ whiteSpace: "nowrap" }}>{city.country}</Text>
</Table.Td>
<Table.Td>
<Anchor target="_blank" href={`https://www.google.com/maps/place/${city.latitude},${city.longitude}`}>
<Text style={{ whiteSpace: "nowrap" }}>
{city.latitude}, {city.longitude}
<Paper withBorder p="xs">
<Group justify="space-between" align="flex-start" wrap="nowrap">
<Stack gap={2} miw={0}>
<Text fw={500} style={{ overflowWrap: "anywhere" }}>
{city.name}
</Text>
</Anchor>
</Table.Td>
<Table.Td>
{city.population ? (
<Text style={{ whiteSpace: "nowrap" }}>{formatter.format(city.population)}</Text>
) : (
<Text c="gray"> {t("population.fallback")}</Text>
)}
</Table.Td>
<Table.Td>
<Text size="xs" c="dimmed" style={{ overflowWrap: "anywhere" }}>
{city.country}
</Text>
<Group gap="xs" wrap="wrap">
<Anchor
size="xs"
target="_blank"
href={`https://www.google.com/maps/place/${city.latitude},${city.longitude}`}
>
{city.latitude}, {city.longitude}
</Anchor>
<Text size="xs" c="dimmed">
{populationLabel}
</Text>
</Group>
</Stack>
<Tooltip
label={t("action.select", {
city: city.name,
countryCode: city.country_code ?? "??",
})}
>
<ActionIcon color="red" variant="subtle" onClick={onSelect}>
<IconClick size={16} />
<ActionIcon
flex="0 0 auto"
color="red"
variant="subtle"
onClick={onSelect}
aria-label={t("action.select", {
city: city.name,
countryCode: city.country_code ?? "??",
})}
>
<IconClick size="var(--mantine-font-size-md)" />
</ActionIcon>
</Tooltip>
</Table.Td>
</Table.Tr>
</Group>
</Paper>
);
};
@@ -1,7 +1,7 @@
import React, { useState } from "react";
import React, { useEffect, useState } from "react";
import { Combobox, Pill, PillsInput, useCombobox } from "@mantine/core";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import type { CommonWidgetInputProps } from "./common";
import { useWidgetInputTranslation } from "./common";
@@ -9,7 +9,7 @@ import { useFormContext } from "./form";
export const WidgetMultiTextInput = ({ property, kind, options }: CommonWidgetInputProps<"multiText">) => {
const t = useWidgetInputTranslation(kind, property);
const tCommon = useScopedI18n("common");
const tCommon = useI18n("common");
const combobox = useCombobox({
onDropdownClose: () => combobox.resetSelectedOption(),
onDropdownOpen: () => combobox.updateSelectedOptionIndex("active"),
@@ -18,10 +18,16 @@ export const WidgetMultiTextInput = ({ property, kind, options }: CommonWidgetIn
const [search, setSearch] = useState("");
const form = useFormContext();
const inputProps = form.getInputProps(`options.${property}`);
const values = inputProps.value as string[];
const fieldPath = `options.${property}`;
const inputProps = form.getInputProps(fieldPath);
const values = Array.isArray(inputProps.value) ? (inputProps.value as string[]) : options.defaultValue;
const onChange = inputProps.onChange as (values: string[]) => void;
useEffect(() => {
if (Array.isArray(inputProps.value)) return;
form.setFieldValue(fieldPath, options.defaultValue);
}, [fieldPath, form, inputProps.value, options.defaultValue]);
const handleRemove = (optionIndex: number) => {
onChange(values.filter((_, index) => index !== optionIndex));
};
@@ -34,8 +34,6 @@ import {
IconTriangleFilled,
IconZoomScan,
} from "@tabler/icons-react";
import { escapeForRegEx } from "@tiptap/react";
import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import { createId } from "@homarr/common";
@@ -50,7 +48,7 @@ import {
import { IconPicker } from "@homarr/forms-collection";
import { createModal, useModalAction } from "@homarr/modals";
import { showErrorNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import { MaskedImage } from "@homarr/ui";
import type { ReleasesRepository, ReleasesVersionFilter } from "../releases/releases-repository";
@@ -63,13 +61,16 @@ interface FormValidation {
errors: FormErrors;
}
const escapeForRegEx = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
export const WidgetMultiReleasesRepositoriesInput = ({
property,
kind,
itemId,
}: CommonWidgetInputProps<"multiReleasesRepositories">) => {
const t = useWidgetInputTranslation(kind, property);
const tRepository = useScopedI18n("widget.releases.option.repositories");
const tRepository = useI18n("widget.releases.option.repositories");
const actionT = useI18n("common.action");
const form = useFormContext();
const repositories = form.values.options[property] as ReleasesRepository[];
const { openModal: openEditModal } = useModalAction(RepositoryEditModal);
@@ -229,19 +230,19 @@ export const WidgetMultiReleasesRepositoriesInput = ({
})
}
variant="light"
leftSection={<IconEdit size={15} />}
leftSection={<IconEdit size="var(--mantine-font-size-sm)" />}
size="xs"
>
{tRepository("edit.label")}
{actionT("edit")}
</Button>
<ActionIcon variant="transparent" color="red" onClick={() => onRepositoryRemove(index)}>
<IconTrash size={15} />
<IconTrash size="var(--mantine-font-size-sm)" />
</ActionIcon>
</Group>
{Object.keys(form.errors).filter((key) => key.startsWith(`options.${property}.${index}.`)).length > 0 && (
<Group align="center" justify="center" gap="xs" bg="red.1">
<IconTriangleFilled size={15} color="var(--mantine-color-red-filled)" />
<IconTriangleFilled size="var(--mantine-font-size-sm)" color="var(--mantine-color-red-filled)" />
<Text size="sm" c="red">
{tRepository("invalid")}
</Text>
@@ -267,23 +268,30 @@ const providersWithAuth: ReleaseProviderKind[] = [
];
const ProviderTokensSection = ({ itemId, repositories }: { itemId: string; repositories: ReleasesRepository[] }) => {
const tRepository = useScopedI18n("widget.releases.option.repositories");
const tRepository = useI18n("widget.releases.option.repositories");
const utils = clientApi.useUtils();
const { data: configuredKinds = [], refetch } = clientApi.widget.secrets.getConfiguredKinds.useQuery({ itemId });
const setSecret = clientApi.widget.secrets.setSecret.useMutation({
onSuccess: () => refetch(),
onError: (error) => {
onSuccess: () => {
void refetch();
void utils.widget.releases.getLatest.invalidate();
},
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) => {
onSuccess: () => {
void refetch();
void utils.widget.releases.getLatest.invalidate();
},
onError: () => {
showErrorNotification({
title: "Failed to delete token",
message: error.message,
title: tRepository("tokens.label"),
message: tRepository("tokens.notConfigured"),
});
},
});
@@ -331,7 +339,8 @@ const ProviderTokenInput = ({
onSave: (value: string) => Promise<void>;
onDelete: () => Promise<void>;
}) => {
const tRepository = useScopedI18n("widget.releases.option.repositories");
const tRepository = useI18n("widget.releases.option.repositories");
const actionT = useI18n("common.action");
const [value, setValue] = useState("");
const [editing, setEditing] = useState(false);
const [saving, setSaving] = useState(false);
@@ -371,16 +380,16 @@ const ProviderTokenInput = ({
}}
style={{ flex: 1 }}
size="xs"
leftSection={<IconKey size={14} />}
leftSection={<IconKey size="var(--mantine-font-size-sm)" />}
/>
{editing && value.trim() && (
<Button size="xs" onClick={handleSave} loading={saving}>
{tRepository("tokens.save")}
{actionT("save")}
</Button>
)}
{hasToken && !editing && (
<ActionIcon variant="light" color="red" size="sm" onClick={handleDelete} loading={saving}>
<IconTrash size={14} />
<IconTrash size="var(--mantine-font-size-sm)" />
</ActionIcon>
)}
</Group>
@@ -412,7 +421,8 @@ interface RepositoryEditProps {
}
const RepositoryEditModal = createModal<RepositoryEditProps>(({ innerProps, actions }) => {
const tRepository = useScopedI18n("widget.releases.option.repositories");
const tRepository = useI18n("widget.releases.option.repositories");
const actionT = useI18n("common.action");
const [loading, setLoading] = useState(false);
const [tempRepository, setTempRepository] = useState(() => ({ ...innerProps.repository }));
const [formErrors, setFormErrors] = useState<FormErrors>({});
@@ -576,11 +586,11 @@ const RepositoryEditModal = createModal<RepositoryEditProps>(({ innerProps, acti
<Divider my={"sm"} />
<Group justify="flex-end">
<Button variant="default" onClick={handleCancel} color="gray.5">
{tRepository("editForm.cancel.label")}
{actionT("cancel")}
</Button>
<Button data-autofocus onClick={handleConfirm} loading={loading}>
{tRepository("editForm.confirm.label")}
{actionT("confirm")}
</Button>
</Group>
</Stack>
@@ -611,7 +621,7 @@ const ImportRepositorySelect = ({
disabled = false,
onImageSelectionChanged = undefined,
}: ImportRepositorySelectProps) => {
const tRepository = useScopedI18n("widget.releases.option.repositories");
const tRepository = useI18n("widget.releases.option.repositories");
const provider = repository.provider;
return (
@@ -687,7 +697,8 @@ interface RepositoryImportProps {
}
const RepositoryImportModal = createModal<RepositoryImportProps>(({ innerProps, actions }) => {
const tRepository = useScopedI18n("widget.releases.option.repositories");
const tRepository = useI18n("widget.releases.option.repositories");
const actionT = useI18n("common.action");
const [loading, setLoading] = useState(false);
const [selectedImages, setSelectedImages] = useState([] as ReleasesRepositoryImport[]);
@@ -847,11 +858,11 @@ const RepositoryImportModal = createModal<RepositoryImportProps>(({ innerProps,
<Group justify="flex-end">
<Button variant="default" onClick={actions.closeModal} color="gray.5">
{tRepository("editForm.cancel.label")}
{actionT("cancel")}
</Button>
<Button onClick={handleConfirm} loading={loading} disabled={selectedImages.length === 0}>
{tRepository("editForm.confirm.label")}
{actionT("confirm")}
</Button>
</Group>
</Stack>
@@ -2,13 +2,45 @@
import { NumberInput } from "@mantine/core";
import { useSettings } from "@homarr/settings";
import type { CommonWidgetInputProps } from "./common";
import { useWidgetInputTranslation } from "./common";
import { useFormContext } from "./form";
const KIBIBYTE = 1024;
const KILOBYTE = 1000;
export const WidgetNumberInput = ({ property, kind, options }: CommonWidgetInputProps<"number">) => {
const t = useWidgetInputTranslation(kind, property);
const form = useFormContext();
const { byteUnitSystem } = useSettings();
const inputProps = form.getInputProps(`options.${property}`);
let value = inputProps.value;
let onChange = inputProps.onChange;
let suffix: string | undefined;
let decimalScale: number | undefined;
if (options.storedUnit === "kibibytesPerSecond") {
let bytesPerDisplayedUnit = KIBIBYTE;
suffix = " KiB/s";
if (byteUnitSystem === "decimal") {
bytesPerDisplayedUnit = KILOBYTE;
suffix = " KB/s";
}
const displayFactor = KIBIBYTE / bytesPerDisplayedUnit;
if (typeof value === "number") value *= displayFactor;
onChange = (nextValue: string | number) => {
if (typeof nextValue !== "number") {
inputProps.onChange(nextValue);
return;
}
inputProps.onChange(nextValue / displayFactor);
};
decimalScale = 6;
}
return (
<NumberInput
@@ -17,7 +49,11 @@ export const WidgetNumberInput = ({ property, kind, options }: CommonWidgetInput
min={options.validate.minValue ?? undefined}
max={options.validate.maxValue ?? undefined}
step={options.step}
{...form.getInputProps(`options.${property}`)}
suffix={suffix}
decimalScale={decimalScale}
{...inputProps}
value={value}
onChange={onChange}
/>
);
};
@@ -51,20 +51,20 @@ export const WidgetSelectInput = ({ property, kind, options }: CommonWidgetInput
label: translateIfNecessary(t, option.label) ?? option.value,
},
)}
leftSection={CurrentIcon && <CurrentIcon size={16} stroke={1.5} />}
leftSection={CurrentIcon && <CurrentIcon size="var(--mantine-font-size-md)" stroke={1.5} />}
renderOption={({ option, checked }) => {
const Icon = getIconFor(options.options, String(option.value));
return (
<Group flex="1" gap="xs">
{Icon && <Icon color="currentColor" opacity={0.6} size={18} stroke={1.5} />}
{Icon && <Icon color="currentColor" opacity={0.6} size="var(--mantine-font-size-lg)" stroke={1.5} />}
{option.label}
{checked && (
<IconCheck
style={{ marginInlineStart: "auto" }}
color="currentColor"
opacity={0.6}
size={18}
size="var(--mantine-font-size-lg)"
stroke={1.5}
/>
)}
@@ -72,6 +72,7 @@ export const WidgetSelectInput = ({ property, kind, options }: CommonWidgetInput
);
}}
description={options.withDescription ? tWidget("description") : undefined}
placeholder={options.withPlaceholder ? tWidget("placeholder") : undefined}
searchable={options.searchable}
{...inputProps}
/>
@@ -16,7 +16,6 @@ import {
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import type { ActionIconProps } from "@mantine/core";
import { ActionIcon, Card, Center, Fieldset, Loader, Stack } from "@mantine/core";
import { IconGripHorizontal } from "@tabler/icons-react";
@@ -32,8 +31,14 @@ export const WidgetSortedItemListInput = <TItem, TOptionValue extends UniqueIden
}: CommonWidgetInputProps<"sortableItemList">) => {
const t = useWidgetInputTranslation(kind, property);
const form = useFormContext();
const initialValues = useMemo(() => initialOptions[property] as TOptionValue[], [initialOptions, property]);
const values = form.values.options[property] as TOptionValue[];
const fieldPath = `options.${property}`;
const initialValues = useMemo(
() =>
Array.isArray(initialOptions[property]) ? (initialOptions[property] as TOptionValue[]) : options.defaultValue,
[initialOptions, options.defaultValue, property],
);
const currentValue = form.values.options[property];
const values = Array.isArray(currentValue) ? (currentValue as TOptionValue[]) : options.defaultValue;
const { data, isLoading, error } = options.useData(initialValues);
const dataMap = useMemo(
() => new Map(data?.map((item) => [options.uniqueIdentifier(item), item as TItem])),
@@ -53,6 +58,11 @@ export const WidgetSortedItemListInput = <TItem, TOptionValue extends UniqueIden
const getIndex = (id: TOptionValue) => values.indexOf(id);
const activeIndex = activeId ? getIndex(activeId) : -1;
useEffect(() => {
if (Array.isArray(currentValue)) return;
form.setFieldValue(fieldPath, options.defaultValue);
}, [currentValue, fieldPath, form, options.defaultValue]);
useEffect(() => {
if (!activeId) {
isFirstAnnouncement.current = true;
@@ -70,22 +80,67 @@ export const WidgetSortedItemListInput = <TItem, TOptionValue extends UniqueIden
[tempMap, dataMap],
);
const updateItems = (callback: (prev: TOptionValue[]) => TOptionValue[]) => {
form.setFieldValue(`options.${property}`, callback);
};
const updateItems = useCallback(
(callback: (prev: TOptionValue[]) => TOptionValue[]) => {
form.setFieldValue(fieldPath, callback);
},
[fieldPath, form],
);
const addItem = (item: TItem) => {
setTempMap((prev) => {
prev.set(options.uniqueIdentifier(item) as TOptionValue, item);
return prev;
const next = new Map(prev);
next.set(options.uniqueIdentifier(item) as TOptionValue, item);
return next;
});
updateItems((values) => [...values, options.uniqueIdentifier(item) as TOptionValue]);
updateItems((currentValues) => [...currentValues, options.uniqueIdentifier(item) as TOptionValue]);
};
const migrateItems = useCallback(
(items: TItem[], optionsPatch: Record<string, unknown>) => {
const migratedEntries = items.map((item) => [options.uniqueIdentifier(item) as TOptionValue, item] as const);
setTempMap((previous) => {
const next = new Map(previous);
for (const [value, item] of migratedEntries) next.set(value, item);
return next;
});
form.setFieldValue("options", (currentOptions) => {
const currentItems = Array.isArray(currentOptions[property])
? (currentOptions[property] as TOptionValue[])
: options.defaultValue;
const nextItems = [...currentItems];
for (const [value] of migratedEntries) {
if (!nextItems.includes(value)) nextItems.push(value);
}
return { ...currentOptions, ...optionsPatch, [property]: nextItems };
});
},
[form, options, property],
);
const removeItem = useCallback(
(value: TOptionValue) => {
updateItems((currentValues) => currentValues.filter((candidate) => candidate !== value));
setTempMap((previous) => {
if (!previous.has(value)) return previous;
const next = new Map(previous);
next.delete(value);
return next;
});
},
[updateItems],
);
return (
<Fieldset legend={t("label")}>
<Stack>
<options.addButton addItem={addItem} values={values} />
<options.addButton
addItem={addItem}
migrateItems={migrateItems}
removeItem={removeItem}
values={values}
initialOptions={initialOptions}
/>
<DndContext
sensors={sensors}
@@ -115,19 +170,6 @@ export const WidgetSortedItemListInput = <TItem, TOptionValue extends UniqueIden
<React.Fragment>
{values.map((value, index) => {
const item = getItem(value);
const removeItem = () => {
form.setValues((previous) => {
const previousValues = previous.options?.[property] as TOptionValue[];
return {
...previous,
options: {
...previous.options,
[property]: previousValues.filter((id) => id !== value),
},
};
});
};
if (!item) {
return null;
}
@@ -138,7 +180,8 @@ export const WidgetSortedItemListInput = <TItem, TOptionValue extends UniqueIden
id={value}
index={index}
item={item}
removeItem={removeItem}
removeItem={() => removeItem(value)}
removeLabel={t("remove")}
options={options}
/>
);
@@ -163,6 +206,7 @@ interface ItemProps<TItem, TOptionValue extends UniqueIdentifier> {
item: TItem;
index: number;
removeItem: () => void;
removeLabel: string;
options: CommonWidgetInputProps<"sortableItemList">["options"];
}
@@ -171,27 +215,13 @@ const Item = <TItem, TOptionValue extends UniqueIdentifier>({
index,
item,
removeItem,
removeLabel,
options,
}: ItemProps<TItem, TOptionValue>) => {
const { attributes, isDragging, listeners, setNodeRef, setActivatorNodeRef, transform, transition } = useSortable({
id,
});
const Handle = (props: Partial<ActionIconProps>) => {
return (
<ActionIcon
variant="transparent"
color="gray"
{...props}
{...listeners}
ref={setActivatorNodeRef}
style={{ cursor: "grab" }}
>
<IconGripHorizontal />
</ActionIcon>
);
};
return (
<Card
padding="sm"
@@ -221,8 +251,19 @@ const Item = <TItem, TOptionValue extends UniqueIdentifier>({
key={index}
item={item}
removeItem={removeItem}
removeLabel={removeLabel}
rootAttributes={attributes}
handle={Handle}
handle={
<ActionIcon
variant="transparent"
color="gray"
{...listeners}
ref={setActivatorNodeRef}
style={{ cursor: "grab" }}
>
<IconGripHorizontal />
</ActionIcon>
}
/>
</Card>
);
@@ -0,0 +1,142 @@
"use client";
import { useState } from "react";
import { ActionIcon, Button, Card, Checkbox, Group, Select, Stack, Text, TextInput } from "@mantine/core";
import { IconArrowDown, IconArrowUp, IconPlus, IconTrash } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { createId } from "@homarr/common";
import { useI18n } from "@homarr/translation/client";
import { statsEntriesSchema } from "../stats/config";
import type { StatsEntry } from "../stats/config";
import type { CommonWidgetInputProps } from "./common";
import { useFormContext } from "./form";
export const WidgetStatsEntriesInput = ({ property }: CommonWidgetInputProps<"statsEntries">) => {
const form = useFormContext();
const t = useI18n("widget.stats");
const [source, setSource] = useState<string | null>(null);
const [selectedMetric, setSelectedMetric] = useState<string | null>(null);
const entries = statsEntriesSchema.safeParse(form.values.options[property]).data ?? [];
const ids = [...new Set(form.values.integrationIds)];
const catalogs = clientApi.useQueries((api) =>
ids.map((integrationId) => api.widget.stats.catalog({ integrationId }, { staleTime: 60_000, retry: false })),
);
const sourceCatalog = catalogs[ids.indexOf(source ?? "")]?.data;
const save = (next: StatsEntry[]) => form.setFieldValue(`options.${property}`, next);
const update = (id: string, patch: Partial<StatsEntry>) =>
save(
entries.map((entry) => {
if (entry.id === id) return { ...entry, ...patch };
return entry;
}),
);
const move = (index: number, delta: number) => {
const next = [...entries];
const item = next.splice(index, 1)[0];
if (!item) return;
next.splice(index + delta, 0, item);
save(next);
};
return (
<Stack>
<Text size="sm" c="dimmed">
{t("editorHelp")}
</Text>
<Select
label={t("source")}
searchable
value={source}
onChange={(value) => {
setSource(value);
setSelectedMetric(null);
}}
data={ids.map((id, index) => ({ value: id, label: catalogs[index]?.data?.name ?? id }))}
/>
<Select
label={t("metric")}
searchable
value={selectedMetric}
onChange={setSelectedMetric}
data={(sourceCatalog?.metrics ?? []).map((metric) => ({ value: metric.key, label: metric.label }))}
nothingFoundMessage={t("noMetrics")}
/>
<Button
size="xs"
leftSection={<IconPlus size={14} />}
disabled={!source || !selectedMetric || entries.length >= 100}
onClick={() => {
if (!source || !selectedMetric) return;
save([
...entries,
{
id: createId(),
integrationId: source,
metric: selectedMetric,
label: "",
hidden: false,
compact: false,
},
]);
}}
>
{t("add")}
</Button>
{entries.map((entry, index) => (
<Card key={entry.id} withBorder padding="sm">
<Stack gap="xs">
<Text size="xs" c="dimmed">
{catalogs[ids.indexOf(entry.integrationId)]?.data?.name} · {entry.metric}
</Text>
<TextInput
aria-label={t("label")}
placeholder={t("label")}
value={entry.label}
maxLength={128}
onChange={(event) => update(entry.id, { label: event.currentTarget.value })}
/>
<Group justify="space-between">
<Group>
<Checkbox
label={t("hidden")}
checked={entry.hidden}
onChange={(event) => update(entry.id, { hidden: event.currentTarget.checked })}
/>
<Checkbox
label={t("compact")}
checked={entry.compact}
onChange={(event) => update(entry.id, { compact: event.currentTarget.checked })}
/>
</Group>
<Group gap={4}>
<ActionIcon
variant="subtle"
disabled={index === 0}
aria-label={t("moveUp")}
onClick={() => move(index, -1)}
>
<IconArrowUp size={16} />
</ActionIcon>
<ActionIcon
variant="subtle"
disabled={index === entries.length - 1}
aria-label={t("moveDown")}
onClick={() => move(index, 1)}
>
<IconArrowDown size={16} />
</ActionIcon>
<ActionIcon
variant="subtle"
color="red"
aria-label={t("remove")}
onClick={() => save(entries.filter((item) => item.id !== entry.id))}
>
<IconTrash size={16} />
</ActionIcon>
</Group>
</Group>
</Stack>
</Card>
))}
</Stack>
);
};
@@ -0,0 +1,249 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import {
closestCenter,
DndContext,
KeyboardSensor,
MouseSensor,
TouchSensor,
useSensor,
useSensors,
} from "@dnd-kit/core";
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { ActionIcon, Button, Card, Fieldset, Group, Select, Stack, Text, TextInput } from "@mantine/core";
import { IconGripVertical, IconPlus, IconTrash } from "@tabler/icons-react";
import { createId } from "@homarr/common";
import type { WidgetKind } from "@homarr/definitions";
import { useI18n } from "@homarr/translation/client";
import type { OptionTimezone } from "../options";
import type { CommonWidgetInputProps } from "./common";
import { useWidgetInputTranslation } from "./common";
import { useFormContext } from "./form";
export const WidgetTimezoneListInput = ({ property, kind, options }: CommonWidgetInputProps<"timezoneList">) => {
const t = useWidgetInputTranslation(kind, property);
const tEditor = useTimezoneListTranslation(kind);
const form = useFormContext();
const fieldPath = `options.${property}`;
const currentValue = form.values.options[property];
const values = Array.isArray(currentValue) ? (currentValue as OptionTimezone[]) : options.defaultValue;
const [selectedTimeZone, setSelectedTimeZone] = useState<string | null>(null);
const sensors = useSensors(
useSensor(MouseSensor),
useSensor(TouchSensor),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
);
useEffect(() => {
if (Array.isArray(currentValue)) return;
form.setFieldValue(fieldPath, options.defaultValue);
}, [currentValue, fieldPath, form, options.defaultValue]);
const usedTimeZones = useMemo(() => new Set(values.map(({ timeZone }) => timeZone)), [values]);
const availableOptions = options.timeZoneOptions.map((option) => ({
...option,
disabled: usedTimeZones.has(option.value),
}));
const isFull = values.length >= options.maxValues;
const setValues = (nextValues: OptionTimezone[]) => form.setFieldValue(fieldPath, nextValues);
const addCity = (city: OptionTimezone) => {
if (isFull || usedTimeZones.has(city.timeZone)) return;
setValues([...values, city]);
};
const addSelectedTimeZone = () => {
if (!selectedTimeZone) return;
const option = options.timeZoneOptions.find(({ value }) => value === selectedTimeZone);
if (!option) return;
const label = option.label.replace(` (${option.value})`, "");
addCity({ id: createId(), label, timeZone: option.value });
setSelectedTimeZone(null);
};
return (
<Fieldset legend={t("label")}>
<Stack gap="sm">
{options.withDescription && (
<Text size="sm" c="dimmed">
{t("description")}
</Text>
)}
<Group gap="xs" align="end" wrap="nowrap">
<Select
flex={1}
label={tEditor("timezone")}
placeholder={tEditor("searchPlaceholder")}
searchable
clearable
data={availableOptions}
value={selectedTimeZone}
onChange={setSelectedTimeZone}
disabled={isFull}
nothingFoundMessage={tEditor("noResults")}
/>
<Button
leftSection={<IconPlus size="var(--mantine-font-size-md)" />}
onClick={addSelectedTimeZone}
disabled={isFull || selectedTimeZone === null}
>
{tEditor("add")}
</Button>
</Group>
<Group gap="xs">
{options.presets.map((preset) => (
<Button
key={preset.timeZone}
size="compact-xs"
variant="light"
disabled={isFull || usedTimeZones.has(preset.timeZone)}
onClick={() => addCity({ ...preset, id: createId() })}
>
{preset.label}
</Button>
))}
</Group>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={({ active, over }) => {
if (!over || active.id === over.id) return;
const activeIndex = values.findIndex(({ id }) => id === active.id);
const overIndex = values.findIndex(({ id }) => id === over.id);
if (activeIndex < 0 || overIndex < 0) return;
setValues(arrayMove(values, activeIndex, overIndex));
}}
>
<SortableContext items={values.map(({ id }) => id)} strategy={verticalListSortingStrategy}>
<Stack gap="xs">
{values.map((city) => (
<SortableTimezone
key={city.id}
kind={kind}
city={city}
timeZoneOptions={options.timeZoneOptions}
usedTimeZones={usedTimeZones}
onChange={(nextCity) =>
setValues(values.map((candidate) => (candidate.id === city.id ? nextCity : candidate)))
}
onRemove={() => setValues(values.filter((candidate) => candidate.id !== city.id))}
/>
))}
</Stack>
</SortableContext>
</DndContext>
<Text size="xs" c="dimmed">
{tEditor("limit", { count: values.length, maximum: options.maxValues })}
</Text>
</Stack>
</Fieldset>
);
};
interface SortableTimezoneProps {
kind: WidgetKind;
city: OptionTimezone;
timeZoneOptions: readonly { value: string; label: string }[];
usedTimeZones: ReadonlySet<string>;
onChange: (city: OptionTimezone) => void;
onRemove: () => void;
}
const SortableTimezone = ({
kind,
city,
timeZoneOptions,
usedTimeZones,
onChange,
onRemove,
}: SortableTimezoneProps) => {
const t = useTimezoneListTranslation(kind);
const { attributes, listeners, setActivatorNodeRef, setNodeRef, transform, transition, isDragging } = useSortable({
id: city.id,
});
const style = {
transform: transform ? `translate3d(${Math.round(transform.x)}px, ${Math.round(transform.y)}px, 0)` : undefined,
transition,
opacity: isDragging ? 0.6 : undefined,
zIndex: isDragging ? 1 : undefined,
};
const hasCurrentTimeZone = timeZoneOptions.some(({ value }) => value === city.timeZone);
const selectableTimeZones = hasCurrentTimeZone
? timeZoneOptions
: [{ value: city.timeZone, label: t("unsupportedOption", { timeZone: city.timeZone }) }, ...timeZoneOptions];
return (
<Card ref={setNodeRef} withBorder p="xs" style={style}>
<Group gap="xs" wrap="wrap" align="end">
<ActionIcon
ref={setActivatorNodeRef}
{...attributes}
{...listeners}
variant="subtle"
color="gray"
size="lg"
aria-label={t("reorder", { city: city.label })}
style={{ cursor: "grab" }}
>
<IconGripVertical size="var(--mantine-font-size-lg)" />
</ActionIcon>
<TextInput
flex={1}
label={t("cityLabel")}
value={city.label}
onChange={(event) => onChange({ ...city, label: event.currentTarget.value })}
/>
<Select
flex={2}
label={t("timezone")}
searchable
data={selectableTimeZones.map((option) => ({
...option,
disabled: option.value !== city.timeZone && usedTimeZones.has(option.value),
}))}
value={city.timeZone}
onChange={(timeZone) => {
if (timeZone) onChange({ ...city, timeZone });
}}
/>
<ActionIcon
color="red"
variant="subtle"
size="lg"
aria-label={t("remove", { city: city.label })}
onClick={onRemove}
>
<IconTrash size="var(--mantine-font-size-lg)" />
</ActionIcon>
</Group>
</Card>
);
};
type TimezoneListTranslationKey =
| "timezone"
| "cityLabel"
| "searchPlaceholder"
| "noResults"
| "unsupportedOption"
| "add"
| "remove"
| "reorder"
| "limit";
type TimezoneListTranslation = (key: TimezoneListTranslationKey, values?: Record<string, string | number>) => string;
const useTimezoneListTranslation = (kind: WidgetKind) =>
useI18n(`widget.${kind}.timezoneList` as never) as unknown as TimezoneListTranslation;
@@ -3,14 +3,15 @@
import { Select, Text } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import type { CommonWidgetInputProps } from "./common";
import { useWidgetInputTranslation } from "./common";
import { useFormContext } from "./form";
export const WidgetUmamiEventInput = ({ property, kind }: CommonWidgetInputProps<"umamiEventName">) => {
const t = useScopedI18n("widget.umami.option.eventName");
const t = useI18n("widget.umami.option.eventName");
const tEvents = useI18n("widget.umami.option.events");
const tInput = useWidgetInputTranslation(kind, property);
const form = useFormContext();
@@ -25,7 +26,7 @@ export const WidgetUmamiEventInput = ({ property, kind }: CommonWidgetInputProps
if (!websiteId || integrationIds.length === 0) {
return (
<Text size="sm" c="dimmed">
{t("configureWebsiteFirst")}
{tEvents("configureWebsiteFirst")}
</Text>
);
}
@@ -36,9 +37,9 @@ export const WidgetUmamiEventInput = ({ property, kind }: CommonWidgetInputProps
<Select
label={tInput("label")}
description={tInput("description")}
placeholder={isPending ? t("loading") : undefined}
placeholder={isPending ? tEvents("loading") : undefined}
searchable
nothingFoundMessage={t("noEvents")}
nothingFoundMessage={tEvents("noEvents")}
data={data}
{...form.getInputProps(`options.${property}`)}
/>
@@ -3,14 +3,15 @@
import { MultiSelect, Text } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import type { CommonWidgetInputProps } from "./common";
import { useWidgetInputTranslation } from "./common";
import { useFormContext } from "./form";
export const WidgetUmamiEventNamesInput = ({ property, kind }: CommonWidgetInputProps<"umamiEventNames">) => {
const t = useScopedI18n("widget.umami.option.eventNames");
const t = useI18n("widget.umami.option.eventNames");
const tEvents = useI18n("widget.umami.option.events");
const tInput = useWidgetInputTranslation(kind, property);
const form = useFormContext();
@@ -25,7 +26,7 @@ export const WidgetUmamiEventNamesInput = ({ property, kind }: CommonWidgetInput
if (!websiteId || integrationIds.length === 0) {
return (
<Text size="sm" c="dimmed">
{t("configureWebsiteFirst")}
{tEvents("configureWebsiteFirst")}
</Text>
);
}
@@ -34,9 +35,9 @@ export const WidgetUmamiEventNamesInput = ({ property, kind }: CommonWidgetInput
<MultiSelect
label={tInput("label")}
description={tInput("description")}
placeholder={isPending ? t("loading") : t("placeholder")}
placeholder={isPending ? tEvents("loading") : t("placeholder")}
searchable
nothingFoundMessage={t("noEvents")}
nothingFoundMessage={tEvents("noEvents")}
data={eventNames ?? []}
{...form.getInputProps(`options.${property}`)}
/>
@@ -3,14 +3,14 @@
import { Select, Text } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import type { CommonWidgetInputProps } from "./common";
import { useWidgetInputTranslation } from "./common";
import { useFormContext } from "./form";
export const WidgetUmamiWebsiteInput = ({ property, kind }: CommonWidgetInputProps<"umamiWebsite">) => {
const t = useScopedI18n("widget.umami.option.websiteId");
const t = useI18n("widget.umami.option.websiteId");
const tInput = useWidgetInputTranslation(kind, property);
const form = useFormContext();
@@ -0,0 +1,393 @@
"use client";
import { useId } from "react";
import { CompositeChart, Sparkline } from "@mantine/charts";
import { Box, Group, Paper, ScrollArea, SimpleGrid, Stack, Text, VisuallyHidden } from "@mantine/core";
import { IconMapPin } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
import { WidgetEmptyState } from "../common/empty-state";
import { formatLocalizedDate, formatLocalizedTime } from "../common/locale";
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state";
import { WidgetQueryLoadingState } from "../common/query-state-indicator";
import type { WidgetComponentProps } from "../definition";
import type { AirQualityData, PollutantKey, PollenKey } from "./display";
import {
getAqiCategory,
getAqiColor,
getAqiStandard,
getAqiValue,
getCompactAirQualityLayout,
getDailyAqiValue,
getDominantPollutant,
getStrongestPollen,
getUpcomingHours,
getUvCategory,
} from "./display";
export default function AirQualityWidget({ options, width, height, displayMode }: WidgetComponentProps<"airQuality">) {
const query = clientApi.widget.airQuality.atLocation.useQuery({
latitude: options.location.latitude,
longitude: options.location.longitude,
});
const airQuality = getUsableWidgetQueryData(query);
if (isInitialWidgetQueryPending(query)) return <WidgetQueryLoadingState />;
if (!airQuality) return <WidgetEmptyState />;
return (
<Box h="100%" w="100%" pos="relative">
{displayMode === "advanced" ? (
<AdvancedAirQuality airQuality={airQuality} options={options} width={width} />
) : (
<CompactAirQuality airQuality={airQuality} height={height} options={options} width={width} />
)}
</Box>
);
}
interface AirQualityViewProps {
airQuality: AirQualityData;
options: WidgetComponentProps<"airQuality">["options"];
}
const CompactAirQuality = ({
airQuality,
height,
options,
width,
}: AirQualityViewProps & { height: number; width: number }) => {
const locale = useCurrentIntlLocale();
const t = useI18n("widget.airQuality");
const layout = getCompactAirQualityLayout(width, height);
const standard = getAqiStandard(options.aqiStandard, locale);
const value = getAqiValue(airQuality.current, standard);
const category = getAqiCategory(value, standard);
const dominant = getDominantPollutant(airQuality.current, standard);
const strongestPollen = getStrongestPollen(airQuality.current);
const labels = useAirQualityLabels();
const sixHours = getUpcomingHours(airQuality, 6)
.map((hour) => getAqiValue(hour, standard))
.filter((hour): hour is number => hour !== null);
return (
<Stack
h="100%"
w="100%"
justify="center"
align="center"
gap={layout.tiny ? 2 : "xs"}
p="xs"
style={{ overflow: "hidden" }}
>
<Group gap="xs" wrap="nowrap" align="center">
<Text fz={layout.tiny ? 34 : 42} fw={700} lh={1} c={getAqiColor(category)}>
{formatAqi(value, locale)}
</Text>
<Stack gap={1} align="flex-start">
<Text size={layout.tiny ? "xs" : "sm"} fw={600}>
{t(`category.${category}`)}
</Text>
</Stack>
</Group>
{!layout.tiny && (
<Group gap="md" justify="center" wrap="wrap">
{options.showUv && (
<MetricLine
label={t("uv.label")}
value={`${formatDecimal(airQuality.current.uvIndex, locale)} · ${t(`uv.category.${getUvCategory(airQuality.current.uvIndex)}`)}`}
/>
)}
{options.showPollutants && dominant && (
<MetricLine label={t("dominantPollutant")} value={labels.pollutants[dominant.key]} />
)}
</Group>
)}
{!layout.tiny && layout.showParticulatesAndPollen && (
<Group gap="md" justify="center" wrap="wrap">
{options.showPollutants && (
<>
<MetricLine
label={labels.pollutants.pm2_5}
value={formatConcentration(airQuality.current.pollutants.pm2_5, locale, t)}
/>
<MetricLine
label={labels.pollutants.pm10}
value={formatConcentration(airQuality.current.pollutants.pm10, locale, t)}
/>
</>
)}
{options.showPollen && strongestPollen && (
<MetricLine
label={t("strongestPollen")}
value={`${labels.pollen[strongestPollen.key]} · ${formatPollen(strongestPollen.value, locale, t)}`}
/>
)}
</Group>
)}
{!layout.tiny && layout.showSparkline && sixHours.length > 1 && (
<Box component="figure" w="100%" h={34} m={0}>
<Sparkline
aria-hidden
h="100%"
w="100%"
data={sixHours}
color={getAqiColor(category)}
curveType="monotone"
fillOpacity={0.2}
strokeWidth={2}
/>
<VisuallyHidden component="figcaption">{t("compact.sixHourTrend")}</VisuallyHidden>
</Box>
)}
</Stack>
);
};
const AdvancedAirQuality = ({ airQuality, options, width }: AirQualityViewProps & { width: number }) => {
const chartId = useId();
const locale = useCurrentIntlLocale();
const t = useI18n("widget.airQuality");
const standard = getAqiStandard(options.aqiStandard, locale);
const value = getAqiValue(airQuality.current, standard);
const category = getAqiCategory(value, standard);
const labels = useAirQualityLabels();
const upcoming = getUpcomingHours(airQuality, 24);
const chartData = upcoming.map((hour) => ({
observedAt: hour.observedAt,
aqi: getAqiValue(hour, standard) ?? Number.NaN,
uv: hour.uvIndex ?? Number.NaN,
}));
const series = [
{ name: "aqi", label: t("aqiLabel"), color: getAqiColor(category), type: "line" as const },
...(options.showUv
? [{ name: "uv", label: t("uv.label"), color: "yellow.7", type: "line" as const, yAxisId: "right" as const }]
: []),
];
return (
<ScrollArea h="100%" w="100%">
<Stack gap="md" p="md">
<Group justify="space-between" align="flex-start" wrap="wrap">
<Stack gap={2}>
<Text fz={38} fw={700} lh={1} c={getAqiColor(category)}>
{formatAqi(value, locale)}
</Text>
<Text fw={600}>{t(`category.${category}`)}</Text>
</Stack>
<Stack gap={2} align="flex-end">
<Group gap={5} wrap="nowrap">
<IconMapPin size="var(--mantine-font-size-md)" aria-hidden />
<Text size="sm" fw={600}>
{options.location.name}
</Text>
</Group>
<Text size="xs" c="dimmed">
{t("advanced.observedAt", {
time: formatLocalizedTime(airQuality.current.observedAt, locale, { timeZone: airQuality.timezone }),
})}
</Text>
</Stack>
</Group>
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="xs">
<MetricCard label={t("aqiLabel")} value={formatAqi(value, locale)} />
{options.showUv && (
<MetricCard label={t("uv.label")} value={formatDecimal(airQuality.current.uvIndex, locale)} />
)}
{options.showPollutants && (
<>
<MetricCard
label={labels.pollutants.pm2_5}
value={formatConcentration(airQuality.current.pollutants.pm2_5, locale, t)}
/>
<MetricCard
label={labels.pollutants.pm10}
value={formatConcentration(airQuality.current.pollutants.pm10, locale, t)}
/>
</>
)}
</SimpleGrid>
<SimpleGrid cols={width >= 800 ? 2 : 1} spacing="md">
<Paper withBorder p="sm">
<Text id={chartId} size="sm" fw={600} mb="xs">
{t("advanced.next24Hours")}
</Text>
{chartData.length > 0 ? (
<>
<Box aria-hidden>
<CompositeChart
h={220}
data={chartData}
dataKey="observedAt"
series={series}
curveType="monotone"
gridAxis="y"
tickLine="none"
withDots={false}
withLegend
withRightYAxis={options.showUv}
rightYAxisProps={{ width: 34 }}
xAxisProps={{
tickFormatter: (time: string) =>
formatLocalizedTime(time, locale, { timeZone: airQuality.timezone }),
}}
/>
</Box>
<VisuallyHidden>
<ul aria-labelledby={chartId}>
{upcoming.map((hour) => (
<li key={hour.observedAt}>
{formatLocalizedTime(hour.observedAt, locale, { timeZone: airQuality.timezone })}:{" "}
{t("advanced.aqiValue", {
value: formatAqi(getAqiValue(hour, standard), locale),
})}
{options.showUv
? `, ${t("advanced.uvValue", { value: formatDecimal(hour.uvIndex, locale) })}`
: ""}
</li>
))}
</ul>
</VisuallyHidden>
</>
) : (
<Text size="sm" c="dimmed">
{t("advanced.noForecast")}
</Text>
)}
</Paper>
<Stack gap="md">
{options.showPollutants && (
<Paper withBorder p="sm">
<Text size="sm" fw={600} mb="xs">
{t("advanced.pollutants")}
</Text>
<SimpleGrid cols={2} spacing="xs">
{pollutantKeys.map((key) => (
<MetricLine
key={key}
label={labels.pollutants[key]}
value={formatConcentration(airQuality.current.pollutants[key], locale, t)}
/>
))}
</SimpleGrid>
</Paper>
)}
{options.showPollen && hasAvailableValue(airQuality.current.pollen) && (
<Paper withBorder p="sm">
<Text size="sm" fw={600} mb="xs">
{t("advanced.pollen")}
</Text>
<SimpleGrid cols={2} spacing="xs">
{pollenKeys.map((key) => {
const pollen = airQuality.current.pollen[key];
if (pollen === null) return null;
return <MetricLine key={key} label={labels.pollen[key]} value={formatPollen(pollen, locale, t)} />;
})}
</SimpleGrid>
</Paper>
)}
</Stack>
</SimpleGrid>
<Paper withBorder p="sm">
<Text size="sm" fw={600} mb="xs">
{t("advanced.sevenDayForecast")}
</Text>
<SimpleGrid cols={{ base: 2, sm: 4, md: 7 }} spacing="xs">
{airQuality.daily.map((day) => {
const dailyAqi = getDailyAqiValue(day, standard);
return (
<Stack key={day.date} gap={2} align="center">
<Text size="xs" c="dimmed">
{formatLocalizedDate(day.date, locale, { weekday: "short" })}
</Text>
<Text fw={700} c={getAqiColor(getAqiCategory(dailyAqi, standard))}>
{formatAqi(dailyAqi, locale)}
</Text>
{options.showUv && (
<Text size="xs">{t("advanced.uvShort", { value: formatDecimal(day.uvIndexMax, locale) })}</Text>
)}
</Stack>
);
})}
</SimpleGrid>
</Paper>
</Stack>
</ScrollArea>
);
};
const MetricCard = ({ label, value }: { label: string; value: string }) => (
<Paper withBorder p="xs">
<Text size="xs" c="dimmed">
{label}
</Text>
<Text fz="lg" fw={700}>
{value}
</Text>
</Paper>
);
const MetricLine = ({ label, value }: { label: string; value: string }) => (
<Stack gap={0} miw={0}>
<Text size="xs" c="dimmed" truncate>
{label}
</Text>
<Text size="sm" fw={600} truncate>
{value}
</Text>
</Stack>
);
const pollutantKeys: PollutantKey[] = ["pm2_5", "pm10", "ozone", "nitrogenDioxide", "sulphurDioxide", "carbonMonoxide"];
const pollenKeys: PollenKey[] = ["alder", "birch", "grass", "mugwort", "olive", "ragweed"];
const useAirQualityLabels = () => {
const t = useI18n("widget.airQuality");
return {
pollutants: {
pm2_5: t("pollutant.pm2_5"),
pm10: t("pollutant.pm10"),
ozone: t("pollutant.ozone"),
nitrogenDioxide: t("pollutant.nitrogenDioxide"),
sulphurDioxide: t("pollutant.sulphurDioxide"),
carbonMonoxide: t("pollutant.carbonMonoxide"),
} satisfies Record<PollutantKey, string>,
pollen: {
alder: t("pollen.alder"),
birch: t("pollen.birch"),
grass: t("pollen.grass"),
mugwort: t("pollen.mugwort"),
olive: t("pollen.olive"),
ragweed: t("pollen.ragweed"),
} satisfies Record<PollenKey, string>,
};
};
const formatAqi = (value: number | null, locale: string) => {
if (value === null) return "—";
return new Intl.NumberFormat(locale, { maximumFractionDigits: 0 }).format(value);
};
const formatDecimal = (value: number | null, locale: string) => {
if (value === null) return "—";
return new Intl.NumberFormat(locale, { maximumFractionDigits: 1 }).format(value);
};
type ScopedAirQualityTranslator = ReturnType<typeof useI18n<"widget.airQuality">>;
const formatConcentration = (value: number | null, locale: string, t: ScopedAirQualityTranslator) =>
t("unit.concentration", { value: formatDecimal(value, locale) });
const formatPollen = (value: number | null, locale: string, t: ScopedAirQualityTranslator) =>
t("unit.pollen", { value: formatDecimal(value, locale) });
const hasAvailableValue = (values: Record<string, number | null>) =>
Object.values(values).some((value) => value !== null);
@@ -0,0 +1,64 @@
import { describe, expect, it } from "vitest";
import {
getAqiCategory,
getAqiStandard,
getCompactAirQualityLayout,
getDominantPollutant,
getStrongestPollen,
} from "./display";
const point = {
observedAt: "2026-07-01T10:00:00.000Z",
europeanAqi: 42,
usAqi: 60,
uvIndex: 4,
pollutants: { pm2_5: 12, pm10: 20, ozone: 30, nitrogenDioxide: 4, sulphurDioxide: 3, carbonMonoxide: 100 },
europeanSubIndices: {
pm2_5: 20,
pm10: 31,
ozone: 42,
nitrogenDioxide: 10,
sulphurDioxide: 5,
carbonMonoxide: null,
},
usSubIndices: {
pm2_5: 50,
pm10: 40,
ozone: 30,
nitrogenDioxide: 20,
sulphurDioxide: 10,
carbonMonoxide: 60,
},
pollen: { alder: null, birch: 12, grass: 20, mugwort: 5, olive: null, ragweed: 2 },
};
describe("air quality display", () => {
it("uses the US scale only for US locales in automatic mode", () => {
expect(getAqiStandard("auto", "en-US")).toBe("us");
expect(getAqiStandard("auto", "fr-FR")).toBe("european");
expect(getAqiStandard("auto", "en")).toBe("european");
expect(getAqiStandard("us", "fr-FR")).toBe("us");
});
it("uses the defined AQI band boundaries", () => {
expect(getAqiCategory(20, "european")).toBe("good");
expect(getAqiCategory(21, "european")).toBe("fair");
expect(getAqiCategory(101, "european")).toBe("extremelyPoor");
expect(getAqiCategory(50, "us")).toBe("good");
expect(getAqiCategory(51, "us")).toBe("moderate");
expect(getAqiCategory(301, "us")).toBe("hazardous");
});
it("finds dominant subindices and raw pollen without combining them", () => {
expect(getDominantPollutant(point, "european")).toEqual({ key: "ozone", value: 42 });
expect(getDominantPollutant(point, "us")).toEqual({ key: "carbonMonoxide", value: 60 });
expect(getStrongestPollen(point)).toEqual({ key: "grass", value: 20 });
});
it("progressively reveals compact details", () => {
expect(getCompactAirQualityLayout(149, 200)).toMatchObject({ tiny: true });
expect(getCompactAirQualityLayout(250, 150)).toMatchObject({ showParticulatesAndPollen: true });
expect(getCompactAirQualityLayout(300, 170)).toMatchObject({ showSparkline: true });
});
});
+144
View File
@@ -0,0 +1,144 @@
export interface AirQualityPollutants {
pm2_5: number | null;
pm10: number | null;
ozone: number | null;
nitrogenDioxide: number | null;
sulphurDioxide: number | null;
carbonMonoxide: number | null;
}
export interface AirQualityPoint {
observedAt: string;
europeanAqi: number | null;
usAqi: number | null;
uvIndex: number | null;
pollutants: AirQualityPollutants;
europeanSubIndices: AirQualityPollutants;
usSubIndices: AirQualityPollutants;
pollen: Record<"alder" | "birch" | "grass" | "mugwort" | "olive" | "ragweed", number | null>;
}
export interface AirQualityData {
timezone: string;
utcOffsetSeconds: number;
current: AirQualityPoint;
hourly: AirQualityPoint[];
daily: {
date: string;
europeanAqiMax: number | null;
usAqiMax: number | null;
uvIndexMax: number | null;
pm2_5Max: number | null;
pm10Max: number | null;
}[];
}
export type AqiStandard = "european" | "us";
export type AqiStandardOption = "auto" | AqiStandard;
export type AqiCategory =
| "good"
| "fair"
| "moderate"
| "unhealthySensitive"
| "unhealthy"
| "poor"
| "veryPoor"
| "veryUnhealthy"
| "extremelyPoor"
| "hazardous"
| "unknown";
export type UvCategory = "low" | "moderate" | "high" | "veryHigh" | "extreme" | "unknown";
export type PollutantKey = keyof AirQualityPoint["pollutants"];
export type PollenKey = keyof AirQualityPoint["pollen"];
export const getAqiStandard = (option: AqiStandardOption, locale: string): AqiStandard => {
if (option !== "auto") return option;
try {
return new Intl.Locale(locale).region === "US" ? "us" : "european";
} catch {
return "european";
}
};
export const getAqiValue = (point: AirQualityPoint, standard: AqiStandard): number | null => {
if (standard === "us") return point.usAqi;
return point.europeanAqi;
};
export const getDailyAqiValue = (day: AirQualityData["daily"][number], standard: AqiStandard): number | null => {
if (standard === "us") return day.usAqiMax;
return day.europeanAqiMax;
};
export const getAqiCategory = (value: number | null, standard: AqiStandard): AqiCategory => {
if (value === null) return "unknown";
if (standard === "us") {
if (value <= 50) return "good";
if (value <= 100) return "moderate";
if (value <= 150) return "unhealthySensitive";
if (value <= 200) return "unhealthy";
if (value <= 300) return "veryUnhealthy";
return "hazardous";
}
if (value <= 20) return "good";
if (value <= 40) return "fair";
if (value <= 60) return "moderate";
if (value <= 80) return "poor";
if (value <= 100) return "veryPoor";
return "extremelyPoor";
};
export const getAqiColor = (category: AqiCategory): string => {
if (category === "good") return "green.6";
if (category === "fair") return "lime.7";
if (category === "moderate") return "yellow.7";
if (category === "unhealthySensitive" || category === "poor") return "orange.7";
if (category === "unhealthy" || category === "veryPoor") return "red.7";
if (category === "veryUnhealthy" || category === "extremelyPoor") return "grape.7";
if (category === "hazardous") return "dark.6";
return "gray.6";
};
export const getUvCategory = (value: number | null): UvCategory => {
if (value === null) return "unknown";
if (value < 3) return "low";
if (value < 6) return "moderate";
if (value < 8) return "high";
if (value < 11) return "veryHigh";
return "extreme";
};
export const getDominantPollutant = (
point: AirQualityPoint,
standard: AqiStandard,
): { key: PollutantKey; value: number } | null => {
const values = standard === "us" ? point.usSubIndices : point.europeanSubIndices;
return getHighestValue(values);
};
export const getStrongestPollen = (point: AirQualityPoint): { key: PollenKey; value: number } | null =>
getHighestValue(point.pollen);
const getHighestValue = <TKey extends string>(
values: Record<TKey, number | null>,
): { key: TKey; value: number } | null => {
let highest: { key: TKey; value: number } | null = null;
for (const [key, value] of Object.entries(values) as [TKey, number | null][]) {
if (value === null) continue;
if (highest === null || value > highest.value) highest = { key, value };
}
return highest;
};
export const getCompactAirQualityLayout = (width: number, height: number) => ({
tiny: width < 150 || height < 96,
showParticulatesAndPollen: width >= 240 && height >= 140,
showSparkline: width >= 300 && height >= 170,
});
export const getUpcomingHours = (data: AirQualityData, count: number): AirQualityData["hourly"] => {
const currentTime = Date.parse(data.current.observedAt);
return data.hourly.filter((hour) => Date.parse(hour.observedAt) >= currentTime).slice(0, count);
};
+43
View File
@@ -0,0 +1,43 @@
import { IconWind } from "@tabler/icons-react";
import { createWidgetDefinition, widgetQueryInputMatches } from "../definition";
import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("airQuality", {
icon: IconWind,
supportsAdvancedFocus: true,
queryKey: [["widget", "airQuality", "atLocation"]],
refetchInterval: 900,
queryMatcher: ({ input }, scope) => {
const location = scope.options.location;
if (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) => ({
location: factory.location({
defaultValue: {
name: "Paris",
latitude: 48.85341,
longitude: 2.3488,
},
}),
aqiStandard: factory.select({
defaultValue: "auto",
options: [
{ value: "auto", label: (t) => t("widget.airQuality.option.aqiStandard.options.auto") },
{ value: "european", label: (t) => t("widget.airQuality.option.aqiStandard.options.european") },
{ value: "us", label: (t) => t("widget.airQuality.option.aqiStandard.options.us") },
],
}),
showUv: factory.switch({ defaultValue: true }),
showPollutants: factory.switch({ defaultValue: true }),
showPollen: factory.switch({ defaultValue: true }),
}));
},
}).withDynamicImport(() => import("./component"));
@@ -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 -139
View File
@@ -1,110 +1,25 @@
"use client";
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 { 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 { ActionIcon, Badge, Button, Center, Group, Stack, Text, TextInput, Tooltip } from "@mantine/core";
import { IconEdit } from "@tabler/icons-react";
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, useI18n } 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 { storedContentToPlainText } from "./content";
import "react-quill-new/dist/quill.snow.css";
import "./anchor-note.css";
type ReactQuillProps = ComponentProps<typeof ReactQuillComponent>;
type ReactQuillOnChange = NonNullable<ReactQuillProps["onChange"]>;
const ReactQuill = dynamic(() => import("react-quill-new"), { ssr: false });
const quillDeltaOperationSchema = z.object({
insert: z.unknown().optional(),
attributes: z.record(z.string(), z.unknown()).optional(),
});
const quillDeltaSchema = z.object({
ops: z.array(quillDeltaOperationSchema),
});
type QuillDelta = z.infer<typeof quillDeltaSchema>;
const DeltaConstructor = Quill.import("delta") as new (
ops?: QuillDelta["ops"] | { ops: QuillDelta["ops"] },
) => DeltaStatic;
const quillModules = {
toolbar: [
["bold", "italic", "underline", "strike"],
[{ header: [1, 2, 3, 4, false] }],
[{ align: [] }],
[{ list: "ordered" }, { list: "bullet" }, { list: "check" }],
[{ indent: "-1" }, { indent: "+1" }],
["blockquote", "code-block"],
["link"],
["clean"],
],
history: {
delay: 1000,
maxStack: 200,
userOnly: true,
},
};
const readOnlyModules = {
toolbar: false,
};
const quillFormats = [
"bold",
"italic",
"underline",
"strike",
"header",
"align",
"list",
"indent",
"blockquote",
"code-block",
"link",
];
const emptyDelta = (): DeltaStatic => new DeltaConstructor([{ insert: "\n" }]);
const toPlainTextDelta = (value: string): DeltaStatic => {
const normalized = value.endsWith("\n") ? value : `${value}\n`;
return new DeltaConstructor([{ insert: normalized }]);
};
const parseStoredContent = (content?: string | null): DeltaStatic => {
if (!content) return emptyDelta();
try {
const parsedDelta = quillDeltaSchema.safeParse(JSON.parse(content));
if (parsedDelta.success) {
return new DeltaConstructor(parsedDelta.data.ops);
}
} catch {
return toPlainTextDelta(content);
}
return toPlainTextDelta(content);
};
const stringifyDelta = (delta: unknown): string => {
const parsedDelta = quillDeltaSchema.safeParse(delta);
if (parsedDelta.success) {
return JSON.stringify(parsedDelta.data);
}
return JSON.stringify(emptyDelta());
};
const AnchorNoteEditor = dynamic(() => import("./editor"), { ssr: false });
const canEditPermission = (permission: AnchorNotePermission) => {
return permission === "owner" || permission === "editor";
@@ -119,8 +34,14 @@ const isForbiddenError = (error: unknown): boolean => {
return (data as { code?: unknown }).code === "FORBIDDEN";
};
export default function AnchorNoteWidget({ options, integrationIds }: WidgetComponentProps<"anchorNote">) {
const t = useScopedI18n("widget.anchorNote");
export default function AnchorNoteWidget({
options,
integrationIds,
width,
height,
displayMode,
}: WidgetComponentProps<"anchorNote">) {
const t = useI18n("widget.anchorNote");
const noteId = options.noteId.trim();
if (!noteId) {
return (
@@ -134,21 +55,45 @@ 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 t = useScopedI18n("widget.anchorNote");
const { data: note, refetch } = clientApi.widget.anchorNotes.getNote.useQuery({
const AnchorNoteWidgetContent = ({
options,
integrationId,
noteId,
width,
height,
isAdvanced,
}: AnchorNoteWidgetContentProps) => {
const t = useI18n("widget.anchorNote");
const tWidgetCommon = useI18n("widget.common");
const actionT = useI18n("common.action");
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 +110,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);
@@ -176,16 +122,7 @@ const AnchorNoteWidgetContent = ({ options, integrationId, noteId }: AnchorNoteW
return normalizedTitle !== note.title || draftContent !== (note.content ?? "");
}, [draftContent, draftTitle, note]);
const editorValue = useMemo(() => parseStoredContent(draftContent), [draftContent]);
const readOnlyValue = useMemo(() => parseStoredContent(note?.content), [note?.content]);
const handleEditorChange = useCallback<ReactQuillOnChange>(
(_html, _delta, source, editor) => {
if (!isEditing || source !== "user") return;
const next = stringifyDelta(editor.getContents());
setDraftContent(next);
},
[isEditing],
);
const plainText = useMemo(() => storedContentToPlainText(note?.content), [note?.content]);
const handleEdit = useCallback(() => {
if (!canEdit || !note) return;
@@ -260,17 +197,17 @@ 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 ? (
<TextInput value={draftTitle} onChange={(event) => setDraftTitle(event.currentTarget.value)} size="sm" />
) : (
options.showTitle && <Text fw={600}>{note.title || t("untitled")}</Text>
(isAdvanced || options.showTitle) && <Text fw={600}>{note.title || t("untitled")}</Text>
)}
{!isEditing && options.showUpdatedAt && (
{!isEditing && (isAdvanced || options.showUpdatedAt) && (
<Text size="xs" c="dimmed">
{t("updatedAt", { date: updatedAtRelative })}
{tWidgetCommon("updatedAt", { date: updatedAtRelative })}
</Text>
)}
{!isEditing && isViewer && (
@@ -278,40 +215,84 @@ 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")}
</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 gap="xs" wrap="nowrap">
{(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}>
{actionT("save")}
</Button>
<Button size="xs" variant="subtle" onClick={handleCancel} disabled={isUpdating}>
{actionT("cancel")}
</Button>
</>
) : isAdvanced ? (
<Button size="xs" variant="light" onClick={handleEdit} disabled={isUpdating}>
{actionT("edit")}
</Button>
) : (
<Tooltip label={actionT("edit")}>
<ActionIcon
className={actionTargetClasses.root}
aria-label={actionT("edit")}
size="md"
variant="light"
onClick={handleEdit}
disabled={isUpdating}
>
<IconEdit size="var(--mantine-font-size-md)" />
</ActionIcon>
</Tooltip>
)}
</Group>
)}
</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 }}
>
<AnchorNoteEditor
content={isEditing ? draftContent : note.content}
readOnly={!isEditing}
onChange={setDraftContent}
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>
);
};
@@ -0,0 +1,27 @@
import { describe, expect, it } from "vitest";
import { parseStoredOperations, storedContentToPlainText, stringifyDelta } from "./content";
describe("Anchor Note stored content", () => {
it("preserves valid Delta operations and extracts their text", () => {
const content = JSON.stringify({
ops: [{ insert: "Hello ", attributes: { bold: true } }, { insert: { image: "ignored" } }, { insert: "world\n" }],
});
expect(parseStoredOperations(content)).toEqual([
{ insert: "Hello ", attributes: { bold: true } },
{ insert: { image: "ignored" } },
{ insert: "world\n" },
]);
expect(storedContentToPlainText(content)).toBe("Hello world");
});
it("keeps legacy plain text readable and normalizes an editor newline", () => {
expect(parseStoredOperations("legacy note")).toEqual([{ insert: "legacy note\n" }]);
expect(storedContentToPlainText("legacy note")).toBe("legacy note");
});
it("falls back to an empty Delta when editor output is invalid", () => {
expect(stringifyDelta({ invalid: true })).toBe('{"ops":[{"insert":"\\n"}]}');
});
});
@@ -0,0 +1,40 @@
import { z } from "zod/v4";
const quillDeltaOperationSchema = z.object({
insert: z.unknown().optional(),
attributes: z.record(z.string(), z.unknown()).optional(),
});
export const quillDeltaSchema = z.object({
ops: z.array(quillDeltaOperationSchema),
});
export type QuillDelta = z.infer<typeof quillDeltaSchema>;
const toPlainTextOperations = (value: string): QuillDelta["ops"] => [
{ insert: value.endsWith("\n") ? value : `${value}\n` },
];
export const parseStoredOperations = (content?: string | null): QuillDelta["ops"] => {
if (!content) return [{ insert: "\n" }];
try {
const parsedDelta = quillDeltaSchema.safeParse(JSON.parse(content));
if (parsedDelta.success) return parsedDelta.data.ops;
} catch {
return toPlainTextOperations(content);
}
return toPlainTextOperations(content);
};
export const stringifyDelta = (delta: unknown): string => {
const parsedDelta = quillDeltaSchema.safeParse(delta);
return JSON.stringify(parsedDelta.success ? parsedDelta.data : { ops: [{ insert: "\n" }] });
};
export const storedContentToPlainText = (content?: string | null): string =>
parseStoredOperations(content)
.map(({ insert }) => (typeof insert === "string" ? insert : ""))
.join("")
.trim();
@@ -0,0 +1,83 @@
"use client";
import { useCallback, useMemo } from "react";
import type { ComponentProps } from "react";
import ReactQuill, { Quill } from "react-quill-new";
import type { DeltaStatic } from "react-quill-new";
import type { QuillDelta } from "./content";
import { parseStoredOperations, stringifyDelta } from "./content";
import "react-quill-new/dist/quill.snow.css";
type ReactQuillOnChange = NonNullable<ComponentProps<typeof ReactQuill>["onChange"]>;
const DeltaConstructor = Quill.import("delta") as new (
ops?: QuillDelta["ops"] | { ops: QuillDelta["ops"] },
) => DeltaStatic;
const quillModules = {
toolbar: [
["bold", "italic", "underline", "strike"],
[{ header: [1, 2, 3, 4, false] }],
[{ align: [] }],
[{ list: "ordered" }, { list: "bullet" }, { list: "check" }],
[{ indent: "-1" }, { indent: "+1" }],
["blockquote", "code-block"],
["link"],
["clean"],
],
history: {
delay: 1000,
maxStack: 200,
userOnly: true,
},
};
const readOnlyModules = {
toolbar: false,
};
const quillFormats = [
"bold",
"italic",
"underline",
"strike",
"header",
"align",
"list",
"indent",
"blockquote",
"code-block",
"link",
];
interface AnchorNoteEditorProps {
content?: string | null;
readOnly: boolean;
placeholder: string;
onChange: (content: string) => void;
}
export default function AnchorNoteEditor({ content, readOnly, placeholder, onChange }: AnchorNoteEditorProps) {
const value = useMemo(() => new DeltaConstructor(parseStoredOperations(content)), [content]);
const handleChange = useCallback<ReactQuillOnChange>(
(_html, _delta, source, editor) => {
if (readOnly || source !== "user") return;
onChange(stringifyDelta(editor.getContents()));
},
[onChange, readOnly],
);
return (
<ReactQuill
theme="snow"
readOnly={readOnly}
value={value}
onChange={handleChange}
modules={readOnly ? readOnlyModules : quillModules}
formats={quillFormats}
placeholder={placeholder}
/>
);
}
+4 -1
View File
@@ -1,12 +1,15 @@
import { IconNotes } from "@tabler/icons-react";
import { getWidgetIntegrationConfig } from "@homarr/definitions";
import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("anchorNote", {
icon: IconNotes,
supportsAdvancedFocus: true,
queryKey: [["widget", "anchorNotes"]],
supportedIntegrations: ["anchor"],
...getWidgetIntegrationConfig("anchorNote"),
createOptions() {
return optionsBuilder.from((factory) => ({
noteId: factory.anchorNote(),
+102 -103
View File
@@ -2,134 +2,110 @@
import type { PropsWithChildren } from "react";
import { Fragment, Suspense } from "react";
import { Flex, rem, Stack, Text, Tooltip, UnstyledButton } from "@mantine/core";
import { Box, Flex, rem, Stack, Text, Tooltip, UnstyledButton } from "@mantine/core";
import { IconMinus } from "@tabler/icons-react";
import combineClasses from "clsx";
import { clientApi } from "@homarr/api/client";
import { useRequiredBoard } from "@homarr/boards/context";
import { useSettings } from "@homarr/settings";
import { useRegisterSpotlightContextResults } from "@homarr/spotlight";
import { useI18n } from "@homarr/translation/client";
import { MaskedOrNormalImage } from "@homarr/ui";
import { WidgetEmptyState } from "../common/empty-state";
import { getSafeAppHref, SAFE_NEW_TAB_REL } from "../common/application-url";
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state";
import { WidgetQueryLoadingState } from "../common/query-state-indicator";
import type { WidgetComponentProps } from "../definition";
import classes from "./app.module.css";
import { PingDot } from "./ping/ping-dot";
import { PingIndicator } from "./ping/ping-indicator";
export default function AppWidget({ options, isEditMode, height, width }: WidgetComponentProps<"app">) {
const t = useI18n();
const tCommon = useI18n("common");
const settings = useSettings();
const board = useRequiredBoard();
const { data: app } = clientApi.app.byId.useQuery({
id: options.appId,
});
useRegisterSpotlightContextResults(
`app-${app?.id ?? options.appId}`,
app?.href
? [
{
id: app.id,
name: app.name,
icon: app.iconUrl,
interaction() {
return {
type: "link",
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
href: app.href!,
newTab: options.openInNewTab,
};
},
},
]
: [],
[app, options.openInNewTab],
);
const appQuery = clientApi.app.byId.useQuery({ id: options.appId }, { enabled: Boolean(options.appId) });
const app = getUsableWidgetQueryData(appQuery);
const href = getSafeAppHref(app?.href);
if (!options.appId) return <WidgetEmptyState />;
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}>
<AppDescriptionTooltip
description={app.description}
enabled={options.descriptionDisplayMode === "tooltip" && Boolean(app.description) && !isEditMode}
>
<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 ? 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>
</AppDescriptionTooltip>
{options.pingEnabled &&
!settings.forceDisableStatus &&
!board.disableStatus &&
Boolean(app.pingUrl ?? app.href) ? (
<Suspense fallback={<PingDot icon={IconMinus} color="gray" tooltip={`${tCommon("action.loading")}…`} />}>
<PingIndicator appId={app.id} />
</Suspense>
) : null}
</AppLink>
</Box>
);
}
@@ -139,13 +115,36 @@ interface AppLinkProps {
enabled: boolean;
}
const AppDescriptionTooltip = ({
description,
enabled,
children,
}: PropsWithChildren<{ description?: string | null; enabled: boolean }>) =>
enabled ? (
<Tooltip.Floating
label={description?.split("\n").map((line, index) => (
<Fragment key={index}>
{line}
<br />
</Fragment>
))}
position="right-start"
multiline
styles={{ tooltip: { maxWidth: 300 } }}
>
{children}
</Tooltip.Floating>
) : (
children
);
const AppLink = ({ href, openInNewTab, enabled, children }: PropsWithChildren<AppLinkProps>) =>
enabled ? (
<UnstyledButton
component="a"
href={href}
target={openInNewTab ? "_blank" : undefined}
rel="noreferrer"
rel={openInNewTab ? SAFE_NEW_TAB_REL : undefined}
h="100%"
w="100%"
>
+4 -3
View File
@@ -15,7 +15,8 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("app", {
icon: IconApps,
queryKeys: [[["app", "byId"]], [["widget", "app"]]],
supportsAdvancedFocus: false,
queryKeys: [[["app", "byId"]], [["widget", "app", "ping"]]],
createOptions(settings) {
return optionsBuilder.from(
(factory) => ({
@@ -54,7 +55,7 @@ export const { definition, componentLoader } = createWidgetDefinition("app", {
options: [
{
label(t) {
return t("widget.app.option.layout.option.column");
return t("widget.common.layout.option.vertical");
},
value: "column",
icon: IconLayoutNavbarExpand,
@@ -68,7 +69,7 @@ export const { definition, componentLoader } = createWidgetDefinition("app", {
},
{
label(t) {
return t("widget.app.option.layout.option.row");
return t("widget.common.layout.option.horizontal");
},
value: "row",
icon: IconLayoutSidebarLeftExpand,
@@ -10,10 +10,12 @@ interface PingIndicatorProps {
}
export const PingIndicator = ({ appId }: PingIndicatorProps) => {
const t = useI18n();
const { data: pingResult } = clientApi.widget.app.ping.useQuery({ id: appId }, { refetchOnMount: false });
const t = useI18n("common");
const { data: pingResult, error } = clientApi.widget.app.ping.useQuery({ id: appId }, { refetchOnMount: false });
if (!pingResult) return <PingDot icon={IconMinus} color="gray" tooltip={`${t("common.action.loading")}…`} />;
if (error) return <PingDot icon={IconX} color="red" tooltip={t("error")} />;
if (!pingResult) return <PingDot icon={IconMinus} color="gray" tooltip={`${t("action.loading")}…`} />;
const isError = "error" in pingResult || pingResult.statusCode >= 500;
+28 -14
View File
@@ -1,25 +1,39 @@
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 type { QueryClient } from "@tanstack/react-query";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { createTrpcQueryKey } from "../trpc-query-key";
const logger = createLogger({ module: "appWidgetPrefetch" });
const prefetchAllAsync: Prefetch<"app"> = async (queryClient, items) => {
const appIds = items.map((item) => item.options.appId);
const distinctAppIds = [...new Set(appIds)];
const dbApps = await db.query.apps.findMany({
where: inArray(apps.id, distinctAppIds),
});
for (const app of dbApps) {
queryClient.setQueryData(createTrpcQueryKey("app.byId", { id: app.id }), app);
const prefetchAll = (queryClient: QueryClient, items: { options: Record<string, unknown> }[]) => {
const appIds: string[] = [];
for (const item of items) {
if (typeof item.options.appId === "string" && item.options.appId.length > 0) appIds.push(item.options.appId);
}
const distinctAppIds = [...new Set(appIds)];
if (distinctAppIds.length === 0) return;
logger.info("Successfully prefetched apps for app widget", { count: dbApps.length });
const dbAppsPromise = Promise.resolve(db.query.apps.findMany({ where: inArray(apps.id, distinctAppIds) })).catch(
(error: unknown) => {
logger.error(new Error("Failed to prefetch apps for app widgets", { cause: error }));
throw error;
},
);
for (const id of distinctAppIds) {
void queryClient.prefetchQuery({
queryKey: createTrpcQueryKey("app.byId", { id }),
queryFn: async () => {
const app = (await dbAppsPromise).find((candidate) => candidate.id === id);
if (!app) throw new Error("App not found");
return app;
},
});
}
};
export default prefetchAllAsync;
export default prefetchAll;
@@ -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 { useByteFormatter } from "@homarr/settings";
import { useCurrentIntlLocale, useI18n } 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,50 @@ 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 = useI18n("widget.archiveTeamWarrior");
const tWidgetCommon = useI18n("widget.common");
const locale = useCurrentIntlLocale();
const { formatBytes, formatByteRate } = useByteFormatter();
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 }] : []),
];
const formatBandwidth = (value?: number) => formatByteRate(Math.round(value ?? 0));
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,45 +74,159 @@ const ArchiveTeamWarriorWidgetContent = ({
</Text>
</Group>
<Badge size="sm" color={status.status === "Running" ? "green" : "gray"}>
{status.status}
</Badge>
<Group gap={4} wrap="nowrap">
<Badge size="sm" color={getStatusColor(statusKey)}>
{t(`status.${statusKey}`)}
</Badge>
</Group>
</Group>
{options.showBroadcastMessage && status.broadcastMessage && (
<Card withBorder p="xs">
<Text size="xs" lineClamp={3}>
{(isAdvanced || options.showBroadcastMessage) && status.broadcastMessage && layout.showBroadcast && (
<Card withBorder p="xs" bg="transparent" style={{ borderColor: neutralSurfaceBorder }}>
<Text size="xs" lineClamp={isAdvanced ? undefined : 3}>
{status.broadcastMessage}
</Text>
</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">
{tWidgetCommon("updatedAt", { date: new Date(status.updatedAt).toLocaleString(locale) })}
</Text>
</Group>
<Group gap="xs" wrap="wrap">
{status.project?.id && (
<Badge size="xs" variant="light">
{t("projectId", { id: status.project.id })}
</Badge>
)}
{status.bandwidth?.received !== undefined && (
<Text size="xs" c="dimmed">
{t("sessionReceived", { value: formatBytes(Math.round(status.bandwidth.received)) })}
</Text>
)}
{status.bandwidth?.sent !== undefined && (
<Text size="xs" c="dimmed">
{t("sessionSent", { value: formatBytes(Math.round(status.bandwidth.sent)) })}
</Text>
)}
{status.bandwidth?.session_id && (
<Text size="xs" c="dimmed">
{t("sessionId", { id: status.bandwidth.session_id })}
</Text>
)}
</Group>
<ScrollArea style={{ flex: 1 }}>
<Stack gap={4}>
{status.items.length === 0 && (
<Text size="sm" c="dimmed" ta="center" py="md">
{t("emptyItems")}
</Text>
)}
{status.items.map((item) => {
const itemStatusKey = getStatusKey(item.status);
return (
<Card key={item.id} withBorder p="xs" bg="transparent" style={{ borderColor: neutralSurfaceBorder }}>
<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>
{item.startTime !== undefined && (
<Text size="xs" c="dimmed" lineClamp={1}>
{t("startedAt", { date: new Date(item.startTime * 1000).toLocaleString(locale) })}
</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>
);
const formatBandwidth = (value?: number) => formatByteRate(Math.round(value ?? 0));
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 neutralSurfaceBorder = "rgb(from var(--mantine-color-default-border) r g b / calc(var(--opacity, 1) * 0.45))";
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";
};
@@ -1,10 +1,14 @@
import { IconArchive } from "@tabler/icons-react";
import { getWidgetIntegrationConfig } from "@homarr/definitions";
import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("archiveTeamWarrior", {
icon: IconArchive,
supportsAdvancedFocus: true,
...getWidgetIntegrationConfig("archiveTeamWarrior"),
createOptions() {
return optionsBuilder.from((factory) => ({
showBroadcastMessage: factory.switch({
@@ -12,5 +16,4 @@ export const { definition, componentLoader } = createWidgetDefinition("archiveTe
}),
}));
},
supportedIntegrations: ["archiveTeamWarrior"],
}).withDynamicImport(() => import("./component"));
@@ -0,0 +1,31 @@
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,
});
});
test("keeps all display sections available in advanced mode", () => {
expect(getArchiveCompactLayout(180, 100, true)).toMatchObject({
showBandwidth: true,
showBroadcast: true,
showSecondaryCounts: true,
});
});
});
@@ -0,0 +1,32 @@
"use client";
import { Center, Stack, Text, ThemeIcon } from "@mantine/core";
import { IconRobot } from "@tabler/icons-react";
import { useI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition";
import { useAssistantWidgetRenderer } from "./context";
export default function AssistantWidget(props: WidgetComponentProps<"assistant">) {
const Renderer = useAssistantWidgetRenderer();
const t = useI18n("widget.assistant.unavailable");
if (Renderer) return <Renderer {...props} />;
return (
<Center h="100%" p="md">
<Stack align="center" gap="xs" ta="center">
<ThemeIcon variant="light" radius="xl" size="lg">
<IconRobot size="var(--mantine-font-size-xl)" />
</ThemeIcon>
<Text size="sm" fw={600}>
{t("title")}
</Text>
<Text size="xs" c="dimmed">
{t("description")}
</Text>
</Stack>
</Center>
);
}
@@ -0,0 +1,20 @@
"use client";
import type { ComponentType, PropsWithChildren } from "react";
import { createContext, useContext } from "react";
import type { WidgetComponentProps } from "../definition";
type AssistantWidgetRenderer = ComponentType<WidgetComponentProps<"assistant">>;
const AssistantWidgetRendererContext = createContext<AssistantWidgetRenderer | null>(null);
interface AssistantWidgetRendererProviderProps extends PropsWithChildren {
renderer: AssistantWidgetRenderer;
}
export const AssistantWidgetRendererProvider = ({ children, renderer }: AssistantWidgetRendererProviderProps) => (
<AssistantWidgetRendererContext.Provider value={renderer}>{children}</AssistantWidgetRendererContext.Provider>
);
export const useAssistantWidgetRenderer = () => useContext(AssistantWidgetRendererContext);
@@ -0,0 +1,16 @@
import { describe, expect, it } from "vitest";
import { supportsAdvancedFocus } from "../definition";
import { definition } from ".";
describe("assistant widget definition", () => {
it("stays compact without an explicit advanced-focus opt-in", () => {
expect(supportsAdvancedFocus(definition)).toBe(false);
});
it("does not persist private conversation data in board options", () => {
const options = definition.createOptions();
expect(options).toEqual({});
});
});
+13
View File
@@ -0,0 +1,13 @@
import { IconRobot } from "@tabler/icons-react";
import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("assistant", {
icon: IconRobot,
createOptions() {
return optionsBuilder.from(() => ({}));
},
}).withDynamicImport(() => import("./component"));
export { AssistantWidgetRendererProvider } from "./context";
@@ -4,8 +4,10 @@ import type { CSSProperties } from "react";
import { Text } from "@mantine/core";
import type { AudiobookshelfDashboardData, NavidromeDashboardData } from "@homarr/integrations/types";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import { zoomCompensatedSize } from "@homarr/ui";
import { getCompactStatLayout } from "../common/compact-stat-layout";
import classes from "./component.module.css";
import type { AudioStatsBackend, AudioStatsDisplayOptions } from "./shared";
import { AUDIO_STATS_TRANSLATION_SCOPE, getGridCols, getIconSize, getVisibleStats } from "./shared";
@@ -25,32 +27,97 @@ const statTileClassByCompact = {
false: "",
} as const;
const statTileClassByAdvanced = {
true: classes.statTileAdvanced,
false: "",
} as const;
const rootClassByLayout = {
default: "",
short: classes.rootShort,
narrowShort: classes.rootShort,
} as const;
const gridClassByLayout = {
default: "",
short: classes.gridShort,
narrowShort: classes.gridShort,
} as const;
const statTileClassByLayout = {
default: "",
short: classes.statTileShort,
narrowShort: `${classes.statTileShort} ${classes.statTileNarrowShort}`,
} as const;
const statLabelClassByLayout = {
default: "",
short: classes.statLabelShort,
narrowShort: classes.statLabelShort,
} as const;
const statValueClassByLayout = {
default: "",
short: "",
narrowShort: classes.statValueNarrowShort,
} as const;
const COMPACT_SINGLE_COLUMN_BREAKPOINT = 220;
interface AudioStatsContentProps {
backend: AudioStatsBackend;
stats: NavidromeDashboardData | AudiobookshelfDashboardData;
options: AudioStatsDisplayOptions;
showAllStats?: boolean;
advanced?: boolean;
width: number;
height: number;
}
export function AudioStatsContent({ backend, stats, options, width }: 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);
export function AudioStatsContent({
backend,
stats,
options,
showAllStats = false,
advanced = false,
width,
height,
}: AudioStatsContentProps) {
const t = useI18n(AUDIO_STATS_TRANSLATION_SCOPE);
const compact = options.compactMode ?? false;
const visibleStats = getVisibleStats(backend, options, stats, showAllStats);
const compactKey = String(compact) as keyof typeof rootClassByCompact;
const advancedKey = String(advanced) as keyof typeof statTileClassByAdvanced;
let defaultGridCols = getGridCols(width, visibleStats.length, compact);
if (!advanced && width < COMPACT_SINGLE_COLUMN_BREAKPOINT) {
defaultGridCols = 1;
}
const layout = getCompactStatLayout({
width,
height,
visibleCount: visibleStats.length,
compactDisplay: !advanced,
defaultColumns: defaultGridCols,
defaultIconSize: getIconSize(width, compact),
});
return (
<div className={`${classes.root} ${rootClassByCompact[compactKey]}`}>
<div className={`${classes.root} ${rootClassByCompact[compactKey]} ${rootClassByLayout[layout.state]}`}>
{visibleStats.length > 0 && (
<div
className={`${classes.grid} ${gridClassByCompact[compactKey]}`}
style={{ "--stat-cols": gridCols } as CSSProperties}
className={`${classes.grid} ${gridClassByCompact[compactKey]} ${gridClassByLayout[layout.state]}`}
style={{ "--stat-cols": layout.columns } as CSSProperties}
>
{visibleStats.map(({ optionKey, statKey, value, Icon }) => (
<div key={optionKey} className={`${classes.statTile} ${statTileClassByCompact[compactKey]}`}>
<Icon className={classes.statIcon} size={iconSize} stroke={1.5} />
<span className={classes.statValue}>{value}</span>
<span className={classes.statLabel}>{t(statKey as never)}</span>
<div
key={optionKey}
className={`${classes.statTile} ${statTileClassByCompact[compactKey]} ${statTileClassByAdvanced[advancedKey]} ${statTileClassByLayout[layout.state]}`}
>
<Icon className={classes.statIcon} style={zoomCompensatedSize(layout.iconSize)} stroke={1.5} />
<span className={`${classes.statValue} ${statValueClassByLayout[layout.state]}`}>{value}</span>
<span className={`${classes.statLabel} ${statLabelClassByLayout[layout.state]}`}>
{t(statKey as never)}
</span>
</div>
))}
</div>
@@ -85,6 +85,21 @@ describe("getVisibleStats", () => {
expect(result).toEqual([]);
});
test("returns every backend-relevant stat when advanced disclosure is enabled", () => {
expect(getVisibleStats("navidrome", {}, navidromeStats, true).map((stat) => stat.statKey)).toEqual([
"artists",
"albums",
"songs",
]);
expect(getVisibleStats("audiobookshelf", {}, audiobookshelfStats, true).map((stat) => stat.statKey)).toEqual([
"libraryCount",
"audiobooks",
"podcasts",
"listeningTime",
"activeSessions",
]);
});
test("formats listening time as duration string", () => {
const result = getVisibleStats("audiobookshelf", { showListeningTime: true }, audiobookshelfStats);
expect(result).toHaveLength(1);
@@ -14,6 +14,11 @@
gap: clamp(2px, 1cqi, 4px);
}
.rootShort {
padding: 4px;
gap: 2px;
}
.grid {
display: grid;
flex: 1;
@@ -29,6 +34,10 @@
gap: clamp(2px, 1cqi, 4px);
}
.gridShort {
gap: 2px;
}
.statTile {
display: flex;
flex-direction: column;
@@ -40,16 +49,33 @@
gap: clamp(2px, 1.5cqi, 4px);
padding: clamp(4px, 2cqi, 8px);
border-radius: var(--mantine-radius-md);
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
background-color: rgb(
from light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6)) r g b / var(--opacity, 1)
);
overflow: hidden;
}
.statTileAdvanced {
background-color: transparent;
}
.statTileCompact {
gap: clamp(1px, 0.8cqi, 2px);
padding: clamp(2px, 1.2cqi, 5px);
border-radius: var(--mantine-radius-sm);
}
.statTileShort {
gap: 2px;
padding: 3px;
}
.statTileNarrowShort {
flex-direction: row;
gap: 2px;
padding: 1px 3px;
}
.statIcon {
color: var(--mantine-color-dimmed);
flex-shrink: 0;
@@ -57,7 +83,12 @@
}
.statLabel {
font-size: clamp(8px, 2.5cqi, 11px);
flex-shrink: 0;
font-size: clamp(
calc(8px * var(--board-canvas-ui-scale, 1)),
calc(2.5cqi * var(--board-canvas-ui-scale, 1)),
calc(11px * var(--board-canvas-ui-scale, 1))
);
color: var(--mantine-color-dimmed);
line-height: 1.2;
text-align: center;
@@ -68,11 +99,32 @@
}
.statTileCompact .statLabel {
font-size: clamp(7px, 2cqi, 9px);
font-size: clamp(
calc(7px * var(--board-canvas-ui-scale, 1)),
calc(2cqi * var(--board-canvas-ui-scale, 1)),
calc(9px * var(--board-canvas-ui-scale, 1))
);
}
.statLabelShort {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.statValue {
font-size: clamp(13px, 5cqi, 20px);
flex-shrink: 0;
font-size: clamp(
calc(13px * var(--board-canvas-ui-scale, 1)),
calc(5cqi * var(--board-canvas-ui-scale, 1)),
calc(20px * var(--board-canvas-ui-scale, 1))
);
font-weight: 700;
color: var(--mantine-primary-color-filled);
line-height: 1.1;
@@ -84,7 +136,16 @@
}
.statTileCompact .statValue {
font-size: clamp(11px, 4cqi, 16px);
font-size: clamp(
calc(11px * var(--board-canvas-ui-scale, 1)),
calc(4cqi * var(--board-canvas-ui-scale, 1)),
calc(16px * var(--board-canvas-ui-scale, 1))
);
}
.statValueNarrowShort {
font-size: calc(11px * var(--board-canvas-ui-scale, 1));
line-height: 1;
}
.emptyState {
+126 -5
View File
@@ -1,17 +1,138 @@
"use client";
import { Badge, Box, Group, ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core";
import { getQueryKey } from "@trpc/react-query";
import { clientApi } from "@homarr/api/client";
import { formatDuration } from "@homarr/common";
import type { AudiobookshelfDashboardData } from "@homarr/integrations/types";
import { useByteFormatter } from "@homarr/settings";
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,
displayScale = 1,
displayMode = "compact",
widgetRuntimeRef,
}: WidgetComponentProps<"audioStats">) {
const { formatBytes } = useByteFormatter();
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} />;
let responsiveWidth = width;
let responsiveHeight = height;
if (displayMode === "compact" && Number.isFinite(displayScale) && displayScale > 0) {
responsiveWidth *= displayScale;
responsiveHeight *= displayScale;
}
const summary = (
<AudioStatsContent
backend={response.kind}
stats={response.data}
options={{ ...options, compactMode: displayMode === "compact" && options.compactMode }}
showAllStats={displayMode === "advanced"}
advanced={displayMode === "advanced"}
width={responsiveWidth}
height={responsiveHeight}
/>
);
if (displayMode === "compact") {
return (
<Box h="100%" pos="relative">
{summary}
</Box>
);
}
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>
);
}
+9 -5
View File
@@ -1,10 +1,13 @@
import { IconHeadphones, IconServerOff } from "@tabler/icons-react";
import { createWidgetDefinition } from "../definition";
import { getWidgetIntegrationConfig } from "@homarr/definitions";
import { createWidgetDefinition, matchesWidgetRuntimeQuery } from "../definition";
import { optionsBuilder } from "../options";
const hideUnlessNavidrome = {
shouldHide: (_: unknown, integrationKinds: string[]) => !integrationKinds.includes("navidrome"),
shouldHide: (_: unknown, integrationKinds: string[]) =>
!integrationKinds.includes("navidrome") && !integrationKinds.includes("mock"),
};
const hideUnlessAudiobookshelf = {
@@ -13,9 +16,10 @@ const hideUnlessAudiobookshelf = {
export const { definition, componentLoader } = createWidgetDefinition("audioStats", {
icon: IconHeadphones,
supportedIntegrations: ["navidrome", "audiobookshelf"],
integrationsRequired: true,
maxIntegrations: 1,
supportsAdvancedFocus: true,
queryKeys: [[["widget", "audioStats", "getStats"]], [["widget", "mediaServer", "getCurrentStreams"]]],
queryMatcher: matchesWidgetRuntimeQuery,
...getWidgetIntegrationConfig("audioStats"),
createOptions() {
return optionsBuilder.from(
(factory) => ({
+2 -1
View File
@@ -133,13 +133,14 @@ export const getVisibleStats = (
backend: AudioStatsBackend,
options: AudioStatsDisplayOptions,
stats: NavidromeDashboardData | AudiobookshelfDashboardData,
showAll = false,
): VisibleStat[] => {
const visibilityOptions = statVisibilityOptionsByBackend[backend];
const statIcons = statIconsByBackend[backend];
const statValues = statValueGetterByBackend[backend](stats);
return Object.entries(visibilityOptions)
.filter(([optionKey]) => Boolean(options[optionKey as keyof typeof visibilityOptions]))
.filter(([optionKey]) => showAll || Boolean(options[optionKey as keyof typeof visibilityOptions]))
.map(([optionKey, statKey]) => ({
optionKey,
statKey,
@@ -29,12 +29,24 @@
gap: clamp(2px, 1.5cqi, 5px);
padding: clamp(4px, 2.5cqi, 10px);
border-radius: var(--mantine-radius-md);
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
background-color: transparent;
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));
background-color: rgb(
from light-dark(var(--mantine-color-orange-0), var(--mantine-color-dark-5)) r g b / var(--opacity, 1)
);
}
.statIcon {
@@ -59,6 +71,8 @@
color: var(--mantine-primary-color-filled);
line-height: 1.1;
text-align: center;
max-width: 100%;
overflow-wrap: anywhere;
}
.statValueWarning {
+85 -26
View File
@@ -2,30 +2,49 @@
import type { CSSProperties } from "react";
import { Text } from "@mantine/core";
import { IconAlertTriangle, IconDeviceTv, IconMovie, IconPlugConnected } from "@tabler/icons-react";
import { IconAlertTriangle, IconBell, IconDeviceTv, IconMovie, IconPlugConnected } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useScopedI18n } from "@homarr/translation/client";
import { getIntegrationName } from "@homarr/definitions";
import { useI18n } from "@homarr/translation/client";
import { zoomCompensatedSize } from "@homarr/ui";
import { WidgetEmptyState } from "../common/empty-state";
import type { WidgetComponentProps } from "../definition";
import classes from "./component.module.css";
const statVisibilityByOption = {
showMissingEpisodes: "episodes",
showMissingMovies: "movies",
showProviderIssues: "providers",
showHealthIssues: "status",
} as const;
const compactStatOptions = [
{ option: "showMissingEpisodes", stat: "episodes" },
{ option: "showMissingMovies", stat: "movies" },
{ option: "showProviderIssues", stat: "providers" },
{ option: "showHealthIssues", stat: "status" },
] as const;
const allStatKeys = [
"episodes",
"movies",
"providers",
"status",
"sonarrSignalr",
"radarrSignalr",
"announcements",
] as const;
type BazarrStatKey = (typeof allStatKeys)[number];
type BazarrVisibilityOptions = Record<(typeof compactStatOptions)[number]["option"], boolean>;
const statIcons = {
episodes: IconDeviceTv,
movies: IconMovie,
providers: IconPlugConnected,
status: IconAlertTriangle,
sonarrSignalr: IconPlugConnected,
radarrSignalr: IconPlugConnected,
announcements: IconBell,
} as const;
const gridColsByWidth = [
{ minWidth: 640, cols: 4 },
{ minWidth: 380, cols: 2 },
{ minWidth: 0, cols: 1 },
] as const;
@@ -36,13 +55,20 @@ const iconSizeByWidth = [
{ minWidth: 0, size: 16 },
] as const;
export default function BazarrWidget({ integrationIds, options, width }: WidgetComponentProps<"bazarr">) {
const t = useScopedI18n("widget.bazarr");
const { data: badges } = clientApi.widget.bazarr.getBadges.useQuery(
export default function BazarrWidget({
integrationIds,
options,
width,
height,
displayMode,
}: WidgetComponentProps<"bazarr">) {
const t = useI18n("widget.bazarr");
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 = {
@@ -50,14 +76,15 @@ export default function BazarrWidget({ integrationIds, options, width }: WidgetC
movies: badges.movies,
providers: badges.providers,
status: badges.status,
} as const;
sonarrSignalr: badges.sonarr_signalr,
radarrSignalr: badges.radarr_signalr,
announcements: badges.announcements,
} satisfies Record<BazarrStatKey, number | string>;
const visibleStatKeys = Object.entries(statVisibilityByOption)
.filter(([optionKey]) => options[optionKey as keyof typeof options])
.map(([, statKey]) => statKey);
const visibleStatKeys = getVisibleBazarrStatKeys(options, displayMode);
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,17 +100,36 @@ 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];
const isWarning = (statKey === "providers" || statKey === "status") && value > 0;
let label: string;
if (statKey === "sonarrSignalr") {
label = `${getIntegrationName("sonarr")} SignalR`;
} else if (statKey === "radarrSignalr") {
label = `${getIntegrationName("radarr")} SignalR`;
} else {
label = t(statKey);
}
const isWarning =
typeof value === "number" &&
(statKey === "providers" || statKey === "status" || statKey === "announcements") &&
value > 0;
return (
<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>
<Icon className={classes.statIcon} style={zoomCompensatedSize(iconSize)} stroke={1.5} />
<span className={`${classes.statValue} ${isWarning ? classes.statValueWarning : ""}`}>
{typeof value === "string" && value.trim() === "" ? "—" : value}
</span>
<span className={classes.statLabel} title={label}>
{label}
</span>
</div>
);
})}
@@ -92,12 +138,25 @@ 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 const getVisibleBazarrStatKeys = (
options: BazarrVisibilityOptions,
displayMode?: "compact" | "advanced",
): BazarrStatKey[] => {
if (displayMode === "advanced") return [...allStatKeys];
return compactStatOptions.filter(({ option }) => options[option]).map(({ stat }) => stat);
};
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;
}
+4 -2
View File
@@ -1,12 +1,14 @@
import { IconBadgeCc, IconServerOff } from "@tabler/icons-react";
import { getWidgetIntegrationConfig } from "@homarr/definitions";
import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("bazarr", {
supportsAdvancedFocus: true,
icon: IconBadgeCc,
supportedIntegrations: ["bazarr"],
integrationsRequired: true,
...getWidgetIntegrationConfig("bazarr"),
createOptions() {
return optionsBuilder.from((factory) => ({
showMissingEpisodes: factory.switch({
@@ -0,0 +1,32 @@
import { describe, expect, test } from "vitest";
import { getGridCols, getIconSize, getVisibleBazarrStatKeys } 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);
});
test("advanced mode includes hidden summaries and all fetched status fields", () => {
expect(
getVisibleBazarrStatKeys(
{
showMissingEpisodes: false,
showMissingMovies: false,
showProviderIssues: false,
showHealthIssues: false,
},
"advanced",
),
).toEqual(["episodes", "movies", "providers", "status", "sonarrSignalr", "radarrSignalr", "announcements"]);
});
});
+109 -69
View File
@@ -1,18 +1,25 @@
"use client";
import { useMemo } from "react";
import { Badge, Box, Center, Divider, Group, Loader, ScrollArea, Stack, Text, ThemeIcon } from "@mantine/core";
import { Badge, Box, Divider, Group, ScrollArea, Stack, Text, ThemeIcon } from "@mantine/core";
import { IconBellOff, IconCircleCheck, IconFlame, IconHistory } from "@tabler/icons-react";
import { getQueryKey } from "@trpc/react-query";
import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
import type { LucideIcon } from "lucide-react";
import { Activity, Battery, Cpu, HardDrive, MemoryStick, Monitor, Network, Server, Thermometer } from "lucide-react";
import { clientApi } from "@homarr/api/client";
import { useScopedI18n } from "@homarr/translation/client";
import { useByteFormatter } from "@homarr/settings";
import { useI18n } from "@homarr/translation/client";
import { WidgetQueryLoadingState } from "../common/query-state-indicator";
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";
import { useWidgetRuntimeQueries } from "../runtime-hooks";
import { getBeszelAlertsQueryInput } from "./display";
import { buildBeszelSystemNameMap, getBeszelSystemName } from "./system-name-map";
const alertIconMap: Record<string, LucideIcon> = {
CPU: Cpu,
@@ -35,60 +42,80 @@ export default function BeszelAlertsWidget({
options,
integrationIds,
isEditMode,
displayMode,
widgetRuntimeRef,
}: WidgetComponentProps<"beszelAlerts">) {
const t = useScopedI18n("widget.beszelAlerts");
const t = useI18n("widget.beszelAlerts");
const isAdvanced = displayMode === "advanced";
const alertsInput = useMemo(
() => ({ integrationIds, includeHistory: options.showHistory, maxHistoryItems: options.maxHistoryItems }),
[integrationIds, options.showHistory, options.maxHistoryItems],
() =>
getBeszelAlertsQueryInput(
integrationIds,
{ showHistory: options.showHistory, maxHistoryItems: options.maxHistoryItems },
isAdvanced,
),
[integrationIds, options.showHistory, options.maxHistoryItems, isAdvanced],
);
const { data: results = [], error: alertsError, isPending } = clientApi.widget.beszel.getAlerts.useQuery(alertsInput);
const alertsQuery = clientApi.widget.beszel.getAlerts.useQuery(alertsInput);
const runtimeQueries = useMemo(
() => [getQueryKey(clientApi.widget.beszel.getAlerts, alertsInput, "query")],
[alertsInput],
);
useWidgetRuntimeQueries(widgetRuntimeRef, runtimeQueries);
const resultData = getUsableWidgetQueryData(alertsQuery);
const results = useMemo(() => resultData ?? [], [resultData]);
const { isPending } = alertsQuery;
const systemNameMap = useMemo(() => {
const map: Record<string, string> = {};
for (const r of results) {
if (r.systemNameMap) {
Object.assign(map, r.systemNameMap);
}
}
return map;
}, [results]);
const systemNameMap = useMemo(() => buildBeszelSystemNameMap(results), [results]);
const showIntegrationName = isAdvanced || results.length > 1;
const alerts = useMemo(
() => results.flatMap((r) => r.alerts.map((a) => ({ ...a, _key: `${r.integrationId}:${a.id}` }))),
() =>
results.flatMap((r) =>
r.alerts.map((a) => ({
...a,
integrationId: r.integrationId,
integrationName: r.integrationName,
key: `${r.integrationId}:${a.id}`,
})),
),
[results],
);
const history = useMemo(() => {
const all = results.flatMap((r) => r.history.map((h) => ({ ...h, _key: `${r.integrationId}:${h.id}` })));
return all
.toSorted((a, b) => new Date(b.created).getTime() - new Date(a.created).getTime())
.slice(0, options.maxHistoryItems);
}, [results, options.maxHistoryItems]);
const all = results.flatMap((r) =>
r.history.map((h) => ({
...h,
integrationId: r.integrationId,
integrationName: r.integrationName,
key: `${r.integrationId}:${h.id}`,
})),
);
const sorted = all.toSorted((a, b) => new Date(b.created).getTime() - new Date(a.created).getTime());
return isAdvanced ? sorted : sorted.slice(0, options.maxHistoryItems);
}, [results, options.maxHistoryItems, isAdvanced]);
const triggeredAlerts = alerts.filter((a) => a.triggered);
const okAlerts = alerts.filter((a) => !a.triggered);
if (alertsError) throw alertsError;
const showHistory = isAdvanced || options.showHistory;
if (isPending) {
return (
<Center h="100%">
<Loader size="sm" />
</Center>
);
return <WidgetQueryLoadingState />;
}
return (
<Box h="100%" pos="relative">
<Box pos="absolute" top={4} right={8} style={{ zIndex: 1 }}>
<BeszelIntegrationErrorIndicator results={results} />
<Group gap={0}>
<IntegrationErrorIndicator results={results} />
</Group>
</Box>
<ScrollArea h="100%" style={{ pointerEvents: isEditMode ? "none" : undefined }}>
<Stack gap="sm" p="sm">
{alerts.length === 0 && (
<Stack align="center" justify="center" py="xl" gap="xs">
<ThemeIcon variant="light" color="gray" size="lg" radius="xl">
<IconBellOff size={18} />
<IconBellOff size="var(--mantine-font-size-lg)" />
</ThemeIcon>
<Text size="sm" c="dimmed">
{t("empty")}
@@ -99,19 +126,21 @@ export default function BeszelAlertsWidget({
{triggeredAlerts.length > 0 && (
<Stack gap={6}>
<Group gap={6}>
<IconFlame size={14} color="var(--mantine-color-red-6)" />
<Text size="xs" fw={600} tt="uppercase" c="red">
<IconFlame size="var(--mantine-font-size-sm)" color="var(--mantine-color-red-6)" />
<Text size="xs" fw={600} c="red" tt={isAdvanced ? undefined : "uppercase"}>
{t("status.triggered")} ({triggeredAlerts.length})
</Text>
</Group>
{triggeredAlerts.map((alert) => (
<AlertRow
key={alert._key}
key={alert.key}
name={alert.name}
value={alert.value}
min={alert.min}
systemName={systemNameMap[alert.system] ?? alert.system}
systemName={getBeszelSystemName(systemNameMap, alert.integrationId, alert.system)}
integrationName={showIntegrationName ? alert.integrationName : undefined}
triggered
isAdvanced={isAdvanced}
/>
))}
</Stack>
@@ -122,40 +151,43 @@ export default function BeszelAlertsWidget({
{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">
<IconCircleCheck size="var(--mantine-font-size-sm)" color="var(--mantine-color-green-6)" />
<Text size="xs" fw={600} c="dimmed" tt={isAdvanced ? undefined : "uppercase"}>
{t("status.ok")} ({okAlerts.length})
</Text>
</Group>
{okAlerts.map((alert) => (
<AlertRow
key={alert._key}
key={alert.key}
name={alert.name}
value={alert.value}
min={alert.min}
systemName={systemNameMap[alert.system] ?? alert.system}
systemName={getBeszelSystemName(systemNameMap, alert.integrationId, alert.system)}
integrationName={showIntegrationName ? alert.integrationName : undefined}
triggered={false}
isAdvanced={isAdvanced}
/>
))}
</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">
<IconHistory size="var(--mantine-font-size-sm)" opacity={0.5} />
<Text size="xs" fw={600} c="dimmed" tt={isAdvanced ? undefined : "uppercase"}>
{t("history")}
</Text>
</Group>
{history.map((entry) => {
const systemName = systemNameMap[entry.system] ?? entry.system;
const systemName = getBeszelSystemName(systemNameMap, entry.integrationId, entry.system);
const systemLabel = showIntegrationName ? `${systemName} · ${entry.integrationName}` : systemName;
const isResolved = !!entry.resolved;
const HistoryIcon = alertIconMap[entry.name] ?? Server;
return (
<Group key={entry._key} justify="space-between" wrap="nowrap" gap="xs" pl={4}>
<Group key={entry.key} justify="space-between" wrap="nowrap" gap="xs" pl={4}>
<Group gap="xs" wrap="nowrap" style={{ minWidth: 0 }}>
<Box
w={3}
@@ -163,13 +195,13 @@ export default function BeszelAlertsWidget({
style={{ borderRadius: 2, flexShrink: 0 }}
bg={isResolved ? "green.6" : "red.6"}
/>
<HistoryIcon size={12} opacity={0.5} style={{ flexShrink: 0 }} />
<HistoryIcon size="var(--mantine-font-size-xs)" opacity={0.5} style={{ flexShrink: 0 }} />
<Stack gap={0} style={{ minWidth: 0 }}>
<Text size="xs" fw={500} truncate>
{entry.name}
</Text>
<Text size="xs" c="dimmed" truncate>
{systemName}
{systemLabel}
</Text>
</Stack>
</Group>
@@ -177,7 +209,7 @@ export default function BeszelAlertsWidget({
<Badge size="xs" variant="dot" color={isResolved ? "green" : "red"}>
{isResolved ? t("resolved") : t("status.triggered")}
</Badge>
<Text size="10px" c="dimmed">
<Text size="xs" c="dimmed">
{dayjs(entry.created).fromNow()}
</Text>
</Stack>
@@ -198,7 +230,9 @@ interface AlertRowProps {
value: number;
min: number;
systemName: string;
integrationName?: string;
triggered: boolean;
isAdvanced: boolean;
}
const unitSuffixMap: Record<string, string> = {
@@ -208,7 +242,6 @@ const unitSuffixMap: Record<string, string> = {
GPU: "%",
Battery: "%",
Temperature: "°",
Bandwidth: " MB/s",
"Load Average": "",
LoadAvg1: "",
LoadAvg5: "",
@@ -216,32 +249,39 @@ const unitSuffixMap: Record<string, string> = {
Status: "",
};
function formatAlertDescription(name: string, value: number, min: number): string {
const suffix = unitSuffixMap[name] ?? "";
if (name === "Status") return `down for ${min} min`;
return `exceeds ${value}${suffix} over ${min} min`;
}
function AlertRow({ name, value, min, systemName, triggered }: AlertRowProps) {
function AlertRow({ name, value, min, systemName, integrationName, triggered, isAdvanced }: AlertRowProps) {
const t = useI18n("widget.beszelAlerts");
const { formatByteRate } = useByteFormatter();
const Icon = alertIconMap[name] ?? Server;
return (
<Group
wrap="nowrap"
gap="xs"
py={4}
pr={8}
pl={12}
style={{
const suffix = unitSuffixMap[name] ?? "";
let formattedValue = `${value}${suffix}`;
if (name === "Bandwidth") {
formattedValue = formatByteRate(value * 1024 ** 2);
}
let description = t("alertDescription.threshold", { value: formattedValue, minutes: min });
if (name === "Status") {
description = t("alertDescription.statusDuration", { minutes: min });
}
const accentColor = triggered ? "red" : "green";
const borderColor = triggered ? "red" : "gray";
const rowStyle = isAdvanced
? {
borderRadius: "var(--mantine-radius-sm)",
borderLeft: `3px solid var(--mantine-color-${triggered ? "red" : "green"}-6)`,
border: `1px solid var(--mantine-color-${borderColor}-4)`,
backgroundColor: triggered ? "var(--mantine-color-red-light)" : "var(--mantine-color-default-hover)",
}}
>
<Icon size={14} opacity={0.7} style={{ flexShrink: 0 }} />
}
: {
borderRadius: "var(--mantine-radius-sm)",
borderLeft: `3px solid var(--mantine-color-${accentColor}-6)`,
backgroundColor: triggered ? "var(--mantine-color-red-light)" : "var(--mantine-color-default-hover)",
};
return (
<Group wrap="nowrap" gap="xs" py={4} pr={8} pl={isAdvanced ? 8 : 12} style={rowStyle}>
<Icon size="var(--mantine-font-size-sm)" 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}
{integrationName ? `${systemName} · ${integrationName}` : systemName}
</Text>
<Text size="xs" c="dimmed">
·
@@ -251,7 +291,7 @@ function AlertRow({ name, value, min, systemName, triggered }: AlertRowProps) {
</Text>
</Group>
<Text size="xs" c="dimmed" truncate>
{formatAlertDescription(name, value, min)}
{description}
</Text>
</Stack>
</Group>
@@ -0,0 +1,26 @@
import { describe, expect, test } from "vitest";
import { getBeszelAlertsQueryInput } from "./display";
describe("Beszel alerts query input", () => {
test("preserves compact history settings", () => {
expect(getBeszelAlertsQueryInput(["beszel-a"], { showHistory: false, maxHistoryItems: 10 }, false)).toEqual({
integrationIds: ["beszel-a"],
includeHistory: false,
maxHistoryItems: 10,
});
});
test("requests a useful bounded history in advanced mode", () => {
expect(getBeszelAlertsQueryInput(["beszel-a"], { showHistory: false, maxHistoryItems: 10 }, true)).toEqual({
integrationIds: ["beszel-a"],
includeHistory: true,
maxHistoryItems: 50,
});
expect(getBeszelAlertsQueryInput(["beszel-a"], { showHistory: true, maxHistoryItems: 150 }, true)).toEqual({
integrationIds: ["beszel-a"],
includeHistory: true,
maxHistoryItems: 100,
});
});
});
@@ -0,0 +1,22 @@
export const BESZEL_ALERTS_ADVANCED_MIN_HISTORY_ITEMS = 50;
const BESZEL_ALERTS_MAX_HISTORY_ITEMS = 100;
interface BeszelAlertsDisplayOptions {
showHistory: boolean;
maxHistoryItems: number;
}
export const getBeszelAlertsQueryInput = (
integrationIds: string[],
options: BeszelAlertsDisplayOptions,
isAdvanced: boolean,
) => ({
integrationIds,
includeHistory: isAdvanced || options.showHistory,
maxHistoryItems: isAdvanced
? Math.min(
BESZEL_ALERTS_MAX_HISTORY_ITEMS,
Math.max(BESZEL_ALERTS_ADVANCED_MIN_HISTORY_ITEMS, options.maxHistoryItems),
)
: options.maxHistoryItems,
});
+16 -5
View File
@@ -1,14 +1,25 @@
import { IconBell, IconServerOff } from "@tabler/icons-react";
import { z } from "zod/v4";
import { createWidgetDefinition } from "../definition";
import { getWidgetIntegrationConfig } from "@homarr/definitions";
import { createWidgetDefinition, matchesWidgetRuntimeQuery, widgetQueryInputMatches } from "../definition";
import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("beszelAlerts", {
icon: IconBell,
queryKey: [["widget", "beszel"]],
supportedIntegrations: ["beszel", "mock"],
integrationsRequired: true,
queryKey: [["widget", "beszel", "getAlerts"]],
queryMatcher(query, scope) {
const hasRuntimeAlertsQuery = scope.runtimeQueries.some(({ path }) => path.at(-1) === "getAlerts");
if (hasRuntimeAlertsQuery) return matchesWidgetRuntimeQuery(query, scope);
return widgetQueryInputMatches(query.input, {
integrationIds: scope.integrationIds,
includeHistory: scope.options.showHistory,
maxHistoryItems: scope.options.maxHistoryItems,
});
},
...getWidgetIntegrationConfig("beszelAlerts"),
createOptions() {
return optionsBuilder.from((factory) => ({
showHistory: factory.switch({ defaultValue: true }),
@@ -21,7 +32,7 @@ export const { definition, componentLoader } = createWidgetDefinition("beszelAle
errors: {
INTERNAL_SERVER_ERROR: {
icon: IconServerOff,
message: (t) => t("widget.beszelAlerts.error.internalServerError"),
message: (t) => t("widget.beszel.error.internalServerError"),
},
},
}).withDynamicImport(() => import("./component"));
@@ -0,0 +1,19 @@
import { describe, expect, test } from "vitest";
import { buildBeszelSystemNameMap, getBeszelSystemName } from "./system-name-map";
describe("Beszel alert system names", () => {
test("keeps identical system IDs scoped to their integration", () => {
const systemNames = buildBeszelSystemNameMap([
{ integrationId: "beszel-a", systemNameMap: { shared: "Alpha server" } },
{ integrationId: "beszel-b", systemNameMap: { shared: "Beta server" } },
]);
expect(getBeszelSystemName(systemNames, "beszel-a", "shared")).toBe("Alpha server");
expect(getBeszelSystemName(systemNames, "beszel-b", "shared")).toBe("Beta server");
});
test("falls back to the system ID when no name is available", () => {
expect(getBeszelSystemName(new Map(), "beszel-a", "unknown")).toBe("unknown");
});
});
@@ -0,0 +1,24 @@
interface BeszelSystemNamesByIntegration {
integrationId: string;
systemNameMap: Record<string, string>;
}
export const getBeszelSystemNameKey = (integrationId: string, systemId: string) => `${integrationId}:${systemId}`;
export const buildBeszelSystemNameMap = (results: readonly BeszelSystemNamesByIntegration[]) => {
const systemNames = new Map<string, string>();
for (const result of results) {
for (const [systemId, systemName] of Object.entries(result.systemNameMap)) {
systemNames.set(getBeszelSystemNameKey(result.integrationId, systemId), systemName);
}
}
return systemNames;
};
export const getBeszelSystemName = (
systemNames: ReadonlyMap<string, string>,
integrationId: string,
systemId: string,
) => systemNames.get(getBeszelSystemNameKey(integrationId, systemId)) ?? systemId;
@@ -1,7 +1,28 @@
.clickableCard {
position: relative;
transition: background-color 150ms;
cursor: pointer;
}
.clickableCard:hover {
.clickableCard:hover,
.clickableCard:focus-within {
background-color: color-mix(in srgb, var(--mantine-color-default-hover) 40%, transparent) !important;
}
.clickableCard:focus-within {
outline: 2px solid var(--mantine-primary-color-filled);
outline-offset: -2px;
}
.cardAction {
position: absolute;
inset: 0;
z-index: 1;
border-radius: inherit;
}
@media (prefers-reduced-motion: reduce) {
.clickableCard {
transition: none;
}
}
@@ -1,7 +1,7 @@
"use client";
import type { MantineSize } from "@mantine/core";
import { Badge, Box, Card, Center, Group, Loader, Progress, Text, Stack } from "@mantine/core";
import { Badge, Box, Card, Center, Group, Progress, SimpleGrid, Stack, Text, UnstyledButton } from "@mantine/core";
import {
Activity,
Battery,
@@ -19,26 +19,25 @@ import { IconServerOff } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useRequiredBoard } from "@homarr/boards/context";
import { invariantTechnicalLabels } from "@homarr/definitions";
import { useModalAction } from "@homarr/modals";
import { useScopedI18n } from "@homarr/translation/client";
import { useByteFormatter } from "@homarr/settings";
import { useI18n } from "@homarr/translation/client";
import { zoomCompensatedSize } from "@homarr/ui";
import classes from "./component.module.css";
import { WidgetQueryLoadingState } from "../common/query-state-indicator";
import type { WidgetComponentProps } from "../definition";
import type { BeszelSystemRow } from "../beszel/_shared/types";
import { statusColorMap, thresholdColor } from "../beszel/_shared/colors";
import {
formatByteRate,
formatLoadAvg,
formatPercent,
formatTemp,
formatUptime,
getProgressTrackSize,
} from "../beszel/_shared/format";
import { formatLoadAvg, formatPercent, formatTemp, formatUptime, 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";
import { isBeszelGridMetricVisible } from "./display";
interface SizeConfig {
iconSize: number;
@@ -55,7 +54,7 @@ interface SizeConfig {
const defaultSizeConfig: SizeConfig = {
iconSize: 10,
fontSize: "10px",
fontSize: "xs",
progressSize: "xs",
labelMiw: 40,
valueMiw: 30,
@@ -116,20 +115,15 @@ const getMaxVisibleMetrics = (cellHeight: number, size: SizeConfig): number => {
const MIN_CELL_WIDTH = 140;
const MIN_CELL_HEIGHT = 80;
const ADVANCED_MIN_CELL_WIDTH = 320;
const ADVANCED_MIN_CELL_HEIGHT = 420;
const getColCount = (width: number, _height: number, itemCount: number): number => {
const getColCount = (width: number, height: number, itemCount: number, minCellWidth = MIN_CELL_WIDTH): number => {
if (itemCount <= 1) return 1;
const maxCols = Math.min(itemCount, Math.max(1, Math.floor(width / MIN_CELL_WIDTH)));
let best = 1;
for (let c = 1; c <= maxCols; c++) {
const emptyCells = (c - (itemCount % c)) % c;
const bestEmpty = (best - (itemCount % best)) % best;
if (emptyCells < bestEmpty || (emptyCells === bestEmpty && c > best)) {
best = c;
}
}
return best;
const maxCols = Math.min(itemCount, Math.max(1, Math.floor(width / minCellWidth)));
const aspectRatio = width / Math.max(height, MIN_CELL_HEIGHT);
const idealCols = Math.round(Math.sqrt(itemCount * aspectRatio));
return Math.min(maxCols, Math.max(1, idealCols));
};
interface MetricRowProps {
@@ -167,109 +161,137 @@ const MetricRow = ({ icon, label, value, progress, size }: MetricRowProps) => (
);
interface SystemCardProps {
system: BeszelSystemRow & { _key: string };
system: BeszelSystemRow & { rowKey: string };
options: WidgetComponentProps<"beszelSystemGrid">["options"];
t: ReturnType<typeof useScopedI18n<"widget.beszelSystemGrid">>;
t: ReturnType<typeof useI18n<"widget.beszel">>;
tCommon: ReturnType<typeof useI18n<"common">>;
size: SizeConfig;
maxMetrics: number;
itemRadius: MantineSize;
integrationName: string;
isAdvanced: boolean;
onClick?: () => void;
}
const metricRenderers = [
type BeszelMetricRenderer = {
key: string;
render: (
system: BeszelSystemRow,
t: SystemCardProps["t"],
size: SizeConfig,
tCommon: SystemCardProps["tCommon"],
formatByteRate: (bytes: number) => string,
) => React.ReactNode;
visible: (system: BeszelSystemRow, options: SystemCardProps["options"], advanced: boolean) => boolean;
};
const metricRenderers: BeszelMetricRenderer[] = [
{
key: "showCpu",
render: (s: BeszelSystemRow, t: SystemCardProps["t"], sz: SizeConfig) => (
<MetricRow
key="cpu"
icon={<Cpu size={sz.iconSize} />}
label={t("metric.cpu")}
icon={<Cpu style={zoomCompensatedSize(sz.iconSize)} />}
label={invariantTechnicalLabels.cpu}
value={formatPercent(s.cpu)}
progress={{ value: s.cpu, color: thresholdColor(s.cpu) }}
size={sz}
/>
),
visible: (s: BeszelSystemRow, o: SystemCardProps["options"]) => o.showCpu,
visible: (s: BeszelSystemRow, o: SystemCardProps["options"], advanced: boolean) =>
isBeszelGridMetricVisible(o.showCpu, advanced),
},
{
key: "showMemory",
render: (s: BeszelSystemRow, t: SystemCardProps["t"], sz: SizeConfig) => (
<MetricRow
key="mem"
icon={<MemoryStick size={sz.iconSize} />}
icon={<MemoryStick style={zoomCompensatedSize(sz.iconSize)} />}
label={t("metric.memory")}
value={formatPercent(s.memory)}
progress={{ value: s.memory, color: thresholdColor(s.memory) }}
size={sz}
/>
),
visible: (s: BeszelSystemRow, o: SystemCardProps["options"]) => o.showMemory,
visible: (s: BeszelSystemRow, o: SystemCardProps["options"], advanced: boolean) =>
isBeszelGridMetricVisible(o.showMemory, advanced),
},
{
key: "showDisk",
render: (s: BeszelSystemRow, t: SystemCardProps["t"], sz: SizeConfig) => (
<Group key="disk" gap="xs" wrap="nowrap" justify="space-between" style={{ minHeight: sz.rowHeight }}>
<HardDrive size={sz.iconSize} />
<HardDrive style={zoomCompensatedSize(sz.iconSize)} />
<Text size={sz.fontSize} c="dimmed" w={sz.labelMiw} style={{ whiteSpace: "nowrap", flexShrink: 0 }}>
{t("metric.disk")}
</Text>
<DiskUsage system={s} fontSize={sz.fontSize} progressSize={sz.progressSize} valueMiw={sz.valueMiw} />
</Group>
),
visible: (s: BeszelSystemRow, o: SystemCardProps["options"]) => o.showDisk,
visible: (s: BeszelSystemRow, o: SystemCardProps["options"], advanced: boolean) =>
isBeszelGridMetricVisible(o.showDisk, advanced),
},
{
key: "showGpu",
render: (s: BeszelSystemRow, t: SystemCardProps["t"], sz: SizeConfig) => (
<MetricRow
key="gpu"
icon={<Monitor size={sz.iconSize} />}
label={t("metric.gpu")}
icon={<Monitor style={zoomCompensatedSize(sz.iconSize)} />}
label={invariantTechnicalLabels.gpu}
value={formatPercent(s.gpu)}
progress={{ value: s.gpu, color: thresholdColor(s.gpu) }}
size={sz}
/>
),
visible: (s: BeszelSystemRow, o: SystemCardProps["options"]) => o.showGpu && s.gpu > 0,
visible: (s: BeszelSystemRow, o: SystemCardProps["options"], advanced: boolean) =>
isBeszelGridMetricVisible(o.showGpu, advanced, s.gpu > 0),
},
{
key: "showLoadAvg",
render: (s: BeszelSystemRow, t: SystemCardProps["t"], sz: SizeConfig) => (
<MetricRow
key="la"
icon={<Activity size={sz.iconSize} />}
icon={<Activity style={zoomCompensatedSize(sz.iconSize)} />}
label={t("metric.loadAvg")}
value={formatLoadAvg(s.loadAvg)}
size={sz}
/>
),
visible: (s: BeszelSystemRow, o: SystemCardProps["options"]) => o.showLoadAvg && s.loadAvg !== null,
visible: (s: BeszelSystemRow, o: SystemCardProps["options"], advanced: boolean) =>
isBeszelGridMetricVisible(o.showLoadAvg, advanced, s.loadAvg !== null),
},
{
key: "showNet",
render: (s: BeszelSystemRow, t: SystemCardProps["t"], sz: SizeConfig) => (
render: (
s: BeszelSystemRow,
t: SystemCardProps["t"],
sz: SizeConfig,
_tCommon: SystemCardProps["tCommon"],
formatByteRate: (bytes: number) => string,
) => (
<MetricRow
key="net"
icon={<Network size={sz.iconSize} />}
icon={<Network style={zoomCompensatedSize(sz.iconSize)} />}
label={t("metric.net")}
value={formatByteRate(s.netBytes)}
size={sz}
/>
),
visible: (s: BeszelSystemRow, o: SystemCardProps["options"]) => o.showNet,
visible: (s: BeszelSystemRow, o: SystemCardProps["options"], advanced: boolean) =>
isBeszelGridMetricVisible(o.showNet, advanced),
},
{
key: "showTemp",
render: (s: BeszelSystemRow, t: SystemCardProps["t"], sz: SizeConfig) => (
<MetricRow
key="temp"
icon={<Thermometer size={sz.iconSize} />}
icon={<Thermometer style={zoomCompensatedSize(sz.iconSize)} />}
label={t("metric.temp")}
value={formatTemp(s.temp, false)}
size={sz}
/>
),
visible: (s: BeszelSystemRow, o: SystemCardProps["options"]) => o.showTemp && s.temp !== null,
visible: (s: BeszelSystemRow, o: SystemCardProps["options"], advanced: boolean) =>
isBeszelGridMetricVisible(o.showTemp, advanced, s.temp !== null),
},
{
key: "showBattery",
@@ -279,58 +301,76 @@ const metricRenderers = [
return (
<MetricRow
key="bat"
icon={<Icon size={sz.iconSize} />}
icon={<Icon style={zoomCompensatedSize(sz.iconSize)} />}
label={t("metric.battery")}
value={`${s.battery?.[0] ?? 0}%`}
size={sz}
/>
);
},
visible: (s: BeszelSystemRow, o: SystemCardProps["options"]) => o.showBattery && s.battery !== null,
visible: (s: BeszelSystemRow, o: SystemCardProps["options"], advanced: boolean) =>
isBeszelGridMetricVisible(o.showBattery, advanced, s.battery !== null),
},
{
key: "showServices",
render: (s: BeszelSystemRow, t: SystemCardProps["t"], sz: SizeConfig) => (
render: (s: BeszelSystemRow, _t: SystemCardProps["t"], sz: SizeConfig, tCommon: SystemCardProps["tCommon"]) => (
<MetricRow
key="svc"
icon={<Server size={sz.iconSize} />}
label={t("metric.services")}
icon={<Server style={zoomCompensatedSize(sz.iconSize)} />}
label={tCommon("services")}
value={String(s.services)}
size={sz}
/>
),
visible: (s: BeszelSystemRow, o: SystemCardProps["options"]) => o.showServices,
visible: (s: BeszelSystemRow, o: SystemCardProps["options"], advanced: boolean) =>
isBeszelGridMetricVisible(o.showServices, advanced),
},
{
key: "showUptime",
render: (s: BeszelSystemRow, t: SystemCardProps["t"], sz: SizeConfig) => (
<MetricRow
key="up"
icon={<Activity size={sz.iconSize} />}
icon={<Activity style={zoomCompensatedSize(sz.iconSize)} />}
label={t("metric.uptime")}
value={formatUptime(s.uptime)}
size={sz}
/>
),
visible: (s: BeszelSystemRow, o: SystemCardProps["options"]) => o.showUptime,
visible: (s: BeszelSystemRow, o: SystemCardProps["options"], advanced: boolean) =>
isBeszelGridMetricVisible(o.showUptime, advanced),
},
{
key: "showAgent",
render: (s: BeszelSystemRow, t: SystemCardProps["t"], sz: SizeConfig) => (
<MetricRow
key="agent"
icon={<Wifi size={sz.iconSize} />}
icon={<Wifi style={zoomCompensatedSize(sz.iconSize)} />}
label={t("metric.agent")}
value={s.agentVersion}
size={sz}
/>
),
visible: (s: BeszelSystemRow, o: SystemCardProps["options"]) => o.showAgent,
visible: (s: BeszelSystemRow, o: SystemCardProps["options"], advanced: boolean) =>
isBeszelGridMetricVisible(o.showAgent, advanced),
},
] 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 SystemCard = ({
system,
options,
t,
tCommon,
size,
maxMetrics,
itemRadius,
integrationName,
isAdvanced,
onClick,
}: SystemCardProps) => {
const { formatByteRate, formatBytes } = useByteFormatter();
const enabledMetrics = metricRenderers.filter((metric) => metric.visible(system, options, isAdvanced));
const visibleMetrics = enabledMetrics.slice(0, maxMetrics);
const hiddenMetricCount = enabledMetrics.length - visibleMetrics.length;
return (
<Card
@@ -338,16 +378,21 @@ const SystemCard = ({ system, options, t, size, maxMetrics, itemRadius, onClick
radius={itemRadius}
bg="transparent"
h="100%"
onClick={onClick}
className={onClick ? classes.clickableCard : undefined}
style={{
overflow: "hidden",
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%",
}}
>
{onClick && (
<UnstyledButton type="button" className={classes.cardAction} aria-label={system.name} onClick={onClick} />
)}
<Group gap="xs" mb={2}>
<Badge
size={size.badgeSize}
@@ -359,49 +404,77 @@ const SystemCard = ({ system, options, t, size, maxMetrics, itemRadius, onClick
</Badge>
</Group>
{isAdvanced && (
<SimpleGrid cols={2} spacing={4} verticalSpacing={2} mb={4}>
<SystemMetadata label={t("metric.hostname")} value={system.hostname || "—"} />
<SystemMetadata label={invariantTechnicalLabels.os} value={system.osName || "—"} />
<SystemMetadata label={t("metric.cpuModel")} value={system.cpuModel || "—"} />
<SystemMetadata label={t("metric.cores")} value={String(system.cores)} />
<SystemMetadata
label={t("metric.memoryTotal")}
value={system.memoryTotal > 0 ? formatBytes(system.memoryTotal) : "—"}
/>
<SystemMetadata label={t("metric.source")} value={integrationName} />
</SimpleGrid>
)}
<Stack gap={0} style={{ flex: 1 }} justify="space-evenly">
{visibleMetrics.map((m) => m.render(system, t, size))}
{visibleMetrics.map((m) => m.render(system, t, size, tCommon, formatByteRate))}
</Stack>
{hiddenMetricCount > 0 && (
<Text size="xs" c="dimmed" ta="right">
+{hiddenMetricCount}
</Text>
)}
</Card>
);
};
const SystemMetadata = ({ label, value }: { label: string; value: string }) => (
<Box style={{ minWidth: 0 }}>
<Text size="xs" c="dimmed">
{label}
</Text>
<Text size="xs" fw={500} truncate title={value}>
{value}
</Text>
</Box>
);
export default function BeszelSystemGridWidget({
options,
integrationIds,
isEditMode,
width,
height,
displayMode,
}: WidgetComponentProps<"beszelSystemGrid">) {
const t = useScopedI18n("widget.beszelSystemGrid");
const t = useI18n("widget.beszel");
const tCommon = useI18n("common");
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 isAdvanced = displayMode === "advanced";
const integrationNames = new Map(results.map((result) => [result.integrationId, result.integrationName]));
const filteredSystems = useBeszelFilteredSystems(results, options.statusFilter);
if (systemsError) throw systemsError;
if (isPending) {
return (
<Center h="100%">
<Loader size="sm" />
</Center>
);
return <WidgetQueryLoadingState />;
}
if (filteredSystems.length === 0) {
return (
<Box h="100%" pos="relative" style={{ pointerEvents: isEditMode ? "none" : undefined }}>
<BeszelIntegrationErrorIndicator results={results} />
<Group pos="absolute" top={4} right={8} gap={0} style={{ zIndex: 1 }}>
<IntegrationErrorIndicator results={results} />
</Group>
<Center h="100%">
<Stack align="center" gap="xs">
<IconServerOff size={28} opacity={0.5} />
<IconServerOff style={zoomCompensatedSize(28)} opacity={0.5} />
<Text size="sm" c="dimmed">
{t("empty.noSystems")}
</Text>
@@ -411,42 +484,50 @@ export default function BeszelSystemGridWidget({
);
}
const cols = getColCount(width, height, filteredSystems.length);
const minimumCellWidth = isAdvanced ? ADVANCED_MIN_CELL_WIDTH : MIN_CELL_WIDTH;
const cols = getColCount(width, height, filteredSystems.length, minimumCellWidth);
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 = isAdvanced ? ADVANCED_MIN_CELL_HEIGHT : 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);
const maxMetrics = isAdvanced ? Number.POSITIVE_INFINITY : getMaxVisibleMetrics(effectiveCellHeight, size);
return (
<Box h="100%" pos="relative" style={{ pointerEvents: isEditMode ? "none" : undefined }}>
<BeszelIntegrationErrorIndicator results={results} />
<Group pos="absolute" top={4} right={8} gap={0} style={{ zIndex: 1 }}>
<IntegrationErrorIndicator results={results} />
</Group>
<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",
}}
>
{filteredSystems.map((system) => {
const integrationId = system._key.split(":")[0] ?? "";
const integrationId = system.rowKey.split(":")[0] ?? "";
const integrationName = integrationNames.get(integrationId) ?? "—";
const handleClick = isEditMode
? undefined
: () => openModal({ integrationId, systemId: system.id }, { title: system.name });
return (
<SystemCard
key={system._key}
key={system.rowKey}
system={system}
options={options}
t={t}
tCommon={tCommon}
size={size}
maxMetrics={maxMetrics}
itemRadius={board.itemRadius}
integrationName={integrationName}
isAdvanced={isAdvanced}
onClick={handleClick}
/>
);
@@ -0,0 +1,15 @@
import { describe, expect, test } from "vitest";
import { isBeszelGridMetricVisible } from "./display";
describe("Beszel grid metric visibility", () => {
test("honors compact configuration", () => {
expect(isBeszelGridMetricVisible(false, false)).toBe(false);
expect(isBeszelGridMetricVisible(true, false)).toBe(true);
});
test("forces configured-off metrics in advanced mode when applicable", () => {
expect(isBeszelGridMetricVisible(false, true)).toBe(true);
expect(isBeszelGridMetricVisible(false, true, false)).toBe(false);
});
});
@@ -0,0 +1,2 @@
export const isBeszelGridMetricVisible = (configured: boolean, isAdvanced: boolean, applicable = true) =>
applicable && (isAdvanced || configured);
@@ -1,14 +1,15 @@
import { IconLayoutGrid, IconServerOff } from "@tabler/icons-react";
import { getWidgetIntegrationConfig } from "@homarr/definitions";
import { createWidgetDefinition } from "../definition";
import { statusOptions } from "../beszel/_shared/options";
import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("beszelSystemGrid", {
icon: IconLayoutGrid,
queryKey: [["widget", "beszel"]],
supportedIntegrations: ["beszel", "mock"],
integrationsRequired: true,
queryKey: [["widget", "beszel", "getSystems"]],
...getWidgetIntegrationConfig("beszelSystemGrid"),
createOptions() {
return optionsBuilder.from((factory) => ({
statusFilter: factory.select({
@@ -31,7 +32,7 @@ export const { definition, componentLoader } = createWidgetDefinition("beszelSys
errors: {
INTERNAL_SERVER_ERROR: {
icon: IconServerOff,
message: (t) => t("widget.beszelSystemGrid.error.internalServerError"),
message: (t) => t("widget.beszel.error.internalServerError"),
},
},
}).withDynamicImport(() => import("./component"));
@@ -1,14 +1,43 @@
.beszelStatsSystemToggle {
.beszelStatsControls {
position: absolute;
top: 6px;
left: 6px;
z-index: 1;
z-index: 2;
top: var(--mantine-spacing-sm);
right: var(--mantine-spacing-sm);
left: var(--mantine-spacing-sm);
opacity: 0;
transition: opacity 0.15s ease;
transition: opacity 150ms ease-out;
pointer-events: none;
}
.beszelStatsContainer:hover .beszelStatsSystemToggle {
.beszelStatsRoot:hover .beszelStatsControls,
.beszelStatsRoot:focus-within .beszelStatsControls {
opacity: 1;
pointer-events: auto;
}
.beszelStatsSystemToggle {
max-width: 50%;
flex-shrink: 0;
}
.beszelStatsPeriodToggle {
flex: 1;
min-width: 0;
}
.beszelStatsPeriodButton {
flex: 1;
}
@media (any-pointer: coarse) {
.beszelStatsControls {
opacity: 1;
pointer-events: auto;
}
}
@media (prefers-reduced-motion: reduce) {
.beszelStatsControls {
transition: none;
}
}
@@ -1,77 +1,163 @@
"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, Group, Menu, ScrollArea, 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 { useScopedI18n } from "@homarr/translation/client";
import { showErrorNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { iconSizes, zoomCompensatedSize } from "@homarr/ui";
import classes from "./component.module.css";
import { WidgetQueryLoadingState } from "../common/query-state-indicator";
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,
integrationIds,
isEditMode,
width,
height,
displayScale = 1,
displayMode,
boardId,
itemId,
setOptions,
widgetRuntimeRef,
}: WidgetComponentProps<"beszelSystemStats">) {
const t = useScopedI18n("widget.beszelSystemStats");
const t = useI18n("widget.beszelSystemStats");
const tBeszel = useI18n("widget.beszel");
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 systemsData = getUsableWidgetQueryData(systemsQuery);
const systemsResult = useMemo(() => systemsData ?? [], [systemsData]);
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") },
];
let layoutScale = 1;
if (displayMode !== "advanced" && Number.isFinite(displayScale) && displayScale > 0) {
layoutScale = displayScale;
}
let responsiveWidth = width;
let responsiveHeight = height;
responsiveWidth *= layoutScale;
responsiveHeight *= layoutScale;
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],
);
if (systemsError) throw systemsError;
if (systemsPending)
return (
<Center h="100%">
<Loader size="sm" />
</Center>
);
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 (systemsPending) return <WidgetQueryLoadingState />;
if (systems.length === 0) {
return (
<Box h="100%" pos="relative">
<BeszelIntegrationErrorIndicator results={systemsResult} />
<Group pos="absolute" top={4} right={8} gap={0} style={{ zIndex: 1 }}>
<IntegrationErrorIndicator results={systemsResult} />
</Group>
<Center h="100%">
<Stack align="center" gap="xs">
<IconServerOff size={28} opacity={0.5} />
<IconServerOff style={zoomCompensatedSize(28)} opacity={0.5} />
<Text size="sm" c="dimmed">
{t("empty.noSystems")}
{tBeszel("empty.noSystems")}
</Text>
</Stack>
</Center>
@@ -83,15 +169,16 @@ export default function BeszelSystemStatsWidget({
return (
<Center h="100%">
<Stack align="center" gap="sm" p="md">
<IconQuestionMark size={40} />
<IconQuestionMark style={zoomCompensatedSize(40)} />
<Text size="sm" c="dimmed" ta="center">
{t("error.systemNotFound")}
</Text>
<Select
size="xs"
data={systems}
value={selectedSystem}
value={selectedValue}
placeholder={t("selectSystem")}
disabled={isSelectionSavePending}
onChange={(value) => value && handleSelectSystem(value)}
/>
</Stack>
@@ -100,24 +187,23 @@ export default function BeszelSystemStatsWidget({
}
return (
<Box h="100%" pos="relative">
<Box h="100%" pos="relative" className={classes.beszelStatsRoot}>
<Box pos="absolute" top={4} right={8} style={{ zIndex: 1 }}>
<BeszelIntegrationErrorIndicator results={systemsResult} />
<Group gap={0}>
<IntegrationErrorIndicator results={systemsResult} />
</Group>
</Box>
<ScrollArea
h="100%"
className={classes.beszelStatsContainer}
style={{ pointerEvents: isEditMode ? "none" : undefined }}
>
<Stack gap="md" p="sm">
{!isEditMode && systems.length > 1 && (
{!isEditMode && (
<Group gap="xs" wrap="nowrap" className={classes.beszelStatsControls}>
{systems.length > 1 && (
<Menu position="bottom-start" withArrow shadow="md" withinPortal>
<Menu.Target>
<Button
variant="default"
size="compact-xs"
leftSection={<IconServer size={14} />}
leftSection={<IconServer style={iconSizes.sm} />}
className={classes.beszelStatsSystemToggle}
disabled={isSelectionSavePending}
>
{selectedLabel}
</Button>
@@ -127,8 +213,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,11 +224,47 @@ export default function BeszelSystemStatsWidget({
</Menu.Dropdown>
</Menu>
)}
{responsiveWidth >= 560 ? (
<Button.Group className={classes.beszelStatsPeriodToggle}>
{periodOptions.map((period) => (
<Button
key={period.value}
size="compact-xs"
variant={period.value === options.timePeriod ? "filled" : "default"}
className={classes.beszelStatsPeriodButton}
aria-pressed={period.value === options.timePeriod}
disabled={isSelectionSavePending}
onClick={() => handleTimePeriod(period.value)}
>
{period.label}
</Button>
))}
</Button.Group>
) : (
<Select
size="xs"
value={options.timePeriod}
onChange={(value) => value && handleTimePeriod(value)}
disabled={isSelectionSavePending}
data={periodOptions}
className={classes.beszelStatsPeriodToggle}
/>
)}
</Group>
)}
<ScrollArea
h="100%"
className={classes.beszelStatsContainer}
style={{ pointerEvents: isEditMode ? "none" : undefined }}
>
<Stack gap={0}>
<BeszelStatsView
integrationIds={integrationIds}
systemId={selectedSystem}
showXAxis={responsiveWidth >= 220 && responsiveHeight >= 180}
integrationIds={selectedSystem ? [selectedSystem.integrationId] : []}
systemId={selectedSystem?.systemId ?? ""}
timePeriod={options.timePeriod as BeszelTimePeriod}
columns={width > 600 ? 2 : 1}
columns={responsiveWidth > 600 ? 2 : 1}
availableHeight={height}
visibility={{
cpu: options.showCpu,
memory: options.showMemory,
@@ -152,7 +275,7 @@ export default function BeszelSystemStatsWidget({
dockerMemory: options.showDockerMemory,
dockerNetwork: options.showDockerNetwork,
}}
onSwitchToHistorical={() => setOptions({ newOptions: { timePeriod: "1h" } })}
onSwitchToHistorical={() => handleTimePeriod("1h")}
/>
</Stack>
</ScrollArea>
@@ -1,9 +1,11 @@
import { IconChartAreaLine, IconServerOff } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { getWidgetIntegrationConfig } from "@homarr/definitions";
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" },
@@ -16,9 +18,30 @@ const timePeriodOptions = [
export const { definition, componentLoader } = createWidgetDefinition("beszelSystemStats", {
icon: IconChartAreaLine,
queryKey: [["widget", "beszel"]],
supportedIntegrations: ["beszel", "mock"],
integrationsRequired: true,
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 }))
);
},
...getWidgetIntegrationConfig("beszelSystemStats"),
createOptions() {
return optionsBuilder.from((factory) => ({
systemId: factory.integrationSelect({
@@ -30,7 +53,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 };
},
}),
@@ -51,7 +74,7 @@ export const { definition, componentLoader } = createWidgetDefinition("beszelSys
errors: {
INTERNAL_SERVER_ERROR: {
icon: IconServerOff,
message: (t) => t("widget.beszelSystemStats.error.internalServerError"),
message: (t) => t("widget.beszel.error.internalServerError"),
},
},
}).withDynamicImport(() => import("./component"));
@@ -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;
}
@@ -1,6 +1,7 @@
import { describe, expect, test } from "vitest";
import { parseColumnOrder, parseColumnWidths } from "../common/use-persisted-table-layout";
import { beszelTableMetricOptionKeys, getBeszelTableVisibleMetricKeys } from "./display";
const columnAccessors = ["name", "cpu", "memory", "disk", "temp"];
@@ -32,3 +33,23 @@ describe("Beszel table column layout options", () => {
).toEqual({ cpu: 140 });
});
});
describe("Beszel table metric visibility", () => {
const enabledOptions = Object.fromEntries(beszelTableMetricOptionKeys.map((key) => [key, true])) as Record<
(typeof beszelTableMetricOptionKeys)[number],
boolean
>;
test("keeps the compact width budget", () => {
expect([...getBeszelTableVisibleMetricKeys(enabledOptions, 350, false)]).toEqual(["showCpu"]);
expect(getBeszelTableVisibleMetricKeys(enabledOptions, 600, false).size).toBe(4);
});
test("shows every metric in advanced mode regardless of visibility options", () => {
const disabledOptions = Object.fromEntries(
beszelTableMetricOptionKeys.map((key) => [key, false]),
) as typeof enabledOptions;
expect([...getBeszelTableVisibleMetricKeys(disabledOptions, 200, true)]).toEqual(beszelTableMetricOptionKeys);
});
});
@@ -1,7 +1,7 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Center, Group, Indicator, Loader, Progress, Text } from "@mantine/core";
import { Box, Group, Progress, Text } from "@mantine/core";
import type { DataTableColumn, DataTableSortStatus } from "mantine-datatable";
import {
Activity,
@@ -18,36 +18,41 @@ import {
import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import { zoomCompensatedSize } from "@homarr/ui";
import { constructBoardPermissions } from "@homarr/auth/shared";
import { useOptionalBoard } from "@homarr/boards/context";
import { invariantTechnicalLabels } from "@homarr/definitions";
import { useModalAction } from "@homarr/modals";
import { showErrorNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import { useByteFormatter } from "@homarr/settings";
import { useI18n } from "@homarr/translation/client";
import { WidgetQueryLoadingState } from "../common/query-state-indicator";
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";
import {
formatByteRate,
formatLoadAvg,
formatPercent,
formatTemp,
formatUptime,
getProgressTrackSize,
} from "../beszel/_shared/format";
import { formatLoadAvg, formatPercent, formatTemp, formatUptime, 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";
import { getBeszelTableVisibleMetricKeys } from "./display";
const directionMultiplier: Record<string, number> = { asc: 1, desc: -1 };
type SystemRowWithKey = BeszelSystemRow & { _key: string };
type SystemRowWithKey = BeszelSystemRow & { rowKey: string; integrationName: string };
const columnAccessors = [
"name",
"integrationName",
"hostname",
"osName",
"cpuModel",
"cores",
"memoryTotal",
"cpu",
"memory",
"disk",
@@ -59,7 +64,7 @@ const columnAccessors = [
"services",
"uptime",
"agentVersion",
] as const satisfies readonly (keyof BeszelSystemRow)[];
] as const satisfies readonly (keyof SystemRowWithKey)[];
interface SizeConfig {
iconSize: number;
@@ -88,6 +93,22 @@ const getSizeConfig = (width: number): SizeConfig => {
};
};
const PercentCell = ({ value, size }: { value: number; size: SizeConfig }) => {
return (
<Group gap={8} wrap="nowrap" style={{ flex: 1 }}>
<Text size={size.fontSize} fw={500} w={size.valueMiw} ta="left" style={{ whiteSpace: "nowrap", flexShrink: 0 }}>
{formatPercent(value)}
</Text>
<Progress
value={value}
color={thresholdColor(value)}
size={getProgressTrackSize(size.progressSize)}
style={{ flex: 1 }}
/>
</Group>
);
};
export default function BeszelSystemTableWidget({
options,
integrationIds,
@@ -96,18 +117,26 @@ export default function BeszelSystemTableWidget({
boardId,
itemId,
setOptions,
displayMode,
}: WidgetComponentProps<"beszelSystemTable">) {
const t = useScopedI18n("widget.beszelSystemTable");
const t = useI18n("widget.beszelSystemTable");
const tBeszel = useI18n("widget.beszel");
const tCommon = useI18n("common");
const { openModal } = useModalAction(BeszelSystemStatsModal);
const board = useOptionalBoard();
const { data: session } = useSession();
const { formatByteRate, formatBytes } = useByteFormatter();
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 systemsData = getUsableWidgetQueryData(systemsQuery);
const results = useMemo(() => systemsData ?? [], [systemsData]);
const { isPending } = systemsQuery;
const isAdvanced = displayMode === "advanced";
const size = useMemo(() => getSizeConfig(width), [width]);
const visibleMetricKeys = useMemo(
() => getBeszelTableVisibleMetricKeys(options, width, isAdvanced),
[options, width, isAdvanced],
);
const { mutate: saveItemOptions } = clientApi.widget.options.saveItemOptions.useMutation({
onError: () =>
@@ -125,6 +154,18 @@ export default function BeszelSystemTableWidget({
});
const filteredSystems = useBeszelFilteredSystems(results, options.statusFilter);
const integrationNames = useMemo(
() => new Map(results.map((result) => [result.integrationId, result.integrationName])),
[results],
);
const systemsWithSource = useMemo(
() =>
filteredSystems.map((system) => ({
...system,
integrationName: integrationNames.get(system.rowKey.split(":")[0] ?? "") ?? "—",
})),
[filteredSystems, integrationNames],
);
const [sortStatus, setSortStatus] = useState<DataTableSortStatus<SystemRowWithKey>>({
columnAccessor: options.sortBy,
@@ -138,30 +179,16 @@ export default function BeszelSystemTableWidget({
}, [options.sortBy, options.sortDirection]);
const sortedSystems = useMemo(() => {
const accessor = sortStatus.columnAccessor as keyof BeszelSystemRow;
const accessor = sortStatus.columnAccessor as keyof SystemRowWithKey;
const dir = directionMultiplier[sortStatus.direction] ?? 1;
return [...filteredSystems].toSorted((a, b) => {
return [...systemsWithSource].toSorted((a, b) => {
const aVal = a[accessor] ?? 0;
const bVal = b[accessor] ?? 0;
if (typeof aVal === "string" && typeof bVal === "string") return aVal.localeCompare(bVal) * dir;
if (typeof aVal === "number" && typeof bVal === "number") return (aVal - bVal) * dir;
return 0;
});
}, [filteredSystems, sortStatus]);
const PercentCell = ({ value }: { value: number }) => (
<Group gap={8} wrap="nowrap" style={{ flex: 1 }}>
<Text size={size.fontSize} fw={500} w={size.valueMiw} ta="left" style={{ whiteSpace: "nowrap", flexShrink: 0 }}>
{formatPercent(value)}
</Text>
<Progress
value={value}
color={thresholdColor(value)}
size={getProgressTrackSize(size.progressSize)}
style={{ flex: 1 }}
/>
</Group>
);
}, [systemsWithSource, sortStatus]);
const columns = useMemo((): DataTableColumn<SystemRowWithKey>[] => {
const cols: (DataTableColumn<SystemRowWithKey> | false)[] = [
@@ -171,51 +198,99 @@ export default function BeszelSystemTableWidget({
ellipsis: true,
title: (
<Group gap={4} wrap="nowrap">
<Server size={size.iconSize} />
<Text inherit>{t("column.system")}</Text>
<Server style={zoomCompensatedSize(size.iconSize)} />
<Text inherit>{tBeszel("metric.system")}</Text>
</Group>
),
sortable: true,
render: (record) => (
<Group gap={8} wrap="nowrap" style={{ overflow: "hidden", paddingInlineStart: 4 }}>
<Indicator color={statusColorMap[record.status]} size={7} />
<Box w={7} h={7} bg={statusColorMap[record.status]} style={{ borderRadius: "50%", flexShrink: 0 }} />
<Text size={size.fontSize} fw={500} truncate>
{record.name}
</Text>
</Group>
),
},
options.showCpu && {
isAdvanced && {
accessor: "integrationName",
width: 140,
ellipsis: true,
title: tBeszel("metric.source"),
sortable: true,
render: (record) => <Text size={size.fontSize}>{record.integrationName}</Text>,
},
isAdvanced && {
accessor: "hostname",
width: 150,
ellipsis: true,
title: tBeszel("metric.hostname"),
sortable: true,
render: (record) => <Text size={size.fontSize}>{record.hostname || "—"}</Text>,
},
isAdvanced && {
accessor: "osName",
width: 160,
ellipsis: true,
title: invariantTechnicalLabels.os,
sortable: true,
render: (record) => <Text size={size.fontSize}>{record.osName || "—"}</Text>,
},
isAdvanced && {
accessor: "cpuModel",
width: 220,
ellipsis: true,
title: tBeszel("metric.cpuModel"),
sortable: true,
render: (record) => <Text size={size.fontSize}>{record.cpuModel || "—"}</Text>,
},
isAdvanced && {
accessor: "cores",
width: 80,
title: tBeszel("metric.cores"),
sortable: true,
render: (record) => <Text size={size.fontSize}>{record.cores}</Text>,
},
isAdvanced && {
accessor: "memoryTotal",
width: 120,
title: tBeszel("metric.memoryTotal"),
sortable: true,
render: (record) => (
<Text size={size.fontSize}>{record.memoryTotal > 0 ? formatBytes(record.memoryTotal) : "—"}</Text>
),
},
visibleMetricKeys.has("showCpu") && {
accessor: "cpu",
width: 140,
title: (
<Group gap={4} wrap="nowrap">
<Cpu size={size.iconSize} />
<Text inherit>{t("column.cpu")}</Text>
<Cpu style={zoomCompensatedSize(size.iconSize)} />
<Text inherit>{invariantTechnicalLabels.cpu}</Text>
</Group>
),
sortable: true,
render: (record) => <PercentCell value={record.cpu} />,
render: (record) => <PercentCell value={record.cpu} size={size} />,
},
options.showMemory && {
visibleMetricKeys.has("showMemory") && {
accessor: "memory",
width: 140,
title: (
<Group gap={4} wrap="nowrap">
<MemoryStick size={size.iconSize} />
<Text inherit>{t("column.memory")}</Text>
<MemoryStick style={zoomCompensatedSize(size.iconSize)} />
<Text inherit>{tBeszel("metric.memory")}</Text>
</Group>
),
sortable: true,
render: (record) => <PercentCell value={record.memory} />,
render: (record) => <PercentCell value={record.memory} size={size} />,
},
options.showDisk && {
visibleMetricKeys.has("showDisk") && {
accessor: "disk",
width: 160,
title: (
<Group gap={4} wrap="nowrap">
<HardDrive size={size.iconSize} />
<Text inherit>{t("column.disk")}</Text>
<HardDrive style={zoomCompensatedSize(size.iconSize)} />
<Text inherit>{tBeszel("metric.disk")}</Text>
</Group>
),
sortable: true,
@@ -229,42 +304,47 @@ export default function BeszelSystemTableWidget({
/>
),
},
options.showGpu && {
visibleMetricKeys.has("showGpu") && {
accessor: "gpu",
width: 140,
title: (
<Group gap={4} wrap="nowrap">
<Monitor size={size.iconSize} />
<Text inherit>{t("column.gpu")}</Text>
<Monitor style={zoomCompensatedSize(size.iconSize)} />
<Text inherit>{invariantTechnicalLabels.gpu}</Text>
</Group>
),
sortable: true,
render: (record) => <PercentCell value={record.gpu} />,
render: (record) => <PercentCell value={record.gpu} size={size} />,
},
options.showLoadAvg && {
visibleMetricKeys.has("showLoadAvg") && {
accessor: "loadAvg",
width: 100,
title: (
<Group gap={4} wrap="nowrap">
<Activity size={size.iconSize} />
<Text inherit>{t("column.loadAvg")}</Text>
<Activity style={zoomCompensatedSize(size.iconSize)} />
<Text inherit>{tBeszel("metric.loadAvg")}</Text>
</Group>
),
sortable: true,
render: (record) => (
<Group gap={8} wrap="nowrap">
<Indicator color={record.loadAvg ? loadAvgColor(record.loadAvg[0], record.cores) : "gray"} size={7} />
<Box
w={7}
h={7}
bg={record.loadAvg ? loadAvgColor(record.loadAvg[0], record.cores) : "gray"}
style={{ borderRadius: "50%", flexShrink: 0 }}
/>
<Text size={size.fontSize}>{formatLoadAvg(record.loadAvg)}</Text>
</Group>
),
},
options.showNet && {
visibleMetricKeys.has("showNet") && {
accessor: "netBytes",
width: 100,
title: (
<Group gap={4} wrap="nowrap">
<Network size={size.iconSize} />
<Text inherit>{t("column.net")}</Text>
<Network style={zoomCompensatedSize(size.iconSize)} />
<Text inherit>{tBeszel("metric.net")}</Text>
</Group>
),
sortable: true,
@@ -274,60 +354,60 @@ export default function BeszelSystemTableWidget({
</Text>
),
},
options.showTemp && {
visibleMetricKeys.has("showTemp") && {
accessor: "temp",
width: 80,
title: (
<Group gap={4} wrap="nowrap">
<Thermometer size={size.iconSize} />
<Text inherit>{t("column.temp")}</Text>
<Thermometer style={zoomCompensatedSize(size.iconSize)} />
<Text inherit>{tBeszel("metric.temp")}</Text>
</Group>
),
sortable: true,
render: (record) => <Text size={size.fontSize}>{formatTemp(record.temp, false)}</Text>,
},
options.showBattery && {
visibleMetricKeys.has("showBattery") && {
accessor: "battery",
width: 70,
title: (
<Group gap={4} wrap="nowrap">
<Battery size={size.iconSize} />
<Text inherit>{t("column.battery")}</Text>
<Battery style={zoomCompensatedSize(size.iconSize)} />
<Text inherit>{tBeszel("metric.battery")}</Text>
</Group>
),
render: (record) => <Text size={size.fontSize}>{record.battery ? `${record.battery[0]}%` : "—"}</Text>,
},
options.showServices && {
visibleMetricKeys.has("showServices") && {
accessor: "services",
width: 80,
title: (
<Group gap={4} wrap="nowrap">
<Server size={size.iconSize} />
<Text inherit>{t("column.services")}</Text>
<Server style={zoomCompensatedSize(size.iconSize)} />
<Text inherit>{tCommon("services")}</Text>
</Group>
),
sortable: true,
render: (record) => <Text size={size.fontSize}>{record.services}</Text>,
},
options.showUptime && {
visibleMetricKeys.has("showUptime") && {
accessor: "uptime",
width: 90,
title: (
<Group gap={4} wrap="nowrap">
<Activity size={size.iconSize} />
<Text inherit>{t("column.uptime")}</Text>
<Activity style={zoomCompensatedSize(size.iconSize)} />
<Text inherit>{tBeszel("metric.uptime")}</Text>
</Group>
),
sortable: true,
render: (record) => <Text size={size.fontSize}>{formatUptime(record.uptime)}</Text>,
},
options.showAgent && {
visibleMetricKeys.has("showAgent") && {
accessor: "agentVersion",
width: 90,
title: (
<Group gap={4} wrap="nowrap">
<Wifi size={size.iconSize} />
<Text inherit>{t("column.agent")}</Text>
<Wifi style={zoomCompensatedSize(size.iconSize)} />
<Text inherit>{tBeszel("metric.agent")}</Text>
</Group>
),
sortable: true,
@@ -336,21 +416,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,
]);
}, [formatByteRate, formatBytes, tBeszel, tCommon, size, visibleMetricKeys, isAdvanced]);
const { effectiveColumns, storeKey } = usePersistedTableLayout({
columns,
@@ -364,26 +430,22 @@ export default function BeszelSystemTableWidget({
const handleRowClick = useCallback(
({ record }: { record: SystemRowWithKey }) => {
const integrationId = record._key.split(":")[0] ?? "";
const integrationId = record.rowKey.split(":")[0] ?? "";
openModal({ integrationId, systemId: record.id }, { title: record.name });
},
[openModal],
);
if (systemsError) throw systemsError;
if (isPending) {
return (
<Center h="100%">
<Loader size="sm" />
</Center>
);
return <WidgetQueryLoadingState />;
}
return (
const table = (
<div style={{ position: "relative", height: "100%" }}>
<div style={{ position: "absolute", top: 4, right: 8, zIndex: 1 }}>
<BeszelIntegrationErrorIndicator results={results} />
<Group gap={0}>
<IntegrationErrorIndicator results={results} />
</Group>
</div>
<HomarrDataTable
isEditMode={isEditMode}
@@ -393,11 +455,13 @@ export default function BeszelSystemTableWidget({
columns={effectiveColumns}
sortStatus={sortStatus}
onSortStatusChange={setSortStatus}
noRecordsText={t("noRecords")}
idAccessor="_key"
noRecordsText={tBeszel("empty.noSystems")}
idAccessor="rowKey"
storeColumnsKey={storeKey}
onRowClick={isEditMode ? undefined : handleRowClick}
/>
</div>
);
return table;
}
@@ -0,0 +1,28 @@
export const beszelTableMetricOptionKeys = [
"showCpu",
"showMemory",
"showDisk",
"showGpu",
"showLoadAvg",
"showNet",
"showTemp",
"showBattery",
"showServices",
"showUptime",
"showAgent",
] as const;
type BeszelTableMetricOptions = Record<(typeof beszelTableMetricOptionKeys)[number], boolean>;
export const getBeszelTableVisibleMetricKeys = (
options: BeszelTableMetricOptions,
width: number,
isAdvanced: boolean,
) => {
const enabled = isAdvanced
? [...beszelTableMetricOptionKeys]
: beszelTableMetricOptionKeys.filter((key) => options[key]);
const budget = isAdvanced ? enabled.length : width < 360 ? 1 : width < 560 ? 2 : width < 760 ? 4 : enabled.length;
return new Set(enabled.slice(0, budget));
};
@@ -1,22 +1,25 @@
import { IconServerOff, IconTable } from "@tabler/icons-react";
import { createWidgetDefinition } from "../definition";
import { getWidgetIntegrationConfig, invariantTechnicalLabels } from "@homarr/definitions";
import { createWidgetDefinition, matchesWidgetRuntimeQuery, widgetQueryInputMatches } from "../definition";
import type { SelectOption } from "../_inputs/widget-select-input";
import { statusOptions } from "../beszel/_shared/options";
import { optionsBuilder } from "../options";
const sortOptions = [
{ value: "name", label: "System" },
{ value: "cpu", label: "CPU" },
{ value: "memory", label: "Memory" },
{ value: "disk", label: "Disk" },
{ value: "gpu", label: "GPU" },
{ value: "loadAvg", label: "Load Avg" },
{ value: "netBytes", label: "Net" },
{ value: "temp", label: "Temp" },
{ value: "services", label: "Services" },
{ value: "uptime", label: "Uptime" },
{ value: "agentVersion", label: "Agent" },
];
{ value: "name", label: (t) => t("widget.beszel.metric.system") },
{ value: "cpu", label: invariantTechnicalLabels.cpu },
{ value: "memory", label: (t) => t("widget.beszel.metric.memory") },
{ value: "disk", label: (t) => t("widget.beszel.metric.disk") },
{ value: "gpu", label: invariantTechnicalLabels.gpu },
{ value: "loadAvg", label: (t) => t("widget.beszel.metric.loadAvg") },
{ value: "netBytes", label: (t) => t("widget.beszel.metric.net") },
{ value: "temp", label: (t) => t("widget.beszel.metric.temp") },
{ value: "services", label: (t) => t("common.services") },
{ value: "uptime", label: (t) => t("widget.beszel.metric.uptime") },
{ value: "agentVersion", label: (t) => t("widget.beszel.metric.agent") },
] satisfies SelectOption[];
const sortDirectionOptions = [
{ value: "asc", label: "Ascending" },
@@ -25,9 +28,12 @@ const sortDirectionOptions = [
export const { definition, componentLoader } = createWidgetDefinition("beszelSystemTable", {
icon: IconTable,
queryKey: [["widget", "beszel"]],
supportedIntegrations: ["beszel", "mock"],
integrationsRequired: true,
queryKeys: [[["widget", "beszel", "getSystems"]], [["widget", "beszel", "getSystemStats"]]],
queryMatcher: (query, scope) =>
query.path.at(-1) === "getSystems"
? widgetQueryInputMatches(query.input, { integrationIds: scope.integrationIds })
: matchesWidgetRuntimeQuery(query, scope),
...getWidgetIntegrationConfig("beszelSystemTable"),
createOptions() {
return optionsBuilder.from(
(factory) => ({
@@ -63,7 +69,7 @@ export const { definition, componentLoader } = createWidgetDefinition("beszelSys
errors: {
INTERNAL_SERVER_ERROR: {
icon: IconServerOff,
message: (t) => t("widget.beszelSystemTable.error.internalServerError"),
message: (t) => t("widget.beszel.error.internalServerError"),
},
},
}).withDynamicImport(() => import("./component"));
@@ -2,7 +2,9 @@ import { describe, expect, test } from "vitest";
import type { BeszelSystemStatsRecord } from "@homarr/integrations/types";
import { buildDiskChartData, padLiveTimeGrid } from "./chart";
import { buildDiskChartData, normalizeBeszelByteRate, padLiveTimeGrid } from "./chart";
const GIBIBYTE = 1024 ** 3;
const record = (
created: string,
@@ -30,7 +32,7 @@ const record = (
});
describe("buildDiskChartData", () => {
test("keeps root and extra filesystem usage in GiB without stacking or byte conversion", () => {
test("normalizes root and extra filesystem usage to bytes without stacking", () => {
const data = buildDiskChartData(
[
record("2026-07-11T13:46:00.000Z", 455.81, {
@@ -45,7 +47,12 @@ describe("buildDiskChartData", () => {
);
expect(data).toHaveLength(1);
expect(data[0]).toMatchObject({ Root: 455.81, sda: 1656.45, sdb: 3936.86, sdc: 1589.84 });
expect(data[0]).toMatchObject({
Root: 455.81 * GIBIBYTE,
sda: 1656.45 * GIBIBYTE,
sdb: 3936.86 * GIBIBYTE,
sdc: 1589.84 * GIBIBYTE,
});
});
test("uses zero for a filesystem missing from an individual sample and orders historical records oldest first", () => {
@@ -60,12 +67,23 @@ describe("buildDiskChartData", () => {
);
expect(data.map((point) => ({ Root: point.Root, sda: point.sda }))).toEqual([
{ Root: 1, sda: 0 },
{ Root: 2, sda: 4 },
{ Root: GIBIBYTE, sda: 0 },
{ Root: 2 * GIBIBYTE, sda: 4 * GIBIBYTE },
]);
});
});
describe("normalizeBeszelByteRate", () => {
test("converts legacy MiB/s values to bytes/s", () => {
expect(normalizeBeszelByteRate(undefined, 1.5)).toBe(1.5 * 1024 ** 2);
});
test("keeps the preferred bytes/s value authoritative, including zero", () => {
expect(normalizeBeszelByteRate(0, 1.5)).toBe(0);
expect(normalizeBeszelByteRate(1_500_000, 1.5)).toBe(1_500_000);
});
});
describe("padLiveTimeGrid", () => {
test("keeps a fixed 60-second window with the newest sample at the right edge", () => {
const data = padLiveTimeGrid([{ time: "13:51:30", rawTime: "2026-07-11T13:51:30.217Z", CPU: 18.07 }]);
+101 -39
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() };
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,16 +81,20 @@ 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(),
}
);
});
}
const yAxisBase = { tickMargin: 0, tick: { fontSize: 10 } } as const;
const yAxisBase = {
tickMargin: 0,
tick: { fontSize: "var(--mantine-font-size-xs)" },
} as const;
const chartStyle = { minWidth: 0, minHeight: 1 } as const;
const panelStyle = { minWidth: 0, overflow: "hidden" } as const;
const panelStyle = { minWidth: 0 } as const;
const chartMargin = { top: 0, right: 0, bottom: 0, left: 0 } as const;
export const CPU_Y_AXIS_DOMAIN: [number, string] = [0, "auto"];
interface BeszelChartPanelProps {
@@ -96,12 +105,12 @@ interface BeszelChartPanelProps {
export const BeszelChartPanel = memo(({ title, subtitle, chartProps }: BeszelChartPanelProps) => (
<Stack gap={4} style={panelStyle}>
<Group gap="xs">
<Text size="sm" fw={600}>
<Group gap="xs" wrap="nowrap">
<Text size="sm" fw={600} style={{ flexShrink: 0 }}>
{title}
</Text>
{subtitle && (
<Text size="xs" c="dimmed">
<Text size="xs" c="dimmed" truncate style={{ minWidth: 0 }}>
{subtitle}
</Text>
)}
@@ -110,7 +119,7 @@ export const BeszelChartPanel = memo(({ title, subtitle, chartProps }: BeszelCha
</Stack>
));
type BeszelAreaChartProps = Omit<AreaChartProps, "dataKey" | "curveType" | "withDots" | "withXAxis" | "withYAxis"> & {
type BeszelAreaChartProps = Omit<AreaChartProps, "dataKey" | "curveType" | "withDots" | "withYAxis"> & {
yAxisFormatter: (value: number) => string;
yAxisDomain?: [number, string];
};
@@ -118,15 +127,25 @@ type BeszelAreaChartProps = Omit<AreaChartProps, "dataKey" | "curveType" | "with
const BeszelAreaChart = memo(
({
yAxisFormatter,
withXAxis = true,
yAxisDomain,
yAxisProps: yAxisPropsOverride,
xAxisProps: xAxisPropsOverride,
areaChartProps: areaChartPropsOverride,
type = "default",
...props
}: BeszelAreaChartProps) => {
const mergedXAxis = useMemo(
() => ({
interval: "preserveEnd" as const,
...xAxisPropsOverride,
}),
[xAxisPropsOverride],
);
const mergedYAxis = useMemo(() => {
const base = {
...yAxisBase,
width: 48,
width: 56,
tickMargin: 2,
tickFormatter: yAxisFormatter,
...yAxisPropsOverride,
@@ -147,10 +166,12 @@ const BeszelAreaChart = memo(
type={type}
strokeWidth={1}
fillOpacity={0.2}
withXAxis
withXAxis={withXAxis}
withYAxis
w="100%"
areaChartProps={{ margin: chartMargin, ...areaChartPropsOverride }}
style={chartStyle}
xAxisProps={mergedXAxis}
yAxisProps={mergedYAxis}
{...props}
/>
@@ -162,17 +183,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[]>([]);
@@ -198,13 +220,19 @@ export const useContainerNames = (containerStats: BeszelContainerStatsRecord[] |
type ContainerExtractor = (container: BeszelContainerStatsRecord["stats"][number] | undefined) => number;
// c = CPU (%), m = memory (MB), b = bandwidth [sent,recv] (bytes/s), ns/nr = legacy net (bytes/s)
const MB = 1024 * 1024;
// c = CPU (%), m = memory (MiB), b = bandwidth [sent,recv] (bytes/s), ns/nr = legacy net (MiB/s)
const MEBIBYTE = 1024 * 1024;
const GIBIBYTE = 1024 * MEBIBYTE;
export const normalizeBeszelByteRate = (
bytesPerSecond: number | undefined,
legacyMebibytesPerSecond: number | undefined,
): number => bytesPerSecond ?? (legacyMebibytesPerSecond ?? 0) * MEBIBYTE;
const defaultContainerExtractors: Record<string, ContainerExtractor> = {
cpu: (c) => c?.c ?? 0,
memory: (c) => (c?.m ?? 0) * MB,
network: (c) => (c?.b ? c.b[0] + c.b[1] : (c?.ns ?? 0) + (c?.nr ?? 0)),
memory: (c) => (c?.m ?? 0) * MEBIBYTE,
network: (c) => normalizeBeszelByteRate(c?.b?.[0], c?.ns) + normalizeBeszelByteRate(c?.b?.[1], c?.nr),
};
export const useDiskChartData = (
@@ -212,10 +240,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,35 +252,68 @@ 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),
rawTime: record.created,
[rootSeriesName]: record.stats.du,
[rootSeriesName]: record.stats.du * GIBIBYTE,
};
const efs = record.stats.efs ?? {};
for (const path of efsPaths) {
point[path] = efs[path]?.du ?? 0;
point[path] = (efs[path]?.du ?? 0) * GIBIBYTE;
}
return point;
});
return padTimeGrid(mapped, timePeriod);
return padTimeGrid(mapped, timePeriod, locale);
};
export const useDiskIOChartData = (
systemStats: BeszelSystemStatsRecord[] | undefined,
efsPaths: string[],
rootReadSeriesName: string,
rootWriteSeriesName: string,
readLabel: string,
writeLabel: string,
timePeriod: BeszelTimePeriod = "1h",
locale = "en-US",
) =>
useMemo(() => {
if (!systemStats?.length) return [];
const { fmt, ordered } = prepareRecords(systemStats, timePeriod, locale);
const mapped = ordered.map((record) => {
const point: Record<string, unknown> = {
time: fmt(record.created),
rawTime: record.created,
[rootReadSeriesName]: normalizeBeszelByteRate(record.stats.dio?.[0], record.stats.dr),
[rootWriteSeriesName]: normalizeBeszelByteRate(record.stats.dio?.[1], record.stats.dw),
};
const efs = record.stats.efs ?? {};
for (const path of efsPaths) {
const filesystem = efs[path];
point[`${path} ${readLabel}`] = normalizeBeszelByteRate(filesystem?.rb, filesystem?.r);
point[`${path} ${writeLabel}`] = normalizeBeszelByteRate(filesystem?.wb, filesystem?.w);
}
return point;
});
return padTimeGrid(mapped, timePeriod, locale);
}, [systemStats, efsPaths, rootReadSeriesName, rootWriteSeriesName, readLabel, writeLabel, timePeriod, locale]);
export const useDockerChartData = (
containerStats: BeszelContainerStatsRecord[] | undefined,
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 +322,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,13 @@
.target {
position: relative;
z-index: 2;
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 { useI18n } 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 = useI18n("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="xs" 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>
);
}
@@ -1,17 +1,23 @@
import { describe, expect, test } from "vitest";
import { chartAxisFormatters, formatGB, getProgressTrackSize } from "./format";
import { formatByteRate, formatBytes } from "@homarr/common";
import { createByteChartAxisFormatters, getProgressTrackSize } from "./format";
const GIBIBYTE = 1024 ** 3;
const binaryAxisFormatters = createByteChartAxisFormatters(
(bytes) => formatBytes(bytes, { unit: "binary" }),
(bytes) => formatByteRate(bytes, { unit: "binary" }),
);
describe("Beszel storage formatting", () => {
test("keeps smaller values in GB", () => {
expect(formatGB(455.81)).toBe("455.81 GB");
expect(chartAxisFormatters.gb(455.81)).toBe("456G");
test("formats canonical byte values with shared binary units", () => {
expect(binaryAxisFormatters.bytes(455.81 * GIBIBYTE)).toBe("455.8GiB");
});
test("promotes large GiB values to TB", () => {
expect(formatGB(3323)).toBe("3.25 TB");
expect(formatGB(3936.86)).toBe("3.84 TB");
expect(chartAxisFormatters.gb(3323)).toBe("3.2T");
test("promotes large canonical byte values to TiB", () => {
expect(binaryAxisFormatters.bytes(3323 * GIBIBYTE)).toBe("3.2TiB");
expect(binaryAxisFormatters.bytes(3936.86 * GIBIBYTE)).toBe("3.8TiB");
});
test("maps progress sizes consistently", () => {
+10 -38
View File
@@ -5,51 +5,23 @@ import relativeTime from "dayjs/plugin/relativeTime";
dayjs.extend(duration);
dayjs.extend(relativeTime);
const byteUnits = ["B", "KB", "MB", "GB", "TB"] as const;
const rateUnits = ["B/s", "KB/s", "MB/s", "GB/s"] as const;
const formatScaled = (value: number, units: readonly string[], zeroLabel: string): string => {
if (!value || !Number.isFinite(value)) return zeroLabel;
const i = Math.max(0, Math.min(Math.floor(Math.log(Math.abs(value)) / Math.log(1024)), units.length - 1));
const scaled = value / 1024 ** i;
const decimals = scaled < 10 ? 2 : 1;
return `${scaled.toFixed(decimals)} ${units[i] ?? ""}`;
const compactByteLabel = (value: number, formatter: (value: number) => string): string => {
if (!value || !Number.isFinite(value)) return "0";
return formatter(Number(value)).replace(".0 ", "").replace(" ", "");
};
const formatScaledCompact = (value: number, units: readonly string[], zeroLabel: string): string => {
if (!value || !Number.isFinite(value)) return zeroLabel;
const i = Math.max(0, Math.min(Math.floor(Math.log(Math.abs(value)) / Math.log(1024)), units.length - 1));
const scaled = value / 1024 ** i;
return `${scaled < 10 ? scaled.toFixed(1) : Math.round(scaled)}${units[i] ?? ""}`;
};
export const formatByteRate = (value: number): string => formatScaled(value, rateUnits, "0 B/s");
export const formatStorageBytes = (bytes: number): string => formatScaled(bytes, byteUnits, "0 B");
export const formatGB = (value: number): string => {
if (!value || !Number.isFinite(value)) return "0 GB";
if (Math.abs(value) >= 1024) return `${(value / 1024).toFixed(2)} TB`;
return `${value.toFixed(2)} GB`;
};
export const createByteChartAxisFormatters = (
formatBytes: (bytes: number) => string,
formatByteRate: (bytes: number) => string,
) => ({
bytes: (value: number) => compactByteLabel(value, formatBytes),
rate: (value: number) => compactByteLabel(value, formatByteRate),
});
export const formatPercent = (value: number): string => `${value.toFixed(1)}%`;
export const getProgressTrackSize = (size: "xs" | "sm"): number => (size === "xs" ? 6 : 9);
export const chartAxisFormatters = {
percent: formatPercent,
gb: (value: number) => {
const numericValue = Number(value);
if (!numericValue) return "0";
if (Math.abs(numericValue) >= 1024) return `${(numericValue / 1024).toFixed(1)}T`;
return `${numericValue.toFixed(0)}G`;
},
mb: (value: number) => (!value ? "0" : `${Number(value).toFixed(0)}M`),
bytes: (value: number) => formatScaledCompact(Number(value), byteUnits, "0"),
rate: (value: number) => formatScaledCompact(Number(value), rateUnits, "0"),
} as const;
export const formatUptime = (seconds: number): string => dayjs.duration(seconds, "seconds").humanize();
export const formatTemp = (celsius: number | null, fahrenheit: boolean): string => {
+2 -2
View File
@@ -4,7 +4,7 @@ import { useMemo } from "react";
import type { BeszelSystemRow } from "./types";
type SystemRowWithKey = BeszelSystemRow & { _key: string };
type SystemRowWithKey = BeszelSystemRow & { rowKey: string };
export const useBeszelFilteredSystems = (
results: { integrationId: string; systems: BeszelSystemRow[] }[],
@@ -13,7 +13,7 @@ export const useBeszelFilteredSystems = (
const allSystems = useMemo(
() =>
results
.flatMap((r) => r.systems.map((s) => ({ ...s, _key: `${r.integrationId}:${s.id}` })))
.flatMap((r) => r.systems.map((s) => ({ ...s, rowKey: `${r.integrationId}:${s.id}` })))
.toSorted((a, b) => a.name.localeCompare(b.name)),
[results],
);
@@ -1,44 +1,37 @@
"use client";
import { useMemo } from "react";
import { Button, Center, Group, SimpleGrid, Skeleton, Stack, Text } from "@mantine/core";
import { Button, Center, SimpleGrid, Stack, Text } from "@mantine/core";
import { IconPlugConnectedX, IconServerOff } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { invariantTechnicalLabels } from "@homarr/definitions";
import type { BeszelContainerStatsRecord, BeszelSystemStatsRecord } from "@homarr/integrations/types";
import { useScopedI18n } from "@homarr/translation/client";
import { useByteFormatter } from "@homarr/settings";
import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
import { zoomCompensatedSize } from "@homarr/ui";
import { getUsableWidgetQueryData } from "../../common/query-state";
import { WidgetQueryLoadingState } from "../../common/query-state-indicator";
import { containerColors } from "./colors";
import type { BeszelTimePeriod } from "./chart";
import {
BeszelChartPanel,
CPU_Y_AXIS_DOMAIN,
normalizeBeszelByteRate,
useContainerNames,
useDiskChartData,
useDiskIOChartData,
useDockerChartData,
useSystemChartData,
} from "./chart";
import { chartAxisFormatters, formatByteRate, formatGB, formatPercent, formatStorageBytes } from "./format";
import { createByteChartAxisFormatters, formatPercent } from "./format";
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 ChartSkeleton = () => (
<Stack gap={4} style={{ minWidth: 0 }}>
<Group gap="xs">
<Skeleton h={14} w={72} radius="sm" />
<Skeleton h={10} w={110} radius="sm" />
</Group>
<Skeleton h={CHART_HEIGHT} radius="sm" />
</Stack>
);
const MEBIBYTE = 1024 * 1024;
const GIBIBYTE = 1024 * MEBIBYTE;
export interface BeszelStatsVisibility {
cpu: boolean;
@@ -57,9 +50,21 @@ interface BeszelStatsViewProps {
timePeriod: BeszelTimePeriod;
visibility: BeszelStatsVisibility;
columns: 1 | 2;
availableHeight?: number;
showXAxis?: boolean;
onSwitchToHistorical?: () => void;
}
const MIN_CHART_HEIGHT = 64;
const PANEL_ROW_OVERHEAD = 25;
const GRID_ROW_GAP = 16;
const computeChartHeight = (availableHeight: number | undefined, rowCount: number) => {
if (availableHeight === undefined || rowCount <= 0) return CHART_HEIGHT;
const perRow = (availableHeight - (rowCount - 1) * GRID_ROW_GAP) / rowCount - PANEL_ROW_OVERHEAD;
return Math.max(MIN_CHART_HEIGHT, Math.floor(perRow));
};
const whenVisible = <T,>(visible: boolean, data: T | undefined) => (visible ? data : undefined);
export function BeszelStatsView({
@@ -68,16 +73,35 @@ export function BeszelStatsView({
timePeriod,
visibility,
columns,
availableHeight,
showXAxis = true,
onSwitchToHistorical,
}: BeszelStatsViewProps) {
const t = useScopedI18n("widget.beszelSystemStats");
const t = useI18n("widget.beszelSystemStats");
const tBeszel = useI18n("widget.beszel");
const locale = useCurrentIntlLocale();
const { formatByteRate, formatBytes } = useByteFormatter();
const byteAxisFormatters = useMemo(
() => createByteChartAxisFormatters(formatBytes, formatByteRate),
[formatByteRate, formatBytes],
);
const tooltips = useMemo(
() => ({
percent: makeTooltipProps(formatPercent),
bytes: makeTooltipProps(formatBytes, true),
rate: makeTooltipProps(formatByteRate, true),
percentTotal: makeTooltipProps(formatPercent, true),
bytesTotal: makeTooltipProps(formatBytes, true),
}),
[formatByteRate, formatBytes],
);
const showDocker = visibility.dockerCpu || visibility.dockerMemory || visibility.dockerNetwork;
const isLive = timePeriod === "1m";
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:
@@ -87,18 +111,14 @@ export function BeszelStatsView({
const mappers = useMemo(
() => ({
cpu: (s: { cpu: number }) => ({ [t("chart.cpu.series")]: s.cpu }),
cpu: (s: { cpu: number }) => ({ [invariantTechnicalLabels.cpu]: s.cpu }),
memory: (s: { mu: number; mb: number }) => ({
[t("chart.memory.series")]: s.mu,
[t("chart.memory.cache")]: s.mb ?? 0,
}),
diskIO: (s: { dr?: number; dw?: number }) => ({
[t("chart.diskIO.read")]: (s.dr ?? 0) * MB,
[t("chart.diskIO.write")]: (s.dw ?? 0) * MB,
[t("chart.memory.series")]: s.mu * GIBIBYTE,
[t("chart.memory.cache")]: (s.mb ?? 0) * GIBIBYTE,
}),
network: (s: { ns?: number; nr?: number; b?: [number, number] }) => ({
[t("chart.network.sent")]: s.b?.[0] ?? s.ns ?? 0,
[t("chart.network.recv")]: s.b?.[1] ?? s.nr ?? 0,
[t("chart.network.sent")]: normalizeBeszelByteRate(s.b?.[0], s.ns),
[t("chart.network.recv")]: normalizeBeszelByteRate(s.b?.[1], s.nr),
}),
}),
[t],
@@ -106,15 +126,11 @@ export function BeszelStatsView({
const series = useMemo(
() => ({
cpu: [{ name: t("chart.cpu.series"), color: "teal.6" }],
cpu: [{ name: invariantTechnicalLabels.cpu, color: "teal.6" }],
memory: [
{ name: t("chart.memory.series"), color: "teal.5" },
{ name: t("chart.memory.cache"), color: "teal.8" },
],
diskIO: [
{ name: t("chart.diskIO.write"), color: "orange.6" },
{ name: t("chart.diskIO.read"), color: "blue.6" },
],
network: [
{ name: t("chart.network.sent"), color: "blue.6" },
{ name: t("chart.network.recv"), color: "teal.6" },
@@ -125,10 +141,19 @@ 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 networkData = useSystemChartData(
whenVisible(visibility.network, systemStats),
mappers.network,
timePeriod,
locale,
);
const efsPaths = useMemo(() => {
const paths = new Set<string>();
@@ -138,7 +163,27 @@ export function BeszelStatsView({
return [...paths];
}, [systemStats]);
const rootSeriesName = t("chart.disk.series");
const diskData = useDiskChartData(whenVisible(visibility.disk, systemStats), efsPaths, rootSeriesName, timePeriod);
const readLabel = t("chart.diskIO.read");
const writeLabel = t("chart.diskIO.write");
const rootReadSeriesName = `${rootSeriesName} ${readLabel}`;
const rootWriteSeriesName = `${rootSeriesName} ${writeLabel}`;
const diskData = useDiskChartData(
whenVisible(visibility.disk, systemStats),
efsPaths,
rootSeriesName,
timePeriod,
locale,
);
const diskIOData = useDiskIOChartData(
whenVisible(visibility.diskIO, systemStats),
efsPaths,
rootReadSeriesName,
rootWriteSeriesName,
readLabel,
writeLabel,
timePeriod,
locale,
);
const diskSeries = useMemo(
() => [
{ name: rootSeriesName, color: "grape.6" },
@@ -149,6 +194,23 @@ export function BeszelStatsView({
],
[efsPaths, rootSeriesName],
);
const diskIOSeries = useMemo(
() => [
{ name: rootWriteSeriesName, color: "orange.6" },
{ name: rootReadSeriesName, color: "blue.6" },
...efsPaths.flatMap((path, index) => [
{
name: `${path} ${writeLabel}`,
color: containerColors[(index * 2 + 2) % containerColors.length] as string,
},
{
name: `${path} ${readLabel}`,
color: containerColors[(index * 2 + 3) % containerColors.length] as string,
},
]),
],
[efsPaths, readLabel, rootReadSeriesName, rootWriteSeriesName, writeLabel],
);
const containerNames = useContainerNames(whenVisible(showDocker, containerStats));
const containerSeries = useMemo(
@@ -161,27 +223,28 @@ 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}>
<Stack align="center" gap="sm" p="md">
<IconPlugConnectedX size={40} />
<IconPlugConnectedX style={zoomCompensatedSize(40)} />
<Text size="sm" c="dimmed" ta="center">
{t("error.liveConnectionFailed")}
</Text>
@@ -196,42 +259,51 @@ export function BeszelStatsView({
}
if (!activeStats) {
const visibleChartCount = Object.values(visibility).filter(Boolean).length;
return (
<SimpleGrid cols={columns} spacing="md" aria-label="Loading system statistics">
{Array.from({ length: visibleChartCount }, (_, index) => (
<ChartSkeleton key={index} />
))}
</SimpleGrid>
);
return <WidgetQueryLoadingState />;
}
if ("error" in activeStats && activeStats.error) {
return (
<Center h={CHART_HEIGHT}>
<Stack align="center" gap="xs">
<IconServerOff size={24} opacity={0.5} />
<IconServerOff style={zoomCompensatedSize(24)} opacity={0.5} />
<Text size="sm" c="dimmed">
{activeStats.error}
{tBeszel("error.internalServerError")}
</Text>
</Stack>
</Center>
);
}
const renderedPanelCount =
(visibility.cpu && cpuData.length > 0 ? 1 : 0) +
(visibility.memory && memoryData.length > 0 ? 1 : 0) +
(visibility.disk && diskData.length > 0 ? 1 : 0) +
(visibility.diskIO && diskIOData.length > 0 ? 1 : 0) +
(visibility.network && networkData.length > 0 ? 1 : 0) +
(showDocker && containerSeries.length > 0
? (visibility.dockerCpu && dockerCpuData.length > 0 ? 1 : 0) +
(visibility.dockerMemory && dockerMemoryData.length > 0 ? 1 : 0) +
(visibility.dockerNetwork && dockerNetworkData.length > 0 ? 1 : 0)
: 0);
const effectiveColumns = Math.min(columns, Math.max(1, renderedPanelCount)) as 1 | 2;
const rowCount = Math.ceil(renderedPanelCount / effectiveColumns);
const chartHeight = computeChartHeight(availableHeight, rowCount);
return (
<SimpleGrid cols={columns} spacing="md">
<SimpleGrid cols={effectiveColumns} spacing="md">
{visibility.cpu && cpuData.length > 0 && (
<BeszelChartPanel
title={t("chart.cpu.title")}
subtitle={t("chart.cpu.subtitle")}
chartProps={{
h: CHART_HEIGHT,
h: chartHeight,
withXAxis: showXAxis,
data: cpuData,
series: series.cpu,
yAxisFormatter: chartAxisFormatters.percent,
yAxisFormatter: formatPercent,
yAxisDomain: CPU_Y_AXIS_DOMAIN,
tooltipProps: tooltipPercent,
tooltipProps: tooltips.percent,
}}
/>
)}
@@ -240,12 +312,13 @@ export function BeszelStatsView({
title={t("chart.memory.title")}
subtitle={t("chart.memory.subtitle")}
chartProps={{
h: CHART_HEIGHT,
h: chartHeight,
withXAxis: showXAxis,
data: memoryData,
type: "stacked",
series: series.memory,
yAxisFormatter: chartAxisFormatters.gb,
tooltipProps: tooltipGB,
yAxisFormatter: byteAxisFormatters.bytes,
tooltipProps: tooltips.bytes,
}}
/>
)}
@@ -254,12 +327,13 @@ export function BeszelStatsView({
title={t("chart.disk.title")}
subtitle={t("chart.disk.subtitle")}
chartProps={{
h: CHART_HEIGHT,
h: chartHeight,
withXAxis: showXAxis,
data: diskData,
type: "stacked",
series: diskSeries,
yAxisFormatter: chartAxisFormatters.gb,
tooltipProps: tooltipGB,
yAxisFormatter: byteAxisFormatters.bytes,
tooltipProps: tooltips.bytes,
}}
/>
)}
@@ -268,11 +342,12 @@ export function BeszelStatsView({
title={t("chart.diskIO.title")}
subtitle={t("chart.diskIO.subtitle")}
chartProps={{
h: CHART_HEIGHT,
h: chartHeight,
withXAxis: showXAxis,
data: diskIOData,
series: series.diskIO,
yAxisFormatter: chartAxisFormatters.rate,
tooltipProps: tooltipRate,
series: diskIOSeries,
yAxisFormatter: byteAxisFormatters.rate,
tooltipProps: tooltips.rate,
}}
/>
)}
@@ -281,11 +356,12 @@ export function BeszelStatsView({
title={t("chart.network.title")}
subtitle={t("chart.network.subtitle")}
chartProps={{
h: CHART_HEIGHT,
h: chartHeight,
withXAxis: showXAxis,
data: networkData,
series: series.network,
yAxisFormatter: chartAxisFormatters.rate,
tooltipProps: tooltipRate,
yAxisFormatter: byteAxisFormatters.rate,
tooltipProps: tooltips.rate,
}}
/>
)}
@@ -296,12 +372,13 @@ export function BeszelStatsView({
title={t("chart.dockerCpu.title")}
subtitle={t("chart.dockerCpu.subtitle")}
chartProps={{
h: CHART_HEIGHT,
h: chartHeight,
withXAxis: showXAxis,
data: dockerCpuData,
type: "stacked",
series: containerSeries,
yAxisFormatter: chartAxisFormatters.percent,
tooltipProps: tooltipPercentTotal,
yAxisFormatter: formatPercent,
tooltipProps: tooltips.percentTotal,
}}
/>
)}
@@ -310,12 +387,13 @@ export function BeszelStatsView({
title={t("chart.dockerMemory.title")}
subtitle={t("chart.dockerMemory.subtitle")}
chartProps={{
h: CHART_HEIGHT,
h: chartHeight,
withXAxis: showXAxis,
data: dockerMemoryData,
type: "stacked",
series: containerSeries,
yAxisFormatter: chartAxisFormatters.bytes,
tooltipProps: tooltipBytesTotal,
yAxisFormatter: byteAxisFormatters.bytes,
tooltipProps: tooltips.bytesTotal,
}}
/>
)}
@@ -324,11 +402,12 @@ export function BeszelStatsView({
title={t("chart.dockerNetwork.title")}
subtitle={t("chart.dockerNetwork.subtitle")}
chartProps={{
h: CHART_HEIGHT,
h: chartHeight,
withXAxis: showXAxis,
data: dockerNetworkData,
series: containerSeries,
yAxisFormatter: chartAxisFormatters.rate,
tooltipProps: tooltipRate,
yAxisFormatter: byteAxisFormatters.rate,
tooltipProps: tooltips.rate,
}}
/>
)}
@@ -1,10 +1,9 @@
"use client";
import type { CSSProperties } from "react";
import dayjs from "dayjs";
import localizedFormat from "dayjs/plugin/localizedFormat";
import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
dayjs.extend(localizedFormat);
import { formatLocalizedDate } from "../../common/locale";
const styles: Record<string, CSSProperties> = {
wrapper: {
@@ -12,7 +11,7 @@ const styles: Record<string, CSSProperties> = {
border: "1px solid var(--mantine-color-dark-4)",
borderRadius: 6,
padding: "6px 10px",
fontSize: 12,
fontSize: "var(--mantine-font-size-xs)",
lineHeight: 1.5,
pointerEvents: "none",
overflowX: "hidden",
@@ -54,6 +53,8 @@ interface BeszelTooltipProps {
}
const BeszelTooltipContent = ({ active, label, payload, formatter, showTotal }: BeszelTooltipProps) => {
const locale = useCurrentIntlLocale();
const t = useI18n("common");
const isActive = active && (payload?.length ?? 0) > 0;
if (!isActive) return null;
@@ -66,7 +67,9 @@ const BeszelTooltipContent = ({ 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);
return (
@@ -81,7 +84,7 @@ const BeszelTooltipContent = ({ 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>
)}
@@ -100,6 +103,6 @@ export const makeTooltipProps = (formatter: (v: number) => string, showTotal = f
showTotal={showTotal}
/>
),
wrapperStyle: { zIndex: 10000, pointerEvents: "none" as const },
wrapperStyle: { pointerEvents: "none" as const },
isAnimationActive: false,
});
+187 -19
View File
@@ -1,30 +1,198 @@
"use client";
import { Button } from "@mantine/core";
import { useEffect, useMemo, useRef, useState } from "react";
import {
Avatar,
Combobox,
Group,
Loader,
PillsInput,
ScrollArea,
Stack,
Text,
ThemeIcon,
useCombobox,
} from "@mantine/core";
import { IconCheck, IconLink, IconPlus } from "@tabler/icons-react";
import { useModalAction } from "@homarr/modals";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { useI18n } from "@homarr/translation/client";
import type { SortableItemListInput } from "../options";
import { AppSelectModal } from "./app-select-modal";
import { createDirectBookmark, getBookmarkFaviconUrl, splitBookmarkUrls } from "./bookmark-item";
import type { BookmarkItem } from "./bookmark-item";
export const BookmarkAddButton: SortableItemListInput<
{
name: string;
description: string | null;
id: string;
iconUrl: string;
href: string | null;
pingUrl: string | null;
},
string
>["AddButton"] = ({ addItem, values }) => {
const { openModal } = useModalAction(AppSelectModal);
const t = useI18n();
type SelectableApp = RouterOutputs["app"]["selectable"][number];
export type BookmarkSelectionItem = SelectableApp | BookmarkItem;
const createOptionValue = "$create-url";
export const BookmarkAddButton: SortableItemListInput<BookmarkSelectionItem, string>["AddButton"] = ({
addItem,
migrateItems,
values,
initialOptions,
}) => {
const t = useI18n("widget.bookmarks.option.items.apps");
const tCommon = useI18n("common");
const tSelect = useI18n("app.action.select");
const { data: apps = [], isPending, error } = clientApi.app.selectable.useQuery();
const [search, setSearch] = useState("");
const combobox = useCombobox({
onDropdownClose: () => combobox.resetSelectedOption(),
onDropdownOpen: () => combobox.updateSelectedOptionIndex("active"),
});
const selectedValues = useMemo(() => new Set(values), [values]);
const migrationStartedRef = useRef(false);
const legacyUrls = useMemo(() => {
if (!Array.isArray(initialOptions.customUrls)) return [];
return initialOptions.customUrls.filter((value): value is string => typeof value === "string");
}, [initialOptions.customUrls]);
useEffect(() => {
if (migrationStartedRef.current || legacyUrls.length === 0) return;
migrationStartedRef.current = true;
const legacyItems = legacyUrls.flatMap((url) => createDirectBookmark(url) ?? []);
migrateItems(legacyItems, { customUrls: [] });
}, [legacyUrls, migrateItems]);
const normalizedSearch = search.trim().toLowerCase();
const filteredApps = apps.filter((app) => {
if (selectedValues.has(app.id)) return false;
if (normalizedSearch.length === 0) return true;
return app.name.toLowerCase().includes(normalizedSearch) || app.href?.toLowerCase().includes(normalizedSearch);
});
const pendingUrl = createDirectBookmark(search);
const addUrls = (input: string) => {
const nextValues = new Set(values);
for (const value of splitBookmarkUrls(input)) {
const item = createDirectBookmark(value);
if (!item || nextValues.has(item.id)) continue;
addItem(item);
nextValues.add(item.id);
}
setSearch("");
combobox.closeDropdown();
};
const handleOptionSubmit = (value: string) => {
if (value === createOptionValue) {
addUrls(search);
return;
}
const app = apps.find((candidate) => candidate.id === value);
if (app) addItem(app);
setSearch("");
};
const options = filteredApps.map((app) => (
<Combobox.Option value={app.id} key={app.id}>
<Group gap="sm" wrap="nowrap">
<Avatar src={app.iconUrl} name={app.name} color="gray" size={28} radius="sm">
<IconLink size={16} />
</Avatar>
<Stack gap={0} miw={0} flex={1}>
<Text size="sm" fw={600} truncate>
{app.name}
</Text>
<Text size="xs" c="dimmed" truncate>
{app.href}
</Text>
</Stack>
<IconPlus size={16} aria-hidden />
</Group>
</Combobox.Option>
));
return (
<Button onClick={() => openModal({ onSelect: addItem, presentAppIds: values })}>
{t("widget.bookmarks.option.items.add")}
</Button>
<Combobox
store={combobox}
onOptionSubmit={handleOptionSubmit}
position="bottom-start"
middlewares={{ flip: true, shift: true }}
styles={{
dropdown: { overflow: "hidden" },
option: { borderRadius: "var(--mantine-radius-sm)" },
}}
>
<Combobox.DropdownTarget>
<PillsInput
label={t("label")}
description={t("description")}
error={error ? tCommon("error") : undefined}
onClick={() => combobox.openDropdown()}
leftSection={
<ThemeIcon variant="light" size="sm" radius="xl">
<IconLink size={14} />
</ThemeIcon>
}
rightSection={isPending ? <Loader size="xs" /> : undefined}
styles={{ input: { minHeight: 44 } }}
>
<Combobox.EventsTarget>
<PillsInput.Field
value={search}
placeholder={t("placeholder")}
onFocus={() => combobox.openDropdown()}
onBlur={() => combobox.closeDropdown()}
onChange={(event) => {
setSearch(event.currentTarget.value);
combobox.openDropdown();
combobox.updateSelectedOptionIndex();
}}
onPaste={(event) => {
const pastedValue = event.clipboardData.getData("text");
if (splitBookmarkUrls(pastedValue).length < 2) return;
event.preventDefault();
addUrls(pastedValue);
}}
onKeyDown={(event) => {
if (event.key !== "Enter") return;
if (!pendingUrl) return;
event.preventDefault();
addUrls(search);
}}
/>
</Combobox.EventsTarget>
</PillsInput>
</Combobox.DropdownTarget>
<Combobox.Dropdown>
<ScrollArea.Autosize mah={280}>
<Combobox.Options>
{pendingUrl && !selectedValues.has(pendingUrl.id) ? (
<Combobox.Option value={createOptionValue}>
<Group gap="sm" wrap="nowrap">
<Avatar src={getBookmarkFaviconUrl(pendingUrl.href)} size={28} radius="sm" color="gray">
<IconLink size={16} />
</Avatar>
<Stack gap={0} miw={0} flex={1}>
<Text size="sm" fw={600} truncate>
{t("addUrl")}
</Text>
<Text size="xs" c="dimmed" truncate>
{pendingUrl.href}
</Text>
</Stack>
<IconCheck size={16} aria-hidden />
</Group>
</Combobox.Option>
) : null}
{options}
{!pendingUrl && options.length === 0 ? <Combobox.Empty>{tSelect("notFound")}</Combobox.Empty> : null}
</Combobox.Options>
</ScrollArea.Autosize>
<Combobox.Footer>
<Text size="xs" c="dimmed">
{t("hint")}
</Text>
</Combobox.Footer>
</Combobox.Dropdown>
</Combobox>
);
};
@@ -1,109 +0,0 @@
"use client";
import { memo, useState } from "react";
import type { SelectProps } from "@mantine/core";
import { Group, Loader, Select, Stack } from "@mantine/core";
import { IconCheck } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { useForm } from "@homarr/form";
import { createModal, ModalFormFooter, modalSizeForm } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client";
interface InnerProps {
presentAppIds: string[];
onSelect: (props: RouterOutputs["app"]["selectable"][number]) => void | Promise<void>;
confirmLabel?: string;
}
interface AppSelectFormType {
id: string;
}
export const AppSelectModal = createModal<InnerProps>(({ actions, innerProps }) => {
const t = useI18n();
const { data: apps, isPending } = clientApi.app.selectable.useQuery();
const [loading, setLoading] = useState(false);
const form = useForm<AppSelectFormType>();
const handleSubmitAsync = async (values: AppSelectFormType) => {
const currentApp = apps?.find((app) => app.id === values.id);
if (!currentApp) return;
setLoading(true);
await innerProps.onSelect(currentApp);
setLoading(false);
actions.closeModal();
};
const confirmLabel = innerProps.confirmLabel ?? t("common.action.add");
const currentApp = apps?.find((app) => app.id === form.values.id);
return (
<form onSubmit={form.onSubmit((values) => void handleSubmitAsync(values))}>
<Stack>
<Select
{...form.getInputProps("id")}
label={t("app.action.select.label")}
searchable
clearable
data-autofocus
leftSection={<MemoizedLeftSection isPending={isPending} currentApp={currentApp} />}
nothingFoundMessage={t("app.action.select.notFound")}
renderOption={renderSelectOption}
limit={5}
data={
apps
?.filter((app) => !innerProps.presentAppIds.includes(app.id))
.map((app) => ({
label: app.name,
value: app.id,
iconUrl: app.iconUrl,
})) ?? []
}
/>
<ModalFormFooter onCancel={actions.closeModal} submitLabel={confirmLabel} loading={loading} />
</Stack>
</form>
);
}).withOptions({
defaultTitle: (t) => t("app.action.select.label"),
size: modalSizeForm,
});
const iconProps = {
stroke: 1.5,
color: "currentColor",
opacity: 0.6,
size: 18,
};
const renderSelectOption: SelectProps["renderOption"] = ({ option, checked }) => (
<Group flex="1" gap="xs">
{"iconUrl" in option && typeof option.iconUrl === "string" ? (
<img width={20} height={20} src={option.iconUrl} alt={option.label} />
) : null}
{option.label}
{checked && <IconCheck style={{ marginInlineStart: "auto" }} {...iconProps} />}
</Group>
);
interface LeftSectionProps {
isPending: boolean;
currentApp: RouterOutputs["app"]["selectable"][number] | undefined;
}
const size = 20;
const LeftSection = ({ isPending, currentApp }: LeftSectionProps) => {
if (isPending) {
return <Loader size={size} />;
}
if (currentApp) {
return <img width={size} height={size} src={currentApp.iconUrl} alt={currentApp.name} />;
}
return null;
};
const MemoizedLeftSection = memo(LeftSection);
@@ -0,0 +1,66 @@
import { getSafeApplicationUrl } from "../common/application-url";
export const directBookmarkPrefix = "url:";
export interface BookmarkItem {
id: string;
name: string;
description: string | null;
iconUrl?: string;
href: string | null;
}
export const normalizeBookmarkUrl = (value: string): string | undefined => {
const trimmedValue = value.trim();
if (trimmedValue.length === 0) return undefined;
const directUrl = getSafeApplicationUrl(trimmedValue);
if (directUrl) return directUrl;
if (/^(?:localhost(?::\d+)?|(?:[\w-]+\.)+[\w-]+(?::\d+)?)(?:[/?#].*)?$/i.test(trimmedValue)) {
return getSafeApplicationUrl(`https://${trimmedValue}`);
}
return undefined;
};
export const getDirectBookmarkValue = (url: string) => `${directBookmarkPrefix}${url}`;
export const getDirectBookmarkUrl = (value: string): string | undefined => {
if (!value.startsWith(directBookmarkPrefix)) return undefined;
return normalizeBookmarkUrl(value.slice(directBookmarkPrefix.length));
};
export const getBookmarkFaviconUrl = (href: string | null): string | undefined => {
const safeUrl = getSafeApplicationUrl(href);
if (!safeUrl) return undefined;
const url = new URL(safeUrl);
return `${url.origin}/favicon.ico`;
};
export const createDirectBookmark = (url: string): BookmarkItem | undefined => {
const href = normalizeBookmarkUrl(url);
if (!href) return undefined;
const parsedUrl = new URL(href);
return {
id: getDirectBookmarkValue(href),
name: parsedUrl.hostname,
description: null,
href,
};
};
export const splitBookmarkUrls = (value: string) =>
value.split(/\r?\n+/).flatMap((line) => {
const trimmedLine = line.trim();
if (trimmedLine.length === 0) return [];
const commaSeparatedValues = trimmedLine.split(",").map((part) => part.trim());
if (commaSeparatedValues.length > 1 && commaSeparatedValues.every((part) => normalizeBookmarkUrl(part))) {
return commaSeparatedValues;
}
return [trimmedLine];
});
@@ -1,15 +0,0 @@
.card:hover {
background-color: var(--mantine-color-primaryColor-light-hover);
}
[data-mantine-color-scheme="light"] .card-grid {
background-color: var(--mantine-color-gray-1);
}
[data-mantine-color-scheme="dark"] .card-grid {
background-color: var(--mantine-color-dark-7);
}
.card:hover > div > div.bookmarkIcon {
background-color: var(--mantine-color-iconColor-filled-hover);
}
@@ -0,0 +1,427 @@
"use client";
import type { CSSProperties } from "react";
import { useMemo, useState } from "react";
import {
Avatar,
Box,
Card,
Center,
Grid,
Group,
ScrollArea,
SimpleGrid,
Stack,
Text,
ThemeIcon,
Tooltip,
} from "@mantine/core";
import { useReducedMotion } from "@mantine/hooks";
import { IconArrowUpRight, IconBookmark, IconLink } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useRequiredBoard } from "@homarr/boards/context";
import { useRegisterSpotlightContextResults } from "@homarr/spotlight";
import { useI18n } from "@homarr/translation/client";
import { iconSizes, zoomCompensatedSize } from "@homarr/ui";
import { getSafeAppHref, SAFE_NEW_TAB_REL } from "../common/application-url";
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state";
import { WidgetQueryLoadingState } from "../common/query-state-indicator";
import type { WidgetComponentProps } from "../definition";
import { createDirectBookmark, getBookmarkFaviconUrl, getDirectBookmarkUrl } from "./bookmark-item";
import type { BookmarkItem } from "./bookmark-item";
import { getBookmarkCardDisplay, getBookmarkDisplayPlan } from "./layout";
import type { BookmarkOrientation } from "./layout";
type BookmarkVariant = WidgetComponentProps<"bookmarks">["options"]["variant"];
export default function BookmarksWidget({
options,
itemId,
width,
height,
displayMode,
displayScale = 1,
}: WidgetComponentProps<"bookmarks">) {
const t = useI18n("widget.bookmarks");
const board = useRequiredBoard();
const advanced = displayMode === "advanced";
const appIds = useMemo(() => options.items.filter((value) => !getDirectBookmarkUrl(value)), [options.items]);
const appsQuery = clientApi.app.byIds.useQuery(appIds, {
select(selectedApps) {
return selectedApps;
},
});
const apps = getUsableWidgetQueryData(appsQuery) ?? [];
const appsById = new Map(apps.map((app) => [app.id, app]));
const configuredItems = options.items.flatMap((value) => {
const directUrl = getDirectBookmarkUrl(value);
if (directUrl) return createDirectBookmark(directUrl) ?? [];
return appsById.get(value) ?? [];
});
const configuredHrefs = new Set(configuredItems.map((item) => item.href));
const legacyItems = options.customUrls.flatMap((url) => {
const item = createDirectBookmark(url);
if (!item || configuredHrefs.has(item.href)) return [];
return [item];
});
const data = [...configuredItems, ...legacyItems];
let responsiveWidth = width;
let responsiveHeight = height;
if (!advanced && Number.isFinite(displayScale) && displayScale > 0) {
responsiveWidth *= displayScale;
responsiveHeight *= displayScale;
}
const showTitle = options.showTitle && options.title.length > 0;
let titleHeight = 0;
if (showTitle) {
// Preserve the existing header allowance at 11px and account for the selected line height.
titleHeight = 36 + (options.titleSize - 11) * 1.2;
}
const contentHeight = Math.max(0, responsiveHeight - titleHeight);
let layoutWidth = responsiveWidth;
if (options.layout === "gridHorizontal") layoutWidth = width;
useRegisterSpotlightContextResults(
`bookmark-${itemId}`,
data.flatMap((bookmark) => {
const href = getSafeAppHref(bookmark.href);
if (!href) return [];
return [
{
id: bookmark.id,
dedupeKey: appsById.has(bookmark.id) ? `app:${bookmark.id}` : undefined,
name: bookmark.name,
icon: bookmark.iconUrl ?? IconLink,
interaction() {
return {
type: "link" as const,
href,
newTab: false,
};
},
},
];
}),
[data],
);
const plan = useMemo(
() =>
getBookmarkDisplayPlan({
advanced,
gap: bookmarkSpacingPixels[options.spacing],
height: contentHeight,
itemCount: data.length,
layout: options.layout,
width: layoutWidth,
}),
[advanced, contentHeight, data.length, layoutWidth, options.layout, options.spacing],
);
const cardDisplay = getBookmarkCardDisplay({
advanced,
hideHostname: options.hideHostname,
hideIcon: options.hideIcon,
hideTitle: options.hideTitle,
plan,
});
const cards = data.map((bookmark) => (
<BookmarkCard
key={bookmark.id}
bookmark={bookmark}
advanced={advanced}
orientation={cardDisplay.orientation}
showHostname={cardDisplay.showHostname}
showIcon={cardDisplay.showIcon}
showTitle={cardDisplay.showTitle}
openNewTab={options.openNewTab}
variant={options.variant}
withBorder={options.withBorder}
radius={board.itemRadius}
height={plan.itemHeight}
width={plan.horizontalScroll ? plan.itemWidth : undefined}
/>
));
if (appIds.length > 0 && isInitialWidgetQueryPending(appsQuery)) return <WidgetQueryLoadingState />;
const isTight = responsiveHeight < 120;
let compactStyle: CSSProperties | undefined;
if (!advanced && Number.isFinite(displayScale) && displayScale > 1) {
// Low-column boards zoom the canvas above 100%. Keep bookmark controls at their intended physical size.
// The spacing and font tokens are rebound here because their inherited values resolve at the canvas level.
compactStyle = {
"--board-canvas-ui-scale": 1 / displayScale,
"--mantine-scale": 1 / displayScale,
"--mantine-font-size-xxs": "calc(0.6875rem * var(--board-canvas-ui-scale))",
"--mantine-spacing-sm": "calc(0.75rem * var(--board-canvas-ui-scale))",
"--mantine-spacing-xs": "calc(0.625rem * var(--board-canvas-ui-scale))",
} as CSSProperties;
}
return (
<Stack h="100%" mih={0} gap={isTight ? 6 : "sm"} p={isTight ? 6 : "sm"} style={compactStyle}>
{showTitle && (
<Text fz={options.titleSize} fw={600} px={2} lh={1.2} lineClamp={1} style={{ flexShrink: 0 }}>
{options.title}
</Text>
)}
{data.length === 0 ? (
<Center flex={1}>
<Stack align="center" gap={6}>
<ThemeIcon variant="light" size="lg" radius="xl">
<IconBookmark style={iconSizes.xl} />
</ThemeIcon>
<Text size="sm" c="dimmed" ta="center">
{t("empty")}
</Text>
</Stack>
</Center>
) : (
<ScrollArea
scrollbars={plan.horizontalScroll ? "x" : "y"}
style={{ flex: 1, minHeight: 0 }}
styles={{ content: { height: "100%" } }}
>
<Box miw="100%" h="100%" pb={2}>
{plan.horizontalScroll ? (
<Group gap={plan.itemGap} wrap="nowrap" h="100%" align="center">
{cards}
</Group>
) : options.grow ? (
<Grid grow columns={plan.columns} gap={plan.itemGap}>
{cards.map((card) => (
<Grid.Col key={card.key} span={1}>
{card}
</Grid.Col>
))}
</Grid>
) : (
<SimpleGrid cols={plan.columns} spacing={plan.itemGap} verticalSpacing={plan.itemGap}>
{cards}
</SimpleGrid>
)}
</Box>
</ScrollArea>
)}
</Stack>
);
}
interface BookmarkCardProps {
advanced: boolean;
bookmark: BookmarkItem;
height: number;
openNewTab: boolean;
orientation: BookmarkOrientation;
radius: string;
showHostname: boolean;
showIcon: boolean;
showTitle: boolean;
variant: BookmarkVariant;
width?: number;
withBorder: boolean;
}
const BookmarkCard = ({
advanced,
bookmark,
height,
openNewTab,
orientation,
radius,
showHostname,
showIcon,
showTitle,
variant,
width,
withBorder,
}: BookmarkCardProps) => {
const [active, setActive] = useState(false);
const reduceMotion = useReducedMotion();
const href = getSafeAppHref(bookmark.href);
const hostname = bookmark.href ? getBookmarkHostname(bookmark.href) : undefined;
const iconUrl = bookmark.iconUrl ?? getBookmarkFaviconUrl(bookmark.href);
const iconOnly = orientation === "icon" || (!showTitle && !showHostname);
const background = getBookmarkBackground(variant, active);
const isUltraDense = height <= 32;
const isDense = height <= 48;
let avatarSize = 30;
if (isDense) avatarSize = 28;
if (isUltraDense) avatarSize = 20;
if (iconOnly && !isDense) avatarSize = 32;
if (advanced) avatarSize = 42;
let padding: number | "xs" | "md" = "xs";
if (isDense || iconOnly) padding = 6;
if (isUltraDense) padding = 4;
if (advanced) padding = "md";
const content =
orientation === "vertical" && !advanced ? (
<Stack h="100%" align="center" justify="center" gap={6} flex={1}>
{showIcon ? <BookmarkAvatar bookmark={bookmark} iconUrl={iconUrl} size={iconOnly ? 34 : 38} /> : null}
{showTitle ? (
<Text size="xxs" fw={600} lh={1.2} ta="center" lineClamp={2}>
{bookmark.name}
</Text>
) : null}
{showHostname ? (
<Text fz={9} c="dimmed" ta="center" truncate w="100%">
{hostname}
</Text>
) : null}
</Stack>
) : (
<Group
h="100%"
gap={advanced ? "sm" : "xs"}
wrap="nowrap"
justify={iconOnly ? "center" : "flex-start"}
align="center"
flex={1}
>
{showIcon ? <BookmarkAvatar bookmark={bookmark} iconUrl={iconUrl} size={avatarSize} /> : null}
{!iconOnly ? (
<Stack gap={advanced ? 3 : 0} miw={0} flex={1}>
{showTitle ? (
<Text size={advanced ? "xs" : "xxs"} fw={600} lh={1.2} truncate>
{bookmark.name}
</Text>
) : null}
{showHostname ? (
<Text fz={advanced ? 10 : 9} c="dimmed" truncate>
{hostname}
</Text>
) : null}
{advanced && bookmark.description ? (
<Text fz={10} c="dimmed" lineClamp={2}>
{bookmark.description}
</Text>
) : null}
</Stack>
) : null}
{advanced ? (
<ThemeIcon
variant={active ? "light" : "transparent"}
color={active ? "primaryColor" : "gray"}
size="sm"
radius="xl"
style={{
opacity: active ? 1 : 0.45,
transform: active && !reduceMotion ? "translate(1px, -1px)" : undefined,
transition: reduceMotion ? undefined : "opacity 120ms ease, transform 120ms ease",
}}
>
<IconArrowUpRight style={iconSizes.sm} />
</ThemeIcon>
) : null}
</Group>
);
return (
<Tooltip
label={
<Stack gap={0}>
<Text size="xs" fw={600}>
{bookmark.name}
</Text>
{hostname ? (
<Text size="xs" opacity={0.8}>
{hostname}
</Text>
) : null}
</Stack>
}
openDelay={450}
disabled={advanced}
withArrow
>
<Card
component={href ? "a" : "div"}
href={href}
target={href ? (openNewTab ? "_blank" : "_self") : undefined}
rel={href && openNewTab ? SAFE_NEW_TAB_REL : undefined}
aria-label={bookmark.name}
radius={radius}
withBorder={withBorder || variant === "outline"}
p={padding}
h={height}
w={width}
miw={width}
onMouseEnter={() => setActive(true)}
onMouseLeave={() => setActive(false)}
onFocus={() => setActive(true)}
onBlur={() => setActive(false)}
styles={{
root: {
background,
borderColor: active
? "rgb(from var(--mantine-primary-color-filled) r g b / calc(var(--opacity, 1) * 0.62))"
: "rgb(from var(--mantine-color-secondaryColor-filled) r g b / calc(var(--opacity, 1) * 0.38))",
boxShadow: active ? "var(--mantine-shadow-sm)" : undefined,
color: "var(--mantine-color-text)",
cursor: href ? "pointer" : "default",
display: "flex",
outline: active ? "2px solid var(--mantine-primary-color-light)" : undefined,
outlineOffset: -2,
overflow: "hidden",
textDecoration: "none",
transform: active && !reduceMotion ? "translateY(-1px)" : undefined,
transition: reduceMotion
? undefined
: "background-color 120ms ease, border-color 120ms ease, box-shadow 120ms ease, transform 120ms ease",
},
}}
>
{content}
</Card>
</Tooltip>
);
};
const BookmarkAvatar = ({ bookmark, iconUrl, size }: { bookmark: BookmarkItem; iconUrl?: string; size: number }) => (
<Avatar
src={iconUrl}
name={bookmark.name}
color="gray"
radius="sm"
size={size}
imageProps={{ referrerPolicy: "no-referrer" }}
styles={{ image: { objectFit: "contain" } }}
>
<IconLink style={zoomCompensatedSize(Math.max(14, size / 2))} />
</Avatar>
);
const getBookmarkBackground = (variant: BookmarkVariant, active: boolean): string => {
if (variant === "plain" || variant === "outline") return "transparent";
if (variant === "filled") {
const opacity = active ? 0.64 : 0.48;
return `rgb(from var(--mantine-color-default-hover) r g b / calc(var(--opacity, 1) * ${opacity}))`;
}
const opacity = active ? 0.14 : 0.08;
return `rgb(from var(--mantine-primary-color-filled) r g b / calc(var(--opacity, 1) * ${opacity}))`;
};
const getBookmarkHostname = (href: string): string | undefined => {
try {
return new URL(href).hostname || undefined;
} catch {
return undefined;
}
};
const bookmarkSpacingPixels = {
xs: 10,
sm: 12,
md: 16,
lg: 20,
xl: 32,
} as const;
+1 -303
View File
@@ -1,305 +1,3 @@
"use client";
import { Anchor, Card, Flex, Group, Stack, Text, Title, UnstyledButton } from "@mantine/core";
import combineClasses from "clsx";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { useRequiredBoard } from "@homarr/boards/context";
import { useRegisterSpotlightContextResults } from "@homarr/spotlight";
import { MaskedOrNormalImage } from "@homarr/ui";
import type { WidgetComponentProps } from "../definition";
import classes from "./bookmark.module.css";
export default function BookmarksWidget({ options, itemId }: 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));
},
});
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],
);
return (
<Stack h="100%" gap="sm" p="sm">
{options.title.length > 0 && (
<Title order={4} px="0.25rem">
{options.title}
</Title>
)}
{(options.layout === "grid" || options.layout === "gridHorizontal") && (
<GridLayout
data={data}
itemDirection={options.layout === "gridHorizontal" ? "horizontal" : "vertical"}
hideTitle={options.hideTitle}
hideIcon={options.hideIcon}
hideHostname={options.hideHostname}
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}
/>
)}
</Stack>
);
}
interface FlexLayoutProps {
data: RouterOutputs["app"]["byIds"];
direction: "row" | "column";
hideTitle: boolean;
hideIcon: boolean;
hideHostname: boolean;
openNewTab: boolean;
withBorder: boolean;
hasIconColor: boolean;
}
const FlexLayout = ({
data,
direction,
hideTitle,
hideIcon,
hideHostname,
openNewTab,
withBorder,
hasIconColor,
}: 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}
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 GridLayoutProps {
data: RouterOutputs["app"]["byIds"];
hideTitle: boolean;
hideIcon: boolean;
hideHostname: boolean;
openNewTab: boolean;
withBorder: boolean;
itemDirection: "horizontal" | "vertical";
hasIconColor: boolean;
}
const GridLayout = ({
data,
hideTitle,
hideIcon,
hideHostname,
openNewTab,
withBorder,
itemDirection,
hasIconColor,
}: 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
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>
);
};
const VerticalItem = ({
app,
hideTitle,
hideIcon,
hideHostname,
hasIconColor,
}: {
app: RouterOutputs["app"]["byIds"][number];
hideTitle: boolean;
hideIcon: boolean;
hideHostname: boolean;
hasIconColor: boolean;
}) => {
return (
<Stack h="100%" miw={16} gap="sm" justify={"center"}>
{!hideTitle && (
<Text fw={700} ta="center" size="xs">
{app.name}
</Text>
)}
{!hideIcon && (
<MaskedOrNormalImage
imageUrl={app.iconUrl}
hasColor={hasIconColor}
alt={app.name}
className={classes.bookmarkIcon}
style={{
width: hideHostname && hideTitle ? "min(max(100%, 16px), 40px)" : 40,
height: hideHostname && hideTitle ? "min(max(100%, 16px), 40px)" : 40,
overflow: "auto",
flex: "unset",
marginLeft: "auto",
marginRight: "auto",
}}
/>
)}
{!hideHostname && (
<Anchor ta="center" component="span" size="xs">
{app.href ? new URL(app.href).hostname : undefined}
</Anchor>
)}
</Stack>
);
};
const HorizontalItem = ({
app,
hideTitle,
hideIcon,
hideHostname,
hasIconColor,
}: {
app: RouterOutputs["app"]["byIds"][number];
hideTitle: boolean;
hideIcon: boolean;
hideHostname: boolean;
hasIconColor: boolean;
}) => {
return (
<Group wrap="nowrap" gap="xs" h="100%" justify="start">
{!hideIcon && (
<MaskedOrNormalImage
imageUrl={app.iconUrl}
hasColor={hasIconColor}
alt={app.name}
className={classes.bookmarkIcon}
style={{
overflow: "auto",
width: hideHostname ? 16 : 24,
height: hideHostname ? 16 : 24,
flex: "unset",
}}
/>
)}
{!(hideTitle && hideHostname) && (
<>
<Stack justify="space-between" gap={0}>
{!hideTitle && (
<Text fw={700} size="xs" lineClamp={hideHostname ? 2 : 1}>
{app.name}
</Text>
)}
{!hideHostname && (
<Anchor component="span" size="xs">
{app.href ? new URL(app.href).hostname : undefined}
</Anchor>
)}
</Stack>
</>
)}
</Group>
);
};
export { default } from "./bookmarks-widget";
+64 -17
View File
@@ -1,48 +1,88 @@
import { ActionIcon, Avatar, Group, Stack, Text } from "@mantine/core";
import { IconBookmark, IconX } from "@tabler/icons-react";
import { IconBookmark, IconLink, IconX } from "@tabler/icons-react";
import { z } from "zod/v4";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options";
import { BookmarkAddButton } from "./add-button";
import type { BookmarkSelectionItem } from "./add-button";
import { createDirectBookmark, getBookmarkFaviconUrl, getDirectBookmarkUrl } from "./bookmark-item";
export const { definition, componentLoader } = createWidgetDefinition("bookmarks", {
icon: IconBookmark,
queryKeys: [[["app", "byIds"]]],
supportsAdvancedFocus: true,
queryKey: [["app", "byIds"]],
createOptions() {
return optionsBuilder.from((factory) => ({
title: factory.text(),
showTitle: factory.switch({ defaultValue: true }),
titleSize: factory.slider({
defaultValue: 11,
validate: z.number().min(8).max(32),
step: 1,
}),
layout: factory.select({
options: (["grid", "gridHorizontal", "row", "column"] as const).map((value) => ({
options: (["adaptive", "column", "row", "grid", "gridHorizontal", "icons"] as const).map((value) => ({
value,
label: (t) => t(`widget.bookmarks.option.layout.option.${value}.label`),
label: (t) => {
if (value === "adaptive") return t("widget.bookmarks.option.layout.option.adaptive.label");
if (value === "grid") return t("widget.common.layout.option.grid");
if (value === "row") return t("widget.common.layout.option.horizontal");
if (value === "column") return t("widget.common.layout.option.vertical");
if (value === "icons") return t("widget.bookmarks.option.layout.option.icons.label");
return t("widget.bookmarks.option.layout.option.gridHorizontal.label");
},
})),
defaultValue: "column",
defaultValue: "adaptive",
}),
variant: factory.select({
options: (["soft", "filled", "outline", "plain"] as const).map((value) => ({
value,
label: (t) => t(`widget.bookmarks.option.variant.option.${value}.label`),
})),
defaultValue: "soft",
}),
spacing: factory.select({
options: (["xs", "sm", "md", "lg", "xl"] as const).map((value) => ({
value,
label: (t) => t(`widget.bookmarks.option.spacing.option.${value}.label`),
})),
defaultValue: "xs",
}),
hideTitle: factory.switch({ defaultValue: false }),
hideIcon: factory.switch({ defaultValue: false }),
hideHostname: factory.switch({ defaultValue: false }),
openNewTab: factory.switch({ defaultValue: true }),
withBorder: factory.switch({ defaultValue: false }),
items: factory.sortableItemList<RouterOutputs["app"]["all"][number], string>({
ItemComponent: ({ item, handle: Handle, removeItem, rootAttributes }) => {
grow: factory.switch({ defaultValue: false }),
customUrls: factory.internal({ defaultValue: [] as string[] }),
items: factory.sortableItemList<BookmarkSelectionItem, string>({
ItemComponent: ({ item, handle, removeItem, removeLabel, rootAttributes }) => {
const iconUrl = item.iconUrl ?? getBookmarkFaviconUrl(item.href);
return (
<Group {...rootAttributes} tabIndex={0} justify="space-between" wrap="nowrap">
<Group wrap="nowrap">
<Handle />
<Group wrap="nowrap" miw={0}>
{handle}
<Group>
<Avatar src={item.iconUrl} alt={item.name} />
<Stack gap={0}>
<Text>{item.name}</Text>
<Group wrap="nowrap" miw={0}>
<Avatar src={iconUrl} name={item.name} color="gray" radius="sm">
<IconLink size={20} />
</Avatar>
<Stack gap={0} miw={0}>
<Text fw={600} truncate>
{item.name}
</Text>
<Text size="xs" c="dimmed" truncate>
{item.href}
</Text>
</Stack>
</Group>
</Group>
<ActionIcon variant="transparent" color="red" onClick={removeItem}>
<IconX size={20} />
<ActionIcon variant="subtle" color="red" onClick={removeItem} aria-label={removeLabel}>
<IconX size="var(--mantine-font-size-xl)" />
</ActionIcon>
</Group>
);
@@ -50,7 +90,14 @@ export const { definition, componentLoader } = createWidgetDefinition("bookmarks
AddButton: BookmarkAddButton,
uniqueIdentifier: (item) => item.id,
useData: (initialIds) => {
const { data, error, isLoading } = clientApi.app.byIds.useQuery(initialIds);
const appIds = initialIds.filter((value) => !getDirectBookmarkUrl(value));
const { data: apps, error, isLoading } = clientApi.app.byIds.useQuery(appIds);
const appsById = new Map(apps?.map((app) => [app.id, app]));
const data = initialIds.flatMap((value) => {
const directUrl = getDirectBookmarkUrl(value);
if (directUrl) return createDirectBookmark(directUrl) ?? [];
return appsById.get(value) ?? [];
});
return {
data,
@@ -0,0 +1,69 @@
import { describe, expect, test } from "vitest";
import { createDirectBookmark, normalizeBookmarkUrl, splitBookmarkUrls } from "./bookmark-item";
import { getBookmarkCardDisplay, getBookmarkDisplayPlan } from "./layout";
describe("bookmark display helpers", () => {
test("uses explicit width breakpoints for advanced columns", () => {
expect(
getBookmarkDisplayPlan({ advanced: true, width: 900, height: 500, itemCount: 8, layout: "adaptive" }),
).toMatchObject({
columns: 3,
orientation: "horizontal",
});
expect(
getBookmarkDisplayPlan({ advanced: true, width: 120, height: 500, itemCount: 8, layout: "adaptive" }),
).toMatchObject({
columns: 1,
orientation: "horizontal",
});
});
test("selects an adaptive plan from width and height breakpoints", () => {
expect(
getBookmarkDisplayPlan({ advanced: false, width: 560, height: 260, itemCount: 4, layout: "adaptive" }),
).toMatchObject({
columns: 2,
itemHeight: 128,
orientation: "horizontal",
showHostname: true,
showTitle: true,
});
expect(
getBookmarkDisplayPlan({ advanced: false, width: 180, height: 80, itemCount: 4, layout: "adaptive" }),
).toMatchObject({
columns: 1,
orientation: "icon",
showHostname: false,
showTitle: false,
});
});
test("keeps compact cards visible when icons are hidden", () => {
const plan = getBookmarkDisplayPlan({ advanced: false, width: 180, height: 80, itemCount: 4, layout: "icons" });
expect(
getBookmarkCardDisplay({ advanced: false, hideHostname: false, hideIcon: true, hideTitle: false, plan }),
).toEqual({
orientation: "horizontal",
showHostname: false,
showIcon: false,
showTitle: true,
});
});
test("creates standalone bookmarks without app records", () => {
expect(createDirectBookmark("https://homarr.dev/docs")).toEqual({
id: "url:https://homarr.dev/docs",
name: "homarr.dev",
description: null,
href: "https://homarr.dev/docs",
});
expect(normalizeBookmarkUrl("homarr.dev/docs")).toBe("https://homarr.dev/docs");
expect(normalizeBookmarkUrl("javascript:alert(1)")).toBeUndefined();
});
test("splits URL lists without corrupting commas inside a URL", () => {
expect(splitBookmarkUrls("https://one.example, two.example")).toEqual(["https://one.example", "two.example"]);
expect(splitBookmarkUrls("https://maps.example/@52.5,13.4,15z")).toEqual(["https://maps.example/@52.5,13.4,15z"]);
});
});
+366
View File
@@ -0,0 +1,366 @@
import type { WidgetComponentProps } from "../definition";
export type BookmarkLayout = WidgetComponentProps<"bookmarks">["options"]["layout"];
export type BookmarkOrientation = "horizontal" | "vertical" | "icon";
export interface BookmarkDisplayPlan {
columns: number;
horizontalScroll: boolean;
itemGap: number;
itemHeight: number;
itemWidth: number;
orientation: BookmarkOrientation;
showHostname: boolean;
showTitle: boolean;
}
export interface BookmarkCardDisplay {
orientation: BookmarkOrientation;
showHostname: boolean;
showIcon: boolean;
showTitle: boolean;
}
export const getBookmarkCardDisplay = ({
advanced,
hideHostname,
hideIcon,
hideTitle,
plan,
}: {
advanced: boolean;
hideHostname: boolean;
hideIcon: boolean;
hideTitle: boolean;
plan: BookmarkDisplayPlan;
}): BookmarkCardDisplay => {
let orientation = plan.orientation;
let showTitle = advanced || (!hideTitle && plan.showTitle);
if (!advanced && hideIcon && !hideTitle && !showTitle) {
orientation = "horizontal";
showTitle = true;
}
let showHostname = advanced || (!hideHostname && plan.showHostname);
if (!advanced && plan.itemHeight <= 32 && showTitle) showHostname = false;
const showIcon = advanced || !hideIcon || (!showTitle && !showHostname);
return { orientation, showHostname, showIcon, showTitle };
};
interface BookmarkWidthBreakpoint {
adaptiveColumns: number;
adaptiveOrientation: BookmarkOrientation;
advancedColumns: number;
compactColumns: number;
gridColumns: number;
iconColumns: number;
minWidth: number;
rowItemWidth: number;
showHostname: boolean;
showTitle: boolean;
}
const widthBreakpoints = [
{
minWidth: 900,
adaptiveColumns: 4,
advancedColumns: 3,
compactColumns: 4,
gridColumns: 5,
iconColumns: 10,
rowItemWidth: 180,
adaptiveOrientation: "vertical",
showHostname: true,
showTitle: true,
},
{
minWidth: 720,
adaptiveColumns: 3,
advancedColumns: 2,
compactColumns: 3,
gridColumns: 4,
iconColumns: 8,
rowItemWidth: 172,
adaptiveOrientation: "horizontal",
showHostname: true,
showTitle: true,
},
{
minWidth: 480,
adaptiveColumns: 2,
advancedColumns: 2,
compactColumns: 2,
gridColumns: 3,
iconColumns: 6,
rowItemWidth: 160,
adaptiveOrientation: "horizontal",
showHostname: true,
showTitle: true,
},
{
minWidth: 300,
adaptiveColumns: 2,
advancedColumns: 1,
compactColumns: 1,
gridColumns: 2,
iconColumns: 4,
rowItemWidth: 144,
adaptiveOrientation: "horizontal",
showHostname: false,
showTitle: true,
},
{
minWidth: 180,
adaptiveColumns: 1,
advancedColumns: 1,
compactColumns: 1,
gridColumns: 1,
iconColumns: 3,
rowItemWidth: 136,
adaptiveOrientation: "horizontal",
showHostname: false,
showTitle: true,
},
{
minWidth: 0,
adaptiveColumns: 1,
advancedColumns: 1,
compactColumns: 1,
gridColumns: 1,
iconColumns: 2,
rowItemWidth: 112,
adaptiveOrientation: "icon",
showHostname: false,
showTitle: false,
},
] as const satisfies readonly BookmarkWidthBreakpoint[];
interface BookmarkHeightBreakpoint {
columnItemHeight: number;
itemHeight: number;
minHeight: number;
orientation: BookmarkOrientation;
rowItemHeight: number;
showHostname: boolean;
showTitle: boolean;
}
const heightBreakpoints = [
{
minHeight: 300,
columnItemHeight: 72,
itemHeight: 128,
rowItemHeight: 128,
orientation: "vertical",
showHostname: true,
showTitle: true,
},
{
minHeight: 180,
columnItemHeight: 64,
itemHeight: 128,
rowItemHeight: 112,
orientation: "horizontal",
showHostname: true,
showTitle: true,
},
{
minHeight: 110,
columnItemHeight: 56,
itemHeight: 80,
rowItemHeight: 80,
orientation: "horizontal",
showHostname: false,
showTitle: true,
},
{
minHeight: 60,
columnItemHeight: 48,
itemHeight: 48,
rowItemHeight: 56,
orientation: "icon",
showHostname: false,
showTitle: false,
},
{
minHeight: 0,
columnItemHeight: 32,
itemHeight: 32,
rowItemHeight: 40,
orientation: "icon",
showHostname: false,
showTitle: false,
},
] as const satisfies readonly BookmarkHeightBreakpoint[];
export const getBookmarkDisplayPlan = ({
advanced,
gap = 0,
height,
itemCount,
layout,
width,
}: {
advanced: boolean;
gap?: number;
height: number;
itemCount: number;
layout: BookmarkLayout;
width: number;
}): BookmarkDisplayPlan => {
const count = Math.max(1, itemCount);
const normalizedWidth = Math.max(0, width);
const normalizedHeight = Math.max(0, height);
const widthSettings = widthBreakpoints.find(({ minWidth }) => normalizedWidth >= minWidth);
const heightSettings = heightBreakpoints.find(({ minHeight }) => normalizedHeight >= minHeight);
if (!widthSettings || !heightSettings) throw new Error("Bookmark breakpoints must cover all widget sizes");
if (advanced) {
return {
columns: Math.min(count, widthSettings.advancedColumns),
horizontalScroll: false,
itemGap: gap,
itemHeight: 104,
itemWidth: 260,
orientation: "horizontal",
showHostname: true,
showTitle: true,
};
}
if (layout === "row") {
let orientation: BookmarkOrientation = "horizontal";
if (!widthSettings.showTitle) orientation = "icon";
let itemWidth: number = widthSettings.rowItemWidth;
if (heightSettings.rowItemHeight <= 56) itemWidth = 112;
if (heightSettings.rowItemHeight <= 40) itemWidth = 96;
return {
columns: count,
horizontalScroll: true,
itemGap: Math.min(gap, 8),
itemHeight: Math.min(heightSettings.rowItemHeight, 64),
itemWidth,
orientation,
showHostname: widthSettings.showHostname && heightSettings.showHostname,
showTitle: widthSettings.showTitle,
};
}
if (layout === "column") {
return {
columns: 1,
horizontalScroll: false,
itemGap: Math.min(gap, 4),
itemHeight: 32,
itemWidth: width,
orientation: "horizontal",
showHostname: widthSettings.showHostname,
showTitle: widthSettings.showTitle,
};
}
if (layout === "icons") {
return {
columns: Math.min(count, widthSettings.iconColumns),
horizontalScroll: false,
itemGap: Math.min(gap, 8),
itemHeight: 56,
itemWidth: 56,
orientation: "icon",
showHostname: false,
showTitle: false,
};
}
if (layout === "gridHorizontal") {
return {
columns: Math.min(count, widthSettings.compactColumns),
horizontalScroll: false,
itemGap: Math.min(gap, 8),
itemHeight: 56,
itemWidth: widthSettings.rowItemWidth,
orientation: "horizontal",
showHostname: widthSettings.showHostname && heightSettings.showHostname,
showTitle: widthSettings.showTitle && heightSettings.showTitle,
};
}
let columns: number = widthSettings.adaptiveColumns;
let orientation: BookmarkOrientation = widthSettings.adaptiveOrientation;
if (layout === "grid") {
columns = widthSettings.gridColumns;
orientation = "vertical";
}
if (layout === "adaptive") {
columns = widthSettings.adaptiveColumns;
orientation = widthSettings.adaptiveOrientation;
if (heightSettings.orientation === "icon") orientation = "icon";
if (heightSettings.orientation === "horizontal" && orientation === "vertical") orientation = "horizontal";
}
const visibleColumns = Math.min(count, columns);
const densitySettings = getBookmarkDensitySettings({
columns: visibleColumns,
gap,
height: normalizedHeight,
heightProperty: "itemHeight",
itemCount: count,
preferredSettings: heightSettings,
});
if (layout === "adaptive" && densitySettings.itemHeight < 80 && orientation === "vertical") {
orientation = "horizontal";
}
let showHostname =
orientation !== "icon" && widthSettings.showHostname && heightSettings.showHostname && densitySettings.showHostname;
let showTitle = orientation !== "icon" && widthSettings.showTitle && heightSettings.showTitle;
if (layout === "grid") {
showHostname = showHostname && densitySettings.showHostname;
showTitle = showTitle && densitySettings.showTitle;
if (!showHostname && !showTitle) orientation = "icon";
}
return {
columns: visibleColumns,
horizontalScroll: false,
itemGap: getBookmarkDensityGap(gap, densitySettings),
itemHeight: densitySettings.itemHeight,
itemWidth: widthSettings.rowItemWidth,
orientation,
showHostname,
showTitle,
};
};
type BookmarkHeightProperty = "columnItemHeight" | "itemHeight";
type BookmarkHeightSettings = (typeof heightBreakpoints)[number];
const getBookmarkDensitySettings = ({
columns,
gap,
height,
heightProperty,
itemCount,
preferredSettings,
}: {
columns: number;
gap: number;
height: number;
heightProperty: BookmarkHeightProperty;
itemCount: number;
preferredSettings: BookmarkHeightSettings;
}): BookmarkHeightSettings => {
const rowCount = Math.ceil(itemCount / Math.max(1, columns));
const totalGap = Math.max(0, rowCount - 1) * Math.max(0, gap);
const preferredIndex = heightBreakpoints.indexOf(preferredSettings);
const matchingSettings = heightBreakpoints
.slice(preferredIndex)
.find((settings) => rowCount * settings[heightProperty] + totalGap <= height);
return matchingSettings ?? heightBreakpoints[4];
};
const getBookmarkDensityGap = (gap: number, settings: BookmarkHeightSettings) => {
if (settings.itemHeight > 32 && settings.columnItemHeight > 32) return gap;
return Math.min(gap, 4);
};
+27 -16
View File
@@ -1,32 +1,43 @@
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 type { QueryClient } from "@tanstack/react-query";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { createTrpcQueryKey } from "../trpc-query-key";
import { getDirectBookmarkUrl } from "./bookmark-item";
const logger = createLogger({ module: "bookmarksWidgetPrefetch" });
const prefetchAllAsync: Prefetch<"bookmarks"> = async (queryClient, items) => {
const appIds = items.flatMap((item) => item.options.items);
const distinctAppIds = [...new Set(appIds)];
const getAppIds = (options: Record<string, unknown>) => {
if (!Array.isArray(options.items)) return [];
return options.items.filter((value): value is string => typeof value === "string" && !getDirectBookmarkUrl(value));
};
const dbApps = await db.query.apps.findMany({
where: inArray(apps.id, distinctAppIds),
});
const prefetchAll = (queryClient: QueryClient, items: { options: Record<string, unknown> }[]) => {
const appIds = items.flatMap((item) => getAppIds(item.options));
const distinctAppIds = [...new Set(appIds)];
if (distinctAppIds.length === 0) return;
const dbAppsPromise = Promise.resolve(db.query.apps.findMany({ where: inArray(apps.id, distinctAppIds) })).catch(
(error: unknown) => {
logger.error(new Error("Failed to prefetch apps for bookmarks", { cause: error }));
throw error;
},
);
for (const item of items) {
if (item.options.items.length === 0) {
const itemAppIds = getAppIds(item.options);
if (itemAppIds.length === 0) {
continue;
}
queryClient.setQueryData(
createTrpcQueryKey("app.byIds", item.options.items),
dbApps.filter((app) => item.options.items.includes(app.id)),
);
void queryClient.prefetchQuery({
queryKey: createTrpcQueryKey("app.byIds", itemAppIds),
queryFn: async () => (await dbAppsPromise).filter((app) => itemAppIds.includes(app.id)),
});
}
logger.info("Successfully prefetched apps for bookmarks", { count: dbApps.length });
};
export default prefetchAllAsync;
export default prefetchAll;
@@ -1,3 +1,4 @@
import { useId } from "react";
import {
Badge,
Box,
@@ -16,122 +17,191 @@ 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";
export type CalendarEventWithSource = CalendarEvent & {
source?: { integrationId: string; integrationName: string };
};
interface CalendarEventListProps {
events: CalendarEvent[];
events: CalendarEventWithSource[];
advanced?: boolean;
fillHeight?: boolean;
groupByDate?: boolean;
locale?: string;
}
export const CalendarEventList = ({ events }: CalendarEventListProps) => {
const { colorScheme } = useMantineColorScheme();
const t = useI18n();
export const CalendarEventList = ({
events,
advanced = false,
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}
<Stack gap="md" pb="xs">
{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} advanced={advanced} />
</Stack>
</Box>
);
})
) : (
<CalendarEventRows events={events} locale={effectiveLocale} advanced={advanced} />
)}
</Stack>
</ScrollArea>
);
};
const CalendarEventRows = ({
events,
locale,
advanced,
}: Pick<CalendarEventListProps, "events" | "advanced"> & { locale: string }) => {
const { colorScheme } = useMantineColorScheme();
const t = useI18n("widget.calendar");
return (
<>
{events.map((event, eventIndex) => (
<Group
key={`${event.source?.integrationId ?? "event"}-${event.startDate.toISOString()}-${event.title}-${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={advanced ? undefined : 1} size="sm">
{event.subTitle}
</Text>
)}
<Text fw={"bold"} lineClamp={advanced ? undefined : 1} size="sm">
{event.title}
</Text>
{advanced && event.source && (
<Badge size="xs" variant="light" w="fit-content">
{event.source.integrationName}
</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}
</Stack>
{event.metadata?.type === "radarr" && (
<Group wrap="nowrap">
<Text c="dimmed" size="sm">
{t(`option.releaseType.options.${event.metadata.releaseType}`)}
</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>
)}
<Group gap={3} wrap="nowrap" align={"center"}>
<IconClock opacity={0.7} size={"1rem"} />
{isAllDay(event) ? (
<Group gap={3} wrap="nowrap" align={"center"} ml="auto">
<IconClock opacity={0.7} size={"1rem"} />
{isAllDay(event) ? (
<Text c={"dimmed"} size={"sm"}>
{t("duration.allDay")}
</Text>
) : (
<>
<Text c={"dimmed"} size={"sm"}>
{t("widget.calendar.duration.allDay")}
{formatLocalizedTime(event.startDate, locale)}
</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>
{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}
{event.location !== null && (
<Group gap={4} mb={isNullOrWhitespace(event.description) ? 0 : "sm"}>
<IconPin opacity={0.7} size={"1rem"} />
<Text size={"xs"} c={"dimmed"} lineClamp={advanced ? undefined : 1}>
{event.location}
</Text>
)}
</Group>
)}
{event.links.length > 0 && (
<Group pt={5} gap={5} mt={"auto"} wrap="nowrap">
{event.links
.filter((link) => link.href)
.map((link) => (
{!isNullOrWhitespace(event.description) && (
<Text size={"xs"} c={"dimmed"} lineClamp={advanced ? undefined : 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 +220,14 @@ export const CalendarEventList = ({ events }: CalendarEventListProps) => {
>
<Text>{link.name}</Text>
</Button>
))}
</Group>
)}
</Stack>
</Group>
))}
</Stack>
</ScrollArea>
);
})}
</Group>
)}
</Stack>
</Group>
))}
</>
);
};
Loaded 100 of 3286 files, more files were not shown because too many files have changed in this diff. Show more