merge: stack assistant on mobile boards
This commit is contained in:
1326 files changed
+131355
-46380
No files matched your search
@@ -9,6 +9,7 @@ export interface CommonWidgetInputProps<TKey extends WidgetOptionType> {
|
||||
options: Omit<WidgetOptionOfType<TKey>, "defaultValue" | "type">;
|
||||
initialOptions: Record<string, unknown>;
|
||||
itemId?: string;
|
||||
boardId?: string;
|
||||
}
|
||||
|
||||
type UseWidgetInputTranslationReturnType = (key: "label" | "description" | "placeholder") => string;
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { WidgetOptionType } from "../options";
|
||||
import { WidgetAnchorNoteInput } from "./widget-anchor-note-input";
|
||||
import { WidgetAppInput } from "./widget-app-input";
|
||||
import { WidgetCustomWidgetSelectInput } from "./widget-custom-widget-select-input";
|
||||
import { WidgetCustomWidgetConfigurationInput } from "./widget-custom-widget-configuration-input";
|
||||
import { WidgetDynamicSelectInput } from "./widget-dynamic-select-input";
|
||||
import { WidgetLocationInput } from "./widget-location-input";
|
||||
import { WidgetMultiTextInput } from "./widget-multi-text-input";
|
||||
@@ -18,8 +19,10 @@ import { WidgetUmamiEventNamesInput } from "./widget-umami-event-names-input";
|
||||
import { WidgetUmamiWebsiteInput } from "./widget-umami-website-input";
|
||||
import { WidgetIntegrationSelectInput } from "./widget-integration-select-input";
|
||||
import { WidgetIntegrationMultiSelectInput } from "./widget-integration-multi-select-input";
|
||||
import { WidgetInternalInput } from "./widget-internal-input";
|
||||
|
||||
const mapping = {
|
||||
internal: WidgetInternalInput,
|
||||
anchorNote: WidgetAnchorNoteInput,
|
||||
text: WidgetTextInput,
|
||||
location: WidgetLocationInput,
|
||||
@@ -37,6 +40,7 @@ const mapping = {
|
||||
umamiEventNames: WidgetUmamiEventNamesInput,
|
||||
umamiWebsite: WidgetUmamiWebsiteInput,
|
||||
customWidgetSelect: WidgetCustomWidgetSelectInput,
|
||||
customWidgetConfiguration: WidgetCustomWidgetConfigurationInput,
|
||||
integrationSelect: WidgetIntegrationSelectInput,
|
||||
integrationMultiSelect: WidgetIntegrationMultiSelectInput,
|
||||
} satisfies Record<WidgetOptionType, unknown>;
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import type { Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { JsonOption } from "./widget-custom-widget-configuration-input";
|
||||
|
||||
vi.mock("@homarr/custom-widgets/workbench", () => ({
|
||||
CustomWidgetCodeEditor: (props: {
|
||||
id: string;
|
||||
label: string;
|
||||
value: string;
|
||||
error?: string;
|
||||
onChange(value: string): void;
|
||||
}) => (
|
||||
<textarea
|
||||
id={props.id}
|
||||
aria-label={props.label}
|
||||
aria-invalid={Boolean(props.error)}
|
||||
value={props.value}
|
||||
onChange={(event) => props.onChange(event.currentTarget.value)}
|
||||
/>
|
||||
),
|
||||
}));
|
||||
|
||||
let root: Root;
|
||||
let host: HTMLDivElement;
|
||||
|
||||
beforeEach(() => {
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
root = createRoot(host);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(() => root.unmount());
|
||||
host.remove();
|
||||
});
|
||||
|
||||
async function renderJsonOption(identity: string, value: unknown) {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<JsonOption
|
||||
identity={identity}
|
||||
editorId="settings-json-option"
|
||||
label="Settings"
|
||||
value={value}
|
||||
onChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function enterEditorValue(editor: HTMLTextAreaElement, value: string) {
|
||||
const valueSetter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")?.set;
|
||||
valueSetter?.call(editor, value);
|
||||
editor.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
}
|
||||
|
||||
describe("custom widget JSON option", () => {
|
||||
it("preserves an invalid active draft until its definition or external value changes", async () => {
|
||||
await renderJsonOption("definition-a:settings", { owner: "Alice" });
|
||||
const editor = host.querySelector("textarea");
|
||||
if (!editor) throw new Error("JSON editor was not rendered");
|
||||
expect(editor?.value).toContain("Alice");
|
||||
|
||||
await act(async () => enterEditorValue(editor, '{"owner":'));
|
||||
expect(editor?.value).toBe('{"owner":');
|
||||
|
||||
await renderJsonOption("definition-a:settings", { owner: "Alice" });
|
||||
expect(editor?.value).toBe('{"owner":');
|
||||
|
||||
await renderJsonOption("definition-b:settings", { owner: "Bob" });
|
||||
expect(editor?.value).toContain("Bob");
|
||||
expect(editor?.value).not.toContain("Alice");
|
||||
});
|
||||
|
||||
it("synchronizes a valid external configuration update", async () => {
|
||||
await renderJsonOption("definition-a:settings", { page: 1 });
|
||||
const editor = host.querySelector("textarea");
|
||||
await renderJsonOption("definition-a:settings", { page: 2 });
|
||||
expect(editor?.value).toContain('"page": 2');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,348 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
Accordion,
|
||||
Alert,
|
||||
ColorInput,
|
||||
MultiSelect,
|
||||
NumberInput,
|
||||
Select,
|
||||
Slider,
|
||||
Stack,
|
||||
Switch,
|
||||
Text,
|
||||
Textarea,
|
||||
TextInput,
|
||||
} from "@mantine/core";
|
||||
import { DateInput, TimeInput } from "@mantine/dates";
|
||||
import { IconAlertTriangle } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useOptionalBoard } from "@homarr/boards/context";
|
||||
import type { CustomWidgetOption } from "@homarr/custom-widgets/core";
|
||||
import { validateCustomWidgetOptions } from "@homarr/custom-widgets/core";
|
||||
import { CustomWidgetCodeEditor } from "@homarr/custom-widgets/workbench";
|
||||
import type { CustomWidgetEditorMessages } from "@homarr/custom-widgets/workbench";
|
||||
import { IconPicker } from "@homarr/forms-collection";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { CommonWidgetInputProps } from "./common";
|
||||
import { useFormContext } from "./form";
|
||||
|
||||
export const WidgetCustomWidgetConfigurationInput = ({
|
||||
property,
|
||||
}: CommonWidgetInputProps<"customWidgetConfiguration">) => {
|
||||
const form = useFormContext();
|
||||
const labels = useScopedI18n("widget.customApi.configuration");
|
||||
const board = useOptionalBoard();
|
||||
const definitionId = typeof form.values.options.definitionId === "string" ? form.values.options.definitionId : "";
|
||||
const configurationValue = form.values.options[property];
|
||||
const configuration = useMemo(() => (isRecord(configurationValue) ? configurationValue : {}), [configurationValue]);
|
||||
const available = clientApi.customWidget.available.useQuery(
|
||||
{ boardId: board?.id ?? "", currentId: definitionId || undefined },
|
||||
{ enabled: board !== null && Boolean(definitionId) },
|
||||
);
|
||||
const definition = available.data?.find((candidate) => candidate.id === definitionId);
|
||||
const options = isRecord(definition?.options) ? (definition.options as Record<string, CustomWidgetOption>) : null;
|
||||
const issues = useMemo(
|
||||
() => (options ? validateCustomWidgetOptions(options, configuration) : []),
|
||||
[configuration, options],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const path = `options.${property}`;
|
||||
if (issues[0]) form.setFieldError(path, `${issues[0].path}: ${issues[0].message}`);
|
||||
else form.clearFieldError(path);
|
||||
}, [form, issues, property]);
|
||||
|
||||
if (!definitionId || !options) return null;
|
||||
const entries = Object.entries(options);
|
||||
const regular = entries.filter(([, option]) => !option.advanced);
|
||||
const advanced = entries.filter(([, option]) => option.advanced);
|
||||
return (
|
||||
<Stack gap="md">
|
||||
{issues[0] && (
|
||||
<Alert color="yellow" variant="light" icon={<IconAlertTriangle size={16} />}>
|
||||
<Text size="sm">{labels("needsAttention", { message: issues[0].message })}</Text>
|
||||
</Alert>
|
||||
)}
|
||||
{regular.map(([name, option]) => (
|
||||
<OptionField
|
||||
key={`${definitionId}:${name}`}
|
||||
option={option}
|
||||
path={`options.${property}.${name}`}
|
||||
configuration={configuration}
|
||||
definitionId={definitionId}
|
||||
/>
|
||||
))}
|
||||
{advanced.length > 0 && (
|
||||
<Accordion variant="contained">
|
||||
<Accordion.Item value="advanced-options">
|
||||
<Accordion.Control>{labels("advancedOptions")}</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<Stack gap="sm">
|
||||
{advanced.map(([name, option]) => (
|
||||
<OptionField
|
||||
key={`${definitionId}:${name}`}
|
||||
option={option}
|
||||
path={`options.${property}.${name}`}
|
||||
configuration={configuration}
|
||||
definitionId={definitionId}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
function OptionField({
|
||||
option,
|
||||
path,
|
||||
configuration,
|
||||
definitionId,
|
||||
}: {
|
||||
option: CustomWidgetOption;
|
||||
path: string;
|
||||
configuration: Record<string, unknown>;
|
||||
definitionId: string;
|
||||
}) {
|
||||
const form = useFormContext();
|
||||
const input = form.getInputProps(path, { type: option.control === "switch" ? "checkbox" : "input" });
|
||||
const common = { label: option.label, description: option.description };
|
||||
if (option.choicesFrom)
|
||||
return <DynamicOptionField option={option} path={path} configuration={configuration} definitionId={definitionId} />;
|
||||
if (option.control === "switch") return <Switch {...common} {...input} />;
|
||||
if (["number", "duration"].includes(option.control))
|
||||
return <NumberInput {...common} min={option.min} max={option.max} step={option.step} {...input} />;
|
||||
if (option.control === "slider")
|
||||
return (
|
||||
<Stack gap={4}>
|
||||
<Text size="sm" fw={500}>
|
||||
{option.label}
|
||||
</Text>
|
||||
{option.description && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{option.description}
|
||||
</Text>
|
||||
)}
|
||||
<Slider min={option.min ?? 0} max={option.max ?? 100} step={option.step ?? 1} {...input} />
|
||||
</Stack>
|
||||
);
|
||||
const choices = option.choices?.map((choice) => ({ label: choice.label, value: String(choice.value) })) ?? [];
|
||||
if (option.control === "select")
|
||||
return (
|
||||
<Select
|
||||
{...common}
|
||||
data={choices}
|
||||
value={input.value == null ? null : String(input.value)}
|
||||
onChange={(value) => form.setFieldValue(path, coerceChoice(value, option))}
|
||||
/>
|
||||
);
|
||||
if (option.control === "multiSelect")
|
||||
return (
|
||||
<MultiSelect
|
||||
{...common}
|
||||
data={choices}
|
||||
value={Array.isArray(input.value) ? input.value.map(String) : []}
|
||||
onChange={(value) =>
|
||||
form.setFieldValue(
|
||||
path,
|
||||
value.map((entry) => coerceChoice(entry, option)),
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
if (option.control === "textarea") return <Textarea {...common} autosize minRows={3} {...input} />;
|
||||
if (option.control === "color") return <ColorInput {...common} {...input} />;
|
||||
if (option.control === "date") return <DateInput {...common} valueFormat="YYYY-MM-DD" {...input} />;
|
||||
if (option.control === "time") return <TimeInput {...common} {...input} />;
|
||||
if (option.control === "icon") return <IconPicker withAsterisk={false} {...input} />;
|
||||
if (option.control === "timeZone")
|
||||
return <Select {...common} searchable data={Intl.supportedValuesOf("timeZone")} {...input} />;
|
||||
if (option.control === "json")
|
||||
return (
|
||||
<JsonOption
|
||||
identity={`${definitionId}:${path}`}
|
||||
editorId={`${definitionId}-${path}-json-option`}
|
||||
label={option.label}
|
||||
description={option.description}
|
||||
value={input.value}
|
||||
onChange={(value) => form.setFieldValue(path, value)}
|
||||
/>
|
||||
);
|
||||
return <TextInput {...common} type={option.control === "url" ? "url" : "text"} {...input} />;
|
||||
}
|
||||
|
||||
function DynamicOptionField({
|
||||
option,
|
||||
path,
|
||||
configuration,
|
||||
definitionId,
|
||||
}: {
|
||||
option: CustomWidgetOption;
|
||||
path: string;
|
||||
configuration: Record<string, unknown>;
|
||||
definitionId: string;
|
||||
}) {
|
||||
const form = useFormContext();
|
||||
const board = useOptionalBoard();
|
||||
const source = option.choicesFrom;
|
||||
if (!source) return null;
|
||||
const query = clientApi.customWidget.optionRequest.useQuery(
|
||||
{
|
||||
boardId: board?.id ?? "",
|
||||
definitionId,
|
||||
requestId: source.request,
|
||||
params: primitiveConfiguration(configuration),
|
||||
},
|
||||
{ enabled: Boolean(board?.id && definitionId && source.request) },
|
||||
);
|
||||
const collection = source.itemsPath ? getByPath(query.data?.data, source.itemsPath) : query.data?.data;
|
||||
const data = (Array.isArray(collection) ? collection : []).flatMap((row) => {
|
||||
const value = getByPath(row, source.valuePath);
|
||||
const label = getByPath(row, source.labelPath);
|
||||
return typeof value === "string" || typeof value === "number"
|
||||
? [{ value: String(value), label: String(label ?? value) }]
|
||||
: [];
|
||||
});
|
||||
const input = form.getInputProps(path);
|
||||
const common = {
|
||||
label: option.label,
|
||||
description: option.description,
|
||||
data,
|
||||
searchable: true,
|
||||
disabled: query.isError,
|
||||
rightSection: query.isFetching ? "…" : undefined,
|
||||
};
|
||||
if (option.control === "multiSelect")
|
||||
return (
|
||||
<MultiSelect
|
||||
{...common}
|
||||
value={Array.isArray(input.value) ? input.value.map(String) : []}
|
||||
onChange={(value) =>
|
||||
form.setFieldValue(
|
||||
path,
|
||||
value.map((entry) => coerceChoice(entry, option)),
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<Select
|
||||
{...common}
|
||||
value={input.value == null ? null : String(input.value)}
|
||||
onChange={(value) => form.setFieldValue(path, coerceChoice(value, option))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const editorMessages: CustomWidgetEditorMessages = {
|
||||
languageJsx: "JSX",
|
||||
languageJson: "JSON",
|
||||
undo: "Undo",
|
||||
redo: "Redo",
|
||||
components: "Components",
|
||||
componentSearch: "Search components",
|
||||
componentEmpty: "No components",
|
||||
componentCount: (count) => `${count} components`,
|
||||
insertStarter: "Insert starter",
|
||||
format: "Format",
|
||||
copy: "Copy",
|
||||
copied: "Copied",
|
||||
schema: "Schema",
|
||||
schemaTab: "JSON Schema",
|
||||
minimalTab: "Minimal",
|
||||
fullTab: "Full",
|
||||
errors: (count) => `${count} errors`,
|
||||
warnings: (count) => `${count} warnings`,
|
||||
ready: "Ready",
|
||||
position: ({ line, column }) => `Ln ${line}, Col ${column}`,
|
||||
characters: (count, limit) => (limit ? `${count} / ${limit}` : `${count} characters`),
|
||||
diagnosticsTitle: "Diagnostics",
|
||||
diagnostic: (diagnostic) => diagnostic.value ?? diagnostic.code,
|
||||
};
|
||||
|
||||
export function JsonOption({
|
||||
identity,
|
||||
editorId,
|
||||
label,
|
||||
description,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
identity: string;
|
||||
editorId: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
value: unknown;
|
||||
onChange(value: unknown): void;
|
||||
}) {
|
||||
const serializedValue = JSON.stringify(value ?? null, null, 2);
|
||||
const [draft, setDraft] = useState(serializedValue);
|
||||
const [error, setError] = useState<string>();
|
||||
const synchronizedValueRef = useRef(serializedValue);
|
||||
const identityRef = useRef(identity);
|
||||
useEffect(() => {
|
||||
if (identityRef.current === identity && synchronizedValueRef.current === serializedValue) return;
|
||||
identityRef.current = identity;
|
||||
synchronizedValueRef.current = serializedValue;
|
||||
setDraft(serializedValue);
|
||||
setError(undefined);
|
||||
}, [identity, serializedValue]);
|
||||
return (
|
||||
<CustomWidgetCodeEditor
|
||||
id={editorId}
|
||||
label={label}
|
||||
description={description}
|
||||
language="json"
|
||||
value={draft}
|
||||
height="160px"
|
||||
error={error}
|
||||
onChange={(next) => {
|
||||
setDraft(next);
|
||||
try {
|
||||
const parsed = JSON.parse(next) as unknown;
|
||||
synchronizedValueRef.current = JSON.stringify(parsed ?? null, null, 2);
|
||||
onChange(parsed);
|
||||
setError(undefined);
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : "Invalid JSON");
|
||||
}
|
||||
}}
|
||||
messages={editorMessages}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function coerceChoice(value: string | null, option: CustomWidgetOption) {
|
||||
if (value === null) return null;
|
||||
return typeof option.default === "number" ||
|
||||
option.choices?.some((choice) => typeof choice.value === "number" && String(choice.value) === value)
|
||||
? Number(value)
|
||||
: value;
|
||||
}
|
||||
|
||||
function primitiveConfiguration(value: Record<string, unknown>) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).filter((entry): entry is [string, string | number | boolean] =>
|
||||
["string", "number", "boolean"].includes(typeof entry[1]),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function getByPath(value: unknown, path: string): unknown {
|
||||
return path
|
||||
.replace(/^\$\.?/u, "")
|
||||
.split(".")
|
||||
.filter(Boolean)
|
||||
.reduce<unknown>((current, segment) => (isRecord(current) ? current[segment] : undefined), value);
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import type { Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { WidgetCustomWidgetSelectInput } from "./widget-custom-widget-select-input";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
available: [] as Array<Record<string, unknown>>,
|
||||
permissions: ["admin"] as string[],
|
||||
setFieldValue: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@mantine/core", () => {
|
||||
// oxlint-disable-next-line unicorn/consistent-function-scoping -- the mock factory is hoisted by Vitest
|
||||
const Container = ({ children }: { children?: ReactNode }) => <div>{children}</div>;
|
||||
const Combobox = Object.assign(Container, {
|
||||
Target: Container,
|
||||
Dropdown: Container,
|
||||
Options: Container,
|
||||
Group: Container,
|
||||
Option: Container,
|
||||
Empty: Container,
|
||||
Chevron: () => null,
|
||||
});
|
||||
|
||||
return {
|
||||
Alert: ({ children, title }: { children?: ReactNode; title?: ReactNode }) => (
|
||||
<div role="alert">
|
||||
<strong>{title}</strong>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
Anchor: ({ children, href }: { children?: ReactNode; href?: string }) => <a href={href}>{children}</a>,
|
||||
Avatar: Container,
|
||||
Badge: Container,
|
||||
Combobox,
|
||||
Group: Container,
|
||||
InputBase: ({ value }: { value?: string }) => <input aria-label="Custom widget" value={value} readOnly />,
|
||||
Loader: () => null,
|
||||
Stack: Container,
|
||||
Text: Container,
|
||||
useCombobox: () => ({
|
||||
dropdownOpened: false,
|
||||
resetSelectedOption: vi.fn(),
|
||||
closeDropdown: vi.fn(),
|
||||
openDropdown: vi.fn(),
|
||||
updateSelectedOptionIndex: vi.fn(),
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@homarr/api/client", () => ({
|
||||
clientApi: {
|
||||
customWidget: {
|
||||
available: {
|
||||
useQuery: () => ({ data: mocks.available, isLoading: false }),
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@homarr/auth/client", () => ({
|
||||
useSession: () => ({ data: { user: { permissions: mocks.permissions } } }),
|
||||
}));
|
||||
|
||||
vi.mock("@homarr/boards/context", () => ({
|
||||
useOptionalBoard: () => ({ id: "board-1" }),
|
||||
}));
|
||||
|
||||
vi.mock("@homarr/translation/client", () => ({
|
||||
useScopedI18n: () => (key: string) =>
|
||||
({
|
||||
migrationRequired: "Migration required",
|
||||
migrationDescription: "Copy its redacted prompt to an LLM, then paste the single JSON response into Homarr.",
|
||||
manageMigration: "Open Manage custom widgets",
|
||||
contactAdmin: "Contact an administrator.",
|
||||
})[key] ?? key,
|
||||
}));
|
||||
|
||||
vi.mock("@homarr/ui", () => ({ Link: "a" }));
|
||||
|
||||
vi.mock("./form", () => ({
|
||||
useFormContext: () => ({
|
||||
values: { options: { definitionId: "legacy-widget" } },
|
||||
setFieldValue: mocks.setFieldValue,
|
||||
}),
|
||||
}));
|
||||
|
||||
let root: Root;
|
||||
let host: HTMLDivElement;
|
||||
|
||||
beforeEach(() => {
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
mocks.available = [];
|
||||
mocks.permissions = ["admin"];
|
||||
mocks.setFieldValue.mockReset();
|
||||
host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
root = createRoot(host);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(() => root.unmount());
|
||||
host.remove();
|
||||
});
|
||||
|
||||
async function renderInput() {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<WidgetCustomWidgetSelectInput
|
||||
kind="customApi"
|
||||
property="definitionId"
|
||||
options={{ withDescription: false }}
|
||||
initialOptions={{}}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
describe("custom widget picker migration status", () => {
|
||||
it("keeps an existing legacy selection visible with migration guidance", async () => {
|
||||
mocks.available = [
|
||||
{
|
||||
id: "legacy-widget",
|
||||
name: "Legacy status",
|
||||
description: "Old API widget",
|
||||
iconUrl: null,
|
||||
sources: [],
|
||||
requestCapabilities: [],
|
||||
defaultOptions: {},
|
||||
updatedAt: new Date(0),
|
||||
migrationRequired: true,
|
||||
},
|
||||
];
|
||||
|
||||
await renderInput();
|
||||
|
||||
expect(host.querySelector("input")?.value).toBe("Legacy status");
|
||||
expect(host.textContent).toContain("Migration required");
|
||||
expect(host.textContent).toContain("Copy its redacted prompt to an LLM");
|
||||
expect(host.querySelector('a[href="/manage/custom-widgets"]')?.textContent).toBe("Open Manage custom widgets");
|
||||
});
|
||||
|
||||
it("tells non-admins to contact an administrator without linking management", async () => {
|
||||
mocks.permissions = ["board-modify-all"];
|
||||
mocks.available = [
|
||||
{
|
||||
id: "legacy-widget",
|
||||
name: "Legacy status",
|
||||
description: "Old API widget",
|
||||
iconUrl: null,
|
||||
sources: [],
|
||||
requestCapabilities: [],
|
||||
defaultOptions: {},
|
||||
updatedAt: new Date(0),
|
||||
migrationRequired: true,
|
||||
},
|
||||
];
|
||||
|
||||
await renderInput();
|
||||
|
||||
expect(host.textContent).toContain("Contact an administrator.");
|
||||
expect(host.querySelector('a[href="/manage/custom-widgets"]')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { Combobox, InputBase, Loader, useCombobox } from "@mantine/core";
|
||||
import {
|
||||
Alert,
|
||||
Anchor,
|
||||
Avatar,
|
||||
Badge,
|
||||
Combobox,
|
||||
Group,
|
||||
InputBase,
|
||||
Loader,
|
||||
Stack,
|
||||
Text,
|
||||
useCombobox,
|
||||
} from "@mantine/core";
|
||||
import { IconAlertTriangle, IconApi } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
import { useOptionalBoard } from "@homarr/boards/context";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { Link } from "@homarr/ui";
|
||||
|
||||
import type { CommonWidgetInputProps } from "./common";
|
||||
import { useWidgetInputTranslation } from "./common";
|
||||
@@ -15,18 +32,21 @@ export const WidgetCustomWidgetSelectInput = ({
|
||||
options,
|
||||
}: CommonWidgetInputProps<"customWidgetSelect">) => {
|
||||
const t = useWidgetInputTranslation(kind, property);
|
||||
const labels = useScopedI18n("widget.customApi.picker");
|
||||
const { data: session } = useSession();
|
||||
const isAdmin = session?.user.permissions.includes("admin") ?? false;
|
||||
const form = useFormContext();
|
||||
const { data, isLoading } = clientApi.customWidget.all.useQuery();
|
||||
const board = useOptionalBoard();
|
||||
const currentValue = form.values.options[property] as string;
|
||||
const { data, isLoading } = clientApi.customWidget.available.useQuery(
|
||||
{ boardId: board?.id ?? "", currentId: currentValue || undefined },
|
||||
{ enabled: board !== null },
|
||||
);
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const currentValue = form.values.options[property] as string;
|
||||
|
||||
const definitions = useMemo(
|
||||
() =>
|
||||
(data ?? [])
|
||||
.filter((def) => def.enabled || def.id === currentValue)
|
||||
.map((def) => ({ value: def.id, label: def.name })),
|
||||
[data, currentValue],
|
||||
() => (data ?? []).map((definition) => ({ ...definition, value: definition.id, label: definition.name })),
|
||||
[data],
|
||||
);
|
||||
|
||||
const filteredOptions = useMemo(
|
||||
@@ -39,8 +59,8 @@ export const WidgetCustomWidgetSelectInput = ({
|
||||
[definitions, search],
|
||||
);
|
||||
|
||||
const selectedLabel = definitions.find((d) => d.value === currentValue)?.label;
|
||||
|
||||
const selectedDefinition = definitions.find((definition) => definition.value === currentValue);
|
||||
const selectedLabel = selectedDefinition?.label;
|
||||
const combobox = useCombobox({
|
||||
onDropdownClose: () => {
|
||||
combobox.resetSelectedOption();
|
||||
@@ -49,43 +69,104 @@ export const WidgetCustomWidgetSelectInput = ({
|
||||
});
|
||||
|
||||
return (
|
||||
<Combobox
|
||||
store={combobox}
|
||||
onOptionSubmit={(val) => {
|
||||
form.setFieldValue(`options.${property}`, val);
|
||||
combobox.closeDropdown();
|
||||
}}
|
||||
>
|
||||
<Combobox.Target>
|
||||
<InputBase
|
||||
label={t("label")}
|
||||
description={options.withDescription ? t("description") : undefined}
|
||||
rightSection={isLoading ? <Loader size="xs" /> : <Combobox.Chevron />}
|
||||
rightSectionPointerEvents="none"
|
||||
value={combobox.dropdownOpened ? search : (selectedLabel ?? currentValue)}
|
||||
onChange={(event) => {
|
||||
setSearch(event.currentTarget.value);
|
||||
form.setFieldValue(`options.${property}`, event.currentTarget.value);
|
||||
combobox.openDropdown();
|
||||
combobox.updateSelectedOptionIndex();
|
||||
}}
|
||||
onClick={() => combobox.openDropdown()}
|
||||
onFocus={() => combobox.openDropdown()}
|
||||
onBlur={() => combobox.closeDropdown()}
|
||||
placeholder={t("label")}
|
||||
/>
|
||||
</Combobox.Target>
|
||||
<Stack gap="sm">
|
||||
<Combobox
|
||||
store={combobox}
|
||||
onOptionSubmit={(val) => {
|
||||
const definition = definitions.find((candidate) => candidate.value === val);
|
||||
if (!definition || definition.migrationRequired) return;
|
||||
form.setFieldValue(`options.${property}`, val);
|
||||
form.setFieldValue("options.configuration", definition?.defaultOptions ?? {});
|
||||
form.setFieldValue(
|
||||
"options.configurationVersion",
|
||||
definition?.updatedAt instanceof Date ? definition.updatedAt.getTime() : Date.now(),
|
||||
);
|
||||
combobox.closeDropdown();
|
||||
}}
|
||||
>
|
||||
<Combobox.Target>
|
||||
<InputBase
|
||||
label={t("label")}
|
||||
description={options.withDescription ? t("description") : undefined}
|
||||
rightSection={isLoading ? <Loader size="xs" /> : <Combobox.Chevron />}
|
||||
rightSectionPointerEvents="none"
|
||||
value={combobox.dropdownOpened ? search : (selectedLabel ?? currentValue)}
|
||||
onChange={(event) => {
|
||||
setSearch(event.currentTarget.value);
|
||||
combobox.openDropdown();
|
||||
combobox.updateSelectedOptionIndex();
|
||||
}}
|
||||
onClick={() => combobox.openDropdown()}
|
||||
onFocus={() => combobox.openDropdown()}
|
||||
onBlur={() => combobox.closeDropdown()}
|
||||
placeholder={t("label")}
|
||||
/>
|
||||
</Combobox.Target>
|
||||
|
||||
<Combobox.Dropdown>
|
||||
<Combobox.Options>
|
||||
{filteredOptions.map((def) => (
|
||||
<Combobox.Option key={def.value} value={def.value} active={def.value === currentValue}>
|
||||
{def.label}
|
||||
</Combobox.Option>
|
||||
))}
|
||||
{filteredOptions.length === 0 && !isLoading && <Combobox.Empty>No definitions found</Combobox.Empty>}
|
||||
</Combobox.Options>
|
||||
</Combobox.Dropdown>
|
||||
</Combobox>
|
||||
<Combobox.Dropdown>
|
||||
<Combobox.Options>
|
||||
<Combobox.Group label={labels("group")}>
|
||||
{filteredOptions.map((def) => (
|
||||
<Combobox.Option
|
||||
key={def.value}
|
||||
value={def.value}
|
||||
active={def.value === currentValue}
|
||||
disabled={def.migrationRequired}
|
||||
>
|
||||
<Group wrap="nowrap" gap="sm">
|
||||
<Avatar src={def.iconUrl} size={36} radius="md" color="blue">
|
||||
<IconApi size={18} />
|
||||
</Avatar>
|
||||
<Stack gap={1} style={{ minWidth: 0 }}>
|
||||
<Text size="sm" fw={600} truncate>
|
||||
{def.label}
|
||||
</Text>
|
||||
{def.description && (
|
||||
<Text size="xs" c="dimmed" lineClamp={1}>
|
||||
{def.description}
|
||||
</Text>
|
||||
)}
|
||||
<Group gap={4} mt={2}>
|
||||
{def.migrationRequired && (
|
||||
<Badge size="xs" variant="light" color="yellow">
|
||||
{labels("migrationRequired")}
|
||||
</Badge>
|
||||
)}
|
||||
<Badge size="xs" variant="light">
|
||||
{labels("sources", { count: def.sources.length })}
|
||||
</Badge>
|
||||
<Badge size="xs" variant="light" color="gray">
|
||||
{labels("requests", { count: def.requestCapabilities.length })}
|
||||
</Badge>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Group>
|
||||
</Combobox.Option>
|
||||
))}
|
||||
</Combobox.Group>
|
||||
{filteredOptions.length === 0 && !isLoading && <Combobox.Empty>{labels("empty")}</Combobox.Empty>}
|
||||
</Combobox.Options>
|
||||
</Combobox.Dropdown>
|
||||
</Combobox>
|
||||
{selectedDefinition?.migrationRequired && (
|
||||
<Alert
|
||||
color="yellow"
|
||||
variant="light"
|
||||
icon={<IconAlertTriangle size={18} />}
|
||||
title={labels("migrationRequired")}
|
||||
>
|
||||
<Text size="xs">{labels("migrationDescription")}</Text>
|
||||
{isAdmin ? (
|
||||
<Anchor component={Link} href="/manage/custom-widgets" size="xs" fw={600}>
|
||||
{labels("manageMigration")}
|
||||
</Anchor>
|
||||
) : (
|
||||
<Text size="xs" fw={600}>
|
||||
{labels("contactAdmin")}
|
||||
</Text>
|
||||
)}
|
||||
</Alert>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -17,7 +17,13 @@ export interface DynamicSelectOption {
|
||||
label: string;
|
||||
}
|
||||
|
||||
export const WidgetDynamicSelectInput = ({ property, kind, options }: CommonWidgetInputProps<"dynamicSelect">) => {
|
||||
export const WidgetDynamicSelectInput = ({
|
||||
property,
|
||||
kind,
|
||||
options,
|
||||
itemId,
|
||||
boardId,
|
||||
}: CommonWidgetInputProps<"dynamicSelect">) => {
|
||||
const t = useI18n();
|
||||
const tSelect = useScopedI18n("widget.dynamicSelect");
|
||||
const tWidget = useWidgetInputTranslation(kind, property);
|
||||
@@ -26,10 +32,10 @@ export const WidgetDynamicSelectInput = ({ property, kind, options }: CommonWidg
|
||||
const [search, setSearch] = useState("");
|
||||
const [debouncedSearch] = useDebouncedValue(search, 300);
|
||||
const {
|
||||
error: optionsError,
|
||||
isPending,
|
||||
isError = false,
|
||||
options: selectOptions,
|
||||
} = options.useOptions(debouncedSearch, form.values.integrationIds, form.values.options);
|
||||
} = options.useOptions(debouncedSearch, form.values.integrationIds, form.values.options, itemId, boardId);
|
||||
const currentOption = inputProps.value as DynamicSelectOption | null;
|
||||
const onChange = inputProps.onChange as (value: DynamicSelectOption | null) => void;
|
||||
|
||||
@@ -93,8 +99,7 @@ export const WidgetDynamicSelectInput = ({ property, kind, options }: CommonWidg
|
||||
description={options.withDescription ? tWidget("description") : undefined}
|
||||
searchable
|
||||
{...inputProps}
|
||||
disabled={isError}
|
||||
error={isError ? tSelect("loadError") : inputProps.error}
|
||||
error={inputProps.error ?? optionsError}
|
||||
value={currentOption === null ? null : currentOption.value}
|
||||
onChange={(selectedValue: string | null) => {
|
||||
if (selectedValue === null) {
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
import type { CommonWidgetInputProps } from "./common";
|
||||
|
||||
export const WidgetInternalInput = (_props: CommonWidgetInputProps<"internal">) => null;
|
||||
@@ -21,8 +21,9 @@ import { IconClick, IconListSearch } from "@tabler/icons-react";
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { createModal, useModalAction } from "@homarr/modals";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { useCurrentIntlLocale, useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { formatLocalizedCompactNumber } from "../common/locale";
|
||||
import type { OptionLocation } from "../options";
|
||||
import type { CommonWidgetInputProps } from "./common";
|
||||
import { useWidgetInputTranslation } from "./common";
|
||||
@@ -128,7 +129,7 @@ const LocationSearchModal = createModal<LocationSearchInnerProps>(({ actions, in
|
||||
if (error) {
|
||||
return (
|
||||
<Alert title={tCommon("error")} color="red">
|
||||
{error.message}
|
||||
{tCommon("action.tryAgain")}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
@@ -187,6 +188,7 @@ interface LocationSearchTableRowProps {
|
||||
|
||||
const LocationSelectTableRow = ({ city, onLocationSelect, closeModal }: LocationSearchTableRowProps) => {
|
||||
const t = useScopedI18n("widget.common.location.table");
|
||||
const locale = useCurrentIntlLocale();
|
||||
const onSelect = useCallback(() => {
|
||||
onLocationSelect({
|
||||
name: city.name,
|
||||
@@ -196,8 +198,6 @@ const LocationSelectTableRow = ({ city, onLocationSelect, closeModal }: Location
|
||||
closeModal();
|
||||
}, [city, onLocationSelect, closeModal]);
|
||||
|
||||
const formatter = Intl.NumberFormat("en", { notation: "compact" });
|
||||
|
||||
return (
|
||||
<Table.Tr>
|
||||
<Table.Td>
|
||||
@@ -215,7 +215,7 @@ const LocationSelectTableRow = ({ city, onLocationSelect, closeModal }: Location
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{city.population ? (
|
||||
<Text style={{ whiteSpace: "nowrap" }}>{formatter.format(city.population)}</Text>
|
||||
<Text style={{ whiteSpace: "nowrap" }}>{formatLocalizedCompactNumber(city.population, locale)}</Text>
|
||||
) : (
|
||||
<Text c="gray"> {t("population.fallback")}</Text>
|
||||
)}
|
||||
|
||||
@@ -272,19 +272,19 @@ const ProviderTokensSection = ({ itemId, repositories }: { itemId: string; repos
|
||||
const { data: configuredKinds = [], refetch } = clientApi.widget.secrets.getConfiguredKinds.useQuery({ itemId });
|
||||
const setSecret = clientApi.widget.secrets.setSecret.useMutation({
|
||||
onSuccess: () => refetch(),
|
||||
onError: (error) => {
|
||||
onError: () => {
|
||||
showErrorNotification({
|
||||
title: "Failed to save token",
|
||||
message: error.message,
|
||||
title: tRepository("tokens.label"),
|
||||
message: tRepository("tokens.notConfigured"),
|
||||
});
|
||||
},
|
||||
});
|
||||
const deleteSecret = clientApi.widget.secrets.deleteSecret.useMutation({
|
||||
onSuccess: () => refetch(),
|
||||
onError: (error) => {
|
||||
onError: () => {
|
||||
showErrorNotification({
|
||||
title: "Failed to delete token",
|
||||
message: error.message,
|
||||
title: tRepository("tokens.label"),
|
||||
message: tRepository("tokens.notConfigured"),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { supportsAdvancedFocus } from "./definition";
|
||||
import { widgetImports } from ".";
|
||||
|
||||
const compactOnlyWidgetKinds = [
|
||||
"app",
|
||||
"bazarr",
|
||||
"beszelAlerts",
|
||||
"beszelSystemGrid",
|
||||
"beszelSystemStats",
|
||||
"beszelSystemTable",
|
||||
"coolify",
|
||||
"dnsHoleControls",
|
||||
"dnsHoleSummary",
|
||||
"iframe",
|
||||
"indexerManager",
|
||||
"mediaRequests-requestList",
|
||||
"mediaRequests-requestStats",
|
||||
"minecraftServerStatus",
|
||||
"networkControllerSummary",
|
||||
"notebook",
|
||||
"notifications",
|
||||
"paperlessNgx",
|
||||
"patchmon",
|
||||
"releases",
|
||||
"rssFeed",
|
||||
"stockPrice",
|
||||
"systemDisks",
|
||||
"video",
|
||||
"vpn",
|
||||
] as const;
|
||||
|
||||
describe("advanced focus support", () => {
|
||||
test("is reserved for widgets with a distinct advanced experience", () => {
|
||||
const actualCompactOnlyKinds = Object.entries(widgetImports)
|
||||
.filter(([, widgetImport]) => !supportsAdvancedFocus(widgetImport.definition))
|
||||
.map(([kind]) => kind)
|
||||
.toSorted();
|
||||
|
||||
expect(actualCompactOnlyKinds).toEqual([...compactOnlyWidgetKinds].toSorted());
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,34 @@
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.homarr-anchor-actions {
|
||||
opacity: 0;
|
||||
transform: translateY(-2px);
|
||||
transition:
|
||||
opacity 120ms ease,
|
||||
transform 120ms ease;
|
||||
}
|
||||
|
||||
.homarr-anchor-note:hover .homarr-anchor-actions,
|
||||
.homarr-anchor-note:focus-within .homarr-anchor-actions,
|
||||
.homarr-anchor-actions[data-visible="true"] {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
@media (any-pointer: coarse) {
|
||||
.homarr-anchor-actions {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.homarr-anchor-actions {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
.homarr-anchor-quill .ql-toolbar.ql-snow,
|
||||
.homarr-anchor-quill .ql-container.ql-snow {
|
||||
border: 0;
|
||||
|
||||
@@ -3,19 +3,23 @@
|
||||
import { startTransition, useCallback, useEffect, useMemo, useState } from "react";
|
||||
import type { ComponentProps } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { Button, Center, Group, Stack, Text, TextInput } from "@mantine/core";
|
||||
import { ActionIcon, Badge, Button, Center, Group, Stack, Text, TextInput, Tooltip } from "@mantine/core";
|
||||
import { IconEdit } from "@tabler/icons-react";
|
||||
import { Quill } from "react-quill-new";
|
||||
import type { DeltaStatic } from "react-quill-new";
|
||||
import type ReactQuillComponent from "react-quill-new";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useIntegrationsWithInteractAccess } from "@homarr/auth/client";
|
||||
import { useTimeAgo } from "@homarr/common";
|
||||
import type { AnchorNotePermission } from "@homarr/integrations";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { useCurrentIntlLocale, useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { WidgetEmptyState } from "../common/empty-state";
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { getUsableWidgetQueryData } from "../common/query-state";
|
||||
import actionTargetClasses from "../common/action-target.module.css";
|
||||
|
||||
import "react-quill-new/dist/quill.snow.css";
|
||||
import "./anchor-note.css";
|
||||
@@ -106,6 +110,13 @@ const stringifyDelta = (delta: unknown): string => {
|
||||
return JSON.stringify(emptyDelta());
|
||||
};
|
||||
|
||||
export const storedContentToPlainText = (content?: string | null): string => {
|
||||
return parseStoredContent(content)
|
||||
.ops.map(({ insert }) => (typeof insert === "string" ? insert : ""))
|
||||
.join("")
|
||||
.trim();
|
||||
};
|
||||
|
||||
const canEditPermission = (permission: AnchorNotePermission) => {
|
||||
return permission === "owner" || permission === "editor";
|
||||
};
|
||||
@@ -119,7 +130,13 @@ const isForbiddenError = (error: unknown): boolean => {
|
||||
return (data as { code?: unknown }).code === "FORBIDDEN";
|
||||
};
|
||||
|
||||
export default function AnchorNoteWidget({ options, integrationIds }: WidgetComponentProps<"anchorNote">) {
|
||||
export default function AnchorNoteWidget({
|
||||
options,
|
||||
integrationIds,
|
||||
width,
|
||||
height,
|
||||
displayMode,
|
||||
}: WidgetComponentProps<"anchorNote">) {
|
||||
const t = useScopedI18n("widget.anchorNote");
|
||||
const noteId = options.noteId.trim();
|
||||
if (!noteId) {
|
||||
@@ -134,21 +151,43 @@ export default function AnchorNoteWidget({ options, integrationIds }: WidgetComp
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const integrationId = integrationIds[0]!;
|
||||
|
||||
return <AnchorNoteWidgetContent options={options} integrationId={integrationId} noteId={noteId} />;
|
||||
return (
|
||||
<AnchorNoteWidgetContent
|
||||
options={options}
|
||||
integrationId={integrationId}
|
||||
noteId={noteId}
|
||||
width={width}
|
||||
height={height}
|
||||
isAdvanced={displayMode === "advanced"}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface AnchorNoteWidgetContentProps {
|
||||
options: WidgetComponentProps<"anchorNote">["options"];
|
||||
integrationId: string;
|
||||
noteId: string;
|
||||
width: number;
|
||||
height: number;
|
||||
isAdvanced: boolean;
|
||||
}
|
||||
|
||||
const AnchorNoteWidgetContent = ({ options, integrationId, noteId }: AnchorNoteWidgetContentProps) => {
|
||||
const AnchorNoteWidgetContent = ({
|
||||
options,
|
||||
integrationId,
|
||||
noteId,
|
||||
width,
|
||||
height,
|
||||
isAdvanced,
|
||||
}: AnchorNoteWidgetContentProps) => {
|
||||
const t = useScopedI18n("widget.anchorNote");
|
||||
const { data: note, refetch } = clientApi.widget.anchorNotes.getNote.useQuery({
|
||||
const locale = useCurrentIntlLocale();
|
||||
const noteQuery = clientApi.widget.anchorNotes.getNote.useQuery({
|
||||
integrationId,
|
||||
noteId,
|
||||
});
|
||||
const note = getUsableWidgetQueryData(noteQuery);
|
||||
const { refetch } = noteQuery;
|
||||
const { mutateAsync: updateNoteAsync, isPending: isUpdating } = clientApi.widget.anchorNotes.updateNote.useMutation();
|
||||
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
@@ -165,7 +204,8 @@ const AnchorNoteWidgetContent = ({ options, integrationId, noteId }: AnchorNoteW
|
||||
});
|
||||
}, [isEditing, note]);
|
||||
|
||||
const canEdit = canEditPermission(note?.permission ?? "viewer");
|
||||
const hasInteractAccess = useIntegrationsWithInteractAccess().some(({ id }) => id === integrationId);
|
||||
const canEdit = canEditPermission(note?.permission ?? "viewer") && hasInteractAccess;
|
||||
const isViewer = !note || note.permission === "viewer";
|
||||
const updatedAt = useMemo(() => (note ? new Date(note.updatedAt) : new Date()), [note]);
|
||||
const updatedAtRelative = useTimeAgo(updatedAt, 30000);
|
||||
@@ -178,6 +218,7 @@ const AnchorNoteWidgetContent = ({ options, integrationId, noteId }: AnchorNoteW
|
||||
|
||||
const editorValue = useMemo(() => parseStoredContent(draftContent), [draftContent]);
|
||||
const readOnlyValue = useMemo(() => parseStoredContent(note?.content), [note?.content]);
|
||||
const plainText = useMemo(() => storedContentToPlainText(note?.content), [note?.content]);
|
||||
const handleEditorChange = useCallback<ReactQuillOnChange>(
|
||||
(_html, _delta, source, editor) => {
|
||||
if (!isEditing || source !== "user") return;
|
||||
@@ -260,7 +301,7 @@ const AnchorNoteWidgetContent = ({ options, integrationId, noteId }: AnchorNoteW
|
||||
if (!note) return <WidgetEmptyState />;
|
||||
|
||||
return (
|
||||
<Stack h="100%" gap="xs" p="sm">
|
||||
<Stack className="homarr-anchor-note" h="100%" gap="xs" p={height < 120 ? "xs" : "sm"}>
|
||||
<Group justify="space-between" align="flex-start">
|
||||
<Stack gap={2} style={{ flex: 1 }}>
|
||||
{isEditing ? (
|
||||
@@ -278,40 +319,85 @@ const AnchorNoteWidgetContent = ({ options, integrationId, noteId }: AnchorNoteW
|
||||
{t("readOnlyViewer")}
|
||||
</Text>
|
||||
)}
|
||||
{!isEditing && isAdvanced && (
|
||||
<Group gap={4}>
|
||||
<Badge size="xs" variant="light">
|
||||
{t(`permission.${note.permission}`)}
|
||||
</Badge>
|
||||
{note.isPinned && <Badge size="xs">{t("status.pinned")}</Badge>}
|
||||
{note.isArchived && (
|
||||
<Badge size="xs" color="gray">
|
||||
{t("status.archived")}
|
||||
</Badge>
|
||||
)}
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("createdAt", { date: new Date(note.createdAt).toLocaleDateString(locale) })}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
{saveError && (
|
||||
<Text size="xs" c="red">
|
||||
{saveError}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
<Group gap="xs">
|
||||
{isEditing ? (
|
||||
<>
|
||||
<Button size="xs" onClick={handleSave} loading={isUpdating} disabled={!hasChanges || !canEdit}>
|
||||
{t("save")}
|
||||
{(isEditing || canEdit) && (
|
||||
<Group className="homarr-anchor-actions" data-visible={isEditing || isAdvanced || undefined} gap="xs">
|
||||
{isEditing ? (
|
||||
<>
|
||||
<Button size="xs" onClick={handleSave} loading={isUpdating} disabled={!hasChanges || !canEdit}>
|
||||
{t("save")}
|
||||
</Button>
|
||||
<Button size="xs" variant="subtle" onClick={handleCancel} disabled={isUpdating}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
</>
|
||||
) : isAdvanced ? (
|
||||
<Button size="xs" variant="light" onClick={handleEdit} disabled={isUpdating}>
|
||||
{t("edit")}
|
||||
</Button>
|
||||
<Button size="xs" variant="subtle" onClick={handleCancel} disabled={isUpdating}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button size="xs" variant="light" onClick={handleEdit} disabled={!canEdit || isUpdating}>
|
||||
{t("edit")}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
) : (
|
||||
<Tooltip label={t("edit")}>
|
||||
<ActionIcon
|
||||
className={actionTargetClasses.root}
|
||||
aria-label={t("edit")}
|
||||
size="md"
|
||||
variant="light"
|
||||
onClick={handleEdit}
|
||||
disabled={isUpdating}
|
||||
>
|
||||
<IconEdit size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Group>
|
||||
)}
|
||||
</Group>
|
||||
<div className={`homarr-anchor-quill${isEditing ? "" : " homarr-anchor-quill--readonly"}`} style={{ flex: 1 }}>
|
||||
<ReactQuill
|
||||
theme="snow"
|
||||
readOnly={!isEditing}
|
||||
value={isEditing ? editorValue : readOnlyValue}
|
||||
onChange={handleEditorChange}
|
||||
modules={isEditing ? quillModules : readOnlyModules}
|
||||
formats={quillFormats}
|
||||
placeholder={t("emptyContent")}
|
||||
/>
|
||||
</div>
|
||||
{isEditing || isAdvanced ? (
|
||||
<div
|
||||
className={`homarr-anchor-quill${isEditing ? "" : " homarr-anchor-quill--readonly"}`}
|
||||
style={{ flex: 1, minHeight: 0 }}
|
||||
>
|
||||
<ReactQuill
|
||||
theme="snow"
|
||||
readOnly={!isEditing}
|
||||
value={isEditing ? editorValue : readOnlyValue}
|
||||
onChange={handleEditorChange}
|
||||
modules={isEditing ? quillModules : readOnlyModules}
|
||||
formats={quillFormats}
|
||||
placeholder={t("emptyContent")}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<Text
|
||||
size={width < 180 || height < 100 ? "xs" : "sm"}
|
||||
c={plainText ? undefined : "dimmed"}
|
||||
style={{ whiteSpace: "pre-wrap", overflowWrap: "anywhere" }}
|
||||
lineClamp={Math.max(2, Math.floor((height - 70) / 18))}
|
||||
>
|
||||
{plainText || t("emptyContent")}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -5,6 +5,8 @@ import { optionsBuilder } from "../options";
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("anchorNote", {
|
||||
icon: IconNotes,
|
||||
queryKey: [["widget", "anchorNotes"]],
|
||||
maxIntegrations: 1,
|
||||
supportedIntegrations: ["anchor"],
|
||||
createOptions() {
|
||||
return optionsBuilder.from((factory) => ({
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { Fragment, Suspense } from "react";
|
||||
import { Flex, rem, Stack, Text, Tooltip, UnstyledButton } from "@mantine/core";
|
||||
import { Box, Flex, Stack, Text, Tooltip, UnstyledButton } from "@mantine/core";
|
||||
import { IconMinus } from "@tabler/icons-react";
|
||||
import combineClasses from "clsx";
|
||||
|
||||
@@ -14,6 +14,9 @@ import { useI18n } from "@homarr/translation/client";
|
||||
import { MaskedOrNormalImage } from "@homarr/ui";
|
||||
|
||||
import { WidgetEmptyState } from "../common/empty-state";
|
||||
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url";
|
||||
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state";
|
||||
import { WidgetQueryErrorIndicator, WidgetQueryLoadingState } from "../common/query-state-indicator";
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import classes from "./app.module.css";
|
||||
import { PingDot } from "./ping/ping-dot";
|
||||
@@ -23,12 +26,12 @@ export default function AppWidget({ options, isEditMode, height, width }: Widget
|
||||
const t = useI18n();
|
||||
const settings = useSettings();
|
||||
const board = useRequiredBoard();
|
||||
const { data: app } = clientApi.app.byId.useQuery({
|
||||
id: options.appId,
|
||||
});
|
||||
const appQuery = clientApi.app.byId.useQuery({ id: options.appId });
|
||||
const app = getUsableWidgetQueryData(appQuery);
|
||||
const href = getSafeApplicationUrl(app?.href);
|
||||
useRegisterSpotlightContextResults(
|
||||
`app-${app?.id ?? options.appId}`,
|
||||
app?.href
|
||||
app && href
|
||||
? [
|
||||
{
|
||||
id: app.id,
|
||||
@@ -37,99 +40,102 @@ export default function AppWidget({ options, isEditMode, height, width }: Widget
|
||||
interaction() {
|
||||
return {
|
||||
type: "link",
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
href: app.href!,
|
||||
href,
|
||||
newTab: options.openInNewTab,
|
||||
};
|
||||
},
|
||||
},
|
||||
]
|
||||
: [],
|
||||
[app, options.openInNewTab],
|
||||
[app, href, options.openInNewTab],
|
||||
);
|
||||
|
||||
if (isInitialWidgetQueryPending(appQuery)) return <WidgetQueryLoadingState />;
|
||||
if (!app) return <WidgetEmptyState />;
|
||||
|
||||
const isTiny = height < 100 || width < 100;
|
||||
const isColumnLayout = options.layout.startsWith("column");
|
||||
|
||||
return (
|
||||
<AppLink
|
||||
href={app.href ?? undefined}
|
||||
openInNewTab={options.openInNewTab}
|
||||
enabled={Boolean(app.href) && !isEditMode}
|
||||
>
|
||||
<Tooltip.Floating
|
||||
label={app.description?.split("\n").map((line, index) => (
|
||||
<Fragment key={index}>
|
||||
{line}
|
||||
<br />
|
||||
</Fragment>
|
||||
))}
|
||||
position="right-start"
|
||||
multiline
|
||||
disabled={options.descriptionDisplayMode !== "tooltip" || !app.description || isEditMode}
|
||||
styles={{ tooltip: { maxWidth: 300 } }}
|
||||
>
|
||||
<Flex
|
||||
p={isTiny ? 4 : "sm"}
|
||||
className={combineClasses("app-flex-wrapper", app.name, app.id, app.href && classes.appWithUrl)}
|
||||
h="100%"
|
||||
w="100%"
|
||||
direction={options.layout}
|
||||
justify="center"
|
||||
align="center"
|
||||
gap={isColumnLayout ? 0 : "sm"}
|
||||
onContextMenu={isEditMode ? (e) => e.preventDefault() : undefined}
|
||||
<Box h="100%" w="100%" pos="relative">
|
||||
<AppLink href={href} openInNewTab={options.openInNewTab} enabled={Boolean(href) && !isEditMode}>
|
||||
<Tooltip.Floating
|
||||
label={app.description?.split("\n").map((line, index) => (
|
||||
<Fragment key={index}>
|
||||
{line}
|
||||
<br />
|
||||
</Fragment>
|
||||
))}
|
||||
position="right-start"
|
||||
multiline
|
||||
disabled={options.descriptionDisplayMode !== "tooltip" || !app.description || isEditMode}
|
||||
styles={{ tooltip: { maxWidth: 300 } }}
|
||||
>
|
||||
<Stack gap={0}>
|
||||
{options.showTitle && (
|
||||
<Text
|
||||
className="app-title"
|
||||
fw={700}
|
||||
size={isTiny ? rem(8) : "sm"}
|
||||
ta={isColumnLayout ? "center" : undefined}
|
||||
>
|
||||
{app.name}
|
||||
</Text>
|
||||
)}
|
||||
{options.descriptionDisplayMode === "normal" && (
|
||||
<Text
|
||||
className="app-description"
|
||||
size={isTiny ? rem(8) : "sm"}
|
||||
ta={isColumnLayout ? "center" : undefined}
|
||||
c="dimmed"
|
||||
lineClamp={4}
|
||||
>
|
||||
{app.description?.split("\n").map((line, index) => (
|
||||
<Fragment key={index}>
|
||||
{line}
|
||||
<br />
|
||||
</Fragment>
|
||||
))}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
<MaskedOrNormalImage
|
||||
imageUrl={app.iconUrl}
|
||||
hasColor={board.iconColor !== null}
|
||||
alt={app.name}
|
||||
className={combineClasses(classes.appIcon, "app-icon")}
|
||||
style={{
|
||||
height: "100%",
|
||||
width: "100%",
|
||||
minWidth: "20%",
|
||||
maxWidth: isColumnLayout ? undefined : "50%",
|
||||
}}
|
||||
/>
|
||||
</Flex>
|
||||
</Tooltip.Floating>
|
||||
{options.pingEnabled && !settings.forceDisableStatus && !board.disableStatus && app.href ? (
|
||||
<Suspense fallback={<PingDot icon={IconMinus} color="gray" tooltip={`${t("common.action.loading")}…`} />}>
|
||||
<PingIndicator appId={app.id} />
|
||||
</Suspense>
|
||||
) : null}
|
||||
</AppLink>
|
||||
<Flex
|
||||
p={isTiny ? 4 : "sm"}
|
||||
className={combineClasses("app-flex-wrapper", app.name, app.id, href && classes.appWithUrl)}
|
||||
h="100%"
|
||||
w="100%"
|
||||
direction={options.layout}
|
||||
justify="center"
|
||||
align="center"
|
||||
gap={isColumnLayout ? 0 : "sm"}
|
||||
onContextMenu={isEditMode ? (e) => e.preventDefault() : undefined}
|
||||
>
|
||||
<Stack gap={0}>
|
||||
{options.showTitle && (
|
||||
<Text
|
||||
className="app-title"
|
||||
fw={700}
|
||||
size={isTiny ? "xs" : "sm"}
|
||||
ta={isColumnLayout ? "center" : undefined}
|
||||
>
|
||||
{app.name}
|
||||
</Text>
|
||||
)}
|
||||
{options.descriptionDisplayMode === "normal" && !isTiny && (
|
||||
<Text
|
||||
className="app-description"
|
||||
size="sm"
|
||||
ta={isColumnLayout ? "center" : undefined}
|
||||
c="dimmed"
|
||||
lineClamp={Math.max(1, Math.floor((height - 48) / 18))}
|
||||
>
|
||||
{app.description?.split("\n").map((line, index) => (
|
||||
<Fragment key={index}>
|
||||
{line}
|
||||
<br />
|
||||
</Fragment>
|
||||
))}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
<MaskedOrNormalImage
|
||||
imageUrl={app.iconUrl}
|
||||
hasColor={board.iconColor !== null}
|
||||
alt={app.name}
|
||||
className={combineClasses(classes.appIcon, "app-icon")}
|
||||
style={{
|
||||
height: "100%",
|
||||
width: "100%",
|
||||
minWidth: "20%",
|
||||
maxWidth: isColumnLayout ? undefined : isTiny ? "38%" : "50%",
|
||||
}}
|
||||
/>
|
||||
</Flex>
|
||||
</Tooltip.Floating>
|
||||
{options.pingEnabled && !settings.forceDisableStatus && !board.disableStatus && app.href ? (
|
||||
<Suspense fallback={<PingDot icon={IconMinus} color="gray" tooltip={`${t("common.action.loading")}…`} />}>
|
||||
<PingIndicator appId={app.id} />
|
||||
</Suspense>
|
||||
) : null}
|
||||
</AppLink>
|
||||
{appQuery.error && (
|
||||
<Box pos="absolute" top={4} right={4}>
|
||||
<WidgetQueryErrorIndicator error={appQuery.error} label={t("widget.app.name")} />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -145,7 +151,7 @@ const AppLink = ({ href, openInNewTab, enabled, children }: PropsWithChildren<Ap
|
||||
component="a"
|
||||
href={href}
|
||||
target={openInNewTab ? "_blank" : undefined}
|
||||
rel="noreferrer"
|
||||
rel={openInNewTab ? SAFE_NEW_TAB_REL : undefined}
|
||||
h="100%"
|
||||
w="100%"
|
||||
>
|
||||
|
||||
@@ -15,6 +15,8 @@ import { optionsBuilder } from "../options";
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("app", {
|
||||
icon: IconApps,
|
||||
supportsAdvancedFocus: false,
|
||||
queryKeys: [[["app", "byId"]], [["widget", "app", "ping"]]],
|
||||
createOptions(settings) {
|
||||
return optionsBuilder.from(
|
||||
(factory) => ({
|
||||
|
||||
@@ -11,7 +11,9 @@ interface PingIndicatorProps {
|
||||
|
||||
export const PingIndicator = ({ appId }: PingIndicatorProps) => {
|
||||
const t = useI18n();
|
||||
const { data: pingResult } = clientApi.widget.app.ping.useQuery({ id: appId }, { refetchOnMount: false });
|
||||
const { data: pingResult, error } = clientApi.widget.app.ping.useQuery({ id: appId }, { refetchOnMount: false });
|
||||
|
||||
if (error) return <PingDot icon={IconX} color="red" tooltip={t("common.error")} />;
|
||||
|
||||
if (!pingResult) return <PingDot icon={IconMinus} color="gray" tooltip={`${t("common.action.loading")}…`} />;
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { trpc } from "@homarr/api/server";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { db, inArray } from "@homarr/db";
|
||||
import { apps } from "@homarr/db/schema";
|
||||
|
||||
import type { Prefetch } from "../definition";
|
||||
import { createTrpcQueryKey } from "../trpc-query-key";
|
||||
|
||||
const logger = createLogger({ module: "appWidgetPrefetch" });
|
||||
|
||||
@@ -16,7 +16,7 @@ const prefetchAllAsync: Prefetch<"app"> = async (queryClient, items) => {
|
||||
});
|
||||
|
||||
for (const app of dbApps) {
|
||||
queryClient.setQueryData(trpc.app.byId.queryKey({ id: app.id }), app);
|
||||
queryClient.setQueryData(createTrpcQueryKey("app.byId", { id: app.id }), app);
|
||||
}
|
||||
|
||||
logger.info("Successfully prefetched apps for app widget", { count: dbApps.length });
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { Avatar, Badge, Card, Group, SimpleGrid, Stack, Text } from "@mantine/core";
|
||||
import { Avatar, Badge, Card, Group, ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { formatByteRate } from "@homarr/common";
|
||||
import { getIconUrl } from "@homarr/definitions";
|
||||
import { useCurrentIntlLocale, useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
|
||||
export default function ArchiveTeamWarriorWidget({
|
||||
integrationIds,
|
||||
options,
|
||||
width,
|
||||
height,
|
||||
displayMode,
|
||||
}: WidgetComponentProps<"archiveTeamWarrior">) {
|
||||
const integrationId = integrationIds[0];
|
||||
|
||||
@@ -18,23 +22,47 @@ export default function ArchiveTeamWarriorWidget({
|
||||
return null;
|
||||
}
|
||||
|
||||
return <ArchiveTeamWarriorWidgetContent integrationId={integrationId} options={options} />;
|
||||
return (
|
||||
<ArchiveTeamWarriorWidgetContent
|
||||
integrationId={integrationId}
|
||||
options={options}
|
||||
width={width}
|
||||
height={height}
|
||||
isAdvanced={displayMode === "advanced"}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const ArchiveTeamWarriorWidgetContent = ({
|
||||
integrationId,
|
||||
options,
|
||||
width,
|
||||
height,
|
||||
isAdvanced,
|
||||
}: {
|
||||
integrationId: string;
|
||||
options: WidgetComponentProps<"archiveTeamWarrior">["options"];
|
||||
width: number;
|
||||
height: number;
|
||||
isAdvanced: boolean;
|
||||
}) => {
|
||||
const t = useScopedI18n("widget.archiveTeamWarrior");
|
||||
const locale = useCurrentIntlLocale();
|
||||
const [data] = clientApi.widget.archiveTeamWarrior.getStatus.useSuspenseQuery({ integrationId });
|
||||
|
||||
const status = data.status;
|
||||
const projectName = status.project?.title ?? status.selectedProject ?? "No project selected";
|
||||
const projectName = status.project?.title ?? status.selectedProject ?? t("noProjectSelected");
|
||||
const statusKey = getStatusKey(status.status);
|
||||
const layout = getArchiveCompactLayout(width, height, isAdvanced);
|
||||
const countMetrics = [
|
||||
{ label: t("metric.running"), value: status.counts.running },
|
||||
...(layout.showSecondaryCounts ? [{ label: t("metric.completed"), value: status.counts.completed }] : []),
|
||||
{ label: t("metric.failed"), value: status.counts.failed },
|
||||
...(layout.showSecondaryCounts ? [{ label: t("metric.canceled"), value: status.counts.canceled }] : []),
|
||||
];
|
||||
|
||||
return (
|
||||
<Stack p="xs" gap="xs" h="100%">
|
||||
<Stack p="xs" gap={layout.gap} h="100%" style={{ overflow: "hidden" }}>
|
||||
<Group justify="space-between" align="center" wrap="nowrap">
|
||||
<Group gap="xs" wrap="nowrap" miw={0}>
|
||||
<Avatar size="sm" radius="md" src={getIconUrl("archiveTeamWarrior")} />
|
||||
@@ -43,12 +71,12 @@ const ArchiveTeamWarriorWidgetContent = ({
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
<Badge size="sm" color={status.status === "Running" ? "green" : "gray"}>
|
||||
{status.status}
|
||||
<Badge size="sm" color={getStatusColor(statusKey)}>
|
||||
{t(`status.${statusKey}`)}
|
||||
</Badge>
|
||||
</Group>
|
||||
|
||||
{options.showBroadcastMessage && status.broadcastMessage && (
|
||||
{options.showBroadcastMessage && status.broadcastMessage && layout.showBroadcast && (
|
||||
<Card withBorder p="xs">
|
||||
<Text size="xs" lineClamp={3}>
|
||||
{status.broadcastMessage}
|
||||
@@ -56,32 +84,112 @@ const ArchiveTeamWarriorWidgetContent = ({
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<SimpleGrid cols={2} spacing="xs">
|
||||
<Metric label="Running" value={status.counts.running} />
|
||||
<Metric label="Done" value={status.counts.completed} />
|
||||
<Metric label="Failed" value={status.counts.failed} />
|
||||
<Metric label="Canceled" value={status.counts.canceled} />
|
||||
<SimpleGrid cols={layout.metricColumns} spacing={layout.gap}>
|
||||
{countMetrics.map((metric) => (
|
||||
<Metric key={metric.label} {...metric} dense={layout.dense} />
|
||||
))}
|
||||
</SimpleGrid>
|
||||
|
||||
{status.bandwidth && (
|
||||
<SimpleGrid cols={2} spacing="xs">
|
||||
<Metric label="Download" value={formatBandwidth(status.bandwidth.receiving)} />
|
||||
<Metric label="Upload" value={formatBandwidth(status.bandwidth.sending)} />
|
||||
{status.bandwidth && layout.showBandwidth && (
|
||||
<SimpleGrid cols={2} spacing={layout.gap}>
|
||||
<Metric label={t("metric.download")} value={formatBandwidth(status.bandwidth.receiving)} dense />
|
||||
<Metric label={t("metric.upload")} value={formatBandwidth(status.bandwidth.sending)} dense />
|
||||
</SimpleGrid>
|
||||
)}
|
||||
{isAdvanced && (
|
||||
<>
|
||||
<Group justify="space-between" gap="xs">
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("runnerStatus", { status: t(`status.${getStatusKey(status.runnerStatus ?? status.status)}`) })}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("updatedAt", { date: new Date(status.updatedAt).toLocaleString(locale) })}
|
||||
</Text>
|
||||
</Group>
|
||||
<ScrollArea style={{ flex: 1 }}>
|
||||
<Stack gap={4}>
|
||||
{status.items.map((item) => {
|
||||
const itemStatusKey = getStatusKey(item.status);
|
||||
return (
|
||||
<Card key={item.id} withBorder p="xs">
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Stack gap={0} miw={0}>
|
||||
<Text size="sm" fw={600} lineClamp={1}>
|
||||
{item.name}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" lineClamp={1}>
|
||||
{item.project ?? projectName}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Badge size="xs" color={getStatusColor(itemStatusKey)}>
|
||||
{t(`status.${itemStatusKey}`)}
|
||||
</Badge>
|
||||
</Group>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</ScrollArea>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
const Metric = ({ label, value }: { label: string; value: number | string }) => (
|
||||
const Metric = ({ label, value, dense = false }: { label: string; value: number | string; dense?: boolean }) => (
|
||||
<Stack gap={0} miw={0}>
|
||||
<Text size="md" fw={700} lineClamp={1}>
|
||||
<Text size={dense ? "sm" : "md"} fw={700} lineClamp={1} title={String(value)}>
|
||||
{value}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" lineClamp={1}>
|
||||
<Text size="xs" c="dimmed" lineClamp={1} title={label}>
|
||||
{label}
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
export const getArchiveCompactLayout = (width: number, height: number, isAdvanced = false) => {
|
||||
const dense = !isAdvanced && (width < 240 || height < 160);
|
||||
const showSecondaryCounts = isAdvanced || (width >= 200 && height >= 120);
|
||||
const metricCount = showSecondaryCounts ? 4 : 2;
|
||||
|
||||
return {
|
||||
dense,
|
||||
gap: dense ? 4 : "xs",
|
||||
metricColumns: height < 160 ? metricCount : Math.min(metricCount, width >= 260 ? 4 : 2),
|
||||
showBandwidth: isAdvanced || height >= 190,
|
||||
showBroadcast: isAdvanced || height >= 240,
|
||||
showSecondaryCounts,
|
||||
} as const;
|
||||
};
|
||||
|
||||
const formatBandwidth = (value?: number) => formatByteRate(Math.round(value ?? 0));
|
||||
|
||||
type WarriorStatusKey = "running" | "completed" | "failed" | "canceled" | "stopped" | "idle" | "unknown";
|
||||
|
||||
const getStatusKey = (status?: string | null): WarriorStatusKey => {
|
||||
switch (status?.trim().toLowerCase()) {
|
||||
case "running":
|
||||
return "running";
|
||||
case "done":
|
||||
case "completed":
|
||||
return "completed";
|
||||
case "failed":
|
||||
return "failed";
|
||||
case "canceled":
|
||||
case "cancelled":
|
||||
return "canceled";
|
||||
case "stopped":
|
||||
return "stopped";
|
||||
case "idle":
|
||||
return "idle";
|
||||
default:
|
||||
return "unknown";
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status: WarriorStatusKey): string => {
|
||||
if (status === "running") return "green";
|
||||
if (status === "failed") return "red";
|
||||
if (status === "canceled" || status === "stopped") return "yellow";
|
||||
return "gray";
|
||||
};
|
||||
@@ -5,6 +5,7 @@ import { optionsBuilder } from "../options";
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("archiveTeamWarrior", {
|
||||
icon: IconArchive,
|
||||
maxIntegrations: 1,
|
||||
createOptions() {
|
||||
return optionsBuilder.from((factory) => ({
|
||||
showBroadcastMessage: factory.switch({
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { getArchiveCompactLayout } from "./component";
|
||||
|
||||
describe("archive team warrior compact layout", () => {
|
||||
test("keeps only primary counters in a minimum-size widget", () => {
|
||||
expect(getArchiveCompactLayout(180, 100)).toMatchObject({
|
||||
metricColumns: 2,
|
||||
showBandwidth: false,
|
||||
showBroadcast: false,
|
||||
showSecondaryCounts: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("adds secondary information as height becomes available", () => {
|
||||
expect(getArchiveCompactLayout(320, 260)).toMatchObject({
|
||||
metricColumns: 4,
|
||||
showBandwidth: true,
|
||||
showBroadcast: true,
|
||||
showSecondaryCounts: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -8,7 +8,14 @@ import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import classes from "./component.module.css";
|
||||
import type { AudioStatsBackend, AudioStatsDisplayOptions } from "./shared";
|
||||
import { AUDIO_STATS_TRANSLATION_SCOPE, getGridCols, getIconSize, getVisibleStats } from "./shared";
|
||||
import {
|
||||
AUDIO_STATS_TRANSLATION_SCOPE,
|
||||
getGridCols,
|
||||
getIconSize,
|
||||
prioritizeVisibleStats,
|
||||
getVisibleStatLimit,
|
||||
getVisibleStats,
|
||||
} from "./shared";
|
||||
|
||||
const rootClassByCompact = {
|
||||
true: classes.rootCompact,
|
||||
@@ -30,14 +37,17 @@ interface AudioStatsContentProps {
|
||||
stats: NavidromeDashboardData | AudiobookshelfDashboardData;
|
||||
options: AudioStatsDisplayOptions;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export function AudioStatsContent({ backend, stats, options, width }: AudioStatsContentProps) {
|
||||
export function AudioStatsContent({ backend, stats, options, width, height }: AudioStatsContentProps) {
|
||||
const t = useScopedI18n(AUDIO_STATS_TRANSLATION_SCOPE);
|
||||
const visibleStats = getVisibleStats(backend, options, stats);
|
||||
const compactKey = String(options.compactMode ?? false) as keyof typeof rootClassByCompact;
|
||||
const gridCols = getGridCols(width, visibleStats.length, options.compactMode ?? false);
|
||||
const iconSize = getIconSize(width, options.compactMode ?? false);
|
||||
const compact = options.compactMode ?? false;
|
||||
const enabledStats = prioritizeVisibleStats(getVisibleStats(backend, options, stats), compact);
|
||||
const visibleStats = enabledStats.slice(0, getVisibleStatLimit(width, height, enabledStats.length, compact));
|
||||
const compactKey = String(compact) as keyof typeof rootClassByCompact;
|
||||
const gridCols = getGridCols(width, visibleStats.length, compact);
|
||||
const iconSize = getIconSize(width, compact);
|
||||
|
||||
return (
|
||||
<div className={`${classes.root} ${rootClassByCompact[compactKey]}`}>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, test } from "vitest";
|
||||
|
||||
import type { AudiobookshelfDashboardData, NavidromeDashboardData } from "@homarr/integrations/types";
|
||||
|
||||
import { getGridCols, getIconSize, getVisibleStats } from "./shared";
|
||||
import { getGridCols, getIconSize, getVisibleStatLimit, getVisibleStats, prioritizeVisibleStats } from "./shared";
|
||||
|
||||
describe("getGridCols", () => {
|
||||
test("caps columns to visible stat count", () => {
|
||||
@@ -37,6 +37,17 @@ describe("getIconSize", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("getVisibleStatLimit", () => {
|
||||
test("keeps compact metrics legible when height is constrained", () => {
|
||||
expect(getVisibleStatLimit(100, 80, 5, true)).toBe(1);
|
||||
expect(getVisibleStatLimit(180, 50, 5, true)).toBe(3);
|
||||
});
|
||||
|
||||
test("preserves every configured metric outside compact mode", () => {
|
||||
expect(getVisibleStatLimit(100, 80, 5, false)).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getVisibleStats", () => {
|
||||
const navidromeStats: NavidromeDashboardData = {
|
||||
artistCount: 100,
|
||||
@@ -91,3 +102,21 @@ describe("getVisibleStats", () => {
|
||||
expect(typeof result[0]?.value).toBe("string");
|
||||
});
|
||||
});
|
||||
|
||||
describe("prioritizeVisibleStats", () => {
|
||||
test("puts an active session first only on compact surfaces", () => {
|
||||
const stats = getVisibleStats(
|
||||
"audiobookshelf",
|
||||
{ showLibraryCount: true, showActiveSessions: true },
|
||||
{
|
||||
libraryCount: 3,
|
||||
totalAudiobooks: 150,
|
||||
totalPodcasts: 20,
|
||||
totalListeningTimeSeconds: 86400,
|
||||
activeSessions: 2,
|
||||
},
|
||||
);
|
||||
expect(prioritizeVisibleStats(stats, true)[0]?.statKey).toBe("activeSessions");
|
||||
expect(prioritizeVisibleStats(stats, false)[0]?.statKey).toBe("libraryCount");
|
||||
});
|
||||
});
|
||||
@@ -1,17 +1,120 @@
|
||||
"use client";
|
||||
|
||||
import { Badge, Group, ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core";
|
||||
import { getQueryKey } from "@trpc/react-query";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { formatBytes, formatDuration } from "@homarr/common";
|
||||
import type { AudiobookshelfDashboardData } from "@homarr/integrations/types";
|
||||
|
||||
import { WidgetEmptyState } from "../common/empty-state";
|
||||
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { useWidgetRuntimeQueries } from "../runtime-hooks";
|
||||
import { AudioStatsContent } from "./audio-stats-content";
|
||||
|
||||
export default function AudioStatsWidget({ integrationIds, options, width }: WidgetComponentProps<"audioStats">) {
|
||||
const { data: response } = clientApi.widget.audioStats.getStats.useQuery({
|
||||
integrationId: integrationIds[0] ?? "",
|
||||
});
|
||||
export default function AudioStatsWidget({
|
||||
integrationIds,
|
||||
options,
|
||||
width,
|
||||
height,
|
||||
displayMode = "compact",
|
||||
widgetRuntimeRef,
|
||||
}: WidgetComponentProps<"audioStats">) {
|
||||
const statsInput = { integrationId: integrationIds[0] ?? "" };
|
||||
const { data: response, error: statsError } = clientApi.widget.audioStats.getStats.useQuery(statsInput);
|
||||
const streamsInput = { integrationIds: [statsInput.integrationId], showOnlyPlaying: true };
|
||||
const streamsEnabled = displayMode === "advanced" && response?.kind === "navidrome";
|
||||
const { data: streamResults, error: streamsError } = clientApi.widget.mediaServer.getCurrentStreams.useQuery(
|
||||
streamsInput,
|
||||
{ enabled: streamsEnabled },
|
||||
);
|
||||
useWidgetRuntimeQueries(widgetRuntimeRef, [
|
||||
getQueryKey(clientApi.widget.audioStats.getStats, statsInput, "query"),
|
||||
...(streamsEnabled ? [getQueryKey(clientApi.widget.mediaServer.getCurrentStreams, streamsInput, "query")] : []),
|
||||
]);
|
||||
|
||||
if (statsError && response === undefined) throw statsError;
|
||||
if (streamsEnabled && streamsError && streamResults === undefined) throw streamsError;
|
||||
const currentStreams = streamResults ?? [];
|
||||
if (!response) return <WidgetEmptyState />;
|
||||
|
||||
return <AudioStatsContent backend={response.kind} stats={response.data} options={options} width={width} />;
|
||||
const summary = (
|
||||
<AudioStatsContent
|
||||
backend={response.kind}
|
||||
stats={response.data}
|
||||
options={{ ...options, compactMode: displayMode === "compact" && (options.compactMode || height < 160) }}
|
||||
width={width}
|
||||
height={height}
|
||||
/>
|
||||
);
|
||||
if (displayMode === "compact") return summary;
|
||||
|
||||
const sessions = currentStreams.flatMap((result) =>
|
||||
result.sessions.map((session) => ({ integrationId: result.integrationId, session })),
|
||||
);
|
||||
const audiobookStats = response.kind === "audiobookshelf" ? (response.data as AudiobookshelfDashboardData) : null;
|
||||
const advancedGridColumns = width >= 760 ? 2 : 1;
|
||||
const hasStreamErrors = currentStreams.some(({ error }) => Boolean(error));
|
||||
return (
|
||||
<Stack h="100%" gap="lg" p="md">
|
||||
<div style={{ minHeight: 150 }}>{summary}</div>
|
||||
{hasStreamErrors && (
|
||||
<Group justify="flex-end">
|
||||
<IntegrationErrorIndicator results={currentStreams} />
|
||||
</Group>
|
||||
)}
|
||||
<ScrollArea style={{ flex: 1, minHeight: 0 }}>
|
||||
{response.kind === "navidrome" ? (
|
||||
<SimpleGrid cols={advancedGridColumns} spacing="xs">
|
||||
{sessions.map(({ integrationId, session }) => {
|
||||
const currentlyPlaying = session.currentlyPlaying;
|
||||
if (!currentlyPlaying) return null;
|
||||
return (
|
||||
<Group key={`${integrationId}:${session.sessionId}`} justify="space-between" wrap="nowrap" p="sm">
|
||||
<Stack gap={0} style={{ minWidth: 0, flex: 1 }}>
|
||||
<Text size="sm" fw={600} truncate>
|
||||
{currentlyPlaying.name}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{currentlyPlaying.seasonName ?? currentlyPlaying.albumName ?? session.user?.username ?? "—"}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Badge
|
||||
variant="light"
|
||||
maw="40%"
|
||||
h="auto"
|
||||
py={4}
|
||||
style={{ flexShrink: 0 }}
|
||||
styles={{ label: { whiteSpace: "normal", overflowWrap: "anywhere", lineHeight: 1.2 } }}
|
||||
>
|
||||
{session.sessionName}
|
||||
</Badge>
|
||||
</Group>
|
||||
);
|
||||
})}
|
||||
</SimpleGrid>
|
||||
) : (
|
||||
<SimpleGrid cols={advancedGridColumns} spacing="xs">
|
||||
{(audiobookStats?.libraries ?? []).map((library) => (
|
||||
<Stack key={library.id} gap={2} p="sm">
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Text size="sm" fw={600} truncate>
|
||||
{library.name}
|
||||
</Text>
|
||||
<Badge size="xs" variant="light">
|
||||
{library.totalItems}
|
||||
</Badge>
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed">
|
||||
{library.totalDurationSeconds === null ? "—" : formatDuration(library.totalDurationSeconds * 1000)} ·{" "}
|
||||
{library.totalSizeBytes === null ? "—" : formatBytes(library.totalSizeBytes)}
|
||||
</Text>
|
||||
</Stack>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { IconHeadphones, IconServerOff } from "@tabler/icons-react";
|
||||
|
||||
import { createWidgetDefinition } from "../definition";
|
||||
import { createWidgetDefinition, matchesWidgetRuntimeQuery } from "../definition";
|
||||
import { optionsBuilder } from "../options";
|
||||
|
||||
const hideUnlessNavidrome = {
|
||||
@@ -13,6 +13,8 @@ const hideUnlessAudiobookshelf = {
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("audioStats", {
|
||||
icon: IconHeadphones,
|
||||
queryKeys: [[["widget", "audioStats", "getStats"]], [["widget", "mediaServer", "getCurrentStreams"]]],
|
||||
queryMatcher: matchesWidgetRuntimeQuery,
|
||||
supportedIntegrations: ["navidrome", "audiobookshelf"],
|
||||
integrationsRequired: true,
|
||||
maxIntegrations: 1,
|
||||
|
||||
@@ -155,6 +155,23 @@ export const getGridCols = (width: number, visibleCount: number, compact: boolea
|
||||
return Math.min(maxCols, Math.max(visibleCount, 1));
|
||||
};
|
||||
|
||||
export const getVisibleStatLimit = (width: number, height: number, visibleCount: number, compact: boolean): number => {
|
||||
if (!compact) return visibleCount;
|
||||
|
||||
const columns = getGridCols(width, visibleCount, true);
|
||||
const rows = Math.max(1, Math.floor(Math.max(0, height - 8) / 44));
|
||||
return Math.min(visibleCount, columns * rows);
|
||||
};
|
||||
|
||||
export const prioritizeVisibleStats = (stats: VisibleStat[], compact: boolean): VisibleStat[] => {
|
||||
if (!compact) return stats;
|
||||
return stats.toSorted(
|
||||
(left, right) =>
|
||||
Number(right.statKey === "activeSessions" && Number(right.value) > 0) -
|
||||
Number(left.statKey === "activeSessions" && Number(left.value) > 0),
|
||||
);
|
||||
};
|
||||
|
||||
const fallbackIconSize = { true: 14, false: 16 } as const;
|
||||
|
||||
export const getIconSize = (width: number, compact: boolean): number => {
|
||||
|
||||
@@ -33,6 +33,16 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.grid[data-short] .statTile {
|
||||
flex-direction: row;
|
||||
gap: 3px;
|
||||
padding: 3px 4px;
|
||||
}
|
||||
|
||||
.grid[data-short] .statLabel {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.statTileWarning {
|
||||
background-color: light-dark(var(--mantine-color-orange-0), var(--mantine-color-dark-5));
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ const statIcons = {
|
||||
} as const;
|
||||
|
||||
const gridColsByWidth = [
|
||||
{ minWidth: 640, cols: 4 },
|
||||
{ minWidth: 380, cols: 2 },
|
||||
{ minWidth: 0, cols: 1 },
|
||||
] as const;
|
||||
@@ -36,13 +37,14 @@ const iconSizeByWidth = [
|
||||
{ minWidth: 0, size: 16 },
|
||||
] as const;
|
||||
|
||||
export default function BazarrWidget({ integrationIds, options, width }: WidgetComponentProps<"bazarr">) {
|
||||
export default function BazarrWidget({ integrationIds, options, width, height }: WidgetComponentProps<"bazarr">) {
|
||||
const t = useScopedI18n("widget.bazarr");
|
||||
const { data: badges } = clientApi.widget.bazarr.getBadges.useQuery(
|
||||
const { data: badges, error } = clientApi.widget.bazarr.getBadges.useQuery(
|
||||
{ integrationId: integrationIds[0] ?? "" },
|
||||
{ enabled: Boolean(integrationIds[0]) },
|
||||
);
|
||||
|
||||
if (error && !badges) throw error;
|
||||
if (!badges) return <WidgetEmptyState />;
|
||||
|
||||
const statValues = {
|
||||
@@ -56,8 +58,8 @@ export default function BazarrWidget({ integrationIds, options, width }: WidgetC
|
||||
.filter(([optionKey]) => options[optionKey as keyof typeof options])
|
||||
.map(([, statKey]) => statKey);
|
||||
|
||||
const gridCols = getGridCols(width);
|
||||
const iconSize = getIconSize(width);
|
||||
const gridCols = getGridCols(width, height, visibleStatKeys.length);
|
||||
const iconSize = getIconSize(Math.min(width, height));
|
||||
|
||||
if (visibleStatKeys.length === 0) {
|
||||
return (
|
||||
@@ -73,7 +75,11 @@ export default function BazarrWidget({ integrationIds, options, width }: WidgetC
|
||||
|
||||
return (
|
||||
<div className={classes.root}>
|
||||
<div className={classes.grid} style={{ "--stat-cols": gridCols } as CSSProperties}>
|
||||
<div
|
||||
className={classes.grid}
|
||||
data-short={height < 120 || undefined}
|
||||
style={{ "--stat-cols": gridCols } as CSSProperties}
|
||||
>
|
||||
{visibleStatKeys.map((statKey) => {
|
||||
const Icon = statIcons[statKey];
|
||||
const value = statValues[statKey];
|
||||
@@ -83,7 +89,9 @@ export default function BazarrWidget({ integrationIds, options, width }: WidgetC
|
||||
<div key={statKey} className={`${classes.statTile} ${isWarning ? classes.statTileWarning : ""}`}>
|
||||
<Icon className={classes.statIcon} size={iconSize} stroke={1.5} />
|
||||
<span className={`${classes.statValue} ${isWarning ? classes.statValueWarning : ""}`}>{value}</span>
|
||||
<span className={classes.statLabel}>{t(statKey)}</span>
|
||||
<span className={classes.statLabel} title={t(statKey)}>
|
||||
{t(statKey)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -92,12 +100,17 @@ export default function BazarrWidget({ integrationIds, options, width }: WidgetC
|
||||
);
|
||||
}
|
||||
|
||||
function getGridCols(width: number): number {
|
||||
const match = gridColsByWidth.find(({ minWidth }) => width >= minWidth);
|
||||
return match?.cols ?? 1;
|
||||
export function getGridCols(width: number, height: number, itemCount: number): number {
|
||||
if (itemCount <= 1) return 1;
|
||||
|
||||
const preferredColumns = gridColsByWidth.find(({ minWidth }) => width >= minWidth)?.cols ?? 1;
|
||||
const maxColumnsByWidth = Math.max(1, Math.floor(width / 100));
|
||||
const maxRowsByHeight = Math.max(1, Math.floor(height / 72));
|
||||
const columnsNeededToFit = Math.ceil(itemCount / maxRowsByHeight);
|
||||
return Math.min(itemCount, maxColumnsByWidth, Math.max(preferredColumns, columnsNeededToFit));
|
||||
}
|
||||
|
||||
function getIconSize(width: number): number {
|
||||
export function getIconSize(width: number): number {
|
||||
const match = iconSizeByWidth.find(({ minWidth }) => width >= minWidth);
|
||||
return match?.size ?? 16;
|
||||
}
|
||||
@@ -4,9 +4,11 @@ import { createWidgetDefinition } from "../definition";
|
||||
import { optionsBuilder } from "../options";
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("bazarr", {
|
||||
supportsAdvancedFocus: false,
|
||||
icon: IconBadgeCc,
|
||||
supportedIntegrations: ["bazarr"],
|
||||
integrationsRequired: true,
|
||||
maxIntegrations: 1,
|
||||
createOptions() {
|
||||
return optionsBuilder.from((factory) => ({
|
||||
showMissingEpisodes: factory.switch({
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { getGridCols, getIconSize } from "./component";
|
||||
|
||||
describe("Bazarr responsive layout", () => {
|
||||
test("uses two columns for a short widget so all stats remain visible", () => {
|
||||
expect(getGridCols(220, 100, 4)).toBe(2);
|
||||
});
|
||||
|
||||
test("uses four columns in a wide advanced surface", () => {
|
||||
expect(getGridCols(800, 400, 4)).toBe(4);
|
||||
});
|
||||
|
||||
test("sizes icons from the constrained dimension", () => {
|
||||
expect(getIconSize(180)).toBe(16);
|
||||
expect(getIconSize(340)).toBe(22);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { Badge, Box, Center, Divider, Group, Loader, ScrollArea, Stack, Text, ThemeIcon } from "@mantine/core";
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
Center,
|
||||
Divider,
|
||||
Group,
|
||||
Loader,
|
||||
ScrollArea,
|
||||
Stack,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Tooltip,
|
||||
VisuallyHidden,
|
||||
} from "@mantine/core";
|
||||
import { IconBellOff, IconCircleCheck, IconFlame, IconHistory } from "@tabler/icons-react";
|
||||
import dayjs from "dayjs";
|
||||
import relativeTime from "dayjs/plugin/relativeTime";
|
||||
@@ -12,7 +25,8 @@ import { clientApi } from "@homarr/api/client";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { BeszelIntegrationErrorIndicator } from "../beszel/_shared/error-indicator";
|
||||
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
|
||||
import { getUsableWidgetQueryData } from "../common/query-state";
|
||||
|
||||
const alertIconMap: Record<string, LucideIcon> = {
|
||||
CPU: Cpu,
|
||||
@@ -35,13 +49,16 @@ export default function BeszelAlertsWidget({
|
||||
options,
|
||||
integrationIds,
|
||||
isEditMode,
|
||||
height,
|
||||
}: WidgetComponentProps<"beszelAlerts">) {
|
||||
const t = useScopedI18n("widget.beszelAlerts");
|
||||
const alertsInput = useMemo(
|
||||
() => ({ integrationIds, includeHistory: options.showHistory, maxHistoryItems: options.maxHistoryItems }),
|
||||
[integrationIds, options.showHistory, options.maxHistoryItems],
|
||||
);
|
||||
const { data: results = [], error: alertsError, isPending } = clientApi.widget.beszel.getAlerts.useQuery(alertsInput);
|
||||
const alertsQuery = clientApi.widget.beszel.getAlerts.useQuery(alertsInput);
|
||||
const results = getUsableWidgetQueryData(alertsQuery) ?? [];
|
||||
const { isPending } = alertsQuery;
|
||||
|
||||
const systemNameMap = useMemo(() => {
|
||||
const map: Record<string, string> = {};
|
||||
@@ -67,8 +84,9 @@ export default function BeszelAlertsWidget({
|
||||
|
||||
const triggeredAlerts = alerts.filter((a) => a.triggered);
|
||||
const okAlerts = alerts.filter((a) => !a.triggered);
|
||||
|
||||
if (alertsError) throw alertsError;
|
||||
const showOkAlerts = triggeredAlerts.length === 0 || height >= 260;
|
||||
const showHistory = options.showHistory && height >= 360;
|
||||
const showAlertDescriptions = height >= 190;
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
@@ -81,7 +99,7 @@ export default function BeszelAlertsWidget({
|
||||
return (
|
||||
<Box h="100%" pos="relative">
|
||||
<Box pos="absolute" top={4} right={8} style={{ zIndex: 1 }}>
|
||||
<BeszelIntegrationErrorIndicator results={results} />
|
||||
<IntegrationErrorIndicator results={results} />
|
||||
</Box>
|
||||
<ScrollArea h="100%" style={{ pointerEvents: isEditMode ? "none" : undefined }}>
|
||||
<Stack gap="sm" p="sm">
|
||||
@@ -100,7 +118,7 @@ export default function BeszelAlertsWidget({
|
||||
<Stack gap={6}>
|
||||
<Group gap={6}>
|
||||
<IconFlame size={14} color="var(--mantine-color-red-6)" />
|
||||
<Text size="xs" fw={600} tt="uppercase" c="red">
|
||||
<Text size="xs" fw={600} c="red">
|
||||
{t("status.triggered")} ({triggeredAlerts.length})
|
||||
</Text>
|
||||
</Group>
|
||||
@@ -112,18 +130,19 @@ export default function BeszelAlertsWidget({
|
||||
min={alert.min}
|
||||
systemName={systemNameMap[alert.system] ?? alert.system}
|
||||
triggered
|
||||
showDescription={showAlertDescriptions}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{triggeredAlerts.length > 0 && okAlerts.length > 0 && <Divider />}
|
||||
{triggeredAlerts.length > 0 && showOkAlerts && okAlerts.length > 0 && <Divider />}
|
||||
|
||||
{okAlerts.length > 0 && (
|
||||
{showOkAlerts && okAlerts.length > 0 && (
|
||||
<Stack gap={6}>
|
||||
<Group gap={6}>
|
||||
<IconCircleCheck size={14} color="var(--mantine-color-green-6)" />
|
||||
<Text size="xs" fw={600} tt="uppercase" c="dimmed">
|
||||
<Text size="xs" fw={600} c="dimmed">
|
||||
{t("status.ok")} ({okAlerts.length})
|
||||
</Text>
|
||||
</Group>
|
||||
@@ -135,18 +154,19 @@ export default function BeszelAlertsWidget({
|
||||
min={alert.min}
|
||||
systemName={systemNameMap[alert.system] ?? alert.system}
|
||||
triggered={false}
|
||||
showDescription={showAlertDescriptions}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{options.showHistory && history.length > 0 && (
|
||||
{showHistory && history.length > 0 && (
|
||||
<>
|
||||
<Divider />
|
||||
<Stack gap={6}>
|
||||
<Group gap={6}>
|
||||
<IconHistory size={14} opacity={0.5} />
|
||||
<Text size="xs" fw={600} tt="uppercase" c="dimmed">
|
||||
<Text size="xs" fw={600} c="dimmed">
|
||||
{t("history")}
|
||||
</Text>
|
||||
</Group>
|
||||
@@ -199,6 +219,7 @@ interface AlertRowProps {
|
||||
min: number;
|
||||
systemName: string;
|
||||
triggered: boolean;
|
||||
showDescription: boolean;
|
||||
}
|
||||
|
||||
const unitSuffixMap: Record<string, string> = {
|
||||
@@ -222,38 +243,43 @@ function formatAlertDescription(name: string, value: number, min: number): strin
|
||||
return `exceeds ${value}${suffix} over ${min} min`;
|
||||
}
|
||||
|
||||
function AlertRow({ name, value, min, systemName, triggered }: AlertRowProps) {
|
||||
function AlertRow({ name, value, min, systemName, triggered, showDescription }: AlertRowProps) {
|
||||
const Icon = alertIconMap[name] ?? Server;
|
||||
const description = formatAlertDescription(name, value, min);
|
||||
return (
|
||||
<Group
|
||||
wrap="nowrap"
|
||||
gap="xs"
|
||||
py={4}
|
||||
pr={8}
|
||||
pl={12}
|
||||
style={{
|
||||
borderRadius: "var(--mantine-radius-sm)",
|
||||
borderLeft: `3px solid var(--mantine-color-${triggered ? "red" : "green"}-6)`,
|
||||
backgroundColor: triggered ? "var(--mantine-color-red-light)" : "var(--mantine-color-default-hover)",
|
||||
}}
|
||||
>
|
||||
<Icon size={14} opacity={0.7} style={{ flexShrink: 0 }} />
|
||||
<Stack gap={0} style={{ flex: 1, minWidth: 0 }}>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Text size="xs" fw={600} truncate>
|
||||
{systemName}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
·
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" style={{ whiteSpace: "nowrap" }}>
|
||||
{name}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{formatAlertDescription(name, value, min)}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
<Tooltip label={description} disabled={showDescription} withArrow>
|
||||
<Group
|
||||
wrap="nowrap"
|
||||
gap="xs"
|
||||
py={showDescription ? 4 : 2}
|
||||
px={8}
|
||||
style={{
|
||||
borderRadius: "var(--mantine-radius-sm)",
|
||||
border: `1px solid var(--mantine-color-${triggered ? "red" : "gray"}-4)`,
|
||||
backgroundColor: triggered ? "var(--mantine-color-red-light)" : "var(--mantine-color-default-hover)",
|
||||
}}
|
||||
>
|
||||
<Icon size={14} opacity={0.7} style={{ flexShrink: 0 }} />
|
||||
<Stack gap={0} style={{ flex: 1, minWidth: 0 }}>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Text size="xs" fw={600} truncate>
|
||||
{systemName}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
·
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" style={{ whiteSpace: "nowrap" }}>
|
||||
{name}
|
||||
</Text>
|
||||
</Group>
|
||||
{showDescription && (
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
{!showDescription && <VisuallyHidden>{description}</VisuallyHidden>}
|
||||
</Stack>
|
||||
</Group>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,19 @@
|
||||
import { IconBell, IconServerOff } from "@tabler/icons-react";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { createWidgetDefinition } from "../definition";
|
||||
import { createWidgetDefinition, widgetQueryInputMatches } from "../definition";
|
||||
import { optionsBuilder } from "../options";
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("beszelAlerts", {
|
||||
icon: IconBell,
|
||||
supportsAdvancedFocus: false,
|
||||
queryKey: [["widget", "beszel", "getAlerts"]],
|
||||
queryMatcher: ({ input }, scope) =>
|
||||
widgetQueryInputMatches(input, {
|
||||
integrationIds: scope.integrationIds,
|
||||
includeHistory: scope.options.showHistory,
|
||||
maxHistoryItems: scope.options.maxHistoryItems,
|
||||
}),
|
||||
supportedIntegrations: ["beszel", "mock"],
|
||||
integrationsRequired: true,
|
||||
createOptions() {
|
||||
|
||||
@@ -1,7 +1,20 @@
|
||||
.clickableCard {
|
||||
transition: background-color 150ms;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.clickableCard:hover {
|
||||
.clickableCard:hover,
|
||||
.clickableCard:focus-visible {
|
||||
background-color: color-mix(in srgb, var(--mantine-color-default-hover) 40%, transparent) !important;
|
||||
}
|
||||
|
||||
.clickableCard:focus-visible {
|
||||
outline: 2px solid var(--mantine-primary-color-filled);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.clickableCard {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -36,7 +36,8 @@ import {
|
||||
getProgressTrackSize,
|
||||
} from "../beszel/_shared/format";
|
||||
import { useBeszelFilteredSystems } from "../beszel/_shared/hooks";
|
||||
import { BeszelIntegrationErrorIndicator } from "../beszel/_shared/error-indicator";
|
||||
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
|
||||
import { getUsableWidgetQueryData } from "../common/query-state";
|
||||
import { BeszelSystemStatsModal } from "../beszel/_shared/system-stats-modal";
|
||||
import { DiskUsage } from "../beszel/_shared/disk-usage";
|
||||
|
||||
@@ -330,10 +331,15 @@ const metricRenderers = [
|
||||
] as const;
|
||||
|
||||
const SystemCard = ({ system, options, t, size, maxMetrics, itemRadius, onClick }: SystemCardProps) => {
|
||||
const visibleMetrics = metricRenderers.filter((m) => m.visible(system, options)).slice(0, maxMetrics);
|
||||
const enabledMetrics = metricRenderers.filter((metric) => metric.visible(system, options));
|
||||
const visibleMetrics = enabledMetrics.slice(0, maxMetrics);
|
||||
const hiddenMetricCount = enabledMetrics.length - visibleMetrics.length;
|
||||
|
||||
return (
|
||||
<Card
|
||||
component="button"
|
||||
type="button"
|
||||
disabled={!onClick}
|
||||
padding={size.cardPadding}
|
||||
radius={itemRadius}
|
||||
bg="transparent"
|
||||
@@ -345,7 +351,10 @@ const SystemCard = ({ system, options, t, size, maxMetrics, itemRadius, onClick
|
||||
display: "flex",
|
||||
flexDirection: "column" as const,
|
||||
border: "0.0625rem solid var(--border-color)",
|
||||
cursor: onClick ? "pointer" : undefined,
|
||||
color: "inherit",
|
||||
font: "inherit",
|
||||
textAlign: "left",
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
<Group gap="xs" mb={2}>
|
||||
@@ -362,6 +371,11 @@ const SystemCard = ({ system, options, t, size, maxMetrics, itemRadius, onClick
|
||||
<Stack gap={0} style={{ flex: 1 }} justify="space-evenly">
|
||||
{visibleMetrics.map((m) => m.render(system, t, size))}
|
||||
</Stack>
|
||||
{hiddenMetricCount > 0 && (
|
||||
<Text size="10px" c="dimmed" ta="right">
|
||||
+{hiddenMetricCount}
|
||||
</Text>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -377,16 +391,12 @@ export default function BeszelSystemGridWidget({
|
||||
const board = useRequiredBoard();
|
||||
const { openModal } = useModalAction(BeszelSystemStatsModal);
|
||||
|
||||
const {
|
||||
data: results = [],
|
||||
error: systemsError,
|
||||
isPending,
|
||||
} = clientApi.widget.beszel.getSystems.useQuery({ integrationIds });
|
||||
const systemsQuery = clientApi.widget.beszel.getSystems.useQuery({ integrationIds });
|
||||
const results = getUsableWidgetQueryData(systemsQuery) ?? [];
|
||||
const { isPending } = systemsQuery;
|
||||
|
||||
const filteredSystems = useBeszelFilteredSystems(results, options.statusFilter);
|
||||
|
||||
if (systemsError) throw systemsError;
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<Center h="100%">
|
||||
@@ -398,7 +408,7 @@ export default function BeszelSystemGridWidget({
|
||||
if (filteredSystems.length === 0) {
|
||||
return (
|
||||
<Box h="100%" pos="relative" style={{ pointerEvents: isEditMode ? "none" : undefined }}>
|
||||
<BeszelIntegrationErrorIndicator results={results} />
|
||||
<IntegrationErrorIndicator results={results} />
|
||||
<Center h="100%">
|
||||
<Stack align="center" gap="xs">
|
||||
<IconServerOff size={28} opacity={0.5} />
|
||||
@@ -414,21 +424,22 @@ export default function BeszelSystemGridWidget({
|
||||
const cols = getColCount(width, height, filteredSystems.length);
|
||||
const rows = Math.ceil(filteredSystems.length / cols) || 1;
|
||||
const rawCellHeight = height / rows;
|
||||
const scrollEnabled = rawCellHeight < MIN_CELL_HEIGHT;
|
||||
const effectiveCellHeight = scrollEnabled ? MIN_CELL_HEIGHT : rawCellHeight;
|
||||
const minimumCellHeight = MIN_CELL_HEIGHT;
|
||||
const scrollEnabled = rawCellHeight < minimumCellHeight;
|
||||
const effectiveCellHeight = scrollEnabled ? minimumCellHeight : rawCellHeight;
|
||||
const cellWidth = width / cols;
|
||||
const size = getSizeConfig(cellWidth, effectiveCellHeight);
|
||||
const maxMetrics = getMaxVisibleMetrics(effectiveCellHeight, size);
|
||||
|
||||
return (
|
||||
<Box h="100%" pos="relative" style={{ pointerEvents: isEditMode ? "none" : undefined }}>
|
||||
<BeszelIntegrationErrorIndicator results={results} />
|
||||
<IntegrationErrorIndicator results={results} />
|
||||
<Box
|
||||
h="100%"
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: `repeat(${cols}, 1fr)`,
|
||||
gridTemplateRows: scrollEnabled ? `repeat(${rows}, ${MIN_CELL_HEIGHT}px)` : `repeat(${rows}, 1fr)`,
|
||||
gridTemplateRows: scrollEnabled ? `repeat(${rows}, ${minimumCellHeight}px)` : `repeat(${rows}, 1fr)`,
|
||||
gap: size.gap,
|
||||
overflow: scrollEnabled ? "auto" : "hidden",
|
||||
}}
|
||||
|
||||
@@ -6,7 +6,8 @@ import { optionsBuilder } from "../options";
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("beszelSystemGrid", {
|
||||
icon: IconLayoutGrid,
|
||||
queryKey: [["widget", "beszel"]],
|
||||
supportsAdvancedFocus: false,
|
||||
queryKey: [["widget", "beszel", "getSystems"]],
|
||||
supportedIntegrations: ["beszel", "mock"],
|
||||
integrationsRequired: true,
|
||||
createOptions() {
|
||||
|
||||
@@ -8,7 +8,21 @@
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.beszelStatsContainer:hover .beszelStatsSystemToggle {
|
||||
.beszelStatsContainer:hover .beszelStatsSystemToggle,
|
||||
.beszelStatsContainer:focus-within .beszelStatsSystemToggle {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
@media (any-pointer: coarse) {
|
||||
.beszelStatsSystemToggle {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.beszelStatsSystemToggle {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -1,21 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { Box, Button, Center, Loader, Menu, ScrollArea, Select, Stack, Text } from "@mantine/core";
|
||||
import { useCallback, useMemo, useRef } from "react";
|
||||
import { Box, Button, Center, Loader, Menu, ScrollArea, SegmentedControl, Select, Stack, Text } from "@mantine/core";
|
||||
import { IconQuestionMark, IconServer, IconServerOff } from "@tabler/icons-react";
|
||||
import { getQueryKey } from "@trpc/react-query";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
import { constructBoardPermissions } from "@homarr/auth/shared";
|
||||
import { useOptionalBoard } from "@homarr/boards/context";
|
||||
import { showErrorNotification } from "@homarr/notifications";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import classes from "./component.module.css";
|
||||
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { useWidgetRuntimeQueries } from "../runtime-hooks";
|
||||
import type { BeszelTimePeriod } from "../beszel/_shared/chart";
|
||||
import { BeszelIntegrationErrorIndicator } from "../beszel/_shared/error-indicator";
|
||||
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
|
||||
import { getUsableWidgetQueryData } from "../common/query-state";
|
||||
import { BeszelStatsView } from "../beszel/_shared/stats-view";
|
||||
import { createBeszelSystemChoices, resolveBeszelSystemChoice } from "./selection";
|
||||
|
||||
export default function BeszelSystemStatsWidget({
|
||||
options,
|
||||
@@ -25,37 +30,106 @@ export default function BeszelSystemStatsWidget({
|
||||
boardId,
|
||||
itemId,
|
||||
setOptions,
|
||||
widgetRuntimeRef,
|
||||
}: WidgetComponentProps<"beszelSystemStats">) {
|
||||
const t = useScopedI18n("widget.beszelSystemStats");
|
||||
const board = useOptionalBoard();
|
||||
const { data: session } = useSession();
|
||||
const hasChangeAccess = board ? constructBoardPermissions(board, session).hasChangeAccess : false;
|
||||
const { mutate: saveItemOptions } = clientApi.widget.options.saveItemOptions.useMutation();
|
||||
const {
|
||||
data: systemsResult = [],
|
||||
isPending: systemsPending,
|
||||
error: systemsError,
|
||||
} = clientApi.widget.beszel.getSystems.useQuery({ integrationIds });
|
||||
const selectionSavePendingRef = useRef(false);
|
||||
const { mutate: saveItemOptions, isPending: isSelectionSavePending } =
|
||||
clientApi.widget.options.saveItemOptions.useMutation({
|
||||
onError: () =>
|
||||
showErrorNotification({
|
||||
title: t("error.selectionSaveTitle"),
|
||||
message: t("error.selectionSaveMessage"),
|
||||
}),
|
||||
});
|
||||
const systemsQuery = clientApi.widget.beszel.getSystems.useQuery({ integrationIds });
|
||||
const systemsResult = getUsableWidgetQueryData(systemsQuery) ?? [];
|
||||
const { isPending: systemsPending } = systemsQuery;
|
||||
|
||||
const systems = useMemo(
|
||||
() => systemsResult.flatMap((result) => result.systems.map((system) => ({ value: system.id, label: system.name }))),
|
||||
[systemsResult],
|
||||
const systems = useMemo(() => createBeszelSystemChoices(systemsResult), [systemsResult]);
|
||||
const selectedSystem = resolveBeszelSystemChoice(systems, options.systemId);
|
||||
const selectedValue = selectedSystem?.value ?? "";
|
||||
const selectedLabel = selectedSystem?.label;
|
||||
const systemExists = selectedSystem !== undefined;
|
||||
const includeDocker = options.showDockerCpu || options.showDockerMemory || options.showDockerNetwork;
|
||||
const periodOptions = [
|
||||
{ value: "1m", label: t("period.live") },
|
||||
{ value: "1h", label: t("period.oneHour") },
|
||||
{ value: "12h", label: t("period.twelveHours") },
|
||||
{ value: "24h", label: t("period.oneDay") },
|
||||
{ value: "1w", label: t("period.oneWeek") },
|
||||
{ value: "30d", label: t("period.thirtyDays") },
|
||||
];
|
||||
useWidgetRuntimeQueries(
|
||||
widgetRuntimeRef,
|
||||
selectedSystem && options.timePeriod !== "1m"
|
||||
? [
|
||||
getQueryKey(
|
||||
clientApi.widget.beszel.getSystemStats,
|
||||
{
|
||||
integrationIds: [selectedSystem.integrationId],
|
||||
systemId: selectedSystem.systemId,
|
||||
timePeriod: options.timePeriod as BeszelTimePeriod,
|
||||
includeDocker,
|
||||
},
|
||||
"query",
|
||||
),
|
||||
]
|
||||
: [],
|
||||
);
|
||||
const selectedSystem = options.systemId || systems[0]?.value || "";
|
||||
const selectedLabel = systems.find((system) => system.value === selectedSystem)?.label;
|
||||
const systemExists = systems.some((system) => system.value === selectedSystem);
|
||||
|
||||
const handleSelectSystem = useCallback(
|
||||
(value: string) => {
|
||||
if (selectionSavePendingRef.current) return;
|
||||
|
||||
const previousValue = options.systemId;
|
||||
setOptions({ newOptions: { systemId: value } });
|
||||
if (hasChangeAccess && boardId && itemId) {
|
||||
saveItemOptions({ boardId, itemId, newOptions: { systemId: value } });
|
||||
selectionSavePendingRef.current = true;
|
||||
saveItemOptions(
|
||||
{ boardId, itemId, newOptions: { systemId: value } },
|
||||
{
|
||||
onError: () => {
|
||||
setOptions({ newOptions: { systemId: previousValue } });
|
||||
},
|
||||
onSettled: () => {
|
||||
selectionSavePendingRef.current = false;
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
},
|
||||
[setOptions, hasChangeAccess, boardId, itemId, saveItemOptions],
|
||||
[boardId, hasChangeAccess, itemId, options.systemId, saveItemOptions, setOptions],
|
||||
);
|
||||
|
||||
const handleTimePeriod = useCallback(
|
||||
(value: string) => {
|
||||
if (selectionSavePendingRef.current) return;
|
||||
|
||||
const timePeriod = value as BeszelTimePeriod;
|
||||
const previousValue = options.timePeriod;
|
||||
setOptions({ newOptions: { timePeriod } });
|
||||
if (hasChangeAccess && boardId && itemId) {
|
||||
selectionSavePendingRef.current = true;
|
||||
saveItemOptions(
|
||||
{ boardId, itemId, newOptions: { timePeriod } },
|
||||
{
|
||||
onError: () => {
|
||||
setOptions({ newOptions: { timePeriod: previousValue } });
|
||||
},
|
||||
onSettled: () => {
|
||||
selectionSavePendingRef.current = false;
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
},
|
||||
[boardId, hasChangeAccess, itemId, options.timePeriod, saveItemOptions, setOptions],
|
||||
);
|
||||
|
||||
if (systemsError) throw systemsError;
|
||||
if (systemsPending)
|
||||
return (
|
||||
<Center h="100%">
|
||||
@@ -66,7 +140,7 @@ export default function BeszelSystemStatsWidget({
|
||||
if (systems.length === 0) {
|
||||
return (
|
||||
<Box h="100%" pos="relative">
|
||||
<BeszelIntegrationErrorIndicator results={systemsResult} />
|
||||
<IntegrationErrorIndicator results={systemsResult} />
|
||||
<Center h="100%">
|
||||
<Stack align="center" gap="xs">
|
||||
<IconServerOff size={28} opacity={0.5} />
|
||||
@@ -90,8 +164,9 @@ export default function BeszelSystemStatsWidget({
|
||||
<Select
|
||||
size="xs"
|
||||
data={systems}
|
||||
value={selectedSystem}
|
||||
value={selectedValue}
|
||||
placeholder={t("selectSystem")}
|
||||
disabled={isSelectionSavePending}
|
||||
onChange={(value) => value && handleSelectSystem(value)}
|
||||
/>
|
||||
</Stack>
|
||||
@@ -102,7 +177,7 @@ export default function BeszelSystemStatsWidget({
|
||||
return (
|
||||
<Box h="100%" pos="relative">
|
||||
<Box pos="absolute" top={4} right={8} style={{ zIndex: 1 }}>
|
||||
<BeszelIntegrationErrorIndicator results={systemsResult} />
|
||||
<IntegrationErrorIndicator results={systemsResult} />
|
||||
</Box>
|
||||
<ScrollArea
|
||||
h="100%"
|
||||
@@ -118,6 +193,7 @@ export default function BeszelSystemStatsWidget({
|
||||
size="compact-xs"
|
||||
leftSection={<IconServer size={14} />}
|
||||
className={classes.beszelStatsSystemToggle}
|
||||
disabled={isSelectionSavePending}
|
||||
>
|
||||
{selectedLabel}
|
||||
</Button>
|
||||
@@ -127,8 +203,9 @@ export default function BeszelSystemStatsWidget({
|
||||
<Menu.Item
|
||||
key={system.value}
|
||||
fz="xs"
|
||||
fw={system.value === selectedSystem ? 600 : 400}
|
||||
c={system.value !== selectedSystem ? "dimmed" : undefined}
|
||||
fw={system.value === selectedValue ? 600 : 400}
|
||||
c={system.value !== selectedValue ? "dimmed" : undefined}
|
||||
disabled={isSelectionSavePending}
|
||||
onClick={() => handleSelectSystem(system.value)}
|
||||
>
|
||||
{system.label}
|
||||
@@ -137,9 +214,28 @@ export default function BeszelSystemStatsWidget({
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
)}
|
||||
{!isEditMode &&
|
||||
(width >= 560 ? (
|
||||
<SegmentedControl
|
||||
size="xs"
|
||||
fullWidth
|
||||
value={options.timePeriod}
|
||||
onChange={handleTimePeriod}
|
||||
disabled={isSelectionSavePending}
|
||||
data={periodOptions}
|
||||
/>
|
||||
) : (
|
||||
<Select
|
||||
size="xs"
|
||||
value={options.timePeriod}
|
||||
onChange={(value) => value && handleTimePeriod(value)}
|
||||
disabled={isSelectionSavePending}
|
||||
data={periodOptions}
|
||||
/>
|
||||
))}
|
||||
<BeszelStatsView
|
||||
integrationIds={integrationIds}
|
||||
systemId={selectedSystem}
|
||||
integrationIds={selectedSystem ? [selectedSystem.integrationId] : []}
|
||||
systemId={selectedSystem?.systemId ?? ""}
|
||||
timePeriod={options.timePeriod as BeszelTimePeriod}
|
||||
columns={width > 600 ? 2 : 1}
|
||||
visibility={{
|
||||
@@ -152,7 +248,7 @@ export default function BeszelSystemStatsWidget({
|
||||
dockerMemory: options.showDockerMemory,
|
||||
dockerNetwork: options.showDockerNetwork,
|
||||
}}
|
||||
onSwitchToHistorical={() => setOptions({ newOptions: { timePeriod: "1h" } })}
|
||||
onSwitchToHistorical={() => handleTimePeriod("1h")}
|
||||
/>
|
||||
</Stack>
|
||||
</ScrollArea>
|
||||
|
||||
@@ -2,8 +2,9 @@ import { IconChartAreaLine, IconServerOff } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
|
||||
import { createWidgetDefinition } from "../definition";
|
||||
import { createWidgetDefinition, matchesWidgetRuntimeQuery, widgetQueryInputMatches } from "../definition";
|
||||
import { optionsBuilder } from "../options";
|
||||
import { createBeszelSystemChoices, resolveStoredBeszelQuerySelection } from "./selection";
|
||||
|
||||
const timePeriodOptions = [
|
||||
{ value: "1m", label: "Live" },
|
||||
@@ -15,8 +16,31 @@ const timePeriodOptions = [
|
||||
];
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("beszelSystemStats", {
|
||||
supportsAdvancedFocus: false,
|
||||
icon: IconChartAreaLine,
|
||||
queryKey: [["widget", "beszel"]],
|
||||
queryKeys: [[["widget", "beszel", "getSystems"]], [["widget", "beszel", "getSystemStats"]]],
|
||||
queryMatcher(query, scope) {
|
||||
if (query.path.at(-1) === "getSystems") {
|
||||
return widgetQueryInputMatches(query.input, { integrationIds: scope.integrationIds });
|
||||
}
|
||||
|
||||
const hasRuntimeStatsQuery = scope.runtimeQueries.some(({ path }) => path.at(-1) === "getSystemStats");
|
||||
if (hasRuntimeStatsQuery) return matchesWidgetRuntimeQuery(query, scope);
|
||||
|
||||
const selection = resolveStoredBeszelQuerySelection(String(scope.options.systemId ?? ""), scope.integrationIds);
|
||||
if (!selection) return false;
|
||||
const dockerEnabled = ["showDockerCpu", "showDockerMemory", "showDockerNetwork"].some(
|
||||
(key) => scope.options[key] === true,
|
||||
);
|
||||
const expected = {
|
||||
...selection,
|
||||
timePeriod: scope.options.timePeriod,
|
||||
};
|
||||
return (
|
||||
widgetQueryInputMatches(query.input, { ...expected, includeDocker: false }) ||
|
||||
(dockerEnabled && widgetQueryInputMatches(query.input, { ...expected, includeDocker: true }))
|
||||
);
|
||||
},
|
||||
supportedIntegrations: ["beszel", "mock"],
|
||||
integrationsRequired: true,
|
||||
createOptions() {
|
||||
@@ -30,7 +54,7 @@ export const { definition, componentLoader } = createWidgetDefinition("beszelSys
|
||||
isPending,
|
||||
isError,
|
||||
} = clientApi.widget.beszel.getSystems.useQuery({ integrationIds }, { enabled: integrationIds.length > 0 });
|
||||
const selectData = data.flatMap((r) => r.systems.map((s) => ({ value: s.id, label: s.name })));
|
||||
const selectData = createBeszelSystemChoices(data).map(({ value, label }) => ({ value, label }));
|
||||
return { data: selectData, isPending, isError };
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { createBeszelSystemChoices, resolveBeszelSystemChoice, resolveStoredBeszelQuerySelection } from "./selection";
|
||||
|
||||
const choices = createBeszelSystemChoices([
|
||||
{ integrationId: "one", integrationName: "Primary", systems: [{ id: "same", name: "Host A" }] },
|
||||
{ integrationId: "two", integrationName: "Backup", systems: [{ id: "same", name: "Host B" }] },
|
||||
]);
|
||||
|
||||
describe("Beszel system selection", () => {
|
||||
it("uses a composite integration and system identity", () => {
|
||||
expect(choices.map((choice) => choice.value)).toEqual(["one:same", "two:same"]);
|
||||
expect(resolveBeszelSystemChoice(choices, "two:same")?.integrationId).toBe("two");
|
||||
});
|
||||
|
||||
it("keeps unique legacy plain system ids working", () => {
|
||||
expect(resolveBeszelSystemChoice(choices.slice(0, 1), "same")?.integrationId).toBe("one");
|
||||
});
|
||||
|
||||
it("defaults an empty selection to the first system", () => {
|
||||
expect(resolveBeszelSystemChoice(choices, "")?.integrationId).toBe("one");
|
||||
});
|
||||
|
||||
it("does not guess when a legacy plain system id is ambiguous", () => {
|
||||
expect(resolveBeszelSystemChoice(choices, "same")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not silently replace an unresolved stored system", () => {
|
||||
expect(resolveBeszelSystemChoice(choices, "missing")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("derives the queried integration and system from a composite selection", () => {
|
||||
expect(resolveStoredBeszelQuerySelection("two:same", ["one", "two"])).toEqual({
|
||||
integrationIds: ["two"],
|
||||
systemId: "same",
|
||||
});
|
||||
});
|
||||
|
||||
it("supports a legacy plain system id only when the integration is unambiguous", () => {
|
||||
expect(resolveStoredBeszelQuerySelection("same", ["one"])).toEqual({
|
||||
integrationIds: ["one"],
|
||||
systemId: "same",
|
||||
});
|
||||
expect(resolveStoredBeszelQuerySelection("same", ["one", "two"])).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
export interface BeszelSystemChoice {
|
||||
value: string;
|
||||
label: string;
|
||||
integrationId: string;
|
||||
systemId: string;
|
||||
}
|
||||
|
||||
interface BeszelSystemsResult {
|
||||
integrationId: string;
|
||||
integrationName: string;
|
||||
systems: { id: string; name: string }[];
|
||||
}
|
||||
|
||||
export function createBeszelSystemChoices(results: BeszelSystemsResult[]): BeszelSystemChoice[] {
|
||||
const includeIntegrationName = results.length > 1;
|
||||
return results.flatMap((result) =>
|
||||
result.systems.map((system) => ({
|
||||
value: `${result.integrationId}:${system.id}`,
|
||||
label: includeIntegrationName ? `${result.integrationName} · ${system.name}` : system.name,
|
||||
integrationId: result.integrationId,
|
||||
systemId: system.id,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveBeszelSystemChoice(choices: BeszelSystemChoice[], storedValue: string) {
|
||||
const exactMatch = choices.find((choice) => choice.value === storedValue);
|
||||
if (exactMatch) return exactMatch;
|
||||
if (storedValue === "") return choices[0];
|
||||
|
||||
const legacyMatches = choices.filter((choice) => choice.systemId === storedValue);
|
||||
return legacyMatches.length === 1 ? legacyMatches[0] : undefined;
|
||||
}
|
||||
|
||||
export function resolveStoredBeszelQuerySelection(storedValue: string, integrationIds: readonly string[]) {
|
||||
for (const integrationId of integrationIds) {
|
||||
const prefix = `${integrationId}:`;
|
||||
if (storedValue.startsWith(prefix)) {
|
||||
return { integrationIds: [integrationId], systemId: storedValue.slice(prefix.length) };
|
||||
}
|
||||
}
|
||||
|
||||
if (storedValue !== "" && integrationIds.length === 1) {
|
||||
return { integrationIds: [integrationIds[0] as string], systemId: storedValue };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -26,6 +26,7 @@ import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { HomarrDataTable } from "../common/homarr-data-table";
|
||||
import { getUsableWidgetQueryData } from "../common/query-state";
|
||||
import { usePersistedTableLayout, useTableLayoutPersistence } from "../common/use-persisted-table-layout";
|
||||
import type { BeszelSystemRow } from "../beszel/_shared/types";
|
||||
import { loadAvgColor, statusColorMap, thresholdColor } from "../beszel/_shared/colors";
|
||||
@@ -38,7 +39,7 @@ import {
|
||||
getProgressTrackSize,
|
||||
} from "../beszel/_shared/format";
|
||||
import { useBeszelFilteredSystems } from "../beszel/_shared/hooks";
|
||||
import { BeszelIntegrationErrorIndicator } from "../beszel/_shared/error-indicator";
|
||||
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
|
||||
import { BeszelSystemStatsModal } from "../beszel/_shared/system-stats-modal";
|
||||
import { DiskUsage } from "../beszel/_shared/disk-usage";
|
||||
|
||||
@@ -102,12 +103,27 @@ export default function BeszelSystemTableWidget({
|
||||
const board = useOptionalBoard();
|
||||
const { data: session } = useSession();
|
||||
const hasChangeAccess = board ? constructBoardPermissions(board, session).hasChangeAccess : false;
|
||||
const {
|
||||
data: results = [],
|
||||
error: systemsError,
|
||||
isPending,
|
||||
} = clientApi.widget.beszel.getSystems.useQuery({ integrationIds });
|
||||
const systemsQuery = clientApi.widget.beszel.getSystems.useQuery({ integrationIds });
|
||||
const results = getUsableWidgetQueryData(systemsQuery) ?? [];
|
||||
const { isPending } = systemsQuery;
|
||||
const size = useMemo(() => getSizeConfig(width), [width]);
|
||||
const visibleMetricKeys = useMemo(() => {
|
||||
const enabled = [
|
||||
"showCpu",
|
||||
"showMemory",
|
||||
"showDisk",
|
||||
"showGpu",
|
||||
"showLoadAvg",
|
||||
"showNet",
|
||||
"showTemp",
|
||||
"showBattery",
|
||||
"showServices",
|
||||
"showUptime",
|
||||
"showAgent",
|
||||
].filter((key) => options[key as keyof typeof options]) as string[];
|
||||
const budget = width < 360 ? 1 : width < 560 ? 2 : width < 760 ? 4 : enabled.length;
|
||||
return new Set(enabled.slice(0, budget));
|
||||
}, [options, width]);
|
||||
|
||||
const { mutate: saveItemOptions } = clientApi.widget.options.saveItemOptions.useMutation({
|
||||
onError: () =>
|
||||
@@ -185,7 +201,7 @@ export default function BeszelSystemTableWidget({
|
||||
</Group>
|
||||
),
|
||||
},
|
||||
options.showCpu && {
|
||||
visibleMetricKeys.has("showCpu") && {
|
||||
accessor: "cpu",
|
||||
width: 140,
|
||||
title: (
|
||||
@@ -197,7 +213,7 @@ export default function BeszelSystemTableWidget({
|
||||
sortable: true,
|
||||
render: (record) => <PercentCell value={record.cpu} />,
|
||||
},
|
||||
options.showMemory && {
|
||||
visibleMetricKeys.has("showMemory") && {
|
||||
accessor: "memory",
|
||||
width: 140,
|
||||
title: (
|
||||
@@ -209,7 +225,7 @@ export default function BeszelSystemTableWidget({
|
||||
sortable: true,
|
||||
render: (record) => <PercentCell value={record.memory} />,
|
||||
},
|
||||
options.showDisk && {
|
||||
visibleMetricKeys.has("showDisk") && {
|
||||
accessor: "disk",
|
||||
width: 160,
|
||||
title: (
|
||||
@@ -229,7 +245,7 @@ export default function BeszelSystemTableWidget({
|
||||
/>
|
||||
),
|
||||
},
|
||||
options.showGpu && {
|
||||
visibleMetricKeys.has("showGpu") && {
|
||||
accessor: "gpu",
|
||||
width: 140,
|
||||
title: (
|
||||
@@ -241,7 +257,7 @@ export default function BeszelSystemTableWidget({
|
||||
sortable: true,
|
||||
render: (record) => <PercentCell value={record.gpu} />,
|
||||
},
|
||||
options.showLoadAvg && {
|
||||
visibleMetricKeys.has("showLoadAvg") && {
|
||||
accessor: "loadAvg",
|
||||
width: 100,
|
||||
title: (
|
||||
@@ -258,7 +274,7 @@ export default function BeszelSystemTableWidget({
|
||||
</Group>
|
||||
),
|
||||
},
|
||||
options.showNet && {
|
||||
visibleMetricKeys.has("showNet") && {
|
||||
accessor: "netBytes",
|
||||
width: 100,
|
||||
title: (
|
||||
@@ -274,7 +290,7 @@ export default function BeszelSystemTableWidget({
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
options.showTemp && {
|
||||
visibleMetricKeys.has("showTemp") && {
|
||||
accessor: "temp",
|
||||
width: 80,
|
||||
title: (
|
||||
@@ -286,7 +302,7 @@ export default function BeszelSystemTableWidget({
|
||||
sortable: true,
|
||||
render: (record) => <Text size={size.fontSize}>{formatTemp(record.temp, false)}</Text>,
|
||||
},
|
||||
options.showBattery && {
|
||||
visibleMetricKeys.has("showBattery") && {
|
||||
accessor: "battery",
|
||||
width: 70,
|
||||
title: (
|
||||
@@ -297,7 +313,7 @@ export default function BeszelSystemTableWidget({
|
||||
),
|
||||
render: (record) => <Text size={size.fontSize}>{record.battery ? `${record.battery[0]}%` : "—"}</Text>,
|
||||
},
|
||||
options.showServices && {
|
||||
visibleMetricKeys.has("showServices") && {
|
||||
accessor: "services",
|
||||
width: 80,
|
||||
title: (
|
||||
@@ -309,7 +325,7 @@ export default function BeszelSystemTableWidget({
|
||||
sortable: true,
|
||||
render: (record) => <Text size={size.fontSize}>{record.services}</Text>,
|
||||
},
|
||||
options.showUptime && {
|
||||
visibleMetricKeys.has("showUptime") && {
|
||||
accessor: "uptime",
|
||||
width: 90,
|
||||
title: (
|
||||
@@ -321,7 +337,7 @@ export default function BeszelSystemTableWidget({
|
||||
sortable: true,
|
||||
render: (record) => <Text size={size.fontSize}>{formatUptime(record.uptime)}</Text>,
|
||||
},
|
||||
options.showAgent && {
|
||||
visibleMetricKeys.has("showAgent") && {
|
||||
accessor: "agentVersion",
|
||||
width: 90,
|
||||
title: (
|
||||
@@ -336,21 +352,7 @@ export default function BeszelSystemTableWidget({
|
||||
];
|
||||
|
||||
return cols.filter(Boolean) as DataTableColumn<SystemRowWithKey>[];
|
||||
}, [
|
||||
options.showAgent,
|
||||
options.showBattery,
|
||||
options.showCpu,
|
||||
options.showDisk,
|
||||
options.showGpu,
|
||||
options.showLoadAvg,
|
||||
options.showMemory,
|
||||
options.showNet,
|
||||
options.showServices,
|
||||
options.showTemp,
|
||||
options.showUptime,
|
||||
size,
|
||||
t,
|
||||
]);
|
||||
}, [t, size, visibleMetricKeys]);
|
||||
|
||||
const { effectiveColumns, storeKey } = usePersistedTableLayout({
|
||||
columns,
|
||||
@@ -370,8 +372,6 @@ export default function BeszelSystemTableWidget({
|
||||
[openModal],
|
||||
);
|
||||
|
||||
if (systemsError) throw systemsError;
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<Center h="100%">
|
||||
@@ -380,10 +380,10 @@ export default function BeszelSystemTableWidget({
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
const table = (
|
||||
<div style={{ position: "relative", height: "100%" }}>
|
||||
<div style={{ position: "absolute", top: 4, right: 8, zIndex: 1 }}>
|
||||
<BeszelIntegrationErrorIndicator results={results} />
|
||||
<IntegrationErrorIndicator results={results} />
|
||||
</div>
|
||||
<HomarrDataTable
|
||||
isEditMode={isEditMode}
|
||||
@@ -400,4 +400,6 @@ export default function BeszelSystemTableWidget({
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
return table;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { IconServerOff, IconTable } from "@tabler/icons-react";
|
||||
|
||||
import { createWidgetDefinition } from "../definition";
|
||||
import { createWidgetDefinition, matchesWidgetRuntimeQuery, widgetQueryInputMatches } from "../definition";
|
||||
import { statusOptions } from "../beszel/_shared/options";
|
||||
import { optionsBuilder } from "../options";
|
||||
|
||||
@@ -25,7 +25,12 @@ const sortDirectionOptions = [
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("beszelSystemTable", {
|
||||
icon: IconTable,
|
||||
queryKey: [["widget", "beszel"]],
|
||||
supportsAdvancedFocus: false,
|
||||
queryKeys: [[["widget", "beszel", "getSystems"]], [["widget", "beszel", "getSystemStats"]]],
|
||||
queryMatcher: (query, scope) =>
|
||||
query.path.at(-1) === "getSystems"
|
||||
? widgetQueryInputMatches(query.input, { integrationIds: scope.integrationIds })
|
||||
: matchesWidgetRuntimeQuery(query, scope),
|
||||
supportedIntegrations: ["beszel", "mock"],
|
||||
integrationsRequired: true,
|
||||
createOptions() {
|
||||
|
||||
@@ -8,33 +8,38 @@ import dayjs from "dayjs";
|
||||
|
||||
import type { BeszelContainerStatsRecord, BeszelSystemStatsRecord } from "@homarr/integrations/types";
|
||||
|
||||
import { formatLocalizedDate } from "../../common/locale";
|
||||
|
||||
export type BeszelTimePeriod = "1m" | "1h" | "12h" | "24h" | "1w" | "30d";
|
||||
|
||||
const timeFormats: Record<BeszelTimePeriod, string> = {
|
||||
"1m": "HH:mm:ss",
|
||||
"1h": "HH:mm",
|
||||
"12h": "HH:mm",
|
||||
"24h": "HH:mm",
|
||||
"1w": "MMM D",
|
||||
"30d": "MMM D",
|
||||
const timeFormatOptions: Record<BeszelTimePeriod, Intl.DateTimeFormatOptions> = {
|
||||
"1m": { hour: "numeric", minute: "2-digit", second: "2-digit" },
|
||||
"1h": { hour: "numeric", minute: "2-digit" },
|
||||
"12h": { hour: "numeric", minute: "2-digit" },
|
||||
"24h": { hour: "numeric", minute: "2-digit" },
|
||||
"1w": { month: "short", day: "numeric" },
|
||||
"30d": { month: "short", day: "numeric" },
|
||||
};
|
||||
|
||||
const periodDays: Partial<Record<BeszelTimePeriod, number>> = { "1w": 7, "30d": 30 };
|
||||
|
||||
function prepareRecords<T>(records: T[], timePeriod: BeszelTimePeriod) {
|
||||
const fmt = (timestamp: string) => dayjs(timestamp).format(timeFormats[timePeriod]);
|
||||
export const formatBeszelChartTimestamp = (timestamp: string, timePeriod: BeszelTimePeriod, locale: string): string =>
|
||||
formatLocalizedDate(timestamp, locale, timeFormatOptions[timePeriod]);
|
||||
|
||||
function prepareRecords<T>(records: T[], timePeriod: BeszelTimePeriod, locale: string) {
|
||||
const fmt = (timestamp: string) => formatBeszelChartTimestamp(timestamp, timePeriod, locale);
|
||||
if (timePeriod === "1m") {
|
||||
return { fmt, ordered: records };
|
||||
}
|
||||
return { fmt, ordered: [...records].toReversed() };
|
||||
}
|
||||
|
||||
function padTimeGrid(data: Record<string, unknown>[], timePeriod: BeszelTimePeriod) {
|
||||
if (timePeriod === "1m") return padLiveTimeGrid(data);
|
||||
function padTimeGrid(data: Record<string, unknown>[], timePeriod: BeszelTimePeriod, locale: string) {
|
||||
if (timePeriod === "1m") return padLiveTimeGrid(data, 60, locale);
|
||||
const days = periodDays[timePeriod];
|
||||
if (!days) return data;
|
||||
|
||||
const fmt = (ts: string) => dayjs(ts).format(timeFormats[timePeriod]);
|
||||
const fmt = (timestamp: string) => formatBeszelChartTimestamp(timestamp, timePeriod, locale);
|
||||
const now = dayjs();
|
||||
const existingDays = new Set(data.map((d) => dayjs(d.rawTime as string).format("YYYY-MM-DD")));
|
||||
const result = [...data];
|
||||
@@ -51,7 +56,7 @@ function padTimeGrid(data: Record<string, unknown>[], timePeriod: BeszelTimePeri
|
||||
return result.toSorted((a, b) => new Date(a.rawTime as string).getTime() - new Date(b.rawTime as string).getTime());
|
||||
}
|
||||
|
||||
export function padLiveTimeGrid(data: Record<string, unknown>[], pointCount = 60) {
|
||||
export function padLiveTimeGrid(data: Record<string, unknown>[], pointCount = 60, locale = "en-US") {
|
||||
if (data.length === 0) return data;
|
||||
|
||||
const end = dayjs(
|
||||
@@ -76,7 +81,7 @@ export function padLiveTimeGrid(data: Record<string, unknown>[], pointCount = 60
|
||||
const timestamp = end.subtract(pointCount - index - 1, "second");
|
||||
return (
|
||||
pointsBySecond.get(timestamp.valueOf()) ?? {
|
||||
time: timestamp.format(timeFormats["1m"]),
|
||||
time: formatBeszelChartTimestamp(timestamp.toISOString(), "1m", locale),
|
||||
rawTime: timestamp.toISOString(),
|
||||
}
|
||||
);
|
||||
@@ -162,17 +167,18 @@ export const useSystemChartData = (
|
||||
systemStats: BeszelSystemStatsRecord[] | undefined,
|
||||
mapFn: (stats: BeszelSystemStatsRecord["stats"]) => Record<string, unknown>,
|
||||
timePeriod: BeszelTimePeriod = "1h",
|
||||
locale = "en-US",
|
||||
) =>
|
||||
useMemo(() => {
|
||||
if (!systemStats) return [];
|
||||
const { fmt, ordered } = prepareRecords(systemStats, timePeriod);
|
||||
const { fmt, ordered } = prepareRecords(systemStats, timePeriod, locale);
|
||||
const mapped = ordered.map((r) => ({
|
||||
time: fmt(r.created),
|
||||
rawTime: r.created,
|
||||
...mapFn(r.stats),
|
||||
}));
|
||||
return padTimeGrid(mapped, timePeriod);
|
||||
}, [systemStats, mapFn, timePeriod]);
|
||||
return padTimeGrid(mapped, timePeriod, locale);
|
||||
}, [systemStats, mapFn, timePeriod, locale]);
|
||||
|
||||
export const useContainerNames = (containerStats: BeszelContainerStatsRecord[] | undefined, max = 15) => {
|
||||
const prevRef = useRef<string[]>([]);
|
||||
@@ -212,10 +218,11 @@ export const useDiskChartData = (
|
||||
efsPaths: string[],
|
||||
rootSeriesName: string,
|
||||
timePeriod: BeszelTimePeriod = "1h",
|
||||
locale = "en-US",
|
||||
) =>
|
||||
useMemo(
|
||||
() => buildDiskChartData(systemStats, efsPaths, rootSeriesName, timePeriod),
|
||||
[systemStats, efsPaths, rootSeriesName, timePeriod],
|
||||
() => buildDiskChartData(systemStats, efsPaths, rootSeriesName, timePeriod, locale),
|
||||
[systemStats, efsPaths, rootSeriesName, timePeriod, locale],
|
||||
);
|
||||
|
||||
export const buildDiskChartData = (
|
||||
@@ -223,9 +230,10 @@ export const buildDiskChartData = (
|
||||
efsPaths: string[],
|
||||
rootSeriesName: string,
|
||||
timePeriod: BeszelTimePeriod = "1h",
|
||||
locale = "en-US",
|
||||
) => {
|
||||
if (!systemStats?.length) return [];
|
||||
const { fmt, ordered } = prepareRecords(systemStats, timePeriod);
|
||||
const { fmt, ordered } = prepareRecords(systemStats, timePeriod, locale);
|
||||
const mapped = ordered.map((record) => {
|
||||
const point: Record<string, unknown> = {
|
||||
time: fmt(record.created),
|
||||
@@ -238,7 +246,7 @@ export const buildDiskChartData = (
|
||||
}
|
||||
return point;
|
||||
});
|
||||
return padTimeGrid(mapped, timePeriod);
|
||||
return padTimeGrid(mapped, timePeriod, locale);
|
||||
};
|
||||
|
||||
export const useDockerChartData = (
|
||||
@@ -246,12 +254,13 @@ export const useDockerChartData = (
|
||||
containerNames: string[],
|
||||
metric: "cpu" | "memory" | "network",
|
||||
timePeriod: BeszelTimePeriod = "1h",
|
||||
locale = "en-US",
|
||||
) =>
|
||||
useMemo(() => {
|
||||
if (!containerStats?.length) return [];
|
||||
const extract = defaultContainerExtractors[metric];
|
||||
if (!extract) return [];
|
||||
const { fmt, ordered } = prepareRecords(containerStats, timePeriod);
|
||||
const { fmt, ordered } = prepareRecords(containerStats, timePeriod, locale);
|
||||
const mapped = ordered.map((record) => {
|
||||
const point: Record<string, unknown> = { time: fmt(record.created), rawTime: record.created };
|
||||
const byName = new Map(record.stats.map((c) => [c.n, c]));
|
||||
@@ -260,5 +269,5 @@ export const useDockerChartData = (
|
||||
}
|
||||
return point;
|
||||
});
|
||||
return padTimeGrid(mapped, timePeriod);
|
||||
}, [containerStats, containerNames, metric, timePeriod]);
|
||||
return padTimeGrid(mapped, timePeriod, locale);
|
||||
}, [containerStats, containerNames, metric, timePeriod, locale]);
|
||||
@@ -0,0 +1,11 @@
|
||||
.target {
|
||||
min-width: 24px;
|
||||
min-height: 24px;
|
||||
}
|
||||
|
||||
@media (pointer: coarse) {
|
||||
.target {
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,12 @@
|
||||
import { Box, Group, HoverCard, Progress, Stack, Text, UnstyledButton } from "@mantine/core";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Box, Group, Popover, Progress, Stack, Text, UnstyledButton } from "@mantine/core";
|
||||
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { BeszelSystemRow } from "./types";
|
||||
import { thresholdColor } from "./colors";
|
||||
import { formatPercent, getProgressTrackSize } from "./format";
|
||||
import classes from "./disk-usage.module.css";
|
||||
|
||||
interface DiskUsageProps {
|
||||
system: BeszelSystemRow;
|
||||
@@ -17,33 +21,44 @@ const mountLabel = (path: string) =>
|
||||
const severityColor = (value: number) => `var(--mantine-color-${thresholdColor(value)}-6)`;
|
||||
|
||||
export const DiskUsage = ({ system, fontSize, progressSize, valueMiw, valueGap = 6 }: DiskUsageProps) => {
|
||||
const t = useScopedI18n("common");
|
||||
const [opened, setOpened] = useState(false);
|
||||
const filesystems = Object.entries(system.extraFilesystems).filter(([path]) => path !== "/");
|
||||
const filesystemUsageLabel = t("filesystemUsage", { count: filesystems.length + 1 });
|
||||
const trackSize = getProgressTrackSize(progressSize);
|
||||
const dotSize = progressSize === "xs" ? 2 : 3;
|
||||
const dotGap = 2;
|
||||
|
||||
useEffect(() => {
|
||||
if (filesystems.length === 0) setOpened(false);
|
||||
}, [filesystems.length]);
|
||||
|
||||
return (
|
||||
<Group gap={valueGap} wrap="nowrap" style={{ flex: 1, minWidth: 0, marginLeft: "auto" }}>
|
||||
<Text size={fontSize} fw={500} w={valueMiw} ta="left" style={{ whiteSpace: "nowrap", flexShrink: 0 }}>
|
||||
{formatPercent(system.disk)}
|
||||
</Text>
|
||||
<HoverCard
|
||||
position="right"
|
||||
withArrow
|
||||
shadow="md"
|
||||
openDelay={200}
|
||||
closeDelay={100}
|
||||
disabled={filesystems.length === 0}
|
||||
>
|
||||
<HoverCard.Target>
|
||||
{filesystems.length > 0 ? (
|
||||
{filesystems.length > 0 ? (
|
||||
<Popover
|
||||
opened={opened}
|
||||
onChange={setOpened}
|
||||
zIndex="var(--homarr-z-index-widget-background-tooltip)"
|
||||
position="right"
|
||||
withArrow
|
||||
shadow="md"
|
||||
>
|
||||
<Popover.Target>
|
||||
<UnstyledButton
|
||||
aria-label={`Show usage for ${filesystems.length + 1} filesystems`}
|
||||
aria-label={filesystemUsageLabel}
|
||||
className={classes.target}
|
||||
onMouseEnter={() => setOpened(true)}
|
||||
onMouseLeave={() => setOpened(false)}
|
||||
onBlur={() => setOpened(false)}
|
||||
onClick={() => setOpened(true)}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
flex: 1,
|
||||
minWidth: 24,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
@@ -74,32 +89,32 @@ export const DiskUsage = ({ system, fontSize, progressSize, valueMiw, valueGap =
|
||||
</Group>
|
||||
</Box>
|
||||
</UnstyledButton>
|
||||
) : (
|
||||
<Box style={{ display: "flex", alignItems: "center", flex: 1, minWidth: 24 }}>
|
||||
<Box pos="relative" style={{ flex: 1, minWidth: 24 }}>
|
||||
<Progress value={system.disk} color={thresholdColor(system.disk)} size={trackSize} />
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</HoverCard.Target>
|
||||
<HoverCard.Dropdown p={8}>
|
||||
<Stack gap={6} miw={145}>
|
||||
{[["/", system.disk] as const, ...filesystems].map(([path, value]) => (
|
||||
<Stack key={path} gap={1}>
|
||||
<Text size="10px" c="dimmed" fw={500} lh={1.2}>
|
||||
{mountLabel(path)}
|
||||
</Text>
|
||||
<Group gap={8} wrap="nowrap">
|
||||
<Text size="sm" fw={600} miw={48} lh={1.25}>
|
||||
{formatPercent(value)}
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown aria-label={filesystemUsageLabel} p={8}>
|
||||
<Stack gap={6} miw={145}>
|
||||
{[["/", system.disk] as const, ...filesystems].map(([path, value]) => (
|
||||
<Stack key={path} gap={1}>
|
||||
<Text size="10px" c="dimmed" fw={500} lh={1.2}>
|
||||
{mountLabel(path)}
|
||||
</Text>
|
||||
<Progress value={value} color={thresholdColor(value)} size="xs" style={{ flex: 1 }} />
|
||||
</Group>
|
||||
</Stack>
|
||||
))}
|
||||
</Stack>
|
||||
</HoverCard.Dropdown>
|
||||
</HoverCard>
|
||||
<Group gap={8} wrap="nowrap">
|
||||
<Text size="sm" fw={600} miw={48} lh={1.25}>
|
||||
{formatPercent(value)}
|
||||
</Text>
|
||||
<Progress value={value} color={thresholdColor(value)} size="xs" style={{ flex: 1 }} />
|
||||
</Group>
|
||||
</Stack>
|
||||
))}
|
||||
</Stack>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
) : (
|
||||
<Box style={{ display: "flex", alignItems: "center", flex: 1, minWidth: 24 }}>
|
||||
<Box pos="relative" style={{ flex: 1, minWidth: 24 }}>
|
||||
<Progress value={system.disk} color={thresholdColor(system.disk)} size={trackSize} />
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
@@ -1,25 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Tooltip } from "@mantine/core";
|
||||
import { IconAlertTriangle } from "@tabler/icons-react";
|
||||
|
||||
interface IntegrationResult {
|
||||
integrationId: string;
|
||||
integrationName?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface BeszelIntegrationErrorIndicatorProps {
|
||||
results: IntegrationResult[];
|
||||
}
|
||||
|
||||
export function BeszelIntegrationErrorIndicator({ results }: BeszelIntegrationErrorIndicatorProps) {
|
||||
const failed = results.filter((r) => "error" in r && r.error);
|
||||
if (failed.length === 0) return null;
|
||||
const label = failed.map((r) => r.integrationName ?? r.integrationId).join(", ");
|
||||
return (
|
||||
<Tooltip label={label} position="left" withArrow>
|
||||
<IconAlertTriangle size={14} color="var(--mantine-color-orange-6)" style={{ cursor: "help", flexShrink: 0 }} />
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -6,8 +6,9 @@ import { IconPlugConnectedX, IconServerOff } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import type { BeszelContainerStatsRecord, BeszelSystemStatsRecord } from "@homarr/integrations/types";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { useCurrentIntlLocale, useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { getUsableWidgetQueryData } from "../../common/query-state";
|
||||
import { containerColors } from "./colors";
|
||||
import type { BeszelTimePeriod } from "./chart";
|
||||
import {
|
||||
@@ -19,16 +20,23 @@ import {
|
||||
useSystemChartData,
|
||||
} from "./chart";
|
||||
import { chartAxisFormatters, formatByteRate, formatGB, formatPercent, formatStorageBytes } from "./format";
|
||||
import type { BeszelTooltipLayer } from "./tooltip";
|
||||
import { makeTooltipProps } from "./tooltip";
|
||||
import { useLiveStats } from "./use-live-stats";
|
||||
|
||||
const CHART_HEIGHT = 180;
|
||||
const MB = 1024 * 1024;
|
||||
const tooltipPercent = makeTooltipProps(formatPercent);
|
||||
const tooltipGB = makeTooltipProps(formatGB, true);
|
||||
const tooltipRate = makeTooltipProps(formatByteRate, true);
|
||||
const tooltipPercentTotal = makeTooltipProps(formatPercent, true);
|
||||
const tooltipBytesTotal = makeTooltipProps(formatStorageBytes, true);
|
||||
const makeTooltipSet = (layer: BeszelTooltipLayer) => ({
|
||||
percent: makeTooltipProps(formatPercent, false, layer),
|
||||
gigabytes: makeTooltipProps(formatGB, true, layer),
|
||||
rate: makeTooltipProps(formatByteRate, true, layer),
|
||||
percentTotal: makeTooltipProps(formatPercent, true, layer),
|
||||
bytesTotal: makeTooltipProps(formatStorageBytes, true, layer),
|
||||
});
|
||||
const tooltipSets = {
|
||||
background: makeTooltipSet("background"),
|
||||
modal: makeTooltipSet("modal"),
|
||||
};
|
||||
|
||||
const ChartSkeleton = () => (
|
||||
<Stack gap={4} style={{ minWidth: 0 }}>
|
||||
@@ -58,6 +66,7 @@ interface BeszelStatsViewProps {
|
||||
visibility: BeszelStatsVisibility;
|
||||
columns: 1 | 2;
|
||||
onSwitchToHistorical?: () => void;
|
||||
tooltipLayer?: BeszelTooltipLayer;
|
||||
}
|
||||
|
||||
const whenVisible = <T,>(visible: boolean, data: T | undefined) => (visible ? data : undefined);
|
||||
@@ -69,15 +78,19 @@ export function BeszelStatsView({
|
||||
visibility,
|
||||
columns,
|
||||
onSwitchToHistorical,
|
||||
tooltipLayer = "background",
|
||||
}: BeszelStatsViewProps) {
|
||||
const t = useScopedI18n("widget.beszelSystemStats");
|
||||
const locale = useCurrentIntlLocale();
|
||||
const showDocker = visibility.dockerCpu || visibility.dockerMemory || visibility.dockerNetwork;
|
||||
const isLive = timePeriod === "1m";
|
||||
const tooltips = tooltipSets[tooltipLayer];
|
||||
|
||||
const { data: historicalData, error: historicalError } = clientApi.widget.beszel.getSystemStats.useQuery(
|
||||
const historicalQuery = clientApi.widget.beszel.getSystemStats.useQuery(
|
||||
{ integrationIds, systemId, timePeriod, includeDocker: showDocker },
|
||||
{ refetchInterval: isLive ? false : 5_000, enabled: !isLive && systemId !== "" },
|
||||
);
|
||||
const historicalData = isLive ? historicalQuery.data : getUsableWidgetQueryData(historicalQuery);
|
||||
const { data: liveData, error: liveError } = useLiveStats(integrationIds, systemId, isLive && systemId !== "");
|
||||
|
||||
const activeStats:
|
||||
@@ -125,10 +138,25 @@ export function BeszelStatsView({
|
||||
|
||||
const systemStats = activeStats?.systemStats;
|
||||
const containerStats = activeStats?.containerStats;
|
||||
const cpuData = useSystemChartData(whenVisible(visibility.cpu, systemStats), mappers.cpu, timePeriod);
|
||||
const memoryData = useSystemChartData(whenVisible(visibility.memory, systemStats), mappers.memory, timePeriod);
|
||||
const diskIOData = useSystemChartData(whenVisible(visibility.diskIO, systemStats), mappers.diskIO, timePeriod);
|
||||
const networkData = useSystemChartData(whenVisible(visibility.network, systemStats), mappers.network, timePeriod);
|
||||
const cpuData = useSystemChartData(whenVisible(visibility.cpu, systemStats), mappers.cpu, timePeriod, locale);
|
||||
const memoryData = useSystemChartData(
|
||||
whenVisible(visibility.memory, systemStats),
|
||||
mappers.memory,
|
||||
timePeriod,
|
||||
locale,
|
||||
);
|
||||
const diskIOData = useSystemChartData(
|
||||
whenVisible(visibility.diskIO, systemStats),
|
||||
mappers.diskIO,
|
||||
timePeriod,
|
||||
locale,
|
||||
);
|
||||
const networkData = useSystemChartData(
|
||||
whenVisible(visibility.network, systemStats),
|
||||
mappers.network,
|
||||
timePeriod,
|
||||
locale,
|
||||
);
|
||||
|
||||
const efsPaths = useMemo(() => {
|
||||
const paths = new Set<string>();
|
||||
@@ -138,7 +166,13 @@ export function BeszelStatsView({
|
||||
return [...paths];
|
||||
}, [systemStats]);
|
||||
const rootSeriesName = t("chart.disk.series");
|
||||
const diskData = useDiskChartData(whenVisible(visibility.disk, systemStats), efsPaths, rootSeriesName, timePeriod);
|
||||
const diskData = useDiskChartData(
|
||||
whenVisible(visibility.disk, systemStats),
|
||||
efsPaths,
|
||||
rootSeriesName,
|
||||
timePeriod,
|
||||
locale,
|
||||
);
|
||||
const diskSeries = useMemo(
|
||||
() => [
|
||||
{ name: rootSeriesName, color: "grape.6" },
|
||||
@@ -161,22 +195,23 @@ export function BeszelStatsView({
|
||||
containerNames,
|
||||
"cpu",
|
||||
timePeriod,
|
||||
locale,
|
||||
);
|
||||
const dockerMemoryData = useDockerChartData(
|
||||
whenVisible(visibility.dockerMemory, containerStats),
|
||||
containerNames,
|
||||
"memory",
|
||||
timePeriod,
|
||||
locale,
|
||||
);
|
||||
const dockerNetworkData = useDockerChartData(
|
||||
whenVisible(visibility.dockerNetwork, containerStats),
|
||||
containerNames,
|
||||
"network",
|
||||
timePeriod,
|
||||
locale,
|
||||
);
|
||||
|
||||
if (historicalError) throw historicalError;
|
||||
|
||||
if (isLive && liveError) {
|
||||
return (
|
||||
<Center h={CHART_HEIGHT}>
|
||||
@@ -198,7 +233,7 @@ export function BeszelStatsView({
|
||||
if (!activeStats) {
|
||||
const visibleChartCount = Object.values(visibility).filter(Boolean).length;
|
||||
return (
|
||||
<SimpleGrid cols={columns} spacing="md" aria-label="Loading system statistics">
|
||||
<SimpleGrid cols={columns} spacing="md" aria-label={t("name")}>
|
||||
{Array.from({ length: visibleChartCount }, (_, index) => (
|
||||
<ChartSkeleton key={index} />
|
||||
))}
|
||||
@@ -212,7 +247,7 @@ export function BeszelStatsView({
|
||||
<Stack align="center" gap="xs">
|
||||
<IconServerOff size={24} opacity={0.5} />
|
||||
<Text size="sm" c="dimmed">
|
||||
{activeStats.error}
|
||||
{t("error.internalServerError")}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
@@ -231,7 +266,7 @@ export function BeszelStatsView({
|
||||
series: series.cpu,
|
||||
yAxisFormatter: chartAxisFormatters.percent,
|
||||
yAxisDomain: CPU_Y_AXIS_DOMAIN,
|
||||
tooltipProps: tooltipPercent,
|
||||
tooltipProps: tooltips.percent,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -245,7 +280,7 @@ export function BeszelStatsView({
|
||||
type: "stacked",
|
||||
series: series.memory,
|
||||
yAxisFormatter: chartAxisFormatters.gb,
|
||||
tooltipProps: tooltipGB,
|
||||
tooltipProps: tooltips.gigabytes,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -259,7 +294,7 @@ export function BeszelStatsView({
|
||||
type: "stacked",
|
||||
series: diskSeries,
|
||||
yAxisFormatter: chartAxisFormatters.gb,
|
||||
tooltipProps: tooltipGB,
|
||||
tooltipProps: tooltips.gigabytes,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -272,7 +307,7 @@ export function BeszelStatsView({
|
||||
data: diskIOData,
|
||||
series: series.diskIO,
|
||||
yAxisFormatter: chartAxisFormatters.rate,
|
||||
tooltipProps: tooltipRate,
|
||||
tooltipProps: tooltips.rate,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -285,7 +320,7 @@ export function BeszelStatsView({
|
||||
data: networkData,
|
||||
series: series.network,
|
||||
yAxisFormatter: chartAxisFormatters.rate,
|
||||
tooltipProps: tooltipRate,
|
||||
tooltipProps: tooltips.rate,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -301,7 +336,7 @@ export function BeszelStatsView({
|
||||
type: "stacked",
|
||||
series: containerSeries,
|
||||
yAxisFormatter: chartAxisFormatters.percent,
|
||||
tooltipProps: tooltipPercentTotal,
|
||||
tooltipProps: tooltips.percentTotal,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -315,7 +350,7 @@ export function BeszelStatsView({
|
||||
type: "stacked",
|
||||
series: containerSeries,
|
||||
yAxisFormatter: chartAxisFormatters.bytes,
|
||||
tooltipProps: tooltipBytesTotal,
|
||||
tooltipProps: tooltips.bytesTotal,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -328,7 +363,7 @@ export function BeszelStatsView({
|
||||
data: dockerNetworkData,
|
||||
series: containerSeries,
|
||||
yAxisFormatter: chartAxisFormatters.rate,
|
||||
tooltipProps: tooltipRate,
|
||||
tooltipProps: tooltips.rate,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -50,6 +50,7 @@ export const BeszelSystemStatsModal = createModal<BeszelSystemStatsModalProps>((
|
||||
timePeriod={timePeriod}
|
||||
visibility={allCharts}
|
||||
columns={2}
|
||||
tooltipLayer="modal"
|
||||
onSwitchToHistorical={() => setTimePeriod("1h")}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
@@ -3,10 +3,9 @@
|
||||
import type { CSSProperties } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import dayjs from "dayjs";
|
||||
import localizedFormat from "dayjs/plugin/localizedFormat";
|
||||
import { useCurrentIntlLocale, useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
dayjs.extend(localizedFormat);
|
||||
import { formatLocalizedDate } from "../../common/locale";
|
||||
|
||||
const styles: Record<string, CSSProperties> = {
|
||||
wrapper: {
|
||||
@@ -53,11 +52,15 @@ interface PortalTooltipProps {
|
||||
payload?: TooltipPayloadItem[];
|
||||
formatter: (value: number) => string;
|
||||
showTotal?: boolean;
|
||||
layer: BeszelTooltipLayer;
|
||||
}
|
||||
|
||||
const MARGIN = 14;
|
||||
export type BeszelTooltipLayer = "background" | "modal";
|
||||
|
||||
const PortalTooltipContent = ({ active, label, payload, formatter, showTotal }: PortalTooltipProps) => {
|
||||
const MARGIN = 14;
|
||||
const PortalTooltipContent = ({ active, label, payload, formatter, showTotal, layer }: PortalTooltipProps) => {
|
||||
const locale = useCurrentIntlLocale();
|
||||
const t = useScopedI18n("common");
|
||||
const mouseRef = useRef({ x: 0, y: 0 });
|
||||
const [pos, setPos] = useState({ x: 0, y: 0 });
|
||||
const rafRef = useRef(0);
|
||||
@@ -70,7 +73,6 @@ const PortalTooltipContent = ({ active, label, payload, formatter, showTotal }:
|
||||
const el = tooltipRef.current;
|
||||
const mx = mouseRef.current.x;
|
||||
const my = mouseRef.current.y;
|
||||
|
||||
if (!el) {
|
||||
setPos({ x: mx + MARGIN, y: my });
|
||||
return;
|
||||
@@ -113,14 +115,17 @@ const PortalTooltipContent = ({ active, label, payload, formatter, showTotal }:
|
||||
if (!sorted.length) return null;
|
||||
|
||||
const rawTime = sorted[0]?.payload?.rawTime as string | undefined;
|
||||
const tooltipLabel = rawTime ? dayjs(rawTime).format("MMM D, LT") : label;
|
||||
const tooltipLabel = rawTime
|
||||
? formatLocalizedDate(rawTime, locale, { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" })
|
||||
: label;
|
||||
const total = sorted.reduce((sum, p) => sum + p.value, 0);
|
||||
|
||||
const portalStyle: CSSProperties = {
|
||||
position: "fixed",
|
||||
left: pos.x,
|
||||
top: pos.y,
|
||||
zIndex: 10000,
|
||||
zIndex:
|
||||
layer === "modal" ? "var(--homarr-z-index-modal-tooltip)" : "var(--homarr-z-index-widget-background-tooltip)",
|
||||
pointerEvents: "none",
|
||||
};
|
||||
|
||||
@@ -137,7 +142,7 @@ const PortalTooltipContent = ({ active, label, payload, formatter, showTotal }:
|
||||
))}
|
||||
{showTotal && sorted.length > 1 && (
|
||||
<div style={{ ...styles.row, ...styles.separator }}>
|
||||
<span style={styles.name}>Total</span>
|
||||
<span style={styles.name}>{t("total")}</span>
|
||||
<span style={styles.value}>{formatter(total)}</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -148,7 +153,11 @@ const PortalTooltipContent = ({ active, label, payload, formatter, showTotal }:
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export const makeTooltipProps = (formatter: (v: number) => string, showTotal = false) => ({
|
||||
export const makeTooltipProps = (
|
||||
formatter: (v: number) => string,
|
||||
showTotal = false,
|
||||
layer: BeszelTooltipLayer = "background",
|
||||
) => ({
|
||||
content: (props: any) => (
|
||||
<PortalTooltipContent
|
||||
active={props.active}
|
||||
@@ -156,6 +165,7 @@ export const makeTooltipProps = (formatter: (v: number) => string, showTotal = f
|
||||
payload={props.payload}
|
||||
formatter={formatter}
|
||||
showTotal={showTotal}
|
||||
layer={layer}
|
||||
/>
|
||||
),
|
||||
wrapperStyle: { visibility: "hidden" as const, position: "absolute" as const, width: 0, height: 0 },
|
||||
|
||||
@@ -2,6 +2,12 @@
|
||||
background-color: var(--mantine-color-primaryColor-light-hover);
|
||||
}
|
||||
|
||||
.bookmarkButton:focus-visible {
|
||||
outline: 2px solid var(--mantine-primary-color-filled);
|
||||
outline-offset: 2px;
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
}
|
||||
|
||||
[data-mantine-color-scheme="light"] .card-grid {
|
||||
background-color: var(--mantine-color-gray-1);
|
||||
}
|
||||
@@ -13,3 +19,7 @@
|
||||
.card:hover > div > div.bookmarkIcon {
|
||||
background-color: var(--mantine-color-iconColor-filled-hover);
|
||||
}
|
||||
|
||||
.bookmarkButton:focus-visible .bookmarkIcon {
|
||||
background-color: var(--mantine-color-iconColor-filled-hover);
|
||||
}
|
||||
@@ -1,6 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import { Anchor, Card, Flex, Group, Stack, Text, Title, UnstyledButton } from "@mantine/core";
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
Anchor,
|
||||
Box,
|
||||
Card,
|
||||
Flex,
|
||||
Group,
|
||||
ScrollArea,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
UnstyledButton,
|
||||
} from "@mantine/core";
|
||||
import combineClasses from "clsx";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
@@ -10,67 +23,138 @@ import { useRegisterSpotlightContextResults } from "@homarr/spotlight";
|
||||
import { MaskedOrNormalImage } from "@homarr/ui";
|
||||
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url";
|
||||
import { getUsableWidgetQueryData } from "../common/query-state";
|
||||
import classes from "./bookmark.module.css";
|
||||
|
||||
export default function BookmarksWidget({ options, itemId }: WidgetComponentProps<"bookmarks">) {
|
||||
type BookmarkLayout = WidgetComponentProps<"bookmarks">["options"]["layout"];
|
||||
|
||||
export interface CompactBookmarkLayout {
|
||||
columns: number;
|
||||
hideHostname: boolean;
|
||||
hideTitle: boolean;
|
||||
minimumItemSize: number;
|
||||
}
|
||||
|
||||
export function getCompactBookmarkLayout(
|
||||
width: number,
|
||||
height: number,
|
||||
itemCount: number,
|
||||
layout: BookmarkLayout,
|
||||
): CompactBookmarkLayout {
|
||||
const count = Math.max(1, itemCount);
|
||||
const isGrid = layout === "grid" || layout === "gridHorizontal";
|
||||
const targetWidth = layout === "gridHorizontal" ? 180 : 104;
|
||||
const columns = isGrid ? Math.max(1, Math.min(count, Math.floor(width / targetWidth))) : layout === "row" ? count : 1;
|
||||
const rows = Math.max(1, Math.ceil(count / columns));
|
||||
const cellWidth = width / columns;
|
||||
const cellHeight = height / rows;
|
||||
const usesHorizontalItems = layout === "column" || layout === "gridHorizontal";
|
||||
|
||||
return {
|
||||
columns,
|
||||
hideHostname: cellWidth < 120 || cellHeight < (usesHorizontalItems ? 52 : 96),
|
||||
hideTitle: cellWidth < 64 || cellHeight < 40,
|
||||
minimumItemSize: usesHorizontalItems ? 48 : 96,
|
||||
};
|
||||
}
|
||||
|
||||
export default function BookmarksWidget({
|
||||
options,
|
||||
itemId,
|
||||
width,
|
||||
height,
|
||||
displayMode,
|
||||
}: WidgetComponentProps<"bookmarks">) {
|
||||
const board = useRequiredBoard();
|
||||
const { data = [] } = clientApi.app.byIds.useQuery(options.items, {
|
||||
select(data) {
|
||||
return data.toSorted((appA, appB) => options.items.indexOf(appA.id) - options.items.indexOf(appB.id));
|
||||
},
|
||||
});
|
||||
const data =
|
||||
getUsableWidgetQueryData(
|
||||
clientApi.app.byIds.useQuery(options.items, {
|
||||
select(apps) {
|
||||
return apps.toSorted((appA, appB) => options.items.indexOf(appA.id) - options.items.indexOf(appB.id));
|
||||
},
|
||||
}),
|
||||
) ?? [];
|
||||
|
||||
useRegisterSpotlightContextResults(
|
||||
`bookmark-${itemId}`,
|
||||
data
|
||||
.filter((app) => app.href !== null)
|
||||
.map((app) => ({
|
||||
id: app.id,
|
||||
name: app.name,
|
||||
icon: app.iconUrl,
|
||||
interaction() {
|
||||
return {
|
||||
type: "link",
|
||||
// We checked above that app.href is defined
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
href: app.href!,
|
||||
newTab: false,
|
||||
};
|
||||
},
|
||||
})),
|
||||
data.flatMap((app) => {
|
||||
const href = getSafeApplicationUrl(app.href);
|
||||
return href
|
||||
? [
|
||||
{
|
||||
id: app.id,
|
||||
name: app.name,
|
||||
icon: app.iconUrl,
|
||||
interaction() {
|
||||
return {
|
||||
type: "link",
|
||||
href,
|
||||
newTab: false,
|
||||
};
|
||||
},
|
||||
},
|
||||
]
|
||||
: [];
|
||||
}),
|
||||
[data],
|
||||
);
|
||||
|
||||
const compactLayout = useMemo(
|
||||
() => getCompactBookmarkLayout(width, height, data.length, options.layout),
|
||||
[width, height, data.length, options.layout],
|
||||
);
|
||||
const compactHideTitle = options.hideTitle || (compactLayout.hideTitle && !options.hideIcon);
|
||||
const compactHideHostname =
|
||||
options.hideHostname || (compactLayout.hideHostname && !(options.hideTitle && options.hideIcon));
|
||||
|
||||
return (
|
||||
<Stack h="100%" gap="sm" p="sm">
|
||||
<Stack h="100%" mih={0} gap="sm" p={height < 120 ? "xs" : "sm"}>
|
||||
{options.title.length > 0 && (
|
||||
<Title order={4} px="0.25rem">
|
||||
<Title order={4} px="0.25rem" lineClamp={1}>
|
||||
{options.title}
|
||||
</Title>
|
||||
)}
|
||||
{(options.layout === "grid" || options.layout === "gridHorizontal") && (
|
||||
<GridLayout
|
||||
{displayMode === "advanced" && (
|
||||
<AdvancedBookmarksLayout
|
||||
data={data}
|
||||
itemDirection={options.layout === "gridHorizontal" ? "horizontal" : "vertical"}
|
||||
hideTitle={options.hideTitle}
|
||||
hideIcon={options.hideIcon}
|
||||
hideHostname={options.hideHostname}
|
||||
width={width}
|
||||
openNewTab={options.openNewTab}
|
||||
withBorder={options.withBorder}
|
||||
hasIconColor={board.iconColor !== null}
|
||||
/>
|
||||
)}
|
||||
{options.layout !== "grid" && options.layout !== "gridHorizontal" && (
|
||||
<FlexLayout
|
||||
data={data}
|
||||
direction={options.layout}
|
||||
hideTitle={options.hideTitle}
|
||||
hideIcon={options.hideIcon}
|
||||
hideHostname={options.hideHostname}
|
||||
openNewTab={options.openNewTab}
|
||||
withBorder={options.withBorder}
|
||||
hasIconColor={board.iconColor !== null}
|
||||
/>
|
||||
{displayMode !== "advanced" && (
|
||||
<ScrollArea type="auto" scrollbarSize={6} offsetScrollbars style={{ flex: 1, minHeight: 0 }}>
|
||||
<Box mih="100%">
|
||||
{options.layout === "grid" || options.layout === "gridHorizontal" ? (
|
||||
<GridLayout
|
||||
data={data}
|
||||
columns={compactLayout.columns}
|
||||
minimumItemHeight={compactLayout.minimumItemSize}
|
||||
itemDirection={options.layout === "gridHorizontal" ? "horizontal" : "vertical"}
|
||||
hideTitle={compactHideTitle}
|
||||
hideIcon={options.hideIcon}
|
||||
hideHostname={compactHideHostname}
|
||||
openNewTab={options.openNewTab}
|
||||
withBorder={options.withBorder}
|
||||
hasIconColor={board.iconColor !== null}
|
||||
/>
|
||||
) : (
|
||||
<FlexLayout
|
||||
data={data}
|
||||
direction={options.layout}
|
||||
minimumItemSize={compactLayout.minimumItemSize}
|
||||
hideTitle={compactHideTitle}
|
||||
hideIcon={options.hideIcon}
|
||||
hideHostname={compactHideHostname}
|
||||
openNewTab={options.openNewTab}
|
||||
withBorder={options.withBorder}
|
||||
hasIconColor={board.iconColor !== null}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
@@ -85,6 +169,7 @@ interface FlexLayoutProps {
|
||||
openNewTab: boolean;
|
||||
withBorder: boolean;
|
||||
hasIconColor: boolean;
|
||||
minimumItemSize: number;
|
||||
}
|
||||
|
||||
const FlexLayout = ({
|
||||
@@ -96,54 +181,130 @@ const FlexLayout = ({
|
||||
openNewTab,
|
||||
withBorder,
|
||||
hasIconColor,
|
||||
minimumItemSize,
|
||||
}: FlexLayoutProps) => {
|
||||
const board = useRequiredBoard();
|
||||
return (
|
||||
<Flex direction={direction} gap="0" w="100%">
|
||||
{data.map((app) => (
|
||||
<div key={app.id} style={{ display: "flex", flex: "1", flexDirection: direction }}>
|
||||
<UnstyledButton
|
||||
component="a"
|
||||
href={app.href ?? undefined}
|
||||
target={openNewTab ? "_blank" : "_self"}
|
||||
rel="noopener noreferrer"
|
||||
key={app.id}
|
||||
w="100%"
|
||||
>
|
||||
<Card
|
||||
radius={board.itemRadius}
|
||||
className={classes.card}
|
||||
<Flex direction={direction} gap={4} w="100%" mih="100%" wrap="nowrap">
|
||||
{data.map((app) => {
|
||||
const href = getSafeApplicationUrl(app.href);
|
||||
return (
|
||||
<div key={app.id} style={{ display: "flex", flex: `1 0 ${minimumItemSize}px`, flexDirection: direction }}>
|
||||
<UnstyledButton
|
||||
className={classes.bookmarkButton}
|
||||
component={href ? "a" : "div"}
|
||||
href={href}
|
||||
target={href ? (openNewTab ? "_blank" : "_self") : undefined}
|
||||
rel={href && openNewTab ? SAFE_NEW_TAB_REL : undefined}
|
||||
key={app.id}
|
||||
w="100%"
|
||||
display="flex"
|
||||
p={4}
|
||||
h="100%"
|
||||
withBorder={withBorder}
|
||||
>
|
||||
{direction === "row" ? (
|
||||
<VerticalItem
|
||||
app={app}
|
||||
hideTitle={hideTitle}
|
||||
hideIcon={hideIcon}
|
||||
hideHostname={hideHostname}
|
||||
hasIconColor={hasIconColor}
|
||||
/>
|
||||
) : (
|
||||
<HorizontalItem
|
||||
app={app}
|
||||
hideTitle={hideTitle}
|
||||
hideIcon={hideIcon}
|
||||
hideHostname={hideHostname}
|
||||
hasIconColor={hasIconColor}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
</UnstyledButton>
|
||||
</div>
|
||||
))}
|
||||
<Card
|
||||
radius={board.itemRadius}
|
||||
className={classes.card}
|
||||
w="100%"
|
||||
display="flex"
|
||||
p={4}
|
||||
h="100%"
|
||||
withBorder={withBorder}
|
||||
>
|
||||
{direction === "row" ? (
|
||||
<VerticalItem
|
||||
app={app}
|
||||
hideTitle={hideTitle}
|
||||
hideIcon={hideIcon}
|
||||
hideHostname={hideHostname}
|
||||
hasIconColor={hasIconColor}
|
||||
/>
|
||||
) : (
|
||||
<HorizontalItem
|
||||
app={app}
|
||||
hideTitle={hideTitle}
|
||||
hideIcon={hideIcon}
|
||||
hideHostname={hideHostname}
|
||||
hasIconColor={hasIconColor}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
</UnstyledButton>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
interface AdvancedBookmarksLayoutProps {
|
||||
data: RouterOutputs["app"]["byIds"];
|
||||
width: number;
|
||||
openNewTab: boolean;
|
||||
withBorder: boolean;
|
||||
hasIconColor: boolean;
|
||||
}
|
||||
|
||||
export const getAdvancedBookmarkColumns = (width: number, itemCount: number): number =>
|
||||
Math.max(1, Math.min(itemCount || 1, Math.floor(width / 280)));
|
||||
|
||||
export const getBookmarkHostname = (href: string | null): string | undefined => {
|
||||
const safeHref = getSafeApplicationUrl(href);
|
||||
return safeHref ? new URL(safeHref).hostname : undefined;
|
||||
};
|
||||
|
||||
const AdvancedBookmarksLayout = ({
|
||||
data,
|
||||
width,
|
||||
openNewTab,
|
||||
withBorder,
|
||||
hasIconColor,
|
||||
}: AdvancedBookmarksLayoutProps) => {
|
||||
const board = useRequiredBoard();
|
||||
|
||||
return (
|
||||
<ScrollArea h="100%" style={{ flex: 1 }}>
|
||||
<SimpleGrid cols={getAdvancedBookmarkColumns(width, data.length)} spacing="sm">
|
||||
{data.map((app) => {
|
||||
const href = getSafeApplicationUrl(app.href);
|
||||
return (
|
||||
<UnstyledButton
|
||||
key={app.id}
|
||||
className={classes.bookmarkButton}
|
||||
component={href ? "a" : "div"}
|
||||
href={href}
|
||||
target={href ? (openNewTab ? "_blank" : "_self") : undefined}
|
||||
rel={href && openNewTab ? SAFE_NEW_TAB_REL : undefined}
|
||||
>
|
||||
<Card radius={board.itemRadius} className={classes.card} withBorder={withBorder} p="md" h="100%">
|
||||
<Group align="flex-start" wrap="nowrap">
|
||||
<MaskedOrNormalImage
|
||||
imageUrl={app.iconUrl}
|
||||
hasColor={hasIconColor}
|
||||
alt={app.name}
|
||||
className={classes.bookmarkIcon}
|
||||
style={{ width: 40, height: 40, flex: "0 0 auto" }}
|
||||
/>
|
||||
<Stack gap={3} miw={0}>
|
||||
<Text fw={700} size="sm" truncate="end">
|
||||
{app.name}
|
||||
</Text>
|
||||
<Anchor component="span" size="xs" truncate="end">
|
||||
{getBookmarkHostname(app.href)}
|
||||
</Anchor>
|
||||
{app.description && (
|
||||
<Text size="xs" c="dimmed" lineClamp={3}>
|
||||
{app.description}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Group>
|
||||
</Card>
|
||||
</UnstyledButton>
|
||||
);
|
||||
})}
|
||||
</SimpleGrid>
|
||||
</ScrollArea>
|
||||
);
|
||||
};
|
||||
|
||||
interface GridLayoutProps {
|
||||
data: RouterOutputs["app"]["byIds"];
|
||||
hideTitle: boolean;
|
||||
@@ -153,6 +314,8 @@ interface GridLayoutProps {
|
||||
withBorder: boolean;
|
||||
itemDirection: "horizontal" | "vertical";
|
||||
hasIconColor: boolean;
|
||||
columns: number;
|
||||
minimumItemHeight: number;
|
||||
}
|
||||
|
||||
const GridLayout = ({
|
||||
@@ -164,48 +327,61 @@ const GridLayout = ({
|
||||
withBorder,
|
||||
itemDirection,
|
||||
hasIconColor,
|
||||
columns,
|
||||
minimumItemHeight,
|
||||
}: GridLayoutProps) => {
|
||||
const board = useRequiredBoard();
|
||||
|
||||
return (
|
||||
<Flex miw="100%" gap={4} wrap="wrap" style={{ flex: 1 }}>
|
||||
{data.map((app) => (
|
||||
<UnstyledButton
|
||||
component="a"
|
||||
href={app.href ?? undefined}
|
||||
target={openNewTab ? "_blank" : "_self"}
|
||||
rel="noopener noreferrer"
|
||||
key={app.id}
|
||||
flex="1"
|
||||
>
|
||||
<Card
|
||||
<SimpleGrid
|
||||
cols={columns}
|
||||
spacing={4}
|
||||
verticalSpacing={4}
|
||||
miw="100%"
|
||||
mih="100%"
|
||||
style={{ gridAutoRows: `minmax(${minimumItemHeight}px, 1fr)` }}
|
||||
>
|
||||
{data.map((app) => {
|
||||
const href = getSafeApplicationUrl(app.href);
|
||||
return (
|
||||
<UnstyledButton
|
||||
className={classes.bookmarkButton}
|
||||
component={href ? "a" : "div"}
|
||||
href={href}
|
||||
target={href ? (openNewTab ? "_blank" : "_self") : undefined}
|
||||
rel={href && openNewTab ? SAFE_NEW_TAB_REL : undefined}
|
||||
key={app.id}
|
||||
h="100%"
|
||||
className={combineClasses(classes.card, classes["card-grid"])}
|
||||
radius={board.itemRadius}
|
||||
withBorder={withBorder}
|
||||
p="xs"
|
||||
>
|
||||
{itemDirection === "horizontal" ? (
|
||||
<HorizontalItem
|
||||
app={app}
|
||||
hideTitle={hideTitle}
|
||||
hideIcon={hideIcon}
|
||||
hideHostname={hideHostname}
|
||||
hasIconColor={hasIconColor}
|
||||
/>
|
||||
) : (
|
||||
<VerticalItem
|
||||
app={app}
|
||||
hideTitle={hideTitle}
|
||||
hideIcon={hideIcon}
|
||||
hideHostname={hideHostname}
|
||||
hasIconColor={hasIconColor}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
</UnstyledButton>
|
||||
))}
|
||||
</Flex>
|
||||
<Card
|
||||
h="100%"
|
||||
className={combineClasses(classes.card, classes["card-grid"])}
|
||||
radius={board.itemRadius}
|
||||
withBorder={withBorder}
|
||||
p="xs"
|
||||
>
|
||||
{itemDirection === "horizontal" ? (
|
||||
<HorizontalItem
|
||||
app={app}
|
||||
hideTitle={hideTitle}
|
||||
hideIcon={hideIcon}
|
||||
hideHostname={hideHostname}
|
||||
hasIconColor={hasIconColor}
|
||||
/>
|
||||
) : (
|
||||
<VerticalItem
|
||||
app={app}
|
||||
hideTitle={hideTitle}
|
||||
hideIcon={hideIcon}
|
||||
hideHostname={hideHostname}
|
||||
hasIconColor={hasIconColor}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
</UnstyledButton>
|
||||
);
|
||||
})}
|
||||
</SimpleGrid>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -225,7 +401,7 @@ const VerticalItem = ({
|
||||
return (
|
||||
<Stack h="100%" miw={16} gap="sm" justify={"center"}>
|
||||
{!hideTitle && (
|
||||
<Text fw={700} ta="center" size="xs">
|
||||
<Text fw={700} ta="center" size="xs" lineClamp={2}>
|
||||
{app.name}
|
||||
</Text>
|
||||
)}
|
||||
@@ -246,8 +422,8 @@ const VerticalItem = ({
|
||||
/>
|
||||
)}
|
||||
{!hideHostname && (
|
||||
<Anchor ta="center" component="span" size="xs">
|
||||
{app.href ? new URL(app.href).hostname : undefined}
|
||||
<Anchor ta="center" component="span" size="xs" truncate="end" w="100%">
|
||||
{getBookmarkHostname(app.href)}
|
||||
</Anchor>
|
||||
)}
|
||||
</Stack>
|
||||
@@ -285,7 +461,7 @@ const HorizontalItem = ({
|
||||
)}
|
||||
{!(hideTitle && hideHostname) && (
|
||||
<>
|
||||
<Stack justify="space-between" gap={0}>
|
||||
<Stack justify="space-between" gap={0} miw={0} style={{ flex: 1 }}>
|
||||
{!hideTitle && (
|
||||
<Text fw={700} size="xs" lineClamp={hideHostname ? 2 : 1}>
|
||||
{app.name}
|
||||
@@ -293,8 +469,8 @@ const HorizontalItem = ({
|
||||
)}
|
||||
|
||||
{!hideHostname && (
|
||||
<Anchor component="span" size="xs">
|
||||
{app.href ? new URL(app.href).hostname : undefined}
|
||||
<Anchor component="span" size="xs" truncate="end">
|
||||
{getBookmarkHostname(app.href)}
|
||||
</Anchor>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
@@ -10,6 +10,7 @@ import { BookmarkAddButton } from "./add-button";
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("bookmarks", {
|
||||
icon: IconBookmark,
|
||||
queryKey: [["app", "byIds"]],
|
||||
createOptions() {
|
||||
return optionsBuilder.from((factory) => ({
|
||||
title: factory.text(),
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { getAdvancedBookmarkColumns, getBookmarkHostname, getCompactBookmarkLayout } from "./component";
|
||||
|
||||
describe("bookmark display helpers", () => {
|
||||
test("uses bounded advanced columns", () => {
|
||||
expect(getAdvancedBookmarkColumns(900, 8)).toBe(3);
|
||||
expect(getAdvancedBookmarkColumns(120, 8)).toBe(1);
|
||||
expect(getAdvancedBookmarkColumns(900, 0)).toBe(1);
|
||||
});
|
||||
|
||||
test("uses container geometry to keep compact cards legible", () => {
|
||||
expect(getCompactBookmarkLayout(420, 220, 4, "gridHorizontal")).toMatchObject({
|
||||
columns: 2,
|
||||
hideHostname: false,
|
||||
hideTitle: false,
|
||||
minimumItemSize: 48,
|
||||
});
|
||||
|
||||
expect(getCompactBookmarkLayout(180, 80, 4, "row")).toMatchObject({
|
||||
columns: 4,
|
||||
hideHostname: true,
|
||||
hideTitle: true,
|
||||
minimumItemSize: 96,
|
||||
});
|
||||
});
|
||||
|
||||
test("does not crash on malformed bookmark URLs", () => {
|
||||
expect(getBookmarkHostname("https://homarr.dev/docs")).toBe("homarr.dev");
|
||||
expect(getBookmarkHostname("not a URL")).toBeUndefined();
|
||||
expect(getBookmarkHostname(null)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,9 @@
|
||||
import { trpc } from "@homarr/api/server";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { db, inArray } from "@homarr/db";
|
||||
import { apps } from "@homarr/db/schema";
|
||||
|
||||
import type { Prefetch } from "../definition";
|
||||
import { createTrpcQueryKey } from "../trpc-query-key";
|
||||
|
||||
const logger = createLogger({ module: "bookmarksWidgetPrefetch" });
|
||||
|
||||
@@ -21,7 +21,7 @@ const prefetchAllAsync: Prefetch<"bookmarks"> = async (queryClient, items) => {
|
||||
}
|
||||
|
||||
queryClient.setQueryData(
|
||||
trpc.app.byIds.queryKey(item.options.items),
|
||||
createTrpcQueryKey("app.byIds", item.options.items),
|
||||
dbApps.filter((app) => item.options.items.includes(app.id)),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useId } from "react";
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
@@ -16,122 +17,173 @@ import dayjs from "dayjs";
|
||||
|
||||
import { isNullOrWhitespace } from "@homarr/common";
|
||||
import type { CalendarEvent } from "@homarr/integrations/types";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { groupEventsByDate } from "./calendar-events";
|
||||
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url";
|
||||
import { formatLocalizedTime } from "../common/locale";
|
||||
import classes from "./calendar-event-list.module.css";
|
||||
|
||||
interface CalendarEventListProps {
|
||||
events: CalendarEvent[];
|
||||
fillHeight?: boolean;
|
||||
groupByDate?: boolean;
|
||||
locale?: string;
|
||||
}
|
||||
|
||||
export const CalendarEventList = ({ events }: CalendarEventListProps) => {
|
||||
const { colorScheme } = useMantineColorScheme();
|
||||
const t = useI18n();
|
||||
export const CalendarEventList = ({
|
||||
events,
|
||||
fillHeight = false,
|
||||
groupByDate = false,
|
||||
locale,
|
||||
}: CalendarEventListProps) => {
|
||||
const headingIdPrefix = useId();
|
||||
const currentLocale = useCurrentIntlLocale();
|
||||
const effectiveLocale = locale ?? currentLocale;
|
||||
const dateFormatter = new Intl.DateTimeFormat(effectiveLocale, {
|
||||
weekday: "long",
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
});
|
||||
const groups = groupByDate ? [...groupEventsByDate(events).entries()] : null;
|
||||
|
||||
return (
|
||||
<ScrollArea
|
||||
offsetScrollbars
|
||||
pt={5}
|
||||
w="100%"
|
||||
h={fillHeight ? "100%" : undefined}
|
||||
styles={{
|
||||
viewport: {
|
||||
maxHeight: 450,
|
||||
maxHeight: fillHeight ? undefined : 450,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Stack>
|
||||
{events.map((event, eventIndex) => (
|
||||
<Group key={`event-${eventIndex}`} align={"stretch"} wrap="nowrap">
|
||||
{event.image !== null && (
|
||||
<Box pos="relative">
|
||||
<Image
|
||||
src={event.image.src}
|
||||
w={70}
|
||||
mah={150}
|
||||
style={{
|
||||
aspectRatio: event.image.aspectRatio
|
||||
? `${event.image.aspectRatio.width} / ${event.image.aspectRatio.height}`
|
||||
: "1/1",
|
||||
}}
|
||||
radius="sm"
|
||||
fallbackSrc="https://placehold.co/400x400?text=No%20image"
|
||||
/>
|
||||
{event.image.badge !== undefined && (
|
||||
<Badge pos="absolute" bottom={-6} left="50%" w="90%" className={classes.badge}>
|
||||
{event.image.badge.content}
|
||||
</Badge>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
<Stack style={{ flexGrow: 1 }} gap={0}>
|
||||
<Group justify="space-between" align="start" mb="xs" wrap="nowrap">
|
||||
<Stack gap={0}>
|
||||
{event.subTitle !== null && (
|
||||
<Text lineClamp={1} size="sm">
|
||||
{event.subTitle}
|
||||
</Text>
|
||||
)}
|
||||
<Text fw={"bold"} lineClamp={1} size="sm">
|
||||
{event.title}
|
||||
</Text>
|
||||
</Stack>
|
||||
{event.metadata?.type === "radarr" && (
|
||||
<Group wrap="nowrap">
|
||||
<Text c="dimmed" size="sm">
|
||||
{t(`widget.calendar.option.releaseType.options.${event.metadata.releaseType}`)}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
<Group gap={3} wrap="nowrap" align={"center"}>
|
||||
<IconClock opacity={0.7} size={"1rem"} />
|
||||
{isAllDay(event) ? (
|
||||
<Text c={"dimmed"} size={"sm"}>
|
||||
{t("widget.calendar.duration.allDay")}
|
||||
</Text>
|
||||
) : (
|
||||
<>
|
||||
<Text c={"dimmed"} size={"sm"}>
|
||||
{dayjs(event.startDate).format("HH:mm")}
|
||||
</Text>
|
||||
|
||||
{event.endDate !== null && (
|
||||
<>
|
||||
-{" "}
|
||||
<Text c={"dimmed"} size={"sm"}>
|
||||
{dayjs(event.endDate).format("HH:mm")}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{event.location !== null && (
|
||||
<Group gap={4} mb={isNullOrWhitespace(event.description) ? 0 : "sm"}>
|
||||
<IconPin opacity={0.7} size={"1rem"} />
|
||||
<Text size={"xs"} c={"dimmed"} lineClamp={1}>
|
||||
{event.location}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{!isNullOrWhitespace(event.description) && (
|
||||
<Text size={"xs"} c={"dimmed"} lineClamp={2}>
|
||||
{event.description}
|
||||
<Stack gap="md">
|
||||
{groups ? (
|
||||
groups.map(([date, groupedEvents]) => {
|
||||
const headingId = `${headingIdPrefix}-${date}`;
|
||||
return (
|
||||
<Box key={date} component="section" aria-labelledby={headingId}>
|
||||
<Text id={headingId} component="h3" size="sm" fw={600} tt="capitalize">
|
||||
{dateFormatter.format(groupedEvents[0]?.startDate)}
|
||||
</Text>
|
||||
<Stack mt="xs">
|
||||
<CalendarEventRows events={groupedEvents} locale={effectiveLocale} />
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<CalendarEventRows events={events} locale={effectiveLocale} />
|
||||
)}
|
||||
</Stack>
|
||||
</ScrollArea>
|
||||
);
|
||||
};
|
||||
|
||||
const CalendarEventRows = ({ events, locale }: Pick<CalendarEventListProps, "events"> & { locale: string }) => {
|
||||
const { colorScheme } = useMantineColorScheme();
|
||||
const t = useI18n();
|
||||
return (
|
||||
<>
|
||||
{events.map((event, eventIndex) => (
|
||||
<Group key={`event-${eventIndex}`} align={"stretch"} wrap="nowrap">
|
||||
{event.image !== null && (
|
||||
<Box pos="relative">
|
||||
<Image
|
||||
src={event.image.src}
|
||||
w={70}
|
||||
mah={150}
|
||||
style={{
|
||||
aspectRatio: event.image.aspectRatio
|
||||
? `${event.image.aspectRatio.width} / ${event.image.aspectRatio.height}`
|
||||
: "1/1",
|
||||
}}
|
||||
radius="sm"
|
||||
fallbackSrc="https://placehold.co/400x400?text=No%20image"
|
||||
/>
|
||||
{event.image.badge !== undefined && (
|
||||
<Badge pos="absolute" bottom={-6} left="50%" w="90%" className={classes.badge}>
|
||||
{event.image.badge.content}
|
||||
</Badge>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
<Stack style={{ flexGrow: 1, minWidth: 0 }} gap={0}>
|
||||
<Group justify="space-between" align="start" mb="xs" wrap="wrap">
|
||||
<Stack gap={0} style={{ flex: "1 1 12rem", minWidth: 0 }}>
|
||||
{event.subTitle !== null && (
|
||||
<Text lineClamp={1} size="sm">
|
||||
{event.subTitle}
|
||||
</Text>
|
||||
)}
|
||||
<Text fw={"bold"} lineClamp={1} size="sm">
|
||||
{event.title}
|
||||
</Text>
|
||||
</Stack>
|
||||
{event.metadata?.type === "radarr" && (
|
||||
<Group wrap="nowrap">
|
||||
<Text c="dimmed" size="sm">
|
||||
{t(`widget.calendar.option.releaseType.options.${event.metadata.releaseType}`)}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{event.links.length > 0 && (
|
||||
<Group pt={5} gap={5} mt={"auto"} wrap="nowrap">
|
||||
{event.links
|
||||
.filter((link) => link.href)
|
||||
.map((link) => (
|
||||
<Group gap={3} wrap="nowrap" align={"center"} ml="auto">
|
||||
<IconClock opacity={0.7} size={"1rem"} />
|
||||
{isAllDay(event) ? (
|
||||
<Text c={"dimmed"} size={"sm"}>
|
||||
{t("widget.calendar.duration.allDay")}
|
||||
</Text>
|
||||
) : (
|
||||
<>
|
||||
<Text c={"dimmed"} size={"sm"}>
|
||||
{formatLocalizedTime(event.startDate, locale)}
|
||||
</Text>
|
||||
|
||||
{event.endDate !== null && (
|
||||
<>
|
||||
-{" "}
|
||||
<Text c={"dimmed"} size={"sm"}>
|
||||
{formatLocalizedTime(event.endDate, locale)}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{event.location !== null && (
|
||||
<Group gap={4} mb={isNullOrWhitespace(event.description) ? 0 : "sm"}>
|
||||
<IconPin opacity={0.7} size={"1rem"} />
|
||||
<Text size={"xs"} c={"dimmed"} lineClamp={1}>
|
||||
{event.location}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{!isNullOrWhitespace(event.description) && (
|
||||
<Text size={"xs"} c={"dimmed"} lineClamp={2}>
|
||||
{event.description}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{event.links.length > 0 && (
|
||||
<Group pt={5} gap={5} mt={"auto"} wrap="nowrap">
|
||||
{event.links
|
||||
.filter((link) => link.href)
|
||||
.map((link) => {
|
||||
const href = getSafeApplicationUrl(link.href.toString());
|
||||
return (
|
||||
<Button
|
||||
key={link.href}
|
||||
component={"a"}
|
||||
href={link.href.toString()}
|
||||
target={"_blank"}
|
||||
component={href ? "a" : "div"}
|
||||
href={href}
|
||||
target={href ? "_blank" : undefined}
|
||||
rel={href ? SAFE_NEW_TAB_REL : undefined}
|
||||
size={"xs"}
|
||||
radius={"xl"}
|
||||
variant={link.color ? undefined : "default"}
|
||||
@@ -150,14 +202,14 @@ export const CalendarEventList = ({ events }: CalendarEventListProps) => {
|
||||
>
|
||||
<Text>{link.name}</Text>
|
||||
</Button>
|
||||
))}
|
||||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
</ScrollArea>
|
||||
);
|
||||
})}
|
||||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
</Group>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import dayjs from "dayjs";
|
||||
|
||||
import type { CalendarEvent } from "@homarr/integrations/types";
|
||||
|
||||
export const toCalendarDateKey = (date: Date) => dayjs(date).format("YYYY-MM-DD");
|
||||
|
||||
export const moveCalendarMonth = (month: Date, amount: number): Date =>
|
||||
dayjs(month).startOf("month").add(amount, "month").toDate();
|
||||
|
||||
const matchesReleaseType = (event: CalendarEvent, releaseTypes: readonly string[]) =>
|
||||
event.metadata?.type !== "radarr" || releaseTypes.includes(event.metadata.releaseType);
|
||||
|
||||
export const getCalendarAgendaEvents = (
|
||||
events: CalendarEvent[],
|
||||
viewedMonth: Date,
|
||||
releaseTypes: readonly string[],
|
||||
): CalendarEvent[] => {
|
||||
const monthStart = dayjs(viewedMonth).startOf("month");
|
||||
const monthEnd = monthStart.add(1, "month");
|
||||
|
||||
return events
|
||||
.filter((event) => {
|
||||
if (!matchesReleaseType(event, releaseTypes)) return false;
|
||||
|
||||
const start = dayjs(event.startDate);
|
||||
if (!event.endDate) return !start.isBefore(monthStart) && start.isBefore(monthEnd);
|
||||
|
||||
// Calendar intervals are half-open: an all-day event ending at midnight does not occupy the next day.
|
||||
return start.isBefore(monthEnd) && dayjs(event.endDate).isAfter(monthStart);
|
||||
})
|
||||
.toSorted((eventA, eventB) => eventA.startDate.getTime() - eventB.startDate.getTime());
|
||||
};
|
||||
|
||||
export const groupEventsByDate = (events: CalendarEvent[]): Map<string, CalendarEvent[]> => {
|
||||
const result = new Map<string, CalendarEvent[]>();
|
||||
for (const event of events) {
|
||||
const key = toCalendarDateKey(event.startDate);
|
||||
const entries = result.get(key) ?? [];
|
||||
entries.push(event);
|
||||
result.set(key, entries);
|
||||
}
|
||||
|
||||
return new Map(
|
||||
[...result.entries()]
|
||||
.toSorted(([dateA], [dateB]) => dateA.localeCompare(dateB))
|
||||
.map(([date, entries]) => [
|
||||
date,
|
||||
entries.toSorted((eventA, eventB) => eventA.startDate.getTime() - eventB.startDate.getTime()),
|
||||
]),
|
||||
);
|
||||
};
|
||||
|
||||
/** Splits multi-day events into single-day calendar tiles. */
|
||||
export const splitEvents = (events: CalendarEvent[]): CalendarEvent[] => {
|
||||
const splitEventEntries: CalendarEvent[] = [];
|
||||
for (const event of events) {
|
||||
if (!event.endDate) {
|
||||
splitEventEntries.push(event);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (dayjs(event.startDate).isSame(event.endDate, "day")) {
|
||||
splitEventEntries.push(event);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (dayjs(event.startDate).isAfter(event.endDate)) continue;
|
||||
|
||||
let currentStart = dayjs(event.startDate);
|
||||
while (currentStart.isBefore(event.endDate)) {
|
||||
splitEventEntries.push({
|
||||
...event,
|
||||
startDate: currentStart.toDate(),
|
||||
endDate: currentStart.endOf("day").isAfter(event.endDate) ? event.endDate : currentStart.endOf("day").toDate(),
|
||||
});
|
||||
currentStart = currentStart.add(1, "day").startOf("day");
|
||||
}
|
||||
}
|
||||
return splitEventEntries;
|
||||
};
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, test } from "vitest";
|
||||
|
||||
import type { CalendarEvent } from "@homarr/integrations/types";
|
||||
|
||||
import { splitEvents } from "./component";
|
||||
import { getCalendarAgendaEvents, groupEventsByDate, moveCalendarMonth, splitEvents } from "./calendar-events";
|
||||
|
||||
describe("splitEvents should split multi-day events into multiple single-day events", () => {
|
||||
test("2 day all-day event should be split up into two all-day events", () => {
|
||||
@@ -53,6 +53,52 @@ describe("splitEvents should split multi-day events into multiple single-day eve
|
||||
});
|
||||
});
|
||||
|
||||
describe("groupEventsByDate", () => {
|
||||
test("groups once per day and sorts each bucket", () => {
|
||||
const later = createEvent(new Date(2025, 0, 1, 18));
|
||||
const earlier = createEvent(new Date(2025, 0, 1, 9));
|
||||
const nextDay = createEvent(new Date(2025, 0, 2, 9));
|
||||
|
||||
const result = groupEventsByDate([later, nextDay, earlier]);
|
||||
|
||||
expect(result.size).toBe(2);
|
||||
expect([...result.values()][0]).toEqual([earlier, later]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getCalendarAgendaEvents", () => {
|
||||
test("keeps overlapping original events once and excludes adjacent-month events", () => {
|
||||
const spansIntoMonth = createEvent(new Date(2024, 11, 31, 22), new Date(2025, 0, 2, 8));
|
||||
const inMonth = createEvent(new Date(2025, 0, 20, 9));
|
||||
const endsAtMonthStart = createEvent(new Date(2024, 11, 31), new Date(2025, 0, 1));
|
||||
const nextMonth = createEvent(new Date(2025, 1, 1));
|
||||
|
||||
const result = getCalendarAgendaEvents(
|
||||
[nextMonth, spansIntoMonth, endsAtMonthStart, inMonth],
|
||||
new Date(2025, 0, 15),
|
||||
[],
|
||||
);
|
||||
|
||||
expect(result).toEqual([spansIntoMonth, inMonth]);
|
||||
});
|
||||
|
||||
test("applies Radarr release filters without splitting multi-day events", () => {
|
||||
const cinema = createEvent(new Date(2025, 0, 5), new Date(2025, 0, 8));
|
||||
cinema.metadata = { type: "radarr", releaseType: "inCinemas" };
|
||||
const digital = createEvent(new Date(2025, 0, 7));
|
||||
digital.metadata = { type: "radarr", releaseType: "digitalRelease" };
|
||||
|
||||
expect(getCalendarAgendaEvents([cinema, digital], new Date(2025, 0, 1), ["inCinemas"])).toEqual([cinema]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("moveCalendarMonth", () => {
|
||||
test("uses the first day so short months do not skip", () => {
|
||||
expect(moveCalendarMonth(new Date(2025, 0, 31), 1)).toEqual(new Date(2025, 1, 1));
|
||||
expect(moveCalendarMonth(new Date(2025, 2, 31), -1)).toEqual(new Date(2025, 1, 1));
|
||||
});
|
||||
});
|
||||
|
||||
const createEvent = (startDate: Date, endDate: Date | null = null): CalendarEvent => ({
|
||||
title: "Test",
|
||||
subTitle: null,
|
||||
|
||||
@@ -1,26 +1,62 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useMantineTheme } from "@mantine/core";
|
||||
import { ActionIcon, Box, Center, Group, Loader, Stack, Text, Tooltip, useMantineTheme } from "@mantine/core";
|
||||
import { Calendar } from "@mantine/dates";
|
||||
import { useElementSize } from "@mantine/hooks";
|
||||
import { IconChevronLeft, IconChevronRight } from "@tabler/icons-react";
|
||||
import { getQueryKey } from "@trpc/react-query";
|
||||
import dayjs from "dayjs";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
import type { CalendarEvent } from "@homarr/integrations/types";
|
||||
import { useSettings } from "@homarr/settings";
|
||||
import { useCurrentIntlLocale, useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import actionTargetClasses from "../common/action-target.module.css";
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { getUsableWidgetQueryData } from "../common/query-state";
|
||||
import { useWidgetRuntimeQueries } from "../runtime-hooks";
|
||||
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
|
||||
import { CalendarDay } from "./calender-day";
|
||||
import {
|
||||
getCalendarAgendaEvents,
|
||||
groupEventsByDate,
|
||||
moveCalendarMonth,
|
||||
splitEvents,
|
||||
toCalendarDateKey,
|
||||
} from "./calendar-events";
|
||||
import { CalendarEventList } from "./calendar-event-list";
|
||||
import classes from "./component.module.css";
|
||||
|
||||
export default function CalendarWidget(props: WidgetComponentProps<"calendar">) {
|
||||
const [month, setMonth] = useState(new Date());
|
||||
const [month, setMonth] = useState(() => new Date());
|
||||
const runtimeInput = {
|
||||
integrationIds: props.integrationIds,
|
||||
month: month.getMonth(),
|
||||
year: month.getFullYear(),
|
||||
releaseType: props.options.releaseType,
|
||||
showUnmonitored: props.options.showUnmonitored,
|
||||
};
|
||||
useWidgetRuntimeQueries(
|
||||
props.widgetRuntimeRef,
|
||||
props.integrationIds.length > 0
|
||||
? [getQueryKey(clientApi.widget.calendar.findAllEvents, runtimeInput, "query")]
|
||||
: [],
|
||||
);
|
||||
|
||||
if (props.integrationIds.length === 0) {
|
||||
return <CalendarBase {...props} events={[]} month={month} setMonth={setMonth} />;
|
||||
return (
|
||||
<CalendarBase
|
||||
{...props}
|
||||
events={[]}
|
||||
failedIntegrations={[]}
|
||||
isPending={false}
|
||||
month={month}
|
||||
setMonth={setMonth}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <FetchCalendar month={month} setMonth={setMonth} {...props} />;
|
||||
@@ -31,7 +67,7 @@ interface FetchCalendarProps extends WidgetComponentProps<"calendar"> {
|
||||
setMonth: (date: Date) => void;
|
||||
}
|
||||
|
||||
const FetchCalendar = ({ month, setMonth, isEditMode, integrationIds, options }: FetchCalendarProps) => {
|
||||
const FetchCalendar = ({ month, setMonth, isEditMode, integrationIds, options, displayMode }: FetchCalendarProps) => {
|
||||
const input = {
|
||||
integrationIds,
|
||||
month: month.getMonth() + 1,
|
||||
@@ -39,24 +75,52 @@ const FetchCalendar = ({ month, setMonth, isEditMode, integrationIds, options }:
|
||||
releaseType: options.releaseType,
|
||||
showUnmonitored: options.showUnmonitored,
|
||||
};
|
||||
const { data } = clientApi.widget.calendar.findAllEvents.useQuery(input);
|
||||
const calendarQuery = clientApi.widget.calendar.findAllEvents.useQuery(input);
|
||||
const data = getUsableWidgetQueryData(calendarQuery);
|
||||
const { isPending } = calendarQuery;
|
||||
|
||||
const events = useMemo(() => data?.flatMap((item) => item.events) ?? [], [data]);
|
||||
const failedIntegrations =
|
||||
data?.flatMap(({ integration, error }) =>
|
||||
error ? [{ integrationId: integration.id, integrationName: integration.name, error }] : [],
|
||||
) ?? [];
|
||||
|
||||
return <CalendarBase isEditMode={isEditMode} events={events} month={month} setMonth={setMonth} options={options} />;
|
||||
return (
|
||||
<CalendarBase
|
||||
isEditMode={isEditMode}
|
||||
events={events}
|
||||
failedIntegrations={failedIntegrations}
|
||||
isPending={isPending}
|
||||
month={month}
|
||||
setMonth={setMonth}
|
||||
options={options}
|
||||
displayMode={displayMode}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
interface CalendarBaseProps {
|
||||
isEditMode: boolean;
|
||||
events: CalendarEvent[];
|
||||
failedIntegrations: { integrationId: string; integrationName: string; error: string }[];
|
||||
isPending: boolean;
|
||||
month: Date;
|
||||
setMonth: (date: Date) => void;
|
||||
options: WidgetComponentProps<"calendar">["options"];
|
||||
displayMode?: WidgetComponentProps<"calendar">["displayMode"];
|
||||
}
|
||||
|
||||
const CalendarBase = ({ isEditMode, events, month, setMonth, options }: CalendarBaseProps) => {
|
||||
const params = useParams();
|
||||
const locale = params.locale as string;
|
||||
const CalendarBase = ({
|
||||
isEditMode,
|
||||
events,
|
||||
failedIntegrations,
|
||||
isPending,
|
||||
month,
|
||||
setMonth,
|
||||
options,
|
||||
displayMode,
|
||||
}: CalendarBaseProps) => {
|
||||
const locale = useCurrentIntlLocale();
|
||||
const { firstDayOfWeek } = useSettings();
|
||||
const board = useRequiredBoard();
|
||||
const mantineTheme = useMantineTheme();
|
||||
@@ -64,125 +128,197 @@ const CalendarBase = ({ isEditMode, events, month, setMonth, options }: Calendar
|
||||
const { ref, width, height } = useElementSize();
|
||||
const isSmall = width < 256;
|
||||
|
||||
const normalizedEvents = useMemo(() => splitEvents(events), [events]);
|
||||
const normalizedEvents = useMemo(
|
||||
() => (displayMode === "advanced" ? [] : splitEvents(events)),
|
||||
[displayMode, events],
|
||||
);
|
||||
const visibleEvents = useMemo(
|
||||
() =>
|
||||
normalizedEvents.filter(
|
||||
(event) => event.metadata?.type !== "radarr" || options.releaseType.includes(event.metadata.releaseType),
|
||||
),
|
||||
[normalizedEvents, options.releaseType],
|
||||
);
|
||||
const eventsByDate = useMemo(() => groupEventsByDate(visibleEvents), [visibleEvents]);
|
||||
const agendaEvents = useMemo(
|
||||
() => (displayMode === "advanced" ? getCalendarAgendaEvents(events, month, options.releaseType) : []),
|
||||
[displayMode, events, month, options.releaseType],
|
||||
);
|
||||
|
||||
if (displayMode === "advanced") {
|
||||
return (
|
||||
<CalendarAgenda
|
||||
events={agendaEvents}
|
||||
failedIntegrations={failedIntegrations}
|
||||
isPending={isPending}
|
||||
isEditMode={isEditMode}
|
||||
locale={locale}
|
||||
month={month}
|
||||
setMonth={setMonth}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Calendar
|
||||
defaultDate={new Date()}
|
||||
onPreviousMonth={(month) => setMonth(new Date(month))}
|
||||
onNextMonth={(month) => setMonth(new Date(month))}
|
||||
highlightToday
|
||||
locale={locale}
|
||||
hideWeekdays={false}
|
||||
date={month}
|
||||
maxLevel="month"
|
||||
firstDayOfWeek={firstDayOfWeek}
|
||||
static={isEditMode}
|
||||
className={classes.calendar}
|
||||
w="100%"
|
||||
h="100%"
|
||||
ref={ref}
|
||||
styles={{
|
||||
calendarHeaderControl: {
|
||||
pointerEvents: isEditMode ? "none" : undefined,
|
||||
borderRadius: "md",
|
||||
height: isSmall ? "1.5rem" : undefined,
|
||||
width: isSmall ? "1.5rem" : undefined,
|
||||
},
|
||||
calendarHeaderLevel: {
|
||||
pointerEvents: "none",
|
||||
fontSize: isSmall ? "0.75rem" : undefined,
|
||||
height: "100%",
|
||||
},
|
||||
levelsGroup: {
|
||||
height: "100%",
|
||||
padding: "md",
|
||||
},
|
||||
calendarHeader: {
|
||||
maxWidth: "unset",
|
||||
marginBottom: 0,
|
||||
},
|
||||
monthCell: {
|
||||
textAlign: "center",
|
||||
position: "relative",
|
||||
},
|
||||
day: {
|
||||
borderRadius: actualItemRadius,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
},
|
||||
month: {
|
||||
height: "100%",
|
||||
},
|
||||
weekday: {
|
||||
padding: 0,
|
||||
},
|
||||
weekdaysRow: {
|
||||
height: 22,
|
||||
},
|
||||
}}
|
||||
renderDay={(tileDate) => {
|
||||
const eventsForDate = normalizedEvents
|
||||
.filter((event) => dayjs(event.startDate).isSame(tileDate, "day"))
|
||||
.filter(
|
||||
(event) => event.metadata?.type !== "radarr" || options.releaseType.includes(event.metadata.releaseType),
|
||||
)
|
||||
.toSorted((eventA, eventB) => eventA.startDate.getTime() - eventB.startDate.getTime());
|
||||
<Stack ref={ref} h="100%" w="100%" gap="xs" style={{ overflow: "hidden" }}>
|
||||
{failedIntegrations.length > 0 && (
|
||||
<Group px="xs" justify="flex-end">
|
||||
<IntegrationErrorIndicator results={failedIntegrations} />
|
||||
</Group>
|
||||
)}
|
||||
<Calendar
|
||||
defaultDate={new Date()}
|
||||
onPreviousMonth={(previousMonth) => setMonth(new Date(previousMonth))}
|
||||
onNextMonth={(nextMonth) => setMonth(new Date(nextMonth))}
|
||||
highlightToday
|
||||
locale={locale}
|
||||
hideWeekdays={false}
|
||||
date={month}
|
||||
maxLevel="month"
|
||||
firstDayOfWeek={firstDayOfWeek}
|
||||
static={isEditMode}
|
||||
className={classes.calendar}
|
||||
w="100%"
|
||||
h="100%"
|
||||
styles={{
|
||||
calendarHeaderControl: {
|
||||
pointerEvents: isEditMode ? "none" : undefined,
|
||||
borderRadius: "md",
|
||||
height: isSmall ? "1.5rem" : undefined,
|
||||
width: isSmall ? "1.5rem" : undefined,
|
||||
},
|
||||
calendarHeaderLevel: {
|
||||
pointerEvents: "none",
|
||||
fontSize: isSmall ? "0.75rem" : undefined,
|
||||
height: "100%",
|
||||
},
|
||||
levelsGroup: {
|
||||
height: "100%",
|
||||
padding: "md",
|
||||
},
|
||||
calendarHeader: {
|
||||
maxWidth: "unset",
|
||||
marginBottom: 0,
|
||||
},
|
||||
monthCell: {
|
||||
textAlign: "center",
|
||||
position: "relative",
|
||||
},
|
||||
day: {
|
||||
borderRadius: actualItemRadius,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
},
|
||||
month: {
|
||||
height: "100%",
|
||||
},
|
||||
weekday: {
|
||||
padding: 0,
|
||||
},
|
||||
weekdaysRow: {
|
||||
height: 22,
|
||||
},
|
||||
}}
|
||||
renderDay={(tileDate) => {
|
||||
const eventsForDate = eventsByDate.get(toCalendarDateKey(dayjs(tileDate).toDate())) ?? [];
|
||||
|
||||
return (
|
||||
<CalendarDay
|
||||
date={dayjs(tileDate).toDate()}
|
||||
events={eventsForDate}
|
||||
disabled={isEditMode || eventsForDate.length === 0}
|
||||
rootWidth={width}
|
||||
rootHeight={height}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
return (
|
||||
<CalendarDay
|
||||
date={dayjs(tileDate).toDate()}
|
||||
events={eventsForDate}
|
||||
disabled={isEditMode || eventsForDate.length === 0}
|
||||
rootWidth={width}
|
||||
rootHeight={height}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Splits multi-day events into multiple single-day events.
|
||||
* @param events The events to split.
|
||||
* @returns The split events.
|
||||
*/
|
||||
export const splitEvents = (events: CalendarEvent[]): CalendarEvent[] => {
|
||||
const splitEvents: CalendarEvent[] = [];
|
||||
for (const event of events) {
|
||||
if (!event.endDate) {
|
||||
splitEvents.push(event);
|
||||
continue;
|
||||
}
|
||||
interface CalendarAgendaProps {
|
||||
events: CalendarEvent[];
|
||||
failedIntegrations: CalendarBaseProps["failedIntegrations"];
|
||||
isPending: boolean;
|
||||
isEditMode: boolean;
|
||||
locale: string;
|
||||
month: Date;
|
||||
setMonth: (date: Date) => void;
|
||||
}
|
||||
|
||||
if (dayjs(event.startDate).isSame(event.endDate, "day")) {
|
||||
splitEvents.push(event);
|
||||
continue;
|
||||
}
|
||||
const CalendarAgenda = ({
|
||||
events,
|
||||
failedIntegrations,
|
||||
isPending,
|
||||
isEditMode,
|
||||
locale,
|
||||
month,
|
||||
setMonth,
|
||||
}: CalendarAgendaProps) => {
|
||||
const t = useScopedI18n("widget.calendar.advanced");
|
||||
const monthLabel = new Intl.DateTimeFormat(locale, { month: "long", year: "numeric" }).format(month);
|
||||
|
||||
if (dayjs(event.startDate).isAfter(event.endDate)) {
|
||||
// Invalid event, skip it
|
||||
continue;
|
||||
}
|
||||
return (
|
||||
<Stack h="100%" w="100%" gap="sm" p="md" style={{ overflow: "hidden" }}>
|
||||
<Group justify="space-between" align="center" wrap="nowrap">
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Tooltip label={t("previousMonth")} events={{ hover: true, focus: true, touch: false }}>
|
||||
<ActionIcon
|
||||
className={actionTargetClasses.root}
|
||||
variant="subtle"
|
||||
size="lg"
|
||||
aria-label={t("previousMonth")}
|
||||
disabled={isEditMode}
|
||||
onClick={() => setMonth(moveCalendarMonth(month, -1))}
|
||||
>
|
||||
<IconChevronLeft size={18} aria-hidden />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Stack gap={0} miw={0}>
|
||||
<Text component="h3" size="sm" fw={600} tt="capitalize" lineClamp={1}>
|
||||
{monthLabel}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{isPending ? t("loading") : t("eventCount", { count: events.length })}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Tooltip label={t("nextMonth")} events={{ hover: true, focus: true, touch: false }}>
|
||||
<ActionIcon
|
||||
className={actionTargetClasses.root}
|
||||
variant="subtle"
|
||||
size="lg"
|
||||
aria-label={t("nextMonth")}
|
||||
disabled={isEditMode}
|
||||
onClick={() => setMonth(moveCalendarMonth(month, 1))}
|
||||
>
|
||||
<IconChevronRight size={18} aria-hidden />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
{failedIntegrations.length > 0 && <IntegrationErrorIndicator results={failedIntegrations} />}
|
||||
</Group>
|
||||
|
||||
// Event spans multiple days, split it
|
||||
let currentStart = dayjs(event.startDate);
|
||||
|
||||
while (currentStart.isBefore(event.endDate)) {
|
||||
splitEvents.push({
|
||||
...event,
|
||||
startDate: currentStart.toDate(),
|
||||
endDate: currentStart.endOf("day").isAfter(event.endDate) ? event.endDate : currentStart.endOf("day").toDate(),
|
||||
});
|
||||
|
||||
currentStart = currentStart.add(1, "day").startOf("day");
|
||||
}
|
||||
}
|
||||
return splitEvents;
|
||||
<Box style={{ flex: 1, minHeight: 0 }}>
|
||||
{isPending ? (
|
||||
<Center component="output" h="100%" aria-label={t("loading")}>
|
||||
<Loader size="sm" />
|
||||
</Center>
|
||||
) : events.length > 0 ? (
|
||||
<CalendarEventList events={events} groupByDate locale={locale} fillHeight />
|
||||
) : (
|
||||
<Stack h="100%" align="center" justify="center" p="md">
|
||||
<Text c="dimmed" size="sm" ta="center">
|
||||
{t("noEvents")}
|
||||
</Text>
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -4,11 +4,13 @@ import { z } from "zod/v4";
|
||||
import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||
import { radarrReleaseTypes } from "@homarr/integrations/types";
|
||||
|
||||
import { createWidgetDefinition } from "../definition";
|
||||
import { createWidgetDefinition, matchesWidgetRuntimeQuery } from "../definition";
|
||||
import { optionsBuilder } from "../options";
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("calendar", {
|
||||
icon: IconCalendar,
|
||||
queryKey: [["widget", "calendar", "findAllEvents"]],
|
||||
queryMatcher: matchesWidgetRuntimeQuery,
|
||||
createOptions() {
|
||||
return optionsBuilder.from((factory) => ({
|
||||
releaseType: factory.multiSelect({
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import {
|
||||
IconActivityHeartbeat,
|
||||
IconAd,
|
||||
IconApi,
|
||||
IconApps,
|
||||
IconArchive,
|
||||
IconBadgeCc,
|
||||
IconBatteryCharging,
|
||||
IconBell,
|
||||
IconBinaryTree,
|
||||
IconBookmark,
|
||||
IconBrandDocker,
|
||||
IconBrandMinecraft,
|
||||
IconBrowser,
|
||||
IconBuildingBank,
|
||||
IconBusStop,
|
||||
IconCalendar,
|
||||
IconChartAreaLine,
|
||||
IconChartBar,
|
||||
IconClock,
|
||||
IconCloud,
|
||||
IconDeviceCctv,
|
||||
IconDeviceGamepad,
|
||||
IconDownload,
|
||||
IconFileText,
|
||||
IconGraphFilled,
|
||||
IconHeadphones,
|
||||
IconHeartRateMonitor,
|
||||
IconHeartbeat,
|
||||
IconLayoutGrid,
|
||||
IconMessage,
|
||||
IconMovie,
|
||||
IconNotes,
|
||||
IconPhoto,
|
||||
IconReportSearch,
|
||||
IconRocket,
|
||||
IconRobot,
|
||||
IconRoute,
|
||||
IconRss,
|
||||
IconServer2,
|
||||
IconShieldCheck,
|
||||
IconShieldLock,
|
||||
IconSpeedboat,
|
||||
IconTable,
|
||||
IconTicket,
|
||||
IconTopologyFull,
|
||||
IconTransform,
|
||||
IconVideo,
|
||||
IconWall,
|
||||
IconZoomQuestion,
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
import type { WidgetKind } from "@homarr/definitions";
|
||||
import type { TablerIcon } from "@homarr/ui";
|
||||
|
||||
export const widgetCatalogIcons: Record<WidgetKind, TablerIcon> = {
|
||||
clock: IconClock,
|
||||
weather: IconCloud,
|
||||
app: IconApps,
|
||||
assistant: IconRobot,
|
||||
iframe: IconBrowser,
|
||||
video: IconDeviceCctv,
|
||||
notebook: IconNotes,
|
||||
anchorNote: IconNotes,
|
||||
dnsHoleSummary: IconAd,
|
||||
dnsHoleControls: IconDeviceGamepad,
|
||||
"smartHome-entityState": IconBinaryTree,
|
||||
"smartHome-executeAutomation": IconBinaryTree,
|
||||
stockPrice: IconBuildingBank,
|
||||
mediaServer: IconVideo,
|
||||
calendar: IconCalendar,
|
||||
downloads: IconDownload,
|
||||
"mediaRequests-requestList": IconZoomQuestion,
|
||||
"mediaRequests-requestStats": IconChartBar,
|
||||
mediaTranscoding: IconTransform,
|
||||
mediaMissing: IconMovie,
|
||||
minecraftServerStatus: IconBrandMinecraft,
|
||||
networkControllerSummary: IconTopologyFull,
|
||||
networkControllerStatus: IconTopologyFull,
|
||||
rssFeed: IconRss,
|
||||
bookmarks: IconBookmark,
|
||||
indexerManager: IconReportSearch,
|
||||
healthMonitoring: IconHeartRateMonitor,
|
||||
releases: IconRocket,
|
||||
mediaReleases: IconTicket,
|
||||
dockerContainers: IconBrandDocker,
|
||||
firewall: IconWall,
|
||||
notifications: IconMessage,
|
||||
systemResources: IconGraphFilled,
|
||||
coolify: IconCloud,
|
||||
systemDisks: IconServer2,
|
||||
timetable: IconBusStop,
|
||||
"immich-serverStats": IconGraphFilled,
|
||||
"immich-albumCarousel": IconPhoto,
|
||||
paperlessNgx: IconFileText,
|
||||
patchmon: IconShieldCheck,
|
||||
bazarr: IconBadgeCc,
|
||||
tracearr: IconActivityHeartbeat,
|
||||
speedtestTracker: IconSpeedboat,
|
||||
uptimeKuma: IconHeartbeat,
|
||||
audioStats: IconHeadphones,
|
||||
umami: IconChartBar,
|
||||
vpn: IconShieldLock,
|
||||
archiveTeamWarrior: IconArchive,
|
||||
ups: IconBatteryCharging,
|
||||
beszelSystemTable: IconTable,
|
||||
beszelSystemGrid: IconLayoutGrid,
|
||||
beszelAlerts: IconBell,
|
||||
beszelSystemStats: IconChartAreaLine,
|
||||
traefik: IconRoute,
|
||||
customApi: IconApi,
|
||||
};
|
||||
@@ -1,14 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Box, Stack, Text, Title } from "@mantine/core";
|
||||
import { Box, Loader, Stack, Text, Title } from "@mantine/core";
|
||||
import dayjs from "dayjs";
|
||||
import advancedFormat from "dayjs/plugin/advancedFormat";
|
||||
import timezones from "dayjs/plugin/timezone";
|
||||
import utc from "dayjs/plugin/utc";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { metricToImperial } from "@homarr/common";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { isInitialWidgetQueryPending } from "../common/query-state";
|
||||
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { AnimatedWeatherIcon } from "../weather/animated-icon";
|
||||
|
||||
@@ -16,7 +20,7 @@ dayjs.extend(advancedFormat);
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(timezones);
|
||||
|
||||
export default function ClockWidget({ options, width }: WidgetComponentProps<"clock">) {
|
||||
export default function ClockWidget({ options, width, height, displayMode }: WidgetComponentProps<"clock">) {
|
||||
const secondsFormat = options.showSeconds ? ":ss" : "";
|
||||
const timeFormat = options.is24HourFormat ? `HH:mm${secondsFormat}` : `hh:mm${secondsFormat} A`;
|
||||
const dateFormat = options.dateFormat;
|
||||
@@ -25,7 +29,8 @@ export default function ClockWidget({ options, width }: WidgetComponentProps<"cl
|
||||
const timezone = options.useCustomTimezone ? options.timezone : Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
const time = useCurrentTime(options);
|
||||
|
||||
const sizing = width < 128 ? "xs" : width < 196 ? "sm" : "md";
|
||||
const minimumAxis = Math.min(width, height * 1.5);
|
||||
const sizing = minimumAxis < 128 ? "xs" : minimumAxis < 196 ? "sm" : "md";
|
||||
const showWeatherCorner = options.showWeather && sizing !== "xs";
|
||||
|
||||
return (
|
||||
@@ -35,6 +40,7 @@ export default function ClockWidget({ options, width }: WidgetComponentProps<"cl
|
||||
latitude={options.weatherLocation.latitude}
|
||||
longitude={options.weatherLocation.longitude}
|
||||
isFahrenheit={options.isWeatherFormatFahrenheit}
|
||||
showDetails={displayMode === "advanced"}
|
||||
/>
|
||||
)}
|
||||
<Stack className="clock-text-stack" h="100%" align="center" justify="center" gap={sizing}>
|
||||
@@ -44,9 +50,11 @@ export default function ClockWidget({ options, width }: WidgetComponentProps<"cl
|
||||
</Text>
|
||||
)}
|
||||
<Title className="clock-time-text" fw={700} order={sizing === "md" ? 2 : sizing === "sm" ? 4 : 6} lh="1">
|
||||
{options.customTimeFormat
|
||||
? dayjs(time).tz(timezone).format(customTimeFormat)
|
||||
: dayjs(time).tz(timezone).format(timeFormat)}
|
||||
<time dateTime={dayjs(time).tz(timezone).toISOString()}>
|
||||
{options.customTimeFormat
|
||||
? dayjs(time).tz(timezone).format(customTimeFormat)
|
||||
: dayjs(time).tz(timezone).format(timeFormat)}
|
||||
</time>
|
||||
</Title>
|
||||
{options.showDate && (
|
||||
<Text className="clock-date-text" size={sizing} lineClamp={1}>
|
||||
@@ -55,6 +63,11 @@ export default function ClockWidget({ options, width }: WidgetComponentProps<"cl
|
||||
: dayjs(time).tz(timezone).format(dateFormat)}
|
||||
</Text>
|
||||
)}
|
||||
{displayMode === "advanced" && (
|
||||
<Text size="xs" c="dimmed" lineClamp={1}>
|
||||
{timezone} · UTC{dayjs(time).tz(timezone).format("Z")}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
@@ -64,12 +77,26 @@ interface ClockWeatherCornerProps {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
isFahrenheit: boolean;
|
||||
showDetails: boolean;
|
||||
}
|
||||
|
||||
const ClockWeatherCorner = ({ latitude, longitude, isFahrenheit }: ClockWeatherCornerProps) => {
|
||||
const { data: weather } = clientApi.widget.weather.atLocation.useQuery({ latitude, longitude });
|
||||
const ClockWeatherCorner = ({ latitude, longitude, isFahrenheit, showDetails }: ClockWeatherCornerProps) => {
|
||||
const t = useI18n();
|
||||
const tCommon = useScopedI18n("common");
|
||||
const weatherQuery = clientApi.widget.weather.atLocation.useQuery({ latitude, longitude });
|
||||
const weather = weatherQuery.data;
|
||||
|
||||
if (!weather) return null;
|
||||
if (!weather) {
|
||||
return (
|
||||
<Box pos="absolute" top={4} left={4}>
|
||||
{isInitialWidgetQueryPending(weatherQuery) ? (
|
||||
<Loader size="xs" aria-label={tCommon("action.loading")} />
|
||||
) : (
|
||||
<WidgetQueryErrorIndicator error={weatherQuery.error} label={t("widget.weather.name")} />
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const temp = isFahrenheit ? weather.current.temperature * (9 / 5) + 32 : weather.current.temperature;
|
||||
const unit = isFahrenheit ? "°F" : "°C";
|
||||
@@ -81,6 +108,13 @@ const ClockWeatherCorner = ({ latitude, longitude, isFahrenheit }: ClockWeatherC
|
||||
{Math.round(temp)}
|
||||
{unit}
|
||||
</Text>
|
||||
{showDetails && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{Math.round(isFahrenheit ? metricToImperial(weather.current.windspeed) : weather.current.windspeed)}{" "}
|
||||
{isFahrenheit ? tCommon("unit.speed.milesPerHour") : tCommon("unit.speed.kilometersPerHour")}
|
||||
</Text>
|
||||
)}
|
||||
<WidgetQueryErrorIndicator error={weatherQuery.error} label={t("widget.weather.name")} />
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,11 +1,28 @@
|
||||
import { IconClock } from "@tabler/icons-react";
|
||||
import dayjs from "dayjs";
|
||||
|
||||
import { createWidgetDefinition } from "../definition";
|
||||
import { createWidgetDefinition, widgetQueryInputMatches } from "../definition";
|
||||
import { optionsBuilder } from "../options";
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("clock", {
|
||||
icon: IconClock,
|
||||
queryKey: [["widget", "weather", "atLocation"]],
|
||||
queryMatcher: ({ input }, scope) => {
|
||||
const location = scope.options.weatherLocation;
|
||||
if (
|
||||
scope.options.showWeather !== true ||
|
||||
location === null ||
|
||||
typeof location !== "object" ||
|
||||
!("latitude" in location) ||
|
||||
!("longitude" in location)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return widgetQueryInputMatches(input, {
|
||||
latitude: location.latitude,
|
||||
longitude: location.longitude,
|
||||
});
|
||||
},
|
||||
createOptions() {
|
||||
return optionsBuilder.from(
|
||||
(factory) => ({
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
.root {
|
||||
min-width: 24px;
|
||||
min-height: 24px;
|
||||
}
|
||||
|
||||
@media (any-pointer: coarse) {
|
||||
.root {
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { getSafeApplicationUrl } from "./application-url";
|
||||
|
||||
describe("getSafeApplicationUrl", () => {
|
||||
test.each(["javascript:alert(1)", "data:text/html,test", "not a URL", "/relative", "./relative"])(
|
||||
"rejects unsafe or non-absolute URL %s",
|
||||
(value) => expect(getSafeApplicationUrl(value)).toBeUndefined(),
|
||||
);
|
||||
|
||||
test("rejects credential-bearing URLs", () => {
|
||||
expect(getSafeApplicationUrl("https://user:password@example.com/path")).toBeUndefined();
|
||||
expect(getSafeApplicationUrl("/path", { baseUrl: "https://user:password@example.com" })).toBeUndefined();
|
||||
});
|
||||
|
||||
test("allows absolute HTTP and HTTPS URLs", () => {
|
||||
expect(getSafeApplicationUrl("http://example.com/path")).toBe("http://example.com/path");
|
||||
expect(getSafeApplicationUrl("https://example.com/path?q=1#result")).toBe("https://example.com/path?q=1#result");
|
||||
});
|
||||
|
||||
test("resolves feed-relative URLs only against a safe HTTP(S) base", () => {
|
||||
expect(getSafeApplicationUrl("/story", { baseUrl: "https://example.com/feed.xml?token=secret" })).toBe(
|
||||
"https://example.com/story",
|
||||
);
|
||||
});
|
||||
|
||||
test.each([undefined, null, ""])("rejects missing URL %s", (value) => {
|
||||
expect(getSafeApplicationUrl(value)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "@homarr/common";
|
||||
@@ -0,0 +1,32 @@
|
||||
"use client";
|
||||
|
||||
import { ActionIcon, Tooltip } from "@mantine/core";
|
||||
import { IconAlertTriangle } from "@tabler/icons-react";
|
||||
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
interface IntegrationResult {
|
||||
integrationId: string;
|
||||
integrationName?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface IntegrationErrorIndicatorProps {
|
||||
results: readonly IntegrationResult[];
|
||||
}
|
||||
|
||||
export function IntegrationErrorIndicator({ results }: IntegrationErrorIndicatorProps) {
|
||||
const t = useScopedI18n("common");
|
||||
const failed = results.filter((result) => Boolean(result.error));
|
||||
if (failed.length === 0) return null;
|
||||
|
||||
const names = failed.map((result) => result.integrationName ?? result.integrationId).join(", ");
|
||||
const label = `${t("error")}: ${names}`;
|
||||
return (
|
||||
<Tooltip label={label} position="left" withArrow>
|
||||
<ActionIcon variant="transparent" color="orange" size={28} aria-label={label} style={{ cursor: "help" }}>
|
||||
<IconAlertTriangle aria-hidden size={14} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { formatLocalizedCompactNumber, formatLocalizedDate, formatLocalizedTime, getBinaryStatusKey } from "./locale";
|
||||
|
||||
const sample = new Date("2026-01-02T13:05:09.000Z");
|
||||
|
||||
describe("localized widget formatting", () => {
|
||||
test.each([
|
||||
["en-US", /Jan/],
|
||||
["fr-FR", /janv/i],
|
||||
["de-DE", /Jan/],
|
||||
["he-IL", /[\u0590-\u05ff]/],
|
||||
])("formats dates for %s", (locale, expected) => {
|
||||
expect(
|
||||
formatLocalizedDate(sample, locale, { weekday: "short", month: "short", day: "numeric", timeZone: "UTC" }),
|
||||
).toMatch(expected);
|
||||
});
|
||||
|
||||
test("supports explicit 12-hour and 24-hour clocks", () => {
|
||||
expect(formatLocalizedTime(sample, "en-US", { hour12: true, timeZone: "UTC" })).toBe("1:05 PM");
|
||||
expect(formatLocalizedTime(sample, "en-US", { hour12: false, timeZone: "UTC" })).toBe("13:05");
|
||||
});
|
||||
|
||||
test("keeps date-only values on the requested calendar day", () => {
|
||||
expect(formatLocalizedDate("2026-08-03", "en-US", { month: "long", day: "numeric" })).toBe("August 3");
|
||||
});
|
||||
|
||||
test("localizes compact numbers", () => {
|
||||
expect(formatLocalizedCompactNumber(1_234_567, "en-US")).toBe("1.2M");
|
||||
expect(formatLocalizedCompactNumber(1_234_567, "fr-FR")).toContain("1,2");
|
||||
expect(formatLocalizedCompactNumber(1_234_567, "de-DE")).toContain("Mio.");
|
||||
});
|
||||
|
||||
test("normalizes binary status for translated accessibility labels", () => {
|
||||
expect(getBinaryStatusKey("enabled")).toBe("enabled");
|
||||
expect(getBinaryStatusKey("disabled")).toBe("disabled");
|
||||
expect(getBinaryStatusKey()).toBe("unknown");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
export type LocalizedDateValue = Date | number | string;
|
||||
|
||||
const dateOnlyPattern = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
interface LocalizedTimeOptions {
|
||||
hour12?: boolean;
|
||||
includeSeconds?: boolean;
|
||||
timeZone?: string;
|
||||
}
|
||||
|
||||
const toValidDate = (value: LocalizedDateValue): Date | undefined => {
|
||||
const date =
|
||||
value instanceof Date
|
||||
? value
|
||||
: new Date(typeof value === "string" && dateOnlyPattern.test(value) ? `${value}T00:00:00Z` : value);
|
||||
return Number.isNaN(date.getTime()) ? undefined : date;
|
||||
};
|
||||
|
||||
export const formatLocalizedDate = (
|
||||
value: LocalizedDateValue,
|
||||
locale: string,
|
||||
options: Intl.DateTimeFormatOptions,
|
||||
fallback = "?",
|
||||
): string => {
|
||||
const date = toValidDate(value);
|
||||
if (!date) return fallback;
|
||||
|
||||
const normalizedOptions =
|
||||
typeof value === "string" && dateOnlyPattern.test(value) && options.timeZone === undefined
|
||||
? { ...options, timeZone: "UTC" }
|
||||
: options;
|
||||
return new Intl.DateTimeFormat(locale, normalizedOptions).format(date);
|
||||
};
|
||||
|
||||
export const formatLocalizedTime = (
|
||||
value: LocalizedDateValue,
|
||||
locale: string,
|
||||
{ hour12, includeSeconds = false, timeZone }: LocalizedTimeOptions = {},
|
||||
fallback = "?",
|
||||
): string =>
|
||||
formatLocalizedDate(
|
||||
value,
|
||||
locale,
|
||||
{
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
second: includeSeconds ? "2-digit" : undefined,
|
||||
hour12,
|
||||
timeZone,
|
||||
},
|
||||
fallback,
|
||||
);
|
||||
|
||||
export const formatLocalizedDateTime = (
|
||||
value: LocalizedDateValue,
|
||||
locale: string,
|
||||
options: Pick<LocalizedTimeOptions, "hour12" | "timeZone"> = {},
|
||||
fallback = "?",
|
||||
): string =>
|
||||
formatLocalizedDate(
|
||||
value,
|
||||
locale,
|
||||
{ dateStyle: "medium", timeStyle: "short", hour12: options.hour12, timeZone: options.timeZone },
|
||||
fallback,
|
||||
);
|
||||
|
||||
export const formatLocalizedCompactNumber = (value: number, locale: string): string =>
|
||||
new Intl.NumberFormat(locale, { notation: "compact" }).format(value);
|
||||
|
||||
export type BinaryStatusKey = "enabled" | "disabled" | "unknown";
|
||||
|
||||
export const getBinaryStatusKey = (status?: "enabled" | "disabled"): BinaryStatusKey => status ?? "unknown";
|
||||
@@ -0,0 +1,72 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act, createElement } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { WidgetQueryErrorIndicator, WidgetQueryLoadingState } from "./query-state-indicator";
|
||||
|
||||
vi.mock("@homarr/translation/client", () => ({
|
||||
useScopedI18n: () => (key: string, values?: { widget?: string }) => {
|
||||
if (key === "stale") return `Could not refresh ${values?.widget}; showing saved data`;
|
||||
if (key === "loading") return "Loading widget data";
|
||||
return key;
|
||||
},
|
||||
}));
|
||||
|
||||
const matchMedia = (query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: () => undefined,
|
||||
removeListener: () => undefined,
|
||||
addEventListener: () => undefined,
|
||||
removeEventListener: () => undefined,
|
||||
dispatchEvent: () => false,
|
||||
});
|
||||
|
||||
describe("WidgetQueryErrorIndicator", () => {
|
||||
let host: HTMLDivElement | undefined;
|
||||
let root: ReturnType<typeof createRoot> | undefined;
|
||||
|
||||
afterEach(async () => {
|
||||
if (root) await act(async () => root?.unmount());
|
||||
host?.remove();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
test("uses a localized label without exposing the raw failure", async () => {
|
||||
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
||||
vi.stubGlobal("matchMedia", matchMedia);
|
||||
host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
root = createRoot(host);
|
||||
const secret = "http://admin:password@private.local/path?token=secret";
|
||||
|
||||
await act(async () =>
|
||||
root?.render(
|
||||
createElement(
|
||||
MantineProvider,
|
||||
null,
|
||||
createElement(WidgetQueryErrorIndicator, { error: new Error(secret), label: "Weather" }),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(host.querySelector("[aria-label='Could not refresh Weather; showing saved data']")).not.toBeNull();
|
||||
expect(host.textContent).not.toMatch(/password|private\.local|token=secret/);
|
||||
});
|
||||
|
||||
test("announces the loading state to assistive technology", async () => {
|
||||
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
||||
vi.stubGlobal("matchMedia", matchMedia);
|
||||
host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
root = createRoot(host);
|
||||
|
||||
await act(async () => root?.render(createElement(MantineProvider, null, createElement(WidgetQueryLoadingState))));
|
||||
|
||||
expect(host.querySelector("output")?.textContent).toContain("Loading widget data");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import { ActionIcon, Center, Loader, Tooltip, VisuallyHidden } from "@mantine/core";
|
||||
import { IconAlertTriangle } from "@tabler/icons-react";
|
||||
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
interface WidgetQueryErrorIndicatorProps {
|
||||
error: unknown;
|
||||
label: string;
|
||||
}
|
||||
|
||||
/** Localized warning for stale or optional-query failures. Never renders the raw error. */
|
||||
export function WidgetQueryErrorIndicator({ error, label }: WidgetQueryErrorIndicatorProps) {
|
||||
const t = useScopedI18n("common.widgetQuery");
|
||||
if (!error) return null;
|
||||
|
||||
const accessibleLabel = t("stale", { widget: label });
|
||||
return (
|
||||
<Tooltip label={accessibleLabel} position="left" withArrow>
|
||||
<ActionIcon
|
||||
component="span"
|
||||
color="orange"
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
role="note"
|
||||
aria-label={accessibleLabel}
|
||||
tabIndex={0}
|
||||
style={{ cursor: "help" }}
|
||||
>
|
||||
<IconAlertTriangle aria-hidden size={14} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export function WidgetQueryLoadingState() {
|
||||
const t = useScopedI18n("common.widgetQuery");
|
||||
return (
|
||||
<Center component="output" h="100%" w="100%" p="sm" aria-live="polite">
|
||||
<Loader size="sm" />
|
||||
<VisuallyHidden>{t("loading")}</VisuallyHidden>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "./query-state";
|
||||
|
||||
describe("getUsableWidgetQueryData", () => {
|
||||
test.each([[], null, 0, false, ""])("preserves successful empty value %#", (data) => {
|
||||
expect(getUsableWidgetQueryData({ data, error: null })).toBe(data);
|
||||
});
|
||||
|
||||
test("preserves stale cached data when a refetch fails", () => {
|
||||
const data = [{ id: "healthy" }];
|
||||
expect(getUsableWidgetQueryData({ data, error: new Error("offline") })).toBe(data);
|
||||
});
|
||||
|
||||
test("throws a terminal failure when no usable data exists", () => {
|
||||
const error = new Error("offline");
|
||||
expect(() => getUsableWidgetQueryData({ data: undefined, error })).toThrow(error);
|
||||
});
|
||||
|
||||
test("keeps the initial pending state when neither data nor an error exists", () => {
|
||||
expect(getUsableWidgetQueryData({ data: undefined, error: null })).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("isInitialWidgetQueryPending", () => {
|
||||
test("only marks the unresolved first request as initial pending", () => {
|
||||
expect(isInitialWidgetQueryPending({ data: undefined, error: null, isPending: true })).toBe(true);
|
||||
expect(isInitialWidgetQueryPending({ data: [], error: null, isPending: true })).toBe(false);
|
||||
expect(isInitialWidgetQueryPending({ data: undefined, error: new Error("offline"), isPending: true })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
interface WidgetQueryState {
|
||||
data: unknown;
|
||||
error: unknown;
|
||||
}
|
||||
|
||||
interface PendingWidgetQueryState extends WidgetQueryState {
|
||||
isPending: boolean;
|
||||
}
|
||||
|
||||
/** Throws terminal query failures while preserving successful empty values and stale cached data. */
|
||||
export const getUsableWidgetQueryData = <TQuery extends WidgetQueryState>(query: TQuery): TQuery["data"] => {
|
||||
if (query.error && query.data === undefined) throw query.error;
|
||||
return query.data;
|
||||
};
|
||||
|
||||
/** True only before the first result; successful empty values and cached data are both ready to render. */
|
||||
export const isInitialWidgetQueryPending = ({ data, error, isPending }: PendingWidgetQueryState): boolean =>
|
||||
isPending && data === undefined && !error;
|
||||
@@ -1,16 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import { ScrollArea, Stack, Text } from "@mantine/core";
|
||||
import { ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { getUsableWidgetQueryData } from "../common/query-state";
|
||||
import { createWidgetKey } from "./coolify-utils";
|
||||
import { InstanceCard } from "./instance-card";
|
||||
import { SingleInstanceLayout } from "./single-instance-layout";
|
||||
|
||||
export default function CoolifyWidget({ options, integrationIds, width }: WidgetComponentProps<"coolify">) {
|
||||
export default function CoolifyWidget({ options, integrationIds, width, height }: WidgetComponentProps<"coolify">) {
|
||||
const t = useScopedI18n("widget.coolify");
|
||||
|
||||
if (integrationIds.length === 0) {
|
||||
@@ -21,29 +22,54 @@ export default function CoolifyWidget({ options, integrationIds, width }: Widget
|
||||
);
|
||||
}
|
||||
|
||||
return <CoolifyContent integrationIds={integrationIds} options={options} width={width} />;
|
||||
return <CoolifyContent integrationIds={integrationIds} options={options} width={width} height={height} />;
|
||||
}
|
||||
|
||||
interface CoolifyContentProps {
|
||||
integrationIds: string[];
|
||||
options: WidgetComponentProps<"coolify">["options"];
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
function CoolifyContent({ integrationIds, options, width }: CoolifyContentProps) {
|
||||
const { data: instancesData = [] } = clientApi.widget.coolify.getInstancesInfo.useQuery({ integrationIds });
|
||||
function CoolifyContent({ integrationIds, options, width, height }: CoolifyContentProps) {
|
||||
const t = useScopedI18n("common");
|
||||
const instancesQuery = clientApi.widget.coolify.getInstancesInfo.useQuery({
|
||||
integrationIds,
|
||||
});
|
||||
const instancesData = getUsableWidgetQueryData(instancesQuery) ?? [];
|
||||
const { isPending } = instancesQuery;
|
||||
|
||||
const isTiny = width < 256;
|
||||
const isTiny = width < 256 || height < 144;
|
||||
const hideFooter = height < 112;
|
||||
const [firstInstance] = instancesData;
|
||||
const widgetKey = createWidgetKey(integrationIds);
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<Stack align="center" justify="center" h="100%">
|
||||
<Text c="dimmed" size="sm">
|
||||
{t("action.loading")}
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
if (instancesData.length === 1 && firstInstance) {
|
||||
return <SingleInstanceLayout instance={firstInstance} options={options} isTiny={isTiny} widgetKey={widgetKey} />;
|
||||
return (
|
||||
<SingleInstanceLayout
|
||||
instance={firstInstance}
|
||||
options={options}
|
||||
isTiny={isTiny}
|
||||
widgetKey={widgetKey}
|
||||
hideFooter={hideFooter}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollArea h="100%">
|
||||
<Stack gap="xs" p="xs">
|
||||
<SimpleGrid cols={width >= 760 ? 2 : 1} spacing="sm" p="xs">
|
||||
{instancesData.map((instance) => (
|
||||
<InstanceCard
|
||||
key={instance.integrationId}
|
||||
@@ -51,9 +77,10 @@ function CoolifyContent({ integrationIds, options, width }: CoolifyContentProps)
|
||||
options={options}
|
||||
isTiny={isTiny}
|
||||
widgetKey={widgetKey}
|
||||
hideFooter={hideFooter}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</SimpleGrid>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import dayjs from "dayjs";
|
||||
import frenchLocale from "dayjs/locale/fr";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { formatRelativeTime, getResourceTimestamp } from "./coolify-utils";
|
||||
|
||||
describe("Coolify time formatting", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-08-02T12:00:00Z"));
|
||||
dayjs.locale(frenchLocale);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
dayjs.locale("en");
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("uses the active Day.js locale for past and future timestamps", () => {
|
||||
expect(formatRelativeTime("2026-08-02T11:00:00Z")).toBe("il y a une heure");
|
||||
expect(formatRelativeTime("2026-08-02T13:00:00Z")).toBe("dans une heure");
|
||||
});
|
||||
|
||||
it("omits missing, invalid, and currently running timestamps", () => {
|
||||
expect(formatRelativeTime(undefined)).toBeUndefined();
|
||||
expect(formatRelativeTime("invalid")).toBeUndefined();
|
||||
expect(getResourceTimestamp({ status: "running", updated_at: "2026-08-02T11:00:00Z" }, "service")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,14 @@
|
||||
import dayjs from "dayjs";
|
||||
import relativeTime from "dayjs/plugin/relativeTime";
|
||||
|
||||
import type {
|
||||
CoolifyApplicationWithContext,
|
||||
CoolifyServer,
|
||||
CoolifyServiceWithContext,
|
||||
} from "@homarr/integrations/types";
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
export function parseStatus(status: string): string {
|
||||
const firstPart = status.split(":")[0]?.toLowerCase();
|
||||
if (!firstPart) return "unknown";
|
||||
@@ -42,26 +47,8 @@ export function getBadgeColor(running: number, total: number): string {
|
||||
|
||||
export function formatRelativeTime(dateString: string | null | undefined): string | undefined {
|
||||
if (!dateString) return undefined;
|
||||
const date = new Date(dateString);
|
||||
if (isNaN(date.getTime())) return undefined;
|
||||
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffSec = Math.floor(diffMs / 1000);
|
||||
const diffMin = Math.floor(diffSec / 60);
|
||||
const diffHour = Math.floor(diffMin / 60);
|
||||
const diffDay = Math.floor(diffHour / 24);
|
||||
|
||||
if (diffDay > 0) {
|
||||
const remainingHours = diffHour % 24;
|
||||
return remainingHours > 0 ? `${diffDay}d ${remainingHours}h ago` : `${diffDay}d ago`;
|
||||
}
|
||||
if (diffHour > 0) {
|
||||
const remainingMin = diffMin % 60;
|
||||
return remainingMin > 0 ? `${diffHour}h ${remainingMin}m ago` : `${diffHour}h ago`;
|
||||
}
|
||||
if (diffMin > 0) return `${diffMin}m ago`;
|
||||
return "just now";
|
||||
const date = dayjs(dateString);
|
||||
return date.isValid() ? date.fromNow() : undefined;
|
||||
}
|
||||
|
||||
export function getResourceTimestamp(
|
||||
@@ -116,7 +103,7 @@ export function buildServerResourceCounts(
|
||||
}
|
||||
|
||||
export function createWidgetKey(integrationIds: string[]): string {
|
||||
return integrationIds.slice().sort().join("-");
|
||||
return integrationIds.toSorted().join("-");
|
||||
}
|
||||
|
||||
export function createStorageKey(widgetKey: string, integrationId: string, type: "sections" | "show-ip"): string {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { createWidgetDefinition } from "../definition";
|
||||
import { optionsBuilder } from "../options";
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("coolify", {
|
||||
supportsAdvancedFocus: false,
|
||||
icon: IconCloud,
|
||||
createOptions() {
|
||||
return optionsBuilder.from((factory) => ({
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { Accordion, Anchor, Badge, Card, Group, Image, Text } from "@mantine/core";
|
||||
import { Accordion, Anchor, Badge, Card, Group, Image, Text, Tooltip } from "@mantine/core";
|
||||
import { useLocalStorage } from "@mantine/hooks";
|
||||
|
||||
import { useTimeAgo } from "@homarr/common";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { ApplicationsSection } from "./applications-section";
|
||||
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url";
|
||||
import { buildServerResourceCounts, getBadgeColor, parseStatus } from "./coolify-utils";
|
||||
import { ServersSection } from "./servers-section";
|
||||
import { ServicesSection } from "./services-section";
|
||||
@@ -18,10 +19,12 @@ interface InstanceCardProps {
|
||||
options: CoolifyOptions;
|
||||
isTiny: boolean;
|
||||
widgetKey: string;
|
||||
hideFooter: boolean;
|
||||
}
|
||||
|
||||
export function InstanceCard({ instance, options, isTiny, widgetKey }: InstanceCardProps) {
|
||||
export function InstanceCard({ instance, options, isTiny, widgetKey, hideFooter }: InstanceCardProps) {
|
||||
const t = useScopedI18n("widget.coolify");
|
||||
const tCommon = useScopedI18n("common");
|
||||
const cardKey = `${widgetKey}-${instance.integrationId}`;
|
||||
const [showIp, setShowIp] = useLocalStorage({
|
||||
key: `coolify-show-ip-${cardKey}`,
|
||||
@@ -31,14 +34,13 @@ export function InstanceCard({ instance, options, isTiny, widgetKey }: InstanceC
|
||||
key: `coolify-sections-${cardKey}`,
|
||||
defaultValue: ["applications"],
|
||||
});
|
||||
|
||||
const serverResourceCounts = buildServerResourceCounts(
|
||||
instance.instanceInfo.servers,
|
||||
instance.instanceInfo.applications,
|
||||
instance.instanceInfo.services,
|
||||
);
|
||||
|
||||
const baseUrl = instance.integrationUrl.replace(/\/+$/, "");
|
||||
const baseUrl = getSafeApplicationUrl(instance.integrationUrl)?.replace(/\/+$/, "") ?? "";
|
||||
const relativeTime = useTimeAgo(instance.updatedAt);
|
||||
|
||||
const onlineServers = instance.instanceInfo.servers.filter((s) => s.is_reachable !== false).length;
|
||||
@@ -48,6 +50,23 @@ export function InstanceCard({ instance, options, isTiny, widgetKey }: InstanceC
|
||||
const runningServices = instance.instanceInfo.services.filter(
|
||||
(s) => parseStatus(s.status ?? "") === "running",
|
||||
).length;
|
||||
const healthyResources =
|
||||
(options.showServers ? onlineServers : 0) +
|
||||
(options.showApplications ? runningApps : 0) +
|
||||
(options.showServices ? runningServices : 0);
|
||||
const totalResources =
|
||||
(options.showServers ? instance.instanceInfo.servers.length : 0) +
|
||||
(options.showApplications ? instance.instanceInfo.applications.length : 0) +
|
||||
(options.showServices ? instance.instanceInfo.services.length : 0);
|
||||
const resourceSummary = [
|
||||
options.showServers ? `${tCommon("servers")}: ${onlineServers}/${instance.instanceInfo.servers.length}` : null,
|
||||
options.showApplications
|
||||
? `${tCommon("applications")}: ${runningApps}/${instance.instanceInfo.applications.length}`
|
||||
: null,
|
||||
options.showServices ? `${tCommon("services")}: ${runningServices}/${instance.instanceInfo.services.length}` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
|
||||
return (
|
||||
<Card p={0} radius="sm">
|
||||
@@ -59,17 +78,33 @@ export function InstanceCard({ instance, options, isTiny, widgetKey }: InstanceC
|
||||
>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<Image src={COOLIFY_ICON_URL} alt="Coolify" w={16} h={16} />
|
||||
<Anchor href={baseUrl} target="_blank" fz={isTiny ? "10px" : "xs"} fw={600} c="inherit" lineClamp={1}>
|
||||
<Anchor
|
||||
component={baseUrl ? "a" : "span"}
|
||||
href={baseUrl}
|
||||
target={baseUrl ? "_blank" : undefined}
|
||||
rel={baseUrl ? SAFE_NEW_TAB_REL : undefined}
|
||||
fz={isTiny ? "10px" : "xs"}
|
||||
fw={600}
|
||||
c="inherit"
|
||||
lineClamp={1}
|
||||
>
|
||||
{instance.integrationName}
|
||||
</Anchor>
|
||||
</Group>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
{options.showServers && (
|
||||
{isTiny && totalResources > 0 ? (
|
||||
<Tooltip label={resourceSummary}>
|
||||
<Badge variant="dot" color={getBadgeColor(healthyResources, totalResources)} size="xs">
|
||||
{healthyResources}/{totalResources}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
{!isTiny && options.showServers && (
|
||||
<Badge variant="dot" color={getBadgeColor(onlineServers, instance.instanceInfo.servers.length)} size="xs">
|
||||
{onlineServers}/{instance.instanceInfo.servers.length}
|
||||
</Badge>
|
||||
)}
|
||||
{options.showApplications && (
|
||||
{!isTiny && options.showApplications && (
|
||||
<Badge
|
||||
variant="dot"
|
||||
color={getBadgeColor(runningApps, instance.instanceInfo.applications.length)}
|
||||
@@ -78,7 +113,7 @@ export function InstanceCard({ instance, options, isTiny, widgetKey }: InstanceC
|
||||
{runningApps}/{instance.instanceInfo.applications.length}
|
||||
</Badge>
|
||||
)}
|
||||
{options.showServices && (
|
||||
{!isTiny && options.showServices && (
|
||||
<Badge
|
||||
variant="dot"
|
||||
color={getBadgeColor(runningServices, instance.instanceInfo.services.length)}
|
||||
@@ -109,14 +144,20 @@ export function InstanceCard({ instance, options, isTiny, widgetKey }: InstanceC
|
||||
)}
|
||||
</Accordion>
|
||||
|
||||
<Group justify="space-between" p={4} style={{ borderTop: "1px solid var(--mantine-color-dark-4)" }}>
|
||||
<Text size="10px" c="dimmed">
|
||||
v{instance.instanceInfo.version}
|
||||
</Text>
|
||||
<Text size="10px" c="dimmed">
|
||||
{t("footer.updated", { when: relativeTime })}
|
||||
</Text>
|
||||
</Group>
|
||||
{!hideFooter && (
|
||||
<Group
|
||||
justify="space-between"
|
||||
p={4}
|
||||
style={{ borderTop: "1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4))" }}
|
||||
>
|
||||
<Text size="10px" c="dimmed">
|
||||
v{instance.instanceInfo.version}
|
||||
</Text>
|
||||
<Text size="10px" c="dimmed">
|
||||
{t("footer.updated", { when: relativeTime })}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import { ActionIcon, Anchor, Group, Indicator, Stack, Text } from "@mantine/core";
|
||||
import { IconExternalLink, IconFileText, IconLink } from "@tabler/icons-react";
|
||||
import { ActionIcon, Anchor, Group, Stack, Text } from "@mantine/core";
|
||||
import {
|
||||
IconCircleCheckFilled,
|
||||
IconCircleXFilled,
|
||||
IconFileText,
|
||||
IconHelpCircleFilled,
|
||||
IconLink,
|
||||
IconLoader2,
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import actionTargetClasses from "../common/action-target.module.css";
|
||||
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url";
|
||||
import { cleanFqdn, getResourceTimestamp, getStatusColor, parseStatus } from "./coolify-utils";
|
||||
|
||||
interface ResourceRowProps {
|
||||
@@ -24,52 +35,103 @@ interface ResourceRowProps {
|
||||
}
|
||||
|
||||
export function ResourceRow({ item, baseUrl, isTiny, resourceType }: ResourceRowProps) {
|
||||
const t = useScopedI18n("widget.coolify");
|
||||
const status = parseStatus(item.status ?? "");
|
||||
const statusColor = getStatusColor(status);
|
||||
const isTransitioning = status === "starting" || status === "restarting";
|
||||
const StatusIcon =
|
||||
status === "running"
|
||||
? IconCircleCheckFilled
|
||||
: isTransitioning
|
||||
? IconLoader2
|
||||
: status === "unknown"
|
||||
? IconHelpCircleFilled
|
||||
: IconCircleXFilled;
|
||||
const statusLabel =
|
||||
status === "running"
|
||||
? t("status.running")
|
||||
: status === "stopped"
|
||||
? t("status.stopped")
|
||||
: status === "exited"
|
||||
? t("status.exited")
|
||||
: status === "starting"
|
||||
? t("status.starting")
|
||||
: status === "restarting"
|
||||
? t("status.restarting")
|
||||
: t("status.unknown");
|
||||
|
||||
const resourceUrl =
|
||||
const resourceUrl = getSafeApplicationUrl(
|
||||
item.projectUuid && item.environmentUuid
|
||||
? `${baseUrl}/project/${item.projectUuid}/environment/${item.environmentUuid}/${resourceType}/${item.uuid}`
|
||||
: undefined;
|
||||
: undefined,
|
||||
);
|
||||
|
||||
const logsUrl = resourceUrl ? `${resourceUrl}/logs` : undefined;
|
||||
const logsUrl = getSafeApplicationUrl(resourceUrl ? `${resourceUrl}/logs` : undefined);
|
||||
const publicUrl = getSafeApplicationUrl(cleanFqdn(item.fqdn));
|
||||
const resourceTimestamp = getResourceTimestamp(item, resourceType);
|
||||
|
||||
return (
|
||||
<Stack gap={0}>
|
||||
<Group wrap="nowrap" gap={isTiny ? 4 : "xs"}>
|
||||
<Indicator size={isTiny ? 4 : 8} color={statusColor} />
|
||||
<StatusIcon aria-label={statusLabel} size={isTiny ? 12 : 16} color={`var(--mantine-color-${statusColor}-6)`} />
|
||||
{resourceUrl ? (
|
||||
<Anchor href={resourceUrl} target="_blank" fz={isTiny ? "8px" : "xs"} c="inherit" lineClamp={1}>
|
||||
<Anchor
|
||||
className={actionTargetClasses.root}
|
||||
href={resourceUrl}
|
||||
target="_blank"
|
||||
rel={SAFE_NEW_TAB_REL}
|
||||
fz="xs"
|
||||
c="inherit"
|
||||
truncate="end"
|
||||
style={{ display: "inline-flex", alignItems: "center", overflow: "hidden" }}
|
||||
>
|
||||
{item.name}
|
||||
</Anchor>
|
||||
) : (
|
||||
<Text lineClamp={1} fz={isTiny ? "8px" : "xs"}>
|
||||
<Text lineClamp={1} fz="xs">
|
||||
{item.name}
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
<Group wrap="nowrap" gap={4} ml={16}>
|
||||
{cleanFqdn(item.fqdn) && (
|
||||
<ActionIcon component="a" href={cleanFqdn(item.fqdn)} target="_blank" size="xs" variant="subtle" c="dimmed">
|
||||
{publicUrl && (
|
||||
<ActionIcon
|
||||
className={actionTargetClasses.root}
|
||||
component="a"
|
||||
href={publicUrl}
|
||||
target="_blank"
|
||||
rel={SAFE_NEW_TAB_REL}
|
||||
aria-label={t("action.openResource", { name: item.name })}
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
c="dimmed"
|
||||
>
|
||||
<IconLink size={12} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
{resourceUrl && (
|
||||
<ActionIcon component="a" href={resourceUrl} target="_blank" size="xs" variant="subtle" c="dimmed">
|
||||
<IconExternalLink size={12} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
{logsUrl && (
|
||||
<ActionIcon component="a" href={logsUrl} target="_blank" size="xs" variant="subtle" c="dimmed">
|
||||
{!isTiny && logsUrl && (
|
||||
<ActionIcon
|
||||
className={actionTargetClasses.root}
|
||||
component="a"
|
||||
href={logsUrl}
|
||||
target="_blank"
|
||||
rel={SAFE_NEW_TAB_REL}
|
||||
aria-label={t("action.openLogs", { name: item.name })}
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
c="dimmed"
|
||||
>
|
||||
<IconFileText size={12} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
<Text fz="10px" c="dimmed" lineClamp={1}>
|
||||
{item.projectName ?? "-"} / {item.environmentName ?? "-"}
|
||||
</Text>
|
||||
{getResourceTimestamp(item, resourceType) && (
|
||||
{!isTiny && (
|
||||
<Text fz="10px" c="dimmed" lineClamp={1}>
|
||||
{item.projectName ?? "-"} / {item.environmentName ?? "-"}
|
||||
</Text>
|
||||
)}
|
||||
{!isTiny && resourceTimestamp && (
|
||||
<Text fz="10px" c="dimmed" ml="auto">
|
||||
{getResourceTimestamp(item, resourceType)}
|
||||
{resourceTimestamp}
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { Accordion, ActionIcon, Anchor, Badge, Group, Indicator, Stack, Text } from "@mantine/core";
|
||||
import { IconExternalLink, IconEye, IconEyeOff, IconServer } from "@tabler/icons-react";
|
||||
import { Accordion, ActionIcon, Anchor, Badge, Group, Stack, Text } from "@mantine/core";
|
||||
import { IconCircleCheckFilled, IconCircleXFilled, IconEye, IconEyeOff, IconServer } from "@tabler/icons-react";
|
||||
|
||||
import type { CoolifyServer } from "@homarr/integrations/types";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import actionTargetClasses from "../common/action-target.module.css";
|
||||
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url";
|
||||
import { getBadgeColor } from "./coolify-utils";
|
||||
|
||||
interface ServersSectionProps {
|
||||
@@ -31,27 +33,28 @@ export function ServersSection({
|
||||
|
||||
return (
|
||||
<Accordion.Item value="servers">
|
||||
<Accordion.Control icon={isTiny ? null : <IconServer size={16} />}>
|
||||
<Group gap="xs" justify="space-between" wrap="nowrap" style={{ flex: 1 }}>
|
||||
<Group gap={0} wrap="nowrap">
|
||||
<Accordion.Control icon={isTiny ? null : <IconServer size={16} />} style={{ flex: 1, minWidth: 0 }}>
|
||||
<Group gap="xs">
|
||||
<Text size="xs">{tCommon("servers")}</Text>
|
||||
<Badge variant="dot" color={getBadgeColor(onlineServers, servers.length)} size="xs">
|
||||
{onlineServers} / {servers.length}
|
||||
</Badge>
|
||||
</Group>
|
||||
<ActionIcon
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
c="dimmed"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onToggleIp();
|
||||
}}
|
||||
>
|
||||
{showIp ? <IconEye size={12} /> : <IconEyeOff size={12} />}
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Accordion.Control>
|
||||
</Accordion.Control>
|
||||
<ActionIcon
|
||||
className={actionTargetClasses.root}
|
||||
aria-label={t(showIp ? "action.hideIp" : "action.showIp")}
|
||||
aria-pressed={showIp}
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
c="dimmed"
|
||||
mr="xs"
|
||||
onClick={onToggleIp}
|
||||
>
|
||||
{showIp ? <IconEye size={12} /> : <IconEyeOff size={12} />}
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
<Accordion.Panel p={4}>
|
||||
{servers.length > 0 ? (
|
||||
<Stack gap={4}>
|
||||
@@ -90,13 +93,28 @@ function ServerRow({ server, counts, baseUrl, isTiny, showIp }: ServerRowProps)
|
||||
const t = useScopedI18n("widget.coolify");
|
||||
const isBuildServer = server.settings?.is_build_server === true;
|
||||
const isOnline = server.is_reachable !== false;
|
||||
const serverUrl = `${baseUrl}/server/${server.uuid}`;
|
||||
const serverUrl = getSafeApplicationUrl(`${baseUrl}/server/${server.uuid}`);
|
||||
const StatusIcon = isOnline ? IconCircleCheckFilled : IconCircleXFilled;
|
||||
|
||||
return (
|
||||
<Stack gap={0}>
|
||||
<Group wrap="nowrap" gap={isTiny ? 4 : "xs"}>
|
||||
<Indicator size={isTiny ? 4 : 8} color={isOnline ? "green" : "red"} />
|
||||
<Anchor href={serverUrl} target="_blank" fz={isTiny ? "8px" : "xs"} c="inherit" lineClamp={1}>
|
||||
<StatusIcon
|
||||
aria-label={t(isOnline ? "status.online" : "status.offline")}
|
||||
size={isTiny ? 12 : 16}
|
||||
color={`var(--mantine-color-${isOnline ? "green" : "red"}-6)`}
|
||||
/>
|
||||
<Anchor
|
||||
className={actionTargetClasses.root}
|
||||
component={serverUrl ? "a" : "span"}
|
||||
href={serverUrl}
|
||||
target={serverUrl ? "_blank" : undefined}
|
||||
rel={serverUrl ? SAFE_NEW_TAB_REL : undefined}
|
||||
fz="xs"
|
||||
c="inherit"
|
||||
truncate="end"
|
||||
style={{ display: "inline-flex", alignItems: "center", overflow: "hidden" }}
|
||||
>
|
||||
{server.name}
|
||||
</Anchor>
|
||||
{isBuildServer ? (
|
||||
@@ -105,14 +123,11 @@ function ServerRow({ server, counts, baseUrl, isTiny, showIp }: ServerRowProps)
|
||||
</Badge>
|
||||
) : (
|
||||
<Text fz="10px" c="dimmed">
|
||||
({counts.apps} apps / {counts.services} svcs)
|
||||
({t("server.resourceCounts", { applications: counts.apps, services: counts.services })})
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
<Group wrap="nowrap" gap={4} ml={16}>
|
||||
<ActionIcon component="a" href={serverUrl} target="_blank" size="xs" variant="subtle" c="dimmed">
|
||||
<IconExternalLink size={12} />
|
||||
</ActionIcon>
|
||||
<Text fz="10px" c="dimmed">
|
||||
{showIp ? server.ip : "***.***.***.***"}
|
||||
</Text>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useTimeAgo } from "@homarr/common";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { ApplicationsSection } from "./applications-section";
|
||||
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url";
|
||||
import { buildServerResourceCounts } from "./coolify-utils";
|
||||
import { ServersSection } from "./servers-section";
|
||||
import { ServicesSection } from "./services-section";
|
||||
@@ -18,9 +19,10 @@ interface SingleInstanceLayoutProps {
|
||||
options: CoolifyOptions;
|
||||
isTiny: boolean;
|
||||
widgetKey: string;
|
||||
hideFooter: boolean;
|
||||
}
|
||||
|
||||
export function SingleInstanceLayout({ instance, options, isTiny, widgetKey }: SingleInstanceLayoutProps) {
|
||||
export function SingleInstanceLayout({ instance, options, isTiny, widgetKey, hideFooter }: SingleInstanceLayoutProps) {
|
||||
const t = useScopedI18n("widget.coolify");
|
||||
const [showIp, setShowIp] = useLocalStorage({
|
||||
key: `coolify-show-ip-${widgetKey}`,
|
||||
@@ -30,15 +32,14 @@ export function SingleInstanceLayout({ instance, options, isTiny, widgetKey }: S
|
||||
key: `coolify-sections-${widgetKey}`,
|
||||
defaultValue: ["applications"],
|
||||
});
|
||||
|
||||
const serverResourceCounts = buildServerResourceCounts(
|
||||
instance.instanceInfo.servers,
|
||||
instance.instanceInfo.applications,
|
||||
instance.instanceInfo.services,
|
||||
);
|
||||
|
||||
const baseUrl = instance.integrationUrl.replace(/\/+$/, "");
|
||||
const displayUrl = baseUrl.replace(/^https?:\/\//, "");
|
||||
const baseUrl = getSafeApplicationUrl(instance.integrationUrl)?.replace(/\/+$/, "") ?? "";
|
||||
const displayUrl = baseUrl ? baseUrl.replace(/^https?:\/\//, "") : "—";
|
||||
const relativeTime = useTimeAgo(instance.updatedAt);
|
||||
|
||||
return (
|
||||
@@ -51,7 +52,16 @@ export function SingleInstanceLayout({ instance, options, isTiny, widgetKey }: S
|
||||
oolify
|
||||
</Text>
|
||||
</Group>
|
||||
<Anchor href={baseUrl} target="_blank" fz={isTiny ? "xs" : "sm"} fw={500} c="dimmed" lineClamp={1}>
|
||||
<Anchor
|
||||
component={baseUrl ? "a" : "span"}
|
||||
href={baseUrl}
|
||||
target={baseUrl ? "_blank" : undefined}
|
||||
rel={baseUrl ? SAFE_NEW_TAB_REL : undefined}
|
||||
fz={isTiny ? "xs" : "sm"}
|
||||
fw={500}
|
||||
c="dimmed"
|
||||
lineClamp={1}
|
||||
>
|
||||
{displayUrl}
|
||||
</Anchor>
|
||||
</Group>
|
||||
@@ -75,21 +85,23 @@ export function SingleInstanceLayout({ instance, options, isTiny, widgetKey }: S
|
||||
)}
|
||||
</Accordion>
|
||||
|
||||
<Group
|
||||
justify="space-between"
|
||||
p={4}
|
||||
style={{ borderTop: "1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4))" }}
|
||||
>
|
||||
<Group gap={2}>
|
||||
<Image src={COOLIFY_ICON_URL} alt="Coolify" w={16} h={16} />
|
||||
{!hideFooter && (
|
||||
<Group
|
||||
justify="space-between"
|
||||
p={4}
|
||||
style={{ borderTop: "1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4))" }}
|
||||
>
|
||||
<Group gap={2}>
|
||||
<Image src={COOLIFY_ICON_URL} alt="Coolify" w={16} h={16} />
|
||||
<Text size="xs" c="dimmed">
|
||||
v{instance.instanceInfo.version}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed">
|
||||
v{instance.instanceInfo.version}
|
||||
{t("footer.updated", { when: relativeTime })}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("footer.updated", { when: relativeTime })}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
</ScrollArea>
|
||||
);
|
||||
|
||||
@@ -1,493 +1,132 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import { useState } from "react";
|
||||
import type { ComponentType } from "react";
|
||||
import {
|
||||
ActionIcon,
|
||||
Button,
|
||||
Card,
|
||||
Center,
|
||||
Code,
|
||||
Flex,
|
||||
Group,
|
||||
Loader,
|
||||
Progress,
|
||||
ScrollArea,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
Title,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { useElementSize } from "@mantine/hooks";
|
||||
import { IconAlertTriangle, IconCheck, IconExternalLink, IconPlayerPlay } from "@tabler/icons-react";
|
||||
import { Button, Center, Loader, Stack, Text } from "@mantine/core";
|
||||
import { IconAlertTriangle } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useConfirmModal } from "@homarr/modals";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { Link } from "@homarr/ui";
|
||||
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { isLegacyCustomWidgetMigrationError, isTerminalCustomWidgetDefinitionError } from "./migration-state";
|
||||
|
||||
const CustomJsxDisplay = dynamic(() => import("./custom-jsx-display"), { ssr: false });
|
||||
|
||||
const valueSizeMap: Record<string, string> = { sm: "sm", md: "md", lg: "lg", xl: "xl" };
|
||||
|
||||
function SingleValueDisplay({ data }: { data: Record<string, unknown> }) {
|
||||
const size = valueSizeMap[(data.valueSize as string) ?? "lg"] ?? "lg";
|
||||
const labelAbove = (data.labelPosition as string) === "above";
|
||||
const label = data.label ? (
|
||||
<Text c="dimmed" size="sm">
|
||||
{String(data.label)}
|
||||
</Text>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<Stack h="100%" align="center" justify="center" gap="xs">
|
||||
{labelAbove && label}
|
||||
<Title order={size === "xl" ? 1 : size === "lg" ? 2 : size === "md" ? 3 : 4}>
|
||||
{String(data.value ?? "—")}
|
||||
{data.unit ? ` ${data.unit}` : ""}
|
||||
</Title>
|
||||
{!labelAbove && label}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function KeyValueDisplay({ data }: { data: Record<string, unknown> }) {
|
||||
const entries = (data.entries as Array<{ label: string; unit: string; value: unknown }>) ?? [];
|
||||
const layout = (data.layout as string) ?? "list";
|
||||
const columns = (data.columns as number) ?? 2;
|
||||
|
||||
if (layout === "grid") {
|
||||
return (
|
||||
<SimpleGrid cols={columns} spacing="xs" p="sm" h="100%">
|
||||
{entries.map((entry, i) => (
|
||||
<Stack key={i} align="center" gap={2}>
|
||||
<Text size="sm" fw={600}>
|
||||
{String(entry.value ?? "—")}
|
||||
{entry.unit ? ` ${entry.unit}` : ""}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{entry.label}
|
||||
</Text>
|
||||
</Stack>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack h="100%" justify="center" gap="xs" p="sm">
|
||||
{entries.map((entry, i) => (
|
||||
<Group key={i} justify="space-between" wrap="nowrap">
|
||||
<Text size="sm" c="dimmed">
|
||||
{entry.label}
|
||||
</Text>
|
||||
<Text size="sm" fw={600}>
|
||||
{String(entry.value ?? "—")}
|
||||
{entry.unit ? ` ${entry.unit}` : ""}
|
||||
</Text>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function TableDisplay({ data }: { data: Record<string, unknown> }) {
|
||||
const columns = (data.columns as string[]) ?? [];
|
||||
const rows = (data.rows as unknown[][]) ?? [];
|
||||
const striped = (data.striped as boolean) ?? true;
|
||||
const compact = (data.compact as boolean) ?? false;
|
||||
|
||||
return (
|
||||
<ScrollArea>
|
||||
<Table striped={striped} highlightOnHover withTableBorder>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
{columns.map((col, i) => (
|
||||
<Table.Th key={i} py={compact ? 4 : undefined}>
|
||||
{col}
|
||||
</Table.Th>
|
||||
))}
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{rows.map((row, i) => (
|
||||
<Table.Tr key={i}>
|
||||
{row.map((cell, j) => (
|
||||
<Table.Td key={j} py={compact ? 2 : undefined}>
|
||||
{String(cell ?? "—")}
|
||||
</Table.Td>
|
||||
))}
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
|
||||
function StatGridCard({
|
||||
item,
|
||||
cardStyle,
|
||||
}: {
|
||||
item: { label: string; unit: string; color: string; value: unknown };
|
||||
cardStyle: string;
|
||||
}) {
|
||||
const { ref, height, width } = useElementSize();
|
||||
const isWide = width > height + 20;
|
||||
const hideLabel = height > 0 && height <= 38;
|
||||
|
||||
const bgMap: Record<string, string> = {
|
||||
filled: `var(--mantine-color-${item.color}-light)`,
|
||||
outline: "transparent",
|
||||
subtle: `var(--mantine-color-${item.color}-light)`,
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
ref={ref}
|
||||
p="sm"
|
||||
radius="md"
|
||||
bg={bgMap[cardStyle] ?? bgMap.filled}
|
||||
withBorder={cardStyle === "outline"}
|
||||
h="100%"
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
<Flex
|
||||
h="100%"
|
||||
w="100%"
|
||||
align="center"
|
||||
justify="center"
|
||||
direction={isWide ? "row" : "column"}
|
||||
gap={isWide ? 8 : 4}
|
||||
>
|
||||
<Flex direction="column" align={isWide ? "flex-start" : "center"} gap={0}>
|
||||
<Text size="md" fw={700} ta="center" lh={1.1}>
|
||||
{String(item.value ?? "—")}
|
||||
{item.unit ? ` ${item.unit}` : ""}
|
||||
</Text>
|
||||
{!hideLabel && (
|
||||
<Text size="xs" c="dimmed" ta="center" tt="uppercase" lh={1.3} style={{ letterSpacing: "0.03em" }}>
|
||||
{item.label}
|
||||
</Text>
|
||||
)}
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function StatGridDisplay({ data }: { data: Record<string, unknown> }) {
|
||||
const items = (data.items as Array<{ label: string; unit: string; color: string; value: unknown }>) ?? [];
|
||||
const columns = (data.columns as number) ?? 2;
|
||||
const cardStyle = (data.cardStyle as string) ?? "filled";
|
||||
|
||||
return (
|
||||
<SimpleGrid
|
||||
cols={columns}
|
||||
spacing="xs"
|
||||
p="xs"
|
||||
h="100%"
|
||||
style={{ gridTemplateRows: `repeat(${Math.ceil(items.length / columns)}, 1fr)` }}
|
||||
>
|
||||
{items.map((item, i) => (
|
||||
<StatGridCard key={i} item={item} cardStyle={cardStyle} />
|
||||
))}
|
||||
</SimpleGrid>
|
||||
);
|
||||
}
|
||||
|
||||
function ProgressBarsDisplay({ data }: { data: Record<string, unknown> }) {
|
||||
const bars = (data.bars as Array<{ label: string; unit: string; color: string; value: number; max?: number }>) ?? [];
|
||||
const showPercentage = (data.showPercentage as boolean) ?? true;
|
||||
const barSize = (data.barSize as string) ?? "md";
|
||||
|
||||
const sizeMap: Record<string, number> = { sm: 8, md: 14, lg: 22 };
|
||||
|
||||
return (
|
||||
<Stack h="100%" justify="center" gap="sm" p="sm">
|
||||
{bars.map((bar, i) => {
|
||||
const max = bar.max ?? 100;
|
||||
const pct = max > 0 ? Math.min((bar.value / max) * 100, 100) : 0;
|
||||
return (
|
||||
<Stack key={i} gap={4}>
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Text size="xs" fw={500}>
|
||||
{bar.label}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{showPercentage ? `${pct.toFixed(0)}%` : `${bar.value}${bar.unit ? ` ${bar.unit}` : ""}`}
|
||||
{bar.max !== undefined && showPercentage
|
||||
? ` (${bar.value}/${max}${bar.unit ? ` ${bar.unit}` : ""})`
|
||||
: ""}
|
||||
</Text>
|
||||
</Group>
|
||||
<Progress value={pct} size={sizeMap[barSize] ?? 14} color={bar.color} radius="sm" />
|
||||
</Stack>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusIndicatorDisplay({ data }: { data: Record<string, unknown> }) {
|
||||
const items = (data.items as Array<{ label: string; value: string; isGood: boolean }>) ?? [];
|
||||
const layout = (data.layout as string) ?? "list";
|
||||
const dotSize = (data.dotSize as string) ?? "md";
|
||||
|
||||
const dotSizeMap: Record<string, number> = { sm: 8, md: 10, lg: 14 };
|
||||
const size = dotSizeMap[dotSize] ?? 10;
|
||||
|
||||
const renderItem = (item: { label: string; value: string; isGood: boolean }, i: number) => (
|
||||
<Group key={i} gap="xs" wrap="nowrap">
|
||||
<div
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: "50%",
|
||||
backgroundColor: item.isGood ? "var(--mantine-color-green-6)" : "var(--mantine-color-red-6)",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
<Text size="sm" fw={500}>
|
||||
{item.label}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" ml="auto">
|
||||
{item.value}
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
|
||||
if (layout === "grid") {
|
||||
return (
|
||||
<SimpleGrid cols={2} spacing="xs" p="sm" h="100%">
|
||||
{items.map(renderItem)}
|
||||
</SimpleGrid>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack h="100%" justify="center" gap="xs" p="sm">
|
||||
{items.map(renderItem)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function CountGridDisplay({ data }: { data: Record<string, unknown> }) {
|
||||
const items = (data.items as Array<{ label: string; unit: string; value: unknown }>) ?? [];
|
||||
const columns = (data.columns as number) ?? 2;
|
||||
const valueSize = (data.valueSize as string) ?? "md";
|
||||
|
||||
const sizeMap: Record<string, string> = { sm: "sm", md: "md", lg: "lg" };
|
||||
|
||||
return (
|
||||
<SimpleGrid cols={columns} spacing="xs" p="sm" h="100%">
|
||||
{items.map((item, i) => (
|
||||
<Stack key={i} align="center" justify="center" gap={0}>
|
||||
<Text size={sizeMap[valueSize] ?? "md"} fw={700} lh={1.2}>
|
||||
{String(item.value ?? "—")}
|
||||
{item.unit ? ` ${item.unit}` : ""}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" tt="uppercase" ta="center" lh={1.3} style={{ letterSpacing: "0.03em" }}>
|
||||
{item.label}
|
||||
</Text>
|
||||
</Stack>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
);
|
||||
}
|
||||
|
||||
function openJsonInBrowser(json: unknown) {
|
||||
const blob = new Blob([JSON.stringify(json, null, 2)], { type: "application/json" });
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
window.open(blobUrl);
|
||||
setTimeout(() => URL.revokeObjectURL(blobUrl), 60_000);
|
||||
}
|
||||
|
||||
function RawDisplay({ data }: { data: Record<string, unknown> }) {
|
||||
const maxHeight = (data.maxHeight as number) ?? 300;
|
||||
const jsonString = JSON.stringify(data.data, null, 2);
|
||||
|
||||
return (
|
||||
<Stack gap={4} p="xs">
|
||||
<Group justify="flex-end">
|
||||
<Tooltip label="Open in browser JSON viewer">
|
||||
<ActionIcon variant="subtle" size="sm" onClick={() => openJsonInBrowser(data.data)}>
|
||||
<IconExternalLink size={14} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
<ScrollArea mah={maxHeight}>
|
||||
<Code block style={{ fontSize: 12 }}>
|
||||
{jsonString}
|
||||
</Code>
|
||||
</ScrollArea>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function ActionButtonDisplay({ data }: { data: Record<string, unknown> }) {
|
||||
export default function CustomApiWidget({
|
||||
options,
|
||||
itemId,
|
||||
isEditMode,
|
||||
removeItem,
|
||||
}: WidgetComponentProps<"customApi">) {
|
||||
const t = useScopedI18n("widget.customApi");
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const executeMutation = clientApi.customWidget.execute.useMutation();
|
||||
const [lastSuccess, setLastSuccess] = useState(false);
|
||||
|
||||
const buttonLabel = (data.buttonLabel as string) ?? "Execute";
|
||||
const buttonColor = (data.buttonColor as string) ?? "blue";
|
||||
const confirmText = (data.confirmText as string) || "";
|
||||
const successMessage = (data.successMessage as string) || t("executeSuccess");
|
||||
const definitionId = data.widgetDefinitionId as string | undefined;
|
||||
|
||||
const handleExecute = async () => {
|
||||
if (!definitionId) return;
|
||||
setLastSuccess(false);
|
||||
try {
|
||||
const result = await executeMutation.mutateAsync({ definitionId });
|
||||
if (result.success) {
|
||||
setLastSuccess(true);
|
||||
showSuccessNotification({ title: buttonLabel, message: successMessage });
|
||||
setTimeout(() => setLastSuccess(false), 3000);
|
||||
} else {
|
||||
showErrorNotification({ title: buttonLabel, message: result.error ?? t("executeFailed") });
|
||||
}
|
||||
} catch {
|
||||
showErrorNotification({ title: buttonLabel, message: t("executeFailed") });
|
||||
}
|
||||
};
|
||||
|
||||
const handleClick = () => {
|
||||
if (confirmText) {
|
||||
openConfirmModal({
|
||||
title: buttonLabel,
|
||||
children: confirmText,
|
||||
onConfirm: () => void handleExecute(),
|
||||
});
|
||||
} else {
|
||||
void handleExecute();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Center h="100%">
|
||||
<Button
|
||||
size="lg"
|
||||
color={buttonColor}
|
||||
onClick={handleClick}
|
||||
loading={executeMutation.isPending}
|
||||
leftSection={lastSuccess ? <IconCheck size={20} /> : <IconPlayerPlay size={20} />}
|
||||
variant={lastSuccess ? "light" : "filled"}
|
||||
>
|
||||
{executeMutation.isPending ? t("executing") : buttonLabel}
|
||||
</Button>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
export const displayComponents: Record<string, ComponentType<{ data: Record<string, unknown> }>> = {
|
||||
singleValue: SingleValueDisplay,
|
||||
keyValue: KeyValueDisplay,
|
||||
table: TableDisplay,
|
||||
statGrid: StatGridDisplay,
|
||||
progressBars: ProgressBarsDisplay,
|
||||
statusIndicator: StatusIndicatorDisplay,
|
||||
countGrid: CountGridDisplay,
|
||||
raw: RawDisplay,
|
||||
actionButton: ActionButtonDisplay,
|
||||
customJsx: CustomJsxDisplay,
|
||||
};
|
||||
|
||||
export default function CustomApiWidget({ options }: WidgetComponentProps<"customApi">) {
|
||||
const t = useScopedI18n("widget.customApi");
|
||||
const { definitionId, refreshInterval } = options;
|
||||
|
||||
if (!definitionId) {
|
||||
return (
|
||||
<Center h="100%">
|
||||
<Stack align="center" gap="xs">
|
||||
<IconAlertTriangle size={32} color="var(--mantine-color-yellow-6)" />
|
||||
<Text c="dimmed" size="sm">
|
||||
{t("noDefinition")}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
return <CustomApiWidgetInner definitionId={definitionId} refreshInterval={refreshInterval as number} />;
|
||||
}
|
||||
|
||||
function CustomApiWidgetInner({ definitionId, refreshInterval }: { definitionId: string; refreshInterval: number }) {
|
||||
const t = useScopedI18n("widget.customApi");
|
||||
const tCustomWidget = useScopedI18n("customWidget");
|
||||
const safeInterval = Number.isFinite(refreshInterval) ? refreshInterval : 30;
|
||||
const intervalMs = Math.max(1000, safeInterval * 1000);
|
||||
const { data, isLoading, error } = clientApi.widget.customApi.getData.useQuery(
|
||||
{ definitionId },
|
||||
const tCustomJsx = useScopedI18n("widget.customApi.customJsx");
|
||||
const { data: session } = useSession();
|
||||
const isAdmin = session?.user.permissions.includes("admin") ?? false;
|
||||
const definitionId = options.definitionId;
|
||||
const safeInterval = Number.isFinite(options.refreshInterval) ? options.refreshInterval : 30;
|
||||
const intervalMs = Math.max(1_000, safeInterval * 1_000);
|
||||
const query = clientApi.widget.customApi.getData.useQuery(
|
||||
{ itemId: itemId ?? "" },
|
||||
{
|
||||
refetchInterval: (query) => {
|
||||
const result = query.state.data as Record<string, unknown> | undefined;
|
||||
if (result?.type === "actionButton" || result?.type === "disabled") return false;
|
||||
return intervalMs;
|
||||
},
|
||||
retry: (failureCount, err) => {
|
||||
if (err.data?.code === "NOT_FOUND") return false;
|
||||
return failureCount < 3;
|
||||
},
|
||||
enabled: Boolean(itemId) && Boolean(definitionId),
|
||||
refetchInterval: (currentQuery) =>
|
||||
isTerminalCustomWidgetDefinitionError(currentQuery.state.error) ? false : intervalMs,
|
||||
retry: (failureCount, error) => !isTerminalCustomWidgetDefinitionError(error) && failureCount < 3,
|
||||
},
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
if (!definitionId) {
|
||||
return <Unavailable message={t("definitionNotFound")} removeLabel={t("removeFromBoard")} onRemove={removeItem} />;
|
||||
}
|
||||
if (isEditMode && !itemId) return <Unavailable message={t("editModePending")} />;
|
||||
if (query.isLoading)
|
||||
return (
|
||||
<Center h="100%">
|
||||
<Loader size="sm" />
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
const isNotFound = error.data?.code === "NOT_FOUND";
|
||||
if (query.error) {
|
||||
const errorCode = query.error.data?.code;
|
||||
const migrationRequired = isLegacyCustomWidgetMigrationError(query.error);
|
||||
if (isEditMode && errorCode === "NOT_FOUND") {
|
||||
return <Unavailable message={t("editModePending")} />;
|
||||
}
|
||||
const isUnavailable = errorCode === "NOT_FOUND" || errorCode === "FORBIDDEN";
|
||||
return (
|
||||
<Center h="100%">
|
||||
<Stack align="center" gap="xs">
|
||||
<IconAlertTriangle size={32} color={`var(--mantine-color-${isNotFound ? "yellow" : "red"}-6)`} />
|
||||
<Text c="dimmed" size="sm" ta="center">
|
||||
{isNotFound ? t("definitionNotFound") : error.message}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
<Unavailable
|
||||
message={
|
||||
migrationRequired
|
||||
? t("migrationRequired")
|
||||
: isUnavailable
|
||||
? t("definitionNotFound")
|
||||
: errorCode === "PRECONDITION_FAILED"
|
||||
? t("configurationNeedsRepair")
|
||||
: tCustomJsx("requestFailed")
|
||||
}
|
||||
danger={!isUnavailable && !migrationRequired}
|
||||
removeLabel={isUnavailable ? t("removeFromBoard") : undefined}
|
||||
actionLabel={migrationRequired && isAdmin ? t("manageMigration") : undefined}
|
||||
actionHref={migrationRequired && isAdmin ? "/manage/custom-widgets" : undefined}
|
||||
supplementaryMessage={migrationRequired && !isAdmin ? t("contactAdmin") : undefined}
|
||||
onRemove={removeItem}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data) return null;
|
||||
|
||||
const widgetData = data as Record<string, unknown>;
|
||||
const dataType = widgetData.type as string | undefined;
|
||||
|
||||
if (dataType === "disabled") {
|
||||
return (
|
||||
<Center h="100%">
|
||||
<Text c="dimmed" size="sm">
|
||||
{tCustomWidget("widget.disabled")}
|
||||
</Text>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
const Component = dataType ? displayComponents[dataType] : undefined;
|
||||
if (Component) {
|
||||
const enrichedData = dataType === "actionButton" ? { ...widgetData, widgetDefinitionId: definitionId } : widgetData;
|
||||
return <Component data={enrichedData} />;
|
||||
}
|
||||
if (!query.data) return null;
|
||||
|
||||
return (
|
||||
<ScrollArea h="100%" p="xs">
|
||||
<Code block style={{ fontSize: 11 }}>
|
||||
{JSON.stringify(data, null, 2)}
|
||||
</Code>
|
||||
</ScrollArea>
|
||||
<CustomJsxDisplay
|
||||
data={{
|
||||
...query.data,
|
||||
widgetDefinitionId: definitionId,
|
||||
widgetItemId: itemId,
|
||||
isEditMode,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
function Unavailable({
|
||||
message,
|
||||
danger = false,
|
||||
removeLabel,
|
||||
actionLabel,
|
||||
actionHref,
|
||||
supplementaryMessage,
|
||||
onRemove,
|
||||
}: {
|
||||
message: string;
|
||||
danger?: boolean;
|
||||
removeLabel?: string;
|
||||
actionLabel?: string;
|
||||
actionHref?: string;
|
||||
supplementaryMessage?: string;
|
||||
onRemove?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Center h="100%" p="sm">
|
||||
<Stack align="center" gap="xs">
|
||||
<IconAlertTriangle size={32} color={`var(--mantine-color-${danger ? "red" : "yellow"}-6)`} />
|
||||
<Text c="dimmed" size="sm" ta="center">
|
||||
{message}
|
||||
</Text>
|
||||
{supplementaryMessage && (
|
||||
<Text size="sm" fw={600} ta="center">
|
||||
{supplementaryMessage}
|
||||
</Text>
|
||||
)}
|
||||
{actionLabel && actionHref && (
|
||||
<Button component={Link} href={actionHref} size="compact-sm" color="yellow" variant="light">
|
||||
{actionLabel}
|
||||
</Button>
|
||||
)}
|
||||
{removeLabel && onRemove && (
|
||||
<Button size="compact-sm" color="red" variant="light" onClick={onRemove}>
|
||||
{removeLabel}
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,47 @@
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import * as MantineCharts from "@mantine/charts";
|
||||
import * as MantineCore from "@mantine/core";
|
||||
import * as MantineDates from "@mantine/dates";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { act, createElement } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { Root } from "react-dom/client";
|
||||
|
||||
import { customJsxComponentRegistry, enabledCustomJsxComponents } from "@homarr/custom-widgets/catalog";
|
||||
import {
|
||||
BUNDLED_CUSTOM_WIDGETS,
|
||||
customJsxExamples,
|
||||
customJsxTablerIconNames,
|
||||
getCustomWidgetDefaultOptions,
|
||||
} from "@homarr/custom-widgets/core";
|
||||
import { renderSafeJsx, SafeJsxError, sanitizeCustomJsxProps } from "@homarr/custom-widgets/jsx";
|
||||
|
||||
import CustomJsxDisplay from "./custom-jsx-display";
|
||||
import { SAFE_BINDINGS } from "./jsx-whitelist";
|
||||
import { createCustomWidgetComponents, SAFE_BINDINGS } from "./jsx-components";
|
||||
import { SAFE_TABLER_ICON_NAMES } from "./jsx-icon-adapter";
|
||||
|
||||
const CUSTOM_WIDGET_COMPONENTS = createCustomWidgetComponents({ copy: "Copy", copied: "Copied" });
|
||||
|
||||
vi.mock("@homarr/translation/client", () => ({
|
||||
useScopedI18n: () => (key: string, params?: Record<string, string>) =>
|
||||
key === "templateWarnings" ? `${params?.count ?? "0"} template warning(s):` : key,
|
||||
}));
|
||||
|
||||
const isComponentLikeExport = ([name, value]: [string, unknown]): boolean => {
|
||||
if (!/^[A-Z][A-Za-z0-9]*$/.test(name) || name.endsWith("Context")) return false;
|
||||
if (typeof value === "function") return true;
|
||||
if (!value || typeof value !== "object") return false;
|
||||
const reactType = (value as { $$typeof?: unknown }).$$typeof;
|
||||
return reactType === Symbol.for("react.forward_ref") || reactType === Symbol.for("react.memo");
|
||||
};
|
||||
|
||||
describe("CustomJsxDisplay", () => {
|
||||
it("keeps safe Tabler icon metadata synchronized with the runtime", () => {
|
||||
expect(SAFE_TABLER_ICON_NAMES).toEqual(customJsxTablerIconNames);
|
||||
});
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
@@ -27,10 +60,18 @@ describe("CustomJsxDisplay", () => {
|
||||
dispatchEvent: () => false,
|
||||
}),
|
||||
});
|
||||
Object.defineProperty(globalThis, "ResizeObserver", {
|
||||
writable: true,
|
||||
value: class {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
root?.unmount();
|
||||
afterEach(async () => {
|
||||
await act(async () => root?.unmount());
|
||||
container?.remove();
|
||||
});
|
||||
|
||||
@@ -38,8 +79,15 @@ describe("CustomJsxDisplay", () => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
await act(async () => {
|
||||
root.render(createElement(MantineProvider, null, createElement(CustomJsxDisplay, { data })));
|
||||
root.render(
|
||||
createElement(
|
||||
MantineProvider,
|
||||
null,
|
||||
createElement(QueryClientProvider, { client: queryClient }, createElement(CustomJsxDisplay, { data })),
|
||||
),
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -53,6 +101,65 @@ describe("CustomJsxDisplay", () => {
|
||||
expect(container.textContent).toContain("3");
|
||||
});
|
||||
|
||||
it("keeps runtime components inside an inert provider before a preview session exists", async () => {
|
||||
await renderDisplay({ template: '<RefreshButton label="Refresh" />', data: {} });
|
||||
|
||||
expect(container.querySelector('button[aria-label="Refresh"]')).not.toBeNull();
|
||||
expect(container.textContent).not.toContain("RUNTIME_RENDER_ERROR");
|
||||
expect(container.querySelector("button")?.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("renders the bundled Pokédex list without a widget-wide runtime failure", async () => {
|
||||
const pokedex = BUNDLED_CUSTOM_WIDGETS.find(({ id }) => id === "seed-pokedex")?.widget;
|
||||
expect(pokedex).toBeDefined();
|
||||
await renderDisplay({
|
||||
template: pokedex?.template,
|
||||
data: {
|
||||
pokemon: {
|
||||
count: 2,
|
||||
results: [
|
||||
{ name: "bulbasaur", url: "https://pokeapi.co/api/v2/pokemon/1/" },
|
||||
{ name: "pikachu", url: "https://pokeapi.co/api/v2/pokemon/25/" },
|
||||
],
|
||||
},
|
||||
},
|
||||
status: { pokemon: { loading: false, ok: true, status: 200 } },
|
||||
options: getCustomWidgetDefaultOptions(pokedex?.options ?? {}),
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain("bulbasaur");
|
||||
expect(container.textContent).toContain("pikachu");
|
||||
expect(
|
||||
container.querySelector('img[src="https://cdn.jsdelivr.net/gh/PokeAPI/sprites@master/sprites/pokemon/1.png"]'),
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
container.querySelector('img[src="https://cdn.jsdelivr.net/gh/PokeAPI/sprites@master/sprites/pokemon/25.png"]'),
|
||||
).not.toBeNull();
|
||||
expect(container.querySelectorAll("img.mantine-Image-root")).toHaveLength(2);
|
||||
expect(container.querySelectorAll(".mantine-Card-root")).toHaveLength(2);
|
||||
expect(container.textContent).not.toContain("RUNTIME_RENDER_ERROR");
|
||||
});
|
||||
|
||||
it("recovers when preview data arrives after an initial render error", async () => {
|
||||
const template = "<Stack>{data.items.map((item) => <Text key={item}>{item}</Text>)}</Stack>";
|
||||
await renderDisplay({ template, data: {} });
|
||||
expect(container.textContent).toContain("RUNTIME_RENDER_ERROR");
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
createElement(
|
||||
MantineProvider,
|
||||
null,
|
||||
createElement(CustomJsxDisplay, { data: { template, data: { items: ["Bulbasaur", "Pikachu"] } } }),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain("Bulbasaur");
|
||||
expect(container.textContent).toContain("Pikachu");
|
||||
expect(container.textContent).not.toContain("RUNTIME_RENDER_ERROR");
|
||||
});
|
||||
|
||||
it("blocks script tags and event handlers", async () => {
|
||||
await renderDisplay({
|
||||
template: '<script>document.title = "hacked"</script><button onClick={() => {}}>Click</button><Text>Safe</Text>',
|
||||
@@ -64,6 +171,127 @@ describe("CustomJsxDisplay", () => {
|
||||
expect(container.textContent).toContain("Safe");
|
||||
});
|
||||
|
||||
it("supports bounded collection callbacks and safe string helpers", async () => {
|
||||
await renderDisplay({
|
||||
template:
|
||||
'<Stack>{data.items.filter((item) => item.enabled).slice(0, 2).map((item, i) => <Text key={i}>{String(i + 1) + ". " + item.name.toUpperCase()}</Text>)}</Stack>',
|
||||
data: {
|
||||
items: [
|
||||
{ name: "alpha", enabled: true },
|
||||
{ name: "hidden", enabled: false },
|
||||
{ name: "beta", enabled: true },
|
||||
{ name: "gamma", enabled: true },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain("1. ALPHA");
|
||||
expect(container.textContent).toContain("2. BETA");
|
||||
expect(container.textContent).not.toContain("GAMMA");
|
||||
});
|
||||
|
||||
it("blocks reflective properties assembled with string concatenation", async () => {
|
||||
await renderDisplay({
|
||||
template: '<Text>{data["con" + "structor"]}</Text>',
|
||||
data: { value: 1 },
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain("reflective property");
|
||||
});
|
||||
|
||||
it.each([
|
||||
'<Text>{data["constr\\u0075ctor"]}</Text>',
|
||||
'<Text>{data["__pro" + "to__"]}</Text>',
|
||||
'<Text>{String["b" + "ind"](null)}</Text>',
|
||||
"<Text>{data.items.map.call(null, (item) => item)}</Text>",
|
||||
"<Text>{data.items.map.apply(null, [])}</Text>",
|
||||
])("blocks Unicode, computed, and call/apply reflective escapes", (template) => {
|
||||
expect(() =>
|
||||
renderSafeJsx({
|
||||
template,
|
||||
components: { Text: (() => null) as never },
|
||||
bindings: SAFE_BINDINGS({ items: [1] }),
|
||||
}),
|
||||
).toThrow(/reflective property/);
|
||||
});
|
||||
|
||||
it("never resolves inherited data properties", () => {
|
||||
const inherited = Object.create({ secret: "leak" }) as Record<string, unknown>;
|
||||
inherited.visible = "safe";
|
||||
const bindings = SAFE_BINDINGS(inherited);
|
||||
const data = bindings.data as Record<string, unknown>;
|
||||
|
||||
expect(data.visible).toBe("safe");
|
||||
expect(data.secret).toBeUndefined();
|
||||
expect(Object.getPrototypeOf(data)).toBeNull();
|
||||
});
|
||||
|
||||
it("blocks direct global calls but allows benign text containing fetch", async () => {
|
||||
await renderDisplay({ template: "<Text>fetch is available through controlled requests</Text>", data: {} });
|
||||
expect(container.textContent).toContain("fetch is available through controlled requests");
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
createElement(
|
||||
MantineProvider,
|
||||
null,
|
||||
createElement(CustomJsxDisplay, { data: { template: '<Text>{fetch("/private")}</Text>', data: {} } }),
|
||||
),
|
||||
);
|
||||
});
|
||||
expect(container.textContent).toContain("Unknown binding: fetch");
|
||||
});
|
||||
|
||||
it("strips polymorphic, callback, unsafe URL, and escaping style props", () => {
|
||||
const props = sanitizeCustomJsxProps({
|
||||
component: "iframe",
|
||||
renderRoot: "iframe",
|
||||
withinPortal: true,
|
||||
onClick: () => undefined,
|
||||
href: "javascript:alert(1)",
|
||||
style: { position: "fixed", zIndex: 999_999, flex: 1, color: "red" },
|
||||
});
|
||||
|
||||
expect(props).toEqual({ style: { flex: 1, color: "red" } });
|
||||
});
|
||||
|
||||
it("enforces collection and template length budgets", () => {
|
||||
const bindings = SAFE_BINDINGS({ items: [1, 2, 3] });
|
||||
|
||||
expect(() =>
|
||||
renderSafeJsx({
|
||||
template: "<Stack>{data.items.map((item) => <Text>{item}</Text>)}</Stack>",
|
||||
components: {
|
||||
Text: ((props: { children?: unknown }) => props.children) as never,
|
||||
Stack: (() => null) as never,
|
||||
},
|
||||
bindings,
|
||||
budgets: { maxCollectionItems: 2 },
|
||||
}),
|
||||
).toThrow(/collection limit/);
|
||||
|
||||
expect(() => renderSafeJsx({ template: " ".repeat(50_001), components: {}, bindings: {} })).toThrow(
|
||||
new SafeJsxError("Template exceeds the 50000 character limit"),
|
||||
);
|
||||
|
||||
expect(() =>
|
||||
renderSafeJsx({
|
||||
template: '<Text>{"x".padStart(1000, "x")}</Text>',
|
||||
components: { Text: (() => null) as never },
|
||||
bindings: {},
|
||||
budgets: { maxStringLength: 10 },
|
||||
}),
|
||||
).toThrow(/string length limit/);
|
||||
|
||||
expect(() =>
|
||||
renderSafeJsx({
|
||||
template: "<B/>".repeat(10_000),
|
||||
components: { B: (() => null) as never },
|
||||
bindings: {},
|
||||
}),
|
||||
).toThrow("Template exceeded the rendered node limit (10000)");
|
||||
});
|
||||
|
||||
describe("safe bindings security", () => {
|
||||
it("exposes wrapper functions, not native constructors", () => {
|
||||
const bindings = SAFE_BINDINGS({ value: 7 });
|
||||
@@ -153,6 +381,17 @@ describe("CustomJsxDisplay", () => {
|
||||
expect(math.abs(-5)).toBe(5);
|
||||
});
|
||||
|
||||
it("Date helpers only return primitives and getDay returns the weekday", () => {
|
||||
const bindings = SAFE_BINDINGS({});
|
||||
const date = bindings.Date as {
|
||||
create: (value: string) => number;
|
||||
getDay: (value: string) => number;
|
||||
};
|
||||
|
||||
expect(typeof date.create("2024-01-07T12:00:00Z")).toBe("number");
|
||||
expect(date.getDay("2024-01-07T12:00:00Z")).toBe(0);
|
||||
});
|
||||
|
||||
it("JSON.stringify works correctly", () => {
|
||||
const bindings = SAFE_BINDINGS({});
|
||||
const json = bindings.JSON as { stringify: (v: unknown) => string };
|
||||
@@ -176,3 +415,209 @@ describe("CustomJsxDisplay", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
describe("Custom JSX component registry", () => {
|
||||
it.each([
|
||||
["@mantine/core", MantineCore],
|
||||
["@mantine/charts", MantineCharts],
|
||||
["@mantine/dates", MantineDates],
|
||||
] as const)("classifies every component-like %s export", (packageName, moduleExports) => {
|
||||
const classified = new Set(
|
||||
customJsxComponentRegistry
|
||||
.filter((component) => component.package === packageName)
|
||||
.flatMap(({ name }) => [name, name.replaceAll(".", "")]),
|
||||
);
|
||||
const unclassified = Object.entries(moduleExports)
|
||||
.filter(isComponentLikeExport)
|
||||
.map(([name]) => name)
|
||||
.filter((name) => !classified.has(name));
|
||||
|
||||
expect(unclassified, `Unclassified ${packageName} component exports`).toEqual([]);
|
||||
});
|
||||
|
||||
it("has a runtime implementation for every enabled registry component", () => {
|
||||
const missing = enabledCustomJsxComponents
|
||||
.map(({ name }) => name)
|
||||
.filter((name) => !Object.hasOwn(CUSTOM_WIDGET_COMPONENTS, name));
|
||||
|
||||
expect(missing, "Enabled components without a runtime implementation").toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
const renderTemplate = (template: string, data: Record<string, unknown> = {}) =>
|
||||
renderSafeJsx({ template, components: CUSTOM_WIDGET_COMPONENTS, bindings: SAFE_BINDINGS(data) });
|
||||
|
||||
describe("new collection/string methods", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root?.unmount());
|
||||
container?.remove();
|
||||
});
|
||||
|
||||
const renderAndGetText = async (template: string, data: Record<string, unknown> = {}) => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(createElement(MantineProvider, null, createElement(CustomJsxDisplay, { data: { template, data } })));
|
||||
});
|
||||
return container.textContent ?? "";
|
||||
};
|
||||
|
||||
it("find returns the first matching item", async () => {
|
||||
const text = await renderAndGetText(`<Text>{data.items.find((x) => x.id === 2).name}</Text>`, {
|
||||
items: [
|
||||
{ id: 1, name: "a" },
|
||||
{ id: 2, name: "b" },
|
||||
],
|
||||
});
|
||||
expect(text).toContain("b");
|
||||
});
|
||||
|
||||
it("findIndex returns correct index", async () => {
|
||||
const text = await renderAndGetText(`<Text>{data.items.findIndex((x) => x > 5)}</Text>`, { items: [1, 3, 7, 9] });
|
||||
expect(text).toContain("2");
|
||||
});
|
||||
|
||||
it("some returns boolean", async () => {
|
||||
const text = await renderAndGetText(`<Text>{data.items.some((x) => x > 5) ? "yes" : "no"}</Text>`, {
|
||||
items: [1, 2, 3],
|
||||
});
|
||||
expect(text).toContain("no");
|
||||
});
|
||||
|
||||
it("every returns boolean", async () => {
|
||||
const text = await renderAndGetText(`<Text>{data.items.every((x) => x > 0) ? "all" : "nope"}</Text>`, {
|
||||
items: [1, 2, 3],
|
||||
});
|
||||
expect(text).toContain("all");
|
||||
});
|
||||
|
||||
it("sort with comparator returns stable order on NaN", async () => {
|
||||
const text = await renderAndGetText(
|
||||
`<Text>{data.items.sort((a, b) => a.v - b.v).map((x) => x.n).join(",")}</Text>`,
|
||||
{ items: [{ n: "b", v: 2 }, { n: "a", v: 1 }, { n: "c" }] },
|
||||
);
|
||||
expect(text).toContain("a,");
|
||||
expect(text).toContain("b");
|
||||
});
|
||||
|
||||
it("reduce accumulates values", async () => {
|
||||
const text = await renderAndGetText(`<Text>{data.nums.reduce((acc, n) => acc + n, 0)}</Text>`, {
|
||||
nums: [1, 2, 3],
|
||||
});
|
||||
expect(text).toContain("6");
|
||||
});
|
||||
|
||||
it("at returns element by negative index", async () => {
|
||||
const text = await renderAndGetText(`<Text>{data.items.at(-1)}</Text>`, { items: ["a", "b", "c"] });
|
||||
expect(text).toContain("c");
|
||||
});
|
||||
|
||||
it("flat(2) throws SafeJsxError", () => {
|
||||
expect(() => renderTemplate(`<Text>{data.items.flat(2)}</Text>`, { items: [[1, [2]]] })).toThrow(SafeJsxError);
|
||||
});
|
||||
|
||||
it("flat(1) flattens one level", async () => {
|
||||
const text = await renderAndGetText(`<Text>{data.items.flat().join(",")}</Text>`, { items: [[1, 2], [3]] });
|
||||
expect(text).toContain("1,2,3");
|
||||
});
|
||||
|
||||
it("reverse returns reversed copy", async () => {
|
||||
const text = await renderAndGetText(`<Text>{data.items.reverse().join(",")}</Text>`, { items: [1, 2, 3] });
|
||||
expect(text).toContain("3,2,1");
|
||||
});
|
||||
|
||||
it("string replaceAll works", async () => {
|
||||
const text = await renderAndGetText(`<Text>{data.s.replaceAll("o", "0")}</Text>`, { s: "foobar" });
|
||||
expect(text).toContain("f00bar");
|
||||
});
|
||||
|
||||
it("string replaceAll with empty search is a no-op", async () => {
|
||||
const text = await renderAndGetText(`<Text>{data.s.replaceAll("", "x")}</Text>`, { s: "abc" });
|
||||
expect(text).toContain("abc");
|
||||
});
|
||||
|
||||
it("string repeat caps at budget", async () => {
|
||||
const text = await renderAndGetText(`<Text>{data.s.repeat(3)}</Text>`, { s: "hi" });
|
||||
expect(text).toContain("hihihi");
|
||||
});
|
||||
|
||||
it("string indexOf and lastIndexOf return positions", async () => {
|
||||
const text = await renderAndGetText(`<Text>{data.s.indexOf("l")}-{data.s.lastIndexOf("l")}</Text>`, {
|
||||
s: "hello",
|
||||
});
|
||||
expect(text).toContain("2-3");
|
||||
});
|
||||
|
||||
it("string trimStart and trimEnd work", async () => {
|
||||
const text = await renderAndGetText(`<Text>{data.s.trimStart().trimEnd()}</Text>`, { s: " hi " });
|
||||
expect(text).toContain("hi");
|
||||
});
|
||||
|
||||
it("filter(Boolean) removes falsy values", async () => {
|
||||
const text = await renderAndGetText(`<Text>{data.items.filter(Boolean).join(",")}</Text>`, {
|
||||
items: ["a", "", "b", null, "c"],
|
||||
});
|
||||
expect(text).toContain("a,b,c");
|
||||
});
|
||||
|
||||
it("pop returns last element", async () => {
|
||||
const text = await renderAndGetText(`<Text>{data.url.split("/").filter(Boolean).pop()}</Text>`, {
|
||||
url: "https://pokeapi.co/api/v2/pokemon/1/",
|
||||
});
|
||||
expect(text).toContain("1");
|
||||
});
|
||||
|
||||
it("String(n).padStart works for number formatting", async () => {
|
||||
const text = await renderAndGetText(`<Text>{String(data.n + 1).padStart(4, "0")}</Text>`, { n: 5 });
|
||||
expect(text).toContain("0006");
|
||||
});
|
||||
|
||||
it("number arithmetic in string concatenation", async () => {
|
||||
const text = await renderAndGetText(`<Text>{"https://example.com/" + (data.index + 1) + ".png"}</Text>`, {
|
||||
index: 3,
|
||||
});
|
||||
expect(text).toContain("https://example.com/4.png");
|
||||
});
|
||||
});
|
||||
|
||||
describe("shared Custom JSX examples", () => {
|
||||
const sampleData: Record<string, unknown> = {
|
||||
name: "Server A",
|
||||
status: "online",
|
||||
value: 42,
|
||||
count: 1351,
|
||||
results: [{ name: "bulbasaur" }, { name: "ivysaur" }],
|
||||
holdings: [{ symbol: "HOMR", price: 42.5, change: 1.2, history: [38, 40, 42.5] }],
|
||||
timeline: [
|
||||
{ date: "2026-07-11", value: 40 },
|
||||
{ date: "2026-07-12", value: 42.5 },
|
||||
],
|
||||
albumArt: "https://example.com/cover.jpg",
|
||||
album: "Album",
|
||||
title: "Track",
|
||||
artist: "Artist",
|
||||
progress: 55,
|
||||
playing: true,
|
||||
shuffle: false,
|
||||
state: "on",
|
||||
};
|
||||
|
||||
it.each(customJsxExamples)("parses and creates the $id renderer tree", (example) => {
|
||||
const rendered = renderSafeJsx({
|
||||
template: example.widget.template,
|
||||
components: CUSTOM_WIDGET_COMPONENTS,
|
||||
bindings: {
|
||||
...SAFE_BINDINGS(sampleData),
|
||||
status: {},
|
||||
options: getCustomWidgetDefaultOptions(example.widget.options ?? {}),
|
||||
inputs: {},
|
||||
},
|
||||
});
|
||||
|
||||
expect(rendered.warnings).toEqual([]);
|
||||
expect(rendered.node).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,74 +1,92 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import JsxParser from "react-jsx-parser";
|
||||
import { Alert, Stack, Text } from "@mantine/core";
|
||||
import { IconAlertTriangle } from "@tabler/icons-react";
|
||||
|
||||
import { WHITELISTED_COMPONENTS, SAFE_BINDINGS } from "./jsx-whitelist";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import type { CustomWidgetPublishedQueryState } from "@homarr/custom-widgets/runtime";
|
||||
import { CustomJsxRenderer, parseRequestCapabilities } from "@homarr/custom-widgets/runtime";
|
||||
|
||||
const MAX_PARSE_ERRORS = 5;
|
||||
import { createCustomWidgetComponents, SAFE_BINDINGS } from "./jsx-components";
|
||||
import { InactiveWidgetDefinitionProvider, WidgetDefinitionProvider } from "./widget-definition-context";
|
||||
|
||||
function appendParseError(prev: string[], message: string): string[] {
|
||||
if (prev.length >= MAX_PARSE_ERRORS) return prev;
|
||||
if (prev.includes(message)) return prev;
|
||||
return [...prev, message];
|
||||
}
|
||||
export { CUSTOM_JSX_METHOD_COLORS } from "@homarr/custom-widgets/runtime";
|
||||
|
||||
export default function CustomJsxDisplay({ data }: { data: Record<string, unknown> }) {
|
||||
const template = String(data.template ?? "");
|
||||
const apiData = data.data;
|
||||
const [parseErrors, setParseErrors] = useState<string[]>([]);
|
||||
const bindings = useMemo(() => SAFE_BINDINGS(apiData), [apiData]);
|
||||
|
||||
useEffect(() => {
|
||||
setParseErrors([]);
|
||||
}, [template, bindings]);
|
||||
|
||||
const handleError = useCallback((error: Error) => {
|
||||
setParseErrors((prev) => appendParseError(prev, error.message));
|
||||
const t = useScopedI18n("widget.customApi.customJsx");
|
||||
const capabilities = useMemo(() => parseRequestCapabilities(data.requestCapabilities), [data.requestCapabilities]);
|
||||
const components = useMemo(() => createCustomWidgetComponents({ copy: t("copy"), copied: t("copied") }), [t]);
|
||||
const [queryState, setQueryState] = useState<Record<string, CustomWidgetPublishedQueryState>>({});
|
||||
useEffect(
|
||||
() => setQueryState({}),
|
||||
[data.widgetDefinitionId, data.widgetItemId, data.previewSessionId, data.template],
|
||||
);
|
||||
const publishQueryState = useCallback((requestId: string, value: CustomWidgetPublishedQueryState | null) => {
|
||||
setQueryState((current) => {
|
||||
if (!value) {
|
||||
if (!Object.hasOwn(current, requestId)) return current;
|
||||
const next = { ...current };
|
||||
delete next[requestId];
|
||||
return next;
|
||||
}
|
||||
const previous = current[requestId];
|
||||
if (previous && previous.data === value.data && sameStatus(previous.status, value.status)) return current;
|
||||
return { ...current, [requestId]: value };
|
||||
});
|
||||
}, []);
|
||||
|
||||
if (!template.trim()) {
|
||||
const baseData = isRecord(data.data) ? data.data : {};
|
||||
const baseStatus = isRecord(data.status) ? data.status : {};
|
||||
const renderer = (
|
||||
<CustomJsxRenderer
|
||||
template={String(data.template ?? "")}
|
||||
data={{ ...baseData, ...Object.fromEntries(Object.entries(queryState).map(([id, value]) => [id, value.data])) }}
|
||||
status={{
|
||||
...baseStatus,
|
||||
...Object.fromEntries(Object.entries(queryState).map(([id, value]) => [id, value.status])),
|
||||
}}
|
||||
options={isRecord(data.options) ? data.options : {}}
|
||||
components={components}
|
||||
createBindings={SAFE_BINDINGS}
|
||||
messages={{
|
||||
noTemplate: t("noTemplate"),
|
||||
templateWarnings: (count) => t("templateWarnings", { count: String(count) }),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
const definitionId = typeof data.widgetDefinitionId === "string" ? data.widgetDefinitionId : undefined;
|
||||
const itemId = typeof data.widgetItemId === "string" ? data.widgetItemId : undefined;
|
||||
const previewSessionId = typeof data.previewSessionId === "string" ? data.previewSessionId : undefined;
|
||||
if (!itemId && !previewSessionId) {
|
||||
return (
|
||||
<Alert color="gray" variant="light" p="xs">
|
||||
<Text size="xs" c="dimmed">
|
||||
No JSX template configured
|
||||
</Text>
|
||||
</Alert>
|
||||
<InactiveWidgetDefinitionProvider definitionId={definitionId} isEditMode={data.isEditMode === true}>
|
||||
{renderer}
|
||||
</InactiveWidgetDefinitionProvider>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap={0} h="100%">
|
||||
<JsxParser
|
||||
jsx={template}
|
||||
components={WHITELISTED_COMPONENTS as never}
|
||||
bindings={bindings}
|
||||
disableKeyGeneration
|
||||
componentsOnly
|
||||
allowUnknownElements={false}
|
||||
blacklistedAttrs={[/^on.+/i, /^dangerously/i]}
|
||||
blacklistedTags={["script", "iframe", "object", "embed", "form", "style", "link", "meta", "base"]}
|
||||
onError={handleError}
|
||||
renderError={({ error }) => (
|
||||
<Alert color="red" variant="light" icon={<IconAlertTriangle size={16} />} p="xs">
|
||||
<Text size="xs">{String(error)}</Text>
|
||||
</Alert>
|
||||
)}
|
||||
/>
|
||||
{parseErrors.length > 0 && (
|
||||
<Alert color="yellow" variant="light" p="xs" mt="xs">
|
||||
<Text size="xs" c="dimmed">
|
||||
{parseErrors.length} template warning(s):
|
||||
</Text>
|
||||
{parseErrors.map((msg) => (
|
||||
<Text key={msg} size="xs" c="dimmed" style={{ fontFamily: "monospace" }}>
|
||||
{msg}
|
||||
</Text>
|
||||
))}
|
||||
</Alert>
|
||||
)}
|
||||
</Stack>
|
||||
<WidgetDefinitionProvider
|
||||
definitionId={definitionId}
|
||||
itemId={itemId}
|
||||
previewSessionId={previewSessionId}
|
||||
previewLiveActions={data.previewLiveActions === true}
|
||||
queriesDisabled={data.queriesDisabled === true}
|
||||
isEditMode={data.isEditMode === true}
|
||||
requestCapabilities={capabilities}
|
||||
setQueryState={publishQueryState}
|
||||
>
|
||||
{renderer}
|
||||
</WidgetDefinitionProvider>
|
||||
);
|
||||
}
|
||||
function sameStatus(left: CustomWidgetPublishedQueryState["status"], right: CustomWidgetPublishedQueryState["status"]) {
|
||||
return (
|
||||
left.loading === right.loading &&
|
||||
left.ok === right.ok &&
|
||||
left.status === right.status &&
|
||||
left.statusText === right.statusText &&
|
||||
left.error === right.error
|
||||
);
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export { extractDisplayData } from "@homarr/api/custom-widget/display-data";
|
||||
@@ -9,6 +9,8 @@ export const { definition, componentLoader } = createWidgetDefinition("customApi
|
||||
createOptions() {
|
||||
return optionsBuilder.from((factory) => ({
|
||||
definitionId: factory.customWidgetSelect({ defaultValue: "" }),
|
||||
configuration: factory.customWidgetConfiguration(),
|
||||
configurationVersion: factory.internal({ defaultValue: 1 }),
|
||||
refreshInterval: factory.slider({
|
||||
defaultValue: 30,
|
||||
validate: z.number().min(1).max(3600),
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { ComponentType } from "react";
|
||||
|
||||
import { createCustomJsxBindings, createCustomJsxComponents } from "@homarr/custom-widgets/jsx";
|
||||
|
||||
import { SafeTablerIcon } from "./jsx-icon-adapter";
|
||||
|
||||
// Labels are replaced by the localized runtime adapter in the renderer. These
|
||||
// fallbacks are used only in isolated component-map tests.
|
||||
export const createCustomWidgetComponents = (copyLabels: { copy: string; copied: string }) =>
|
||||
createCustomJsxComponents({ TablerIcon: SafeTablerIcon as ComponentType<never>, copyLabels });
|
||||
|
||||
export const SAFE_BINDINGS = createCustomJsxBindings;
|
||||
@@ -0,0 +1,248 @@
|
||||
import {
|
||||
IconActivity,
|
||||
IconAlertCircle,
|
||||
IconAlertTriangle,
|
||||
IconApi,
|
||||
IconArrowDown,
|
||||
IconArrowLeft,
|
||||
IconArrowRight,
|
||||
IconArrowUp,
|
||||
IconBell,
|
||||
IconBluetooth,
|
||||
IconBolt,
|
||||
IconBookmark,
|
||||
IconBrandDocker,
|
||||
IconBug,
|
||||
IconCalendar,
|
||||
IconCamera,
|
||||
IconChartBar,
|
||||
IconChartLine,
|
||||
IconChartPie,
|
||||
IconCheck,
|
||||
IconChevronDown,
|
||||
IconChevronLeft,
|
||||
IconChevronRight,
|
||||
IconChevronUp,
|
||||
IconCircleCheck,
|
||||
IconCircleX,
|
||||
IconClipboard,
|
||||
IconClock,
|
||||
IconCloud,
|
||||
IconCloudOff,
|
||||
IconCode,
|
||||
IconCompass,
|
||||
IconCopy,
|
||||
IconCpu,
|
||||
IconDatabase,
|
||||
IconDeviceDesktop,
|
||||
IconDeviceMobile,
|
||||
IconDownload,
|
||||
IconDroplet,
|
||||
IconEdit,
|
||||
IconExternalLink,
|
||||
IconEye,
|
||||
IconEyeOff,
|
||||
IconFile,
|
||||
IconFlag,
|
||||
IconFlame,
|
||||
IconFolder,
|
||||
IconGauge,
|
||||
IconGitBranch,
|
||||
IconHeart,
|
||||
IconHistory,
|
||||
IconHome,
|
||||
IconInfoCircle,
|
||||
IconKey,
|
||||
IconLink,
|
||||
IconLoader,
|
||||
IconLock,
|
||||
IconLockOpen,
|
||||
IconMail,
|
||||
IconMap,
|
||||
IconMapPin,
|
||||
IconMinus,
|
||||
IconMoon,
|
||||
IconMusic,
|
||||
IconNetwork,
|
||||
IconPhoto,
|
||||
IconPokeball,
|
||||
IconPin,
|
||||
IconPlayerPause,
|
||||
IconPlayerPlay,
|
||||
IconPlayerSkipBack,
|
||||
IconPlayerSkipForward,
|
||||
IconPlayerStop,
|
||||
IconPlus,
|
||||
IconPower,
|
||||
IconProgress,
|
||||
IconHeartbeat,
|
||||
IconRefresh,
|
||||
IconReload,
|
||||
IconSearch,
|
||||
IconServer,
|
||||
IconSettings,
|
||||
IconShield,
|
||||
IconStar,
|
||||
IconSun,
|
||||
IconTerminal,
|
||||
IconThermometer,
|
||||
IconTrash,
|
||||
IconTrendingDown,
|
||||
IconTrendingUp,
|
||||
IconUpload,
|
||||
IconUser,
|
||||
IconUsers,
|
||||
IconVideo,
|
||||
IconVolume,
|
||||
IconVolume2,
|
||||
IconVolumeOff,
|
||||
IconWifi,
|
||||
IconWifiOff,
|
||||
IconWorld,
|
||||
IconX,
|
||||
IconSpeedboat,
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
function tablerIconExportToKey(exportName: string): string {
|
||||
return exportName
|
||||
.slice(4)
|
||||
.replace(/([a-z0-9])([A-Z])/g, "$1-$2")
|
||||
.replace(/([A-Z]+)([A-Z][a-z])/g, "$1-$2")
|
||||
.replace(/([A-Za-z])(\d)/g, "$1-$2")
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
const TABLER_ICON_IMPORTS = {
|
||||
IconCheck,
|
||||
IconX,
|
||||
IconPlus,
|
||||
IconMinus,
|
||||
IconArrowUp,
|
||||
IconArrowDown,
|
||||
IconArrowLeft,
|
||||
IconArrowRight,
|
||||
IconChevronUp,
|
||||
IconChevronDown,
|
||||
IconChevronLeft,
|
||||
IconChevronRight,
|
||||
IconHome,
|
||||
IconSettings,
|
||||
IconUser,
|
||||
IconUsers,
|
||||
IconSearch,
|
||||
IconBell,
|
||||
IconMail,
|
||||
IconHeart,
|
||||
IconStar,
|
||||
IconBookmark,
|
||||
IconBrandDocker,
|
||||
IconFlag,
|
||||
IconPin,
|
||||
IconPlayerPlay,
|
||||
IconPlayerPause,
|
||||
IconPlayerStop,
|
||||
IconPlayerSkipForward,
|
||||
IconPlayerSkipBack,
|
||||
IconVolume,
|
||||
IconVolumeOff,
|
||||
IconVolume2,
|
||||
IconWifi,
|
||||
IconWifiOff,
|
||||
IconBluetooth,
|
||||
IconCloud,
|
||||
IconCloudOff,
|
||||
IconServer,
|
||||
IconDatabase,
|
||||
IconCpu,
|
||||
IconDeviceDesktop,
|
||||
IconDeviceMobile,
|
||||
IconFolder,
|
||||
IconFile,
|
||||
IconDownload,
|
||||
IconUpload,
|
||||
IconRefresh,
|
||||
IconReload,
|
||||
IconPower,
|
||||
IconBolt,
|
||||
IconFlame,
|
||||
IconDroplet,
|
||||
IconSun,
|
||||
IconMoon,
|
||||
IconEye,
|
||||
IconEyeOff,
|
||||
IconLock,
|
||||
IconLockOpen,
|
||||
IconShield,
|
||||
IconKey,
|
||||
IconAlertTriangle,
|
||||
IconAlertCircle,
|
||||
IconInfoCircle,
|
||||
IconCircleCheck,
|
||||
IconCircleX,
|
||||
IconTrash,
|
||||
IconEdit,
|
||||
IconCopy,
|
||||
IconClipboard,
|
||||
IconExternalLink,
|
||||
IconLink,
|
||||
IconPhoto,
|
||||
IconPokeball,
|
||||
IconCamera,
|
||||
IconMusic,
|
||||
IconVideo,
|
||||
IconCalendar,
|
||||
IconClock,
|
||||
IconHistory,
|
||||
IconMap,
|
||||
IconMapPin,
|
||||
IconCompass,
|
||||
IconTerminal,
|
||||
IconCode,
|
||||
IconBug,
|
||||
IconGitBranch,
|
||||
IconNetwork,
|
||||
IconWorld,
|
||||
IconApi,
|
||||
IconChartBar,
|
||||
IconChartLine,
|
||||
IconChartPie,
|
||||
IconTrendingUp,
|
||||
IconTrendingDown,
|
||||
IconActivity,
|
||||
IconProgress,
|
||||
IconLoader,
|
||||
IconThermometer,
|
||||
IconGauge,
|
||||
IconSpeedboat,
|
||||
IconHeartbeat,
|
||||
} as const;
|
||||
|
||||
const TABLER_ICON_MAP: Record<string, ComponentType<never>> = {
|
||||
...Object.fromEntries(
|
||||
Object.entries(TABLER_ICON_IMPORTS).map(([exportName, IconComponent]) => [
|
||||
tablerIconExportToKey(exportName),
|
||||
IconComponent,
|
||||
]),
|
||||
),
|
||||
pulse: IconHeartbeat,
|
||||
};
|
||||
|
||||
export const SAFE_TABLER_ICON_NAMES = Object.freeze(Object.keys(TABLER_ICON_MAP).toSorted());
|
||||
|
||||
function isValidIconProp(value: unknown): value is string | number {
|
||||
return typeof value === "number" || typeof value === "string";
|
||||
}
|
||||
|
||||
export function SafeTablerIcon(props: Record<string, unknown>) {
|
||||
const name = props.name;
|
||||
if (typeof name !== "string") return null;
|
||||
const IconComponent = TABLER_ICON_MAP[name];
|
||||
if (!IconComponent) return null;
|
||||
const iconProps: Record<string, unknown> = {};
|
||||
if (isValidIconProp(props.size)) iconProps.size = props.size;
|
||||
if (typeof props.color === "string") iconProps.color = props.color;
|
||||
if (isValidIconProp(props.stroke)) iconProps.stroke = props.stroke;
|
||||
return createElement(IconComponent as never, iconProps);
|
||||
}
|
||||
import { createElement } from "react";
|
||||
import type { ComponentType } from "react";
|
||||
@@ -1,228 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { Children, isValidElement, useEffect, useRef, useState } from "react";
|
||||
import { ActionIcon, Badge, Collapse, Group, Stack, Tabs, Text, UnstyledButton } from "@mantine/core";
|
||||
import { IconChevronDown, IconChevronLeft, IconChevronRight, IconChevronUp } from "@tabler/icons-react";
|
||||
|
||||
interface PaginatedListProps {
|
||||
children: ReactNode;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export function PaginatedList({ children, pageSize = 6 }: PaginatedListProps) {
|
||||
const items = Children.toArray(children);
|
||||
const totalPages = Math.max(1, Math.ceil(items.length / pageSize));
|
||||
const [page, setPage] = useState(0);
|
||||
const prevCountRef = useRef(items.length);
|
||||
|
||||
useEffect(() => {
|
||||
if (items.length !== prevCountRef.current) {
|
||||
setPage(0);
|
||||
prevCountRef.current = items.length;
|
||||
}
|
||||
}, [items.length]);
|
||||
|
||||
const clampedPage = Math.min(page, totalPages - 1);
|
||||
const start = clampedPage * pageSize;
|
||||
const visible = items.slice(start, start + pageSize);
|
||||
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
<div>{visible}</div>
|
||||
{totalPages > 1 && (
|
||||
<Group justify="center" gap="xs">
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
disabled={clampedPage === 0}
|
||||
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
||||
>
|
||||
<IconChevronLeft size={14} />
|
||||
</ActionIcon>
|
||||
<Text size="xs" c="dimmed">
|
||||
{clampedPage + 1} / {totalPages}
|
||||
</Text>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
disabled={clampedPage >= totalPages - 1}
|
||||
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
|
||||
>
|
||||
<IconChevronRight size={14} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
interface TabsContainerProps {
|
||||
children: ReactNode;
|
||||
defaultTab?: string;
|
||||
}
|
||||
|
||||
function isTabPanel(child: ReactNode): child is React.ReactElement<TabPanelProps> {
|
||||
return isValidElement<TabPanelProps>(child) && child.type === TabPanel && typeof child.props.value === "string";
|
||||
}
|
||||
|
||||
export function TabsContainer({ children, defaultTab }: TabsContainerProps) {
|
||||
const panels = Children.toArray(children).filter(isTabPanel);
|
||||
const firstPanel = panels[0];
|
||||
const panelValues = new Set(panels.map((p) => p.props.value));
|
||||
const resolvedDefault = defaultTab && panelValues.has(defaultTab) && defaultTab;
|
||||
const initialTab = resolvedDefault || firstPanel?.props.value || null;
|
||||
const [activeTab, setActiveTab] = useState<string | null>(initialTab);
|
||||
|
||||
return (
|
||||
<Tabs value={activeTab} onChange={setActiveTab}>
|
||||
<Tabs.List>
|
||||
{panels.map((panel) => (
|
||||
<Tabs.Tab key={panel.props.value} value={panel.props.value}>
|
||||
{panel.props.label ?? panel.props.value}
|
||||
</Tabs.Tab>
|
||||
))}
|
||||
</Tabs.List>
|
||||
{panels.map((panel) => (
|
||||
<Tabs.Panel key={panel.props.value} value={panel.props.value} pt="sm">
|
||||
{panel.props.children}
|
||||
</Tabs.Panel>
|
||||
))}
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
|
||||
interface TabPanelProps {
|
||||
children: ReactNode;
|
||||
value: string;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export function TabPanel({ children }: TabPanelProps) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
interface CollapsibleProps {
|
||||
children: ReactNode;
|
||||
title: string;
|
||||
defaultOpen?: boolean;
|
||||
}
|
||||
|
||||
const COLLAPSE_CHEVRONS = {
|
||||
closed: IconChevronDown,
|
||||
open: IconChevronUp,
|
||||
} as const;
|
||||
|
||||
const COLLAPSE_STATE: Record<0 | 1, keyof typeof COLLAPSE_CHEVRONS> = {
|
||||
0: "closed",
|
||||
1: "open",
|
||||
};
|
||||
|
||||
export function Collapsible({ children, title, defaultOpen = false }: CollapsibleProps) {
|
||||
const [opened, setOpened] = useState(defaultOpen);
|
||||
const Chevron = COLLAPSE_CHEVRONS[COLLAPSE_STATE[Number(opened) as 0 | 1]];
|
||||
|
||||
return (
|
||||
<Stack gap={0}>
|
||||
<UnstyledButton onClick={() => setOpened((o) => !o)} py={4}>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Chevron size={14} />
|
||||
<Text size="sm" fw={600}>
|
||||
{title}
|
||||
</Text>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
<Collapse expanded={opened}>{children}</Collapse>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
interface StatBarProps {
|
||||
value: number;
|
||||
max?: number;
|
||||
label?: string;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
function calcPct(value: number, max: number): number {
|
||||
if (max <= 0) return 0;
|
||||
return Math.min((value / max) * 100, 100);
|
||||
}
|
||||
|
||||
export function StatBar({ value, max = 100, label, color = "blue" }: StatBarProps) {
|
||||
const pct = calcPct(value, max);
|
||||
|
||||
return (
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
{label && (
|
||||
<Text size="xs" w={80} ta="right" c="dimmed">
|
||||
{label}
|
||||
</Text>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
background: "var(--mantine-color-default-border)",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: `${pct}%`,
|
||||
height: "100%",
|
||||
borderRadius: 4,
|
||||
background: `var(--mantine-color-${color}-6)`,
|
||||
transition: "width 0.3s ease",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Text size="xs" w={30} c="dimmed">
|
||||
{value}
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
interface TypeBadgeProps {
|
||||
type: string;
|
||||
size?: "xs" | "sm" | "md" | "lg" | "xl";
|
||||
}
|
||||
|
||||
const TYPE_COLORS: Record<string, string> = {
|
||||
normal: "gray",
|
||||
fire: "red",
|
||||
water: "blue",
|
||||
electric: "yellow",
|
||||
grass: "green",
|
||||
ice: "cyan",
|
||||
fighting: "orange",
|
||||
poison: "grape",
|
||||
ground: "yellow",
|
||||
flying: "indigo",
|
||||
psychic: "pink",
|
||||
bug: "lime",
|
||||
rock: "orange",
|
||||
ghost: "violet",
|
||||
dragon: "indigo",
|
||||
dark: "dark",
|
||||
steel: "gray",
|
||||
fairy: "pink",
|
||||
};
|
||||
|
||||
function normalizeType(type: unknown): string {
|
||||
if (typeof type === "string") return type;
|
||||
if (type == null) return "";
|
||||
return String(type);
|
||||
}
|
||||
|
||||
export function TypeBadge({ type, size = "sm" }: TypeBadgeProps) {
|
||||
const typeStr = normalizeType(type);
|
||||
const color = TYPE_COLORS[typeStr.toLowerCase()] ?? "gray";
|
||||
return (
|
||||
<Badge color={color} size={size} variant="filled" tt="capitalize">
|
||||
{typeStr}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
@@ -1,192 +0,0 @@
|
||||
import type { ComponentType } from "react";
|
||||
import { createElement } from "react";
|
||||
import {
|
||||
Stack,
|
||||
Group,
|
||||
Flex,
|
||||
Grid,
|
||||
SimpleGrid,
|
||||
Center,
|
||||
Space,
|
||||
Container,
|
||||
AspectRatio,
|
||||
Text,
|
||||
Title,
|
||||
Code,
|
||||
Highlight,
|
||||
Mark,
|
||||
Kbd,
|
||||
Blockquote,
|
||||
Anchor,
|
||||
NumberFormatter,
|
||||
Badge,
|
||||
Card,
|
||||
Paper,
|
||||
Alert,
|
||||
ThemeIcon,
|
||||
ColorSwatch,
|
||||
Table,
|
||||
List,
|
||||
Timeline,
|
||||
Accordion,
|
||||
Indicator,
|
||||
Pill,
|
||||
Spoiler,
|
||||
Progress,
|
||||
RingProgress,
|
||||
Skeleton,
|
||||
Loader,
|
||||
Image,
|
||||
Avatar,
|
||||
BackgroundImage,
|
||||
Tooltip,
|
||||
Divider,
|
||||
ScrollArea,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
AreaChart,
|
||||
BarChart,
|
||||
LineChart,
|
||||
DonutChart,
|
||||
PieChart,
|
||||
RadarChart,
|
||||
RadialBarChart,
|
||||
Sparkline,
|
||||
} from "@mantine/charts";
|
||||
|
||||
import { PaginatedList, TabsContainer, TabPanel, Collapsible, StatBar, TypeBadge } from "./jsx-interactive-components";
|
||||
|
||||
const SAFE_URL_PATTERN = /^https?:\/\//i;
|
||||
|
||||
function isSafeUrl(url: unknown): boolean {
|
||||
if (typeof url !== "string") return false;
|
||||
return SAFE_URL_PATTERN.test(url) || url.startsWith("/") || url.startsWith("#");
|
||||
}
|
||||
|
||||
function SafeAnchor(props: Record<string, unknown>) {
|
||||
const href = props.href;
|
||||
const safeHref = href && isSafeUrl(href) ? href : undefined;
|
||||
return createElement(Anchor as never, { ...props, href: safeHref });
|
||||
}
|
||||
|
||||
export const WHITELISTED_COMPONENTS: Record<string, ComponentType<never>> = {
|
||||
Stack,
|
||||
Group,
|
||||
Flex,
|
||||
Grid,
|
||||
"Grid.Col": Grid.Col,
|
||||
SimpleGrid,
|
||||
Center,
|
||||
Space,
|
||||
Container,
|
||||
AspectRatio,
|
||||
Text,
|
||||
Title,
|
||||
Code,
|
||||
Highlight,
|
||||
Mark,
|
||||
Kbd,
|
||||
Blockquote,
|
||||
Anchor: SafeAnchor,
|
||||
NumberFormatter,
|
||||
Badge,
|
||||
Card,
|
||||
"Card.Section": Card.Section,
|
||||
Paper,
|
||||
Alert,
|
||||
ThemeIcon,
|
||||
ColorSwatch,
|
||||
Table,
|
||||
"Table.Thead": Table.Thead,
|
||||
"Table.Tbody": Table.Tbody,
|
||||
"Table.Tr": Table.Tr,
|
||||
"Table.Th": Table.Th,
|
||||
"Table.Td": Table.Td,
|
||||
List,
|
||||
"List.Item": List.Item,
|
||||
Timeline,
|
||||
"Timeline.Item": Timeline.Item,
|
||||
Accordion,
|
||||
"Accordion.Item": Accordion.Item,
|
||||
"Accordion.Control": Accordion.Control,
|
||||
"Accordion.Panel": Accordion.Panel,
|
||||
Indicator,
|
||||
Pill,
|
||||
Spoiler,
|
||||
Progress,
|
||||
"Progress.Section": Progress.Section,
|
||||
RingProgress,
|
||||
Skeleton,
|
||||
Loader,
|
||||
Image,
|
||||
Avatar,
|
||||
"Avatar.Group": Avatar.Group,
|
||||
BackgroundImage,
|
||||
Tooltip,
|
||||
Divider,
|
||||
ScrollArea,
|
||||
AreaChart,
|
||||
BarChart,
|
||||
LineChart,
|
||||
DonutChart,
|
||||
PieChart,
|
||||
RadarChart,
|
||||
RadialBarChart,
|
||||
Sparkline,
|
||||
PaginatedList,
|
||||
TabsContainer,
|
||||
TabPanel,
|
||||
Collapsible,
|
||||
StatBar,
|
||||
TypeBadge,
|
||||
};
|
||||
|
||||
function sanitizeData(obj: unknown): unknown {
|
||||
if (obj === null || obj === undefined) return obj;
|
||||
if (typeof obj !== "object") return obj;
|
||||
if (Array.isArray(obj)) return obj.map(sanitizeData);
|
||||
|
||||
const safe: Record<string, unknown> = Object.create(null);
|
||||
for (const [key, value] of Object.entries(obj as Record<string, unknown>)) {
|
||||
if (key === "constructor" || key === "__proto__" || key === "prototype") continue;
|
||||
safe[key] = sanitizeData(value);
|
||||
}
|
||||
return safe;
|
||||
}
|
||||
|
||||
const safeMath: Record<string, unknown> = Object.create(null);
|
||||
safeMath.round = (v: number) => Math.round(v);
|
||||
safeMath.floor = (v: number) => Math.floor(v);
|
||||
safeMath.ceil = (v: number) => Math.ceil(v);
|
||||
safeMath.abs = (v: number) => Math.abs(v);
|
||||
safeMath.min = (...args: number[]) => Math.min(...args);
|
||||
safeMath.max = (...args: number[]) => Math.max(...args);
|
||||
safeMath.pow = (b: number, e: number) => Math.pow(b, e);
|
||||
safeMath.sqrt = (v: number) => Math.sqrt(v);
|
||||
safeMath.PI = Math.PI;
|
||||
Object.freeze(safeMath);
|
||||
|
||||
const safeJSON: Record<string, unknown> = Object.create(null);
|
||||
safeJSON.stringify = (v: unknown) => JSON.stringify(v);
|
||||
Object.freeze(safeJSON);
|
||||
|
||||
const safeArray: Record<string, unknown> = Object.create(null);
|
||||
safeArray.isArray = (v: unknown) => Array.isArray(v);
|
||||
Object.freeze(safeArray);
|
||||
|
||||
const safeObject: Record<string, unknown> = Object.create(null);
|
||||
safeObject.keys = (v: object) => Object.keys(v);
|
||||
safeObject.values = (v: object) => Object.values(v);
|
||||
safeObject.entries = (v: object) => Object.entries(v);
|
||||
Object.freeze(safeObject);
|
||||
|
||||
export const SAFE_BINDINGS = (apiData: unknown) => ({
|
||||
data: sanitizeData(apiData),
|
||||
String: (v: unknown) => String(v),
|
||||
Number: (v: unknown) => Number(v),
|
||||
Boolean: (v: unknown) => Boolean(v),
|
||||
Math: safeMath,
|
||||
JSON: safeJSON,
|
||||
Array: safeArray,
|
||||
Object: safeObject,
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { isLegacyCustomWidgetMigrationError, isTerminalCustomWidgetDefinitionError } from "./migration-state";
|
||||
|
||||
describe("custom widget migration state", () => {
|
||||
it("recognizes the preserved legacy definition error and stops retries", () => {
|
||||
const error = {
|
||||
message: "LEGACY_CUSTOM_WIDGET_MIGRATION_REQUIRED",
|
||||
data: { code: "PRECONDITION_FAILED" },
|
||||
};
|
||||
|
||||
expect(isLegacyCustomWidgetMigrationError(error)).toBe(true);
|
||||
expect(isTerminalCustomWidgetDefinitionError(error)).toBe(true);
|
||||
});
|
||||
|
||||
it("does not label an invalid v2 configuration as a legacy migration", () => {
|
||||
const error = {
|
||||
message: "Custom widget configuration needs repair",
|
||||
data: { code: "PRECONDITION_FAILED" },
|
||||
};
|
||||
|
||||
expect(isLegacyCustomWidgetMigrationError(error)).toBe(false);
|
||||
expect(isTerminalCustomWidgetDefinitionError(error)).toBe(true);
|
||||
});
|
||||
|
||||
it("allows transient request failures to retry", () => {
|
||||
const error = { message: "Request failed", data: { code: "INTERNAL_SERVER_ERROR" } };
|
||||
|
||||
expect(isLegacyCustomWidgetMigrationError(error)).toBe(false);
|
||||
expect(isTerminalCustomWidgetDefinitionError(error)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
const LEGACY_MIGRATION_ERROR = "LEGACY_CUSTOM_WIDGET_MIGRATION_REQUIRED";
|
||||
const TERMINAL_DEFINITION_CODES = new Set(["NOT_FOUND", "FORBIDDEN", "PRECONDITION_FAILED"]);
|
||||
|
||||
export function isLegacyCustomWidgetMigrationError(error: unknown): boolean {
|
||||
return Boolean(error && typeof error === "object" && "message" in error && error.message === LEGACY_MIGRATION_ERROR);
|
||||
}
|
||||
|
||||
export function isTerminalCustomWidgetDefinitionError(error: unknown): boolean {
|
||||
if (!error || typeof error !== "object" || !("data" in error)) return false;
|
||||
const data = error.data;
|
||||
if (!data || typeof data !== "object" || !("code" in data) || typeof data.code !== "string") return false;
|
||||
return TERMINAL_DEFINITION_CODES.has(data.code);
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { fetchApi, clientApi } from "@homarr/api/client";
|
||||
import type {
|
||||
CustomWidgetPublishedQueryState,
|
||||
CustomJsxRequestCapability,
|
||||
CustomWidgetRuntimeMessages,
|
||||
CustomWidgetRuntimePort,
|
||||
} from "@homarr/custom-widgets/runtime";
|
||||
import { CustomWidgetRuntimeProvider } from "@homarr/custom-widgets/runtime";
|
||||
import { useConfirmModal } from "@homarr/modals";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
interface WidgetDefinitionProviderProps {
|
||||
itemId?: string;
|
||||
definitionId?: string;
|
||||
previewSessionId?: string;
|
||||
previewLiveActions?: boolean;
|
||||
queriesDisabled?: boolean;
|
||||
isEditMode?: boolean;
|
||||
requestCapabilities?: readonly CustomJsxRequestCapability[];
|
||||
setQueryState?(requestId: string, value: CustomWidgetPublishedQueryState | null): void;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const INACTIVE_PORT: CustomWidgetRuntimePort = {
|
||||
query: async () => ({ ok: false, status: 0, data: null, error: "Custom widget unavailable" }),
|
||||
executeAction: async () => ({ ok: false, status: 0, data: null, error: "Custom widget unavailable" }),
|
||||
invalidate: async () => undefined,
|
||||
confirm: async () => false,
|
||||
notify: () => undefined,
|
||||
};
|
||||
|
||||
const useRuntimeMessages = (): CustomWidgetRuntimeMessages => {
|
||||
const t = useScopedI18n("widget.customApi.customJsx");
|
||||
return {
|
||||
requestIdRequired: t("requestIdRequired"),
|
||||
unsavedPreview: t("unsavedPreview"),
|
||||
invalidParams: t("invalidParams"),
|
||||
loadRequest: t("loadRequest"),
|
||||
requestFailed: t("requestFailed"),
|
||||
loading: t("loading"),
|
||||
retry: t("retry"),
|
||||
widgetItemUnavailable: t("widgetItemUnavailable"),
|
||||
actionsDisabledEditMode: t("actionsDisabledEditMode"),
|
||||
actionSimulated: t("actionSimulated"),
|
||||
actionCompleted: t("actionCompleted"),
|
||||
confirmDelete: t("confirmDelete"),
|
||||
toggle: t("toggle"),
|
||||
refresh: t("refresh"),
|
||||
};
|
||||
};
|
||||
|
||||
export function InactiveWidgetDefinitionProvider({
|
||||
definitionId,
|
||||
isEditMode,
|
||||
children,
|
||||
}: Pick<WidgetDefinitionProviderProps, "definitionId" | "isEditMode" | "children">) {
|
||||
const messages = useRuntimeMessages();
|
||||
return (
|
||||
<CustomWidgetRuntimeProvider
|
||||
definitionId={definitionId}
|
||||
isEditMode={isEditMode ?? false}
|
||||
requestCapabilities={[]}
|
||||
port={INACTIVE_PORT}
|
||||
messages={messages}
|
||||
>
|
||||
{children}
|
||||
</CustomWidgetRuntimeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export function WidgetDefinitionProvider(props: WidgetDefinitionProviderProps) {
|
||||
const utils = clientApi.useUtils();
|
||||
const queryClient = useQueryClient();
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const messages = useRuntimeMessages();
|
||||
const port: CustomWidgetRuntimePort = {
|
||||
query: async (input, signal) => {
|
||||
if (input.itemId) {
|
||||
return fetchApi.widget.customApi.queryRequest.query(
|
||||
{ itemId: input.itemId, requestId: input.requestId, params: input.params },
|
||||
{ signal },
|
||||
);
|
||||
}
|
||||
return fetchApi.customWidget.previewQuery.query(
|
||||
{ sessionId: input.previewSessionId ?? "", requestId: input.requestId, params: input.params },
|
||||
{ signal },
|
||||
);
|
||||
},
|
||||
executeAction: (input) =>
|
||||
input.itemId
|
||||
? fetchApi.widget.customApi.executeAction.mutate({
|
||||
itemId: input.itemId,
|
||||
requestId: input.requestId,
|
||||
params: input.params,
|
||||
confirmed: input.confirmed,
|
||||
})
|
||||
: fetchApi.customWidget.previewAction.mutate({
|
||||
sessionId: input.previewSessionId ?? "",
|
||||
requestId: input.requestId,
|
||||
params: input.params,
|
||||
confirmed: input.confirmed,
|
||||
}),
|
||||
invalidate: async ({ itemId, previewSessionId, targets }) => {
|
||||
if (previewSessionId) {
|
||||
await queryClient.invalidateQueries({
|
||||
predicate: (query) => {
|
||||
const key = query.queryKey;
|
||||
return key[0] === "custom-widget" && key[1] === previewSessionId && targets.includes(String(key[2]));
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!itemId) return;
|
||||
if (targets.length === 0) return;
|
||||
const tasks: Promise<unknown>[] = [
|
||||
queryClient.invalidateQueries({
|
||||
predicate: (query) => {
|
||||
const key = query.queryKey;
|
||||
return key[0] === "custom-widget" && key[1] === itemId && targets.includes(String(key[2]));
|
||||
},
|
||||
}),
|
||||
];
|
||||
if (
|
||||
(props.requestCapabilities ?? []).some((request) => request.trigger === "load" && targets.includes(request.id))
|
||||
) {
|
||||
tasks.push(utils.widget.customApi.getData.invalidate({ itemId }));
|
||||
}
|
||||
await Promise.all(tasks);
|
||||
},
|
||||
confirm: ({ title, message, confirmLabel, destructive }) =>
|
||||
new Promise((resolve) => {
|
||||
openConfirmModal({
|
||||
title,
|
||||
children: message,
|
||||
labels: { confirm: confirmLabel },
|
||||
confirmProps: { color: destructive ? "red.9" : "blue" },
|
||||
onConfirm: () => resolve(true),
|
||||
onCancel: () => resolve(false),
|
||||
});
|
||||
}),
|
||||
notify: ({ kind, ...notification }) =>
|
||||
kind === "success" ? showSuccessNotification(notification) : showErrorNotification(notification),
|
||||
};
|
||||
return (
|
||||
<CustomWidgetRuntimeProvider
|
||||
itemId={props.itemId}
|
||||
definitionId={props.definitionId}
|
||||
previewSessionId={props.previewSessionId}
|
||||
previewLiveActions={props.previewLiveActions}
|
||||
queriesDisabled={props.queriesDisabled}
|
||||
isEditMode={props.isEditMode ?? false}
|
||||
requestCapabilities={props.requestCapabilities ?? []}
|
||||
port={port}
|
||||
messages={messages}
|
||||
setQueryState={props.setQueryState}
|
||||
>
|
||||
{props.children}
|
||||
</CustomWidgetRuntimeProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { definition as appDefinition } from "./app";
|
||||
import { definition as beszelSystemGridDefinition } from "./beszel-system-grid";
|
||||
import { definition as beszelSystemTableDefinition } from "./beszel-system-table";
|
||||
import { getWidgetQueryKeys, supportsAdvancedFocus } from "./definition";
|
||||
|
||||
describe("getWidgetQueryKeys", () => {
|
||||
test("falls back to the widget router namespace", () => {
|
||||
expect(getWidgetQueryKeys({ kind: "example" })).toEqual([[["widget", "example"]]]);
|
||||
});
|
||||
|
||||
test("wraps a singular explicit key", () => {
|
||||
expect(getWidgetQueryKeys({ kind: "example", queryKey: [["app", "byId"]] })).toEqual([[["app", "byId"]]]);
|
||||
});
|
||||
|
||||
test("preserves every explicit key for multi-root widgets", () => {
|
||||
const queryKeys = [
|
||||
[["widget", "audioStats", "getStats"]],
|
||||
[["widget", "mediaServer", "getCurrentStreams"]],
|
||||
] as const;
|
||||
|
||||
expect(getWidgetQueryKeys({ kind: "audioStats", queryKeys })).toBe(queryKeys);
|
||||
});
|
||||
});
|
||||
|
||||
describe("supportsAdvancedFocus", () => {
|
||||
test.each([appDefinition, beszelSystemGridDefinition, beszelSystemTableDefinition])(
|
||||
"keeps non-enhanced widgets compact",
|
||||
(widgetDefinition) => {
|
||||
expect(supportsAdvancedFocus(widgetDefinition)).toBe(false);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import type React from "react";
|
||||
import type { LoaderComponent } from "next/dynamic";
|
||||
import type { QueryClient, QueryKey } from "@tanstack/react-query";
|
||||
import { hashKey } from "@tanstack/react-query";
|
||||
import type { DefaultErrorData } from "@trpc/server/unstable-core-do-not-import";
|
||||
|
||||
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
|
||||
@@ -10,7 +11,7 @@ import type { stringOrTranslation } from "@homarr/translation";
|
||||
import type { TablerIcon } from "@homarr/ui";
|
||||
|
||||
import type { WidgetImports } from ".";
|
||||
import type { inferOptionsFromCreator, inferOptionsFromDefinition, WidgetOptionsRecord } from "./options";
|
||||
import type { inferOptionsFromCreator, WidgetOptionsRecord } from "./options";
|
||||
|
||||
export interface WidgetContextMenuAction {
|
||||
key: string;
|
||||
@@ -26,18 +27,50 @@ export interface WidgetContextMenuContext {
|
||||
isEditMode: boolean;
|
||||
boardId: string | undefined;
|
||||
itemId: string | undefined;
|
||||
canInteractWithSelectedIntegrations: boolean;
|
||||
}
|
||||
|
||||
export interface WidgetContextActionProps<
|
||||
TKind extends WidgetKind,
|
||||
TOptions extends WidgetOptionsRecord = WidgetOptionsRecord,
|
||||
> {
|
||||
kind: TKind;
|
||||
options: inferOptionsFromDefinition<TOptions>;
|
||||
setOptions: (partial: Partial<inferOptionsFromDefinition<TOptions>>) => void;
|
||||
export interface WidgetRuntimeActions {
|
||||
togglePolling?: () => void;
|
||||
testAllIndexers?: () => void;
|
||||
previousPhoto?: () => void;
|
||||
nextPhoto?: () => void;
|
||||
toggleSlideshow?: () => void;
|
||||
}
|
||||
|
||||
export interface WidgetRuntimeState {
|
||||
queries: readonly NormalizedWidgetQuery[];
|
||||
actions: WidgetRuntimeActions;
|
||||
}
|
||||
|
||||
export type WidgetRuntimeRef = React.MutableRefObject<WidgetRuntimeState>;
|
||||
|
||||
export const createWidgetRuntimeState = (): WidgetRuntimeState => ({
|
||||
queries: [],
|
||||
actions: {},
|
||||
});
|
||||
|
||||
export interface NormalizedWidgetQuery {
|
||||
path: readonly string[];
|
||||
input: unknown;
|
||||
}
|
||||
|
||||
export interface WidgetQueryMatcherScope {
|
||||
itemId: string;
|
||||
boardId: string | undefined;
|
||||
integrationIds: readonly string[];
|
||||
options: Record<string, unknown>;
|
||||
runtimeQueries: readonly NormalizedWidgetQuery[];
|
||||
}
|
||||
|
||||
export type WidgetQueryMatcher = (query: NormalizedWidgetQuery, scope: WidgetQueryMatcherScope) => boolean;
|
||||
|
||||
export interface WidgetContextActionProps {
|
||||
options: Record<string, unknown>;
|
||||
setOptions: (partial: Record<string, unknown>) => void;
|
||||
integrationIds: string[];
|
||||
context: WidgetContextMenuContext;
|
||||
widgetStateRef: React.MutableRefObject<Record<string, unknown> | null>;
|
||||
widgetRuntimeRef: WidgetRuntimeRef;
|
||||
}
|
||||
|
||||
const createWithDynamicImport =
|
||||
@@ -69,7 +102,10 @@ export const createWidgetDefinition = <TKind extends WidgetKind, TDefinition ext
|
||||
|
||||
export interface WidgetDefinition {
|
||||
icon: TablerIcon;
|
||||
supportsAdvancedFocus?: boolean;
|
||||
queryKey?: QueryKey;
|
||||
queryKeys?: readonly QueryKey[];
|
||||
queryMatcher?: WidgetQueryMatcher;
|
||||
refetchInterval?: number | null;
|
||||
supportedIntegrations?: IntegrationKind[];
|
||||
integrationsRequired?: boolean;
|
||||
@@ -87,9 +123,50 @@ export interface WidgetDefinition {
|
||||
}
|
||||
>
|
||||
>;
|
||||
contextActions?: (props: Omit<WidgetContextActionProps<WidgetKind>, "kind">) => WidgetContextMenuAction[];
|
||||
contextActions?: (props: WidgetContextActionProps) => WidgetContextMenuAction[];
|
||||
}
|
||||
|
||||
export const supportsAdvancedFocus = (definition: object) =>
|
||||
!("supportsAdvancedFocus" in definition) || definition.supportsAdvancedFocus !== false;
|
||||
|
||||
export const getWidgetQueryKeys = (
|
||||
definition: {
|
||||
kind?: string;
|
||||
queryKey?: QueryKey;
|
||||
queryKeys?: readonly QueryKey[];
|
||||
},
|
||||
kind?: WidgetKind,
|
||||
): readonly QueryKey[] => {
|
||||
if (definition.queryKeys && definition.queryKeys.length > 0) return definition.queryKeys;
|
||||
return [definition.queryKey ?? [["widget", definition.kind ?? kind]]];
|
||||
};
|
||||
|
||||
export const normalizeWidgetQuery = (queryKey: QueryKey): NormalizedWidgetQuery | null => {
|
||||
const path = queryKey[0];
|
||||
if (!Array.isArray(path) || !path.every((part): part is string => typeof part === "string")) return null;
|
||||
|
||||
const queryKeyOptions = queryKey[1];
|
||||
const input = isRecord(queryKeyOptions) && "input" in queryKeyOptions ? queryKeyOptions.input : undefined;
|
||||
return { path, input };
|
||||
};
|
||||
|
||||
export const getWidgetRuntimeQueries = (widgetRuntimeRef: WidgetRuntimeRef): readonly NormalizedWidgetQuery[] =>
|
||||
widgetRuntimeRef.current.queries;
|
||||
|
||||
export const matchesWidgetRuntimeQuery: WidgetQueryMatcher = (query, scope) =>
|
||||
scope.runtimeQueries.some(
|
||||
(runtimeQuery) =>
|
||||
widgetQueryValueEquals(runtimeQuery.path, query.path) && widgetQueryValueEquals(runtimeQuery.input, query.input),
|
||||
);
|
||||
|
||||
export const widgetQueryInputMatches = (input: unknown, expected: Record<string, unknown>) =>
|
||||
isRecord(input) && Object.entries(expected).every(([key, value]) => widgetQueryValueEquals(input[key], value));
|
||||
|
||||
export const widgetQueryValueEquals = (left: unknown, right: unknown) => hashKey([left]) === hashKey([right]);
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
|
||||
export interface WidgetProps<TKind extends WidgetKind> {
|
||||
options: inferOptionsFromCreator<WidgetOptionsRecordOf<TKind>>;
|
||||
integrationIds: string[];
|
||||
@@ -99,10 +176,13 @@ export interface WidgetProps<TKind extends WidgetKind> {
|
||||
export type WidgetComponentProps<TKind extends WidgetKind> = WidgetProps<TKind> & {
|
||||
boardId: string | undefined; // undefined when in preview mode
|
||||
isEditMode: boolean;
|
||||
displayMode?: "compact" | "advanced";
|
||||
setOptions: ({ newOptions }: { newOptions: Partial<inferOptionsFromCreator<WidgetOptionsRecordOf<TKind>>> }) => void;
|
||||
width: number;
|
||||
height: number;
|
||||
widgetRuntimeRef?: WidgetRuntimeRef;
|
||||
widgetStateRef?: React.MutableRefObject<Record<string, unknown> | null>;
|
||||
removeItem?: () => void;
|
||||
};
|
||||
|
||||
export type WidgetOptionsRecordOf<TKind extends WidgetKind> = WidgetImports[TKind]["definition"]["createOptions"];
|
||||
|
||||
@@ -33,6 +33,8 @@ import { MaskedOrNormalImage } from "@homarr/ui";
|
||||
|
||||
import type { widgetKind } from ".";
|
||||
import type { WidgetComponentProps } from "../../definition";
|
||||
import { getUsableWidgetQueryData } from "../../common/query-state";
|
||||
import actionTargetClasses from "../../common/action-target.module.css";
|
||||
import classes from "./component.module.css";
|
||||
import TimerModal from "./TimerModal";
|
||||
|
||||
@@ -44,6 +46,7 @@ export default function DnsHoleControlsWidget({
|
||||
integrationIds,
|
||||
isEditMode,
|
||||
width,
|
||||
height,
|
||||
}: WidgetComponentProps<typeof widgetKind>) {
|
||||
const board = useRequiredBoard();
|
||||
// DnsHole integrations with interaction permissions
|
||||
@@ -51,18 +54,26 @@ export default function DnsHoleControlsWidget({
|
||||
.map(({ id }) => id)
|
||||
.filter((id) => integrationIds.includes(id));
|
||||
|
||||
const { data: summaries = [] } = clientApi.widget.dnsHole.summary.useQuery({
|
||||
integrationIds,
|
||||
});
|
||||
const summaryQuery = clientApi.widget.dnsHole.summary.useQuery({ integrationIds });
|
||||
const summaries = getUsableWidgetQueryData(summaryQuery) ?? [];
|
||||
const { isPending: isSummaryPending } = summaryQuery;
|
||||
const utils = clientApi.useUtils();
|
||||
|
||||
const { mutate: enableDns } = clientApi.widget.dnsHole.enable.useMutation({
|
||||
const {
|
||||
mutateAsync: enableDns,
|
||||
isPending: isEnabling,
|
||||
error: enableError,
|
||||
} = clientApi.widget.dnsHole.enable.useMutation({
|
||||
onSettled: () => void utils.widget.dnsHole.summary.invalidate(),
|
||||
});
|
||||
const { mutate: disableDns } = clientApi.widget.dnsHole.disable.useMutation({
|
||||
const {
|
||||
mutateAsync: disableDns,
|
||||
isPending: isDisabling,
|
||||
error: disableError,
|
||||
} = clientApi.widget.dnsHole.disable.useMutation({
|
||||
onSettled: () => void utils.widget.dnsHole.summary.invalidate(),
|
||||
});
|
||||
const toggleDns = (integrationId: string) => {
|
||||
const toggleDns = async (integrationId: string) => {
|
||||
const integrationStatus = summaries.find(({ integration }) => integration.id === integrationId);
|
||||
if (!integrationStatus?.summary.status) return;
|
||||
utils.widget.dnsHole.summary.setData(
|
||||
@@ -86,9 +97,9 @@ export default function DnsHoleControlsWidget({
|
||||
},
|
||||
);
|
||||
if (integrationStatus.summary.status === "enabled") {
|
||||
disableDns({ integrationId, duration: 0 });
|
||||
await disableDns({ integrationId, duration: 0 });
|
||||
} else {
|
||||
enableDns({ integrationId });
|
||||
await enableDns({ integrationId });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -103,9 +114,30 @@ export default function DnsHoleControlsWidget({
|
||||
|
||||
// Timer modal setup
|
||||
const [selectedIntegrationIds, setSelectedIntegrationIds] = useState<string[]>([]);
|
||||
const [bulkPending, setBulkPending] = useState(false);
|
||||
const [bulkFailureCount, setBulkFailureCount] = useState(0);
|
||||
const [opened, { close, open }] = useDisclosure(false);
|
||||
|
||||
const controlAllButtonsVisible = options.showToggleAllButtons && integrationsWithInteractions.length > 0;
|
||||
const actionsPending = bulkPending || isEnabling || isDisabling;
|
||||
const actionError = enableError ?? disableError;
|
||||
const runBulkToggle = async (integrationIdsToToggle: string[]) => {
|
||||
setBulkPending(true);
|
||||
setBulkFailureCount(0);
|
||||
const results = await Promise.allSettled(integrationIdsToToggle.map(toggleDns));
|
||||
setBulkFailureCount(results.filter(({ status }) => status === "rejected").length);
|
||||
setBulkPending(false);
|
||||
};
|
||||
|
||||
if (isSummaryPending) {
|
||||
return (
|
||||
<Stack h="100%" justify="center" align="center">
|
||||
<Text c="dimmed" size="sm">
|
||||
{t("common.action.loading")}
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack
|
||||
@@ -120,11 +152,12 @@ export default function DnsHoleControlsWidget({
|
||||
<Flex className="dns-hole-controls-buttons" gap="sm">
|
||||
<Tooltip label={t("widget.dnsHoleControls.controls.enableAll")}>
|
||||
<Button
|
||||
aria-label={t("widget.dnsHoleControls.controls.enableAll")}
|
||||
size="xs"
|
||||
p={0}
|
||||
className="dns-hole-controls-enable-all-button"
|
||||
onClick={() => integrationsSummaries.disabled.forEach((integrationId) => toggleDns(integrationId))}
|
||||
disabled={integrationsSummaries.disabled.length === 0}
|
||||
onClick={() => void runBulkToggle(integrationsSummaries.disabled)}
|
||||
disabled={integrationsSummaries.disabled.length === 0 || actionsPending}
|
||||
variant="light"
|
||||
color="green"
|
||||
bd={0}
|
||||
@@ -137,6 +170,7 @@ export default function DnsHoleControlsWidget({
|
||||
|
||||
<Tooltip label={t("widget.dnsHoleControls.controls.setTimer")}>
|
||||
<Button
|
||||
aria-label={t("widget.dnsHoleControls.controls.setTimer")}
|
||||
size="xs"
|
||||
p={0}
|
||||
className="dns-hole-controls-timer-all-button"
|
||||
@@ -144,7 +178,7 @@ export default function DnsHoleControlsWidget({
|
||||
setSelectedIntegrationIds(integrationsSummaries.enabled);
|
||||
open();
|
||||
}}
|
||||
disabled={integrationsSummaries.enabled.length === 0}
|
||||
disabled={integrationsSummaries.enabled.length === 0 || actionsPending}
|
||||
variant="light"
|
||||
color="yellow"
|
||||
bd={0}
|
||||
@@ -157,11 +191,12 @@ export default function DnsHoleControlsWidget({
|
||||
|
||||
<Tooltip label={t("widget.dnsHoleControls.controls.disableAll")}>
|
||||
<Button
|
||||
aria-label={t("widget.dnsHoleControls.controls.disableAll")}
|
||||
size="xs"
|
||||
p={0}
|
||||
className="dns-hole-controls-disable-all-button"
|
||||
onClick={() => integrationsSummaries.enabled.forEach((integrationId) => toggleDns(integrationId))}
|
||||
disabled={integrationsSummaries.enabled.length === 0}
|
||||
onClick={() => void runBulkToggle(integrationsSummaries.enabled)}
|
||||
disabled={integrationsSummaries.enabled.length === 0 || actionsPending}
|
||||
variant="light"
|
||||
color="red"
|
||||
bd={0}
|
||||
@@ -192,16 +227,31 @@ export default function DnsHoleControlsWidget({
|
||||
t={t}
|
||||
hasIconColor={board.iconColor !== null}
|
||||
rootWidth={width}
|
||||
rootHeight={height}
|
||||
actionsPending={actionsPending}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</ScrollArea.Autosize>
|
||||
|
||||
{bulkFailureCount > 0 && (
|
||||
<Text size="xs" c="red" ta="center">
|
||||
{t("widget.dnsHoleControls.error.bulkActionsFailed", { count: bulkFailureCount })}
|
||||
</Text>
|
||||
)}
|
||||
{actionError && (
|
||||
<Tooltip label={t("widget.dnsHoleControls.error.internalServerError")}>
|
||||
<Text size="xs" c="red" ta="center" lineClamp={2} tabIndex={0}>
|
||||
{t("widget.dnsHoleControls.error.internalServerError")}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<TimerModal
|
||||
opened={opened}
|
||||
close={close}
|
||||
selectedIntegrationIds={selectedIntegrationIds}
|
||||
disableDns={disableDns}
|
||||
disableDns={(input) => void disableDns(input).catch(() => undefined)}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
@@ -209,13 +259,15 @@ export default function DnsHoleControlsWidget({
|
||||
|
||||
interface ControlsCardProps {
|
||||
integrationsWithInteractions: string[];
|
||||
toggleDns: (integrationId: string) => void;
|
||||
toggleDns: (integrationId: string) => Promise<void>;
|
||||
data: RouterOutputs["widget"]["dnsHole"]["summary"][number];
|
||||
setSelectedIntegrationIds: (integrationId: string[]) => void;
|
||||
open: () => void;
|
||||
t: TranslationFunction;
|
||||
hasIconColor: boolean;
|
||||
rootWidth: number;
|
||||
rootHeight: number;
|
||||
actionsPending: boolean;
|
||||
}
|
||||
|
||||
const ControlsCard: React.FC<ControlsCardProps> = ({
|
||||
@@ -227,16 +279,18 @@ const ControlsCard: React.FC<ControlsCardProps> = ({
|
||||
t,
|
||||
hasIconColor,
|
||||
rootWidth,
|
||||
rootHeight,
|
||||
actionsPending,
|
||||
}) => {
|
||||
const isConnected = useIntegrationConnected(data.integration.updatedAt, { timeout: 30000 });
|
||||
const isEnabled = data.summary.status ? data.summary.status === "enabled" : undefined;
|
||||
const isInteractPermitted = integrationsWithInteractions.includes(data.integration.id);
|
||||
// Use all factors to infer the state of the action buttons
|
||||
const controlEnabled = isInteractPermitted && isEnabled !== undefined && isConnected;
|
||||
const controlEnabled = isInteractPermitted && isEnabled !== undefined && isConnected && !actionsPending;
|
||||
const board = useRequiredBoard();
|
||||
|
||||
const iconUrl = integrationDefs[data.integration.kind].iconUrl;
|
||||
const layout = rootWidth < 256 ? "sm" : "md";
|
||||
const layout = rootWidth < 256 || rootHeight < 112 ? "sm" : "md";
|
||||
|
||||
return (
|
||||
<Indicator
|
||||
@@ -296,7 +350,9 @@ const ControlsCard: React.FC<ControlsCardProps> = ({
|
||||
<Group gap="xs" grow wrap="nowrap" w="100%">
|
||||
{!isEnabled ? (
|
||||
<ActionIcon
|
||||
onClick={() => toggleDns(data.integration.id)}
|
||||
className={actionTargetClasses.root}
|
||||
aria-label={`${t("widget.dnsHoleControls.controls.enableAll")}: ${data.integration.name}`}
|
||||
onClick={() => void toggleDns(data.integration.id).catch(() => undefined)}
|
||||
disabled={!controlEnabled}
|
||||
size="sm"
|
||||
color="green"
|
||||
@@ -306,7 +362,9 @@ const ControlsCard: React.FC<ControlsCardProps> = ({
|
||||
</ActionIcon>
|
||||
) : (
|
||||
<ActionIcon
|
||||
onClick={() => toggleDns(data.integration.id)}
|
||||
className={actionTargetClasses.root}
|
||||
aria-label={`${t("widget.dnsHoleControls.controls.disableAll")}: ${data.integration.name}`}
|
||||
onClick={() => void toggleDns(data.integration.id).catch(() => undefined)}
|
||||
disabled={!controlEnabled}
|
||||
size="sm"
|
||||
color="red"
|
||||
@@ -316,6 +374,8 @@ const ControlsCard: React.FC<ControlsCardProps> = ({
|
||||
</ActionIcon>
|
||||
)}
|
||||
<ActionIcon
|
||||
className={actionTargetClasses.root}
|
||||
aria-label={`${t("widget.dnsHoleControls.controls.setTimer")}: ${data.integration.name}`}
|
||||
onClick={() => {
|
||||
setSelectedIntegrationIds([data.integration.id]);
|
||||
open();
|
||||
@@ -323,6 +383,8 @@ const ControlsCard: React.FC<ControlsCardProps> = ({
|
||||
size="sm"
|
||||
color="yellow"
|
||||
variant="light"
|
||||
display={isInteractPermitted ? undefined : "none"}
|
||||
disabled={!controlEnabled || !isEnabled}
|
||||
>
|
||||
<IconClockPause size={12} />
|
||||
</ActionIcon>
|
||||
@@ -330,11 +392,16 @@ const ControlsCard: React.FC<ControlsCardProps> = ({
|
||||
)}
|
||||
{layout === "md" && (
|
||||
<UnstyledButton
|
||||
aria-label={`${
|
||||
isEnabled
|
||||
? t("widget.dnsHoleControls.controls.disableAll")
|
||||
: t("widget.dnsHoleControls.controls.enableAll")
|
||||
}: ${data.integration.name}`}
|
||||
className="dns-hole-controls-item-toggle-button"
|
||||
disabled={!controlEnabled}
|
||||
display="contents"
|
||||
style={{ cursor: controlEnabled ? "pointer" : "default" }}
|
||||
onClick={() => toggleDns(data.integration.id)}
|
||||
onClick={() => void toggleDns(data.integration.id).catch(() => undefined)}
|
||||
>
|
||||
<Badge
|
||||
className={`dns-hole-controls-item-toggle-button-styling${controlEnabled ? " hoverable-component clickable-component" : ""}`}
|
||||
@@ -372,7 +439,8 @@ const ControlsCard: React.FC<ControlsCardProps> = ({
|
||||
</Flex>
|
||||
{layout === "md" && (
|
||||
<ActionIcon
|
||||
className="dns-hole-controls-item-timer-button"
|
||||
aria-label={`${t("widget.dnsHoleControls.controls.setTimer")}: ${data.integration.name}`}
|
||||
className={combineClasses("dns-hole-controls-item-timer-button", actionTargetClasses.root)}
|
||||
display={isInteractPermitted ? undefined : "none"}
|
||||
disabled={!controlEnabled || !isEnabled}
|
||||
color="yellow"
|
||||
|
||||
@@ -8,6 +8,7 @@ import { optionsBuilder } from "../../options";
|
||||
export const widgetKind = "dnsHoleControls";
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition(widgetKind, {
|
||||
supportsAdvancedFocus: false,
|
||||
icon: IconDeviceGamepad,
|
||||
queryKey: [["widget", "dnsHole"]],
|
||||
refetchInterval: 5,
|
||||
|
||||
@@ -1,8 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import type { BoxProps } from "@mantine/core";
|
||||
import { Avatar, AvatarGroup, Card, Flex, SimpleGrid, Stack, Text, Tooltip, TooltipFloating } from "@mantine/core";
|
||||
import {
|
||||
Avatar,
|
||||
AvatarGroup,
|
||||
Badge,
|
||||
Card,
|
||||
Flex,
|
||||
Group,
|
||||
ScrollArea,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { useElementSize } from "@mantine/hooks";
|
||||
import { IconBarrierBlock, IconPercentage, IconSearch, IconWorldWww } from "@tabler/icons-react";
|
||||
|
||||
@@ -18,37 +29,84 @@ import type { TablerIcon } from "@homarr/ui";
|
||||
|
||||
import type { widgetKind } from ".";
|
||||
import type { WidgetComponentProps, WidgetProps } from "../../definition";
|
||||
import { getUsableWidgetQueryData } from "../../common/query-state";
|
||||
|
||||
export default function DnsHoleSummaryWidget({ options, integrationIds }: WidgetComponentProps<typeof widgetKind>) {
|
||||
const { data: summaries = [] } = clientApi.widget.dnsHole.summary.useQuery({
|
||||
export default function DnsHoleSummaryWidget({
|
||||
options,
|
||||
integrationIds,
|
||||
width,
|
||||
height,
|
||||
}: WidgetComponentProps<typeof widgetKind>) {
|
||||
const summaryQuery = clientApi.widget.dnsHole.summary.useQuery({
|
||||
integrationIds,
|
||||
});
|
||||
const summaries = getUsableWidgetQueryData(summaryQuery) ?? [];
|
||||
const { isPending } = summaryQuery;
|
||||
|
||||
const t = useI18n();
|
||||
|
||||
const data = useMemo(() => summaries.flatMap(({ summary }) => summary), [summaries]);
|
||||
const data = summaries.flatMap(({ summary }) => summary);
|
||||
const layoutProps = boxPropsByLayout(options.layout);
|
||||
const showSourceStatuses = summaries.length > 1 && width >= 240 && height >= 220;
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<Stack h="100%" justify="center" align="center">
|
||||
<Text c="dimmed" size="sm">
|
||||
{t("common.action.loading")}
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SimpleGrid cols={2} spacing="xs" h="100%" p={"xs"} {...boxPropsByLayout(options.layout)}>
|
||||
{data.length > 0 ? (
|
||||
stats.map((item) => (
|
||||
<StatCard key={item.color} item={item} usePiHoleColors={options.usePiHoleColors} data={data} t={t} />
|
||||
))
|
||||
) : (
|
||||
<Stack h="100%" w="100%" justify="center" align="center" gap="sm" p="sm">
|
||||
<AvatarGroup spacing="md">
|
||||
{summaries.map(({ integration }) => (
|
||||
<Tooltip key={integration.id} label={integration.name}>
|
||||
<Avatar h={30} w={30} src={integrationDefs[integration.kind].iconUrl} />
|
||||
</Tooltip>
|
||||
<Stack h="100%" gap={0}>
|
||||
<SimpleGrid cols={2} spacing="xs" p="xs" {...layoutProps} style={{ ...layoutProps.style, flex: 1, minHeight: 0 }}>
|
||||
{data.length > 0 ? (
|
||||
stats.map((item) => (
|
||||
<StatCard key={item.color} item={item} usePiHoleColors={options.usePiHoleColors} data={data} t={t} />
|
||||
))
|
||||
) : (
|
||||
<Stack
|
||||
aria-live="polite"
|
||||
h="100%"
|
||||
w="100%"
|
||||
justify="center"
|
||||
align="center"
|
||||
gap="sm"
|
||||
p="sm"
|
||||
style={{ gridColumn: "1 / -1" }}
|
||||
>
|
||||
<AvatarGroup spacing="md">
|
||||
{summaries.map(({ integration }) => (
|
||||
<Tooltip key={integration.id} label={integration.name}>
|
||||
<Avatar h={30} w={30} src={integrationDefs[integration.kind].iconUrl} />
|
||||
</Tooltip>
|
||||
))}
|
||||
</AvatarGroup>
|
||||
<Text fz="md" ta="center">
|
||||
{t("widget.dnsHoleSummary.error.integrationsDisconnected")}
|
||||
</Text>
|
||||
</Stack>
|
||||
)}
|
||||
</SimpleGrid>
|
||||
{showSourceStatuses && (
|
||||
<ScrollArea type="auto" scrollbarSize={4} px="xs" pb="xs">
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
{summaries.map(({ integration, summary }) => (
|
||||
<Badge
|
||||
key={integration.id}
|
||||
variant="light"
|
||||
color={summary.status === "enabled" ? "green" : summary.status === "disabled" ? "red" : "gray"}
|
||||
style={{ flexShrink: 0 }}
|
||||
>
|
||||
{integration.name}: {t(`widget.dnsHoleSummary.status.${summary.status ?? "unknown"}` as never)}
|
||||
</Badge>
|
||||
))}
|
||||
</AvatarGroup>
|
||||
<Text fz="md" ta="center">
|
||||
{t("widget.dnsHoleSummary.error.integrationsDisconnected")}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -61,7 +119,7 @@ const stats = [
|
||||
size === "sm" ? 0 : 2,
|
||||
),
|
||||
label: (t) => t("widget.dnsHoleSummary.data.adsBlockedToday"),
|
||||
color: "rgba(240, 82, 60, 0.4)", // RED
|
||||
color: "var(--mantine-color-red-light)",
|
||||
},
|
||||
{
|
||||
icon: IconPercentage,
|
||||
@@ -71,7 +129,7 @@ const stats = [
|
||||
return `${formatNumber(totalCount === 0 ? 0 : (blocked / totalCount) * 100, size === "sm" ? 0 : 2)}%`;
|
||||
},
|
||||
label: (t) => t("widget.dnsHoleSummary.data.adsBlockedTodayPercentage"),
|
||||
color: "rgba(255, 165, 20, 0.4)", // YELLOW
|
||||
color: "var(--mantine-color-yellow-light)",
|
||||
},
|
||||
{
|
||||
icon: IconSearch,
|
||||
@@ -81,7 +139,7 @@ const stats = [
|
||||
size === "sm" ? 0 : 2,
|
||||
),
|
||||
label: (t) => t("widget.dnsHoleSummary.data.dnsQueriesToday"),
|
||||
color: "rgba(0, 175, 218, 0.4)", // BLUE
|
||||
color: "var(--mantine-color-cyan-light)",
|
||||
},
|
||||
{
|
||||
icon: IconWorldWww,
|
||||
@@ -97,7 +155,7 @@ const stats = [
|
||||
},
|
||||
tooltip: (data, t) => (data.length >= 2 ? t("widget.dnsHoleSummary.domainsTooltip") : undefined),
|
||||
label: (t) => t("widget.dnsHoleSummary.data.domainsBeingBlocked"),
|
||||
color: "rgba(0, 176, 96, 0.4)", // GREEN
|
||||
color: "var(--mantine-color-green-light)",
|
||||
},
|
||||
] satisfies StatItem[];
|
||||
|
||||
@@ -122,15 +180,20 @@ const StatCard = ({ item, data, usePiHoleColors, t }: StatCardProps) => {
|
||||
const hideLabel = (height <= 32 && width <= 256) || (height <= 64 && width <= 92);
|
||||
const tooltip = item.tooltip?.(data, t);
|
||||
const board = useRequiredBoard();
|
||||
const label = translateIfNecessary(t, item.label);
|
||||
const value = item.value(data, width <= 64 ? "sm" : "md");
|
||||
|
||||
return (
|
||||
<TooltipFloating label={tooltip} disabled={!tooltip} w={250} multiline>
|
||||
<Tooltip label={tooltip} disabled={!tooltip} w={250} multiline events={{ hover: true, focus: true, touch: true }}>
|
||||
<Card
|
||||
ref={ref}
|
||||
component="section"
|
||||
tabIndex={tooltip ? 0 : undefined}
|
||||
aria-label={`${label}: ${value}`}
|
||||
className="summary-card"
|
||||
p="sm"
|
||||
radius={board.itemRadius}
|
||||
bg={usePiHoleColors ? item.color : "rgba(96, 96, 96, 0.1)"}
|
||||
bg={usePiHoleColors ? item.color : "var(--mantine-color-default-hover)"}
|
||||
style={{
|
||||
flex: 1,
|
||||
}}
|
||||
@@ -158,17 +221,17 @@ const StatCard = ({ item, data, usePiHoleColors, t }: StatCardProps) => {
|
||||
wrap="wrap"
|
||||
>
|
||||
<Text className="summary-card-value text-flash" ta="center" size="lg" fw="bold" maw="100%">
|
||||
{item.value(data, width <= 64 ? "sm" : "md")}
|
||||
{value}
|
||||
</Text>
|
||||
{!hideLabel && (
|
||||
<Text className="summary-card-label" ta="center" size="xs" maw="100%">
|
||||
{translateIfNecessary(t, item.label)}
|
||||
{label}
|
||||
</Text>
|
||||
)}
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Card>
|
||||
</TooltipFloating>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import { optionsBuilder } from "../../options";
|
||||
export const widgetKind = "dnsHoleSummary";
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition(widgetKind, {
|
||||
supportsAdvancedFocus: false,
|
||||
icon: IconAd,
|
||||
queryKey: [["widget", "dnsHole"]],
|
||||
refetchInterval: 5,
|
||||
|
||||
@@ -1,7 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { ActionIcon, Avatar, Badge, Box, Center, Group, Menu, Portal, Stack, Text, Tooltip } from "@mantine/core";
|
||||
import {
|
||||
ActionIcon,
|
||||
Avatar,
|
||||
Badge,
|
||||
Box,
|
||||
Center,
|
||||
getDefaultZIndex,
|
||||
Group,
|
||||
Menu,
|
||||
Portal,
|
||||
Stack,
|
||||
Text,
|
||||
Tooltip,
|
||||
VisuallyHidden,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
IconBrandDocker,
|
||||
IconCategoryPlus,
|
||||
@@ -29,8 +43,11 @@ import { showErrorNotification, showSuccessNotification } from "@homarr/notifica
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { getUsableWidgetQueryData } from "../common/query-state";
|
||||
import actionTargetClasses from "../common/action-target.module.css";
|
||||
import { HomarrDataTable } from "../common/homarr-data-table";
|
||||
import { usePersistedTableLayout, useTableLayoutPersistence } from "../common/use-persisted-table-layout";
|
||||
import { getDockerColumnVisibility, getDockerFooterVisibility } from "./layout";
|
||||
|
||||
type DockerContainer = RouterOutputs["docker"]["getContainers"]["containers"][number];
|
||||
type ContainerAction = "start" | "stop" | "restart" | "remove";
|
||||
@@ -63,10 +80,32 @@ const ContainerStateBadge = ({ state }: { state: ContainerState }) => {
|
||||
);
|
||||
};
|
||||
|
||||
const ContainerStateDot = ({ state }: { state: ContainerState }) => {
|
||||
const t = useScopedI18n("docker.field.state.option");
|
||||
const label = t(state);
|
||||
|
||||
return (
|
||||
<Tooltip label={label} withArrow>
|
||||
<Box component="span" style={{ display: "inline-flex", flexShrink: 0 }}>
|
||||
<Box
|
||||
component="span"
|
||||
aria-hidden="true"
|
||||
w={8}
|
||||
h={8}
|
||||
bg={`${containerStateColorMap[state]}.6`}
|
||||
style={{ borderRadius: "50%" }}
|
||||
/>
|
||||
<VisuallyHidden>{label}</VisuallyHidden>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
const createColumns = (
|
||||
t: ReturnType<typeof useScopedI18n<"docker">>,
|
||||
handlers: ContainerActionHandlers,
|
||||
sortingEnabled: boolean,
|
||||
inlineState: boolean,
|
||||
): DataTableColumn<DockerContainer>[] => [
|
||||
{
|
||||
accessor: "name",
|
||||
@@ -76,6 +115,7 @@ const createColumns = (
|
||||
sortable: sortingEnabled,
|
||||
render: (container) => (
|
||||
<Group gap="xs" wrap="nowrap" style={{ overflow: "hidden" }}>
|
||||
{inlineState && <ContainerStateDot state={container.state} />}
|
||||
<Avatar
|
||||
variant="outline"
|
||||
radius="sm"
|
||||
@@ -188,6 +228,7 @@ export default function DockerWidget({
|
||||
options,
|
||||
width,
|
||||
isEditMode,
|
||||
displayMode,
|
||||
boardId,
|
||||
itemId,
|
||||
setOptions,
|
||||
@@ -198,9 +239,11 @@ export default function DockerWidget({
|
||||
const board = useOptionalBoard();
|
||||
const { data: session } = useSession();
|
||||
const hasChangeAccess = board ? constructBoardPermissions(board, session).hasChangeAccess : false;
|
||||
const isTiny = width <= 256;
|
||||
const isAdvanced = displayMode === "advanced";
|
||||
|
||||
const { data, refetch, isFetching } = clientApi.docker.getContainers.useQuery();
|
||||
const containersQuery = clientApi.docker.getContainers.useQuery();
|
||||
const data = getUsableWidgetQueryData(containersQuery);
|
||||
const { refetch, isFetching } = containersQuery;
|
||||
const containers = useMemo(() => data?.containers ?? [], [data?.containers]);
|
||||
const timestamp = useMemo(() => data?.timestamp ?? new Date(), [data?.timestamp]);
|
||||
const relativeTime = useTimeAgo(timestamp);
|
||||
@@ -279,13 +322,18 @@ export default function DockerWidget({
|
||||
[containers],
|
||||
);
|
||||
|
||||
const selectedColumns = useMemo(() => new Set<string>(options.columns), [options.columns]);
|
||||
const columnVisibility = useMemo(
|
||||
() => getDockerColumnVisibility(options.columns, width, isAdvanced),
|
||||
[isAdvanced, options.columns, width],
|
||||
);
|
||||
const inlineState =
|
||||
!isAdvanced && width < 340 && options.columns.includes("name") && options.columns.includes("state");
|
||||
const columns = useMemo(() => {
|
||||
const sortingEnabled = options.enableRowSorting && !isEditMode;
|
||||
return createColumns(t, actionHandlers, sortingEnabled).filter(({ accessor }) =>
|
||||
selectedColumns.has(String(accessor)),
|
||||
const sortingEnabled = (isAdvanced || options.enableRowSorting) && !isEditMode;
|
||||
return createColumns(t, actionHandlers, sortingEnabled, inlineState).filter(
|
||||
({ accessor }) => columnVisibility[String(accessor) as keyof typeof columnVisibility],
|
||||
);
|
||||
}, [actionHandlers, isEditMode, options.enableRowSorting, selectedColumns, t]);
|
||||
}, [actionHandlers, columnVisibility, inlineState, isAdvanced, isEditMode, options.enableRowSorting, t]);
|
||||
const { effectiveColumns, storeKey } = usePersistedTableLayout({
|
||||
columns,
|
||||
columnAccessors,
|
||||
@@ -303,7 +351,7 @@ export default function DockerWidget({
|
||||
setContextMenu({ x: event.clientX, y: event.clientY, container: record });
|
||||
}, []);
|
||||
|
||||
if (options.columns.length === 0) {
|
||||
if (!isAdvanced && options.columns.length === 0) {
|
||||
return (
|
||||
<Center h="100%">
|
||||
<Text>{t("error.noColumns")}</Text>
|
||||
@@ -311,6 +359,8 @@ export default function DockerWidget({
|
||||
);
|
||||
}
|
||||
|
||||
const footerVisibility = getDockerFooterVisibility(width, isAdvanced);
|
||||
|
||||
return (
|
||||
<Stack gap={0} h="100%" style={{ overflow: "hidden" }}>
|
||||
<Box style={{ flex: 1, minHeight: 0 }}>
|
||||
@@ -325,7 +375,7 @@ export default function DockerWidget({
|
||||
columns={effectiveColumns}
|
||||
storeColumnsKey={storeKey}
|
||||
sortStatus={sortStatus}
|
||||
onSortStatusChange={options.enableRowSorting && !isEditMode ? setSortStatus : undefined}
|
||||
onSortStatusChange={(isAdvanced || options.enableRowSorting) && !isEditMode ? setSortStatus : undefined}
|
||||
idAccessor="id"
|
||||
onRowContextMenu={isEditMode ? undefined : handleContextMenu}
|
||||
onScroll={() => {
|
||||
@@ -334,8 +384,15 @@ export default function DockerWidget({
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{!isTiny && (
|
||||
<Group justify="space-between" style={{ borderTop: "0.0625rem solid var(--border-color)" }} p={4} wrap="nowrap">
|
||||
{footerVisibility.footer && (
|
||||
<Group
|
||||
justify="space-between"
|
||||
style={{
|
||||
borderTop: "0.0625rem solid var(--border-color)",
|
||||
}}
|
||||
p={4}
|
||||
wrap="nowrap"
|
||||
>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<IconBrandDocker size={20} style={{ flexShrink: 0 }} />
|
||||
<Text size="sm" truncate>
|
||||
@@ -344,14 +401,19 @@ export default function DockerWidget({
|
||||
</Group>
|
||||
|
||||
<Group gap="sm" wrap="nowrap" justify="flex-end" style={{ minWidth: 0 }}>
|
||||
<Text size="sm" style={{ whiteSpace: "nowrap" }}>
|
||||
{t("table.totalCpu", { cpu: totals.cpu.toFixed(2) })}
|
||||
</Text>
|
||||
<Text size="sm" style={{ whiteSpace: "nowrap" }}>
|
||||
{t("table.totalMemory", { memory: formatBytes(totals.memory) })}
|
||||
</Text>
|
||||
{footerVisibility.cpu && (
|
||||
<Text size="sm" style={{ whiteSpace: "nowrap" }}>
|
||||
{t("table.totalCpu", { cpu: totals.cpu.toFixed(2) })}
|
||||
</Text>
|
||||
)}
|
||||
{footerVisibility.memory && (
|
||||
<Text size="sm" style={{ whiteSpace: "nowrap" }}>
|
||||
{t("table.totalMemory", { memory: formatBytes(totals.memory) })}
|
||||
</Text>
|
||||
)}
|
||||
<Tooltip label={t("table.refresh.lastUpdated", { when: relativeTime })}>
|
||||
<ActionIcon
|
||||
className={actionTargetClasses.root}
|
||||
size="sm"
|
||||
variant="transparent"
|
||||
c="var(--mantine-color-text)"
|
||||
@@ -386,6 +448,7 @@ function ContainerMenuButton({
|
||||
<Menu opened={opened} onChange={setOpened} closeOnItemClick={false} withinPortal position="bottom-end" shadow="sm">
|
||||
<Menu.Target>
|
||||
<ActionIcon
|
||||
className={actionTargetClasses.root}
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="sm"
|
||||
@@ -486,7 +549,7 @@ function ContainerContextMenu({
|
||||
left={0}
|
||||
w="100vw"
|
||||
h="100vh"
|
||||
style={{ zIndex: 1000 }}
|
||||
style={{ zIndex: getDefaultZIndex("popover") }}
|
||||
onClick={onClose}
|
||||
onContextMenu={(event) => {
|
||||
event.preventDefault();
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { getDockerColumnVisibility, getDockerFooterVisibility } from "./layout";
|
||||
|
||||
const columns = ["name", "state", "host", "cpuUsage", "memoryUsage", "actions"] as const;
|
||||
|
||||
describe("getDockerColumnVisibility", () => {
|
||||
test("keeps only essential columns in a narrow compact widget", () => {
|
||||
expect(getDockerColumnVisibility(columns, 240, false)).toEqual({
|
||||
name: true,
|
||||
state: false,
|
||||
host: false,
|
||||
cpuUsage: false,
|
||||
memoryUsage: false,
|
||||
actions: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("reveals compact metrics as width becomes available", () => {
|
||||
expect(getDockerColumnVisibility(columns, 440, false)).toMatchObject({
|
||||
host: false,
|
||||
cpuUsage: true,
|
||||
memoryUsage: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("keeps a configured state column when there is no name column for the inline dot", () => {
|
||||
expect(getDockerColumnVisibility(["state"], 240, false).state).toBe(true);
|
||||
});
|
||||
|
||||
test("uses every expert column in advanced mode", () => {
|
||||
expect(getDockerColumnVisibility([], 240, true)).toEqual({
|
||||
name: true,
|
||||
state: true,
|
||||
host: true,
|
||||
cpuUsage: true,
|
||||
memoryUsage: true,
|
||||
actions: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getDockerFooterVisibility", () => {
|
||||
test("reveals totals only when they fit", () => {
|
||||
expect(getDockerFooterVisibility(300, false)).toEqual({ footer: true, cpu: false, memory: false });
|
||||
expect(getDockerFooterVisibility(400, false)).toEqual({ footer: true, cpu: true, memory: false });
|
||||
});
|
||||
|
||||
test("keeps every total in advanced mode", () => {
|
||||
expect(getDockerFooterVisibility(240, true)).toEqual({ footer: true, cpu: true, memory: true });
|
||||
});
|
||||
});
|
||||
Loaded 100 of 1326 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user