Compare commits

..
Author SHA1 Message Date
ajnart 978b3c7f33 fix: stabilize widgets in board edit mode 2026-08-23 01:56:31 +02:00
81 changed files with 265 additions and 877 deletions

No files matched your search

@@ -1,60 +0,0 @@
---
name: homarr-local-harness
description: Build and inspect a writable Docker demo harness from the current Homarr checkout when validating a local branch in a browser.
---
# Homarr Local Harness
Use this skill when a Homarr change needs a real production image and browser validation. Work from the repository root and keep the harness name Docker-safe, such as `feature-board`.
## Set up the harness
Run the TypeScript operations CLI; this workflow does not use the interactive developer TUI:
```sh
pnpm cli harness setup feature-board
```
`setup` builds the current checkout as `homarr:feature-board`, replaces the named harness container, and starts it with a persistent data volume. It enables demo mode, mock integrations, and writable demo mutations by default:
- `DEMO_MODE=true`
- `DEMO_READ_ONLY=false`
- `UNSAFE_ENABLE_MOCK_INTEGRATION=true`
Pass repeatable environment overrides when the feature needs them:
```sh
pnpm cli harness setup feature-board \
--env WORKSHOP_WEB_URL=https://example.test \
--env FEATURE_FLAG=true
```
Use `pnpm cli harness build <name>` when only the image is needed, and `pnpm cli harness run <name>` to start an already-built image. `--port <number>` pins a host port; otherwise Docker assigns one.
Always use the URL printed by the command. If the output is no longer available, query it directly:
```sh
pnpm cli harness port feature-board
```
Do not assume port `7575`; the container listens on `7575`, but the host port is dynamic by default.
## Browser validation
Open the printed URL with the environment's `browser-harness` tool. In this workspace, the `agent-browser` CLI is the supported browser automation fallback. Load its current workflow before invoking it:
```sh
agent-browser skills get core
```
Navigate to the exact harness URL, wait for the app to become usable, exercise the changed flow, and capture screenshots or other browser evidence. Keep browser state in a named session when the workflow needs login persistence. Report the exact URL, viewport/session used, and screenshot paths; a Docker process or HTTP health response alone is not browser acceptance.
## Cleanup
Stop the named container when finished:
```sh
pnpm cli harness stop feature-board
```
Stopping keeps the data volume so the same harness can be restarted. A later `setup` rebuilds the image and replaces only the container. Preserve unrelated containers, images, volumes, and working-tree changes.
@@ -1,4 +0,0 @@
interface:
display_name: "Homarr Local Harness"
short_description: "Build and browse a writable local Homarr branch harness"
default_prompt: "Use $homarr-local-harness to set up a writable Homarr harness for the current branch and validate it in the browser."
@@ -194,15 +194,11 @@ To configure this, click on the small 3 dots at the top of the item in the edit
<figcaption>The tile menu contains the item editor and the separate move-and-resize tools.</figcaption>
</figure>
The side editor keeps the selected tile visible while you choose the app and adjust its display settings:
In the modal you can edit the app that you want to display:
<figure>
<img
src={editItemCurrent}
alt="App tile highlighted beside the Edit item side editor"
style={{ maxWidth: "620px" }}
/>
<figcaption>The selected tile stays highlighted while the rest of the board is subdued.</figcaption>
<img src={editItemCurrent} alt="Current Edit item modal for an App tile" style={{ maxWidth: "620px" }} />
<figcaption>Use the Edit item modal to choose the app and configure how its tile is displayed.</figcaption>
</figure>
As soon as you save your changes, the tile on the board will refresh and display your app.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 144 KiB

After

Width:  |  Height:  |  Size: 68 KiB

