fix: refine widget modernization
This commit is contained in:
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) => {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -2782,6 +2782,10 @@
|
||||
"running": "Running automation",
|
||||
"success": "Automation completed"
|
||||
},
|
||||
"status": {
|
||||
"running": "Running automation",
|
||||
"success": "Automation completed"
|
||||
},
|
||||
"error": {
|
||||
"executeFailed": "Unable to run automation"
|
||||
}
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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} />;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -6,7 +6,6 @@ import { optionsBuilder } from "../options";
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("customApi", {
|
||||
icon: IconApi,
|
||||
queryKey: [["widget", "customApi"]],
|
||||
contextActions({ options, widgetStateRef }) {
|
||||
return [
|
||||
{
|
||||
|
||||
@@ -42,7 +42,6 @@ const sortColumns = [
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("downloads", {
|
||||
icon: IconDownload,
|
||||
queryKey: [["widget", "downloads"]],
|
||||
refetchInterval: 5,
|
||||
createOptions() {
|
||||
return optionsBuilder.from(
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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(() => ({}));
|
||||
|
||||
@@ -7,7 +7,6 @@ import { optionsBuilder } from "../options";
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("weather", {
|
||||
icon: IconCloud,
|
||||
queryKey: [["widget", "weather"]],
|
||||
createOptions() {
|
||||
return optionsBuilder.from(
|
||||
(factory) => ({
|
||||
|
||||
Reference in new issue
Block a user