fix: refine widget modernization

This commit is contained in:
Thomas Camlong authored and ajnart committed 2026-08-13 19:54:29 +02:00
1 parent 6f2cd7fc2b
commit 777bbd89a1
44 files changed
+259 -320

No files matched your search

@@ -22,6 +22,7 @@
overscroll-behavior: contain;
animation: focus-surface-in 220ms cubic-bezier(0.16, 1, 0.3, 1);
transform-origin: top left;
padding-top: 60px;
}
.backdropClosing {
@@ -57,6 +58,21 @@
pointer-events: none;
}
.surfaceControlsAdvanced {
right: 0;
bottom: auto;
height: 60px;
background: color-mix(in srgb, var(--mantine-color-body) 92%, transparent);
border-bottom: 1px solid var(--mantine-color-default-border);
}
.surfaceTitle {
position: absolute;
top: 20px;
right: 120px;
left: 16px;
}
.closeButton {
position: absolute;
top: 8px;
@@ -68,7 +84,7 @@
.pinButton {
position: absolute;
top: 8px;
right: 52px;
right: 60px;
z-index: 2;
pointer-events: auto;
}
@@ -82,7 +98,7 @@
pointer-events: auto;
}
@media (pointer: coarse) {
@media (any-pointer: coarse) {
.touchButton {
display: inline-flex;
}
@@ -4,12 +4,12 @@ import { getAdvancedFocusRect } from "./geometry";
describe("getAdvancedFocusRect", () => {
it("expands a widget and keeps it inside the viewport", () => {
expect(getAdvancedFocusRect({ left: 10, top: 10, width: 300, height: 200 }, { width: 1200, height: 800 })).toEqual({
left: 240,
top: 190,
width: 720,
height: 480,
});
const rect = getAdvancedFocusRect({ left: 10, top: 10, width: 300, height: 200 }, { width: 1200, height: 800 });
expect(rect.left).toBe(120);
expect(rect.top).toBeCloseTo(118.6);
expect(rect.width).toBe(960);
expect(rect.height).toBeCloseTo(622.8);
});
it("uses the available viewport on small screens", () => {
@@ -11,8 +11,10 @@ export const getAdvancedFocusRect = (source: FocusRect, viewport: { width: numbe
const margin = viewport.width < 640 ? 8 : 24;
const availableWidth = Math.max(0, viewport.width - margin * 2);
const availableHeight = Math.max(0, viewport.height - appShellHeaderHeight - margin * 2);
const width = Math.min(availableWidth, Math.max(source.width, Math.min(960, source.width * 2.4)));
const height = Math.min(availableHeight, Math.max(source.height, Math.min(720, source.height * 2.4)));
const preferredWidth = viewport.width < 640 ? availableWidth : Math.min(960, availableWidth * 0.9);
const preferredHeight = viewport.width < 640 ? availableHeight : Math.min(720, availableHeight * 0.9);
const width = Math.min(availableWidth, Math.max(source.width, preferredWidth));
const height = Math.min(availableHeight, Math.max(source.height, preferredHeight));
return {
left: Math.max(margin, (viewport.width - width) / 2),
@@ -3,7 +3,6 @@ import { useCallback } from "react";
import { useUpdateBoard } from "@homarr/boards/updater";
import type { BoardItemAdvancedOptions } from "@homarr/validation/shared";
import type { Board } from "~/app/[locale]/boards/_types";
import type { CreateItemInput } from "./actions/create-item";
import { createItemCallback } from "./actions/create-item";
import type { DuplicateItemInput } from "./actions/duplicate-item";
@@ -33,17 +32,7 @@ interface UpdateItemIntegrations {
export const useItemActions = () => {
const { updateBoard } = useUpdateBoard();
const createItem = useCallback(
(input: CreateItemInput) => {
let nextBoard: Board | undefined;
updateBoard((previous) => {
nextBoard = createItemCallback(input)(previous);
return nextBoard;
});
return nextBoard;
},
[updateBoard],
);
const createItem = useCallback((input: CreateItemInput) => updateBoard(createItemCallback(input)), [updateBoard]);
const duplicateItem = useCallback(
({ itemId }: DuplicateItemInput) => {
+9
View File
@@ -76,6 +76,8 @@ describe("Board advanced interactions", () => {
await page.keyboard.down("Shift");
await widget.hover();
await expect(advancedDialog).toBeVisible({ timeout: 2_000 });
const advancedBounds = await advancedDialog.boundingBox();
expect(advancedBounds?.width).toBeGreaterThanOrEqual(900);
await expect(advancedDialog).toHaveAttribute("data-lifecycle-probe", "same-instance");
await expect(widget).toBeFocused();
await page.keyboard.up("Shift");
@@ -156,8 +158,15 @@ describe("Board advanced interactions", () => {
if (!bounds) return;
expect(bounds.x).toBeGreaterThanOrEqual(0);
expect(bounds.y).toBeGreaterThanOrEqual(60);
expect(bounds.width).toBeGreaterThanOrEqual(44);
expect(bounds.height).toBeGreaterThanOrEqual(44);
expect(bounds.x + bounds.width).toBeLessThanOrEqual(1366);
expect(bounds.y + bounds.height).toBeLessThanOrEqual(768);
const dialogBounds = await page.getByRole("dialog", { name: "Date and time advanced view" }).boundingBox();
const contentBounds = await page.locator(".clock-widget-container").boundingBox();
expect(dialogBounds).not.toBeNull();
expect(contentBounds).not.toBeNull();
if (dialogBounds && contentBounds) expect(contentBounds.y).toBeGreaterThanOrEqual(dialogBounds.y + 60);
await closeButton.click();
await expect(page.getByRole("dialog", { name: "Date and time advanced view" })).toBeHidden();
} finally {
@@ -37,7 +37,6 @@ const queryFirewall = <
summary: createFallback(),
error: error instanceof Error ? error.message : "Integration request failed",
}),
throwOnAllFailures: true,
},
),
);
-18
View File
@@ -1,18 +0,0 @@
import { describe, expect, it } from "vitest";
import { getBoardSaveScopeId, shouldRefetchAfterSaveError } from "./updater";
describe("board persistence coordination", () => {
it("uses one mutation scope for every save of the same board", () => {
expect(getBoardSaveScopeId("board-1")).toBe(getBoardSaveScopeId("board-1"));
expect(getBoardSaveScopeId("board-1")).not.toBe(getBoardSaveScopeId("board-2"));
});
it("only refetches after a failed save when no newer local board exists", () => {
const failedBoard = { id: "board-1", items: ["first"] };
const newerBoard = { id: "board-1", items: ["first", "second"] };
expect(shouldRefetchAfterSaveError(failedBoard, failedBoard)).toBe(true);
expect(shouldRefetchAfterSaveError(newerBoard, failedBoard)).toBe(false);
});
});
+3 -10
View File
@@ -14,11 +14,6 @@ export const updateBoardName = (name: string | null) => {
boardName = name;
};
export const getBoardSaveScopeId = (boardId: string) => `board-save:${boardId}`;
export const shouldRefetchAfterSaveError = <T>(currentBoard: T | undefined, failedBoard: T) =>
currentBoard === failedBoard;
export const useUpdateBoard = () => {
const utils = clientApi.useUtils();
@@ -51,19 +46,17 @@ export const usePersistBoard = ({ id, name }: Pick<Board, "id" | "name">) => {
const utils = clientApi.useUtils();
const { updateBoard } = useUpdateBoard();
const { mutateAsync: saveBoardAsync } = clientApi.board.saveBoard.useMutation({
scope: { id: getBoardSaveScopeId(id) },
scope: { id: `board-save:${id}` },
});
const updateAndPersistBoard = useCallback(
(updater: UpdateCallback, options?: PersistBoardOptions) => {
const updatedBoard = updateBoard(updater);
if (!updatedBoard) return undefined;
const persistedBoard = utils.board.getBoardByName.getData({ name });
const persistedBoard = updateBoard(updater);
if (!persistedBoard) return undefined;
void saveBoardAsync(persistedBoard).catch((error: unknown) => {
const currentBoard = utils.board.getBoardByName.getData({ name });
if (shouldRefetchAfterSaveError(currentBoard, persistedBoard)) {
if (currentBoard === persistedBoard) {
void utils.board.getBoardByName.invalidate({ name });
}
options?.onError?.(error);
+4
View File
@@ -2782,6 +2782,10 @@
"running": "Running automation",
"success": "Automation completed"
},
"status": {
"running": "Running automation",
"success": "Automation completed"
},
"error": {
"executeFailed": "Unable to run automation"
}
+4 -33
View File
@@ -133,7 +133,6 @@ export default function AnchorNoteWidget({
width,
height,
displayMode,
widgetStateRef,
}: WidgetComponentProps<"anchorNote">) {
const t = useScopedI18n("widget.anchorNote");
const noteId = options.noteId.trim();
@@ -157,20 +156,10 @@ export default function AnchorNoteWidget({
width={width}
height={height}
isAdvanced={displayMode === "advanced"}
widgetStateRef={widgetStateRef}
/>
);
}
interface AnchorNotePersistedState {
integrationId: string;
noteId: string;
isEditing: boolean;
draftTitle: string;
draftContent: string;
saveError: string | null;
}
interface AnchorNoteWidgetContentProps {
options: WidgetComponentProps<"anchorNote">["options"];
integrationId: string;
@@ -178,7 +167,6 @@ interface AnchorNoteWidgetContentProps {
width: number;
height: number;
isAdvanced: boolean;
widgetStateRef?: WidgetComponentProps<"anchorNote">["widgetStateRef"];
}
const AnchorNoteWidgetContent = ({
@@ -188,35 +176,18 @@ const AnchorNoteWidgetContent = ({
width,
height,
isAdvanced,
widgetStateRef,
}: AnchorNoteWidgetContentProps) => {
const t = useScopedI18n("widget.anchorNote");
const persistedState = widgetStateRef?.current?.anchorNote as AnchorNotePersistedState | undefined;
const canHydratePersistedState = persistedState?.integrationId === integrationId && persistedState.noteId === noteId;
const { data: note, refetch } = clientApi.widget.anchorNotes.getNote.useQuery({
integrationId,
noteId,
});
const { mutateAsync: updateNoteAsync, isPending: isUpdating } = clientApi.widget.anchorNotes.updateNote.useMutation();
const [isEditing, setIsEditing] = useState(canHydratePersistedState ? (persistedState?.isEditing ?? false) : false);
const [draftTitle, setDraftTitle] = useState(
canHydratePersistedState ? (persistedState?.draftTitle ?? "") : (note?.title ?? ""),
);
const [draftContent, setDraftContent] = useState(
canHydratePersistedState ? (persistedState?.draftContent ?? "") : (note?.content ?? ""),
);
const [saveError, setSaveError] = useState<string | null>(
canHydratePersistedState ? (persistedState?.saveError ?? null) : null,
);
useEffect(() => {
if (!widgetStateRef) return;
widgetStateRef.current = {
...widgetStateRef.current,
anchorNote: { integrationId, noteId, isEditing, draftTitle, draftContent, saveError },
};
}, [draftContent, draftTitle, integrationId, isEditing, noteId, saveError, widgetStateRef]);
const [isEditing, setIsEditing] = useState(false);
const [draftTitle, setDraftTitle] = useState(note?.title ?? "");
const [draftContent, setDraftContent] = useState(note?.content ?? "");
const [saveError, setSaveError] = useState<string | null>(null);
useEffect(() => {
if (isEditing || !note) return;
@@ -5,7 +5,6 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("archiveTeamWarrior", {
icon: IconArchive,
queryKey: [["widget", "archiveTeamWarrior"]],
maxIntegrations: 1,
createOptions() {
return optionsBuilder.from((factory) => ({
-1
View File
@@ -5,7 +5,6 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("bazarr", {
icon: IconBadgeCc,
queryKey: [["widget", "bazarr"]],
supportedIntegrations: ["bazarr"],
integrationsRequired: true,
maxIntegrations: 1,
+2 -14
View File
@@ -1,6 +1,6 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useMemo, useState } from "react";
import { useParams } from "next/navigation";
import { Box, Stack, Text, useMantineTheme } from "@mantine/core";
import { Calendar } from "@mantine/dates";
@@ -19,19 +19,7 @@ import { CalendarEventList } from "./calendar-event-list";
import classes from "./component.module.css";
export default function CalendarWidget(props: WidgetComponentProps<"calendar">) {
const persistedState = props.widgetStateRef?.current?.calendar as { monthTimestamp?: number } | undefined;
const [month, setMonth] = useState(() => {
const timestamp = persistedState?.monthTimestamp;
return typeof timestamp === "number" && Number.isFinite(timestamp) ? new Date(timestamp) : new Date();
});
useEffect(() => {
if (!props.widgetStateRef) return;
props.widgetStateRef.current = {
...props.widgetStateRef.current,
calendar: { monthTimestamp: month.getTime() },
};
}, [month, props.widgetStateRef]);
const [month, setMonth] = useState(() => new Date());
if (props.integrationIds.length === 0) {
return <CalendarBase {...props} events={[]} month={month} setMonth={setMonth} />;
-1
View File
@@ -9,7 +9,6 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("calendar", {
icon: IconCalendar,
queryKey: [["widget", "calendar"]],
createOptions() {
return optionsBuilder.from((factory) => ({
releaseType: factory.multiSelect({
-1
View File
@@ -5,7 +5,6 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("coolify", {
icon: IconCloud,
queryKey: [["widget", "coolify"]],
createOptions() {
return optionsBuilder.from((factory) => ({
showServers: factory.switch({
-1
View File
@@ -6,7 +6,6 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("customApi", {
icon: IconApi,
queryKey: [["widget", "customApi"]],
contextActions({ options, widgetStateRef }) {
return [
{
-1
View File
@@ -42,7 +42,6 @@ const sortColumns = [
export const { definition, componentLoader } = createWidgetDefinition("downloads", {
icon: IconDownload,
queryKey: [["widget", "downloads"]],
refetchInterval: 5,
createOptions() {
return optionsBuilder.from(
+48 -23
View File
@@ -33,6 +33,20 @@ export interface Firewall {
value: string;
}
interface FirewallQueryState {
isPending: boolean;
isFetching: boolean;
isError: boolean;
data?: readonly { error?: string }[];
}
export const hasTotalFirewallFailure = (queries: readonly FirewallQueryState[]) =>
queries.every((query) => !query.isPending && !query.isFetching) &&
queries.every(
(query) =>
query.isError || (query.data !== undefined && query.data.length > 0 && query.data.every(({ error }) => error)),
);
export default function FirewallWidget({
integrationIds,
width,
@@ -48,34 +62,44 @@ export default function FirewallWidget({
setSelectedFirewall(value ?? "");
}, []);
const { data: firewallsCpuData = [] } = clientApi.widget.firewall.getFirewallCpuStatus.useQuery({ integrationIds });
const { data: firewallsMemoryData = [] } = clientApi.widget.firewall.getFirewallMemoryStatus.useQuery({
const cpuQuery = clientApi.widget.firewall.getFirewallCpuStatus.useQuery({ integrationIds });
const memoryQuery = clientApi.widget.firewall.getFirewallMemoryStatus.useQuery({
integrationIds,
});
const { data: firewallsVersionData = [] } = clientApi.widget.firewall.getFirewallVersionStatus.useQuery({
const versionQuery = clientApi.widget.firewall.getFirewallVersionStatus.useQuery({
integrationIds,
});
const { data: firewallsInterfacesData = [] } = clientApi.widget.firewall.getFirewallInterfacesStatus.useQuery({
const interfacesQuery = clientApi.widget.firewall.getFirewallInterfacesStatus.useQuery({
integrationIds,
});
const firewallsCpuData = cpuQuery.data ?? [];
const firewallsMemoryData = memoryQuery.data ?? [];
const firewallsVersionData = versionQuery.data ?? [];
const firewallsInterfacesData = interfacesQuery.data ?? [];
const queries = [cpuQuery, memoryQuery, versionQuery, interfacesQuery];
const hasTotalFailure = hasTotalFirewallFailure(queries);
const [accordionValue, setAccordionValue] = useLocalStorage<string | null>({
key: `homarr-${itemId}-firewall`,
defaultValue: "interfaces",
});
const initialSelectedFirewall = firewallsVersionData[0]?.integration.id ?? "";
const activeFirewall = firewallsVersionData.some(({ integration }) => integration.id === selectedFirewall)
? selectedFirewall
: initialSelectedFirewall;
const displayedFirewallIds = isAdvanced
? firewallsVersionData.map(({ integration }) => integration.id)
: activeFirewall
? [activeFirewall]
: [];
const dropdownItems = firewallsVersionData.map((firewall) => ({
label: firewall.integration.name,
value: firewall.integration.id,
if (hasTotalFailure) {
throw new Error(t("widget.firewall.error.internalServerError"));
}
const firewallMetadata = new Map(
[...firewallsVersionData, ...firewallsCpuData, ...firewallsMemoryData, ...firewallsInterfacesData].map(
({ integration }) => [integration.id, integration] as const,
),
);
const firewallIds = [...firewallMetadata.keys()];
const initialSelectedFirewall = firewallIds[0] ?? "";
const activeFirewall = firewallMetadata.has(selectedFirewall) ? selectedFirewall : initialSelectedFirewall;
const displayedFirewallIds = isAdvanced ? firewallIds : activeFirewall ? [activeFirewall] : [];
const dropdownItems = firewallIds.map((firewallId) => ({
label: firewallMetadata.get(firewallId)?.name ?? firewallId,
value: firewallId,
}));
return (
@@ -90,7 +114,7 @@ export default function FirewallWidget({
isTiny={isTiny}
/>
<FirewallVersion
firewallsVersionData={firewallsVersionData}
firewallsVersionData={firewallsVersionData.filter(({ error }) => !error)}
selectedFirewall={activeFirewall}
isTiny={isTiny}
/>
@@ -104,16 +128,17 @@ export default function FirewallWidget({
const version = firewallsVersionData.find(({ integration }) => integration.id === firewallId);
const interfaces = firewallsInterfacesData.find(({ integration }) => integration.id === firewallId);
const hasError = Boolean(cpu?.error || memory?.error || version?.error || interfaces?.error);
const metadata = firewallMetadata.get(firewallId);
return (
<FirewallPanel
key={firewallId}
name={version?.integration.name ?? cpu?.integration.name ?? firewallId}
kind={version?.integration.kind}
version={version?.summary.version}
cpu={cpu?.summary.total}
memory={memory?.summary.percent}
interfaces={interfaces?.summary}
name={metadata?.name ?? firewallId}
kind={metadata?.kind}
version={version?.error ? undefined : version?.summary.version}
cpu={cpu?.error ? undefined : cpu?.summary.total}
memory={memory?.error ? undefined : memory?.summary.percent}
interfaces={interfaces?.error ? undefined : interfaces?.summary}
hasError={hasError}
isAdvanced={isAdvanced}
isTiny={isTiny}
@@ -0,0 +1,25 @@
import { describe, expect, it } from "vitest";
import { hasTotalFirewallFailure } from "./component";
const failedQuery = {
isPending: false,
isFetching: false,
isError: false,
data: [{ error: "offline" }],
};
describe("firewall failure state", () => {
it("rejects only after every dimension fails", () => {
expect(hasTotalFirewallFailure([failedQuery, failedQuery, failedQuery, failedQuery])).toBe(true);
expect(
hasTotalFirewallFailure([failedQuery, failedQuery, failedQuery, { ...failedQuery, data: [{ error: undefined }] }]),
).toBe(false);
});
it("lets an error-only persisted cache recover while it refetches", () => {
expect(hasTotalFirewallFailure([failedQuery, failedQuery, { ...failedQuery, isFetching: true }, failedQuery])).toBe(
false,
);
});
});
@@ -21,6 +21,7 @@ export interface FirewallIntegration {
export const FirewallVersion = ({ firewallsVersionData, selectedFirewall, isTiny }: FirewallVersionProps) => {
const t = useI18n();
const version = firewallsVersionData.find(({ integration }) => integration.id === selectedFirewall)?.summary.version;
return (
<Box>
@@ -31,11 +32,7 @@ export const FirewallVersion = ({ firewallsVersionData, selectedFirewall, isTiny
size={isTiny ? "8px" : "xs"}
style={{ minHeight: "24px" }}
>
{firewallsVersionData
.filter(({ integration }) => integration.id === selectedFirewall)
.map(({ summary, integration }) => (
<span key={integration.id}>{formatVersion(summary.version, t("widget.firewall.versionUnknown"))}</span>
))}
{formatVersion(version ?? "", t("widget.firewall.versionUnknown"))}
</Badge>
</Box>
);
-1
View File
@@ -7,7 +7,6 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("firewall", {
icon: IconWall,
queryKey: [["widget", "firewall"]],
refetchInterval: 5,
createOptions() {
return optionsBuilder.from(() => ({}));
@@ -27,13 +27,8 @@ export default function ImmichAlbumCarouselWidget({
displayMode = "compact",
widgetStateRef,
}: WidgetComponentProps<"immich-albumCarousel">) {
const persistedState = widgetStateRef?.current?.immichAlbumCarousel as
| { currentPhotoIndex?: number; paused?: boolean }
| undefined;
const [currentPhotoIndex, setCurrentPhotoIndex] = useState(() =>
typeof persistedState?.currentPhotoIndex === "number" ? persistedState.currentPhotoIndex : 0,
);
const [paused, setPaused] = useState(persistedState?.paused === true);
const [currentPhotoIndex, setCurrentPhotoIndex] = useState(0);
const [paused, setPaused] = useState(false);
const { data: album } = clientApi.widget.immich.getAlbum.useQuery(
{
@@ -53,14 +48,6 @@ export default function ImmichAlbumCarouselWidget({
setCurrentPhotoIndex((current) => Math.min(current, photoAssets.length - 1));
}, [photoAssets.length]);
useEffect(() => {
if (!widgetStateRef) return;
widgetStateRef.current = {
...widgetStateRef.current,
immichAlbumCarousel: { currentPhotoIndex, paused },
};
}, [currentPhotoIndex, paused, widgetStateRef]);
useEffect(() => {
if (!widgetStateRef) return;
const clearActions = () => {
@@ -30,7 +30,10 @@ export default function ImmichServerStatsWidget({
if (!stats) return <WidgetEmptyState />;
const statsContent = (
<SimpleGrid cols={displayMode === "advanced" ? 4 : width >= 320 ? 2 : 1} spacing="sm">
<SimpleGrid
cols={displayMode === "advanced" ? (width >= 720 ? 4 : width >= 360 ? 2 : 1) : width >= 320 ? 2 : 1}
spacing="sm"
>
{options.showUsers && (
<StatItem icon={<IconUsers size={20} />} label={t("widget.immich-serverStats.users")} value={stats.userCount} />
)}
@@ -7,7 +7,6 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("indexerManager", {
icon: IconReportSearch,
queryKey: [["widget", "indexerManager"]],
contextActions: ({ widgetStateRef, context }) => [
{
key: "test-all-indexers",
@@ -7,7 +7,6 @@ import { optionsBuilder } from "../options";
export const { componentLoader, definition } = createWidgetDefinition("mediaServer", {
icon: IconVideo,
queryKey: [["widget", "mediaServer"]],
refetchInterval: 5,
createOptions() {
return optionsBuilder.from((factory) => ({
@@ -11,7 +11,6 @@ export const views = ["workers", "queue", "statistics"] as const;
export const { componentLoader, definition } = createWidgetDefinition("mediaTranscoding", {
icon: IconTransform,
queryKey: [["widget", "mediaTranscoding"]],
maxIntegrations: 1,
refetchInterval: null,
createOptions() {
@@ -21,6 +21,7 @@ export default function NetworkControllerNetworkStatusWidget({
options,
integrationIds,
displayMode,
width,
}: WidgetComponentProps<"networkControllerStatus">) {
const { data: summaries = [] } = clientApi.widget.networkController.summary.useQuery({
integrationIds,
@@ -35,7 +36,7 @@ export default function NetworkControllerNetworkStatusWidget({
return (
<Box p={isAdvanced ? "md" : "sm"} h="100%">
<SimpleGrid cols={isAdvanced ? 2 : 1} h="100%" spacing="sm">
<SimpleGrid cols={isAdvanced && width >= 560 ? 2 : 1} h="100%" spacing="sm">
{(isAdvanced || options.content === "wifi") && (
<Card p={isAdvanced ? "md" : 0} withBorder={isAdvanced}>
<WifiVariant countGuests={countWifiGuests} countUsers={countWifiUsers} compact={!isAdvanced} />
+39 -56
View File
@@ -91,14 +91,6 @@ const controlIconProps = {
stroke: 1.5,
};
interface NotebookPersistedState {
itemId: string | undefined;
content: string;
previousContent: string;
isEditing: boolean;
saveError: string | null;
}
export function Notebook({
options,
setOptions,
@@ -107,58 +99,44 @@ export function Notebook({
itemId,
displayMode,
height,
widgetStateRef,
}: WidgetComponentProps<"notebook">) {
const persistedState = widgetStateRef?.current?.notebook as NotebookPersistedState | undefined;
const canHydratePersistedState = persistedState?.itemId === itemId;
const initialContent = canHydratePersistedState ? (persistedState?.content ?? options.content) : options.content;
const [content, setContent] = useState(initialContent);
const previousContentRef = useRef(
canHydratePersistedState ? (persistedState?.previousContent ?? initialContent) : initialContent,
);
const [saveError, setSaveError] = useState<string | null>(
canHydratePersistedState ? (persistedState?.saveError ?? null) : null,
);
const [content, setContent] = useState(options.content);
const previousContentRef = useRef(options.content);
const [saveError, setSaveError] = useState<string | null>(null);
const board = useRequiredBoard();
const { data: session } = useSession();
const { hasChangeAccess } = constructBoardPermissions(board, session);
const canChange = !isEditMode && hasChangeAccess;
const [isEditing, setIsEditing] = useState(canHydratePersistedState ? (persistedState?.isEditing ?? false) : false);
const [isEditing, setIsEditing] = useState(false);
const canChangeRef = useRef(canChange);
const allowReadOnlyCheckRef = useRef(options.allowReadOnlyCheck);
const savingRef = useRef(false);
const readOnlyCheckEventName = `homarr:notebook-read-only-check:${useId()}`;
useEffect(() => {
canChangeRef.current = canChange;
allowReadOnlyCheckRef.current = options.allowReadOnlyCheck;
}, [canChange, options.allowReadOnlyCheck]);
useEffect(() => {
if (!widgetStateRef) return;
widgetStateRef.current = {
...widgetStateRef.current,
notebook: {
itemId,
content,
previousContent: previousContentRef.current,
isEditing,
saveError,
},
};
}, [content, isEditing, itemId, saveError, widgetStateRef]);
}, [options.allowReadOnlyCheck]);
const { primaryColor } = useMantineTheme();
const { colorScheme } = useMantineColorScheme();
const { mutateAsync } = clientApi.widget.notebook.updateContent.useMutation();
const { mutateAsync, isPending: isSaving } = clientApi.widget.notebook.updateContent.useMutation({
scope: { id: `notebook-content:${boardId ?? "preview"}:${itemId ?? "preview"}` },
});
useEffect(() => {
canChangeRef.current = canChange && !isSaving;
}, [canChange, isSaving]);
const tControls = useScopedI18n("widget.notebook.controls");
const t = useI18n();
const handleContentUpdate = useCallback(
async (contentUpdate: string) => {
savingRef.current = true;
canChangeRef.current = false;
setSaveError(null);
try {
@@ -166,26 +144,17 @@ export function Notebook({
await mutateAsync({ boardId, itemId, content: contentUpdate });
}
previousContentRef.current = contentUpdate;
if (widgetStateRef) {
widgetStateRef.current = {
...widgetStateRef.current,
notebook: {
itemId,
content: contentUpdate,
previousContent: contentUpdate,
isEditing,
saveError: null,
},
};
}
setOptions({ newOptions: { content: contentUpdate } });
return true;
} catch (error) {
setSaveError(error instanceof Error ? error.message : t("widget.notebook.saveFailed"));
return false;
} finally {
savingRef.current = false;
canChangeRef.current = canChange;
}
},
[boardId, isEditing, itemId, mutateAsync, setOptions, t, widgetStateRef],
[boardId, canChange, itemId, mutateAsync, setOptions, t],
);
const editor = useEditor(
@@ -311,6 +280,7 @@ export function Notebook({
}, [editor, isEditing, options.content]);
const handleEditCancelCallback = useCallback(() => {
if (savingRef.current) return true;
if (!editor) return false;
editor.setEditable(false);
@@ -322,6 +292,7 @@ export function Notebook({
const { openConfirmModal } = useConfirmModal();
const handleEditCancel = useCallback(() => {
if (savingRef.current) return;
openConfirmModal({
title: t("widget.notebook.dismiss.title"),
children: t("widget.notebook.dismiss.message"),
@@ -336,7 +307,7 @@ export function Notebook({
}, [setIsEditing, handleEditCancelCallback, openConfirmModal, t]);
const handleEditToggle = useCallback(async () => {
if (!editor) return;
if (!editor || savingRef.current) return;
if (!isEditing) {
setSaveError(null);
editor.setEditable(true);
@@ -344,15 +315,19 @@ export function Notebook({
return;
}
editor.setEditable(false);
const wasSaved = await handleContentUpdate(content);
if (!wasSaved) return;
if (!wasSaved) {
editor.setEditable(true);
return;
}
editor.setEditable(false);
setIsEditing(false);
}, [content, editor, handleContentUpdate, isEditing]);
const handleDoubleClick = useCallback(
(event: ReactMouseEvent<HTMLDivElement>) => {
if (!canChange || isEditing) return;
if (!canChange || isEditing || savingRef.current) return;
// Ignore double-clicks bubbling up from interactive controls (e.g. the
// edit/save ActionIcon), which would otherwise toggle edit mode twice.
if (event.target instanceof Element && event.target.closest("button, a")) {
@@ -377,7 +352,9 @@ export function Notebook({
p={0}
mt={0}
h="100%"
onKeyDown={isEditing ? getHotkeyHandler([[hotkeys.saveNotebook, () => void handleEditToggle()]]) : undefined}
onKeyDown={
isEditing && !isSaving ? getHotkeyHandler([[hotkeys.saveNotebook, () => void handleEditToggle()]]) : undefined
}
editor={editor}
styles={(theme) => ({
root: {
@@ -404,7 +381,8 @@ export function Notebook({
>
<RichTextEditor.Toolbar
style={{
display: isEditing && options.showToolbar === true && displayMode === "advanced" ? "flex" : "none",
display:
isEditing && !isSaving && options.showToolbar === true && displayMode === "advanced" ? "flex" : "none",
}}
>
<RichTextEditor.ControlsGroup>
@@ -505,7 +483,7 @@ export function Notebook({
<RichTextEditor.Redo />
</RichTextEditor.ControlsGroup>
</RichTextEditor.Toolbar>
{editor && (
{editor && isEditing && !isSaving && (
<BubbleMenu editor={editor}>
<RichTextEditor.ControlsGroup>
<RichTextEditor.Bold title={tControls("bold")} />
@@ -557,6 +535,7 @@ export function Notebook({
<>
<ActionIcon
title={isEditing ? t("common.action.save") : t("common.action.edit")}
aria-label={isEditing ? t("common.action.save") : t("common.action.edit")}
style={{
zIndex: 1,
}}
@@ -566,6 +545,8 @@ export function Notebook({
color={primaryColor}
variant="light"
size={30}
loading={isSaving}
disabled={isSaving}
onClick={() => void handleEditToggle()}
>
{isEditing ? <IconDeviceFloppy {...iconProps} /> : <IconEdit {...iconProps} />}
@@ -573,6 +554,7 @@ export function Notebook({
{isEditing && (
<ActionIcon
title={t("common.action.cancel")}
aria-label={t("common.action.cancel")}
style={{
zIndex: 1,
}}
@@ -582,6 +564,7 @@ export function Notebook({
color={primaryColor}
variant="light"
size={30}
disabled={isSaving}
onClick={handleEditCancel}
>
<IconX {...iconProps} />
@@ -5,7 +5,6 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("paperlessNgx", {
icon: IconFileText,
queryKey: [["widget", "paperlessNgx"]],
refetchInterval: null,
supportedIntegrations: ["paperlessNgx"],
integrationsRequired: true,
-1
View File
@@ -8,7 +8,6 @@ import { patchmonOptionsSuperRefine } from "./threshold-validation";
export const { definition, componentLoader } = createWidgetDefinition("patchmon", {
icon: IconShieldCheck,
queryKey: [["widget", "patchmon"]],
supportedIntegrations: ["patchmon"],
integrationsRequired: true,
maxIntegrations: 1,
-1
View File
@@ -14,7 +14,6 @@ const relativeDateSchema = z
export const { definition, componentLoader } = createWidgetDefinition("releases", {
icon: IconRocket,
queryKey: [["widget", "releases"]],
refetchInterval: null,
createOptions() {
return optionsBuilder.from((factory) => ({
-1
View File
@@ -12,7 +12,6 @@ import { optionsBuilder } from "../options";
*/
export const { definition, componentLoader } = createWidgetDefinition("rssFeed", {
icon: IconRss,
queryKey: [["widget", "rssFeed"]],
refetchInterval: null,
createOptions() {
return optionsBuilder.from((factory) => ({
@@ -1,7 +1,18 @@
"use client";
import React from "react";
import { Center, LoadingOverlay, Overlay, Stack, Text, ThemeIcon, Tooltip, UnstyledButton } from "@mantine/core";
import {
Box,
Center,
LoadingOverlay,
Overlay,
Stack,
Text,
ThemeIcon,
Tooltip,
UnstyledButton,
VisuallyHidden,
} from "@mantine/core";
import { useDisclosure, useTimeout } from "@mantine/hooks";
import { IconAutomation, IconCheck } from "@tabler/icons-react";
@@ -75,67 +86,79 @@ export default function SmartHomeTriggerAutomationWidget({
const isTiny = width < 128 || height < 96;
return (
<UnstyledButton
onClick={handleClick}
disabled={!integrationId || !canInteract || isPending}
style={{
cursor: !isEditMode && integrationId && canInteract ? "pointer" : "initial",
pointerEvents: isEditMode ? "none" : undefined,
}}
aria-description={error?.message}
w="100%"
h="100%"
>
{isShowSuccess && (
<Overlay>
<Center w="100%" h="100%">
<ThemeIcon variant="filled" color="green" size="xl" radius="xl">
<IconCheck style={{ width: "70%", height: "70%" }} stroke={1.5} />
</ThemeIcon>
</Center>
</Overlay>
<Box pos="relative" w="100%" h="100%">
<VisuallyHidden role="status">
{isPending
? t("widget.smartHome-executeAutomation.status.running")
: isShowSuccess
? t("widget.smartHome-executeAutomation.status.success")
: ""}
</VisuallyHidden>
{error && (
<VisuallyHidden role="alert">{t("widget.smartHome-executeAutomation.error.executeFailed")}</VisuallyHidden>
)}
<LoadingOverlay visible={isPending} />
{error && displayMode !== "advanced" && (
<Overlay>
<Center w="100%" h="100%" p="xs">
<Tooltip label={error.message} multiline>
<Text size="xs" c="red" ta="center" lineClamp={3}>
{t("widget.smartHome-executeAutomation.error.executeFailed")}
</Text>
</Tooltip>
</Center>
</Overlay>
)}
<Center w="100%" h="100%">
<Stack align="center" gap="md">
<IconAutomation size={isTiny ? 16 : undefined} />
<Text ta="center" fw="bold" fz={isTiny ? "xs" : undefined}>
{options.displayName}
</Text>
{displayMode === "advanced" && (
<>
{error ? (
<Tooltip label={error.message} multiline>
<Text ta="center" size="xs" c="red">
{t("widget.smartHome-executeAutomation.error.executeFailed")}
</Text>
</Tooltip>
) : (
<Text ta="center" size="xs" c="dimmed">
{lastExecutedAt
? t("widget.smartHome-executeAutomation.advanced.lastExecuted", {
time: lastExecutedAt.toLocaleTimeString(),
})
: t("widget.smartHome-executeAutomation.advanced.automationId", {
id: options.automationId,
})}
<UnstyledButton
onClick={handleClick}
disabled={!integrationId || !canInteract || isPending}
style={{
cursor: !isEditMode && integrationId && canInteract ? "pointer" : "initial",
pointerEvents: isEditMode ? "none" : undefined,
}}
aria-description={error?.message}
w="100%"
h="100%"
>
{isShowSuccess && (
<Overlay>
<Center w="100%" h="100%">
<ThemeIcon variant="filled" color="green" size="xl" radius="xl">
<IconCheck style={{ width: "70%", height: "70%" }} stroke={1.5} />
</ThemeIcon>
</Center>
</Overlay>
)}
<LoadingOverlay visible={isPending} />
{error && displayMode !== "advanced" && (
<Overlay>
<Center w="100%" h="100%" p="xs">
<Tooltip label={error.message} multiline>
<Text size="xs" c="red" ta="center" lineClamp={3}>
{t("widget.smartHome-executeAutomation.error.executeFailed")}
</Text>
)}
</>
)}
</Stack>
</Center>
</UnstyledButton>
</Tooltip>
</Center>
</Overlay>
)}
<Center w="100%" h="100%">
<Stack align="center" gap="md">
<IconAutomation size={isTiny ? 16 : undefined} />
<Text ta="center" fw="bold" fz={isTiny ? "xs" : undefined}>
{options.displayName}
</Text>
{displayMode === "advanced" && (
<>
{error ? (
<Tooltip label={error.message} multiline>
<Text ta="center" size="xs" c="red">
{t("widget.smartHome-executeAutomation.error.executeFailed")}
</Text>
</Tooltip>
) : (
<Text ta="center" size="xs" c="dimmed">
{lastExecutedAt
? t("widget.smartHome-executeAutomation.advanced.lastExecuted", {
time: lastExecutedAt.toLocaleTimeString(),
})
: t("widget.smartHome-executeAutomation.advanced.automationId", {
id: options.automationId,
})}
</Text>
)}
</>
)}
</Stack>
</Center>
</UnstyledButton>
</Box>
);
}
@@ -5,7 +5,6 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("speedtestTracker", {
icon: IconSpeedboat,
queryKey: [["widget", "speedtestTracker"]],
refetchInterval: null,
createOptions() {
return optionsBuilder.from(
-1
View File
@@ -13,7 +13,6 @@ const timeIntervalOptions = stockPriceTimeFrames.interval;
export const { definition, componentLoader } = createWidgetDefinition("stockPrice", {
icon: IconBuildingBank,
queryKey: [["widget", "stockPrice"]],
refetchInterval: null,
createOptions() {
return optionsBuilder.from((factory) => ({
@@ -40,34 +40,18 @@ export const toChartItem = (healthInfo: {
export const appendBoundedHistory = (history: ChartItem[], item: ChartItem, limit: number): ChartItem[] =>
[...history, item].slice(-limit);
export const boundHistoryByIntegration = (
historyByIntegration: Record<string, ChartItem[]>,
limit: number,
): Record<string, ChartItem[]> =>
Object.fromEntries(
Object.entries(historyByIntegration).map(([integrationId, history]) => [integrationId, history.slice(-limit)]),
);
export default function SystemResources({
integrationIds,
options,
width,
displayMode,
widgetStateRef,
}: WidgetComponentProps<"systemResources">) {
const { data = [], dataUpdatedAt } = clientApi.widget.healthMonitoring.getSystemHealthStatus.useQuery({
integrationIds,
});
const isAdvanced = displayMode === "advanced";
const integrationKey = useMemo(() => integrationIds.join("\u0000"), [integrationIds]);
const persistedState = widgetStateRef?.current?.systemResources as
| { integrationKey?: string; historyByIntegration?: Record<string, ChartItem[]> }
| undefined;
const [historyByIntegration, setHistoryByIntegration] = useState<Record<string, ChartItem[]>>(() =>
persistedState?.integrationKey === integrationKey && persistedState.historyByIntegration
? boundHistoryByIntegration(persistedState.historyByIntegration, ADVANCED_HISTORY_SIZE)
: {},
);
const [historyByIntegration, setHistoryByIntegration] = useState<Record<string, ChartItem[]>>({});
const previousIntegrationKey = useRef(integrationKey);
useEffect(() => {
@@ -94,17 +78,6 @@ export default function SystemResources({
);
}, [dataUpdatedAt, data]);
useEffect(() => {
if (!widgetStateRef) return;
widgetStateRef.current = {
...widgetStateRef.current,
systemResources: {
integrationKey,
historyByIntegration: boundHistoryByIntegration(historyByIntegration, ADVANCED_HISTORY_SIZE),
},
};
}, [historyByIntegration, integrationKey, widgetStateRef]);
const panelColumns = isAdvanced && width >= 960 && data.length > 1 ? 2 : 1;
const panelWidth = width / panelColumns;
const renderCharts = (entry: (typeof data)[number], availableWidth: number) => {
@@ -1,6 +1,6 @@
import { describe, expect, test } from "vitest";
import { appendBoundedHistory, boundHistoryByIntegration, toChartItem } from "./component";
import { appendBoundedHistory, toChartItem } from "./component";
describe("system resource history", () => {
test("averages all GPU values", () => {
@@ -18,12 +18,4 @@ describe("system resource history", () => {
const item = toChartItem({ cpuUtilization: 1, memUsedInBytes: 2, gpu: [], network: null });
expect(appendBoundedHistory([item, item], item, 2)).toHaveLength(2);
});
test("bounds restored history for every integration", () => {
const item = toChartItem({ cpuUtilization: 1, memUsedInBytes: 2, gpu: [], network: null });
expect(boundHistoryByIntegration({ first: [item, item, item], second: [item, item] }, 2)).toEqual({
first: [item, item],
second: [item, item],
});
});
});
-1
View File
@@ -5,7 +5,6 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("tracearr", {
icon: IconActivityHeartbeat,
queryKey: [["widget", "tracearr"]],
refetchInterval: 5,
createOptions() {
return optionsBuilder.from((factory) => ({
-1
View File
@@ -5,7 +5,6 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("traefik", {
icon: IconRoute,
queryKey: [["widget", "traefik"]],
createOptions() {
return optionsBuilder.from((factory) => ({
showTcp: factory.switch({
-1
View File
@@ -8,7 +8,6 @@ export const timeFrameValues = ["today", "24h", "7d", "30d", "month", "lastMonth
export const { definition, componentLoader } = createWidgetDefinition("umami", {
icon: IconChartBar,
queryKey: [["widget", "umami"]],
refetchInterval: null,
supportedIntegrations: ["umami"],
integrationsRequired: true,
-1
View File
@@ -7,7 +7,6 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("ups", {
icon: IconBatteryCharging,
queryKey: [["widget", "ups"]],
createOptions() {
return optionsBuilder.from((factory) => ({
showBattery: factory.switch({
@@ -5,7 +5,6 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("uptimeKuma", {
icon: IconHeartbeat,
queryKey: [["widget", "uptimeKuma"]],
createOptions() {
return optionsBuilder.from((factory) => ({
showAverageUptime: factory.switch({
-1
View File
@@ -7,7 +7,6 @@ import { optionsBuilder } from "../options";
export const { componentLoader, definition } = createWidgetDefinition("vpn", {
icon: IconShieldLock,
queryKey: [["widget", "vpn"]],
refetchInterval: null,
createOptions() {
return optionsBuilder.from(() => ({}));
-1
View File
@@ -7,7 +7,6 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("weather", {
icon: IconCloud,
queryKey: [["widget", "weather"]],
createOptions() {
return optionsBuilder.from(
(factory) => ({