@@ -1,6 +1,6 @@
"use client";
import { use, useCallback, useId, useState } from "react";
import { use, useCallback, useState } from "react";
import { ActionIcon, Affix, Card } from "@mantine/core";
import { IconDimensions, IconPencil, IconToggleLeft, IconToggleRight } from "@tabler/icons-react";
import { QueryErrorResetBoundary } from "@tanstack/react-query";
@@ -32,7 +32,6 @@ export const WidgetPreviewPageContent = ({ kind }: WidgetPreviewPageContentProps
const { openModal: openWidgetEditModal } = useModalAction(LazyWidgetEditModal);
const { openModal: openPreviewDimensionsModal } = useModalAction(PreviewDimensionsModal);
const { definition: currentDefinition, Component } = use(loadWidgetResources(kind));
const previewId = useId();
const [editMode, setEditMode] = useState(false);
const [isEditorLoading, setIsEditorLoading] = useState(false);
const [dimensions, setDimensions] = useState<Dimensions>({
@@ -59,7 +58,6 @@ export const WidgetPreviewPageContent = ({ kind }: WidgetPreviewPageContentProps
preloadWidgetEditModal();
try {
const integrationData = hasIntegrationSupport ? await utils.integration.all.ensureData() : [];
const originalState = state;
openWidgetEditModal(
{
kind,
@@ -68,14 +66,11 @@ export const WidgetPreviewPageContent = ({ kind }: WidgetPreviewPageContentProps
onSuccessfulEdit: (value) => {
setState(value);
},
onPreviewChange: setState,
onPreviewRestore: () => setState(originalState),
integrationData: integrationData.filter((integration) =>
(currentDefinition.supportedIntegrations ?? []).includes(integration.kind),
),
integrationSupport: hasIntegrationSupport,
settings,
focusTargetId: previewId,
},
{
title(translate) {
@@ -91,7 +86,7 @@ export const WidgetPreviewPageContent = ({ kind }: WidgetPreviewPageContentProps
} finally {
setIsEditorLoading(false);
}
}, [currentDefinition, kind, openWidgetEditModal, previewId, settings, state, t, utils]);
}, [currentDefinition, kind, openWidgetEditModal, settings, state, t, utils]);
const toggleEditMode = useCallback(() => {
setEditMode((editMode) => !editMode);
@@ -115,7 +110,7 @@ export const WidgetPreviewPageContent = ({ kind }: WidgetPreviewPageContentProps
return (
<>
<Card id={previewId} w={dimensions.width} h={dimensions.height} p={dimensions.height >= 96 ? undefined : 4}>
<Card w={dimensions.width} h={dimensions.height} p={dimensions.height >= 96 ? undefined : 4}>
<QueryErrorResetBoundary>
{({ reset }) => (
<ErrorBoundary
@@ -184,10 +184,6 @@
min-width: 0;
}
.widgetPanel[data-editing] .panelActions {
padding-inline-end: 2.75rem;
}
.panelHeader {
min-width: 0;
min-height: 3.75rem;
@@ -30,13 +30,6 @@ interface UpdateItemIntegrations {
newIntegrations: string[];
}
interface UpdateItemSettings {
itemId: string;
options: Record<string, unknown>;
advancedOptions: BoardItemAdvancedOptions;
integrationIds: string[];
}
export const useItemActions = () => {
const { updateBoard } = useUpdateBoard();
const currentLayoutId = useCurrentLayout();
@@ -84,20 +77,6 @@ export const useItemActions = () => {
[updateBoard],
);
const updateItemSettings = useCallback(
({ itemId, options, advancedOptions, integrationIds }: UpdateItemSettings) => {
updateBoard((previous) => ({
...previous,
items: previous.items.map((item) => {
if (item.id !== itemId || !("integrationIds" in item)) return item;
return { ...item, options, advancedOptions, integrationIds };
}),
}));
},
[updateBoard],
);
const moveAndResizeItem = useCallback(
(input: Omit<MoveAndResizeItemInput, "layoutId">) => {
updateBoard(moveAndResizeItemCallback({ ...input, layoutId: currentLayoutId }));
@@ -126,7 +105,6 @@ export const useItemActions = () => {
updateItemOptions,
updateItemAdvancedOptions,
updateItemIntegrations,
updateItemSettings,
duplicateItem,
createItem,
};
@@ -12,6 +12,10 @@
}
.settingsButton {
position: absolute;
top: -34px;
right: 14px;
z-index: 201;
opacity: 0;
pointer-events: none;
transition: opacity 120ms ease;
@@ -58,3 +62,11 @@
transition: none;
}
}
@media (max-width: 768px) {
.settingsButton {
right: 4px;
opacity: 1;
pointer-events: auto;
}
}
@@ -297,7 +297,6 @@ const LoadedBoardItemContent = ({
role={isPreview ? "region" : undefined}
aria-label={isPreview ? advancedViewLabel : undefined}
data-advanced-focus-surface={isPreview || undefined}
data-advanced-focus-item-id={isAdvanced ? item.id : undefined}
data-grid-item-content
radius={board.itemRadius}
p={isAdvanced ? undefined : 0}
@@ -3,7 +3,6 @@ import { ActionIcon, Menu } from "@mantine/core";
import { IconCopy, IconDotsVertical, IconLayoutKanban, IconPencil } from "@tabler/icons-react";
import { useSession } from "@homarr/auth/client";
import { useRequiredBoard } from "@homarr/boards/context";
import { useEditMode } from "@homarr/boards/edit-mode";
import { getWidgetName } from "@homarr/definitions";
import { useModalAction } from "@homarr/modals";
@@ -34,7 +33,6 @@ export const BoardItemMenu = (props: BoardItemMenuProps) => {
};
const BoardItemMenuInner = ({ item, definition, resetErrorBoundary }: BoardItemMenuProps) => {
const { data: session } = useSession();
const board = useRequiredBoard();
const canDuplicate = item.kind !== "customApi" || (session?.user.permissions.includes("admin") ?? false);
const refResetErrorBoundaryOnNextRender = useRef(false);
const tItem = useI18n("item");
@@ -42,7 +40,8 @@ const BoardItemMenuInner = ({ item, definition, resetErrorBoundary }: BoardItemM
const { openModal } = useModalAction(LazyWidgetEditModal);
const openMoveModal = useOpenItemMoveModal();
const [isMenuOpen, setIsMenuOpen] = useState(false);
const { updateItemSettings, duplicateItem, removeItem } = useItemActions();
const { updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations, duplicateItem, removeItem } =
useItemActions();
const { integrations: integrationData, section } = useSectionContext();
const settings = useSettings();
const label = item.advancedOptions.title?.trim() || getWidgetName(item.kind, t);
@@ -56,11 +55,6 @@ const BoardItemMenuInner = ({ item, definition, resetErrorBoundary }: BoardItemM
}, [item, resetErrorBoundary]);
const openEditModal = () => {
const originalSettings = {
options: item.options,
advancedOptions: item.advancedOptions,
integrationIds: item.integrationIds,
};
openModal(
{
kind: item.kind,
@@ -70,14 +64,19 @@ const BoardItemMenuInner = ({ item, definition, resetErrorBoundary }: BoardItemM
options: item.options,
integrationIds: item.integrationIds,
},
onPreviewChange: (value) => {
updateItemSettings({ itemId: item.id, ...value });
},
onPreviewRestore: () => {
updateItemSettings({ itemId: item.id, ...originalSettings });
},
onSuccessfulEdit: (value) => {
updateItemSettings({ itemId: item.id, ...value });
onSuccessfulEdit: ({ options, integrationIds, advancedOptions }) => {
updateItemOptions({
itemId: item.id,
newOptions: options,
});
updateItemAdvancedOptions({
itemId: item.id,
newAdvancedOptions: advancedOptions,
});
updateItemIntegrations({
itemId: item.id,
newIntegrations: integrationIds,
});
refResetErrorBoundaryOnNextRender.current = true;
},
onIntegrationSaved: resetErrorBoundary,
@@ -90,7 +89,6 @@ const BoardItemMenuInner = ({ item, definition, resetErrorBoundary }: BoardItemM
integrationSupport: "supportedIntegrations" in definition,
settings,
itemId: item.id,
boardId: board.id,
appId: item.kind === "app" ? (item.options.appId as string | undefined) : undefined,
},
{
@@ -108,10 +106,6 @@ const BoardItemMenuInner = ({ item, definition, resetErrorBoundary }: BoardItemM
variant="default"
size={24}
radius="sm"
pos="absolute"
top={4}
right={4}
style={{ zIndex: 30 }}
className={itemContentClasses.settingsButton}
data-menu-open={isMenuOpen || undefined}
data-board-widget-settings
@@ -277,7 +277,6 @@ const ItemSelectModalContent = ({
integrationData: [],
integrationSupport: false,
settings,
boardId: board.id,
},
{
title: (titleT) => `${titleT("item.edit.title")} - ${customWidgetDefinition.name}`,
@@ -347,7 +346,6 @@ const ItemSelectModalContent = ({
integrationData: availableIntegrations,
integrationSupport: hasIntegrationSupport,
settings,
boardId: board.id,
},
{
title: (titleT) => `${titleT("item.edit.title")} - ${getWidgetName(kind, titleT)}`,
@@ -157,8 +157,6 @@ export const LazyWidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>
defaultTitle(t) {
return t("item.edit.title");
},
presentation: "inspector",
withOverlay: false,
size: modalSizeForm,
closeOnClickOutside: false,
});
@@ -58,7 +58,7 @@ export const WidgetContextMenu = ({
const tCommon = useI18n("common.action");
const settings = useSettings();
const { openModal } = useModalAction(LazyWidgetEditModal);
const { updateItemSettings } = useItemActions();
const { updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations } = useItemActions();
const hasSupportedIntegrations = (definition.supportedIntegrations?.length ?? 0) > 0;
const { data: integrationData = [], isPending } = clientApi.integration.all.useQuery(undefined, {
enabled: hasSupportedIntegrations,
@@ -154,27 +154,19 @@ export const WidgetContextMenu = ({
}) ?? [];
const openEditModal = useCallback(() => {
const originalSettings = {
options: item.options,
advancedOptions: item.advancedOptions,
integrationIds: item.integrationIds,
};
openModal(
{
kind: item.kind,
definition,
value: {
advancedOptions: item.advancedOptions,
options,
options: item.options,
integrationIds: item.integrationIds,
},
onPreviewChange: (value) => {
updateItemSettings({ itemId: item.id, ...value });
},
onPreviewRestore: () => {
updateItemSettings({ itemId: item.id, ...originalSettings });
},
onSuccessfulEdit: (editResult) => {
updateItemOptions({ itemId: item.id, newOptions: editResult.options });
updateItemAdvancedOptions({ itemId: item.id, newAdvancedOptions: editResult.advancedOptions });
updateItemIntegrations({ itemId: item.id, newIntegrations: editResult.integrationIds });
persistBoard((previous) => ({
...previous,
items: previous.items.map((boardItem) =>
@@ -197,8 +189,6 @@ export const WidgetContextMenu = ({
),
integrationSupport: definition.supportedIntegrations !== undefined,
settings,
itemId: item.id,
boardId: board.id,
appId: item.kind === "app" ? (item.options.appId as string | undefined) : undefined,
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -207,16 +197,16 @@ export const WidgetContextMenu = ({
},
);
}, [
board.id,
definition,
integrationData,
item,
handleRefetch,
openModal,
options,
persistBoard,
settings,
updateItemSettings,
updateItemAdvancedOptions,
updateItemIntegrations,
updateItemOptions,
]);
const handleToggle = useCallback(
@@ -31,7 +31,7 @@
align-items: center;
gap: 8px;
min-height: 28px;
padding: 4px 8px;
padding: 4px 40px 4px 8px;
border: 1px solid var(--border-color);
border-radius: var(--mantine-radius-md);
background-color: var(--background-color);
Binary file not shown.

Before

Width:  |  Height:  |  Size: 298 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 288 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 281 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 284 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 284 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 589 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 397 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 395 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 327 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 578 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 651 KiB

@@ -7,11 +7,9 @@
.inspectorContent {
--modal-content-flex: 0 0 100%;
--modal-content-max-height: min(88dvh, 52rem);
--modal-size: 100%;
width: 100%;
max-width: 100%;
min-width: 0;
border-radius: var(--mantine-radius-lg) var(--mantine-radius-lg) 0 0;
}
@@ -25,13 +23,8 @@
--modal-content-flex: 0 0 min(34rem, 46vw);
--modal-content-height: 100dvh;
--modal-content-max-height: 100dvh;
--modal-size: min(34rem, 46vw);
width: min(34rem, 46vw);
border-radius: 0;
}
.inspectorInner:has([data-widget-inspector-side="left"]) {
justify-content: flex-start;
}
}
-1
View File
@@ -19,7 +19,6 @@ export type CreateModalOptions = Pick<
| "centered"
| "keepMounted"
| "withCloseButton"
| "withOverlay"
| "zIndex"
| "scrollAreaComponent"
| "yOffset"
+1 -2
View File
@@ -1904,8 +1904,7 @@
}
},
"widgetQuery": {
"loading": "Loading widget data",
"stale": "Could not refresh {widget}; showing saved data"
"loading": "Loading widget data"
},
"total": "Total",
"filesystemUsage": "{count, plural, one {Show usage for # filesystem} other {Show usage for # filesystems}}",
@@ -11,7 +11,7 @@ import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
import { WidgetEmptyState } from "../common/empty-state";
import { formatLocalizedDate, formatLocalizedTime } from "../common/locale";
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state";
import { WidgetQueryErrorIndicator, WidgetQueryLoadingState } from "../common/query-state-indicator";
import { WidgetQueryLoadingState } from "../common/query-state-indicator";
import type { WidgetComponentProps } from "../definition";
import type { AirQualityData, PollutantKey, PollenKey } from "./display";
import {
@@ -28,7 +28,6 @@ import {
} from "./display";
export default function AirQualityWidget({ options, width, height, displayMode }: WidgetComponentProps<"airQuality">) {
const t = useI18n("widget.airQuality");
const query = clientApi.widget.airQuality.atLocation.useQuery({
latitude: options.location.latitude,
longitude: options.location.longitude,
@@ -45,11 +44,6 @@ export default function AirQualityWidget({ options, width, height, displayMode }
) : (
<CompactAirQuality airQuality={airQuality} height={height} options={options} width={width} />
)}
{query.error && (
<Box pos="absolute" top={4} right={4}>
<WidgetQueryErrorIndicator error={query.error} label={t("name")} />
</Box>
)}
</Box>
);
}
@@ -14,7 +14,6 @@ import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
import { WidgetEmptyState } from "../common/empty-state";
import type { WidgetComponentProps } from "../definition";
import { getUsableWidgetQueryData } from "../common/query-state";
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
import actionTargetClasses from "../common/action-target.module.css";
import { storedContentToPlainText } from "./content";
@@ -239,7 +238,6 @@ const AnchorNoteWidgetContent = ({
)}
</Stack>
<Group gap="xs" wrap="nowrap">
<WidgetQueryErrorIndicator error={noteQuery.error} label={t("name")} />
{(isEditing || canEdit) && (
<Group className="homarr-anchor-actions" data-visible={isEditing || isAdvanced || undefined} gap="xs">
{isEditing ? (
+1 -7
View File
@@ -16,7 +16,7 @@ import { MaskedOrNormalImage } from "@homarr/ui";
import { WidgetEmptyState } from "../common/empty-state";
import { getSafeAppHref, SAFE_NEW_TAB_REL } from "../common/application-url";
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state";
import { WidgetQueryErrorIndicator, WidgetQueryLoadingState } from "../common/query-state-indicator";
import { WidgetQueryLoadingState } from "../common/query-state-indicator";
import type { WidgetComponentProps } from "../definition";
import classes from "./app.module.css";
import { PingDot } from "./ping/ping-dot";
@@ -24,7 +24,6 @@ import { PingIndicator } from "./ping/ping-indicator";
export default function AppWidget({ options, isEditMode, height, width }: WidgetComponentProps<"app">) {
const tCommon = useI18n("common");
const tApp = useI18n("widget.app");
const settings = useSettings();
const board = useRequiredBoard();
const appQuery = clientApi.app.byId.useQuery({ id: options.appId }, { enabled: Boolean(options.appId) });
@@ -127,11 +126,6 @@ export default function AppWidget({ options, isEditMode, height, width }: Widget
</Suspense>
) : null}
</AppLink>
{appQuery.error && (
<Box pos="absolute" top={4} right={4}>
<WidgetQueryErrorIndicator error={appQuery.error} label={tApp("name")} />
</Box>
)}
</Box>
);
}
@@ -8,7 +8,6 @@ import { getIconUrl } from "@homarr/definitions";
import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition";
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
export default function ArchiveTeamWarriorWidget({
integrationIds,
@@ -50,7 +49,7 @@ const ArchiveTeamWarriorWidgetContent = ({
const t = useI18n("widget.archiveTeamWarrior");
const tWidgetCommon = useI18n("widget.common");
const locale = useCurrentIntlLocale();
const [data, statusQuery] = clientApi.widget.archiveTeamWarrior.getStatus.useSuspenseQuery({ integrationId });
const [data] = clientApi.widget.archiveTeamWarrior.getStatus.useSuspenseQuery({ integrationId });
const status = data.status;
const projectName = status.project?.title ?? status.selectedProject ?? t("noProjectSelected");
@@ -74,7 +73,6 @@ const ArchiveTeamWarriorWidgetContent = ({
</Group>
<Group gap={4} wrap="nowrap">
<WidgetQueryErrorIndicator error={statusQuery.error} label={t("name")} />
<Badge size="sm" color={getStatusColor(statusKey)}>
{t(`status.${statusKey}`)}
</Badge>
+1 -10
View File
@@ -6,11 +6,9 @@ import { getQueryKey } from "@trpc/react-query";
import { clientApi } from "@homarr/api/client";
import { formatBytes, formatDuration } from "@homarr/common";
import type { AudiobookshelfDashboardData } from "@homarr/integrations/types";
import { useI18n } from "@homarr/translation/client";
import { WidgetEmptyState } from "../common/empty-state";
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
import type { WidgetComponentProps } from "../definition";
import { useWidgetRuntimeQueries } from "../runtime-hooks";
import { AudioStatsContent } from "./audio-stats-content";
@@ -23,8 +21,6 @@ export default function AudioStatsWidget({
displayMode = "compact",
widgetRuntimeRef,
}: WidgetComponentProps<"audioStats">) {
const tAudio = useI18n("widget.audioStats");
const tMediaServer = useI18n("widget.mediaServer");
const statsInput = { integrationId: integrationIds[0] ?? "" };
const { data: response, error: statsError } = clientApi.widget.audioStats.getStats.useQuery(statsInput);
const streamsInput = { integrationIds: [statsInput.integrationId], showOnlyPlaying: true };
@@ -56,9 +52,6 @@ export default function AudioStatsWidget({
if (displayMode === "compact") {
return (
<Box h="100%" pos="relative">
<Box pos="absolute" top={4} right={8} style={{ zIndex: 1 }}>
<WidgetQueryErrorIndicator error={statsError} label={tAudio("name")} />
</Box>
{summary}
</Box>
);
@@ -73,10 +66,8 @@ export default function AudioStatsWidget({
return (
<Stack h="100%" gap="lg" p="md">
<div style={{ minHeight: 150 }}>{summary}</div>
{(statsError || streamsError || hasStreamErrors) && (
{hasStreamErrors && (
<Group justify="flex-end">
<WidgetQueryErrorIndicator error={statsError} label={tAudio("name")} />
<WidgetQueryErrorIndicator error={streamsError} label={tMediaServer("name")} />
<IntegrationErrorIndicator results={currentStreams} />
</Group>
)}
@@ -28,7 +28,6 @@ import { useI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition";
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
import { getUsableWidgetQueryData } from "../common/query-state";
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
import { useWidgetRuntimeQueries } from "../runtime-hooks";
import { getBeszelAlertsQueryInput } from "./display";
import { buildBeszelSystemNameMap, getBeszelSystemName } from "./system-name-map";
@@ -126,7 +125,6 @@ export default function BeszelAlertsWidget({
<Box h="100%" pos="relative">
<Box pos="absolute" top={4} right={8} style={{ zIndex: 1 }}>
<Group gap={0}>
<WidgetQueryErrorIndicator error={alertsQuery.error} label={t("name")} />
<IntegrationErrorIndicator results={results} />
</Group>
</Box>
@@ -52,7 +52,6 @@ import {
import { useBeszelFilteredSystems } from "../beszel/_shared/hooks";
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
import { getUsableWidgetQueryData } from "../common/query-state";
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
import { BeszelSystemStatsModal } from "../beszel/_shared/system-stats-modal";
import { DiskUsage } from "../beszel/_shared/disk-usage";
import { isBeszelGridMetricVisible } from "./display";
@@ -460,7 +459,6 @@ export default function BeszelSystemGridWidget({
displayMode,
}: WidgetComponentProps<"beszelSystemGrid">) {
const t = useI18n("widget.beszel");
const tFeature = useI18n("widget.beszelSystemGrid");
const tCommon = useI18n("common");
const board = useRequiredBoard();
const { openModal } = useModalAction(BeszelSystemStatsModal);
@@ -485,7 +483,6 @@ export default function BeszelSystemGridWidget({
return (
<Box h="100%" pos="relative" style={{ pointerEvents: isEditMode ? "none" : undefined }}>
<Group pos="absolute" top={4} right={8} gap={0} style={{ zIndex: 1 }}>
<WidgetQueryErrorIndicator error={systemsQuery.error} label={tFeature("name")} />
<IntegrationErrorIndicator results={results} />
</Group>
<Center h="100%">
@@ -514,7 +511,6 @@ export default function BeszelSystemGridWidget({
return (
<Box h="100%" pos="relative" style={{ pointerEvents: isEditMode ? "none" : undefined }}>
<Group pos="absolute" top={4} right={8} gap={0} style={{ zIndex: 1 }}>
<WidgetQueryErrorIndicator error={systemsQuery.error} label={tFeature("name")} />
<IntegrationErrorIndicator results={results} />
</Group>
<Box
@@ -19,7 +19,6 @@ import { useWidgetRuntimeQueries } from "../runtime-hooks";
import type { BeszelTimePeriod } from "../beszel/_shared/chart";
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
import { getUsableWidgetQueryData } from "../common/query-state";
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
import { BeszelStatsView } from "../beszel/_shared/stats-view";
import { createBeszelSystemChoices, resolveBeszelSystemChoice } from "./selection";
@@ -144,7 +143,6 @@ export default function BeszelSystemStatsWidget({
return (
<Box h="100%" pos="relative">
<Group pos="absolute" top={4} right={8} gap={0} style={{ zIndex: 1 }}>
<WidgetQueryErrorIndicator error={systemsQuery.error} label={t("name")} />
<IntegrationErrorIndicator results={systemsResult} />
</Group>
<Center h="100%">
@@ -184,7 +182,6 @@ export default function BeszelSystemStatsWidget({
<Box h="100%" pos="relative">
<Box pos="absolute" top={4} right={8} style={{ zIndex: 1 }}>
<Group gap={0}>
<WidgetQueryErrorIndicator error={systemsQuery.error} label={t("name")} />
<IntegrationErrorIndicator results={systemsResult} />
</Group>
</Box>
@@ -42,7 +42,6 @@ import {
} from "../beszel/_shared/format";
import { useBeszelFilteredSystems } from "../beszel/_shared/hooks";
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
import { BeszelSystemStatsModal } from "../beszel/_shared/system-stats-modal";
import { DiskUsage } from "../beszel/_shared/disk-usage";
import { getBeszelTableVisibleMetricKeys } from "./display";
@@ -453,7 +452,6 @@ export default function BeszelSystemTableWidget({
<div style={{ position: "relative", height: "100%" }}>
<div style={{ position: "absolute", top: 4, right: 8, zIndex: 1 }}>
<Group gap={0}>
<WidgetQueryErrorIndicator error={systemsQuery.error} label={t("name")} />
<IntegrationErrorIndicator results={results} />
</Group>
</div>
+2 -20
View File
@@ -16,7 +16,6 @@ import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
import actionTargetClasses from "../common/action-target.module.css";
import type { WidgetComponentProps } from "../definition";
import { getUsableWidgetQueryData } from "../common/query-state";
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
import { useWidgetRuntimeQueries } from "../runtime-hooks";
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
import { CalendarDay } from "./calender-day";
@@ -54,7 +53,6 @@ export default function CalendarWidget(props: WidgetComponentProps<"calendar">)
events={[]}
failedIntegrations={[]}
isPending={false}
queryError={undefined}
month={month}
setMonth={setMonth}
/>
@@ -106,7 +104,6 @@ const FetchCalendar = ({ month, setMonth, isEditMode, integrationIds, options, d
events={events}
failedIntegrations={failedIntegrations}
isPending={isPending}
queryError={calendarQuery.error}
month={month}
setMonth={setMonth}
options={options}
@@ -120,7 +117,6 @@ interface CalendarBaseProps {
events: CalendarEventWithSource[];
failedIntegrations: { integrationId: string; integrationName: string; error: string }[];
isPending: boolean;
queryError: unknown;
month: Date;
setMonth: (date: Date) => void;
options: WidgetComponentProps<"calendar">["options"];
@@ -132,13 +128,11 @@ const CalendarBase = ({
events,
failedIntegrations,
isPending,
queryError,
month,
setMonth,
options,
displayMode,
}: CalendarBaseProps) => {
const t = useI18n("widget.calendar");
const locale = useCurrentIntlLocale();
const { firstDayOfWeek } = useSettings();
const board = useRequiredBoard();
@@ -170,8 +164,6 @@ const CalendarBase = ({
events={agendaEvents}
failedIntegrations={failedIntegrations}
isPending={isPending}
queryError={queryError}
queryErrorLabel={t("name")}
isEditMode={isEditMode}
locale={locale}
month={month}
@@ -180,8 +172,6 @@ const CalendarBase = ({
);
}
const hasErrors = failedIntegrations.length > 0 || Boolean(queryError);
return (
<Box ref={ref} h="100%" w="100%" pos="relative" style={{ overflow: "hidden" }}>
<Calendar
@@ -194,13 +184,11 @@ const CalendarBase = ({
date={month}
maxLevel="month"
firstDayOfWeek={firstDayOfWeek}
static={isEditMode}
className={classes.calendar}
w="100%"
h="100%"
styles={{
calendarHeaderControl: {
pointerEvents: isEditMode ? "none" : undefined,
borderRadius: "md",
height: isSmall ? "1.5rem" : undefined,
width: isSmall ? "1.5rem" : undefined,
@@ -249,17 +237,16 @@ const CalendarBase = ({
<CalendarDay
date={dayjs(tileDate).toDate()}
events={eventsForDate}
disabled={isEditMode || eventsForDate.length === 0}
disabled={eventsForDate.length === 0}
rootWidth={width}
rootHeight={height}
/>
);
}}
/>
{hasErrors && (
{failedIntegrations.length > 0 && (
<Group className={classes.errorIndicator} gap={0} pos="absolute">
<IntegrationErrorIndicator results={failedIntegrations} />
<WidgetQueryErrorIndicator error={queryError} label={t("name")} />
</Group>
)}
</Box>
@@ -270,8 +257,6 @@ interface CalendarAgendaProps {
events: CalendarEventWithSource[];
failedIntegrations: CalendarBaseProps["failedIntegrations"];
isPending: boolean;
queryError: unknown;
queryErrorLabel: string;
isEditMode: boolean;
locale: string;
month: Date;
@@ -282,8 +267,6 @@ const CalendarAgenda = ({
events,
failedIntegrations,
isPending,
queryError,
queryErrorLabel,
isEditMode,
locale,
month,
@@ -331,7 +314,6 @@ const CalendarAgenda = ({
</Group>
<Group gap={0}>
<IntegrationErrorIndicator results={failedIntegrations} />
<WidgetQueryErrorIndicator error={queryError} label={queryErrorLabel} />
</Group>
</Group>
@@ -4,7 +4,6 @@ import { clientApi } from "@homarr/api/client";
import { useI18n } from "@homarr/translation/client";
import { isInitialWidgetQueryPending } from "../common/query-state";
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
import { AnimatedWeatherIcon } from "../weather/animated-icon";
import { WeatherDescription } from "../weather/icon";
@@ -25,17 +24,16 @@ export const ClockWeatherSummary = ({
animateIcon,
detailed,
}: ClockWeatherSummaryProps) => {
const t = useI18n();
const tCommon = useI18n("common");
const weatherQuery = clientApi.widget.weather.atLocation.useQuery({ latitude, longitude });
const weather = weatherQuery.data;
if (!weather) {
return isInitialWidgetQueryPending(weatherQuery) ? (
<Loader size="xs" aria-label={tCommon("action.loading")} />
) : (
<WidgetQueryErrorIndicator error={weatherQuery.error} label={t("widget.weather.name")} />
);
if (isInitialWidgetQueryPending(weatherQuery)) {
return <Loader size="xs" aria-label={tCommon("action.loading")} />;
}
return null;
}
const temperature = isFahrenheit ? weather.current.temperature * (9 / 5) + 32 : weather.current.temperature;
@@ -72,7 +70,6 @@ export const ClockWeatherSummary = ({
</Text>
</Box>
)}
<WidgetQueryErrorIndicator error={weatherQuery.error} label={t("widget.weather.name")} />
</Stack>
);
};
@@ -149,8 +149,8 @@ export function HomarrDataTable<T>({
defaultColumnProps={{
...defaultColumnProps,
noWrap: defaultColumnProps?.noWrap ?? true,
draggable: !isEditMode,
resizable: !isEditMode,
draggable: true,
resizable: true,
cellsStyle: defaultColumnProps?.cellsStyle ?? (() => ({ padding: cellPadding })),
}}
scrollAreaProps={{ type: "auto", scrollbarSize: 6, ...scrollAreaProps }}
@@ -5,11 +5,10 @@ import { createRoot } from "react-dom/client";
import { MantineProvider } from "@mantine/core";
import { afterEach, describe, expect, test, vi } from "vitest";
import { WidgetQueryErrorIndicator, WidgetQueryLoadingState } from "./query-state-indicator";
import { WidgetQueryLoadingState } from "./query-state-indicator";
vi.mock("@homarr/translation/client", () => ({
useI18n: () => (key: string, values?: { widget?: string }) => {
if (key === "stale") return `Could not refresh ${values?.widget}; showing saved data`;
useI18n: () => (key: string) => {
if (key === "loading") return "Loading widget data";
return key;
},
@@ -26,7 +25,7 @@ const matchMedia = (query: string) => ({
dispatchEvent: () => false,
});
describe("WidgetQueryErrorIndicator", () => {
describe("WidgetQueryLoadingState", () => {
let host: HTMLDivElement | undefined;
let root: ReturnType<typeof createRoot> | undefined;
@@ -36,28 +35,6 @@ describe("WidgetQueryErrorIndicator", () => {
vi.unstubAllGlobals();
});
test("uses a localized label without exposing the raw failure", async () => {
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
vi.stubGlobal("matchMedia", matchMedia);
host = document.createElement("div");
document.body.append(host);
root = createRoot(host);
const secret = "http://admin:password@private.local/path?token=secret";
await act(async () =>
root?.render(
createElement(
MantineProvider,
null,
createElement(WidgetQueryErrorIndicator, { error: new Error(secret), label: "Weather" }),
),
),
);
expect(host.querySelector("[aria-label='Could not refresh Weather; showing saved data']")).not.toBeNull();
expect(host.textContent).not.toMatch(/password|private\.local|token=secret/);
});
test("announces the loading state to assistive technology", async () => {
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
vi.stubGlobal("matchMedia", matchMedia);
@@ -1,39 +1,9 @@
"use client";
import { ActionIcon, Center, Loader, Tooltip, VisuallyHidden } from "@mantine/core";
import { IconAlertTriangle } from "@tabler/icons-react";
import { Center, Loader, VisuallyHidden } from "@mantine/core";
import { useI18n } from "@homarr/translation/client";
interface WidgetQueryErrorIndicatorProps {
error: unknown;
label: string;
}
/** Localized warning for stale or optional-query failures. Never renders the raw error. */
export function WidgetQueryErrorIndicator({ error, label }: WidgetQueryErrorIndicatorProps) {
const t = useI18n("common.widgetQuery");
if (!error) return null;
const accessibleLabel = t("stale", { widget: label });
return (
<Tooltip label={accessibleLabel} position="left" withArrow>
<ActionIcon
component="span"
color="orange"
variant="subtle"
size="sm"
role="note"
aria-label={accessibleLabel}
tabIndex={0}
style={{ cursor: "help" }}
>
<IconAlertTriangle aria-hidden size="var(--mantine-font-size-sm)" />
</ActionIcon>
</Tooltip>
);
}
export function WidgetQueryLoadingState() {
const t = useI18n("common.widgetQuery");
return (
@@ -3,11 +3,9 @@
import { Badge, Box, Group, ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { getIntegrationName } from "@homarr/definitions";
import { useI18n } from "@homarr/translation/client";
import { getUsableWidgetQueryData } from "../common/query-state";
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
import { WidgetEmptyState } from "../common/empty-state";
import type { WidgetComponentProps } from "../definition";
import { createWidgetKey, getCoolifySectionVisibility } from "./coolify-utils";
@@ -148,9 +146,6 @@ const FailedSourceBadges = ({
{t("source.unavailable", { source: source.integrationName })}
</Badge>
))}
<Box ml="auto">
<WidgetQueryErrorIndicator error={queryError} label={getIntegrationName("coolify")} />
</Box>
</Group>
);
};
@@ -35,7 +35,6 @@ import type { widgetKind } from ".";
import type { WidgetComponentProps } from "../../definition";
import { IntegrationErrorIndicator } from "../../common/integration-error-indicator";
import { getUsableWidgetQueryData } from "../../common/query-state";
import { WidgetQueryErrorIndicator } from "../../common/query-state-indicator";
import actionTargetClasses from "../../common/action-target.module.css";
import classes from "./component.module.css";
import TimerModal from "./TimerModal";
@@ -165,7 +164,6 @@ export default function DnsHoleControlsWidget({
<Box pos="absolute" top={4} right={4} style={{ zIndex: 2 }}>
<Group gap={0}>
<IntegrationErrorIndicator results={summaryResults} />
<WidgetQueryErrorIndicator error={summaryQuery.error} label={t("name")} />
</Group>
</Box>
{controlAllButtonsVisible && (
@@ -32,7 +32,6 @@ import type { widgetKind } from ".";
import type { WidgetComponentProps, WidgetProps } from "../../definition";
import { IntegrationErrorIndicator } from "../../common/integration-error-indicator";
import { getUsableWidgetQueryData } from "../../common/query-state";
import { WidgetQueryErrorIndicator } from "../../common/query-state-indicator";
export default function DnsHoleSummaryWidget({
options,
@@ -71,7 +70,6 @@ export default function DnsHoleSummaryWidget({
<Box pos="absolute" top={4} right={4} style={{ zIndex: 2 }}>
<Group gap={0}>
<IntegrationErrorIndicator results={summaries} />
<WidgetQueryErrorIndicator error={summaryQuery.error} label={tDns("name")} />
</Group>
</Box>
<SimpleGrid cols={2} spacing="xs" p="xs" {...layoutProps} style={{ ...layoutProps.style, flex: 1, minHeight: 0 }}>
@@ -55,7 +55,6 @@ import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition";
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
import { getUsableWidgetQueryData } from "../common/query-state";
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
import { HomarrDataTable } from "../common/homarr-data-table";
import { formatLocalizedDateTime } from "../common/locale";
import { usePersistedTableLayout, useTableLayoutPersistence } from "../common/use-persisted-table-layout";
@@ -314,7 +313,6 @@ export default function DownloadClientsWidget({
const queryIndicators = (
<Group gap={0}>
<IntegrationErrorIndicator results={currentItems ?? []} />
<WidgetQueryErrorIndicator error={downloadsQuery.error} label={t("name")} />
</Group>
);
@@ -1,5 +1,5 @@
import { useState } from "react";
import { Accordion, Box, Center, Flex, Group, RingProgress, Stack, Text } from "@mantine/core";
import { Accordion, Center, Flex, Group, RingProgress, Stack, Text } from "@mantine/core";
import { IconBrain, IconCpu, IconCube, IconDatabase, IconDeviceLaptop, IconServer } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
@@ -9,7 +9,6 @@ import { useI18n } from "@homarr/translation/client";
import { WidgetEmptyState } from "../../common/empty-state";
import { getUsableWidgetQueryData } from "../../common/query-state";
import { WidgetQueryErrorIndicator } from "../../common/query-state-indicator";
import type { WidgetComponentProps } from "../../definition";
import { formatUptime } from "../system-health";
import { getClusterAccordionDefault, getClusterVisibleSections } from "./accordion-state";
@@ -71,9 +70,6 @@ export const ClusterHealthMonitoring = ({
const isTiny = displayMode !== "advanced" && width < 256;
return (
<Stack h={isAdvanced ? "auto" : "100%"} p="xs" gap={isTiny ? "xs" : "md"} pos="relative">
<Box pos="absolute" top={4} right={8} style={{ zIndex: 2 }}>
<WidgetQueryErrorIndicator error={healthQuery.error} label={t("name")} />
</Box>
{(isAdvanced || options.showUptime) && !isTiny && (
<Group justify="center" wrap="nowrap">
<Text fz="xs" fw={700} c="dimmed" ta="center">
@@ -45,7 +45,7 @@ import { filterStorageVolumes, normalizeStorageDeviceName } from "../filter-stor
import { WidgetEmptyState } from "../common/empty-state";
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state";
import { WidgetQueryErrorIndicator, WidgetQueryLoadingState } from "../common/query-state-indicator";
import { WidgetQueryLoadingState } from "../common/query-state-indicator";
import type { WidgetComponentProps } from "../definition";
import actionTargetClasses from "../common/action-target.module.css";
import { CpuRing } from "./rings/cpu-ring";
@@ -81,7 +81,6 @@ export const SystemHealthMonitoring = ({
const queryIndicators = (
<Group gap={0}>
<IntegrationErrorIndicator results={healthResults} />
<WidgetQueryErrorIndicator error={healthQuery.error} label={t("name")} />
</Group>
);
@@ -20,7 +20,6 @@ import { WidgetEmptyState } from "../../common/empty-state";
import type { WidgetComponentProps } from "../../definition";
import { useWidgetRuntimeActions } from "../../runtime-hooks";
import { getUsableWidgetQueryData } from "../../common/query-state";
import { WidgetQueryErrorIndicator } from "../../common/query-state-indicator";
import classes from "./component.module.css";
import { ALL_PHOTOS_ALBUM_ID } from "./constants";
@@ -32,7 +31,6 @@ export default function ImmichAlbumCarouselWidget({
}: WidgetComponentProps<"immich-albumCarousel">) {
const [currentPhotoIndex, setCurrentPhotoIndex] = useState(0);
const [paused, setPaused] = useState(false);
const t = useI18n("widget.immich-albumCarousel");
const albumQuery = clientApi.widget.immich.getAlbum.useQuery(
{
@@ -71,9 +69,6 @@ export default function ImmichAlbumCarouselWidget({
return (
<Box h="100%" pos="relative">
<Box pos="absolute" top={4} right={8} style={{ zIndex: 3 }}>
<WidgetQueryErrorIndicator error={albumQuery.error} label={t("name")} />
</Box>
{album.assets.length === 0 || photoAssets.length === 0 ? (
<NoPhotosInAlbum />
) : (
@@ -1,7 +1,7 @@
"use client";
import React from "react";
import { Box, Center, Group, Progress, ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core";
import { Group, Progress, ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core";
import { IconDatabase, IconPhoto, IconUsers, IconVideo } from "@tabler/icons-react";
import { getQueryKey } from "@trpc/react-query";
@@ -12,7 +12,7 @@ import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
import { WidgetEmptyState } from "../../common/empty-state";
import type { WidgetComponentProps } from "../../definition";
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../../common/query-state";
import { WidgetQueryErrorIndicator, WidgetQueryLoadingState } from "../../common/query-state-indicator";
import { WidgetQueryLoadingState } from "../../common/query-state-indicator";
import { useWidgetRuntimeQueries } from "../../runtime-hooks";
import classes from "./component.module.css";
@@ -103,11 +103,6 @@ export default function ImmichServerStatsWidget({
return (
<Stack gap="md" h="100%" p={statsLayout.dense ? "xs" : "md"} justify="center" pos="relative">
{statsContent}
{statsQuery.error && (
<Box pos="absolute" top={4} right={4}>
<WidgetQueryErrorIndicator error={statsQuery.error} label={t("name")} />
</Box>
)}
</Stack>
);
}
@@ -121,15 +116,10 @@ export default function ImmichServerStatsWidget({
<Text size="xs" c="dimmed">
{t("albumLimit", { count: MAX_ADVANCED_ALBUMS })}
</Text>
<WidgetQueryErrorIndicator error={statsQuery.error ?? albumsQuery.error} label={t("name")} />
</Group>
<ScrollArea style={{ flex: 1, minHeight: 0 }}>
{isInitialWidgetQueryPending(albumsQuery) ? (
<WidgetQueryLoadingState />
) : albumsQuery.error && albumsQuery.data === undefined ? (
<Center h="100%" p="md">
<WidgetQueryErrorIndicator error={albumsQuery.error} label={t("name")} />
</Center>
) : albums.length === 0 ? (
<WidgetEmptyState />
) : (
@@ -29,7 +29,6 @@ import { useWidgetRuntimeActions } from "../runtime-hooks";
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url";
import { WidgetEmptyState } from "../common/empty-state";
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state";
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
import actionTargetClasses from "../common/action-target.module.css";
import classes from "./component.module.css";
import { getIndexerDisplayStatus } from "./status";
@@ -118,7 +117,6 @@ export default function IndexerManagerWidget({
)}
</Group>
)}
<WidgetQueryErrorIndicator error={indexersQuery.error} label={t("title")} />
<Tooltip label={testAllError ? tCommon("error") : t("testAll")}>
<ActionIcon
className={combineClasses("indexer-manager-test-action-icon", classes.testAction, actionTargetClasses.root)}
@@ -28,7 +28,6 @@ import { useI18n } from "@homarr/translation/client";
import { WidgetEmptyState } from "../common/empty-state";
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url";
import { getUsableWidgetQueryData } from "../common/query-state";
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
import type { WidgetComponentProps } from "../definition";
import { useWidgetRuntimeQueries } from "../runtime-hooks";
import { NoIntegrationDataError } from "../errors/no-data-integration";
@@ -164,15 +163,10 @@ export default function MediaMissingWidget({
))}
</Group>
);
const queryFailure = <WidgetQueryErrorIndicator error={mediaQuery.error} label={t("name")} />;
if (isAdvanced) {
return (
<Stack h="100%" gap={0}>
<Group justify="flex-end" px="xs">
{queryFailure}
{partialFailures}
</Group>
{partialFailures}
<SimpleGrid cols={enabledPanelCount} spacing="sm" p="sm" style={{ flex: 1, minHeight: 0 }}>
{showMissing && (
<Paper withBorder radius="sm" style={{ minHeight: 0, overflow: "hidden" }}>
@@ -211,10 +205,7 @@ export default function MediaMissingWidget({
h="100%"
style={{ display: "flex", flexDirection: "column" }}
>
<Group justify="flex-end" px="xs">
{queryFailure}
{partialFailures}
</Group>
{partialFailures}
<Tabs.List grow>
{showMissing && (
<Tabs.Tab
@@ -33,7 +33,7 @@ import type { WidgetComponentProps } from "../definition";
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url";
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state";
import { WidgetQueryErrorIndicator, WidgetQueryLoadingState } from "../common/query-state-indicator";
import { WidgetQueryLoadingState } from "../common/query-state-indicator";
import classes from "./component.module.css";
export default function MediaReleasesWidget({
@@ -43,7 +43,6 @@ export default function MediaReleasesWidget({
height,
displayMode,
}: WidgetComponentProps<"mediaReleases">) {
const t = useI18n("widget.mediaReleases");
const releasesQuery = clientApi.widget.mediaRelease.getMediaReleases.useQuery({ integrationIds });
const response = getUsableWidgetQueryData(releasesQuery);
@@ -70,10 +69,9 @@ export default function MediaReleasesWidget({
</SimpleGrid>
</ScrollArea>
)}
{(failedIntegrations.length > 0 || releasesQuery.error) && (
{failedIntegrations.length > 0 && (
<Group pos="absolute" top={4} right={4} gap={0}>
<IntegrationErrorIndicator results={failedIntegrations} />
<WidgetQueryErrorIndicator error={releasesQuery.error} label={t("name")} />
</Group>
)}
</Box>
@@ -40,7 +40,6 @@ import type { TablerIcon } from "@homarr/ui";
import type { WidgetComponentProps } from "../definition";
import { getUsableWidgetQueryData } from "../common/query-state";
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
import classes from "./component.module.css";
type TranscodingDecision = NonNullable<NonNullable<StreamSession["currentlyPlaying"]>["metadata"]>["transcoding"];
@@ -262,14 +261,12 @@ export default function MediaServerWidget({
aria-label={tSearch("placeholder")}
leftSection={<IconSearch size="var(--mantine-font-size-xs)" aria-hidden />}
/>
<WidgetQueryErrorIndicator error={currentStreamsQuery.error} label={t("name")} />
<IntegrationErrorIndicator results={currentStreams} />
</Group>
</Group>
)}
{!isAdvanced && (hasFailedIntegrations || currentStreamsQuery.error) && (
{!isAdvanced && hasFailedIntegrations && (
<Group px="xs" justify="flex-end">
<WidgetQueryErrorIndicator error={currentStreamsQuery.error} label={t("name")} />
<IntegrationErrorIndicator results={currentStreams} />
</Group>
)}
@@ -4,7 +4,6 @@ import { useEffect, useMemo, useState } from "react";
import type { ReactNode } from "react";
import { getQueryKey } from "@trpc/react-query";
import {
Box,
Button,
Divider,
Group,
@@ -26,7 +25,6 @@ import { views } from ".";
import { WidgetEmptyState } from "../common/empty-state";
import type { WidgetComponentProps } from "../definition";
import { getUsableWidgetQueryData } from "../common/query-state";
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
import { useWidgetRuntimeQueries } from "../runtime-hooks";
import { HealthCheckStatus } from "./health-check-status";
import { QueuePanel } from "./panels/queue.panel";
@@ -115,16 +113,9 @@ export default function MediaTranscodingWidget({
const queuePageCount = totalQueuePages ?? 1;
const isTiny = !isAdvanced && (width < 280 || height < 140);
const footerLayout = getTranscodingFooterLayout(width, height);
const queryIndicator = (
<Box pos="absolute" top={4} right={8} style={{ zIndex: 2 }}>
<WidgetQueryErrorIndicator error={transcodingQuery.error} label={t("name")} />
</Box>
);
if (isAdvanced) {
return (
<Stack gap="xs" h="100%" p="xs" pos="relative">
{queryIndicator}
<ScrollArea h="100%" style={{ flex: 1 }}>
<SimpleGrid cols={widthLayout.advancedColumns} spacing="sm">
<AdvancedPanel title={t("tab.workers")} icon={IconCpu2}>
@@ -155,7 +146,6 @@ export default function MediaTranscodingWidget({
return (
<Stack gap={4} h="100%" pos="relative">
{queryIndicator}
{view === "workers" ? (
<WorkersPanel workers={transcodingData.data.workers} isTiny={isTiny} />
) : view === "queue" ? (
@@ -17,10 +17,7 @@ export const WidgetAdvancedOptionsModal = createModal<InnerProps>(({ actions, in
const t = useI18n("item.edit");
const theme = useMantineTheme();
const form = useForm({
initialValues: {
...innerProps.advancedOptions,
title: innerProps.advancedOptions.title ?? "",
},
initialValues: innerProps.advancedOptions,
});
const handleSubmit = (values: BoardItemAdvancedOptions) => {
innerProps.onSuccess({
@@ -1,118 +0,0 @@
"use client";
import { useState } from "react";
import { Box, getDefaultZIndex, Overlay, Portal } from "@mantine/core";
import { useIsomorphicEffect } from "@mantine/hooks";
import classes from "./widget-edit-modal.module.css";
interface FocusRect {
top: number;
left: number;
width: number;
height: number;
}
export type WidgetInspectorSide = "left" | "right";
const focusGap = 6;
const getFocusRect = (element: HTMLElement): FocusRect | null => {
const rect = element.getBoundingClientRect();
const top = Math.max(4, rect.top - focusGap);
const left = Math.max(4, rect.left - focusGap);
const right = Math.min(window.innerWidth - 4, rect.right + focusGap);
const bottom = Math.min(window.innerHeight - 4, rect.bottom + focusGap);
if (right <= left || bottom <= top) return null;
return {
top,
left,
width: right - left,
height: bottom - top,
};
};
const focusRectsMatch = (current: FocusRect | null, next: FocusRect | null) => {
if (current === next) return true;
if (!current || !next) return false;
return (
current.top === next.top &&
current.left === next.left &&
current.width === next.width &&
current.height === next.height
);
};
export const useWidgetEditFocus = (itemId: string | undefined, focusTargetId: string | undefined) => {
const [focusRect, setFocusRect] = useState<FocusRect | null>(null);
useIsomorphicEffect(() => {
let element: HTMLElement | null = null;
if (focusTargetId) {
element = document.getElementById(focusTargetId);
} else if (itemId) {
element = document.querySelector<HTMLElement>(`[data-advanced-focus-item-id="${CSS.escape(itemId)}"]`);
element ??= document.querySelector<HTMLElement>(`[data-item-id="${CSS.escape(itemId)}"]`);
}
if (!element) {
setFocusRect(null);
return;
}
let animationFrame = 0;
const update = () => {
animationFrame = 0;
const nextRect = getFocusRect(element);
setFocusRect((current) => (focusRectsMatch(current, nextRect) ? current : nextRect));
};
const scheduleUpdate = () => {
if (animationFrame !== 0) return;
animationFrame = window.requestAnimationFrame(update);
};
update();
const resizeObserver = new ResizeObserver(scheduleUpdate);
resizeObserver.observe(element);
window.addEventListener("resize", scheduleUpdate);
window.addEventListener("scroll", scheduleUpdate, true);
return () => {
resizeObserver.disconnect();
window.removeEventListener("resize", scheduleUpdate);
window.removeEventListener("scroll", scheduleUpdate, true);
window.cancelAnimationFrame(animationFrame);
};
}, [focusTargetId, itemId]);
const inspectorSide: WidgetInspectorSide =
focusRect && focusRect.left + focusRect.width / 2 > window.innerWidth / 2 ? "left" : "right";
return { focusRect, inspectorSide };
};
export const WidgetEditFocusOverlay = ({ focusRect }: { focusRect: FocusRect | null }) => (
<Portal>
{focusRect ? (
<>
<Overlay fixed backgroundOpacity={0} zIndex={getDefaultZIndex("modal")} />
<Box
aria-hidden
className={classes.focusFrame}
style={{
top: focusRect.top,
left: focusRect.left,
width: focusRect.width,
height: focusRect.height,
zIndex: getDefaultZIndex("modal"),
}}
/>
</>
) : (
<Overlay fixed color="#000" backgroundOpacity={0.58} blur={1} zIndex={getDefaultZIndex("modal")} />
)}
</Portal>
);
@@ -1,90 +0,0 @@
.panel {
min-width: 0;
}
.settings {
min-width: 0;
container-type: inline-size;
}
.switchGrid {
display: grid;
grid-template-columns: minmax(0, 1fr);
align-items: stretch;
gap: var(--mantine-spacing-sm);
}
.switchOption {
display: flex;
height: 100%;
min-height: 4.25rem;
align-items: center;
padding: 0.625rem 0.75rem;
border: 1px solid var(--mantine-color-default-border);
border-radius: var(--mantine-radius-sm);
background: var(--mantine-color-body);
}
.switchOption > *,
.switchOption :global(.mantine-Switch-root),
.switchOption :global(.mantine-Switch-body) {
width: 100%;
}
.switchOption :global(.mantine-Switch-body) {
flex-direction: row-reverse;
align-items: center;
justify-content: space-between;
gap: var(--mantine-spacing-sm);
}
.switchOption :global(.mantine-Switch-labelWrapper) {
min-width: 0;
flex: 1;
}
.footer {
position: sticky;
z-index: 2;
bottom: 0;
margin-inline: calc(var(--mantine-spacing-md) * -1);
margin-bottom: calc(var(--mantine-spacing-md) * -1);
padding: var(--mantine-spacing-sm) var(--mantine-spacing-md);
border-top: 1px solid var(--mantine-color-default-border);
background: color-mix(in srgb, var(--mantine-color-body) 96%, transparent);
backdrop-filter: blur(10px);
}
.footerActions {
margin-inline-start: auto;
}
@container (min-width: 30rem) {
.switchGrid {
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-auto-rows: 1fr;
}
}
.focusFrame {
position: fixed;
pointer-events: none;
border: 2px solid var(--mantine-primary-color-filled);
border-radius: var(--mantine-radius-md);
box-shadow:
0 0 0 2px color-mix(in srgb, var(--mantine-primary-color-filled) 32%, transparent),
0 0 2.5rem color-mix(in srgb, var(--mantine-primary-color-filled) 28%, transparent),
0 0 0 100vmax rgb(0 0 0 / 0.58);
clip-path: inset(-100vmax);
transition:
top 160ms ease,
left 160ms ease,
width 160ms ease,
height 160ms ease;
}
@media (prefers-reduced-motion: reduce) {
.focusFrame {
transition: none;
}
}
+189 -229
View File
@@ -1,6 +1,6 @@
"use client";
import { useEffect, useId, useRef, useState } from "react";
import { useEffect, useRef, useState } from "react";
import type { ComponentType, FormEvent } from "react";
import { Box, Button, Group, Stack, Tabs, Text } from "@mantine/core";
import { schemaResolver } from "@mantine/form";
@@ -11,7 +11,7 @@ import { objectEntries } from "@homarr/common";
import { useSession } from "@homarr/auth/client";
import { useOptionalBoard } from "@homarr/boards/context";
import type { WidgetKind } from "@homarr/definitions";
import { createModal, modalSizeForm, useModalAction } from "@homarr/modals";
import { createModal, ModalFormFooter, modalSizeForm, useModalAction } from "@homarr/modals";
import type { SettingsContextProps } from "@homarr/settings/creator";
import { useI18n } from "@homarr/translation/client";
import { IntegrationAvatar } from "@homarr/ui";
@@ -26,10 +26,8 @@ import { OPTIONS_SUPER_REFINE } from "../options";
import type { IntegrationSelectOption } from "../widget-integration-select";
import { WidgetIntegrationSelect } from "../widget-integration-select";
import { WidgetAdvancedOptionsModal } from "./widget-advanced-options-modal";
import { useWidgetEditFocus, WidgetEditFocusOverlay } from "./widget-edit-focus-overlay";
import type { EmbeddedAppEditFormHandle } from "./embedded-app-edit-form";
import { EmbeddedAppEditForm } from "./embedded-app-edit-form";
import classes from "./widget-edit-modal.module.css";
export interface WidgetEditModalState {
options: Record<string, unknown>;
@@ -52,14 +50,11 @@ export interface WidgetEditModalProps<TSort extends WidgetKind> {
definition: WidgetDefinition;
value: WidgetEditModalState;
onSuccessfulEdit: (value: WidgetEditModalState) => void;
onPreviewChange?: (value: WidgetEditModalState) => void;
onPreviewRestore?: () => void;
integrationData: IntegrationSelectOption[];
integrationSupport: boolean;
settings: SettingsContextProps;
itemId?: string;
boardId?: string;
focusTargetId?: string;
appId?: string;
integrationEditForm?: ComponentType<EmbeddedIntegrationEditFormProps>;
onIntegrationSaved?: () => void;
@@ -86,18 +81,6 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({
const [mountedIntegrationIds, setMountedIntegrationIds] = useState<string[]>([]);
const [activeIntegrationId, setActiveIntegrationId] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const hasPreviewedRef = useRef(false);
const hasSavedRef = useRef(false);
const previewCallbacksRef = useRef({
onPreviewChange: innerProps.onPreviewChange,
onPreviewRestore: innerProps.onPreviewRestore,
});
previewCallbacksRef.current = {
onPreviewChange: innerProps.onPreviewChange,
onPreviewRestore: innerProps.onPreviewRestore,
};
const widgetFormId = useId();
const { focusRect, inspectorSide } = useWidgetEditFocus(innerProps.itemId, innerProps.focusTargetId);
z.config({
customError: zodErrorMap(t),
@@ -129,47 +112,25 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({
if (integrationsRequired) integrationIdsSchema = integrationIdsSchema.min(1);
if (Number.isFinite(maxIntegrations)) integrationIdsSchema = integrationIdsSchema.max(maxIntegrations);
const widgetFormSchema = z.object({
options: optionsSchema,
integrationIds: integrationIdsSchema,
});
const applyPreview = (value: WidgetEditModalState) => {
if (!innerProps.onPreviewChange) return;
hasPreviewedRef.current = true;
innerProps.onPreviewChange(value);
};
const form = useForm({
mode: "controlled",
initialValues: {
options: innerProps.value.options,
integrationIds: innerProps.value.integrationIds,
},
validate: schemaResolver(widgetFormSchema, { sync: true }),
initialValues: innerProps.value,
validate: schemaResolver(
z.object({
options: optionsSchema,
integrationIds: integrationIdsSchema,
advancedOptions: z.object({
customCssClasses: z.array(z.string()),
borderColor: z.string(),
}),
}),
{ sync: true },
),
validateInputOnBlur: true,
validateInputOnChange: true,
onValuesChange: (values) => {
if (!widgetFormSchema.safeParse(values).success) return;
applyPreview({ ...values, advancedOptions });
},
});
const { openModal } = useModalAction(WidgetAdvancedOptionsModal);
useEffect(
() => () => {
if (!hasPreviewedRef.current || hasSavedRef.current) return;
const { onPreviewChange, onPreviewRestore } = previewCallbacksRef.current;
if (onPreviewRestore) {
onPreviewRestore();
return;
}
onPreviewChange?.(innerProps.value);
},
[innerProps.value],
);
const canModifyApps = session?.user.permissions.includes("app-modify-all") ?? false;
const canConfigureWidget = innerProps.kind !== "customApi" || (session?.user.permissions.includes("admin") ?? false);
const appId = innerProps.appId;
@@ -249,7 +210,6 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({
...values,
advancedOptions,
});
hasSavedRef.current = true;
actions.closeModal();
} finally {
setIsSubmitting(false);
@@ -271,202 +231,202 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({
}
: undefined;
const selectedIntegrationKinds = innerProps.integrationData
.filter(({ id }) => form.values.integrationIds.includes(id))
.map(({ kind }) => kind);
const visibleOptions = Object.entries(options).filter(([, value]) => {
const Input = getInputForType(value.type);
if (!Input) return false;
return !value.shouldHide?.(form.values.options as never, selectedIntegrationKinds);
});
const switchOptions = visibleOptions.filter(([, value]) => value.type === "switch");
const otherOptions = visibleOptions.filter(([, value]) => value.type !== "switch");
const widgetFormContent = (
<form id={widgetFormId} onSubmit={onFormSubmit}>
<Stack className={classes.settings} gap="md">
{canConfigureWidget && innerProps.integrationSupport && (
<WidgetIntegrationSelect
label={tItem("field.integrations.label")}
data={innerProps.integrationData}
canSelectMultiple={maxIntegrations > 1}
withAsterisk={integrationsRequired}
onOpenNewIntegration={canCreateIntegration ? handleOpenNewIntegration : undefined}
{...form.getInputProps("integrationIds")}
/>
)}
{canConfigureWidget && switchOptions.length > 0 && (
<Box className={classes.switchGrid}>
{switchOptions.map(([key, value]) => {
const Input = getInputForType(value.type);
if (!Input) return null;
<Stack>
{canConfigureWidget && innerProps.integrationSupport && (
<WidgetIntegrationSelect
label={tItem("field.integrations.label")}
data={innerProps.integrationData}
canSelectMultiple={maxIntegrations > 1}
withAsterisk={integrationsRequired}
onOpenNewIntegration={canCreateIntegration ? handleOpenNewIntegration : undefined}
{...form.getInputProps("integrationIds")}
/>
)}
{canConfigureWidget &&
Object.entries(options).map(([key, value]) => {
const Input = getInputForType(value.type);
return (
<Box key={key} className={classes.switchOption}>
<Input
kind={innerProps.kind}
property={key}
options={value as never}
initialOptions={innerProps.value.options}
itemId={innerProps.itemId}
boardId={innerProps.boardId ?? board?.id}
/>
</Box>
);
})}
</Box>
)}
{canConfigureWidget &&
otherOptions.map(([key, value]) => {
const Input = getInputForType(value.type);
if (!Input) return null;
if (
!Input ||
value.shouldHide?.(
form.values.options as never,
innerProps.integrationData
.filter(({ id }) => form.values.integrationIds.includes(id))
.map(({ kind }) => kind),
)
) {
return null;
}
return (
<Input
key={key}
kind={innerProps.kind}
property={key}
options={value as never}
initialOptions={innerProps.value.options}
itemId={innerProps.itemId}
boardId={innerProps.boardId ?? board?.id}
/>
);
})}
</Stack>
</form>
);
return (
<>
<WidgetEditFocusOverlay focusRect={focusRect} />
<FormProvider form={form}>
<Stack className={classes.panel} data-widget-inspector-side={inspectorSide} gap={0}>
<Box pb="md">
{showResourceTabs ? (
<Tabs value={activeTab} onChange={setActiveTab}>
<Tabs.List grow>
<Tabs.Tab value="widget">{tItem("tab.widget")}</Tabs.Tab>
{showAppTab && <Tabs.Tab value="app">{tItem("tab.app")}</Tabs.Tab>}
{showIntegrationTab && <Tabs.Tab value="integration">{tItem("tab.integration")}</Tabs.Tab>}
</Tabs.List>
<Tabs.Panel value="widget" pt="md">
{widgetFormContent}
</Tabs.Panel>
{showAppTab && (
<Tabs.Panel value="app" pt="md">
{appId && <EmbeddedAppEditForm appId={appId} handleRef={appEditRef} />}
</Tabs.Panel>
)}
{showIntegrationTab && (
<Tabs.Panel value="integration" pt="md">
<Stack>
{displayedIntegrationId === null && (
<>
<Text size="sm" c="dimmed">
{tItem("integration.description")}
</Text>
{selectedIntegrations.map((integration) => {
const canEdit = editableIntegrationIds.includes(integration.id);
return (
<Group key={integration.id} justify="space-between" wrap="nowrap" gap="sm">
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
<IntegrationAvatar kind={integration.kind} size="sm" />
<Stack gap={0} style={{ minWidth: 0 }}>
<Text fw={600} size="sm" truncate>
{integration.name}
</Text>
<Text size="xs" c="dimmed" truncate>
{integration.url}
</Text>
</Stack>
</Group>
{canEdit ? (
<Button
type="button"
variant="light"
leftSection={<IconPencil size={16} />}
onClick={() => beginEditingIntegration(integration.id)}
aria-label={tItem("integration.editLabel", { name: integration.name })}
>
{tItem("integration.action")}
</Button>
) : (
<Text size="xs" c="dimmed" ta="end">
{tItem("integration.fullAccessRequired")}
</Text>
)}
</Group>
);
})}
</>
)}
{displayedIntegrationId !== null && selectedIntegrations.length > 1 && (
<Button
type="button"
variant="subtle"
leftSection={<IconArrowLeft size={16} />}
onClick={() => setActiveIntegrationId(null)}
style={{ alignSelf: "start" }}
>
{tCommon("action.previous")}
</Button>
)}
{integrationIdsToRender.map((integrationId) => (
<Box key={integrationId} hidden={displayedIntegrationId !== integrationId}>
{IntegrationEditForm && (
<IntegrationEditForm
integrationId={integrationId}
handleRef={getIntegrationEditRef(integrationId)}
onSuccess={innerProps.onIntegrationSaved}
/>
)}
</Box>
))}
</Stack>
</Tabs.Panel>
)}
</Tabs>
) : (
widgetFormContent
)}
</Box>
<Group className={classes.footer} justify="space-between" wrap="wrap">
return (
<Input
key={key}
kind={innerProps.kind}
property={key}
options={value as never}
initialOptions={innerProps.value.options}
itemId={innerProps.itemId}
boardId={innerProps.boardId ?? board?.id}
/>
);
})}
{showResourceTabs ? (
<Button
variant="subtle"
type="button"
onClick={() =>
openModal({
advancedOptions,
onSuccess: setAdvancedOptions,
})
}
>
{tItem("advancedOptions.label")}
</Button>
) : (
<ModalFormFooter
onCancel={actions.closeModal}
leftSection={
<Button
variant="subtle"
type="button"
onClick={() =>
openModal({
advancedOptions,
onSuccess: (value) => {
setAdvancedOptions(value);
applyPreview({ ...form.getValues(), advancedOptions: value });
},
onSuccess: setAdvancedOptions,
})
}
>
{tItem("advancedOptions.label")}
</Button>
<Group className={classes.footerActions} gap="xs" justify="flex-end" wrap="wrap">
<Button type="button" onClick={actions.closeModal} variant="subtle" color="gray">
{tCommon("action.cancel")}
</Button>
<Button type="submit" form={widgetFormId} loading={isSubmitting}>
{tCommon("action.saveChanges")}
</Button>
</Group>
</Group>
</Stack>
}
/>
)}
</Stack>
);
return (
<form onSubmit={onFormSubmit}>
<FormProvider form={form}>
{showResourceTabs ? (
<Stack>
<Tabs value={activeTab} onChange={setActiveTab}>
<Tabs.List grow>
<Tabs.Tab value="widget">{tItem("tab.widget")}</Tabs.Tab>
{showAppTab && <Tabs.Tab value="app">{tItem("tab.app")}</Tabs.Tab>}
{showIntegrationTab && <Tabs.Tab value="integration">{tItem("tab.integration")}</Tabs.Tab>}
</Tabs.List>
<Tabs.Panel value="widget" pt="md">
{widgetFormContent}
</Tabs.Panel>
{showAppTab && (
<Tabs.Panel value="app" pt="md">
{appId && <EmbeddedAppEditForm appId={appId} handleRef={appEditRef} />}
</Tabs.Panel>
)}
{showIntegrationTab && (
<Tabs.Panel value="integration" pt="md">
<Stack>
{displayedIntegrationId === null && (
<>
<Text size="sm" c="dimmed">
{tItem("integration.description")}
</Text>
{selectedIntegrations.map((integration) => {
const canEdit = editableIntegrationIds.includes(integration.id);
return (
<Group key={integration.id} justify="space-between" wrap="nowrap" gap="sm">
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
<IntegrationAvatar kind={integration.kind} size="sm" />
<Stack gap={0} style={{ minWidth: 0 }}>
<Text fw={600} size="sm" truncate>
{integration.name}
</Text>
<Text size="xs" c="dimmed" truncate>
{integration.url}
</Text>
</Stack>
</Group>
{canEdit ? (
<Button
type="button"
variant="light"
leftSection={<IconPencil size={16} />}
onClick={() => beginEditingIntegration(integration.id)}
aria-label={tItem("integration.editLabel", { name: integration.name })}
>
{tItem("integration.action")}
</Button>
) : (
<Text size="xs" c="dimmed" ta="end">
{tItem("integration.fullAccessRequired")}
</Text>
)}
</Group>
);
})}
</>
)}
{displayedIntegrationId !== null && selectedIntegrations.length > 1 && (
<Button
type="button"
variant="subtle"
leftSection={<IconArrowLeft size={16} />}
onClick={() => setActiveIntegrationId(null)}
style={{ alignSelf: "start" }}
>
{tCommon("action.previous")}
</Button>
)}
{integrationIdsToRender.map((integrationId) => (
<Box key={integrationId} hidden={displayedIntegrationId !== integrationId}>
{IntegrationEditForm && (
<IntegrationEditForm
integrationId={integrationId}
handleRef={getIntegrationEditRef(integrationId)}
onSuccess={innerProps.onIntegrationSaved}
/>
)}
</Box>
))}
</Stack>
</Tabs.Panel>
)}
</Tabs>
<Box
pos="sticky"
bottom={0}
style={{
marginInline: "calc(var(--mantine-spacing-md) * -1)",
marginBottom: "calc(var(--mantine-spacing-md) * -1)",
paddingInline: "var(--mantine-spacing-md)",
paddingBlock: "var(--mantine-spacing-sm)",
background: "var(--mantine-color-body)",
borderTop: "1px solid var(--mantine-color-default-border)",
}}
>
<Group justify="end">
<Button onClick={actions.closeModal} variant="subtle" color="gray">
{tCommon("action.cancel")}
</Button>
<Button type="submit" loading={isSubmitting}>
{tCommon("action.saveChanges")}
</Button>
</Group>
</Box>
</Stack>
) : (
widgetFormContent
)}
</FormProvider>
</>
</form>
);
}).withOptions({
keepMounted: true,
defaultTitle(t) {
return t("item.edit.title");
},
presentation: "inspector",
withOverlay: false,
size: modalSizeForm,
closeOnClickOutside: false,
});
@@ -9,7 +9,6 @@ import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../../definition";
import { IntegrationErrorIndicator } from "../../common/integration-error-indicator";
import { getUsableWidgetQueryData } from "../../common/query-state";
import { WidgetQueryErrorIndicator } from "../../common/query-state-indicator";
import { getNetworkControllerStatusLayout } from "./layout";
import { WifiVariant } from "./variants/wifi-variant";
import { WiredVariant } from "./variants/wired-variant";
@@ -35,7 +34,6 @@ export default function NetworkControllerNetworkStatusWidget({
),
[results],
);
const t = useI18n("widget.networkControllerStatus");
const tWidgetCommon = useI18n("widget.common");
const tCommon = useI18n("common");
const locale = useCurrentIntlLocale();
@@ -49,7 +47,6 @@ export default function NetworkControllerNetworkStatusWidget({
const queryIndicators = (
<Group gap={0} justify="flex-end">
<IntegrationErrorIndicator results={results} />
<WidgetQueryErrorIndicator error={summaryQuery.error} label={t("name")} />
</Group>
);
@@ -15,7 +15,6 @@ import type { WidgetComponentProps } from "../../definition";
import { IntegrationErrorIndicator } from "../../common/integration-error-indicator";
import { getBinaryStatusKey } from "../../common/locale";
import { getUsableWidgetQueryData } from "../../common/query-state";
import { WidgetQueryErrorIndicator } from "../../common/query-state-indicator";
import type { NetworkControllerMatrixMetricKey, NetworkControllerMatrixSectionKey } from "./display";
import { getNetworkControllerMatrix } from "./display";
@@ -57,7 +56,6 @@ export default function NetworkControllerSummaryWidget({
const queryIndicators = (
<Group gap={0} justify="flex-end">
<IntegrationErrorIndicator results={results} />
<WidgetQueryErrorIndicator error={summaryQuery.error} label={t("name")} />
</Group>
);
@@ -11,7 +11,6 @@ import { useI18n } from "@homarr/translation/client";
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url";
import { getUsableWidgetQueryData } from "../common/query-state";
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
import type { WidgetComponentProps } from "../definition";
import { getNotificationDisplay } from "./display";
import classes from "./component.module.css";
@@ -67,11 +66,6 @@ export default function NotificationsWidget({
return (
<ScrollArea className="scroll-area-w100" w="100%" h="100%" p="xs">
<Stack w="100%" gap="xs">
{notificationsQuery.error && (
<Group justify="flex-end">
<WidgetQueryErrorIndicator error={notificationsQuery.error} label={t("name")} />
</Group>
)}
{failedIntegrations.length > 0 && (
<Group gap={4} wrap="wrap">
{failedIntegrations.map((integration) => (
@@ -29,13 +29,6 @@
min-height: 220px;
}
.queryIndicator {
position: absolute;
top: 4px;
right: 4px;
z-index: 2;
}
.compactRoot {
padding: clamp(4px, 3cqi, 8px);
gap: 2px;
+1 -5
View File
@@ -16,13 +16,12 @@ import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
import { clientApi } from "@homarr/api/client";
import { getIntegrationName } from "@homarr/definitions";
import { useI18n } from "@homarr/translation/client";
import type { TablerIcon } from "@homarr/ui";
import { WidgetEmptyState } from "../common/empty-state";
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state";
import { WidgetQueryErrorIndicator, WidgetQueryLoadingState } from "../common/query-state-indicator";
import { WidgetQueryLoadingState } from "../common/query-state-indicator";
import type { WidgetComponentProps } from "../definition";
import classes from "./component.module.css";
import { getGridCols, getVisiblePatchMonStatKeys, shouldShowComplianceHeroText } from "./layout-utils";
@@ -278,9 +277,6 @@ export default function PatchMonWidget({
return (
<div className={classes.root}>
<div className={classes.queryIndicator}>
<WidgetQueryErrorIndicator error={statsQuery.error} label={getIntegrationName("patchmon")} />
</div>
{showHero && (
<ComplianceHero
compliancePercent={compliancePercent}
@@ -9,7 +9,6 @@ import { useI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition";
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
import { getUsableWidgetQueryData } from "../common/query-state";
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
import { WidgetEmptyState } from "../common/empty-state";
import { AveragesSection } from "./averages";
import { combineSpeedtestDashboards, getAvailableSpeedtestDashboards, getCompactSections } from "./helpers";
@@ -72,7 +71,6 @@ export default function SpeedtestTrackerWidget({
<Box h="100%" pos="relative">
<Group pos="absolute" top={4} right={8} gap={0} style={{ zIndex: 2 }}>
<IntegrationErrorIndicator results={dashboardData} />
<WidgetQueryErrorIndicator error={dashboardQuery.error} label={t("name")} />
</Group>
<ScrollArea h="100%">
<Stack gap="md" p="md">
@@ -131,7 +129,6 @@ export default function SpeedtestTrackerWidget({
<Box h="100%" pos="relative">
<Group pos="absolute" top={4} right={8} gap={0} style={{ zIndex: 2 }}>
<IntegrationErrorIndicator results={dashboardData} />
<WidgetQueryErrorIndicator error={dashboardQuery.error} label={t("name")} />
</Group>
<Stack h="100%" gap="sm" p="xs" style={{ overflow: "hidden" }}>
{dashboardData.length > 1 && (
+1 -6
View File
@@ -9,7 +9,7 @@ import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
import { WidgetEmptyState } from "../common/empty-state";
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state";
import { WidgetQueryErrorIndicator, WidgetQueryLoadingState } from "../common/query-state-indicator";
import { WidgetQueryLoadingState } from "../common/query-state-indicator";
import type { WidgetComponentProps } from "../definition";
function round(value: number) {
@@ -196,11 +196,6 @@ export default function StockPriceWidget({
return (
<Box h="100%" w="100%" pos="relative">
{content}
{stockQuery.error && (
<Box pos="absolute" top={4} right={4}>
<WidgetQueryErrorIndicator error={stockQuery.error} label={t("name")} />
</Box>
)}
</Box>
);
}
@@ -11,7 +11,7 @@ import { useI18n } from "@homarr/translation/client";
import { WidgetEmptyState } from "../common/empty-state";
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state";
import { WidgetQueryErrorIndicator, WidgetQueryLoadingState } from "../common/query-state-indicator";
import { WidgetQueryLoadingState } from "../common/query-state-indicator";
import type { WidgetComponentProps } from "../definition";
import { filterStorageVolumes, normalizeStorageDeviceName } from "../filter-storage-volumes";
import { NoIntegrationDataError } from "../errors/no-data-integration";
@@ -253,7 +253,6 @@ export default function SystemResources({
const queryIndicators = (
<Group gap={0}>
<IntegrationErrorIndicator results={results} />
<WidgetQueryErrorIndicator error={healthQuery.error} label={t("name")} />
</Group>
);
const emptyState = (
@@ -4,13 +4,11 @@ import { useEffect, useMemo, useRef, useState } from "react";
import { Box, Center, Group, Loader, ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { useI18n } from "@homarr/translation/client";
import { WidgetEmptyState } from "../common/empty-state";
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
import type { WidgetComponentProps } from "../definition";
import { getUsableWidgetQueryData } from "../common/query-state";
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
import { CombinedNetworkTrafficChart } from "./chart/combined-network-traffic";
import { SystemResourceCPUChart } from "./chart/cpu-chart";
import { SystemResourceGPUChart } from "./chart/gpu-chart";
@@ -84,7 +82,6 @@ export default function SystemResources({
height,
displayMode,
}: WidgetComponentProps<"systemResources">) {
const t = useI18n("widget.systemResources");
const healthQuery = clientApi.widget.healthMonitoring.getSystemHealthStatus.useQuery({ integrationIds });
const results = getUsableWidgetQueryData(healthQuery) ?? [];
const data = results.filter(
@@ -98,7 +95,6 @@ export default function SystemResources({
const queryIndicators = (
<Group gap={0}>
<IntegrationErrorIndicator results={results} />
<WidgetQueryErrorIndicator error={healthQuery.error} label={t("name")} />
</Group>
);
@@ -3,14 +3,12 @@
import { Box, Group, ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { getIntegrationName } from "@homarr/definitions";
import type { TracearrDashboardData } from "@homarr/integrations/types";
import { useI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition";
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
import { getUsableWidgetQueryData } from "../common/query-state";
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
import { NoIntegrationDataError } from "../errors/no-data-integration";
import { formatTotalTracearrBitrate } from "./bitrate";
import { RecentActivityList } from "./recent-activity-section";
@@ -186,7 +184,6 @@ function TracearrContent({ integrationIds, options, width, height, displayMode }
<Box h="100%" pos="relative">
<Group pos="absolute" top={4} right={8} gap={0} style={{ zIndex: 2 }}>
<IntegrationErrorIndicator results={dashboardData} />
<WidgetQueryErrorIndicator error={dashboardQuery.error} label={getIntegrationName("tracearr")} />
</Group>
<ScrollArea h="100%">
<SimpleGrid
@@ -223,7 +220,6 @@ function TracearrContent({ integrationIds, options, width, height, displayMode }
<Box h="100%" pos="relative">
<Group pos="absolute" top={4} right={8} gap={0} style={{ zIndex: 2 }}>
<IntegrationErrorIndicator results={dashboardData} />
<WidgetQueryErrorIndicator error={dashboardQuery.error} label={getIntegrationName("tracearr")} />
</Group>
<ScrollArea h="100%">
<Stack gap="xs" p="xs">
@@ -12,7 +12,6 @@ import {
} from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { getIntegrationName } from "@homarr/definitions";
import type { TraefikDashboardData, TraefikProtocolSummary, TraefikResourceSummary } from "@homarr/integrations/types";
import { useI18n } from "@homarr/translation/client";
@@ -20,7 +19,6 @@ import { WidgetEmptyState } from "../common/empty-state";
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
import type { WidgetComponentProps } from "../definition";
import { getUsableWidgetQueryData } from "../common/query-state";
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
import { NoIntegrationDataError } from "../errors/no-data-integration";
import classes from "./component.module.css";
@@ -212,7 +210,6 @@ function TraefikWidgetContent({
<Box h="100%" pos="relative">
<Group pos="absolute" top={4} right={8} gap={0} style={{ zIndex: 2 }}>
<IntegrationErrorIndicator results={data} />
<WidgetQueryErrorIndicator error={dashboardQuery.error} label={getIntegrationName("traefik")} />
</Group>
<ScrollArea h="100%">{summary}</ScrollArea>
</Box>
@@ -265,7 +262,6 @@ function TraefikWidgetContent({
<Box h="100%" pos="relative">
<Group pos="absolute" top={4} right={8} gap={0} style={{ zIndex: 2 }}>
<IntegrationErrorIndicator results={data} />
<WidgetQueryErrorIndicator error={dashboardQuery.error} label={getIntegrationName("traefik")} />
</Group>
<ScrollArea h="100%">
<Stack gap="md" p="md">
@@ -281,7 +277,6 @@ function TraefikWidgetContent({
<Box h="100%" pos="relative">
<Group pos="absolute" top={4} right={8} gap={0} style={{ zIndex: 2 }}>
<IntegrationErrorIndicator results={data} />
<WidgetQueryErrorIndicator error={dashboardQuery.error} label={getIntegrationName("traefik")} />
</Group>
<SimpleGrid cols={2} spacing="md" h="100%" p="md" style={{ gridTemplateRows: "minmax(0, 1fr)" }}>
<ScrollArea h="100%">{summary}</ScrollArea>
+1 -9
View File
@@ -15,7 +15,7 @@ import { UmamiTopPagesContent, UmamiTopReferrersContent } from "./umami-top-list
import { formatTimeFrameLabel, formatXLabel, umamiQueryOptions } from "./umami-utils";
import type { TimeFrame } from "./umami-utils";
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state";
import { WidgetQueryErrorIndicator, WidgetQueryLoadingState } from "../common/query-state-indicator";
import { WidgetQueryLoadingState } from "../common/query-state-indicator";
interface UmamiContentProps {
integrationIds: string[];
@@ -270,14 +270,6 @@ export function UmamiContent({
<Box className={classes.chartSurface} mt={4} style={{ flex: 1, minHeight: 0 }}>
{displayMode === "advanced" ? advancedContent : selectedView}
</Box>
{(statsQuery.error || activeVisitorsQuery.error || multiEventQuery.error) && (
<Box pos="absolute" top={4} right={4}>
<WidgetQueryErrorIndicator
error={statsQuery.error ?? activeVisitorsQuery.error ?? multiEventQuery.error}
label={t("name")}
/>
</Box>
)}
</Stack>
);
}
-3
View File
@@ -24,7 +24,6 @@ import { WidgetEmptyState } from "../common/empty-state";
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
import type { WidgetComponentProps } from "../definition";
import { getUsableWidgetQueryData } from "../common/query-state";
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
import { NoIntegrationSelectedError } from "../errors/no-integration-selected";
const statusColors: Record<UpsStatus, string> = {
@@ -87,7 +86,6 @@ function UpsContent({ integrationIds, options, width, height, displayMode }: Ups
<Box h="100%" pos="relative">
<Group pos="absolute" top={4} right={8} gap={0} style={{ zIndex: 2 }}>
<IntegrationErrorIndicator results={data} />
<WidgetQueryErrorIndicator error={summariesQuery.error} label={t("name")} />
</Group>
<WidgetEmptyState />
</Box>
@@ -118,7 +116,6 @@ function UpsContent({ integrationIds, options, width, height, displayMode }: Ups
<Box h="100%" pos="relative">
<Group pos="absolute" top={4} right={8} gap={0} style={{ zIndex: 2 }}>
<IntegrationErrorIndicator results={data} />
<WidgetQueryErrorIndicator error={summariesQuery.error} label={t("name")} />
</Group>
<ScrollArea h="100%">
{displayMode === "advanced" ? (
@@ -6,14 +6,12 @@ import { IconArrowDown, IconArrowUp, IconClockPause, IconServer } from "@tabler/
import { clientApi } from "@homarr/api/client";
import { formatNumber } from "@homarr/common";
import { getIntegrationName } from "@homarr/definitions";
import { useI18n } from "@homarr/translation/client";
import { WidgetEmptyState } from "../common/empty-state";
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
import type { WidgetComponentProps } from "../definition";
import { getUsableWidgetQueryData } from "../common/query-state";
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
import { NoIntegrationDataError } from "../errors/no-data-integration";
import classes from "./component.module.css";
import { aggregateUptimeKumaDashboards } from "./aggregate";
@@ -254,7 +252,6 @@ function UptimeKumaContent({ integrationIds, options, width, height, displayMode
<Box h="100%" pos="relative">
<Group pos="absolute" top={4} right={8} gap={0} style={{ zIndex: 2 }}>
<IntegrationErrorIndicator results={dashboardData} />
<WidgetQueryErrorIndicator error={dashboardQuery.error} label={getIntegrationName("uptimeKuma")} />
</Group>
{summaryContent}
</Box>
@@ -291,7 +288,6 @@ function UptimeKumaContent({ integrationIds, options, width, height, displayMode
<Box h="100%" pos="relative">
<Group pos="absolute" top={4} right={8} gap={0} style={{ zIndex: 2 }}>
<IntegrationErrorIndicator results={dashboardData} />
<WidgetQueryErrorIndicator error={dashboardQuery.error} label={getIntegrationName("uptimeKuma")} />
</Group>
<ScrollArea h="100%">
<Stack gap="lg" p="md">
@@ -307,7 +303,6 @@ function UptimeKumaContent({ integrationIds, options, width, height, displayMode
<Box h="100%" pos="relative">
<Group pos="absolute" top={4} right={8} gap={0} style={{ zIndex: 2 }}>
<IntegrationErrorIndicator results={dashboardData} />
<WidgetQueryErrorIndicator error={dashboardQuery.error} label={getIntegrationName("uptimeKuma")} />
</Group>
<SimpleGrid cols={2} spacing="lg" h="100%" p="md" style={{ gridTemplateRows: "minmax(0, 1fr)" }}>
{summaryContent}
@@ -110,10 +110,6 @@
outline-offset: 0.125rem;
}
.errorIndicator {
z-index: 2;
}
@media (any-pointer: coarse) {
.detailsButton,
.forecastButton,
+1 -8
View File
@@ -3,11 +3,10 @@
import { Box } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { useI18n } from "@homarr/translation/client";
import { WidgetEmptyState } from "../common/empty-state";
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state";
import { WidgetQueryErrorIndicator, WidgetQueryLoadingState } from "../common/query-state-indicator";
import { WidgetQueryLoadingState } from "../common/query-state-indicator";
import type { WidgetComponentProps } from "../definition";
import { AdvancedWeather } from "./advanced";
import classes from "./component.module.css";
@@ -20,7 +19,6 @@ export default function WeatherWidget({
height,
displayMode,
}: WidgetComponentProps<"weather">) {
const t = useI18n("widget.weather");
const input = {
latitude: options.location.latitude,
longitude: options.location.longitude,
@@ -40,11 +38,6 @@ export default function WeatherWidget({
<CompactWeather height={height} isEditMode={isEditMode} options={options} weather={weather} width={width} />
)}
</Box>
{weatherQuery.error && (
<Box pos="absolute" top={4} right={4} className={classes.errorIndicator}>
<WidgetQueryErrorIndicator error={weatherQuery.error} label={t("name")} />
</Box>
)}
</Box>
);
}
+1 -7
View File
@@ -12,7 +12,6 @@ import { useI18n } from "@homarr/translation/client";
import { WidgetEmptyState } from "../common/empty-state";
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url";
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
import type { WidgetComponentProps } from "../definition";
import classes from "./component.module.css";
@@ -41,7 +40,7 @@ const WudWidgetContent = ({
isAdvanced: boolean;
}) => {
const t = useI18n("widget.wud");
const [data, statsQuery] = clientApi.widget.wud.getStats.useSuspenseQuery({ integrationId });
const [data] = clientApi.widget.wud.getStats.useSuspenseQuery({ integrationId });
const board = useRequiredBoard();
const isTiny = !isAdvanced && width < 256;
@@ -115,11 +114,6 @@ const WudWidgetContent = ({
return (
<Stack p="xs" gap="xs" h="100%">
{Boolean(statsQuery.error) && (
<Group justify="flex-end">
<WidgetQueryErrorIndicator error={statsQuery.error} label={t("name")} />
</Group>
)}
{showTitle && !isTiny && (
<Group gap="xs" wrap="nowrap" justify="space-between" miw={0}>
<Group gap="xs" wrap="nowrap" miw={0}>