Compare commits

..
Author SHA1 Message Date
ajnart e41d938a5f test: match custom widget cache version 2026-08-14 11:07:02 +02:00
ajnart afa54755fd fix custom widget review findings 2026-08-14 10:35:21 +02:00
ajnart 0482c040ec chore: reconcile v2 after stack rebase 2026-08-13 19:59:46 +02:00
Thomas Camlong 5dc4dbeaef style: format v2 workshop files 2026-08-13 19:51:44 +02:00
Thomas f3f2778f1f test: cover public custom widgets for guests (#6558) 2026-08-13 19:51:44 +02:00
Thomas c08b8979ee feat: rewrite image picker (#6556)
* feat: rewrite image picker

* style: auto-fix lint and format

* fix: address image picker review

* fix: preserve unicode icon searches

* test: stabilize custom widget login

* fix: suggest image searches only on create
2026-08-13 19:51:44 +02:00
Thomas Camlong 9a7a828618 ci: retry flaky container-backed unit tests in fast gate
Integration tests (nzbget, nextcloud, pi-hole, etc.) start Docker
containers and intermittently time out under shared-runner load, which
fails the fast gate and skips the container job that publishes the PR
preview image. Retry failing unit tests twice so transient container
startup failures do not block the image publish.
2026-08-13 19:51:44 +02:00
Thomas Camlong 352a8ed0d0 ci: make e2e tests resilient to CI resource contention
E2E specs spin up Docker containers in parallel and intermittently time
out waiting for UI elements under shared-runner load. Limit parallel
workers to 2 and retry flaky failures twice so the container job can
complete and publish the PR preview image.
2026-08-13 19:51:44 +02:00
ajnart 6d3f7e3e36 test: wait for Home Assistant API readiness 2026-08-13 19:51:44 +02:00
Thomas Camlong 7833cca86b feat(workshop): add social previews 2026-08-13 19:51:44 +02:00
Thomas Camlong 6edf78acd7 fix: persist Workshop PocketBase data 2026-08-13 19:51:44 +02:00
Thomas Camlong 7d43f6e5d9 fix: defer Spotlight without loading feedback 2026-08-13 19:51:44 +02:00
Thomas Camlong 1723a9e744 fix: polish settings and command menu 2026-08-13 19:51:44 +02:00
Thomas Camlong 2ba31fd37b fix(workshop): streamline import review 2026-08-13 19:51:44 +02:00
Thomas Camlong 327e5c15ae fix(workshop): stack widget install flow 2026-08-13 19:51:44 +02:00
Thomas Camlong 3efe64a062 fix(workshop): expose moderation 2026-08-13 19:51:44 +02:00
Thomas Camlong 40b726b8bc fix: simplify custom widget preview controls 2026-08-13 19:51:44 +02:00
Thomas Camlong d2d0d16e4d fix(workshop): use PocketBase name 2026-08-13 19:51:44 +02:00
Thomas Camlong 63ad879022 fix(workshop): use GitHub handles in Homarr 2026-08-13 19:51:44 +02:00
Thomas Camlong 2ae0c2ab43 fix(docs): exclude generated build from typecheck 2026-08-13 19:51:44 +02:00
Thomas Camlong 2410ba5f5c fix(workshop): simplify GitHub identities 2026-08-13 19:51:25 +02:00
Thomas Camlong 8f0039245d docs: update WUD OpenAPI schema 2026-08-13 19:51:25 +02:00
Thomas Camlong 0871e4cf0d test: validate Workshop source locale 2026-08-13 19:51:25 +02:00
Thomas Camlong db9f42520a fix: stabilize v2 CI 2026-08-13 19:51:25 +02:00
Thomas Camlong 3ebcedec26 feat: add Workshop staging deployment 2026-08-13 19:51:25 +02:00
Thomas Camlong c41a3d427e fix: allow Workshop runtime config writes 2026-08-13 19:51:25 +02:00
Thomas Camlong 71c39aa18e fix: restore Workshop runtime URLs 2026-08-13 19:51:25 +02:00
Thomas Camlong 000c51a131 test: harden integration icon checks 2026-08-13 19:51:25 +02:00
Thomas Camlong 2c44c7f2b8 test: fix custom option repair fixture 2026-08-13 19:51:25 +02:00
Thomas Camlong c2cf25b4fb fix: repair stale custom widget options 2026-08-13 19:51:25 +02:00
Thomas Camlong 03ebb8c2db test(e2e): serialize container specs 2026-08-13 19:51:25 +02:00
Thomas Camlong f0b153aa37 fix(settings): avoid nested forms 2026-08-13 19:51:25 +02:00
Thomas Camlong 965d130ce7 fix(clock): defer live time until mount 2026-08-13 19:51:25 +02:00
Thomas Camlong a87a16edb1 fix(e2e): stabilize initial board hydration 2026-08-13 19:51:25 +02:00
Thomas Camlong 1493252e28 fix(clock): suppress live timestamp hydration noise 2026-08-13 19:51:25 +02:00
Thomas Camlong 46ff20b0a0 test(v2): make board tour dismissal optional 2026-08-13 19:51:25 +02:00
Thomas Camlong 1bc84dd90e test(v2): stabilize production E2E flows 2026-08-13 19:51:24 +02:00
Thomas Camlong 14cbc03621 fix(ci): exclude e2e project from unit gate 2026-08-13 19:51:08 +02:00
Thomas Camlong 9ec35b0cda fix(ci): exclude e2e tests from unit gate 2026-08-13 19:51:08 +02:00
Thomas Camlong 2d6dd2f0d0 fix(ci): detect custom widget runtime chunks 2026-08-13 19:51:08 +02:00
Thomas Camlong 572bc71396 fix(ci): refresh v2 generated artifacts 2026-08-13 19:51:08 +02:00
Thomas Camlong d4b1739c46 fix(ci): align Workshop pnpm version 2026-08-13 19:50:46 +02:00
ajnart 76495948e2 ci(workshop): restore image publishing 2026-08-13 19:50:46 +02:00
Thomas a95052c787 fix(workshop): harden deployment and deep links (#6542) 2026-08-13 19:50:46 +02:00
Thomas 400127b45c fix: harden v2 custom widgets and Workshop (#6480) 2026-08-13 19:50:46 +02:00
Thomas f77c47b94b Delete review.md 2026-08-13 19:49:25 +02:00
Thomas 825636c4f6 fix(custom-widgets): address final v2 review feedback (#6451) 2026-08-13 19:49:25 +02:00
Thomas 3aa14e22ed fix(custom-widgets): address v2 merge review (#6446) 2026-08-13 19:49:25 +02:00
Thomas acdfcbb18e feat(custom-widgets): complete Custom JSX v2 (#6390) 2026-08-13 19:48:57 +02:00
Thomas 3a1245cbc5 fix(custom-widgets): fix v2 editor crash and definition-not-found race (#6377) 2026-08-13 19:47:16 +02:00
Thomas Camlong f0963405ca test(integrations): wait for Home Assistant restart 2026-08-13 19:47:16 +02:00
Thomas Camlong d078d761d9 ci: publish multi-platform preview images 2026-08-13 19:47:16 +02:00
Thomas Camlong 84fcf02729 test(integrations): allow recurring calendar setup 2026-08-13 19:47:06 +02:00
Thomas Camlong c308ae5c6e fix(ci): prevent autofix race 2026-08-13 19:46:51 +02:00
Thomas Camlong 93192cfedc fix(ci): autofix commits skip CI to prevent race cancellation 2026-08-13 19:46:45 +02:00
Thomas Camlong 0f9ae13560 fix(ci): remove coverage step, fix auto-fix to format all affected workspaces 2026-08-13 19:46:13 +02:00
Thomas Camlong f4a6f37415 style: fix formatting in v2 files 2026-08-13 19:46:00 +02:00
homarr-renovate[bot] 95bfaf5127 chore(deps): update dependency @gfazioli/mantine-onboarding-tour to ^4.0.7 (#6343)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-08-13 19:46:00 +02:00
Thomas 83e3b22f9a feat(custom-widgets): Full Mantine v9 + SubFetch support for custom JSX (#6304) 2026-08-13 19:45:44 +02:00
Thomas cd71404288 Delete .opencode directory 2026-08-13 19:44:18 +02:00
Thomasandcopilot-swe-agent[bot] 7a089ab177 feat: community workshop for custom widgets and CSS (#5984)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-08-13 19:44:18 +02:00
Thomas 2513e8454f fix: open user menu instantly (#6568) 2026-08-13 11:44:10 +02:00
Thomas 527e911eef fix(beszel): let Recharts own tooltip lifecycle (#6560) 2026-08-11 15:35:17 +02:00
Thomas ff2cdecc86 feat: rewrite image picker (#6556)
* feat: rewrite image picker

* style: auto-fix lint and format

* fix: address image picker review

* fix: preserve unicode icon searches

* test: stabilize custom widget login

* fix: suggest image searches only on create
2026-08-11 14:49:52 +02:00
Sven 742bde1d27 fix(synology): support vol_info in storage_v2 responses (#6554) 2026-08-11 08:59:28 +00:00
26 changed files with 322 additions and 201 deletions

No files matched your search

@@ -10,7 +10,7 @@ export const wudWidget: WidgetDefinition = {
items: [
{
name: "Show title",
description: "Displays the icon and \"What's Up Docker\" label above the stats",
description: 'Displays the icon and "What\'s Up Docker" label above the stats',
values: { type: "boolean" },
defaultValue: "yes",
},
@@ -28,7 +28,8 @@ export const wudWidget: WidgetDefinition = {
},
{
name: "Show container update list",
description: "Lists each monitored container that has an image update available, linking to its release notes when known",
description:
"Lists each monitored container that has an image update available, linking to its release notes when known",
values: { type: "boolean" },
defaultValue: "no",
},
@@ -145,6 +145,7 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi
key={id}
component="a"
href={`#${id}`}
onClick={() => setMobilePane(id === "preview" ? "preview" : "configure")}
size="compact-sm"
variant="subtle"
color={invalidSections.has(id) ? "red" : undefined}
@@ -22,6 +22,7 @@ vi.mock("@homarr/api/client", () => ({
vi.mock("@homarr/translation/client", () => ({ useScopedI18n: () => (key: string) => key }));
vi.mock("@homarr/notifications", () => ({ showErrorNotification: vi.fn() }));
const rendererAttempt = vi.hoisted(() => vi.fn());
const codeEditorProps = vi.hoisted(() => vi.fn());
vi.mock("@homarr/widgets/custom-api/custom-jsx-display", () => ({
default: ({ data }: { data: Record<string, unknown> }) => {
@@ -32,7 +33,12 @@ vi.mock("@homarr/widgets/custom-api/custom-jsx-display", () => ({
return <div>widget-renderer</div>;
},
}));
vi.mock("./_code-editor", () => ({ CodeEditor: ({ label }: { label: string }) => <div>{label}</div> }));
vi.mock("./_code-editor", () => ({
CodeEditor: (props: { label: string; readOnly?: boolean }) => {
codeEditorProps(props);
return <div>{props.label}</div>;
},
}));
vi.mock("./_custom-widget-preview-action", () => ({ PreviewActionControl: () => <div>action-control</div> }));
let root: Root;
@@ -109,6 +115,7 @@ describe("Custom Widget preview panel", () => {
);
await act(async () => dataTab?.click());
expect(host.textContent).toContain("requestData");
expect(codeEditorProps).toHaveBeenCalledWith(expect.objectContaining({ label: "requestData", readOnly: true }));
expect(host.textContent).not.toContain("invalid");
const optionsTab = [...host.querySelectorAll<HTMLElement>('[role="tab"]')].find((element) =>
@@ -172,6 +172,7 @@ export function CustomWidgetPreviewPanel(props: PreviewPanelProps) {
label={t("requestData")}
language="json"
value={JSON.stringify({ data: props.preview.data, status: props.preview.status }, null, 2)}
readOnly
onChange={() => undefined}
/>
</Tabs.Panel>
@@ -251,6 +252,7 @@ export function CustomWidgetPreviewPanel(props: PreviewPanelProps) {
label={t("journal")}
language="json"
value={JSON.stringify(journalQuery.data ?? [], null, 2)}
readOnly
onChange={() => undefined}
/>
)}
@@ -87,7 +87,9 @@ export function CustomWidgetSourcesEditor({
form.setFieldValue("secrets", [...current, { sourceId, kind, value, hasValue: existing?.hasValue }]);
};
const addSource = () => {
const id = sources.length === 0 ? "default" : `source-${sources.length + 1}`;
let suffix = sources.length + 1;
while (sources.some(({ id }) => id === `source-${suffix}`)) suffix += 1;
const id = sources.length === 0 ? "default" : `source-${suffix}`;
form.setFieldValue(
"sources",
JSON.stringify(
@@ -0,0 +1,46 @@
// @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 { JsonPreviewEditor } from "./_json-preview-editor";
const editorProps = vi.hoisted(() => vi.fn());
vi.mock("@homarr/translation/client", () => ({ useScopedI18n: () => (key: string) => key }));
vi.mock("./_code-editor", () => ({
CodeEditor: (props: { error?: string; onChange(value: string): void }) => {
editorProps(props);
return <button onClick={() => props.onChange("{invalid")}>edit</button>;
},
}));
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();
});
describe("preview JSON editor", () => {
it("surfaces invalid JSON and clears the previously rendered options", async () => {
const onChange = vi.fn();
await act(async () => {
root.render(<JsonPreviewEditor id="options" label="Options" value={{ stale: true }} onChange={onChange} />);
});
await act(async () => (host.querySelector("button") as HTMLButtonElement).click());
expect(onChange).toHaveBeenCalledWith({});
expect(editorProps).toHaveBeenLastCalledWith(expect.objectContaining({ error: "invalidJson" }));
});
});
@@ -2,6 +2,8 @@
import { useEffect, useState } from "react";
import { useScopedI18n } from "@homarr/translation/client";
import { CodeEditor } from "./_code-editor";
import { isRecord, parseJson } from "./_custom-widget-form-utils";
@@ -16,19 +18,26 @@ export function JsonPreviewEditor({
value: Record<string, unknown>;
onChange(value: Record<string, unknown>): void;
}) {
const t = useScopedI18n("customWidget.workbench.builder");
const serialized = JSON.stringify(value, null, 2);
const [draft, setDraft] = useState(serialized);
useEffect(() => setDraft(serialized), [serialized]);
const [invalid, setInvalid] = useState(false);
useEffect(() => {
if (!invalid) setDraft(serialized);
}, [invalid, serialized]);
return (
<CodeEditor
id={id}
label={label}
language="json"
value={draft}
error={invalid ? t("invalidJson") : undefined}
onChange={(next) => {
setDraft(next);
const parsed = parseJson(next);
if (isRecord(parsed)) onChange(parsed);
const valid = isRecord(parsed);
setInvalid(!valid);
onChange(valid ? parsed : {});
}}
/>
);
@@ -48,4 +48,17 @@ describe("custom widget unsaved changes guard", () => {
expect(listLink.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, button: 0 }))).toBe(true);
expect(confirmNavigation).not.toHaveBeenCalled();
});
it("restores the current page and confirms browser history navigation", () => {
window.history.replaceState(null, "", "/en/manage/custom-widgets");
window.history.pushState(null, "", "/en/manage/custom-widgets/edit/widget-1");
const confirmNavigation = vi.fn();
cleanups.push(registerUnsavedChangesGuard({ isDirty: () => true, confirmNavigation }));
window.history.replaceState(null, "", "/en/manage/custom-widgets");
window.dispatchEvent(new PopStateEvent("popstate"));
expect(window.location.pathname).toBe("/en/manage/custom-widgets/edit/widget-1");
expect(confirmNavigation).toHaveBeenCalledWith("/en/manage/custom-widgets");
});
});
@@ -4,6 +4,7 @@ interface UnsavedChangesGuardOptions {
}
export function registerUnsavedChangesGuard({ isDirty, confirmNavigation }: UnsavedChangesGuardOptions) {
let currentHref = `${window.location.pathname}${window.location.search}${window.location.hash}`;
const handleClick = (event: MouseEvent) => {
if (!isDirty() || event.defaultPrevented || event.button !== 0) return;
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
@@ -32,11 +33,22 @@ export function registerUnsavedChangesGuard({ isDirty, confirmNavigation }: Unsa
event.preventDefault();
event.returnValue = true;
};
const handlePopState = () => {
const destination = `${window.location.pathname}${window.location.search}${window.location.hash}`;
if (!isDirty()) {
currentHref = destination;
return;
}
window.history.pushState(window.history.state, "", currentHref);
confirmNavigation(destination);
};
document.addEventListener("click", handleClick, true);
window.addEventListener("beforeunload", handleBeforeUnload);
window.addEventListener("popstate", handlePopState);
return () => {
document.removeEventListener("click", handleClick, true);
window.removeEventListener("beforeunload", handleBeforeUnload);
window.removeEventListener("popstate", handlePopState);
};
}
@@ -1,38 +1,17 @@
"use client";
import type { ComponentProps, ReactNode } from "react";
import { Suspense, use, useEffect, useState } from "react";
import { Box, Loader, UnstyledButton, useMantineColorScheme } from "@mantine/core";
import type { ReactNode } from "react";
import { useEffect, useState } from "react";
import { UnstyledButton, useMantineColorScheme } from "@mantine/core";
import { useHotkeys } from "@mantine/hooks";
import { ErrorBoundary } from "react-error-boundary";
import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import { hotkeys } from "@homarr/definitions";
import { UserAvatarMenu } from "~/components/user-avatar-menu";
import { UpdateIndicator } from "./update";
type UserAvatarMenuModule = typeof import("~/components/user-avatar-menu");
type UserAvatarMenuProps = ComponentProps<UserAvatarMenuModule["UserAvatarMenu"]>;
let userAvatarMenuPromise: Promise<UserAvatarMenuModule> | undefined;
const loadUserAvatarMenu = () => {
if (userAvatarMenuPromise) return userAvatarMenuPromise;
const promise = import("~/components/user-avatar-menu");
userAvatarMenuPromise = promise;
void promise.catch(() => {
if (userAvatarMenuPromise === promise) userAvatarMenuPromise = undefined;
});
return promise;
};
const preloadUserAvatarMenu = () => void loadUserAvatarMenu().catch(() => undefined);
const LazyUserAvatarMenu = (props: UserAvatarMenuProps) => {
const { UserAvatarMenu } = use(loadUserAvatarMenu());
return <UserAvatarMenu {...props} />;
};
interface UserButtonClientProps {
avatar: ReactNode;
isAdmin: boolean;
@@ -41,8 +20,6 @@ interface UserButtonClientProps {
export const UserButtonClient = ({ avatar, isAdmin, isDockerEnabled }: UserButtonClientProps) => {
const [canCheckForUpdates, setCanCheckForUpdates] = useState(false);
const [isMenuMounted, setIsMenuMounted] = useState(false);
const [isMenuOpen, setIsMenuOpen] = useState(false);
const session = useSession();
const { toggleColorScheme } = useMantineColorScheme();
useHotkeys([[hotkeys.toggleColorScheme, toggleColorScheme]]);
@@ -66,60 +43,14 @@ export const UserButtonClient = ({ avatar, isAdmin, isDockerEnabled }: UserButto
staleTime: 60 * 60 * 1_000,
});
const visibleUpdates = isCurrentSessionAdmin ? availableUpdates : undefined;
const openMenu = () => {
setIsMenuMounted(true);
setIsMenuOpen(true);
};
const renderButton = (onClick: () => void, isLoading = false) => (
<UnstyledButton
onClick={onClick}
onFocus={preloadUserAvatarMenu}
onMouseEnter={preloadUserAvatarMenu}
onPointerDown={preloadUserAvatarMenu}
aria-busy={isLoading || undefined}
disabled={isLoading}
>
<UpdateIndicator availableUpdates={visibleUpdates} disabled={!isCurrentSessionAdmin}>
<Box pos="relative" style={{ display: "inline-flex" }}>
<Box opacity={isLoading ? 0.35 : 1}>{avatar}</Box>
{isLoading && (
<Loader
aria-label="Loading user menu"
size={18}
pos="absolute"
top="50%"
left="50%"
style={{ transform: "translate(-50%, -50%)" }}
/>
)}
</Box>
</UpdateIndicator>
</UnstyledButton>
);
const button = renderButton(openMenu);
const loadingButton = renderButton(() => undefined, true);
if (!isMenuMounted) return button;
return (
<ErrorBoundary
fallbackRender={({ resetErrorBoundary }) =>
renderButton(() => {
resetErrorBoundary();
openMenu();
})
}
>
<Suspense fallback={loadingButton}>
<LazyUserAvatarMenu
availableUpdates={visibleUpdates}
isDockerEnabled={isDockerEnabled}
opened={isMenuOpen}
onOpenChange={setIsMenuOpen}
>
{button}
</LazyUserAvatarMenu>
</Suspense>
</ErrorBoundary>
<UserAvatarMenu availableUpdates={visibleUpdates} isDockerEnabled={isDockerEnabled}>
<UnstyledButton>
<UpdateIndicator availableUpdates={visibleUpdates} disabled={!isCurrentSessionAdmin}>
{avatar}
</UpdateIndicator>
</UnstyledButton>
</UserAvatarMenu>
);
};
@@ -21,17 +21,9 @@ interface UserAvatarMenuProps {
children: ReactNode;
availableUpdates?: RouterOutputs["updateChecker"]["getAvailableUpdates"];
isDockerEnabled?: boolean;
opened: boolean;
onOpenChange: (opened: boolean) => void;
}
export const UserAvatarMenu = ({
children,
availableUpdates,
isDockerEnabled,
opened,
onOpenChange,
}: UserAvatarMenuProps) => {
export const UserAvatarMenu = ({ children, availableUpdates, isDockerEnabled }: UserAvatarMenuProps) => {
const t = useScopedI18n("common.userAvatar.menu");
const session = useSession();
@@ -48,7 +40,7 @@ export const UserAvatarMenu = ({
return (
// We use keepMounted so we can add event listeners to prevent navigating away without saving the board
<Menu width={300} withinPortal keepMounted opened={opened} onChange={onOpenChange}>
<Menu width={300} withinPortal keepMounted>
<Menu.Dropdown>
<AvailableUpdatesMenuItem availableUpdates={availableUpdates} />
<Menu.Item component={Link} href="/boards" leftSection={<IconHome size="1rem" />}>
@@ -295,19 +295,15 @@ export function WorkshopBrowser({ type = "customWidget", onInstall, onUseCss, mo
<Stack gap="xs" mt="md">
<Group justify="space-between">
<Text size="xs" c="dimmed">
{t("author", { name: item.authorName || "Community member" })}
{t("author", { name: item.authorName || t("communityMember") })}
</Text>
<Text size="sm" c="dimmed">
<IconThumbUp size={13} /> {item.upvotes} · <IconThumbDown size={13} /> {item.downvotes}
</Text>
</Group>
<Group gap="xs">
{item.outdated && <Badge color="yellow">Outdated</Badge>}
{item.reportCount > 0 && (
<Badge color="red">
{item.reportCount} report{item.reportCount === 1 ? "" : "s"}
</Badge>
)}
{item.outdated && <Badge color="yellow">{t("outdated")}</Badge>}
{item.reportCount > 0 && <Badge color="red">{t("reportCount", { count: item.reportCount })}</Badge>}
</Group>
<Text fw={700} lineClamp={1}>
{item.title}
@@ -349,7 +345,7 @@ export function WorkshopBrowser({ type = "customWidget", onInstall, onUseCss, mo
<Box>
<Title order={3}>{detail.data.title}</Title>
<Text size="sm" c="dimmed">
{t("author", { name: detail.data.authorName || "Community member" })}
{t("author", { name: detail.data.authorName || t("communityMember") })}
</Text>
</Box>
<Button
@@ -366,20 +362,18 @@ export function WorkshopBrowser({ type = "customWidget", onInstall, onUseCss, mo
{detail.data.description && <Text>{detail.data.description}</Text>}
{detail.data.outdated && (
<Alert color="yellow" icon={<IconAlertTriangle size={18} />}>
The author marked this submission as outdated. Review it carefully before importing.
{t("outdatedWarning")}
</Alert>
)}
{detail.data.reportCount > 0 && (
<Stack gap="xs">
<Alert color="red" icon={<IconAlertTriangle size={18} />}>
This submission has {detail.data.reportCount} open community report
{detail.data.reportCount === 1 ? "" : "s"}. It remains installable, but you should review its content
first.
{t("reportWarning", { count: detail.data.reportCount })}
</Alert>
{reportSummaries.data?.map((summary) => (
<Card key={summary.id} withBorder p="sm">
<Badge color="red" variant="light" mb="xs">
{summary.category}
{t(`reportCategory.${summary.category}`)}
</Badge>
<Text size="sm" style={{ whiteSpace: "pre-wrap" }}>
{summary.explanation}
@@ -388,7 +382,7 @@ export function WorkshopBrowser({ type = "customWidget", onInstall, onUseCss, mo
))}
{reportSummaries.data?.length === 0 && (
<Text size="xs" c="dimmed">
Report details are visible only to this submission's author and Workshop admins.
{t("reportVisibility")}
</Text>
)}
</Stack>
@@ -400,7 +394,7 @@ export function WorkshopBrowser({ type = "customWidget", onInstall, onUseCss, mo
key={file}
src={client.fileUrl(detail.data.id, file, "960x640")}
radius="md"
alt={`${detail.data.title} screenshot ${index + 1}`}
alt={t("screenshotAlt", { title: detail.data.title, count: index + 1 })}
/>
))}
</SimpleGrid>
@@ -433,6 +427,7 @@ export function WorkshopBrowser({ type = "customWidget", onInstall, onUseCss, mo
leftSection={<IconThumbUp size={16} />}
loading={vote.isPending && vote.variables?.value === 1}
disabled={!user || vote.isPending}
aria-label={t("upvote", { count: detail.data.upvotes })}
onClick={() => vote.mutate({ submission: detail.data.id, value: 1 })}
>
{detail.data.upvotes}
@@ -442,6 +437,7 @@ export function WorkshopBrowser({ type = "customWidget", onInstall, onUseCss, mo
leftSection={<IconThumbDown size={16} />}
loading={vote.isPending && vote.variables?.value === -1}
disabled={!user || vote.isPending}
aria-label={t("downvote", { count: detail.data.downvotes })}
onClick={() => vote.mutate({ submission: detail.data.id, value: -1 })}
>
{detail.data.downvotes}
@@ -508,7 +504,10 @@ export function WorkshopBrowser({ type = "customWidget", onInstall, onUseCss, mo
label={t("reportReason")}
value={reportCategory}
onChange={(value) => setReportCategory((value as WorkshopReport["category"]) ?? "other")}
data={["outdated", "malicious", "spam", "copyright", "inappropriate", "other"]}
data={(["outdated", "malicious", "spam", "copyright", "inappropriate", "other"] as const).map((value) => ({
value,
label: t(`reportCategory.${value}`),
}))}
allowDeselect={false}
/>
<Textarea
@@ -6,6 +6,7 @@ import { Alert, Button, Checkbox, FileInput, Group, Modal, Stack, Textarea, Text
import { clientApi } from "@homarr/api/client";
import { showErrorNotification } from "@homarr/notifications";
import { useWorkshopCreateMutation } from "@homarr/workshop/backend";
import { workshopScreenshotsSchema } from "@homarr/workshop/schema";
import type { WorkshopUser } from "@homarr/workshop/schema";
import { useScopedI18n } from "@homarr/translation/client";
@@ -59,6 +60,9 @@ export function WorkshopPublishModal({
setError(null);
try {
if (!definition.data) throw new Error(t("publish.error"));
if (!workshopScreenshotsSchema.safeParse(screenshots).success) {
throw new Error(t("publish.invalidScreenshot"));
}
const inspectedDefinition = definition.data;
const result = await publishWorkshopDefinition({
inspectedDefinition,
+14 -3
View File
@@ -21,7 +21,9 @@
<div style="font-size: 12px; font-weight: bold; letter-spacing: 1px; text-transform: uppercase">
Moderation alert
</div>
<div style="margin-top: 8px; font-size: 22px; font-weight: bold">A Workshop submission was reported</div>
<div style="margin-top: 8px; font-size: 22px; font-weight: bold">
A Workshop submission was reported
</div>
</td>
</tr>
<tr>
@@ -29,7 +31,9 @@
<p style="margin: 0 0 18px">
<strong>{{reporterName}}</strong> reported <a href="{{submissionUrl}}">{{submissionTitle}}</a>.
</p>
<p style="margin: 0 0 8px; font-size: 12px; font-weight: bold; text-transform: uppercase; color: #991b1b">
<p
style="margin: 0 0 8px; font-size: 12px; font-weight: bold; text-transform: uppercase; color: #991b1b"
>
{{category}}
</p>
<blockquote
@@ -39,7 +43,14 @@
</blockquote>
<a
href="{{adminUrl}}"
style="display: inline-block; padding: 12px 20px; background: #991b1b; color: #ffffff; font-weight: bold; text-decoration: none"
style="
display: inline-block;
padding: 12px 20px;
background: #991b1b;
color: #ffffff;
font-weight: bold;
text-decoration: none;
"
>
REVIEW REPORT
</a>
+4 -1
View File
@@ -36,7 +36,10 @@ describe("Custom JSX v2 workbench", () => {
await page.waitForLoadState("networkidle");
await page.getByLabel("Username").fill(adminCredentials.username);
await page.locator("#password").fill(adminCredentials.password);
const signedIn = page.waitForURL(baseUrl, { waitUntil: "commit", timeout: 15_000 });
const signedIn = page.waitForURL((url) => url.origin === baseUrl && url.pathname !== "/auth/login", {
waitUntil: "commit",
timeout: 60_000,
});
await page.locator("button[type='submit']").click();
await signedIn;
@@ -8,12 +8,20 @@ import {
recordPreviewJournal,
resolvePreviewRequestParams,
} from "./preview-procedure-helpers";
import { executeCustomWidgetRequest } from "./request-executor";
import { executeCustomWidgetRequest, invalidateCustomWidgetResponseCache } from "./request-executor";
import { hashRuntimeParams, renderRequestBody, renderRequestTarget } from "./request-manifest";
import { acquireCustomWidgetRequestLimit } from "./request-limits";
import { getPreviewJournal, getPreviewSession, setPreviewSessionLiveActions } from "./preview-sessions";
export const previewQueryProcedures = {
previewRefresh: permissionRequiredProcedure
.requiresPermission("admin")
.input(z.object({ sessionId: z.string().min(1) }))
.mutation(async ({ ctx, input }) => {
const session = await getPreviewSession(input.sessionId, ctx.session.user.id);
invalidateCustomWidgetResponseCache([`custom-jsx:preview:${session.id}:`]);
}),
previewQuery: permissionRequiredProcedure
.requiresPermission("admin")
.meta({
@@ -10,11 +10,12 @@ import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
const mocks = vi.hoisted(() => ({
executeRequest: vi.fn(async () => ({ ok: true, status: 200, statusText: "OK", data: { value: 42 } })),
invalidateResponseCache: vi.fn(),
}));
vi.mock("../../custom-widget/request-executor", () => ({
executeCustomWidgetRequest: mocks.executeRequest,
invalidateCustomWidgetResponseCache: vi.fn(),
invalidateCustomWidgetResponseCache: mocks.invalidateResponseCache,
}));
vi.mock("../../custom-widget/request-limits", () => ({
@@ -111,7 +112,21 @@ async function setup(
}
describe("Custom JSX v2 board router", () => {
beforeEach(() => mocks.executeRequest.mockClear());
beforeEach(() => {
mocks.executeRequest.mockClear();
mocks.invalidateResponseCache.mockClear();
});
test("refreshes the server response cache for a placed widget", async () => {
const { db, itemId } = await setup();
const caller = customApiRouter.createCaller({ db, deviceType: undefined, session: null });
await caller.refresh({ itemId });
expect(mocks.invalidateResponseCache).toHaveBeenCalledWith([
expect.stringMatching(new RegExp(`^custom-jsx:${itemId}:[a-f0-9]{16}:$`, "u")),
]);
});
test("loads placed widgets and public queries for anonymous visitors on public boards", async () => {
const { db, itemId } = await setup();
@@ -195,6 +195,13 @@ const executeRequest = async (
};
export const customApiRouter = createTRPCRouter({
refresh: publicProcedure.input(itemInputSchema).mutation(async ({ ctx, input }) => {
const resolved = await resolvePlacedDefinitionAsync(ctx, input.itemId);
invalidateCustomWidgetResponseCache([
`custom-jsx:${resolved.item.id}:${getCustomWidgetCacheVersion(resolved.stored)}:`,
]);
}),
getData: publicProcedure.input(itemInputSchema).query(async ({ ctx, input }) => {
const resolved = await resolvePlacedDefinitionAsync(ctx, input.itemId);
const loadRequests = Object.entries(resolved.definition.requests).filter(
@@ -53,8 +53,10 @@ export function SubFetch(props: SubFetchProps) {
(props.trigger !== "manual" || manualRun),
);
const refreshMs = normalizeRefreshInterval(props.refreshInterval);
const scope = itemId ? "item" : "preview";
const scopeId = itemId ?? previewSessionId;
const query = useQuery({
queryKey: ["custom-widget", itemId ?? previewSessionId, props.requestId, paramsKey],
queryKey: ["custom-widget", scope, scopeId, props.requestId, paramsKey],
queryFn: ({ signal }) =>
port.query({ itemId, previewSessionId, requestId: props.requestId ?? "", params: params ?? {} }, signal),
enabled,
@@ -10,8 +10,28 @@ describe("Workshop navigation translations", () => {
const messages = JSON.parse(await readFile(resolve(languageDirectory, "en.json"), "utf8")) as {
management?: { navbar?: { items?: { workshop?: unknown } } };
navigationStructure?: { manage?: { workshop?: { label?: unknown } } };
workshop?: Record<string, unknown>;
};
expect(messages.management?.navbar?.items?.workshop).toBe("Workshop");
expect(messages.navigationStructure?.manage?.workshop?.label).toBe("Workshop");
expect(messages.workshop).toMatchObject({
communityMember: "Community member",
outdated: "Outdated",
reportCount: expect.any(String),
reportWarning: expect.any(String),
reportVisibility: expect.any(String),
screenshotAlt: expect.any(String),
upvote: expect.any(String),
downvote: expect.any(String),
reportCategory: expect.objectContaining({
outdated: expect.any(String),
malicious: expect.any(String),
spam: expect.any(String),
copyright: expect.any(String),
inappropriate: expect.any(String),
other: expect.any(String),
}),
publish: expect.objectContaining({ invalidScreenshot: expect.any(String) }),
});
});
});
@@ -85,6 +85,11 @@ export class SynologyClient {
if (volumesFromV2.length > 0) {
return volumesFromV2;
}
const legacyVolumesFromV2 = this.mapLegacyVolumes(storageV2.vol_info);
if (legacyVolumesFromV2.length > 0) {
return legacyVolumesFromV2;
}
} catch (error) {
logger.debug("storage_v2 unavailable, falling back to legacy storage info", {
integrationId: this.integrationId,
@@ -78,6 +78,7 @@ export const synologyStorageV2VolumeSchema = z
export const synologyStorageV2DataSchema = z.object({
volumes: z.array(synologyStorageV2VolumeSchema).optional(),
vol_info: z.array(synologyVolumeInfoSchema).optional(),
});
export const synologyUtilizationCpuSchema = z.object({
@@ -426,6 +426,60 @@ describe("SynologyIntegration.getSystemInfoAsync", () => {
expect(result.fileSystem[0]?.deviceName).toBe("volume_1");
});
test("uses vol_info from storage_v2 without falling back to legacy storage", async () => {
mockSuccessfulFlow({
storageV2: {
success: true,
data: {
vol_info: [
{
name: "volume_1",
used_size: "4000000000",
total_size: "10000000000",
status: "normal",
},
{
name: "volume_2",
used_size: "5000000000",
total_size: "20000000000",
status: "attention",
},
],
},
},
});
const integration = createIntegration();
const result = await integration.getSystemInfoAsync();
expect(result.fileSystem).toEqual([
{
deviceName: "volume_1",
used: "4000000000",
available: "6000000000",
percentage: 40,
},
{
deviceName: "volume_2",
used: "5000000000",
available: "15000000000",
percentage: 25,
},
]);
const legacyStorageRequests = mockFetch.mock.calls.filter(([url]) => {
const urlString = toUrlString(url);
return (
getApiFromUrl(urlString) === "SYNO.Core.System" &&
getMethodFromUrl(urlString) === "info" &&
getTypeFromUrl(urlString) === "storage"
);
});
expect(legacyStorageRequests).toHaveLength(0);
});
test("continues when optional tier-2 calls fail", async () => {
mockSuccessfulFlow({ skipOptional: true });
const integration = createIntegration();
+18
View File
@@ -1151,6 +1151,15 @@
"emptyCss": "No CSS themes match this search.",
"noDescription": "No description",
"author": "by {name}",
"communityMember": "Community member",
"outdated": "Outdated",
"outdatedWarning": "The author marked this submission as outdated. Review it carefully before importing.",
"reportCount": "{count, plural, one {# report} other {# reports}}",
"reportWarning": "{count, plural, one {This submission has # open community report.} other {This submission has # open community reports.}} It remains installable, but you should review its content first.",
"reportVisibility": "Report details are visible only to this submission's author and Workshop admins.",
"screenshotAlt": "{title} screenshot {count, number}",
"upvote": "Upvote, {count, plural, one {# vote} other {# votes}}",
"downvote": "Downvote, {count, plural, one {# vote} other {# votes}}",
"inspectCss": "Inspect CSS",
"sourceCode": "Workshop entry",
"loadError": "This submission could not be loaded.",
@@ -1172,6 +1181,14 @@
"reportSend": "Send report",
"reportSent": "Report sent",
"reportSentDescription": "Workshop moderators can now review this submission.",
"reportCategory": {
"outdated": "Outdated or no longer working",
"malicious": "Malicious",
"spam": "Spam",
"copyright": "Copyright",
"inappropriate": "Inappropriate",
"other": "Other"
},
"voteError": "The vote could not be saved.",
"publish": {
"title": "Publish to Workshop",
@@ -1181,6 +1198,7 @@
"descriptionField": "Description",
"screenshots": "Screenshots",
"screenshotsDescription": "Up to five PNG, JPEG, or WebP images",
"invalidScreenshot": "Use PNG, JPEG, or WebP images no larger than 5 MB each.",
"privateSourceWarning": "This widget contains private source URLs for: {sources}. Replace personal hosts or IP addresses with safe service placeholders before publishing.",
"privateSourceConfirmation": "I reviewed these source URLs and they do not expose private infrastructure details",
"action": "Publish now",
+21 -79
View File
@@ -1,8 +1,6 @@
"use client";
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";
@@ -47,7 +45,7 @@ export interface TooltipPayloadItem {
payload?: Record<string, unknown>;
}
interface PortalTooltipProps {
interface BeszelTooltipProps {
active?: boolean;
label?: string;
payload?: TooltipPayloadItem[];
@@ -55,53 +53,8 @@ interface PortalTooltipProps {
showTotal?: boolean;
}
const MARGIN = 14;
const PortalTooltipContent = ({ active, label, payload, formatter, showTotal }: PortalTooltipProps) => {
const mouseRef = useRef({ x: 0, y: 0 });
const [pos, setPos] = useState({ x: 0, y: 0 });
const rafRef = useRef(0);
const tooltipRef = useRef<HTMLDivElement>(null);
const handleMouse = useCallback((e: MouseEvent) => {
mouseRef.current = { x: e.clientX, y: e.clientY };
cancelAnimationFrame(rafRef.current);
rafRef.current = requestAnimationFrame(() => {
const el = tooltipRef.current;
const mx = mouseRef.current.x;
const my = mouseRef.current.y;
if (!el) {
setPos({ x: mx + MARGIN, y: my });
return;
}
const { width, height } = el.getBoundingClientRect();
const vw = window.innerWidth;
const vh = window.innerHeight;
let x = mx + MARGIN;
let y = my - height / 2;
if (x + width > vw - MARGIN) x = mx - width - MARGIN;
if (y < MARGIN) y = MARGIN;
if (y + height > vh - MARGIN) y = vh - MARGIN - height;
setPos({ x, y });
});
}, []);
const BeszelTooltipContent = ({ active, label, payload, formatter, showTotal }: BeszelTooltipProps) => {
const isActive = active && (payload?.length ?? 0) > 0;
useEffect(() => {
if (!isActive) return;
document.addEventListener("mousemove", handleMouse);
return () => {
document.removeEventListener("mousemove", handleMouse);
cancelAnimationFrame(rafRef.current);
};
}, [isActive, handleMouse]);
if (!isActive) return null;
const deduped = new Map<string, TooltipPayloadItem>();
@@ -116,41 +69,30 @@ const PortalTooltipContent = ({ active, label, payload, formatter, showTotal }:
const tooltipLabel = rawTime ? dayjs(rawTime).format("MMM D, LT") : label;
const total = sorted.reduce((sum, p) => sum + p.value, 0);
const portalStyle: CSSProperties = {
position: "fixed",
left: pos.x,
top: pos.y,
zIndex: 10000,
pointerEvents: "none",
};
return createPortal(
<div ref={tooltipRef} style={portalStyle}>
<div style={styles.wrapper}>
<div style={styles.header}>{tooltipLabel}</div>
{sorted.map((item) => (
<div key={item.dataKey} style={styles.row}>
<div style={{ ...styles.indicator, background: item.color }} />
<span style={styles.name}>{item.name}</span>
<span style={styles.value}>{formatter(item.value)}</span>
</div>
))}
{showTotal && sorted.length > 1 && (
<div style={{ ...styles.row, ...styles.separator }}>
<span style={styles.name}>Total</span>
<span style={styles.value}>{formatter(total)}</span>
</div>
)}
</div>
</div>,
document.body,
return (
<div style={styles.wrapper}>
<div style={styles.header}>{tooltipLabel}</div>
{sorted.map((item) => (
<div key={item.dataKey} style={styles.row}>
<div style={{ ...styles.indicator, background: item.color }} />
<span style={styles.name}>{item.name}</span>
<span style={styles.value}>{formatter(item.value)}</span>
</div>
))}
{showTotal && sorted.length > 1 && (
<div style={{ ...styles.row, ...styles.separator }}>
<span style={styles.name}>Total</span>
<span style={styles.value}>{formatter(total)}</span>
</div>
)}
</div>
);
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const makeTooltipProps = (formatter: (v: number) => string, showTotal = false) => ({
content: (props: any) => (
<PortalTooltipContent
<BeszelTooltipContent
active={props.active}
label={props.label != null ? String(props.label) : undefined}
payload={props.payload}
@@ -158,6 +100,6 @@ export const makeTooltipProps = (formatter: (v: number) => string, showTotal = f
showTotal={showTotal}
/>
),
wrapperStyle: { visibility: "hidden" as const, position: "absolute" as const, width: 0, height: 0 },
wrapperStyle: { zIndex: 10000, pointerEvents: "none" as const },
isAnimationActive: false,
});
@@ -107,26 +107,42 @@ export function WidgetDefinitionProvider(props: WidgetDefinitionProviderProps) {
confirmed: input.confirmed,
}),
invalidate: async ({ itemId, previewSessionId, targets }) => {
const invalidateAll = targets.includes("*");
if (previewSessionId) {
if (invalidateAll) {
await fetchApi.customWidget.previewRefresh.mutate({ sessionId: previewSessionId });
}
await queryClient.invalidateQueries({
predicate: (query) => {
const key = query.queryKey;
return key[0] === "custom-widget" && key[1] === previewSessionId && targets.includes(String(key[2]));
return (
key[0] === "custom-widget" &&
key[1] === "preview" &&
key[2] === previewSessionId &&
(invalidateAll || targets.includes(String(key[3])))
);
},
});
return;
}
if (!itemId) return;
if (targets.length === 0) return;
if (invalidateAll) await fetchApi.widget.customApi.refresh.mutate({ itemId });
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]));
return (
key[0] === "custom-widget" &&
key[1] === "item" &&
key[2] === itemId &&
(invalidateAll || targets.includes(String(key[3])))
);
},
}),
];
if (
invalidateAll ||
(props.requestCapabilities ?? []).some((request) => request.trigger === "load" && targets.includes(request.id))
) {
tasks.push(utils.widget.customApi.getData.invalidate({ itemId }));