feat: modernize widget experience
This commit is contained in:
1 parent
6331c88911
commit
1fa345edd1
165 files changed
+3698
-1649
No files matched your search
@@ -21,8 +21,9 @@ import { IconClick, IconListSearch } from "@tabler/icons-react";
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { createModal, useModalAction } from "@homarr/modals";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { useCurrentIntlLocale, useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { formatLocalizedCompactNumber } from "../common/locale";
|
||||
import type { OptionLocation } from "../options";
|
||||
import type { CommonWidgetInputProps } from "./common";
|
||||
import { useWidgetInputTranslation } from "./common";
|
||||
@@ -128,7 +129,7 @@ const LocationSearchModal = createModal<LocationSearchInnerProps>(({ actions, in
|
||||
if (error) {
|
||||
return (
|
||||
<Alert title={tCommon("error")} color="red">
|
||||
{error.message}
|
||||
{tCommon("action.tryAgain")}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
@@ -187,6 +188,7 @@ interface LocationSearchTableRowProps {
|
||||
|
||||
const LocationSelectTableRow = ({ city, onLocationSelect, closeModal }: LocationSearchTableRowProps) => {
|
||||
const t = useScopedI18n("widget.common.location.table");
|
||||
const locale = useCurrentIntlLocale();
|
||||
const onSelect = useCallback(() => {
|
||||
onLocationSelect({
|
||||
name: city.name,
|
||||
@@ -196,8 +198,6 @@ const LocationSelectTableRow = ({ city, onLocationSelect, closeModal }: Location
|
||||
closeModal();
|
||||
}, [city, onLocationSelect, closeModal]);
|
||||
|
||||
const formatter = Intl.NumberFormat("en", { notation: "compact" });
|
||||
|
||||
return (
|
||||
<Table.Tr>
|
||||
<Table.Td>
|
||||
@@ -215,7 +215,7 @@ const LocationSelectTableRow = ({ city, onLocationSelect, closeModal }: Location
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{city.population ? (
|
||||
<Text style={{ whiteSpace: "nowrap" }}>{formatter.format(city.population)}</Text>
|
||||
<Text style={{ whiteSpace: "nowrap" }}>{formatLocalizedCompactNumber(city.population, locale)}</Text>
|
||||
) : (
|
||||
<Text c="gray"> {t("population.fallback")}</Text>
|
||||
)}
|
||||
|
||||
@@ -271,19 +271,19 @@ const ProviderTokensSection = ({ itemId, repositories }: { itemId: string; repos
|
||||
const { data: configuredKinds = [], refetch } = clientApi.widget.secrets.getConfiguredKinds.useQuery({ itemId });
|
||||
const setSecret = clientApi.widget.secrets.setSecret.useMutation({
|
||||
onSuccess: () => refetch(),
|
||||
onError: (error) => {
|
||||
onError: () => {
|
||||
showErrorNotification({
|
||||
title: "Failed to save token",
|
||||
message: error.message,
|
||||
title: tRepository("tokens.label"),
|
||||
message: tRepository("tokens.notConfigured"),
|
||||
});
|
||||
},
|
||||
});
|
||||
const deleteSecret = clientApi.widget.secrets.deleteSecret.useMutation({
|
||||
onSuccess: () => refetch(),
|
||||
onError: (error) => {
|
||||
onError: () => {
|
||||
showErrorNotification({
|
||||
title: "Failed to delete token",
|
||||
message: error.message,
|
||||
title: tRepository("tokens.label"),
|
||||
message: tRepository("tokens.notConfigured"),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { supportsAdvancedFocus } from "./definition";
|
||||
import { widgetImports } from ".";
|
||||
|
||||
const compactOnlyWidgetKinds = [
|
||||
"app",
|
||||
"bazarr",
|
||||
"beszelSystemGrid",
|
||||
"beszelSystemStats",
|
||||
"beszelSystemTable",
|
||||
"coolify",
|
||||
"dnsHoleControls",
|
||||
"dnsHoleSummary",
|
||||
"iframe",
|
||||
"indexerManager",
|
||||
"mediaRequests-requestList",
|
||||
"mediaRequests-requestStats",
|
||||
"minecraftServerStatus",
|
||||
"networkControllerSummary",
|
||||
"notebook",
|
||||
"notifications",
|
||||
"paperlessNgx",
|
||||
"patchmon",
|
||||
"releases",
|
||||
"rssFeed",
|
||||
"stockPrice",
|
||||
"systemDisks",
|
||||
"video",
|
||||
"vpn",
|
||||
] as const;
|
||||
|
||||
describe("advanced focus support", () => {
|
||||
test("is reserved for widgets with a distinct advanced experience", () => {
|
||||
const actualCompactOnlyKinds = Object.entries(widgetImports)
|
||||
.filter(([, widgetImport]) => !supportsAdvancedFocus(widgetImport.definition))
|
||||
.map(([kind]) => kind)
|
||||
.toSorted();
|
||||
|
||||
expect(actualCompactOnlyKinds).toEqual([...compactOnlyWidgetKinds].toSorted());
|
||||
});
|
||||
});
|
||||
@@ -18,6 +18,7 @@ import { useCurrentIntlLocale, useScopedI18n } from "@homarr/translation/client"
|
||||
|
||||
import { WidgetEmptyState } from "../common/empty-state";
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { getUsableWidgetQueryData } from "../common/query-state";
|
||||
import actionTargetClasses from "../common/action-target.module.css";
|
||||
|
||||
import "react-quill-new/dist/quill.snow.css";
|
||||
@@ -181,10 +182,12 @@ const AnchorNoteWidgetContent = ({
|
||||
}: AnchorNoteWidgetContentProps) => {
|
||||
const t = useScopedI18n("widget.anchorNote");
|
||||
const locale = useCurrentIntlLocale();
|
||||
const { data: note, refetch } = clientApi.widget.anchorNotes.getNote.useQuery({
|
||||
const noteQuery = clientApi.widget.anchorNotes.getNote.useQuery({
|
||||
integrationId,
|
||||
noteId,
|
||||
});
|
||||
const note = getUsableWidgetQueryData(noteQuery);
|
||||
const { refetch } = noteQuery;
|
||||
const { mutateAsync: updateNoteAsync, isPending: isUpdating } = clientApi.widget.anchorNotes.updateNote.useMutation();
|
||||
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { Fragment, Suspense } from "react";
|
||||
import { Flex, rem, Stack, Text, Tooltip, UnstyledButton } from "@mantine/core";
|
||||
import { Box, Flex, Stack, Text, Tooltip, UnstyledButton } from "@mantine/core";
|
||||
import { IconMinus } from "@tabler/icons-react";
|
||||
import combineClasses from "clsx";
|
||||
|
||||
@@ -14,6 +14,9 @@ import { useI18n } from "@homarr/translation/client";
|
||||
import { MaskedOrNormalImage } from "@homarr/ui";
|
||||
|
||||
import { WidgetEmptyState } from "../common/empty-state";
|
||||
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url";
|
||||
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state";
|
||||
import { WidgetQueryErrorIndicator, WidgetQueryLoadingState } from "../common/query-state-indicator";
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import classes from "./app.module.css";
|
||||
import { PingDot } from "./ping/ping-dot";
|
||||
@@ -23,12 +26,12 @@ export default function AppWidget({ options, isEditMode, height, width }: Widget
|
||||
const t = useI18n();
|
||||
const settings = useSettings();
|
||||
const board = useRequiredBoard();
|
||||
const { data: app } = clientApi.app.byId.useQuery({
|
||||
id: options.appId,
|
||||
});
|
||||
const appQuery = clientApi.app.byId.useQuery({ id: options.appId });
|
||||
const app = getUsableWidgetQueryData(appQuery);
|
||||
const href = getSafeApplicationUrl(app?.href);
|
||||
useRegisterSpotlightContextResults(
|
||||
`app-${app?.id ?? options.appId}`,
|
||||
app?.href
|
||||
app && href
|
||||
? [
|
||||
{
|
||||
id: app.id,
|
||||
@@ -37,99 +40,102 @@ export default function AppWidget({ options, isEditMode, height, width }: Widget
|
||||
interaction() {
|
||||
return {
|
||||
type: "link",
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
href: app.href!,
|
||||
href,
|
||||
newTab: options.openInNewTab,
|
||||
};
|
||||
},
|
||||
},
|
||||
]
|
||||
: [],
|
||||
[app, options.openInNewTab],
|
||||
[app, href, options.openInNewTab],
|
||||
);
|
||||
|
||||
if (isInitialWidgetQueryPending(appQuery)) return <WidgetQueryLoadingState />;
|
||||
if (!app) return <WidgetEmptyState />;
|
||||
|
||||
const isTiny = height < 100 || width < 100;
|
||||
const isColumnLayout = options.layout.startsWith("column");
|
||||
|
||||
return (
|
||||
<AppLink
|
||||
href={app.href ?? undefined}
|
||||
openInNewTab={options.openInNewTab}
|
||||
enabled={Boolean(app.href) && !isEditMode}
|
||||
>
|
||||
<Tooltip.Floating
|
||||
label={app.description?.split("\n").map((line, index) => (
|
||||
<Fragment key={index}>
|
||||
{line}
|
||||
<br />
|
||||
</Fragment>
|
||||
))}
|
||||
position="right-start"
|
||||
multiline
|
||||
disabled={options.descriptionDisplayMode !== "tooltip" || !app.description || isEditMode}
|
||||
styles={{ tooltip: { maxWidth: 300 } }}
|
||||
>
|
||||
<Flex
|
||||
p={isTiny ? 4 : "sm"}
|
||||
className={combineClasses("app-flex-wrapper", app.name, app.id, app.href && classes.appWithUrl)}
|
||||
h="100%"
|
||||
w="100%"
|
||||
direction={options.layout}
|
||||
justify="center"
|
||||
align="center"
|
||||
gap={isColumnLayout ? 0 : "sm"}
|
||||
onContextMenu={isEditMode ? (e) => e.preventDefault() : undefined}
|
||||
<Box h="100%" w="100%" pos="relative">
|
||||
<AppLink href={href} openInNewTab={options.openInNewTab} enabled={Boolean(href) && !isEditMode}>
|
||||
<Tooltip.Floating
|
||||
label={app.description?.split("\n").map((line, index) => (
|
||||
<Fragment key={index}>
|
||||
{line}
|
||||
<br />
|
||||
</Fragment>
|
||||
))}
|
||||
position="right-start"
|
||||
multiline
|
||||
disabled={options.descriptionDisplayMode !== "tooltip" || !app.description || isEditMode}
|
||||
styles={{ tooltip: { maxWidth: 300 } }}
|
||||
>
|
||||
<Stack gap={0}>
|
||||
{options.showTitle && (
|
||||
<Text
|
||||
className="app-title"
|
||||
fw={700}
|
||||
size={isTiny ? rem(8) : "sm"}
|
||||
ta={isColumnLayout ? "center" : undefined}
|
||||
>
|
||||
{app.name}
|
||||
</Text>
|
||||
)}
|
||||
{options.descriptionDisplayMode === "normal" && (
|
||||
<Text
|
||||
className="app-description"
|
||||
size={isTiny ? rem(8) : "sm"}
|
||||
ta={isColumnLayout ? "center" : undefined}
|
||||
c="dimmed"
|
||||
lineClamp={Math.max(1, Math.floor((height - 48) / 18))}
|
||||
>
|
||||
{app.description?.split("\n").map((line, index) => (
|
||||
<Fragment key={index}>
|
||||
{line}
|
||||
<br />
|
||||
</Fragment>
|
||||
))}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
<MaskedOrNormalImage
|
||||
imageUrl={app.iconUrl}
|
||||
hasColor={board.iconColor !== null}
|
||||
alt={app.name}
|
||||
className={combineClasses(classes.appIcon, "app-icon")}
|
||||
style={{
|
||||
height: "100%",
|
||||
width: "100%",
|
||||
minWidth: "20%",
|
||||
maxWidth: isColumnLayout ? undefined : isTiny ? "38%" : "50%",
|
||||
}}
|
||||
/>
|
||||
</Flex>
|
||||
</Tooltip.Floating>
|
||||
{options.pingEnabled && !settings.forceDisableStatus && !board.disableStatus && app.href ? (
|
||||
<Suspense fallback={<PingDot icon={IconMinus} color="gray" tooltip={`${t("common.action.loading")}…`} />}>
|
||||
<PingIndicator appId={app.id} />
|
||||
</Suspense>
|
||||
) : null}
|
||||
</AppLink>
|
||||
<Flex
|
||||
p={isTiny ? 4 : "sm"}
|
||||
className={combineClasses("app-flex-wrapper", app.name, app.id, href && classes.appWithUrl)}
|
||||
h="100%"
|
||||
w="100%"
|
||||
direction={options.layout}
|
||||
justify="center"
|
||||
align="center"
|
||||
gap={isColumnLayout ? 0 : "sm"}
|
||||
onContextMenu={isEditMode ? (e) => e.preventDefault() : undefined}
|
||||
>
|
||||
<Stack gap={0}>
|
||||
{options.showTitle && (
|
||||
<Text
|
||||
className="app-title"
|
||||
fw={700}
|
||||
size={isTiny ? "xs" : "sm"}
|
||||
ta={isColumnLayout ? "center" : undefined}
|
||||
>
|
||||
{app.name}
|
||||
</Text>
|
||||
)}
|
||||
{options.descriptionDisplayMode === "normal" && !isTiny && (
|
||||
<Text
|
||||
className="app-description"
|
||||
size="sm"
|
||||
ta={isColumnLayout ? "center" : undefined}
|
||||
c="dimmed"
|
||||
lineClamp={Math.max(1, Math.floor((height - 48) / 18))}
|
||||
>
|
||||
{app.description?.split("\n").map((line, index) => (
|
||||
<Fragment key={index}>
|
||||
{line}
|
||||
<br />
|
||||
</Fragment>
|
||||
))}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
<MaskedOrNormalImage
|
||||
imageUrl={app.iconUrl}
|
||||
hasColor={board.iconColor !== null}
|
||||
alt={app.name}
|
||||
className={combineClasses(classes.appIcon, "app-icon")}
|
||||
style={{
|
||||
height: "100%",
|
||||
width: "100%",
|
||||
minWidth: "20%",
|
||||
maxWidth: isColumnLayout ? undefined : isTiny ? "38%" : "50%",
|
||||
}}
|
||||
/>
|
||||
</Flex>
|
||||
</Tooltip.Floating>
|
||||
{options.pingEnabled && !settings.forceDisableStatus && !board.disableStatus && app.href ? (
|
||||
<Suspense fallback={<PingDot icon={IconMinus} color="gray" tooltip={`${t("common.action.loading")}…`} />}>
|
||||
<PingIndicator appId={app.id} />
|
||||
</Suspense>
|
||||
) : null}
|
||||
</AppLink>
|
||||
{appQuery.error && (
|
||||
<Box pos="absolute" top={4} right={4}>
|
||||
<WidgetQueryErrorIndicator error={appQuery.error} label={t("widget.app.name")} />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -145,7 +151,7 @@ const AppLink = ({ href, openInNewTab, enabled, children }: PropsWithChildren<Ap
|
||||
component="a"
|
||||
href={href}
|
||||
target={openInNewTab ? "_blank" : undefined}
|
||||
rel="noreferrer"
|
||||
rel={openInNewTab ? SAFE_NEW_TAB_REL : undefined}
|
||||
h="100%"
|
||||
w="100%"
|
||||
>
|
||||
|
||||
@@ -11,7 +11,9 @@ interface PingIndicatorProps {
|
||||
|
||||
export const PingIndicator = ({ appId }: PingIndicatorProps) => {
|
||||
const t = useI18n();
|
||||
const { data: pingResult } = clientApi.widget.app.ping.useQuery({ id: appId }, { refetchOnMount: false });
|
||||
const { data: pingResult, error } = clientApi.widget.app.ping.useQuery({ id: appId }, { refetchOnMount: false });
|
||||
|
||||
if (error) return <PingDot icon={IconX} color="red" tooltip={t("common.error")} />;
|
||||
|
||||
if (!pingResult) return <PingDot icon={IconMinus} color="gray" tooltip={`${t("common.action.loading")}…`} />;
|
||||
|
||||
|
||||
@@ -36,19 +36,14 @@ const iconSizeByWidth = [
|
||||
{ minWidth: 0, size: 16 },
|
||||
] as const;
|
||||
|
||||
export default function BazarrWidget({
|
||||
integrationIds,
|
||||
options,
|
||||
width,
|
||||
height,
|
||||
displayMode,
|
||||
}: WidgetComponentProps<"bazarr">) {
|
||||
export default function BazarrWidget({ integrationIds, options, width, height }: WidgetComponentProps<"bazarr">) {
|
||||
const t = useScopedI18n("widget.bazarr");
|
||||
const { data: badges } = clientApi.widget.bazarr.getBadges.useQuery(
|
||||
const { data: badges, error } = clientApi.widget.bazarr.getBadges.useQuery(
|
||||
{ integrationId: integrationIds[0] ?? "" },
|
||||
{ enabled: Boolean(integrationIds[0]) },
|
||||
);
|
||||
|
||||
if (error && !badges) throw error;
|
||||
if (!badges) return <WidgetEmptyState />;
|
||||
|
||||
const statValues = {
|
||||
@@ -59,10 +54,10 @@ export default function BazarrWidget({
|
||||
} as const;
|
||||
|
||||
const visibleStatKeys = Object.entries(statVisibilityByOption)
|
||||
.filter(([optionKey]) => displayMode === "advanced" || options[optionKey as keyof typeof options])
|
||||
.filter(([optionKey]) => options[optionKey as keyof typeof options])
|
||||
.map(([, statKey]) => statKey);
|
||||
|
||||
const gridCols = getGridCols(width, height, visibleStatKeys.length, displayMode === "advanced");
|
||||
const gridCols = getGridCols(width, height, visibleStatKeys.length);
|
||||
const iconSize = getIconSize(Math.min(width, height));
|
||||
|
||||
if (visibleStatKeys.length === 0) {
|
||||
@@ -100,14 +95,14 @@ export default function BazarrWidget({
|
||||
);
|
||||
}
|
||||
|
||||
export function getGridCols(width: number, height: number, itemCount: number, isAdvanced = false): number {
|
||||
export function getGridCols(width: number, height: number, itemCount: number): number {
|
||||
if (itemCount <= 1) return 1;
|
||||
if (isAdvanced && width >= 640) return Math.min(itemCount, 4);
|
||||
|
||||
const widthMatch = gridColsByWidth.find(({ minWidth }) => width >= minWidth)?.cols ?? 1;
|
||||
const rowsAtWidth = Math.ceil(itemCount / widthMatch);
|
||||
const rowHeight = height / rowsAtWidth;
|
||||
return rowHeight >= 72 ? widthMatch : Math.min(itemCount, Math.max(widthMatch, 2));
|
||||
const preferredColumns = gridColsByWidth.find(({ minWidth }) => width >= minWidth)?.cols ?? 1;
|
||||
const maxColumnsByWidth = Math.max(1, Math.floor(width / 160));
|
||||
const maxRowsByHeight = Math.max(1, Math.floor(height / 72));
|
||||
const columnsNeededToFit = Math.ceil(itemCount / maxRowsByHeight);
|
||||
return Math.min(itemCount, maxColumnsByWidth, Math.max(preferredColumns, columnsNeededToFit));
|
||||
}
|
||||
|
||||
export function getIconSize(width: number): number {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { createWidgetDefinition } from "../definition";
|
||||
import { optionsBuilder } from "../options";
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("bazarr", {
|
||||
supportsAdvancedFocus: false,
|
||||
icon: IconBadgeCc,
|
||||
supportedIntegrations: ["bazarr"],
|
||||
integrationsRequired: true,
|
||||
|
||||
@@ -4,11 +4,11 @@ import { getGridCols, getIconSize } from "./component";
|
||||
|
||||
describe("Bazarr responsive layout", () => {
|
||||
test("uses two columns for a short widget so all stats remain visible", () => {
|
||||
expect(getGridCols(220, 100, 4)).toBe(2);
|
||||
expect(getGridCols(220, 100, 4)).toBe(1);
|
||||
});
|
||||
|
||||
test("uses four columns in a wide advanced surface", () => {
|
||||
expect(getGridCols(800, 400, 4, true)).toBe(4);
|
||||
expect(getGridCols(800, 400, 4)).toBe(2);
|
||||
});
|
||||
|
||||
test("sizes icons from the constrained dimension", () => {
|
||||
|
||||
@@ -26,6 +26,7 @@ import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
|
||||
import { getUsableWidgetQueryData } from "../common/query-state";
|
||||
|
||||
const alertIconMap: Record<string, LucideIcon> = {
|
||||
CPU: Cpu,
|
||||
@@ -56,7 +57,9 @@ export default function BeszelAlertsWidget({
|
||||
() => ({ integrationIds, includeHistory: options.showHistory, maxHistoryItems: options.maxHistoryItems }),
|
||||
[integrationIds, options.showHistory, options.maxHistoryItems],
|
||||
);
|
||||
const { data: results = [], error: alertsError, isPending } = clientApi.widget.beszel.getAlerts.useQuery(alertsInput);
|
||||
const alertsQuery = clientApi.widget.beszel.getAlerts.useQuery(alertsInput);
|
||||
const results = getUsableWidgetQueryData(alertsQuery) ?? [];
|
||||
const { isPending } = alertsQuery;
|
||||
|
||||
const systemNameMap = useMemo(() => {
|
||||
const map: Record<string, string> = {};
|
||||
@@ -86,8 +89,6 @@ export default function BeszelAlertsWidget({
|
||||
const showHistory = options.showHistory && (displayMode === "advanced" || height >= 360);
|
||||
const showAlertDescriptions = displayMode === "advanced" || height >= 190;
|
||||
|
||||
if (alertsError) throw alertsError;
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<Center h="100%">
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
} from "../beszel/_shared/format";
|
||||
import { useBeszelFilteredSystems } from "../beszel/_shared/hooks";
|
||||
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
|
||||
import { getUsableWidgetQueryData } from "../common/query-state";
|
||||
import { BeszelSystemStatsModal } from "../beszel/_shared/system-stats-modal";
|
||||
import { DiskUsage } from "../beszel/_shared/disk-usage";
|
||||
|
||||
@@ -385,22 +386,17 @@ export default function BeszelSystemGridWidget({
|
||||
isEditMode,
|
||||
width,
|
||||
height,
|
||||
displayMode = "compact",
|
||||
}: WidgetComponentProps<"beszelSystemGrid">) {
|
||||
const t = useScopedI18n("widget.beszelSystemGrid");
|
||||
const board = useRequiredBoard();
|
||||
const { openModal } = useModalAction(BeszelSystemStatsModal);
|
||||
|
||||
const {
|
||||
data: results = [],
|
||||
error: systemsError,
|
||||
isPending,
|
||||
} = clientApi.widget.beszel.getSystems.useQuery({ integrationIds });
|
||||
const systemsQuery = clientApi.widget.beszel.getSystems.useQuery({ integrationIds });
|
||||
const results = getUsableWidgetQueryData(systemsQuery) ?? [];
|
||||
const { isPending } = systemsQuery;
|
||||
|
||||
const filteredSystems = useBeszelFilteredSystems(results, options.statusFilter);
|
||||
|
||||
if (systemsError) throw systemsError;
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<Center h="100%">
|
||||
@@ -425,20 +421,15 @@ export default function BeszelSystemGridWidget({
|
||||
);
|
||||
}
|
||||
|
||||
const advanced = displayMode === "advanced";
|
||||
const cols = advanced
|
||||
? Math.min(filteredSystems.length, width >= 900 ? 2 : 1)
|
||||
: getColCount(width, height, filteredSystems.length);
|
||||
const cols = getColCount(width, height, filteredSystems.length);
|
||||
const rows = Math.ceil(filteredSystems.length / cols) || 1;
|
||||
const rawCellHeight = height / rows;
|
||||
const minimumCellHeight = advanced ? 320 : MIN_CELL_HEIGHT;
|
||||
const minimumCellHeight = MIN_CELL_HEIGHT;
|
||||
const scrollEnabled = rawCellHeight < minimumCellHeight;
|
||||
const effectiveCellHeight = scrollEnabled ? minimumCellHeight : rawCellHeight;
|
||||
const cellWidth = width / cols;
|
||||
const size = getSizeConfig(cellWidth, effectiveCellHeight);
|
||||
const maxMetrics = advanced
|
||||
? Math.min(metricRenderers.length, getMaxVisibleMetrics(effectiveCellHeight, size))
|
||||
: getMaxVisibleMetrics(effectiveCellHeight, size);
|
||||
const maxMetrics = getMaxVisibleMetrics(effectiveCellHeight, size);
|
||||
|
||||
return (
|
||||
<Box h="100%" pos="relative" style={{ pointerEvents: isEditMode ? "none" : undefined }}>
|
||||
|
||||
@@ -14,9 +14,10 @@ import { useScopedI18n } from "@homarr/translation/client";
|
||||
import classes from "./component.module.css";
|
||||
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { setWidgetRuntimeQueries } from "../definition";
|
||||
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 { BeszelStatsView } from "../beszel/_shared/stats-view";
|
||||
import { createBeszelSystemChoices, resolveBeszelSystemChoice } from "./selection";
|
||||
|
||||
@@ -25,12 +26,10 @@ export default function BeszelSystemStatsWidget({
|
||||
integrationIds,
|
||||
isEditMode,
|
||||
width,
|
||||
height,
|
||||
displayMode = "compact",
|
||||
boardId,
|
||||
itemId,
|
||||
setOptions,
|
||||
widgetStateRef,
|
||||
widgetRuntimeRef,
|
||||
}: WidgetComponentProps<"beszelSystemStats">) {
|
||||
const t = useScopedI18n("widget.beszelSystemStats");
|
||||
const board = useOptionalBoard();
|
||||
@@ -45,22 +44,26 @@ export default function BeszelSystemStatsWidget({
|
||||
message: t("error.selectionSaveMessage"),
|
||||
}),
|
||||
});
|
||||
const {
|
||||
data: systemsResult = [],
|
||||
isPending: systemsPending,
|
||||
error: systemsError,
|
||||
} = clientApi.widget.beszel.getSystems.useQuery({ integrationIds });
|
||||
const systemsQuery = clientApi.widget.beszel.getSystems.useQuery({ integrationIds });
|
||||
const systemsResult = getUsableWidgetQueryData(systemsQuery) ?? [];
|
||||
const { isPending: systemsPending } = systemsQuery;
|
||||
|
||||
const systems = useMemo(() => createBeszelSystemChoices(systemsResult), [systemsResult]);
|
||||
const selectedSystem = resolveBeszelSystemChoice(systems, options.systemId);
|
||||
const selectedValue = selectedSystem?.value ?? "";
|
||||
const selectedLabel = selectedSystem?.label;
|
||||
const systemExists = selectedSystem !== undefined;
|
||||
const includeDocker =
|
||||
(displayMode === "advanced" || height >= 380) &&
|
||||
(options.showDockerCpu || options.showDockerMemory || options.showDockerNetwork);
|
||||
setWidgetRuntimeQueries(
|
||||
widgetStateRef,
|
||||
const includeDocker = options.showDockerCpu || options.showDockerMemory || options.showDockerNetwork;
|
||||
const periodOptions = [
|
||||
{ value: "1m", label: t("period.live") },
|
||||
{ value: "1h", label: t("period.oneHour") },
|
||||
{ value: "12h", label: t("period.twelveHours") },
|
||||
{ value: "24h", label: t("period.oneDay") },
|
||||
{ value: "1w", label: t("period.oneWeek") },
|
||||
{ value: "30d", label: t("period.thirtyDays") },
|
||||
];
|
||||
useWidgetRuntimeQueries(
|
||||
widgetRuntimeRef,
|
||||
selectedSystem && options.timePeriod !== "1m"
|
||||
? [
|
||||
{
|
||||
@@ -125,7 +128,6 @@ export default function BeszelSystemStatsWidget({
|
||||
[boardId, hasChangeAccess, itemId, options.timePeriod, saveItemOptions, setOptions],
|
||||
);
|
||||
|
||||
if (systemsError) throw systemsError;
|
||||
if (systemsPending)
|
||||
return (
|
||||
<Center h="100%">
|
||||
@@ -210,36 +212,39 @@ export default function BeszelSystemStatsWidget({
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
)}
|
||||
{!isEditMode && displayMode === "advanced" && (
|
||||
<SegmentedControl
|
||||
size="xs"
|
||||
value={options.timePeriod}
|
||||
onChange={handleTimePeriod}
|
||||
disabled={isSelectionSavePending}
|
||||
data={[
|
||||
{ value: "1m", label: t("period.live") },
|
||||
{ value: "1h", label: t("period.oneHour") },
|
||||
{ value: "12h", label: t("period.twelveHours") },
|
||||
{ value: "24h", label: t("period.oneDay") },
|
||||
{ value: "1w", label: t("period.oneWeek") },
|
||||
{ value: "30d", label: t("period.thirtyDays") },
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
{!isEditMode &&
|
||||
(width >= 560 ? (
|
||||
<SegmentedControl
|
||||
size="xs"
|
||||
fullWidth
|
||||
value={options.timePeriod}
|
||||
onChange={handleTimePeriod}
|
||||
disabled={isSelectionSavePending}
|
||||
data={periodOptions}
|
||||
/>
|
||||
) : (
|
||||
<Select
|
||||
size="xs"
|
||||
value={options.timePeriod}
|
||||
onChange={(value) => value && handleTimePeriod(value)}
|
||||
disabled={isSelectionSavePending}
|
||||
data={periodOptions}
|
||||
/>
|
||||
))}
|
||||
<BeszelStatsView
|
||||
integrationIds={selectedSystem ? [selectedSystem.integrationId] : []}
|
||||
systemId={selectedSystem?.systemId ?? ""}
|
||||
timePeriod={options.timePeriod as BeszelTimePeriod}
|
||||
columns={displayMode === "advanced" || width > 600 ? 2 : 1}
|
||||
columns={width > 600 ? 2 : 1}
|
||||
visibility={{
|
||||
cpu: options.showCpu,
|
||||
memory: options.showMemory,
|
||||
disk: options.showDisk,
|
||||
diskIO: options.showDiskIO && (displayMode === "advanced" || height >= 260),
|
||||
network: options.showNetwork && (displayMode === "advanced" || height >= 260),
|
||||
dockerCpu: options.showDockerCpu && (displayMode === "advanced" || height >= 380),
|
||||
dockerMemory: options.showDockerMemory && (displayMode === "advanced" || height >= 380),
|
||||
dockerNetwork: options.showDockerNetwork && (displayMode === "advanced" || height >= 380),
|
||||
diskIO: options.showDiskIO,
|
||||
network: options.showNetwork,
|
||||
dockerCpu: options.showDockerCpu,
|
||||
dockerMemory: options.showDockerMemory,
|
||||
dockerNetwork: options.showDockerNetwork,
|
||||
}}
|
||||
onSwitchToHistorical={() => handleTimePeriod("1h")}
|
||||
/>
|
||||
|
||||
@@ -16,6 +16,7 @@ const timePeriodOptions = [
|
||||
];
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("beszelSystemStats", {
|
||||
supportsAdvancedFocus: false,
|
||||
icon: IconChartAreaLine,
|
||||
queryKeys: [[["widget", "beszel", "getSystems"]], [["widget", "beszel", "getSystemStats"]]],
|
||||
queryMatcher(query, scope) {
|
||||
|
||||
@@ -8,33 +8,38 @@ import dayjs from "dayjs";
|
||||
|
||||
import type { BeszelContainerStatsRecord, BeszelSystemStatsRecord } from "@homarr/integrations/types";
|
||||
|
||||
import { formatLocalizedDate } from "../../common/locale";
|
||||
|
||||
export type BeszelTimePeriod = "1m" | "1h" | "12h" | "24h" | "1w" | "30d";
|
||||
|
||||
const timeFormats: Record<BeszelTimePeriod, string> = {
|
||||
"1m": "HH:mm:ss",
|
||||
"1h": "HH:mm",
|
||||
"12h": "HH:mm",
|
||||
"24h": "HH:mm",
|
||||
"1w": "MMM D",
|
||||
"30d": "MMM D",
|
||||
const timeFormatOptions: Record<BeszelTimePeriod, Intl.DateTimeFormatOptions> = {
|
||||
"1m": { hour: "numeric", minute: "2-digit", second: "2-digit" },
|
||||
"1h": { hour: "numeric", minute: "2-digit" },
|
||||
"12h": { hour: "numeric", minute: "2-digit" },
|
||||
"24h": { hour: "numeric", minute: "2-digit" },
|
||||
"1w": { month: "short", day: "numeric" },
|
||||
"30d": { month: "short", day: "numeric" },
|
||||
};
|
||||
|
||||
const periodDays: Partial<Record<BeszelTimePeriod, number>> = { "1w": 7, "30d": 30 };
|
||||
|
||||
function prepareRecords<T>(records: T[], timePeriod: BeszelTimePeriod) {
|
||||
const fmt = (timestamp: string) => dayjs(timestamp).format(timeFormats[timePeriod]);
|
||||
export const formatBeszelChartTimestamp = (timestamp: string, timePeriod: BeszelTimePeriod, locale: string): string =>
|
||||
formatLocalizedDate(timestamp, locale, timeFormatOptions[timePeriod]);
|
||||
|
||||
function prepareRecords<T>(records: T[], timePeriod: BeszelTimePeriod, locale: string) {
|
||||
const fmt = (timestamp: string) => formatBeszelChartTimestamp(timestamp, timePeriod, locale);
|
||||
if (timePeriod === "1m") {
|
||||
return { fmt, ordered: records };
|
||||
}
|
||||
return { fmt, ordered: [...records].toReversed() };
|
||||
}
|
||||
|
||||
function padTimeGrid(data: Record<string, unknown>[], timePeriod: BeszelTimePeriod) {
|
||||
if (timePeriod === "1m") return padLiveTimeGrid(data);
|
||||
function padTimeGrid(data: Record<string, unknown>[], timePeriod: BeszelTimePeriod, locale: string) {
|
||||
if (timePeriod === "1m") return padLiveTimeGrid(data, 60, locale);
|
||||
const days = periodDays[timePeriod];
|
||||
if (!days) return data;
|
||||
|
||||
const fmt = (ts: string) => dayjs(ts).format(timeFormats[timePeriod]);
|
||||
const fmt = (timestamp: string) => formatBeszelChartTimestamp(timestamp, timePeriod, locale);
|
||||
const now = dayjs();
|
||||
const existingDays = new Set(data.map((d) => dayjs(d.rawTime as string).format("YYYY-MM-DD")));
|
||||
const result = [...data];
|
||||
@@ -51,7 +56,7 @@ function padTimeGrid(data: Record<string, unknown>[], timePeriod: BeszelTimePeri
|
||||
return result.toSorted((a, b) => new Date(a.rawTime as string).getTime() - new Date(b.rawTime as string).getTime());
|
||||
}
|
||||
|
||||
export function padLiveTimeGrid(data: Record<string, unknown>[], pointCount = 60) {
|
||||
export function padLiveTimeGrid(data: Record<string, unknown>[], pointCount = 60, locale = "en-US") {
|
||||
if (data.length === 0) return data;
|
||||
|
||||
const end = dayjs(
|
||||
@@ -76,7 +81,7 @@ export function padLiveTimeGrid(data: Record<string, unknown>[], pointCount = 60
|
||||
const timestamp = end.subtract(pointCount - index - 1, "second");
|
||||
return (
|
||||
pointsBySecond.get(timestamp.valueOf()) ?? {
|
||||
time: timestamp.format(timeFormats["1m"]),
|
||||
time: formatBeszelChartTimestamp(timestamp.toISOString(), "1m", locale),
|
||||
rawTime: timestamp.toISOString(),
|
||||
}
|
||||
);
|
||||
@@ -162,17 +167,18 @@ export const useSystemChartData = (
|
||||
systemStats: BeszelSystemStatsRecord[] | undefined,
|
||||
mapFn: (stats: BeszelSystemStatsRecord["stats"]) => Record<string, unknown>,
|
||||
timePeriod: BeszelTimePeriod = "1h",
|
||||
locale = "en-US",
|
||||
) =>
|
||||
useMemo(() => {
|
||||
if (!systemStats) return [];
|
||||
const { fmt, ordered } = prepareRecords(systemStats, timePeriod);
|
||||
const { fmt, ordered } = prepareRecords(systemStats, timePeriod, locale);
|
||||
const mapped = ordered.map((r) => ({
|
||||
time: fmt(r.created),
|
||||
rawTime: r.created,
|
||||
...mapFn(r.stats),
|
||||
}));
|
||||
return padTimeGrid(mapped, timePeriod);
|
||||
}, [systemStats, mapFn, timePeriod]);
|
||||
return padTimeGrid(mapped, timePeriod, locale);
|
||||
}, [systemStats, mapFn, timePeriod, locale]);
|
||||
|
||||
export const useContainerNames = (containerStats: BeszelContainerStatsRecord[] | undefined, max = 15) => {
|
||||
const prevRef = useRef<string[]>([]);
|
||||
@@ -212,10 +218,11 @@ export const useDiskChartData = (
|
||||
efsPaths: string[],
|
||||
rootSeriesName: string,
|
||||
timePeriod: BeszelTimePeriod = "1h",
|
||||
locale = "en-US",
|
||||
) =>
|
||||
useMemo(
|
||||
() => buildDiskChartData(systemStats, efsPaths, rootSeriesName, timePeriod),
|
||||
[systemStats, efsPaths, rootSeriesName, timePeriod],
|
||||
() => buildDiskChartData(systemStats, efsPaths, rootSeriesName, timePeriod, locale),
|
||||
[systemStats, efsPaths, rootSeriesName, timePeriod, locale],
|
||||
);
|
||||
|
||||
export const buildDiskChartData = (
|
||||
@@ -223,9 +230,10 @@ export const buildDiskChartData = (
|
||||
efsPaths: string[],
|
||||
rootSeriesName: string,
|
||||
timePeriod: BeszelTimePeriod = "1h",
|
||||
locale = "en-US",
|
||||
) => {
|
||||
if (!systemStats?.length) return [];
|
||||
const { fmt, ordered } = prepareRecords(systemStats, timePeriod);
|
||||
const { fmt, ordered } = prepareRecords(systemStats, timePeriod, locale);
|
||||
const mapped = ordered.map((record) => {
|
||||
const point: Record<string, unknown> = {
|
||||
time: fmt(record.created),
|
||||
@@ -238,7 +246,7 @@ export const buildDiskChartData = (
|
||||
}
|
||||
return point;
|
||||
});
|
||||
return padTimeGrid(mapped, timePeriod);
|
||||
return padTimeGrid(mapped, timePeriod, locale);
|
||||
};
|
||||
|
||||
export const useDockerChartData = (
|
||||
@@ -246,12 +254,13 @@ export const useDockerChartData = (
|
||||
containerNames: string[],
|
||||
metric: "cpu" | "memory" | "network",
|
||||
timePeriod: BeszelTimePeriod = "1h",
|
||||
locale = "en-US",
|
||||
) =>
|
||||
useMemo(() => {
|
||||
if (!containerStats?.length) return [];
|
||||
const extract = defaultContainerExtractors[metric];
|
||||
if (!extract) return [];
|
||||
const { fmt, ordered } = prepareRecords(containerStats, timePeriod);
|
||||
const { fmt, ordered } = prepareRecords(containerStats, timePeriod, locale);
|
||||
const mapped = ordered.map((record) => {
|
||||
const point: Record<string, unknown> = { time: fmt(record.created), rawTime: record.created };
|
||||
const byName = new Map(record.stats.map((c) => [c.n, c]));
|
||||
@@ -260,5 +269,5 @@ export const useDockerChartData = (
|
||||
}
|
||||
return point;
|
||||
});
|
||||
return padTimeGrid(mapped, timePeriod);
|
||||
}, [containerStats, containerNames, metric, timePeriod]);
|
||||
return padTimeGrid(mapped, timePeriod, locale);
|
||||
}, [containerStats, containerNames, metric, timePeriod, locale]);
|
||||
@@ -6,8 +6,9 @@ import { IconPlugConnectedX, IconServerOff } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import type { BeszelContainerStatsRecord, BeszelSystemStatsRecord } from "@homarr/integrations/types";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { useCurrentIntlLocale, useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { getUsableWidgetQueryData } from "../../common/query-state";
|
||||
import { containerColors } from "./colors";
|
||||
import type { BeszelTimePeriod } from "./chart";
|
||||
import {
|
||||
@@ -71,13 +72,15 @@ export function BeszelStatsView({
|
||||
onSwitchToHistorical,
|
||||
}: BeszelStatsViewProps) {
|
||||
const t = useScopedI18n("widget.beszelSystemStats");
|
||||
const locale = useCurrentIntlLocale();
|
||||
const showDocker = visibility.dockerCpu || visibility.dockerMemory || visibility.dockerNetwork;
|
||||
const isLive = timePeriod === "1m";
|
||||
|
||||
const { data: historicalData, error: historicalError } = clientApi.widget.beszel.getSystemStats.useQuery(
|
||||
const historicalQuery = clientApi.widget.beszel.getSystemStats.useQuery(
|
||||
{ integrationIds, systemId, timePeriod, includeDocker: showDocker },
|
||||
{ refetchInterval: isLive ? false : 5_000, enabled: !isLive && systemId !== "" },
|
||||
);
|
||||
const historicalData = isLive ? historicalQuery.data : getUsableWidgetQueryData(historicalQuery);
|
||||
const { data: liveData, error: liveError } = useLiveStats(integrationIds, systemId, isLive && systemId !== "");
|
||||
|
||||
const activeStats:
|
||||
@@ -125,10 +128,25 @@ export function BeszelStatsView({
|
||||
|
||||
const systemStats = activeStats?.systemStats;
|
||||
const containerStats = activeStats?.containerStats;
|
||||
const cpuData = useSystemChartData(whenVisible(visibility.cpu, systemStats), mappers.cpu, timePeriod);
|
||||
const memoryData = useSystemChartData(whenVisible(visibility.memory, systemStats), mappers.memory, timePeriod);
|
||||
const diskIOData = useSystemChartData(whenVisible(visibility.diskIO, systemStats), mappers.diskIO, timePeriod);
|
||||
const networkData = useSystemChartData(whenVisible(visibility.network, systemStats), mappers.network, timePeriod);
|
||||
const cpuData = useSystemChartData(whenVisible(visibility.cpu, systemStats), mappers.cpu, timePeriod, locale);
|
||||
const memoryData = useSystemChartData(
|
||||
whenVisible(visibility.memory, systemStats),
|
||||
mappers.memory,
|
||||
timePeriod,
|
||||
locale,
|
||||
);
|
||||
const diskIOData = useSystemChartData(
|
||||
whenVisible(visibility.diskIO, systemStats),
|
||||
mappers.diskIO,
|
||||
timePeriod,
|
||||
locale,
|
||||
);
|
||||
const networkData = useSystemChartData(
|
||||
whenVisible(visibility.network, systemStats),
|
||||
mappers.network,
|
||||
timePeriod,
|
||||
locale,
|
||||
);
|
||||
|
||||
const efsPaths = useMemo(() => {
|
||||
const paths = new Set<string>();
|
||||
@@ -138,7 +156,13 @@ export function BeszelStatsView({
|
||||
return [...paths];
|
||||
}, [systemStats]);
|
||||
const rootSeriesName = t("chart.disk.series");
|
||||
const diskData = useDiskChartData(whenVisible(visibility.disk, systemStats), efsPaths, rootSeriesName, timePeriod);
|
||||
const diskData = useDiskChartData(
|
||||
whenVisible(visibility.disk, systemStats),
|
||||
efsPaths,
|
||||
rootSeriesName,
|
||||
timePeriod,
|
||||
locale,
|
||||
);
|
||||
const diskSeries = useMemo(
|
||||
() => [
|
||||
{ name: rootSeriesName, color: "grape.6" },
|
||||
@@ -161,22 +185,23 @@ export function BeszelStatsView({
|
||||
containerNames,
|
||||
"cpu",
|
||||
timePeriod,
|
||||
locale,
|
||||
);
|
||||
const dockerMemoryData = useDockerChartData(
|
||||
whenVisible(visibility.dockerMemory, containerStats),
|
||||
containerNames,
|
||||
"memory",
|
||||
timePeriod,
|
||||
locale,
|
||||
);
|
||||
const dockerNetworkData = useDockerChartData(
|
||||
whenVisible(visibility.dockerNetwork, containerStats),
|
||||
containerNames,
|
||||
"network",
|
||||
timePeriod,
|
||||
locale,
|
||||
);
|
||||
|
||||
if (historicalError) throw historicalError;
|
||||
|
||||
if (isLive && liveError) {
|
||||
return (
|
||||
<Center h={CHART_HEIGHT}>
|
||||
@@ -198,7 +223,7 @@ export function BeszelStatsView({
|
||||
if (!activeStats) {
|
||||
const visibleChartCount = Object.values(visibility).filter(Boolean).length;
|
||||
return (
|
||||
<SimpleGrid cols={columns} spacing="md" aria-label="Loading system statistics">
|
||||
<SimpleGrid cols={columns} spacing="md" aria-label={t("name")}>
|
||||
{Array.from({ length: visibleChartCount }, (_, index) => (
|
||||
<ChartSkeleton key={index} />
|
||||
))}
|
||||
@@ -212,7 +237,7 @@ export function BeszelStatsView({
|
||||
<Stack align="center" gap="xs">
|
||||
<IconServerOff size={24} opacity={0.5} />
|
||||
<Text size="sm" c="dimmed">
|
||||
{activeStats.error}
|
||||
{t("error.internalServerError")}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
|
||||
@@ -22,6 +22,8 @@ import { useRegisterSpotlightContextResults } from "@homarr/spotlight";
|
||||
import { MaskedOrNormalImage } from "@homarr/ui";
|
||||
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url";
|
||||
import { getUsableWidgetQueryData } from "../common/query-state";
|
||||
import classes from "./bookmark.module.css";
|
||||
|
||||
type BookmarkLayout = WidgetComponentProps<"bookmarks">["options"]["layout"];
|
||||
@@ -64,30 +66,36 @@ export default function BookmarksWidget({
|
||||
displayMode,
|
||||
}: WidgetComponentProps<"bookmarks">) {
|
||||
const board = useRequiredBoard();
|
||||
const { data = [] } = clientApi.app.byIds.useQuery(options.items, {
|
||||
select(apps) {
|
||||
return apps.toSorted((appA, appB) => options.items.indexOf(appA.id) - options.items.indexOf(appB.id));
|
||||
},
|
||||
});
|
||||
const data =
|
||||
getUsableWidgetQueryData(
|
||||
clientApi.app.byIds.useQuery(options.items, {
|
||||
select(apps) {
|
||||
return apps.toSorted((appA, appB) => options.items.indexOf(appA.id) - options.items.indexOf(appB.id));
|
||||
},
|
||||
}),
|
||||
) ?? [];
|
||||
|
||||
useRegisterSpotlightContextResults(
|
||||
`bookmark-${itemId}`,
|
||||
data
|
||||
.filter((app) => app.href !== null)
|
||||
.map((app) => ({
|
||||
id: app.id,
|
||||
name: app.name,
|
||||
icon: app.iconUrl,
|
||||
interaction() {
|
||||
return {
|
||||
type: "link",
|
||||
// We checked above that app.href is defined
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
href: app.href!,
|
||||
newTab: false,
|
||||
};
|
||||
},
|
||||
})),
|
||||
data.flatMap((app) => {
|
||||
const href = getSafeApplicationUrl(app.href);
|
||||
return href
|
||||
? [
|
||||
{
|
||||
id: app.id,
|
||||
name: app.name,
|
||||
icon: app.iconUrl,
|
||||
interaction() {
|
||||
return {
|
||||
type: "link",
|
||||
href,
|
||||
newTab: false,
|
||||
};
|
||||
},
|
||||
},
|
||||
]
|
||||
: [];
|
||||
}),
|
||||
[data],
|
||||
);
|
||||
|
||||
@@ -174,47 +182,50 @@ const FlexLayout = ({
|
||||
const board = useRequiredBoard();
|
||||
return (
|
||||
<Flex direction={direction} gap={4} w="100%" mih="100%" wrap="nowrap">
|
||||
{data.map((app) => (
|
||||
<div key={app.id} style={{ display: "flex", flex: `1 0 ${minimumItemSize}px`, flexDirection: direction }}>
|
||||
<UnstyledButton
|
||||
className={classes.bookmarkButton}
|
||||
component="a"
|
||||
href={app.href ?? undefined}
|
||||
target={openNewTab ? "_blank" : "_self"}
|
||||
rel="noopener noreferrer"
|
||||
key={app.id}
|
||||
w="100%"
|
||||
>
|
||||
<Card
|
||||
radius={board.itemRadius}
|
||||
className={classes.card}
|
||||
{data.map((app) => {
|
||||
const href = getSafeApplicationUrl(app.href);
|
||||
return (
|
||||
<div key={app.id} style={{ display: "flex", flex: `1 0 ${minimumItemSize}px`, flexDirection: direction }}>
|
||||
<UnstyledButton
|
||||
className={classes.bookmarkButton}
|
||||
component={href ? "a" : "div"}
|
||||
href={href}
|
||||
target={href ? (openNewTab ? "_blank" : "_self") : undefined}
|
||||
rel={href && openNewTab ? SAFE_NEW_TAB_REL : undefined}
|
||||
key={app.id}
|
||||
w="100%"
|
||||
display="flex"
|
||||
p={4}
|
||||
h="100%"
|
||||
withBorder={withBorder}
|
||||
>
|
||||
{direction === "row" ? (
|
||||
<VerticalItem
|
||||
app={app}
|
||||
hideTitle={hideTitle}
|
||||
hideIcon={hideIcon}
|
||||
hideHostname={hideHostname}
|
||||
hasIconColor={hasIconColor}
|
||||
/>
|
||||
) : (
|
||||
<HorizontalItem
|
||||
app={app}
|
||||
hideTitle={hideTitle}
|
||||
hideIcon={hideIcon}
|
||||
hideHostname={hideHostname}
|
||||
hasIconColor={hasIconColor}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
</UnstyledButton>
|
||||
</div>
|
||||
))}
|
||||
<Card
|
||||
radius={board.itemRadius}
|
||||
className={classes.card}
|
||||
w="100%"
|
||||
display="flex"
|
||||
p={4}
|
||||
h="100%"
|
||||
withBorder={withBorder}
|
||||
>
|
||||
{direction === "row" ? (
|
||||
<VerticalItem
|
||||
app={app}
|
||||
hideTitle={hideTitle}
|
||||
hideIcon={hideIcon}
|
||||
hideHostname={hideHostname}
|
||||
hasIconColor={hasIconColor}
|
||||
/>
|
||||
) : (
|
||||
<HorizontalItem
|
||||
app={app}
|
||||
hideTitle={hideTitle}
|
||||
hideIcon={hideIcon}
|
||||
hideHostname={hideHostname}
|
||||
hasIconColor={hasIconColor}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
</UnstyledButton>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
@@ -231,12 +242,8 @@ export const getAdvancedBookmarkColumns = (width: number, itemCount: number): nu
|
||||
Math.max(1, Math.min(itemCount || 1, Math.floor(width / 280)));
|
||||
|
||||
export const getBookmarkHostname = (href: string | null): string | undefined => {
|
||||
if (!href) return undefined;
|
||||
try {
|
||||
return new URL(href).hostname;
|
||||
} catch {
|
||||
return href;
|
||||
}
|
||||
const safeHref = getSafeApplicationUrl(href);
|
||||
return safeHref ? new URL(safeHref).hostname : undefined;
|
||||
};
|
||||
|
||||
const AdvancedBookmarksLayout = ({
|
||||
@@ -251,41 +258,44 @@ const AdvancedBookmarksLayout = ({
|
||||
return (
|
||||
<ScrollArea h="100%" style={{ flex: 1 }}>
|
||||
<SimpleGrid cols={getAdvancedBookmarkColumns(width, data.length)} spacing="sm">
|
||||
{data.map((app) => (
|
||||
<UnstyledButton
|
||||
key={app.id}
|
||||
className={classes.bookmarkButton}
|
||||
component="a"
|
||||
href={app.href ?? undefined}
|
||||
target={openNewTab ? "_blank" : "_self"}
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Card radius={board.itemRadius} className={classes.card} withBorder={withBorder} p="md" h="100%">
|
||||
<Group align="flex-start" wrap="nowrap">
|
||||
<MaskedOrNormalImage
|
||||
imageUrl={app.iconUrl}
|
||||
hasColor={hasIconColor}
|
||||
alt={app.name}
|
||||
className={classes.bookmarkIcon}
|
||||
style={{ width: 40, height: 40, flex: "0 0 auto" }}
|
||||
/>
|
||||
<Stack gap={3} miw={0}>
|
||||
<Text fw={700} size="sm" truncate="end">
|
||||
{app.name}
|
||||
</Text>
|
||||
<Anchor component="span" size="xs" truncate="end">
|
||||
{getBookmarkHostname(app.href)}
|
||||
</Anchor>
|
||||
{app.description && (
|
||||
<Text size="xs" c="dimmed" lineClamp={3}>
|
||||
{app.description}
|
||||
{data.map((app) => {
|
||||
const href = getSafeApplicationUrl(app.href);
|
||||
return (
|
||||
<UnstyledButton
|
||||
key={app.id}
|
||||
className={classes.bookmarkButton}
|
||||
component={href ? "a" : "div"}
|
||||
href={href}
|
||||
target={href ? (openNewTab ? "_blank" : "_self") : undefined}
|
||||
rel={href && openNewTab ? SAFE_NEW_TAB_REL : undefined}
|
||||
>
|
||||
<Card radius={board.itemRadius} className={classes.card} withBorder={withBorder} p="md" h="100%">
|
||||
<Group align="flex-start" wrap="nowrap">
|
||||
<MaskedOrNormalImage
|
||||
imageUrl={app.iconUrl}
|
||||
hasColor={hasIconColor}
|
||||
alt={app.name}
|
||||
className={classes.bookmarkIcon}
|
||||
style={{ width: 40, height: 40, flex: "0 0 auto" }}
|
||||
/>
|
||||
<Stack gap={3} miw={0}>
|
||||
<Text fw={700} size="sm" truncate="end">
|
||||
{app.name}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Group>
|
||||
</Card>
|
||||
</UnstyledButton>
|
||||
))}
|
||||
<Anchor component="span" size="xs" truncate="end">
|
||||
{getBookmarkHostname(app.href)}
|
||||
</Anchor>
|
||||
{app.description && (
|
||||
<Text size="xs" c="dimmed" lineClamp={3}>
|
||||
{app.description}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Group>
|
||||
</Card>
|
||||
</UnstyledButton>
|
||||
);
|
||||
})}
|
||||
</SimpleGrid>
|
||||
</ScrollArea>
|
||||
);
|
||||
@@ -327,43 +337,46 @@ const GridLayout = ({
|
||||
mih="100%"
|
||||
style={{ gridAutoRows: `minmax(${minimumItemHeight}px, 1fr)` }}
|
||||
>
|
||||
{data.map((app) => (
|
||||
<UnstyledButton
|
||||
className={classes.bookmarkButton}
|
||||
component="a"
|
||||
href={app.href ?? undefined}
|
||||
target={openNewTab ? "_blank" : "_self"}
|
||||
rel="noopener noreferrer"
|
||||
key={app.id}
|
||||
h="100%"
|
||||
>
|
||||
<Card
|
||||
{data.map((app) => {
|
||||
const href = getSafeApplicationUrl(app.href);
|
||||
return (
|
||||
<UnstyledButton
|
||||
className={classes.bookmarkButton}
|
||||
component={href ? "a" : "div"}
|
||||
href={href}
|
||||
target={href ? (openNewTab ? "_blank" : "_self") : undefined}
|
||||
rel={href && openNewTab ? SAFE_NEW_TAB_REL : undefined}
|
||||
key={app.id}
|
||||
h="100%"
|
||||
className={combineClasses(classes.card, classes["card-grid"])}
|
||||
radius={board.itemRadius}
|
||||
withBorder={withBorder}
|
||||
p="xs"
|
||||
>
|
||||
{itemDirection === "horizontal" ? (
|
||||
<HorizontalItem
|
||||
app={app}
|
||||
hideTitle={hideTitle}
|
||||
hideIcon={hideIcon}
|
||||
hideHostname={hideHostname}
|
||||
hasIconColor={hasIconColor}
|
||||
/>
|
||||
) : (
|
||||
<VerticalItem
|
||||
app={app}
|
||||
hideTitle={hideTitle}
|
||||
hideIcon={hideIcon}
|
||||
hideHostname={hideHostname}
|
||||
hasIconColor={hasIconColor}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
</UnstyledButton>
|
||||
))}
|
||||
<Card
|
||||
h="100%"
|
||||
className={combineClasses(classes.card, classes["card-grid"])}
|
||||
radius={board.itemRadius}
|
||||
withBorder={withBorder}
|
||||
p="xs"
|
||||
>
|
||||
{itemDirection === "horizontal" ? (
|
||||
<HorizontalItem
|
||||
app={app}
|
||||
hideTitle={hideTitle}
|
||||
hideIcon={hideIcon}
|
||||
hideHostname={hideHostname}
|
||||
hasIconColor={hasIconColor}
|
||||
/>
|
||||
) : (
|
||||
<VerticalItem
|
||||
app={app}
|
||||
hideTitle={hideTitle}
|
||||
hideIcon={hideIcon}
|
||||
hideHostname={hideHostname}
|
||||
hasIconColor={hasIconColor}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
</UnstyledButton>
|
||||
);
|
||||
})}
|
||||
</SimpleGrid>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -27,7 +27,7 @@ describe("bookmark display helpers", () => {
|
||||
|
||||
test("does not crash on malformed bookmark URLs", () => {
|
||||
expect(getBookmarkHostname("https://homarr.dev/docs")).toBe("homarr.dev");
|
||||
expect(getBookmarkHostname("not a URL")).toBe("not a URL");
|
||||
expect(getBookmarkHostname("not a URL")).toBeUndefined();
|
||||
expect(getBookmarkHostname(null)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -17,9 +17,11 @@ import dayjs from "dayjs";
|
||||
|
||||
import { isNullOrWhitespace } from "@homarr/common";
|
||||
import type { CalendarEvent } from "@homarr/integrations/types";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { groupEventsByDate } from "./calendar-events";
|
||||
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url";
|
||||
import { formatLocalizedTime } from "../common/locale";
|
||||
import classes from "./calendar-event-list.module.css";
|
||||
|
||||
interface CalendarEventListProps {
|
||||
@@ -36,7 +38,9 @@ export const CalendarEventList = ({
|
||||
locale,
|
||||
}: CalendarEventListProps) => {
|
||||
const headingIdPrefix = useId();
|
||||
const dateFormatter = new Intl.DateTimeFormat(locale, {
|
||||
const currentLocale = useCurrentIntlLocale();
|
||||
const effectiveLocale = locale ?? currentLocale;
|
||||
const dateFormatter = new Intl.DateTimeFormat(effectiveLocale, {
|
||||
weekday: "long",
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
@@ -66,20 +70,20 @@ export const CalendarEventList = ({
|
||||
{dateFormatter.format(groupedEvents[0]?.startDate)}
|
||||
</Text>
|
||||
<Stack mt="xs">
|
||||
<CalendarEventRows events={groupedEvents} />
|
||||
<CalendarEventRows events={groupedEvents} locale={effectiveLocale} />
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<CalendarEventRows events={events} />
|
||||
<CalendarEventRows events={events} locale={effectiveLocale} />
|
||||
)}
|
||||
</Stack>
|
||||
</ScrollArea>
|
||||
);
|
||||
};
|
||||
|
||||
const CalendarEventRows = ({ events }: Pick<CalendarEventListProps, "events">) => {
|
||||
const CalendarEventRows = ({ events, locale }: Pick<CalendarEventListProps, "events"> & { locale: string }) => {
|
||||
const { colorScheme } = useMantineColorScheme();
|
||||
const t = useI18n();
|
||||
return (
|
||||
@@ -136,14 +140,14 @@ const CalendarEventRows = ({ events }: Pick<CalendarEventListProps, "events">) =
|
||||
) : (
|
||||
<>
|
||||
<Text c={"dimmed"} size={"sm"}>
|
||||
{dayjs(event.startDate).format("HH:mm")}
|
||||
{formatLocalizedTime(event.startDate, locale)}
|
||||
</Text>
|
||||
|
||||
{event.endDate !== null && (
|
||||
<>
|
||||
-{" "}
|
||||
<Text c={"dimmed"} size={"sm"}>
|
||||
{dayjs(event.endDate).format("HH:mm")}
|
||||
{formatLocalizedTime(event.endDate, locale)}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
@@ -171,31 +175,35 @@ const CalendarEventRows = ({ events }: Pick<CalendarEventListProps, "events">) =
|
||||
<Group pt={5} gap={5} mt={"auto"} wrap="nowrap">
|
||||
{event.links
|
||||
.filter((link) => link.href)
|
||||
.map((link) => (
|
||||
<Button
|
||||
key={link.href}
|
||||
component={"a"}
|
||||
href={link.href.toString()}
|
||||
target={"_blank"}
|
||||
size={"xs"}
|
||||
radius={"xl"}
|
||||
variant={link.color ? undefined : "default"}
|
||||
styles={{
|
||||
root: {
|
||||
backgroundColor: link.color,
|
||||
color: link.isDark && colorScheme === "dark" ? "white" : "black",
|
||||
"&:hover": link.color
|
||||
? {
|
||||
backgroundColor: link.isDark ? lighten(link.color, 0.1) : darken(link.color, 0.1),
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
}}
|
||||
leftSection={link.logo ? <Image src={link.logo} fit="contain" w={20} h={20} /> : undefined}
|
||||
>
|
||||
<Text>{link.name}</Text>
|
||||
</Button>
|
||||
))}
|
||||
.map((link) => {
|
||||
const href = getSafeApplicationUrl(link.href.toString());
|
||||
return (
|
||||
<Button
|
||||
key={link.href}
|
||||
component={href ? "a" : "div"}
|
||||
href={href}
|
||||
target={href ? "_blank" : undefined}
|
||||
rel={href ? SAFE_NEW_TAB_REL : undefined}
|
||||
size={"xs"}
|
||||
radius={"xl"}
|
||||
variant={link.color ? undefined : "default"}
|
||||
styles={{
|
||||
root: {
|
||||
backgroundColor: link.color,
|
||||
color: link.isDark && colorScheme === "dark" ? "white" : "black",
|
||||
"&:hover": link.color
|
||||
? {
|
||||
backgroundColor: link.isDark ? lighten(link.color, 0.1) : darken(link.color, 0.1),
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
}}
|
||||
leftSection={link.logo ? <Image src={link.logo} fit="contain" w={20} h={20} /> : undefined}
|
||||
>
|
||||
<Text>{link.name}</Text>
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { getSafeApplicationUrl } from "./application-url";
|
||||
|
||||
describe("getSafeApplicationUrl", () => {
|
||||
test.each(["javascript:alert(1)", "data:text/html,test", "not a URL", "/relative", "./relative"])(
|
||||
"rejects unsafe or non-absolute URL %s",
|
||||
(value) => expect(getSafeApplicationUrl(value)).toBeUndefined(),
|
||||
);
|
||||
|
||||
test("rejects credential-bearing URLs", () => {
|
||||
expect(getSafeApplicationUrl("https://user:password@example.com/path")).toBeUndefined();
|
||||
expect(getSafeApplicationUrl("/path", { baseUrl: "https://user:password@example.com" })).toBeUndefined();
|
||||
});
|
||||
|
||||
test("allows absolute HTTP and HTTPS URLs", () => {
|
||||
expect(getSafeApplicationUrl("http://example.com/path")).toBe("http://example.com/path");
|
||||
expect(getSafeApplicationUrl("https://example.com/path?q=1#result")).toBe("https://example.com/path?q=1#result");
|
||||
});
|
||||
|
||||
test("resolves feed-relative URLs only against a safe HTTP(S) base", () => {
|
||||
expect(getSafeApplicationUrl("/story", { baseUrl: "https://example.com/feed.xml?token=secret" })).toBe(
|
||||
"https://example.com/story",
|
||||
);
|
||||
});
|
||||
|
||||
test.each([undefined, null, ""])("rejects missing URL %s", (value) => {
|
||||
expect(getSafeApplicationUrl(value)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "@homarr/common";
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { formatLocalizedCompactNumber, formatLocalizedDate, formatLocalizedTime, getBinaryStatusKey } from "./locale";
|
||||
|
||||
const sample = new Date("2026-01-02T13:05:09.000Z");
|
||||
|
||||
describe("localized widget formatting", () => {
|
||||
test.each([
|
||||
["en-US", /Jan/],
|
||||
["fr-FR", /janv/i],
|
||||
["de-DE", /Jan/],
|
||||
["he-IL", /[\u0590-\u05ff]/],
|
||||
])("formats dates for %s", (locale, expected) => {
|
||||
expect(
|
||||
formatLocalizedDate(sample, locale, { weekday: "short", month: "short", day: "numeric", timeZone: "UTC" }),
|
||||
).toMatch(expected);
|
||||
});
|
||||
|
||||
test("supports explicit 12-hour and 24-hour clocks", () => {
|
||||
expect(formatLocalizedTime(sample, "en-US", { hour12: true, timeZone: "UTC" })).toBe("1:05 PM");
|
||||
expect(formatLocalizedTime(sample, "en-US", { hour12: false, timeZone: "UTC" })).toBe("13:05");
|
||||
});
|
||||
|
||||
test("localizes compact numbers", () => {
|
||||
expect(formatLocalizedCompactNumber(1_234_567, "en-US")).toBe("1.2M");
|
||||
expect(formatLocalizedCompactNumber(1_234_567, "fr-FR")).toContain("1,2");
|
||||
expect(formatLocalizedCompactNumber(1_234_567, "de-DE")).toContain("Mio.");
|
||||
});
|
||||
|
||||
test("normalizes binary status for translated accessibility labels", () => {
|
||||
expect(getBinaryStatusKey("enabled")).toBe("enabled");
|
||||
expect(getBinaryStatusKey("disabled")).toBe("disabled");
|
||||
expect(getBinaryStatusKey()).toBe("unknown");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
export type LocalizedDateValue = Date | number | string;
|
||||
|
||||
interface LocalizedTimeOptions {
|
||||
hour12?: boolean;
|
||||
includeSeconds?: boolean;
|
||||
timeZone?: string;
|
||||
}
|
||||
|
||||
const toValidDate = (value: LocalizedDateValue): Date | undefined => {
|
||||
const date = value instanceof Date ? value : new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? undefined : date;
|
||||
};
|
||||
|
||||
export const formatLocalizedDate = (
|
||||
value: LocalizedDateValue,
|
||||
locale: string,
|
||||
options: Intl.DateTimeFormatOptions,
|
||||
fallback = "?",
|
||||
): string => {
|
||||
const date = toValidDate(value);
|
||||
return date ? new Intl.DateTimeFormat(locale, options).format(date) : fallback;
|
||||
};
|
||||
|
||||
export const formatLocalizedTime = (
|
||||
value: LocalizedDateValue,
|
||||
locale: string,
|
||||
{ hour12, includeSeconds = false, timeZone }: LocalizedTimeOptions = {},
|
||||
fallback = "?",
|
||||
): string =>
|
||||
formatLocalizedDate(
|
||||
value,
|
||||
locale,
|
||||
{
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
second: includeSeconds ? "2-digit" : undefined,
|
||||
hour12,
|
||||
timeZone,
|
||||
},
|
||||
fallback,
|
||||
);
|
||||
|
||||
export const formatLocalizedDateTime = (
|
||||
value: LocalizedDateValue,
|
||||
locale: string,
|
||||
options: Pick<LocalizedTimeOptions, "hour12" | "timeZone"> = {},
|
||||
fallback = "?",
|
||||
): string =>
|
||||
formatLocalizedDate(
|
||||
value,
|
||||
locale,
|
||||
{ dateStyle: "medium", timeStyle: "short", hour12: options.hour12, timeZone: options.timeZone },
|
||||
fallback,
|
||||
);
|
||||
|
||||
export const formatLocalizedCompactNumber = (value: number, locale: string): string =>
|
||||
new Intl.NumberFormat(locale, { notation: "compact" }).format(value);
|
||||
|
||||
export type BinaryStatusKey = "enabled" | "disabled" | "unknown";
|
||||
|
||||
export const getBinaryStatusKey = (status?: "enabled" | "disabled"): BinaryStatusKey => status ?? "unknown";
|
||||
@@ -0,0 +1,56 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act, createElement } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { WidgetQueryErrorIndicator } from "./query-state-indicator";
|
||||
|
||||
vi.mock("@homarr/translation/client", () => ({
|
||||
useScopedI18n: () => (key: string) => key,
|
||||
}));
|
||||
|
||||
const matchMedia = (query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: () => undefined,
|
||||
removeListener: () => undefined,
|
||||
addEventListener: () => undefined,
|
||||
removeEventListener: () => undefined,
|
||||
dispatchEvent: () => false,
|
||||
});
|
||||
|
||||
describe("WidgetQueryErrorIndicator", () => {
|
||||
let host: HTMLDivElement | undefined;
|
||||
let root: ReturnType<typeof createRoot> | undefined;
|
||||
|
||||
afterEach(async () => {
|
||||
if (root) await act(async () => root?.unmount());
|
||||
host?.remove();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
test("uses a localized label without exposing the raw failure", async () => {
|
||||
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
||||
vi.stubGlobal("matchMedia", matchMedia);
|
||||
host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
root = createRoot(host);
|
||||
const secret = "http://admin:password@private.local/path?token=secret";
|
||||
|
||||
await act(async () =>
|
||||
root?.render(
|
||||
createElement(
|
||||
MantineProvider,
|
||||
null,
|
||||
createElement(WidgetQueryErrorIndicator, { error: new Error(secret), label: "Weather" }),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(host.querySelector("[aria-label='Weather']")).not.toBeNull();
|
||||
expect(host.textContent).not.toMatch(/password|private\.local|token=secret/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import { Box, Center, Loader, Tooltip } from "@mantine/core";
|
||||
import { IconAlertTriangle } from "@tabler/icons-react";
|
||||
|
||||
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) {
|
||||
if (!error) return null;
|
||||
|
||||
const accessibleLabel = label;
|
||||
return (
|
||||
<Tooltip label={accessibleLabel} position="left" withArrow>
|
||||
<Box
|
||||
component="span"
|
||||
c="orange"
|
||||
role="img"
|
||||
aria-label={accessibleLabel}
|
||||
tabIndex={0}
|
||||
style={{ display: "inline-flex", cursor: "help" }}
|
||||
>
|
||||
<IconAlertTriangle aria-hidden size={14} />
|
||||
</Box>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export function WidgetQueryLoadingState() {
|
||||
return (
|
||||
<Center h="100%" w="100%" p="sm" role="status" aria-live="polite">
|
||||
<Loader size="sm" />
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { getUsableWidgetQueryData, hasStaleWidgetQueryError, isInitialWidgetQueryPending } from "./query-state";
|
||||
|
||||
describe("getUsableWidgetQueryData", () => {
|
||||
test.each([[], null, 0, false, ""])("preserves successful empty value %#", (data) => {
|
||||
expect(getUsableWidgetQueryData({ data, error: null })).toBe(data);
|
||||
});
|
||||
|
||||
test("preserves stale cached data when a refetch fails", () => {
|
||||
const data = [{ id: "healthy" }];
|
||||
expect(getUsableWidgetQueryData({ data, error: new Error("offline") })).toBe(data);
|
||||
});
|
||||
|
||||
test("throws a terminal failure when no usable data exists", () => {
|
||||
const error = new Error("offline");
|
||||
expect(() => getUsableWidgetQueryData({ data: undefined, error })).toThrow(error);
|
||||
});
|
||||
|
||||
test("keeps the initial pending state when neither data nor an error exists", () => {
|
||||
expect(getUsableWidgetQueryData({ data: undefined, error: null })).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("isInitialWidgetQueryPending", () => {
|
||||
test("only marks the unresolved first request as initial pending", () => {
|
||||
expect(isInitialWidgetQueryPending({ data: undefined, error: null, isPending: true })).toBe(true);
|
||||
expect(isInitialWidgetQueryPending({ data: [], error: null, isPending: true })).toBe(false);
|
||||
expect(isInitialWidgetQueryPending({ data: undefined, error: new Error("offline"), isPending: true })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasStaleWidgetQueryError", () => {
|
||||
test("only marks failures that retain usable cached data", () => {
|
||||
expect(hasStaleWidgetQueryError({ data: [{ id: "cached" }], error: new Error("offline") })).toBe(true);
|
||||
expect(hasStaleWidgetQueryError({ data: undefined, error: new Error("offline") })).toBe(false);
|
||||
expect(hasStaleWidgetQueryError({ data: [], error: null })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
interface WidgetQueryState {
|
||||
data: unknown;
|
||||
error: unknown;
|
||||
}
|
||||
|
||||
interface PendingWidgetQueryState extends WidgetQueryState {
|
||||
isPending: boolean;
|
||||
}
|
||||
|
||||
/** Throws terminal query failures while preserving successful empty values and stale cached data. */
|
||||
export const getUsableWidgetQueryData = <TQuery extends WidgetQueryState>(query: TQuery): TQuery["data"] => {
|
||||
if (query.error && query.data === undefined) throw query.error;
|
||||
return query.data;
|
||||
};
|
||||
|
||||
/** True only before the first result; successful empty values and cached data are both ready to render. */
|
||||
export const isInitialWidgetQueryPending = ({ data, error, isPending }: PendingWidgetQueryState): boolean =>
|
||||
isPending && data === undefined && !error;
|
||||
|
||||
/** A failed background refresh still has usable cached data, but should remain visible to the user. */
|
||||
export const hasStaleWidgetQueryError = ({ data, error }: WidgetQueryState): boolean =>
|
||||
error !== null && error !== undefined && data !== undefined;
|
||||
@@ -6,17 +6,12 @@ import { clientApi } from "@homarr/api/client";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { getUsableWidgetQueryData } from "../common/query-state";
|
||||
import { createWidgetKey } from "./coolify-utils";
|
||||
import { InstanceCard } from "./instance-card";
|
||||
import { SingleInstanceLayout } from "./single-instance-layout";
|
||||
|
||||
export default function CoolifyWidget({
|
||||
options,
|
||||
integrationIds,
|
||||
width,
|
||||
height,
|
||||
displayMode,
|
||||
}: WidgetComponentProps<"coolify">) {
|
||||
export default function CoolifyWidget({ options, integrationIds, width, height }: WidgetComponentProps<"coolify">) {
|
||||
const t = useScopedI18n("widget.coolify");
|
||||
|
||||
if (integrationIds.length === 0) {
|
||||
@@ -27,15 +22,7 @@ export default function CoolifyWidget({
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<CoolifyContent
|
||||
integrationIds={integrationIds}
|
||||
options={options}
|
||||
width={width}
|
||||
height={height}
|
||||
isAdvanced={displayMode === "advanced"}
|
||||
/>
|
||||
);
|
||||
return <CoolifyContent integrationIds={integrationIds} options={options} width={width} height={height} />;
|
||||
}
|
||||
|
||||
interface CoolifyContentProps {
|
||||
@@ -43,17 +30,18 @@ interface CoolifyContentProps {
|
||||
options: WidgetComponentProps<"coolify">["options"];
|
||||
width: number;
|
||||
height: number;
|
||||
isAdvanced: boolean;
|
||||
}
|
||||
|
||||
function CoolifyContent({ integrationIds, options, width, height, isAdvanced }: CoolifyContentProps) {
|
||||
function CoolifyContent({ integrationIds, options, width, height }: CoolifyContentProps) {
|
||||
const t = useScopedI18n("common");
|
||||
const { data: instancesData = [], isPending } = clientApi.widget.coolify.getInstancesInfo.useQuery({
|
||||
const instancesQuery = clientApi.widget.coolify.getInstancesInfo.useQuery({
|
||||
integrationIds,
|
||||
});
|
||||
const instancesData = getUsableWidgetQueryData(instancesQuery) ?? [];
|
||||
const { isPending } = instancesQuery;
|
||||
|
||||
const isTiny = !isAdvanced && (width < 256 || height < 144);
|
||||
const hideFooter = !isAdvanced && height < 112;
|
||||
const isTiny = width < 256 || height < 144;
|
||||
const hideFooter = height < 112;
|
||||
const [firstInstance] = instancesData;
|
||||
const widgetKey = createWidgetKey(integrationIds);
|
||||
|
||||
@@ -74,7 +62,6 @@ function CoolifyContent({ integrationIds, options, width, height, isAdvanced }:
|
||||
options={options}
|
||||
isTiny={isTiny}
|
||||
widgetKey={widgetKey}
|
||||
isAdvanced={isAdvanced}
|
||||
hideFooter={hideFooter}
|
||||
/>
|
||||
);
|
||||
@@ -82,7 +69,7 @@ function CoolifyContent({ integrationIds, options, width, height, isAdvanced }:
|
||||
|
||||
return (
|
||||
<ScrollArea h="100%">
|
||||
<SimpleGrid cols={isAdvanced && width >= 760 ? 2 : 1} spacing="sm" p="xs">
|
||||
<SimpleGrid cols={width >= 760 ? 2 : 1} spacing="sm" p="xs">
|
||||
{instancesData.map((instance) => (
|
||||
<InstanceCard
|
||||
key={instance.integrationId}
|
||||
@@ -90,7 +77,6 @@ function CoolifyContent({ integrationIds, options, width, height, isAdvanced }:
|
||||
options={options}
|
||||
isTiny={isTiny}
|
||||
widgetKey={widgetKey}
|
||||
isAdvanced={isAdvanced}
|
||||
hideFooter={hideFooter}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { createWidgetDefinition } from "../definition";
|
||||
import { optionsBuilder } from "../options";
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("coolify", {
|
||||
supportsAdvancedFocus: false,
|
||||
icon: IconCloud,
|
||||
createOptions() {
|
||||
return optionsBuilder.from((factory) => ({
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import actionTargetClasses from "../common/action-target.module.css";
|
||||
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url";
|
||||
import { cleanFqdn, getResourceTimestamp, getStatusColor, parseStatus } from "./coolify-utils";
|
||||
|
||||
interface ResourceRowProps {
|
||||
@@ -59,13 +60,14 @@ export function ResourceRow({ item, baseUrl, isTiny, resourceType }: ResourceRow
|
||||
? t("status.restarting")
|
||||
: t("status.unknown");
|
||||
|
||||
const resourceUrl =
|
||||
const resourceUrl = getSafeApplicationUrl(
|
||||
item.projectUuid && item.environmentUuid
|
||||
? `${baseUrl}/project/${item.projectUuid}/environment/${item.environmentUuid}/${resourceType}/${item.uuid}`
|
||||
: undefined;
|
||||
: undefined,
|
||||
);
|
||||
|
||||
const logsUrl = resourceUrl ? `${resourceUrl}/logs` : undefined;
|
||||
const publicUrl = cleanFqdn(item.fqdn);
|
||||
const logsUrl = getSafeApplicationUrl(resourceUrl ? `${resourceUrl}/logs` : undefined);
|
||||
const publicUrl = getSafeApplicationUrl(cleanFqdn(item.fqdn));
|
||||
const resourceTimestamp = getResourceTimestamp(item, resourceType);
|
||||
|
||||
return (
|
||||
@@ -77,7 +79,7 @@ export function ResourceRow({ item, baseUrl, isTiny, resourceType }: ResourceRow
|
||||
className={actionTargetClasses.root}
|
||||
href={resourceUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
rel={SAFE_NEW_TAB_REL}
|
||||
fz="xs"
|
||||
c="inherit"
|
||||
truncate="end"
|
||||
@@ -98,7 +100,7 @@ export function ResourceRow({ item, baseUrl, isTiny, resourceType }: ResourceRow
|
||||
component="a"
|
||||
href={publicUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
rel={SAFE_NEW_TAB_REL}
|
||||
aria-label={t("action.openResource", { name: item.name })}
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
@@ -113,7 +115,7 @@ export function ResourceRow({ item, baseUrl, isTiny, resourceType }: ResourceRow
|
||||
component="a"
|
||||
href={logsUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
rel={SAFE_NEW_TAB_REL}
|
||||
aria-label={t("action.openLogs", { name: item.name })}
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { CoolifyServer } from "@homarr/integrations/types";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import actionTargetClasses from "../common/action-target.module.css";
|
||||
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url";
|
||||
import { getBadgeColor } from "./coolify-utils";
|
||||
|
||||
interface ServersSectionProps {
|
||||
@@ -92,7 +93,7 @@ function ServerRow({ server, counts, baseUrl, isTiny, showIp }: ServerRowProps)
|
||||
const t = useScopedI18n("widget.coolify");
|
||||
const isBuildServer = server.settings?.is_build_server === true;
|
||||
const isOnline = server.is_reachable !== false;
|
||||
const serverUrl = `${baseUrl}/server/${server.uuid}`;
|
||||
const serverUrl = getSafeApplicationUrl(`${baseUrl}/server/${server.uuid}`);
|
||||
const StatusIcon = isOnline ? IconCircleCheckFilled : IconCircleXFilled;
|
||||
|
||||
return (
|
||||
@@ -105,9 +106,10 @@ function ServerRow({ server, counts, baseUrl, isTiny, showIp }: ServerRowProps)
|
||||
/>
|
||||
<Anchor
|
||||
className={actionTargetClasses.root}
|
||||
component={serverUrl ? "a" : "span"}
|
||||
href={serverUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
target={serverUrl ? "_blank" : undefined}
|
||||
rel={serverUrl ? SAFE_NEW_TAB_REL : undefined}
|
||||
fz="xs"
|
||||
c="inherit"
|
||||
truncate="end"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { Accordion, Anchor, Group, Image, ScrollArea, Stack, Text } from "@mantine/core";
|
||||
import { useLocalStorage } from "@mantine/hooks";
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useTimeAgo } from "@homarr/common";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { ApplicationsSection } from "./applications-section";
|
||||
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url";
|
||||
import { buildServerResourceCounts } from "./coolify-utils";
|
||||
import { ServersSection } from "./servers-section";
|
||||
import { ServicesSection } from "./services-section";
|
||||
@@ -18,8 +19,8 @@ interface SingleInstanceLayoutProps {
|
||||
instance: InstanceData;
|
||||
options: CoolifyOptions;
|
||||
isTiny: boolean;
|
||||
widgetKey: string;
|
||||
isAdvanced: boolean;
|
||||
widgetKey: string;
|
||||
hideFooter: boolean;
|
||||
}
|
||||
|
||||
@@ -27,8 +28,8 @@ export function SingleInstanceLayout({
|
||||
instance,
|
||||
options,
|
||||
isTiny,
|
||||
widgetKey,
|
||||
isAdvanced,
|
||||
widgetKey,
|
||||
hideFooter,
|
||||
}: SingleInstanceLayoutProps) {
|
||||
const t = useScopedI18n("widget.coolify");
|
||||
@@ -40,36 +41,15 @@ export function SingleInstanceLayout({
|
||||
key: `coolify-sections-${widgetKey}`,
|
||||
defaultValue: ["applications"],
|
||||
});
|
||||
const visibleSections = useMemo(
|
||||
() =>
|
||||
[
|
||||
options.showServers ? "servers" : null,
|
||||
options.showApplications ? "applications" : null,
|
||||
options.showServices ? "services" : null,
|
||||
].filter((section): section is string => section !== null),
|
||||
[options.showApplications, options.showServers, options.showServices],
|
||||
);
|
||||
const [advancedOpenSections, setAdvancedOpenSections] = useState(visibleSections);
|
||||
const previousVisibleSectionsRef = useRef(visibleSections);
|
||||
|
||||
useEffect(() => {
|
||||
const newlyVisibleSections = visibleSections.filter(
|
||||
(section) => !previousVisibleSectionsRef.current.includes(section),
|
||||
);
|
||||
previousVisibleSectionsRef.current = visibleSections;
|
||||
if (newlyVisibleSections.length === 0) return;
|
||||
|
||||
setAdvancedOpenSections((current) => [...new Set([...current, ...newlyVisibleSections])]);
|
||||
}, [visibleSections]);
|
||||
|
||||
const [advancedOpenSections, setAdvancedOpenSections] = useState(["servers", "applications", "services"]);
|
||||
const serverResourceCounts = buildServerResourceCounts(
|
||||
instance.instanceInfo.servers,
|
||||
instance.instanceInfo.applications,
|
||||
instance.instanceInfo.services,
|
||||
);
|
||||
|
||||
const baseUrl = instance.integrationUrl.replace(/\/+$/, "");
|
||||
const displayUrl = baseUrl.replace(/^https?:\/\//, "");
|
||||
const baseUrl = getSafeApplicationUrl(instance.integrationUrl)?.replace(/\/+$/, "") ?? "";
|
||||
const displayUrl = baseUrl ? baseUrl.replace(/^https?:\/\//, "") : "—";
|
||||
const relativeTime = useTimeAgo(instance.updatedAt);
|
||||
|
||||
return (
|
||||
@@ -82,17 +62,25 @@ export function SingleInstanceLayout({
|
||||
oolify
|
||||
</Text>
|
||||
</Group>
|
||||
<Anchor
|
||||
href={baseUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
fz={isTiny ? "xs" : "sm"}
|
||||
fw={500}
|
||||
c="dimmed"
|
||||
lineClamp={1}
|
||||
>
|
||||
{displayUrl}
|
||||
</Anchor>
|
||||
<Stack gap={0} miw={0}>
|
||||
{isAdvanced && (
|
||||
<Text fz="xs" fw={600} truncate="end">
|
||||
{instance.integrationName}
|
||||
</Text>
|
||||
)}
|
||||
<Anchor
|
||||
component={baseUrl ? "a" : "span"}
|
||||
href={baseUrl}
|
||||
target={baseUrl ? "_blank" : undefined}
|
||||
rel={baseUrl ? SAFE_NEW_TAB_REL : undefined}
|
||||
fz={isTiny ? "xs" : "sm"}
|
||||
fw={500}
|
||||
c="dimmed"
|
||||
lineClamp={1}
|
||||
>
|
||||
{isAdvanced ? t("source.url", { url: displayUrl }) : displayUrl}
|
||||
</Anchor>
|
||||
</Stack>
|
||||
</Group>
|
||||
|
||||
<Accordion
|
||||
@@ -108,6 +96,7 @@ export function SingleInstanceLayout({
|
||||
serverResourceCounts={serverResourceCounts}
|
||||
baseUrl={baseUrl}
|
||||
isTiny={isTiny}
|
||||
isAdvanced={isAdvanced}
|
||||
showIp={showIp}
|
||||
onToggleIp={() => setShowIp((prev) => !prev)}
|
||||
/>
|
||||
@@ -116,7 +105,12 @@ export function SingleInstanceLayout({
|
||||
<ApplicationsSection applications={instance.instanceInfo.applications} baseUrl={baseUrl} isTiny={isTiny} />
|
||||
)}
|
||||
{options.showServices && (
|
||||
<ServicesSection services={instance.instanceInfo.services} baseUrl={baseUrl} isTiny={isTiny} />
|
||||
<ServicesSection
|
||||
services={instance.instanceInfo.services}
|
||||
baseUrl={baseUrl}
|
||||
isTiny={isTiny}
|
||||
isAdvanced={isAdvanced}
|
||||
/>
|
||||
)}
|
||||
</Accordion>
|
||||
|
||||
|
||||
@@ -415,7 +415,6 @@ describe("CustomJsxDisplay", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Custom JSX component registry", () => {
|
||||
it.each([
|
||||
["@mantine/core", MantineCore],
|
||||
|
||||
@@ -77,7 +77,6 @@ export default function CustomJsxDisplay({ data }: { data: Record<string, unknow
|
||||
</WidgetDefinitionProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function sameStatus(left: CustomWidgetPublishedQueryState["status"], right: CustomWidgetPublishedQueryState["status"]) {
|
||||
return (
|
||||
left.loading === right.loading &&
|
||||
|
||||
@@ -1,28 +1,11 @@
|
||||
import { IconApi, IconPlayerPause } from "@tabler/icons-react";
|
||||
import { IconApi } from "@tabler/icons-react";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { createWidgetDefinition, widgetQueryInputMatches } from "../definition";
|
||||
import { createWidgetDefinition } from "../definition";
|
||||
import { optionsBuilder } from "../options";
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("customApi", {
|
||||
icon: IconApi,
|
||||
queryKey: [["widget", "customApi", "getData"]],
|
||||
queryMatcher: ({ input }, scope) => widgetQueryInputMatches(input, { definitionId: scope.options.definitionId }),
|
||||
contextActions({ options, widgetStateRef }) {
|
||||
return [
|
||||
{
|
||||
key: "togglePolling",
|
||||
label: (t) => t("widget.customApi.actions.togglePolling"),
|
||||
icon: IconPlayerPause,
|
||||
hidden: typeof options.definitionId !== "string" || options.definitionId.trim() === "",
|
||||
disabled: typeof widgetStateRef?.current?.togglePolling !== "function",
|
||||
onClick: () => {
|
||||
const action = widgetStateRef?.current?.togglePolling;
|
||||
if (typeof action === "function") action();
|
||||
},
|
||||
},
|
||||
];
|
||||
},
|
||||
createOptions() {
|
||||
return optionsBuilder.from((factory) => ({
|
||||
definitionId: factory.customWidgetSelect({ defaultValue: "" }),
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { definition as appDefinition } from "./app";
|
||||
import { definition as beszelAlertsDefinition } from "./beszel-alerts";
|
||||
import { definition as beszelSystemGridDefinition } from "./beszel-system-grid";
|
||||
import { definition as beszelSystemStatsDefinition } from "./beszel-system-stats";
|
||||
import { definition as beszelSystemTableDefinition } from "./beszel-system-table";
|
||||
import {
|
||||
getWidgetQueryKeys,
|
||||
getWidgetRuntimeQueries,
|
||||
setWidgetRuntimeQueries,
|
||||
supportsAdvancedFocus,
|
||||
} from "./definition";
|
||||
|
||||
const togglePolling = () => undefined;
|
||||
import { definition as clockDefinition } from "./clock";
|
||||
import { definition as customApiDefinition } from "./custom-api";
|
||||
import { getWidgetQueryKeys, supportsAdvancedFocus } from "./definition";
|
||||
import { definition as dockerDefinition } from "./docker";
|
||||
import { definition as downloadsDefinition } from "./downloads";
|
||||
import { definition as indexerManagerDefinition } from "./indexer-manager";
|
||||
import { definition as notebookDefinition } from "./notebook";
|
||||
|
||||
describe("getWidgetQueryKeys", () => {
|
||||
test("falls back to the widget router namespace", () => {
|
||||
@@ -29,23 +29,26 @@ describe("getWidgetQueryKeys", () => {
|
||||
|
||||
expect(getWidgetQueryKeys({ kind: "audioStats", queryKeys })).toBe(queryKeys);
|
||||
});
|
||||
|
||||
test("registers runtime queries without replacing imperative widget actions", () => {
|
||||
const widgetStateRef = { current: { togglePolling } };
|
||||
const runtimeQueries = [{ path: ["widget", "calendar", "findAllEvents"], input: { month: 7 } }];
|
||||
|
||||
setWidgetRuntimeQueries(widgetStateRef, runtimeQueries);
|
||||
|
||||
expect(widgetStateRef.current.togglePolling).toBe(togglePolling);
|
||||
expect(getWidgetRuntimeQueries(widgetStateRef)).toEqual(runtimeQueries);
|
||||
});
|
||||
});
|
||||
|
||||
describe("supportsAdvancedFocus", () => {
|
||||
test.each([appDefinition, beszelSystemGridDefinition, beszelSystemTableDefinition])(
|
||||
"keeps non-enhanced widgets compact",
|
||||
(widgetDefinition) => {
|
||||
expect(supportsAdvancedFocus(widgetDefinition)).toBe(false);
|
||||
},
|
||||
);
|
||||
test.each([
|
||||
appDefinition,
|
||||
beszelAlertsDefinition,
|
||||
beszelSystemGridDefinition,
|
||||
beszelSystemStatsDefinition,
|
||||
beszelSystemTableDefinition,
|
||||
clockDefinition,
|
||||
customApiDefinition,
|
||||
dockerDefinition,
|
||||
indexerManagerDefinition,
|
||||
notebookDefinition,
|
||||
])("keeps non-enhanced widgets compact", (widgetDefinition) => {
|
||||
expect(supportsAdvancedFocus(widgetDefinition)).toBe(false);
|
||||
});
|
||||
|
||||
test("requires an explicit opt-in", () => {
|
||||
expect(supportsAdvancedFocus({})).toBe(false);
|
||||
expect(supportsAdvancedFocus(downloadsDefinition)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -33,6 +33,7 @@ import { MaskedOrNormalImage } from "@homarr/ui";
|
||||
|
||||
import type { widgetKind } from ".";
|
||||
import type { WidgetComponentProps } from "../../definition";
|
||||
import { getUsableWidgetQueryData } from "../../common/query-state";
|
||||
import actionTargetClasses from "../../common/action-target.module.css";
|
||||
import classes from "./component.module.css";
|
||||
import TimerModal from "./TimerModal";
|
||||
@@ -46,7 +47,6 @@ export default function DnsHoleControlsWidget({
|
||||
isEditMode,
|
||||
width,
|
||||
height,
|
||||
displayMode,
|
||||
}: WidgetComponentProps<typeof widgetKind>) {
|
||||
const board = useRequiredBoard();
|
||||
// DnsHole integrations with interaction permissions
|
||||
@@ -54,9 +54,9 @@ export default function DnsHoleControlsWidget({
|
||||
.map(({ id }) => id)
|
||||
.filter((id) => integrationIds.includes(id));
|
||||
|
||||
const { data: summaries = [], isPending: isSummaryPending } = clientApi.widget.dnsHole.summary.useQuery({
|
||||
integrationIds,
|
||||
});
|
||||
const summaryQuery = clientApi.widget.dnsHole.summary.useQuery({ integrationIds });
|
||||
const summaries = getUsableWidgetQueryData(summaryQuery) ?? [];
|
||||
const { isPending: isSummaryPending } = summaryQuery;
|
||||
const utils = clientApi.useUtils();
|
||||
|
||||
const {
|
||||
@@ -118,8 +118,7 @@ export default function DnsHoleControlsWidget({
|
||||
const [bulkFailureCount, setBulkFailureCount] = useState(0);
|
||||
const [opened, { close, open }] = useDisclosure(false);
|
||||
|
||||
const controlAllButtonsVisible =
|
||||
(options.showToggleAllButtons || displayMode === "advanced") && integrationsWithInteractions.length > 0;
|
||||
const controlAllButtonsVisible = options.showToggleAllButtons && integrationsWithInteractions.length > 0;
|
||||
const actionsPending = bulkPending || isEnabling || isDisabling;
|
||||
const actionError = enableError ?? disableError;
|
||||
const runBulkToggle = async (integrationIdsToToggle: string[]) => {
|
||||
@@ -229,7 +228,6 @@ export default function DnsHoleControlsWidget({
|
||||
hasIconColor={board.iconColor !== null}
|
||||
rootWidth={width}
|
||||
rootHeight={height}
|
||||
isAdvanced={displayMode === "advanced"}
|
||||
actionsPending={actionsPending}
|
||||
/>
|
||||
))}
|
||||
@@ -242,7 +240,7 @@ export default function DnsHoleControlsWidget({
|
||||
</Text>
|
||||
)}
|
||||
{actionError && (
|
||||
<Tooltip label={actionError.message} multiline>
|
||||
<Tooltip label={t("widget.dnsHoleControls.error.internalServerError")}>
|
||||
<Text size="xs" c="red" ta="center" lineClamp={2} tabIndex={0}>
|
||||
{t("widget.dnsHoleControls.error.internalServerError")}
|
||||
</Text>
|
||||
@@ -269,7 +267,6 @@ interface ControlsCardProps {
|
||||
hasIconColor: boolean;
|
||||
rootWidth: number;
|
||||
rootHeight: number;
|
||||
isAdvanced: boolean;
|
||||
actionsPending: boolean;
|
||||
}
|
||||
|
||||
@@ -283,7 +280,6 @@ const ControlsCard: React.FC<ControlsCardProps> = ({
|
||||
hasIconColor,
|
||||
rootWidth,
|
||||
rootHeight,
|
||||
isAdvanced,
|
||||
actionsPending,
|
||||
}) => {
|
||||
const isConnected = useIntegrationConnected(data.integration.updatedAt, { timeout: 30000 });
|
||||
@@ -294,7 +290,7 @@ const ControlsCard: React.FC<ControlsCardProps> = ({
|
||||
const board = useRequiredBoard();
|
||||
|
||||
const iconUrl = integrationDefs[data.integration.kind].iconUrl;
|
||||
const layout = !isAdvanced && (rootWidth < 256 || rootHeight < 112) ? "sm" : "md";
|
||||
const layout = rootWidth < 256 || rootHeight < 112 ? "sm" : "md";
|
||||
|
||||
return (
|
||||
<Indicator
|
||||
|
||||
@@ -8,6 +8,7 @@ import { optionsBuilder } from "../../options";
|
||||
export const widgetKind = "dnsHoleControls";
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition(widgetKind, {
|
||||
supportsAdvancedFocus: false,
|
||||
icon: IconDeviceGamepad,
|
||||
queryKey: [["widget", "dnsHole"]],
|
||||
refetchInterval: 5,
|
||||
|
||||
@@ -2,7 +2,19 @@
|
||||
|
||||
import { useMemo } from "react";
|
||||
import type { BoxProps } from "@mantine/core";
|
||||
import { Avatar, AvatarGroup, Badge, Card, Flex, Group, SimpleGrid, Stack, Text, Tooltip } from "@mantine/core";
|
||||
import {
|
||||
Avatar,
|
||||
AvatarGroup,
|
||||
Badge,
|
||||
Card,
|
||||
Flex,
|
||||
Group,
|
||||
ScrollArea,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { useElementSize } from "@mantine/hooks";
|
||||
import { IconBarrierBlock, IconPercentage, IconSearch, IconWorldWww } from "@tabler/icons-react";
|
||||
|
||||
@@ -18,20 +30,25 @@ import type { TablerIcon } from "@homarr/ui";
|
||||
|
||||
import type { widgetKind } from ".";
|
||||
import type { WidgetComponentProps, WidgetProps } from "../../definition";
|
||||
import { getUsableWidgetQueryData } from "../../common/query-state";
|
||||
|
||||
export default function DnsHoleSummaryWidget({
|
||||
options,
|
||||
integrationIds,
|
||||
displayMode,
|
||||
width,
|
||||
height,
|
||||
}: WidgetComponentProps<typeof widgetKind>) {
|
||||
const { data: summaries = [], isPending } = clientApi.widget.dnsHole.summary.useQuery({
|
||||
const summaryQuery = clientApi.widget.dnsHole.summary.useQuery({
|
||||
integrationIds,
|
||||
});
|
||||
const summaries = getUsableWidgetQueryData(summaryQuery) ?? [];
|
||||
const { isPending } = summaryQuery;
|
||||
|
||||
const t = useI18n();
|
||||
|
||||
const data = useMemo(() => summaries.flatMap(({ summary }) => summary), [summaries]);
|
||||
const layoutProps = boxPropsByLayout(options.layout);
|
||||
const showSourceStatuses = summaries.length > 1 && width >= 240 && height >= 220;
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
@@ -74,18 +91,21 @@ export default function DnsHoleSummaryWidget({
|
||||
</Stack>
|
||||
)}
|
||||
</SimpleGrid>
|
||||
{displayMode === "advanced" && summaries.length > 0 && (
|
||||
<Group px="xs" pb="xs" gap="xs">
|
||||
{summaries.map(({ integration, summary }) => (
|
||||
<Badge
|
||||
key={integration.id}
|
||||
variant="light"
|
||||
color={summary.status === "enabled" ? "green" : summary.status === "disabled" ? "red" : "gray"}
|
||||
>
|
||||
{integration.name}: {t(`widget.dnsHoleSummary.status.${summary.status ?? "unknown"}`)}
|
||||
</Badge>
|
||||
))}
|
||||
</Group>
|
||||
{showSourceStatuses && (
|
||||
<ScrollArea type="auto" scrollbarSize={4} px="xs" pb="xs">
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
{summaries.map(({ integration, summary }) => (
|
||||
<Badge
|
||||
key={integration.id}
|
||||
variant="light"
|
||||
color={summary.status === "enabled" ? "green" : summary.status === "disabled" ? "red" : "gray"}
|
||||
style={{ flexShrink: 0 }}
|
||||
>
|
||||
{integration.name}: {t(`widget.dnsHoleSummary.status.${summary.status ?? "unknown"}` as never)}
|
||||
</Badge>
|
||||
))}
|
||||
</Group>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
@@ -174,7 +194,7 @@ const StatCard = ({ item, data, usePiHoleColors, t }: StatCardProps) => {
|
||||
className="summary-card"
|
||||
p="sm"
|
||||
radius={board.itemRadius}
|
||||
bg={usePiHoleColors ? item.color : "rgba(96, 96, 96, 0.1)"}
|
||||
bg={usePiHoleColors ? item.color : "var(--mantine-color-default-hover)"}
|
||||
style={{
|
||||
flex: 1,
|
||||
}}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { optionsBuilder } from "../../options";
|
||||
export const widgetKind = "dnsHoleSummary";
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition(widgetKind, {
|
||||
supportsAdvancedFocus: false,
|
||||
icon: IconAd,
|
||||
queryKey: [["widget", "dnsHole"]],
|
||||
refetchInterval: 5,
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act, createElement } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { WidgetError } from "./component";
|
||||
|
||||
vi.mock("@homarr/translation/client", () => ({
|
||||
useI18n: () => (key: string) => key,
|
||||
}));
|
||||
|
||||
const matchMedia = (query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: () => undefined,
|
||||
removeListener: () => undefined,
|
||||
addEventListener: () => undefined,
|
||||
removeEventListener: () => undefined,
|
||||
dispatchEvent: () => false,
|
||||
});
|
||||
|
||||
describe("WidgetError", () => {
|
||||
let host: HTMLDivElement | undefined;
|
||||
let root: ReturnType<typeof createRoot> | undefined;
|
||||
|
||||
afterEach(async () => {
|
||||
if (root) await act(async () => root?.unmount());
|
||||
host?.remove();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
test("never renders an unmapped backend error", 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(WidgetError, {
|
||||
kind: "app",
|
||||
error: new Error(secret),
|
||||
resetErrorBoundary: vi.fn(),
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(host.textContent).toContain("common.error");
|
||||
expect(host.textContent).not.toContain(secret);
|
||||
expect(host.textContent).not.toMatch(/password|private\.local|token=secret/);
|
||||
});
|
||||
});
|
||||
@@ -28,7 +28,14 @@ export const WidgetError = ({ error, resetErrorBoundary, definition }: WidgetErr
|
||||
return <BaseWidgetError {...trpcErrorData} onRetry={resetErrorBoundary} />;
|
||||
}
|
||||
|
||||
return <BaseWidgetError icon={IconExclamationCircle} message={String(error)} onRetry={resetErrorBoundary} />;
|
||||
return (
|
||||
<BaseWidgetError
|
||||
icon={IconExclamationCircle}
|
||||
message={(t) => t("common.error")}
|
||||
showLogsLink
|
||||
onRetry={resetErrorBoundary}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const handleWidgetTrpcError = (
|
||||
@@ -60,7 +67,7 @@ const handleCommonTrpcError = (error: unknown): Omit<BaseWidgetErrorProps, "onRe
|
||||
if (errorData.code === "UNAUTHORIZED" || errorData.code === "FORBIDDEN") {
|
||||
return {
|
||||
icon: IconShield,
|
||||
message: "You don't have permission to access this widget",
|
||||
message: (t) => t("integration.testConnection.error.certificate.alert.permission.title"),
|
||||
showLogsLink: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -44,7 +44,8 @@ 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)),
|
||||
(query.isError && query.data === undefined) ||
|
||||
(query.data !== undefined && query.data.length > 0 && query.data.every(({ error }) => error)),
|
||||
);
|
||||
|
||||
export default function FirewallWidget({
|
||||
@@ -58,7 +59,7 @@ export default function FirewallWidget({
|
||||
const isAdvanced = displayMode === "advanced";
|
||||
const isTiny = !isAdvanced && (width < 256 || height < 180);
|
||||
const ringSize = isAdvanced ? 100 : height < 120 ? 44 : isTiny ? 64 : 100;
|
||||
const showInterfaces = isAdvanced || height >= 120;
|
||||
const showInterfaces = isAdvanced || (!isTiny && height >= 120);
|
||||
const t = useI18n();
|
||||
|
||||
const handleSelect = useCallback((value: string | null) => {
|
||||
@@ -159,7 +160,6 @@ export default function FirewallWidget({
|
||||
interfacesError={interfacesQuery.isError || Boolean(interfaces?.error)}
|
||||
hasError={hasError}
|
||||
isAdvanced={isAdvanced}
|
||||
isTiny={isTiny}
|
||||
ringSize={ringSize}
|
||||
showInterfaces={showInterfaces}
|
||||
accordionValue={accordionValue}
|
||||
@@ -190,7 +190,6 @@ interface FirewallPanelProps {
|
||||
interfacesError: boolean;
|
||||
hasError: boolean;
|
||||
isAdvanced: boolean;
|
||||
isTiny: boolean;
|
||||
ringSize: number;
|
||||
showInterfaces: boolean;
|
||||
accordionValue: string | null;
|
||||
@@ -214,7 +213,6 @@ const FirewallPanel = ({
|
||||
interfacesError,
|
||||
hasError,
|
||||
isAdvanced,
|
||||
isTiny,
|
||||
ringSize,
|
||||
showInterfaces,
|
||||
accordionValue,
|
||||
@@ -276,7 +274,6 @@ const FirewallPanel = ({
|
||||
hasResult={interfacesLoaded}
|
||||
hasError={interfacesError}
|
||||
isAdvanced={isAdvanced}
|
||||
isTiny={isTiny}
|
||||
accordionValue={accordionValue}
|
||||
setAccordionValue={setAccordionValue}
|
||||
errorLabel={errorLabel}
|
||||
@@ -302,6 +299,8 @@ const MetricRing = ({ value, icon: Icon, size, label, t }: MetricRingProps) => {
|
||||
const safeValue = Math.max(0, Math.min(100, Number.isFinite(value) ? value : 0));
|
||||
const status = getMetricStatus(safeValue);
|
||||
const statusLabel = t(`widget.firewall.status.${status}`);
|
||||
const showIcon = size >= 64;
|
||||
const showStatus = size >= 96;
|
||||
|
||||
return (
|
||||
<RingProgress
|
||||
@@ -315,9 +314,9 @@ const MetricRing = ({ value, icon: Icon, size, label, t }: MetricRingProps) => {
|
||||
thickness={size < 72 ? 4 : 8}
|
||||
label={
|
||||
<Center style={{ flexDirection: "column" }}>
|
||||
<Text size={size < 72 ? "8px" : "xs"}>{safeValue.toFixed(1)}%</Text>
|
||||
<Icon size={size < 72 ? 8 : 16} />
|
||||
{size >= 72 && <Text size="8px">{statusLabel}</Text>}
|
||||
<Text size="xs">{safeValue.toFixed(1)}%</Text>
|
||||
{showIcon && <Icon size={size < 96 ? 12 : 16} />}
|
||||
{showStatus && <Text size="xs">{statusLabel}</Text>}
|
||||
</Center>
|
||||
}
|
||||
sections={[
|
||||
@@ -341,7 +340,6 @@ interface InterfacesPanelProps {
|
||||
hasResult: boolean;
|
||||
hasError: boolean;
|
||||
isAdvanced: boolean;
|
||||
isTiny: boolean;
|
||||
accordionValue: string | null;
|
||||
setAccordionValue: (value: string | null) => void;
|
||||
errorLabel: string;
|
||||
@@ -356,7 +354,6 @@ const InterfacesPanel = ({
|
||||
hasResult,
|
||||
hasError,
|
||||
isAdvanced,
|
||||
isTiny,
|
||||
accordionValue,
|
||||
setAccordionValue,
|
||||
errorLabel,
|
||||
@@ -374,9 +371,9 @@ const InterfacesPanel = ({
|
||||
variant={isAdvanced ? "contained" : "default"}
|
||||
>
|
||||
<Accordion.Item value="interfaces">
|
||||
<Accordion.Control icon={isTiny ? null : <IconTopologyBus size={16} />}>
|
||||
<Accordion.Control icon={<IconTopologyBus size={16} />}>
|
||||
<Group justify="space-between" wrap="nowrap" gap="xs">
|
||||
<Text size={isTiny ? "8px" : "xs"}>{label}</Text>
|
||||
<Text size="xs">{label}</Text>
|
||||
{hasError && (
|
||||
<Badge color="red" variant="light" size="xs">
|
||||
{errorBadgeLabel}
|
||||
@@ -387,19 +384,30 @@ const InterfacesPanel = ({
|
||||
<Accordion.Panel>
|
||||
<Stack gap={4}>
|
||||
{bandwidth.map(({ name, receive, transmit }) => (
|
||||
<Group key={name} gap="xs" wrap={isTiny ? "wrap" : "nowrap"} justify="space-between">
|
||||
<Text size={isTiny ? "8px" : "xs"} c="blue.3" truncate="end" style={{ flex: 1 }}>
|
||||
<Group key={name} gap="xs" wrap="nowrap" justify="space-between">
|
||||
<Text
|
||||
size="xs"
|
||||
c="light-dark(var(--mantine-color-blue-8), var(--mantine-color-blue-3))"
|
||||
truncate="end"
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
{name}
|
||||
</Text>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<IconArrowBarUp size={isTiny ? 8 : 12} color="lightgreen" />
|
||||
<Text size={isTiny ? "8px" : "xs"} c="green.3">
|
||||
<IconArrowBarUp
|
||||
size={12}
|
||||
color="light-dark(var(--mantine-color-green-8), var(--mantine-color-green-3))"
|
||||
/>
|
||||
<Text size="xs" c="light-dark(var(--mantine-color-green-8), var(--mantine-color-green-3))">
|
||||
{formatBitsPerSec(transmit, 2)}
|
||||
</Text>
|
||||
</Group>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<IconArrowBarDown size={isTiny ? 8 : 12} color="yellow" />
|
||||
<Text size={isTiny ? "8px" : "xs"} c="yellow.3">
|
||||
<IconArrowBarDown
|
||||
size={12}
|
||||
color="light-dark(var(--mantine-color-yellow-9), var(--mantine-color-yellow-3))"
|
||||
/>
|
||||
<Text size="xs" c="light-dark(var(--mantine-color-yellow-9), var(--mantine-color-yellow-3))">
|
||||
{formatBitsPerSec(receive, 2)}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { hasTotalFirewallFailure } from "./component";
|
||||
import { hasFirewallPartialFailure, hasTotalFirewallFailure } from "./component";
|
||||
|
||||
const failedQuery = {
|
||||
isPending: false,
|
||||
@@ -27,4 +27,31 @@ describe("firewall failure state", () => {
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves stale cached data when a query-wide refetch fails", () => {
|
||||
const staleQuery = {
|
||||
...failedQuery,
|
||||
isError: true,
|
||||
data: [{ integration: { id: "firewall-1" }, error: undefined }],
|
||||
};
|
||||
|
||||
expect(hasTotalFirewallFailure([staleQuery, failedQuery, failedQuery, failedQuery])).toBe(false);
|
||||
});
|
||||
|
||||
it("surfaces query-wide and integration-specific partial failures", () => {
|
||||
const successfulQuery = {
|
||||
isPending: false,
|
||||
isFetching: false,
|
||||
isError: false,
|
||||
data: [{ integration: { id: "firewall-1" }, error: undefined }],
|
||||
};
|
||||
|
||||
expect(hasFirewallPartialFailure("firewall-1", [successfulQuery])).toBe(false);
|
||||
expect(hasFirewallPartialFailure("firewall-1", [{ ...successfulQuery, isError: true }])).toBe(true);
|
||||
expect(
|
||||
hasFirewallPartialFailure("firewall-1", [
|
||||
{ ...successfulQuery, data: [{ integration: { id: "firewall-1" }, error: "offline" }] },
|
||||
]),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,7 @@ import type { Resource } from "@homarr/integrations/types";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { WidgetEmptyState } from "../../common/empty-state";
|
||||
import { getUsableWidgetQueryData } from "../../common/query-state";
|
||||
import type { WidgetComponentProps } from "../../definition";
|
||||
import { formatUptime } from "../system-health";
|
||||
import { getClusterAccordionDefault } from "./accordion-state";
|
||||
@@ -38,9 +39,9 @@ export const ClusterHealthMonitoring = ({
|
||||
displayMode,
|
||||
}: WidgetComponentProps<"healthMonitoring"> & { integrationId: string }) => {
|
||||
const t = useI18n();
|
||||
const { data: healthData } = clientApi.widget.healthMonitoring.getClusterHealthStatus.useQuery({
|
||||
integrationId,
|
||||
});
|
||||
const healthData = getUsableWidgetQueryData(
|
||||
clientApi.widget.healthMonitoring.getClusterHealthStatus.useQuery({ integrationId }),
|
||||
);
|
||||
const accordionScope = `${displayMode}:${options.visibleClusterSections.join(",")}`;
|
||||
const accordionDefault = getClusterAccordionDefault(displayMode, options.visibleClusterSections);
|
||||
const [accordionValues, setAccordionValues] = useState<Record<string, string[]>>({});
|
||||
@@ -67,9 +68,9 @@ export const ClusterHealthMonitoring = ({
|
||||
const isTiny = displayMode !== "advanced" && width < 256;
|
||||
return (
|
||||
<Stack h={displayMode === "advanced" ? "auto" : "100%"} p="xs" gap={isTiny ? "xs" : "md"}>
|
||||
{options.showUptime && (
|
||||
{options.showUptime && !isTiny && (
|
||||
<Group justify="center" wrap="nowrap">
|
||||
<Text fz={isTiny ? 8 : "xs"} fw={700} c="dimmed" ta="center">
|
||||
<Text fz="xs" fw={700} c="dimmed" ta="center">
|
||||
{formatUptime(uptime, t)}
|
||||
</Text>
|
||||
</Group>
|
||||
@@ -190,10 +191,12 @@ const SummaryHeader = ({ cpu, memory, isTiny }: SummaryHeaderProps) => {
|
||||
sections={[{ value: cpu.value, color: cpu.value > 75 ? "orange" : "green" }]}
|
||||
/>
|
||||
<Stack align="center" justify="center" gap={0}>
|
||||
<Text fw={500} size={isTiny ? "xs" : "sm"}>
|
||||
{t("widget.healthMonitoring.cluster.summary.cpu")}
|
||||
</Text>
|
||||
<Text size={isTiny ? "8px" : "xs"}>{cpu.value.toFixed(1)}%</Text>
|
||||
{!isTiny && (
|
||||
<Text fw={500} size="sm">
|
||||
{t("widget.healthMonitoring.cluster.summary.cpu")}
|
||||
</Text>
|
||||
)}
|
||||
<Text size="xs">{cpu.value.toFixed(1)}%</Text>
|
||||
</Stack>
|
||||
</Flex>
|
||||
)}
|
||||
@@ -211,10 +214,12 @@ const SummaryHeader = ({ cpu, memory, isTiny }: SummaryHeaderProps) => {
|
||||
sections={[{ value: memory.value, color: memory.value > 75 ? "orange" : "green" }]}
|
||||
/>
|
||||
<Stack align="center" justify="center" gap={0}>
|
||||
<Text size={isTiny ? "xs" : "sm"} fw={500}>
|
||||
{t("widget.healthMonitoring.cluster.summary.memory")}
|
||||
</Text>
|
||||
<Text size={isTiny ? "8px" : "xs"}>{memory.value.toFixed(1)}%</Text>
|
||||
{!isTiny && (
|
||||
<Text size="sm" fw={500}>
|
||||
{t("widget.healthMonitoring.cluster.summary.memory")}
|
||||
</Text>
|
||||
)}
|
||||
<Text size="xs">{memory.value.toFixed(1)}%</Text>
|
||||
</Stack>
|
||||
</Flex>
|
||||
)}
|
||||
|
||||
@@ -27,21 +27,21 @@ export const ResourceTable = ({ type, data, isTiny }: ResourceTableProps) => {
|
||||
return (
|
||||
<Table highlightOnHover>
|
||||
<TableThead>
|
||||
<TableTr fz={isTiny ? "8px" : "xs"}>
|
||||
<TableTr fz="xs">
|
||||
<TableTh ta="start" p={0}>
|
||||
{t("widget.healthMonitoring.cluster.table.header.name")}
|
||||
</TableTh>
|
||||
{type !== "storage" ? (
|
||||
{!isTiny && type !== "storage" ? (
|
||||
<TableTh ta="start" p={0}>
|
||||
{t("widget.healthMonitoring.cluster.table.header.cpu")}
|
||||
</TableTh>
|
||||
) : null}
|
||||
{type !== "storage" ? (
|
||||
{!isTiny && type !== "storage" ? (
|
||||
<TableTh ta="start" p={0}>
|
||||
{t("widget.healthMonitoring.cluster.table.header.memory")}
|
||||
</TableTh>
|
||||
) : null}
|
||||
{type === "storage" ? (
|
||||
{!isTiny && type === "storage" ? (
|
||||
<TableTh ta="start" p={0}>
|
||||
{t("widget.healthMonitoring.cluster.table.header.node")}
|
||||
</TableTh>
|
||||
@@ -59,25 +59,25 @@ export const ResourceTable = ({ type, data, isTiny }: ResourceTableProps) => {
|
||||
return (
|
||||
<ResourcePopover key={item.id} item={item}>
|
||||
<Popover.Target>
|
||||
<TableTr fz={isTiny ? "8px" : "xs"}>
|
||||
<TableTr fz="xs">
|
||||
<TableTd>
|
||||
<Group wrap="nowrap" gap={isTiny ? 8 : "xs"}>
|
||||
<Indicator size={isTiny ? 4 : 8} color={item.isRunning ? "green" : "yellow"}>
|
||||
<Indicator size={isTiny ? 6 : 8} color={item.isRunning ? "green" : "yellow"}>
|
||||
{null}
|
||||
</Indicator>
|
||||
<Text lineClamp={1} fz={isTiny ? "8px" : "xs"}>
|
||||
<Text lineClamp={1} fz="xs">
|
||||
{item.name}
|
||||
</Text>
|
||||
</Group>
|
||||
</TableTd>
|
||||
{item.type === "storage" ? (
|
||||
<td style={{ WebkitLineClamp: "1" }}>{item.node}</td>
|
||||
{isTiny ? null : item.type === "storage" ? (
|
||||
<TableTd style={{ WebkitLineClamp: "1" }}>{item.node}</TableTd>
|
||||
) : (
|
||||
<>
|
||||
<td style={{ whiteSpace: "nowrap" }}>{(item.cpu.utilization * 100).toFixed(1)}%</td>
|
||||
<td style={{ whiteSpace: "nowrap" }}>
|
||||
<TableTd style={{ whiteSpace: "nowrap" }}>{(item.cpu.utilization * 100).toFixed(1)}%</TableTd>
|
||||
<TableTd style={{ whiteSpace: "nowrap" }}>
|
||||
{(item.memory.total ? (item.memory.used / item.memory.total) * 100 : 0).toFixed(1)}%
|
||||
</td>
|
||||
</TableTd>
|
||||
</>
|
||||
)}
|
||||
</TableTr>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { clientApi } from "@homarr/api/client";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { getUsableWidgetQueryData } from "../common/query-state";
|
||||
import { ClusterHealthMonitoring } from "./cluster/cluster-health";
|
||||
import { partitionHealthMonitoringIntegrations } from "./integration-selection";
|
||||
import { SystemHealthMonitoring } from "./system-health";
|
||||
@@ -15,7 +16,7 @@ import { SystemHealthMonitoring } from "./system-health";
|
||||
dayjs.extend(duration);
|
||||
|
||||
export default function HealthMonitoringWidget(props: WidgetComponentProps<"healthMonitoring">) {
|
||||
const { data: integrations = [] } = clientApi.integration.byIds.useQuery(props.integrationIds);
|
||||
const integrations = getUsableWidgetQueryData(clientApi.integration.byIds.useQuery(props.integrationIds)) ?? [];
|
||||
const t = useI18n();
|
||||
|
||||
const { clusterIntegrationIds, systemIntegrationIds } = partitionHealthMonitoringIntegrations(integrations);
|
||||
|
||||
@@ -41,6 +41,7 @@ import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { filterStorageVolumes, normalizeStorageDeviceName } from "../filter-storage-volumes";
|
||||
import { WidgetEmptyState } from "../common/empty-state";
|
||||
import { getUsableWidgetQueryData } from "../common/query-state";
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import actionTargetClasses from "../common/action-target.module.css";
|
||||
import { CpuRing } from "./rings/cpu-ring";
|
||||
@@ -59,9 +60,9 @@ export const SystemHealthMonitoring = ({
|
||||
withScrollArea = true,
|
||||
}: WidgetComponentProps<"healthMonitoring"> & { withScrollArea?: boolean }) => {
|
||||
const t = useI18n();
|
||||
const { data: healthData = [] } = clientApi.widget.healthMonitoring.getSystemHealthStatus.useQuery({
|
||||
integrationIds,
|
||||
});
|
||||
const healthData =
|
||||
getUsableWidgetQueryData(clientApi.widget.healthMonitoring.getSystemHealthStatus.useQuery({ integrationIds })) ??
|
||||
[];
|
||||
const [openedIntegrationId, setOpenedIntegrationId] = useState<string | null>(null);
|
||||
const board = useRequiredBoard();
|
||||
|
||||
|
||||
@@ -1,43 +1,35 @@
|
||||
"use client";
|
||||
|
||||
import { Anchor, Box, Group, Stack, Text, Title } from "@mantine/core";
|
||||
import { Box, Stack, Text, Title } from "@mantine/core";
|
||||
import { IconBrowserOff, IconProtocol } from "@tabler/icons-react";
|
||||
|
||||
import { objectEntries } from "@homarr/common";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { getSafeApplicationUrl } from "../common/application-url";
|
||||
import classes from "./component.module.css";
|
||||
|
||||
export default function IFrameWidget({ options, isEditMode, displayMode }: WidgetComponentProps<"iframe">) {
|
||||
export default function IFrameWidget({ options, isEditMode }: WidgetComponentProps<"iframe">) {
|
||||
const t = useI18n();
|
||||
const { embedUrl, allowScrolling, ...permissions } = options;
|
||||
const safeEmbedUrl = getSafeApplicationUrl(embedUrl);
|
||||
const allowedPermissions = getAllowedPermissions(permissions);
|
||||
const sandboxFlags = getSandboxFlags(permissions);
|
||||
|
||||
if (embedUrl.trim() === "") return <NoUrl />;
|
||||
if (!isSupportedProtocol(embedUrl)) {
|
||||
if (!safeEmbedUrl) {
|
||||
return <UnsupportedProtocol />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack h="100%" w="100%" gap={0}>
|
||||
{displayMode === "advanced" && (
|
||||
<Group key="advanced-header" px="xs" py={4} wrap="nowrap" justify="space-between">
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{getFrameTitle(embedUrl)}
|
||||
</Text>
|
||||
<Anchor href={embedUrl} target="_blank" rel="noreferrer" size="xs">
|
||||
{t("widget.app.option.openInNewTab.label")}
|
||||
</Anchor>
|
||||
</Group>
|
||||
)}
|
||||
<Box key="frame" style={{ flex: 1, minHeight: 0 }}>
|
||||
<Box style={{ flex: 1, minHeight: 0 }}>
|
||||
<iframe
|
||||
style={isEditMode ? { userSelect: "none", pointerEvents: "none" } : undefined}
|
||||
className={classes.iframe}
|
||||
src={embedUrl}
|
||||
title={getFrameTitle(embedUrl)}
|
||||
src={safeEmbedUrl}
|
||||
title={getFrameTitle(safeEmbedUrl)}
|
||||
allow={allowedPermissions}
|
||||
scrolling={allowScrolling ? "yes" : "no"}
|
||||
sandbox={sandboxFlags.join(" ")}
|
||||
@@ -52,12 +44,7 @@ export default function IFrameWidget({ options, isEditMode, displayMode }: Widge
|
||||
const supportedProtocols = ["http", "https"];
|
||||
|
||||
export const isSupportedProtocol = (url: string) => {
|
||||
try {
|
||||
const parsedUrl = new URL(url);
|
||||
return supportedProtocols.map((protocol) => `${protocol}:`).includes(`${parsedUrl.protocol}`);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
return getSafeApplicationUrl(url) !== undefined;
|
||||
};
|
||||
|
||||
const NoUrl = () => {
|
||||
|
||||
@@ -1,23 +1,39 @@
|
||||
import { IconBrowser } from "@tabler/icons-react";
|
||||
import { IconBrowser, IconExternalLink } from "@tabler/icons-react";
|
||||
|
||||
import { createWidgetDefinition } from "../definition";
|
||||
import { getSafeApplicationUrl } from "../common/application-url";
|
||||
import { optionsBuilder } from "../options";
|
||||
|
||||
const createOptions = () =>
|
||||
optionsBuilder.from((factory) => ({
|
||||
embedUrl: factory.text(),
|
||||
allowFullScreen: factory.switch(),
|
||||
allowScrolling: factory.switch({
|
||||
defaultValue: true,
|
||||
}),
|
||||
allowPayment: factory.switch(),
|
||||
allowAutoPlay: factory.switch(),
|
||||
allowMicrophone: factory.switch(),
|
||||
allowCamera: factory.switch(),
|
||||
allowGeolocation: factory.switch(),
|
||||
allowModals: factory.switch(),
|
||||
}));
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("iframe", {
|
||||
supportsAdvancedFocus: false,
|
||||
icon: IconBrowser,
|
||||
createOptions() {
|
||||
return optionsBuilder.from((factory) => ({
|
||||
embedUrl: factory.text(),
|
||||
allowFullScreen: factory.switch(),
|
||||
allowScrolling: factory.switch({
|
||||
defaultValue: true,
|
||||
}),
|
||||
allowPayment: factory.switch(),
|
||||
allowAutoPlay: factory.switch(),
|
||||
allowMicrophone: factory.switch(),
|
||||
allowCamera: factory.switch(),
|
||||
allowGeolocation: factory.switch(),
|
||||
allowModals: factory.switch(),
|
||||
}));
|
||||
contextActions: ({ options }) => {
|
||||
const embedUrl = getSafeApplicationUrl(options.embedUrl);
|
||||
return embedUrl
|
||||
? [
|
||||
{
|
||||
key: "open-iframe",
|
||||
label: "widget.app.option.openInNewTab.label",
|
||||
icon: IconExternalLink,
|
||||
onClick: () => window.open(embedUrl, "_blank", "noopener,noreferrer"),
|
||||
},
|
||||
]
|
||||
: [];
|
||||
},
|
||||
createOptions,
|
||||
}).withDynamicImport(() => import("./component"));
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { ActionIcon, Box, Center, Group, Image, ScrollArea, Stack, Text, UnstyledButton } from "@mantine/core";
|
||||
import { useReducedMotion } from "@mantine/hooks";
|
||||
import {
|
||||
@@ -18,6 +18,8 @@ import { useCurrentIntlLocale, useI18n, useScopedI18n } from "@homarr/translatio
|
||||
|
||||
import { WidgetEmptyState } from "../../common/empty-state";
|
||||
import type { WidgetComponentProps } from "../../definition";
|
||||
import { useWidgetRuntimeActions } from "../../runtime-hooks";
|
||||
import { getUsableWidgetQueryData } from "../../common/query-state";
|
||||
import classes from "./component.module.css";
|
||||
import { ALL_PHOTOS_ALBUM_ID } from "./constants";
|
||||
|
||||
@@ -25,17 +27,19 @@ export default function ImmichAlbumCarouselWidget({
|
||||
integrationIds,
|
||||
options,
|
||||
displayMode = "compact",
|
||||
widgetStateRef,
|
||||
widgetRuntimeRef,
|
||||
}: WidgetComponentProps<"immich-albumCarousel">) {
|
||||
const [currentPhotoIndex, setCurrentPhotoIndex] = useState(0);
|
||||
const [paused, setPaused] = useState(false);
|
||||
|
||||
const { data: album } = clientApi.widget.immich.getAlbum.useQuery(
|
||||
{
|
||||
integrationId: integrationIds[0] ?? "",
|
||||
albumId: options.albumId && options.albumId !== ALL_PHOTOS_ALBUM_ID ? options.albumId : undefined,
|
||||
},
|
||||
{ enabled: integrationIds.length > 0 },
|
||||
const album = getUsableWidgetQueryData(
|
||||
clientApi.widget.immich.getAlbum.useQuery(
|
||||
{
|
||||
integrationId: integrationIds[0] ?? "",
|
||||
albumId: options.albumId && options.albumId !== ALL_PHOTOS_ALBUM_ID ? options.albumId : undefined,
|
||||
},
|
||||
{ enabled: integrationIds.length > 0 },
|
||||
),
|
||||
);
|
||||
|
||||
const photoAssets = useMemo(() => {
|
||||
@@ -48,30 +52,19 @@ export default function ImmichAlbumCarouselWidget({
|
||||
setCurrentPhotoIndex((current) => Math.min(current, photoAssets.length - 1));
|
||||
}, [photoAssets.length]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!widgetStateRef) return;
|
||||
const clearActions = () => {
|
||||
if (!widgetStateRef.current) return;
|
||||
delete widgetStateRef.current.previousPhoto;
|
||||
delete widgetStateRef.current.nextPhoto;
|
||||
delete widgetStateRef.current.toggleSlideshow;
|
||||
};
|
||||
if (photoAssets.length <= 1) {
|
||||
clearActions();
|
||||
return;
|
||||
}
|
||||
const previousPhoto = () =>
|
||||
setCurrentPhotoIndex((current) => (current - 1 + photoAssets.length) % photoAssets.length);
|
||||
const nextPhoto = () => setCurrentPhotoIndex((current) => (current + 1) % photoAssets.length);
|
||||
const toggleSlideshow = () => setPaused((value) => !value);
|
||||
widgetStateRef.current = {
|
||||
...widgetStateRef.current,
|
||||
previousPhoto,
|
||||
nextPhoto,
|
||||
toggleSlideshow,
|
||||
};
|
||||
return clearActions;
|
||||
}, [photoAssets.length, widgetStateRef]);
|
||||
const previousPhoto = useCallback(
|
||||
() => setCurrentPhotoIndex((current) => (current - 1 + photoAssets.length) % photoAssets.length),
|
||||
[photoAssets.length],
|
||||
);
|
||||
const nextPhoto = useCallback(
|
||||
() => setCurrentPhotoIndex((current) => (current + 1) % photoAssets.length),
|
||||
[photoAssets.length],
|
||||
);
|
||||
const toggleSlideshow = useCallback(() => setPaused((value) => !value), []);
|
||||
useWidgetRuntimeActions(
|
||||
widgetRuntimeRef,
|
||||
photoAssets.length > 1 ? { previousPhoto, nextPhoto, toggleSlideshow } : {},
|
||||
);
|
||||
|
||||
if (!album) return <WidgetEmptyState />;
|
||||
|
||||
|
||||
@@ -8,6 +8,47 @@ import { createWidgetDefinition, widgetQueryInputMatches } from "../../definitio
|
||||
import { optionsBuilder } from "../../options";
|
||||
import { ALL_PHOTOS_ALBUM_ID } from "./constants";
|
||||
|
||||
const createOptions = () =>
|
||||
optionsBuilder.from((factory) => ({
|
||||
albumId: factory.integrationSelect({
|
||||
defaultValue: ALL_PHOTOS_ALBUM_ID,
|
||||
withDescription: true,
|
||||
clearable: true,
|
||||
useOptions: (integrationIds: string[]) => {
|
||||
const t = useScopedI18n("widget.immich-albumCarousel");
|
||||
const {
|
||||
data = [],
|
||||
isPending,
|
||||
isError,
|
||||
} = clientApi.widget.immich.getAlbums.useQuery(
|
||||
{ integrationId: integrationIds[0] ?? "" },
|
||||
{ enabled: integrationIds.length > 0, staleTime: 15 * 60 * 1000 },
|
||||
);
|
||||
return {
|
||||
data: [
|
||||
{ value: ALL_PHOTOS_ALBUM_ID, label: t("allPhotos") },
|
||||
...data.map((album) => ({ value: album.id, label: album.albumName })),
|
||||
],
|
||||
isPending,
|
||||
isError,
|
||||
};
|
||||
},
|
||||
}),
|
||||
rotationIntervalSeconds: factory.number({
|
||||
defaultValue: 5,
|
||||
validate: z.number().min(1).max(3600),
|
||||
withDescription: true,
|
||||
}),
|
||||
showPhotoInfo: factory.switch({
|
||||
defaultValue: false,
|
||||
withDescription: true,
|
||||
}),
|
||||
randomizePhotos: factory.switch({
|
||||
defaultValue: false,
|
||||
withDescription: true,
|
||||
}),
|
||||
}));
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("immich-albumCarousel", {
|
||||
icon: IconPhoto,
|
||||
queryKey: [["widget", "immich", "getAlbum"]],
|
||||
@@ -23,75 +64,31 @@ export const { definition, componentLoader } = createWidgetDefinition("immich-al
|
||||
supportedIntegrations: ["immich"],
|
||||
integrationsRequired: true,
|
||||
maxIntegrations: 1,
|
||||
contextActions({ widgetStateRef }) {
|
||||
const call = (key: string) => () => {
|
||||
const action = widgetStateRef?.current?.[key];
|
||||
if (typeof action === "function") action();
|
||||
};
|
||||
const disabled = (key: string) => typeof widgetStateRef?.current?.[key] !== "function";
|
||||
contextActions: ({ widgetRuntimeRef }) => {
|
||||
const actions = widgetRuntimeRef.current.actions;
|
||||
return [
|
||||
{
|
||||
key: "previousPhoto",
|
||||
label: (t) => t("widget.immich-albumCarousel.actions.previousPhoto"),
|
||||
label: "widget.immich-albumCarousel.actions.previousPhoto",
|
||||
icon: IconChevronLeft,
|
||||
disabled: disabled("previousPhoto"),
|
||||
onClick: call("previousPhoto"),
|
||||
disabled: typeof actions.previousPhoto !== "function",
|
||||
onClick: () => actions.previousPhoto?.(),
|
||||
},
|
||||
{
|
||||
key: "nextPhoto",
|
||||
label: (t) => t("widget.immich-albumCarousel.actions.nextPhoto"),
|
||||
label: "widget.immich-albumCarousel.actions.nextPhoto",
|
||||
icon: IconChevronRight,
|
||||
disabled: disabled("nextPhoto"),
|
||||
onClick: call("nextPhoto"),
|
||||
disabled: typeof actions.nextPhoto !== "function",
|
||||
onClick: () => actions.nextPhoto?.(),
|
||||
},
|
||||
{
|
||||
key: "toggleSlideshow",
|
||||
label: (t) => t("widget.immich-albumCarousel.actions.toggleSlideshow"),
|
||||
label: "widget.immich-albumCarousel.actions.toggleSlideshow",
|
||||
icon: IconPlayerPause,
|
||||
disabled: disabled("toggleSlideshow"),
|
||||
onClick: call("toggleSlideshow"),
|
||||
disabled: typeof actions.toggleSlideshow !== "function",
|
||||
onClick: () => actions.toggleSlideshow?.(),
|
||||
},
|
||||
];
|
||||
},
|
||||
createOptions() {
|
||||
return optionsBuilder.from((factory) => ({
|
||||
albumId: factory.integrationSelect({
|
||||
defaultValue: ALL_PHOTOS_ALBUM_ID,
|
||||
withDescription: true,
|
||||
clearable: true,
|
||||
useOptions: (integrationIds: string[]) => {
|
||||
const t = useScopedI18n("widget.immich-albumCarousel");
|
||||
const {
|
||||
data = [],
|
||||
isPending,
|
||||
isError,
|
||||
} = clientApi.widget.immich.getAlbums.useQuery(
|
||||
{ integrationId: integrationIds[0] ?? "" },
|
||||
{ enabled: integrationIds.length > 0, staleTime: 15 * 60 * 1000 },
|
||||
);
|
||||
return {
|
||||
data: [
|
||||
{ value: ALL_PHOTOS_ALBUM_ID, label: t("allPhotos") },
|
||||
...data.map((album) => ({ value: album.id, label: album.albumName })),
|
||||
],
|
||||
isPending,
|
||||
isError,
|
||||
};
|
||||
},
|
||||
}),
|
||||
rotationIntervalSeconds: factory.number({
|
||||
defaultValue: 5,
|
||||
validate: z.number().min(1).max(3600),
|
||||
withDescription: true,
|
||||
}),
|
||||
showPhotoInfo: factory.switch({
|
||||
defaultValue: false,
|
||||
withDescription: true,
|
||||
}),
|
||||
randomizePhotos: factory.switch({
|
||||
defaultValue: false,
|
||||
withDescription: true,
|
||||
}),
|
||||
}));
|
||||
},
|
||||
createOptions,
|
||||
}).withDynamicImport(() => import("./component"));
|
||||
@@ -1,4 +1,9 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act, createElement } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import ImmichServerStatsWidget from "./server-stats/component";
|
||||
@@ -26,20 +31,52 @@ vi.mock("@homarr/translation/client", () => ({
|
||||
}));
|
||||
|
||||
describe("Immich album queries", () => {
|
||||
let host: HTMLDivElement | undefined;
|
||||
let root: ReturnType<typeof createRoot> | undefined;
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
test("server stats requests a bounded album response", () => {
|
||||
ImmichServerStatsWidget({
|
||||
integrationIds: ["immich-id"],
|
||||
options: { showUsers: true, showPhotos: true, showVideos: true, showStorage: true },
|
||||
displayMode: "advanced",
|
||||
width: 800,
|
||||
height: 600,
|
||||
isEditMode: false,
|
||||
boardId: "board-id",
|
||||
itemId: "item-id",
|
||||
setOptions: vi.fn(),
|
||||
} satisfies WidgetComponentProps<"immich-serverStats">);
|
||||
afterEach(async () => {
|
||||
if (root) await act(async () => root?.unmount());
|
||||
host?.remove();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
test("server stats requests a bounded album response", async () => {
|
||||
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
||||
vi.stubGlobal("matchMedia", (query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: () => undefined,
|
||||
removeListener: () => undefined,
|
||||
addEventListener: () => undefined,
|
||||
removeEventListener: () => undefined,
|
||||
dispatchEvent: () => false,
|
||||
}));
|
||||
host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
root = createRoot(host);
|
||||
|
||||
await act(async () =>
|
||||
root?.render(
|
||||
createElement(
|
||||
MantineProvider,
|
||||
null,
|
||||
createElement(ImmichServerStatsWidget, {
|
||||
integrationIds: ["immich-id"],
|
||||
options: { showUsers: true, showPhotos: true, showVideos: true, showStorage: true },
|
||||
displayMode: "advanced",
|
||||
width: 800,
|
||||
height: 600,
|
||||
isEditMode: false,
|
||||
boardId: "board-id",
|
||||
itemId: "item-id",
|
||||
setOptions: vi.fn(),
|
||||
} satisfies WidgetComponentProps<"immich-serverStats">),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(mocks.getAlbums).toHaveBeenCalledWith(
|
||||
{ integrationId: "immich-id", limit: 50 },
|
||||
|
||||
@@ -2,11 +2,6 @@
|
||||
padding: 12px;
|
||||
border-radius: var(--mantine-radius-md);
|
||||
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5));
|
||||
}
|
||||
}
|
||||
|
||||
.statItemDense {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Group, Progress, ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core";
|
||||
import { Box, Center, Group, Progress, ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core";
|
||||
import { IconDatabase, IconPhoto, IconUsers, IconVideo } from "@tabler/icons-react";
|
||||
import { getQueryKey } from "@trpc/react-query";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { formatBytes } from "@homarr/common";
|
||||
@@ -10,7 +11,9 @@ import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { WidgetEmptyState } from "../../common/empty-state";
|
||||
import type { WidgetComponentProps } from "../../definition";
|
||||
import { setWidgetRuntimeQueries } from "../../definition";
|
||||
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../../common/query-state";
|
||||
import { WidgetQueryErrorIndicator, WidgetQueryLoadingState } from "../../common/query-state-indicator";
|
||||
import { useWidgetRuntimeQueries } from "../../runtime-hooks";
|
||||
import classes from "./component.module.css";
|
||||
|
||||
const MAX_ADVANCED_ALBUMS = 50;
|
||||
@@ -21,7 +24,7 @@ export default function ImmichServerStatsWidget({
|
||||
displayMode = "compact",
|
||||
width,
|
||||
height,
|
||||
widgetStateRef,
|
||||
widgetRuntimeRef,
|
||||
}: WidgetComponentProps<"immich-serverStats">) {
|
||||
const t = useI18n();
|
||||
const locale = useCurrentIntlLocale();
|
||||
@@ -29,25 +32,32 @@ export default function ImmichServerStatsWidget({
|
||||
const albumsInput = { ...input, limit: MAX_ADVANCED_ALBUMS };
|
||||
const isAdvanced = displayMode === "advanced";
|
||||
const albumsEnabled = isAdvanced && integrationIds.length > 0;
|
||||
const { data: stats } = clientApi.widget.immich.getServerStats.useQuery(input);
|
||||
const { data: albums = [] } = clientApi.widget.immich.getAlbums.useQuery(albumsInput, {
|
||||
const statsQuery = clientApi.widget.immich.getServerStats.useQuery(input);
|
||||
const stats = getUsableWidgetQueryData(statsQuery);
|
||||
const albumsQuery = clientApi.widget.immich.getAlbums.useQuery(albumsInput, {
|
||||
enabled: albumsEnabled,
|
||||
staleTime: 15 * 60 * 1000,
|
||||
});
|
||||
setWidgetRuntimeQueries(widgetStateRef, [
|
||||
{ path: ["widget", "immich", "getServerStats"], input },
|
||||
...(albumsEnabled ? [{ path: ["widget", "immich", "getAlbums"], input: albumsInput }] : []),
|
||||
const albums = albumsQuery.data ?? [];
|
||||
useWidgetRuntimeQueries(widgetRuntimeRef, [
|
||||
getQueryKey(clientApi.widget.immich.getServerStats, input, "query"),
|
||||
...(albumsEnabled ? [getQueryKey(clientApi.widget.immich.getAlbums, albumsInput, "query")] : []),
|
||||
]);
|
||||
|
||||
if (isInitialWidgetQueryPending(statsQuery)) return <WidgetQueryLoadingState />;
|
||||
if (!stats) return <WidgetEmptyState />;
|
||||
|
||||
const statVisibility = getImmichStatVisibility(options, isAdvanced);
|
||||
const statCount =
|
||||
Number(options.showUsers) + Number(options.showPhotos) + Number(options.showVideos) + Number(options.showStorage);
|
||||
Number(statVisibility.showUsers) +
|
||||
Number(statVisibility.showPhotos) +
|
||||
Number(statVisibility.showVideos) +
|
||||
Number(statVisibility.showStorage);
|
||||
const statsLayout = getImmichStatsLayout(width, height, statCount, isAdvanced);
|
||||
|
||||
const statsContent = (
|
||||
<SimpleGrid cols={statsLayout.columns} spacing={statsLayout.dense ? 4 : "sm"}>
|
||||
{options.showUsers && (
|
||||
{statVisibility.showUsers && (
|
||||
<StatItem
|
||||
icon={<IconUsers size={20} />}
|
||||
label={t("widget.immich-serverStats.users")}
|
||||
@@ -55,7 +65,7 @@ export default function ImmichServerStatsWidget({
|
||||
dense={statsLayout.dense}
|
||||
/>
|
||||
)}
|
||||
{options.showPhotos && (
|
||||
{statVisibility.showPhotos && (
|
||||
<StatItem
|
||||
icon={<IconPhoto size={20} />}
|
||||
label={t("widget.immich-serverStats.photos")}
|
||||
@@ -63,7 +73,7 @@ export default function ImmichServerStatsWidget({
|
||||
dense={statsLayout.dense}
|
||||
/>
|
||||
)}
|
||||
{options.showVideos && (
|
||||
{statVisibility.showVideos && (
|
||||
<StatItem
|
||||
icon={<IconVideo size={20} />}
|
||||
label={t("widget.immich-serverStats.videos")}
|
||||
@@ -71,7 +81,7 @@ export default function ImmichServerStatsWidget({
|
||||
dense={statsLayout.dense}
|
||||
/>
|
||||
)}
|
||||
{options.showStorage && (
|
||||
{statVisibility.showStorage && (
|
||||
<StatItem
|
||||
icon={<IconDatabase size={20} />}
|
||||
label={t("widget.immich-serverStats.storage")}
|
||||
@@ -84,8 +94,13 @@ export default function ImmichServerStatsWidget({
|
||||
|
||||
if (!isAdvanced) {
|
||||
return (
|
||||
<Stack gap="md" h="100%" p={statsLayout.dense ? "xs" : "md"} justify="center">
|
||||
<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("widget.immich-serverStats.name")} />
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -95,22 +110,41 @@ export default function ImmichServerStatsWidget({
|
||||
return (
|
||||
<Stack gap="lg" h="100%" p="lg">
|
||||
{statsContent}
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("widget.immich-serverStats.albumLimit", { count: MAX_ADVANCED_ALBUMS })}
|
||||
</Text>
|
||||
<WidgetQueryErrorIndicator
|
||||
error={statsQuery.error ?? albumsQuery.error}
|
||||
label={t("widget.immich-serverStats.name")}
|
||||
/>
|
||||
</Group>
|
||||
<ScrollArea style={{ flex: 1, minHeight: 0 }}>
|
||||
<SimpleGrid cols={width >= 900 ? 2 : 1} spacing="xs">
|
||||
{sortedAlbums.map((album) => (
|
||||
<Stack key={album.id} gap={4} p="xs">
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Text size="sm" fw={600} truncate>
|
||||
{album.albumName}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{album.assetCount.toLocaleString(locale)}
|
||||
</Text>
|
||||
</Group>
|
||||
<Progress value={(album.assetCount / Math.max(maxAssets, 1)) * 100} size="sm" />
|
||||
</Stack>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
{isInitialWidgetQueryPending(albumsQuery) ? (
|
||||
<WidgetQueryLoadingState />
|
||||
) : albumsQuery.error && albumsQuery.data === undefined ? (
|
||||
<Center h="100%" p="md">
|
||||
<WidgetQueryErrorIndicator error={albumsQuery.error} label={t("widget.immich-serverStats.name")} />
|
||||
</Center>
|
||||
) : albums.length === 0 ? (
|
||||
<WidgetEmptyState />
|
||||
) : (
|
||||
<SimpleGrid cols={width >= 900 ? 2 : 1} spacing="xs">
|
||||
{sortedAlbums.map((album) => (
|
||||
<Stack key={album.id} gap={4} p="xs">
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Text size="sm" fw={600} truncate>
|
||||
{album.albumName}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{album.assetCount.toLocaleString(locale)}
|
||||
</Text>
|
||||
</Group>
|
||||
<Progress value={(album.assetCount / Math.max(maxAssets, 1)) * 100} size="sm" />
|
||||
</Stack>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</Stack>
|
||||
);
|
||||
@@ -163,3 +197,13 @@ export const getImmichStatsLayout = (width: number, height: number, itemCount: n
|
||||
if (height < 220) return { columns: Math.min(count, 2), dense: true };
|
||||
return { columns: Math.min(count, width >= 280 ? 2 : 1), dense };
|
||||
};
|
||||
|
||||
export const getImmichStatVisibility = (
|
||||
options: WidgetComponentProps<"immich-serverStats">["options"],
|
||||
isAdvanced: boolean,
|
||||
) => ({
|
||||
showUsers: isAdvanced || options.showUsers,
|
||||
showPhotos: isAdvanced || options.showPhotos,
|
||||
showVideos: isAdvanced || options.showVideos,
|
||||
showStorage: isAdvanced || options.showStorage,
|
||||
});
|
||||
@@ -74,8 +74,12 @@ export type {
|
||||
WidgetOptionsSettings,
|
||||
WidgetQueryMatcher,
|
||||
WidgetQueryMatcherScope,
|
||||
WidgetRuntimeActions,
|
||||
WidgetRuntimeRef,
|
||||
WidgetRuntimeState,
|
||||
} from "./definition";
|
||||
export {
|
||||
createWidgetRuntimeState,
|
||||
getWidgetQueryKeys,
|
||||
getWidgetRuntimeQueries,
|
||||
normalizeWidgetQuery,
|
||||
|
||||
@@ -35,10 +35,23 @@
|
||||
}
|
||||
}
|
||||
|
||||
.indexerLink {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-self: stretch;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
@media (any-pointer: coarse) {
|
||||
.testAction {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.indexerRow {
|
||||
min-height: 44px;
|
||||
padding-block: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useMemo } from "react";
|
||||
import { ActionIcon, Anchor, Badge, Card, Flex, Group, ScrollArea, Stack, Text, Tooltip } from "@mantine/core";
|
||||
import { IconCircleCheck, IconCircleX, IconReportSearch, IconTestPipe } from "@tabler/icons-react";
|
||||
import combineClasses from "clsx";
|
||||
@@ -10,6 +10,9 @@ import { useRequiredBoard } from "@homarr/boards/context";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { useWidgetRuntimeActions } from "../runtime-hooks";
|
||||
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url";
|
||||
import { getUsableWidgetQueryData } from "../common/query-state";
|
||||
import actionTargetClasses from "../common/action-target.module.css";
|
||||
import classes from "./component.module.css";
|
||||
|
||||
@@ -18,36 +21,27 @@ export default function IndexerManagerWidget({
|
||||
integrationIds,
|
||||
width,
|
||||
height,
|
||||
displayMode,
|
||||
isEditMode,
|
||||
widgetStateRef,
|
||||
widgetRuntimeRef,
|
||||
}: WidgetComponentProps<"indexerManager">) {
|
||||
const t = useI18n();
|
||||
const { data: indexersData = [] } = clientApi.widget.indexerManager.getIndexersStatus.useQuery({ integrationIds });
|
||||
const indexersData =
|
||||
getUsableWidgetQueryData(clientApi.widget.indexerManager.getIndexersStatus.useQuery({ integrationIds })) ?? [];
|
||||
|
||||
const utils = clientApi.useUtils();
|
||||
const { mutate: testAll, isPending } = clientApi.widget.indexerManager.testAllIndexers.useMutation({
|
||||
onSettled: () => void utils.widget.indexerManager.getIndexersStatus.invalidate(),
|
||||
});
|
||||
const board = useRequiredBoard();
|
||||
const isAdvanced = displayMode === "advanced";
|
||||
const hasSmallWidth = !isAdvanced && width < 256;
|
||||
const isDense = !isAdvanced && (width < 280 || height < 180);
|
||||
const hasSmallWidth = width < 256;
|
||||
const isDense = width < 280 || height < 180;
|
||||
const showHealthCounts = width >= 200 && height >= 100;
|
||||
const allIndexers = useMemo(() => indexersData.flatMap((entry) => entry.indexers), [indexersData]);
|
||||
const unavailableCount = allIndexers.filter(
|
||||
(indexer) => indexer.status === false || indexer.enabled === false,
|
||||
).length;
|
||||
|
||||
useEffect(() => {
|
||||
if (!widgetStateRef) return;
|
||||
widgetStateRef.current = {
|
||||
...widgetStateRef.current,
|
||||
testAllIndexers: () => testAll({ integrationIds }),
|
||||
};
|
||||
return () => {
|
||||
if (widgetStateRef.current) delete widgetStateRef.current.testAllIndexers;
|
||||
};
|
||||
}, [integrationIds, testAll, widgetStateRef]);
|
||||
useWidgetRuntimeActions(widgetRuntimeRef, { testAllIndexers: () => testAll({ integrationIds }) });
|
||||
|
||||
return (
|
||||
<Flex
|
||||
@@ -70,7 +64,7 @@ export default function IndexerManagerWidget({
|
||||
{t("widget.indexerManager.title")}
|
||||
</Text>
|
||||
)}
|
||||
{isAdvanced && (
|
||||
{showHealthCounts && (
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<Badge size="xs" color="green" variant="light">
|
||||
{allIndexers.length - unavailableCount}
|
||||
@@ -110,64 +104,61 @@ export default function IndexerManagerWidget({
|
||||
<ScrollArea className="indexer-manager-list-scroll-area" h="100%" scrollbars="y">
|
||||
{indexersData.map(({ integrationId, integrationName, indexers, error }) => (
|
||||
<Stack gap={4} className={`indexer-manager-${integrationId}-list-container`} p={0} key={integrationId}>
|
||||
{(isAdvanced || indexersData.length > 1) && (
|
||||
{indexersData.length > 1 && (
|
||||
<Group justify="space-between" wrap="nowrap" py={4}>
|
||||
<Text size="xs" fw={600} truncate="end">
|
||||
{integrationName}
|
||||
</Text>
|
||||
{error && (
|
||||
<Tooltip label={error} multiline maw={360}>
|
||||
<Badge size="xs" color="red" variant="light">
|
||||
{t("common.error")}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
<Badge size="xs" color="red" variant="light">
|
||||
{t("common.error")}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
)}
|
||||
{indexers.map((indexer) => (
|
||||
<Group
|
||||
className={`indexer-manager-line indexer-manager-${indexer.name} ${classes.indexerRow}`}
|
||||
key={indexer.id}
|
||||
justify="space-between"
|
||||
gap="xs"
|
||||
wrap="nowrap"
|
||||
>
|
||||
<Anchor
|
||||
className="indexer-manager-line-anchor"
|
||||
href={indexer.url}
|
||||
target={options.openIndexerSiteInNewTab ? "_blank" : "_self"}
|
||||
rel={options.openIndexerSiteInNewTab ? "noopener noreferrer" : undefined}
|
||||
style={{ minWidth: 0, flex: 1 }}
|
||||
{indexers.map((indexer) => {
|
||||
const href = getSafeApplicationUrl(indexer.url);
|
||||
return (
|
||||
<Group
|
||||
className={`indexer-manager-line indexer-manager-${indexer.name} ${classes.indexerRow}`}
|
||||
key={indexer.id}
|
||||
justify="space-between"
|
||||
gap="xs"
|
||||
wrap="nowrap"
|
||||
>
|
||||
<Text
|
||||
className="indexer-manager-line-anchor-text"
|
||||
c="dimmed"
|
||||
size={hasSmallWidth ? "xs" : "sm"}
|
||||
truncate="end"
|
||||
<Anchor
|
||||
className={combineClasses("indexer-manager-line-anchor", classes.indexerLink)}
|
||||
component={href ? "a" : "span"}
|
||||
href={href}
|
||||
target={href ? (options.openIndexerSiteInNewTab ? "_blank" : "_self") : undefined}
|
||||
rel={href && options.openIndexerSiteInNewTab ? SAFE_NEW_TAB_REL : undefined}
|
||||
title={href}
|
||||
>
|
||||
{indexer.name}
|
||||
</Text>
|
||||
</Anchor>
|
||||
{isAdvanced && (
|
||||
<Text size="xs" c="dimmed" truncate="end" style={{ flex: 1 }}>
|
||||
{indexer.url}
|
||||
</Text>
|
||||
)}
|
||||
{indexer.status === false || indexer.enabled === false ? (
|
||||
<IconCircleX
|
||||
className="indexer-manager-line-status-icon indexer-manager-line-icon-disabled"
|
||||
color="#d9534f"
|
||||
size={hasSmallWidth ? 12 : 16}
|
||||
/>
|
||||
) : (
|
||||
<IconCircleCheck
|
||||
className="indexer-manager-line-status-icon indexer-manager-line-icon-enabled"
|
||||
color="#2ecc71"
|
||||
size={hasSmallWidth ? 12 : 16}
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
))}
|
||||
<Text
|
||||
className="indexer-manager-line-anchor-text"
|
||||
c="dimmed"
|
||||
size={hasSmallWidth ? "xs" : "sm"}
|
||||
truncate="end"
|
||||
>
|
||||
{indexer.name}
|
||||
</Text>
|
||||
</Anchor>
|
||||
{indexer.status === false || indexer.enabled === false ? (
|
||||
<IconCircleX
|
||||
className="indexer-manager-line-status-icon indexer-manager-line-icon-disabled"
|
||||
color="var(--mantine-color-red-6)"
|
||||
size={hasSmallWidth ? 12 : 16}
|
||||
/>
|
||||
) : (
|
||||
<IconCircleCheck
|
||||
className="indexer-manager-line-status-icon indexer-manager-line-icon-enabled"
|
||||
color="var(--mantine-color-green-6)"
|
||||
size={hasSmallWidth ? 12 : 16}
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
))}
|
||||
</ScrollArea>
|
||||
|
||||
@@ -5,31 +5,32 @@ import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||
import { createWidgetDefinition } from "../definition";
|
||||
import { optionsBuilder } from "../options";
|
||||
|
||||
const createOptions = () =>
|
||||
optionsBuilder.from((factory) => ({
|
||||
openIndexerSiteInNewTab: factory.switch({
|
||||
defaultValue: true,
|
||||
}),
|
||||
}));
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("indexerManager", {
|
||||
supportsAdvancedFocus: false,
|
||||
icon: IconReportSearch,
|
||||
contextActions: ({ widgetStateRef, context }) => [
|
||||
contextActions: ({ widgetRuntimeRef, context }) => [
|
||||
{
|
||||
key: "test-all-indexers",
|
||||
label: (t) => t("widget.indexerManager.testAll"),
|
||||
label: "widget.indexerManager.testAll",
|
||||
icon: IconTestPipe,
|
||||
disabled:
|
||||
context.isEditMode ||
|
||||
!context.canInteractWithSelectedIntegrations ||
|
||||
typeof widgetStateRef?.current?.testAllIndexers !== "function",
|
||||
typeof widgetRuntimeRef.current.actions.testAllIndexers !== "function",
|
||||
onClick: () => {
|
||||
const action = widgetStateRef?.current?.testAllIndexers;
|
||||
if (typeof action === "function") action();
|
||||
widgetRuntimeRef.current.actions.testAllIndexers?.();
|
||||
},
|
||||
},
|
||||
],
|
||||
refetchInterval: null,
|
||||
createOptions() {
|
||||
return optionsBuilder.from((factory) => ({
|
||||
openIndexerSiteInNewTab: factory.switch({
|
||||
defaultValue: true,
|
||||
}),
|
||||
}));
|
||||
},
|
||||
createOptions,
|
||||
supportedIntegrations: getIntegrationKindsByCategory("indexerManager"),
|
||||
errors: {
|
||||
INTERNAL_SERVER_ERROR: {
|
||||
|
||||
@@ -19,14 +19,18 @@ import {
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { IconDownload, IconMovie, IconQuestionMark, IconVideo } from "@tabler/icons-react";
|
||||
import { getQueryKey } from "@trpc/react-query";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import type { MissingMediaItem, QueuedMediaItem } from "@homarr/integrations/types";
|
||||
import { useScopedI18n } 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 { setWidgetRuntimeQueries } from "../definition";
|
||||
import { useWidgetRuntimeQueries } from "../runtime-hooks";
|
||||
import { NoIntegrationDataError } from "../errors/no-data-integration";
|
||||
import classes from "./component.module.css";
|
||||
import type { MediaMissingTab } from "./tabs";
|
||||
@@ -38,20 +42,23 @@ export default function MediaMissingWidget({
|
||||
width,
|
||||
height,
|
||||
displayMode,
|
||||
widgetStateRef,
|
||||
widgetRuntimeRef,
|
||||
}: WidgetComponentProps<"mediaMissing">) {
|
||||
const t = useScopedI18n("widget.mediaMissing");
|
||||
const isAdvanced = displayMode === "advanced";
|
||||
const showMissing = isAdvanced || options.showMissing;
|
||||
const showQueued = isAdvanced || options.showQueued;
|
||||
const pageSize = isAdvanced ? Math.max(Number(options.pageSize), 50) : Number(options.pageSize);
|
||||
const input = { integrationIds, pageSize };
|
||||
setWidgetRuntimeQueries(widgetStateRef, [{ path: ["widget", "mediaOrganizer", "getData"], input }]);
|
||||
const { data } = clientApi.widget.mediaOrganizer.getData.useQuery(input, {
|
||||
useWidgetRuntimeQueries(widgetRuntimeRef, [getQueryKey(clientApi.widget.mediaOrganizer.getData, input, "query")]);
|
||||
const mediaQuery = clientApi.widget.mediaOrganizer.getData.useQuery(input, {
|
||||
staleTime: 60 * 1000,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
});
|
||||
const [selectedTab, setSelectedTab] = useState<MediaMissingTab>(options.showMissing ? "missing" : "queued");
|
||||
const activeTab = resolveMediaMissingTab(selectedTab, options.showMissing, options.showQueued);
|
||||
const data = getUsableWidgetQueryData(mediaQuery);
|
||||
const [selectedTab, setSelectedTab] = useState<MediaMissingTab>(showMissing ? "missing" : "queued");
|
||||
const activeTab = resolveMediaMissingTab(selectedTab, showMissing, showQueued);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab !== null && activeTab !== selectedTab) setSelectedTab(activeTab);
|
||||
@@ -59,7 +66,7 @@ export default function MediaMissingWidget({
|
||||
|
||||
if (!data) return <WidgetEmptyState />;
|
||||
if (data.length === 0) throw new NoIntegrationDataError();
|
||||
if (!options.showMissing && !options.showQueued)
|
||||
if (!showMissing && !showQueued)
|
||||
return (
|
||||
<Center h="100%" p="sm">
|
||||
<Text c="dimmed" size="sm" ta="center">
|
||||
@@ -74,7 +81,7 @@ export default function MediaMissingWidget({
|
||||
const queuedCount = data.reduce((sum, entry) => sum + entry.queuedCount, 0);
|
||||
const failedIntegrations = data.filter((entry): entry is typeof entry & { error: string } => Boolean(entry.error));
|
||||
|
||||
const enabledPanelCount = Number(options.showMissing) + Number(options.showQueued);
|
||||
const enabledPanelCount = Number(showMissing) + Number(showQueued);
|
||||
const panelWidth = isAdvanced && enabledPanelCount > 1 ? width / enabledPanelCount : width;
|
||||
const isThin = !isAdvanced && panelWidth > 0 && panelWidth < 160;
|
||||
const isShort = !isAdvanced && height > 0 && height < 180;
|
||||
@@ -97,7 +104,12 @@ export default function MediaMissingWidget({
|
||||
) : (
|
||||
<SimpleGrid cols={columns} spacing="xs" verticalSpacing="xs">
|
||||
{entries.map(({ item, integrationId }) => (
|
||||
<MediaCard key={`${integrationId}-${item.type}-${item.id}`} item={item} density={density} />
|
||||
<MediaCard
|
||||
key={`${integrationId}-${item.type}-${item.id}`}
|
||||
item={item}
|
||||
density={density}
|
||||
showQueueDetails={isAdvanced}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
)}
|
||||
@@ -108,7 +120,7 @@ export default function MediaMissingWidget({
|
||||
const partialFailures = failedIntegrations.length > 0 && (
|
||||
<Group gap={4} p={4} wrap="wrap">
|
||||
{failedIntegrations.map((entry) => (
|
||||
<Tooltip key={entry.integrationId} label={entry.error} multiline maw={360}>
|
||||
<Tooltip key={entry.integrationId} label={`${entry.integrationName}: ${t("name")}`}>
|
||||
<Badge size="xs" color="red" variant="light">
|
||||
{entry.integrationName}
|
||||
</Badge>
|
||||
@@ -116,13 +128,17 @@ export default function MediaMissingWidget({
|
||||
))}
|
||||
</Group>
|
||||
);
|
||||
const queryFailure = <WidgetQueryErrorIndicator error={mediaQuery.error} label={t("name")} />;
|
||||
|
||||
if (isAdvanced) {
|
||||
return (
|
||||
<Stack h="100%" gap={0}>
|
||||
{partialFailures}
|
||||
<Group justify="flex-end" px="xs">
|
||||
{queryFailure}
|
||||
{partialFailures}
|
||||
</Group>
|
||||
<SimpleGrid cols={enabledPanelCount} spacing="sm" p="sm" style={{ flex: 1, minHeight: 0 }}>
|
||||
{options.showMissing && (
|
||||
{showMissing && (
|
||||
<Paper withBorder radius="sm" style={{ minHeight: 0, overflow: "hidden" }}>
|
||||
<Group p="xs" gap="xs">
|
||||
<IconQuestionMark size={16} />
|
||||
@@ -133,7 +149,7 @@ export default function MediaMissingWidget({
|
||||
<Box h="calc(100% - 40px)">{renderPanel(missing, t("empty.missing"))}</Box>
|
||||
</Paper>
|
||||
)}
|
||||
{options.showQueued && (
|
||||
{showQueued && (
|
||||
<Paper withBorder radius="sm" style={{ minHeight: 0, overflow: "hidden" }}>
|
||||
<Group p="xs" gap="xs">
|
||||
<IconDownload size={16} />
|
||||
@@ -158,26 +174,29 @@ export default function MediaMissingWidget({
|
||||
h="100%"
|
||||
style={{ display: "flex", flexDirection: "column" }}
|
||||
>
|
||||
{partialFailures}
|
||||
<Group justify="flex-end" px="xs">
|
||||
{queryFailure}
|
||||
{partialFailures}
|
||||
</Group>
|
||||
<Tabs.List grow>
|
||||
{options.showMissing && (
|
||||
{showMissing && (
|
||||
<Tabs.Tab value="missing" px={isThin ? 6 : undefined} leftSection={<IconQuestionMark size={14} />}>
|
||||
{tabLabel(t("tab.missing"), missing.length, missingCount)}
|
||||
</Tabs.Tab>
|
||||
)}
|
||||
{options.showQueued && (
|
||||
{showQueued && (
|
||||
<Tabs.Tab value="queued" px={isThin ? 6 : undefined} leftSection={<IconDownload size={14} />}>
|
||||
{tabLabel(t("tab.queued"), queued.length, queuedCount)}
|
||||
</Tabs.Tab>
|
||||
)}
|
||||
</Tabs.List>
|
||||
|
||||
{options.showMissing && (
|
||||
{showMissing && (
|
||||
<Tabs.Panel value="missing" flex={1} style={{ overflow: "hidden" }}>
|
||||
{renderPanel(missing, t("empty.missing"))}
|
||||
</Tabs.Panel>
|
||||
)}
|
||||
{options.showQueued && (
|
||||
{showQueued && (
|
||||
<Tabs.Panel value="queued" flex={1} style={{ overflow: "hidden" }}>
|
||||
{renderPanel(queued, t("empty.queued"))}
|
||||
</Tabs.Panel>
|
||||
@@ -283,22 +302,33 @@ const CardShell = ({
|
||||
item: MissingMediaItem | QueuedMediaItem;
|
||||
density: Density;
|
||||
children: ReactNode;
|
||||
}) => (
|
||||
<Paper
|
||||
className={classes.card}
|
||||
component="a"
|
||||
href={item.link}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
radius="sm"
|
||||
p="xs"
|
||||
h={CARD_HEIGHT[density]}
|
||||
>
|
||||
<div className={classes.content}>{children}</div>
|
||||
</Paper>
|
||||
);
|
||||
}) => {
|
||||
const href = getSafeApplicationUrl(item.link);
|
||||
return (
|
||||
<Paper
|
||||
className={classes.card}
|
||||
component={href ? "a" : "div"}
|
||||
href={href}
|
||||
target={href ? "_blank" : undefined}
|
||||
rel={href ? SAFE_NEW_TAB_REL : undefined}
|
||||
radius="sm"
|
||||
p="xs"
|
||||
h={CARD_HEIGHT[density]}
|
||||
>
|
||||
<div className={classes.content}>{children}</div>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
const MediaCard = ({ item, density }: { item: MissingMediaItem | QueuedMediaItem; density: Density }) => {
|
||||
const MediaCard = ({
|
||||
item,
|
||||
density,
|
||||
showQueueDetails,
|
||||
}: {
|
||||
item: MissingMediaItem | QueuedMediaItem;
|
||||
density: Density;
|
||||
showQueueDetails: boolean;
|
||||
}) => {
|
||||
const isQueued = "percentComplete" in item;
|
||||
|
||||
return (
|
||||
@@ -315,6 +345,11 @@ const MediaCard = ({ item, density }: { item: MissingMediaItem | QueuedMediaItem
|
||||
{item.type === "episode" ? item.title : item.year}
|
||||
</Text>
|
||||
)}
|
||||
{isQueued && showQueueDetails && (
|
||||
<Text fz="10px" c="dimmed" lineClamp={1} lh={1.1}>
|
||||
{[item.status, item.timeLeft].filter(Boolean).join(" · ")}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
{isQueued && <ProgressRing percent={Math.min(100, Math.max(0, item.percentComplete))} density={density} />}
|
||||
</Group>
|
||||
|
||||
@@ -28,7 +28,11 @@ import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
|
||||
import type { TablerIcon } from "@homarr/ui";
|
||||
import { OverflowBadge } from "@homarr/ui";
|
||||
|
||||
import { WidgetEmptyState } from "../common/empty-state";
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url";
|
||||
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state";
|
||||
import { WidgetQueryErrorIndicator, WidgetQueryLoadingState } from "../common/query-state-indicator";
|
||||
import classes from "./component.module.css";
|
||||
|
||||
export default function MediaReleasesWidget({
|
||||
@@ -38,23 +42,33 @@ export default function MediaReleasesWidget({
|
||||
height,
|
||||
displayMode,
|
||||
}: WidgetComponentProps<"mediaReleases">) {
|
||||
const { data: releases = [] } = clientApi.widget.mediaRelease.getMediaReleases.useQuery({
|
||||
integrationIds,
|
||||
});
|
||||
const t = useI18n();
|
||||
const releasesQuery = clientApi.widget.mediaRelease.getMediaReleases.useQuery({ integrationIds });
|
||||
const releases = getUsableWidgetQueryData(releasesQuery);
|
||||
|
||||
if (isInitialWidgetQueryPending(releasesQuery)) return <WidgetQueryLoadingState />;
|
||||
if (!releases || releases.length === 0) return <WidgetEmptyState />;
|
||||
|
||||
const isAdvanced = displayMode === "advanced";
|
||||
|
||||
return (
|
||||
<ScrollArea h="100%">
|
||||
<SimpleGrid cols={isAdvanced ? Math.max(1, Math.floor(width / 360)) : 1} p="xs" spacing="sm">
|
||||
{releases.map((item, index) => (
|
||||
<Fragment key={`${item.integration.id}:${item.id}`}>
|
||||
{!isAdvanced && index !== 0 && options.layout === "poster" && <Divider />}
|
||||
<Item item={item} options={options} isAdvanced={isAdvanced} width={width} height={height} />
|
||||
</Fragment>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</ScrollArea>
|
||||
<Box h="100%" pos="relative">
|
||||
<ScrollArea h="100%">
|
||||
<SimpleGrid cols={isAdvanced ? Math.max(1, Math.floor(width / 360)) : 1} p="xs" spacing="sm">
|
||||
{releases.map((item, index) => (
|
||||
<Fragment key={`${item.integration.id}:${item.id}`}>
|
||||
{!isAdvanced && index !== 0 && options.layout === "poster" && <Divider />}
|
||||
<Item item={item} options={options} isAdvanced={isAdvanced} width={width} height={height} />
|
||||
</Fragment>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</ScrollArea>
|
||||
{releasesQuery.error && (
|
||||
<Box pos="absolute" top={4} right={4}>
|
||||
<WidgetQueryErrorIndicator error={releasesQuery.error} label={t("widget.mediaReleases.name")} />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -86,6 +100,7 @@ const Item = ({ item, options, isAdvanced, width, height }: ItemProps) => {
|
||||
const showPoster = (isAdvanced || options.layout === "poster") && !isTiny;
|
||||
const showExtendedMetadata = isAdvanced || (!isCompact && width >= 360);
|
||||
const showSide = isAdvanced || (!isTiny && (options.showType || options.showSource));
|
||||
const href = getSafeApplicationUrl(item.href);
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
@@ -102,10 +117,10 @@ const Item = ({ item, options, isAdvanced, width, height }: ItemProps) => {
|
||||
>
|
||||
<UnstyledButton
|
||||
className={classes.item}
|
||||
component="a"
|
||||
href={item.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
component={href ? "a" : "div"}
|
||||
href={href}
|
||||
target={href ? "_blank" : undefined}
|
||||
rel={href ? SAFE_NEW_TAB_REL : undefined}
|
||||
pos="relative"
|
||||
p={isAdvanced ? "sm" : options.layout === "poster" ? 4 : isTiny ? 4 : 6}
|
||||
h="100%"
|
||||
|
||||
@@ -14,6 +14,8 @@ import { openMediaRequestSearch } from "@homarr/spotlight";
|
||||
import { useScopedI18n } 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 actionTargetClasses from "../../common/action-target.module.css";
|
||||
import { IntegrationErrorIndicator } from "../../common/integration-error-indicator";
|
||||
import type { WidgetComponentProps } from "../../definition";
|
||||
@@ -27,25 +29,27 @@ export default function MediaServerWidget({
|
||||
options,
|
||||
width,
|
||||
height,
|
||||
displayMode,
|
||||
}: WidgetComponentProps<"mediaRequests-requestList">) {
|
||||
const interactIntegrationIds = new Set(
|
||||
useIntegrationsWithInteractAccess()
|
||||
.filter(({ id }) => integrationIds.includes(id))
|
||||
.map(({ id }) => id),
|
||||
);
|
||||
const { data: mediaRequestData } = clientApi.widget.mediaRequests.getLatestRequests.useQuery({
|
||||
integrationIds,
|
||||
statuses:
|
||||
options.statusFilter.length > 0
|
||||
? options.statusFilter
|
||||
: ["pending", "approved", "declined", "failed", "completed"],
|
||||
recentDays: options.recentDays,
|
||||
});
|
||||
const mediaRequestData = getUsableWidgetQueryData(
|
||||
clientApi.widget.mediaRequests.getLatestRequests.useQuery({
|
||||
integrationIds,
|
||||
statuses:
|
||||
options.statusFilter.length > 0
|
||||
? options.statusFilter
|
||||
: ["pending", "approved", "declined", "failed", "completed"],
|
||||
recentDays: options.recentDays,
|
||||
}),
|
||||
);
|
||||
|
||||
if (!mediaRequestData) return <WidgetEmptyState />;
|
||||
const { requests: mediaRequests, failedIntegrations } = mediaRequestData;
|
||||
if (mediaRequests.length === 0 && failedIntegrations.length === 0) throw new NoIntegrationDataError();
|
||||
const showIntegrationSource = new Set(mediaRequests.map(({ integrationId }) => integrationId)).size > 1;
|
||||
|
||||
return (
|
||||
<Stack className={searchClasses.searchRoot} gap={0}>
|
||||
@@ -65,9 +69,9 @@ export default function MediaServerWidget({
|
||||
<MediaRequestCard
|
||||
key={`${mediaRequest.integrationId}-${mediaRequest.id}`}
|
||||
request={mediaRequest}
|
||||
isTiny={displayMode !== "advanced" && (width <= 256 || height < 96)}
|
||||
isDense={displayMode !== "advanced" && (width < 340 || height < 150)}
|
||||
isAdvanced={displayMode === "advanced"}
|
||||
isTiny={width <= 256 || height < 96}
|
||||
isDense={width < 340 || height < 150}
|
||||
showIntegrationSource={showIntegrationSource}
|
||||
canInteract={interactIntegrationIds.has(mediaRequest.integrationId)}
|
||||
options={options}
|
||||
/>
|
||||
@@ -100,14 +104,23 @@ interface MediaRequestCardProps {
|
||||
request: RouterOutputs["widget"]["mediaRequests"]["getLatestRequests"]["requests"][number];
|
||||
isTiny: boolean;
|
||||
isDense: boolean;
|
||||
isAdvanced: boolean;
|
||||
showIntegrationSource: boolean;
|
||||
canInteract: boolean;
|
||||
options: WidgetComponentProps<"mediaRequests-requestList">["options"];
|
||||
}
|
||||
|
||||
const MediaRequestCard = ({ request, isTiny, isDense, isAdvanced, canInteract, options }: MediaRequestCardProps) => {
|
||||
const MediaRequestCard = ({
|
||||
request,
|
||||
isTiny,
|
||||
isDense,
|
||||
showIntegrationSource,
|
||||
canInteract,
|
||||
options,
|
||||
}: MediaRequestCardProps) => {
|
||||
const board = useRequiredBoard();
|
||||
const t = useScopedI18n("widget.mediaRequests-requestList");
|
||||
const requestHref = getSafeApplicationUrl(request.href);
|
||||
const requestedByHref = getSafeApplicationUrl(request.requestedBy?.link);
|
||||
|
||||
return (
|
||||
<Card
|
||||
@@ -148,10 +161,11 @@ const MediaRequestCard = ({ request, isTiny, isDense, isAdvanced, canInteract, o
|
||||
<Group gap="xs" justify="space-between" wrap="nowrap" className="mediaRequests-list-item-top-group">
|
||||
<Anchor
|
||||
className="mediaRequests-list-item-info-second-line mediaRequests-list-item-media-title"
|
||||
href={request.href}
|
||||
component={requestHref ? "a" : "span"}
|
||||
href={requestHref}
|
||||
c="var(--mantine-color-text)"
|
||||
target={options.linksTargetNewTab ? "_blank" : "_self"}
|
||||
rel={options.linksTargetNewTab ? "noopener noreferrer" : undefined}
|
||||
target={requestHref ? (options.linksTargetNewTab ? "_blank" : "_self") : undefined}
|
||||
rel={requestHref && options.linksTargetNewTab ? SAFE_NEW_TAB_REL : undefined}
|
||||
fz={isTiny ? "xs" : "sm"}
|
||||
fw={600}
|
||||
title={request.name}
|
||||
@@ -186,7 +200,7 @@ const MediaRequestCard = ({ request, isTiny, isDense, isAdvanced, canInteract, o
|
||||
{t(`availability.${request.availability}`)}
|
||||
</Badge>
|
||||
)}
|
||||
{isAdvanced && (
|
||||
{showIntegrationSource && !isTiny && (
|
||||
<Badge size="xs" variant="outline">
|
||||
{request.integration.name}
|
||||
</Badge>
|
||||
@@ -201,13 +215,14 @@ const MediaRequestCard = ({ request, isTiny, isDense, isAdvanced, canInteract, o
|
||||
/>
|
||||
<Anchor
|
||||
className="mediaRequests-list-item-request-user-name"
|
||||
href={request.requestedBy?.link}
|
||||
component={requestedByHref ? "a" : "span"}
|
||||
href={requestedByHref}
|
||||
c="dimmed"
|
||||
target={options.linksTargetNewTab ? "_blank" : "_self"}
|
||||
rel={options.linksTargetNewTab ? "noopener noreferrer" : undefined}
|
||||
target={requestedByHref ? (options.linksTargetNewTab ? "_blank" : "_self") : undefined}
|
||||
rel={requestedByHref && options.linksTargetNewTab ? SAFE_NEW_TAB_REL : undefined}
|
||||
fz="xs"
|
||||
truncate="end"
|
||||
style={{ minWidth: 0, maxWidth: isAdvanced ? 180 : 100 }}
|
||||
style={{ minWidth: 0, maxWidth: isDense ? 100 : 180 }}
|
||||
>
|
||||
{(request.requestedBy?.displayName ?? "") || "unknown"}
|
||||
</Anchor>
|
||||
@@ -252,7 +267,7 @@ const DecisionButtons = ({ requestId, integrationId, canInteract, alwaysVisible
|
||||
className={`mediaRequests-list-item-pending-buttons ${classes.pendingActions} ${alwaysVisible ? classes.pendingActionsVisible : ""}`}
|
||||
gap={4}
|
||||
wrap="nowrap"
|
||||
title={error?.message}
|
||||
aria-invalid={Boolean(error)}
|
||||
>
|
||||
<Tooltip label={t("pending.approve")}>
|
||||
<ActionIcon
|
||||
|
||||
@@ -8,8 +8,26 @@ import { createWidgetDefinition, widgetQueryInputMatches } from "../../definitio
|
||||
import { optionsBuilder } from "../../options";
|
||||
|
||||
const mediaRequestStatusValues = ["pending", "approved", "declined", "failed", "completed"] as const;
|
||||
const createOptions = () =>
|
||||
optionsBuilder.from((factory) => ({
|
||||
linksTargetNewTab: factory.switch({
|
||||
defaultValue: true,
|
||||
}),
|
||||
statusFilter: factory.multiSelect({
|
||||
defaultValue: [...mediaRequestStatusValues],
|
||||
options: mediaRequestStatusValues.map((value) => ({
|
||||
value,
|
||||
label: (t) => t(`widget.mediaRequests-requestList.status.${value}`),
|
||||
})),
|
||||
}),
|
||||
recentDays: factory.number({
|
||||
validate: z.number().min(0).max(365),
|
||||
defaultValue: 0,
|
||||
}),
|
||||
}));
|
||||
|
||||
export const { componentLoader, definition } = createWidgetDefinition("mediaRequests-requestList", {
|
||||
supportsAdvancedFocus: false,
|
||||
icon: IconZoomQuestion,
|
||||
queryKey: [["widget", "mediaRequests", "getLatestRequests"]],
|
||||
queryMatcher: ({ input }, scope) =>
|
||||
@@ -21,28 +39,11 @@ export const { componentLoader, definition } = createWidgetDefinition("mediaRequ
|
||||
: mediaRequestStatusValues,
|
||||
recentDays: scope.options.recentDays,
|
||||
}),
|
||||
createOptions() {
|
||||
return optionsBuilder.from((factory) => ({
|
||||
linksTargetNewTab: factory.switch({
|
||||
defaultValue: true,
|
||||
}),
|
||||
statusFilter: factory.multiSelect({
|
||||
defaultValue: [...mediaRequestStatusValues],
|
||||
options: mediaRequestStatusValues.map((value) => ({
|
||||
value,
|
||||
label: (t) => t(`widget.mediaRequests-requestList.status.${value}`),
|
||||
})),
|
||||
}),
|
||||
recentDays: factory.number({
|
||||
validate: z.number().min(0).max(365),
|
||||
defaultValue: 0,
|
||||
}),
|
||||
}));
|
||||
},
|
||||
createOptions,
|
||||
contextActions: ({ integrationIds }) => [
|
||||
{
|
||||
key: "search",
|
||||
label: (t) => t("search.mode.media.action.search.label"),
|
||||
label: "search.mode.media.action.search.label",
|
||||
icon: IconSearch,
|
||||
onClick: () => {
|
||||
openMediaRequestSearch({ integrationIds });
|
||||
|
||||
@@ -22,6 +22,8 @@ import { openMediaRequestSearch } from "@homarr/spotlight";
|
||||
import { useScopedI18n } 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 actionTargetClasses from "../../common/action-target.module.css";
|
||||
import { IntegrationErrorIndicator } from "../../common/integration-error-indicator";
|
||||
import type { WidgetComponentProps } from "../../definition";
|
||||
@@ -37,12 +39,9 @@ export default function MediaServerWidget({
|
||||
isEditMode,
|
||||
width,
|
||||
height,
|
||||
displayMode,
|
||||
}: WidgetComponentProps<"mediaRequests-requestStats">) {
|
||||
const t = useScopedI18n("widget.mediaRequests-requestStats");
|
||||
const { data: requestStats } = clientApi.widget.mediaRequests.getStats.useQuery({
|
||||
integrationIds,
|
||||
});
|
||||
const requestStats = getUsableWidgetQueryData(clientApi.widget.mediaRequests.getStats.useQuery({ integrationIds }));
|
||||
|
||||
const board = useRequiredBoard();
|
||||
|
||||
@@ -97,13 +96,10 @@ export default function MediaServerWidget({
|
||||
},
|
||||
] satisfies { name: keyof RequestStats; icon: Icon; number: number }[];
|
||||
|
||||
const isTiny = displayMode !== "advanced" && (width < 256 || height < 180);
|
||||
const showSectionTitles = displayMode === "advanced" || height >= 180;
|
||||
const visibleStats = displayMode === "advanced" ? data : data.slice(0, height < 120 ? 4 : 8);
|
||||
const visibleUsers = requestStats.users.slice(
|
||||
0,
|
||||
displayMode === "advanced" ? 50 : Math.max(1, Math.floor(height / 44) - 3),
|
||||
);
|
||||
const isTiny = width < 256 || height < 180;
|
||||
const showSectionTitles = height >= 180;
|
||||
const visibleStats = data.slice(0, height < 120 ? 4 : 8);
|
||||
const showIntegrationLegend = requestStats.integrations.length > 1 && height >= 240;
|
||||
|
||||
return (
|
||||
<Box className={searchClasses.searchRoot}>
|
||||
@@ -118,7 +114,7 @@ export default function MediaServerWidget({
|
||||
p="sm"
|
||||
align="center"
|
||||
justify="space-between"
|
||||
style={{ pointerEvents: isEditMode ? "none" : undefined }}
|
||||
style={{ pointerEvents: isEditMode ? "none" : undefined, overflow: "hidden" }}
|
||||
>
|
||||
<Stack gap={4} w="100%">
|
||||
{showSectionTitles && (
|
||||
@@ -147,60 +143,63 @@ export default function MediaServerWidget({
|
||||
))}
|
||||
</Grid>
|
||||
</Stack>
|
||||
<Stack gap={4} w="100%">
|
||||
<Stack gap={4} w="100%" style={{ flex: 1, minHeight: 0, overflow: "hidden" }}>
|
||||
{showSectionTitles && (
|
||||
<Text className="mediaRequests-stats-users-title" fw={600} ta="center" size={isTiny ? "xs" : "sm"}>
|
||||
{t("titles.users.main")} ({t("titles.users.requests")})
|
||||
</Text>
|
||||
)}
|
||||
<ScrollArea className="mediaRequests-stats-users-wrapper" flex={1} w="100%">
|
||||
<ScrollArea className="mediaRequests-stats-users-wrapper" flex={1} mih={0} w="100%">
|
||||
<Stack gap={4}>
|
||||
{visibleUsers.map((user) => (
|
||||
<Card
|
||||
component="a"
|
||||
href={user.link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={combineClasses(
|
||||
"mediaRequests-stats-users-user-wrapper",
|
||||
`mediaRequests-stats-users-user-${user.id}`,
|
||||
classes.card,
|
||||
classes.userCard,
|
||||
)}
|
||||
key={`${user.integration.id}:${user.id}`}
|
||||
p="xs"
|
||||
radius={board.itemRadius}
|
||||
>
|
||||
<Group
|
||||
className="mediaRequests-stats-users-user-group"
|
||||
h="100%"
|
||||
p={0}
|
||||
gap="sm"
|
||||
justify="space-between"
|
||||
{requestStats.users.map((user) => {
|
||||
const href = getSafeApplicationUrl(user.link);
|
||||
return (
|
||||
<Card
|
||||
component={href ? "a" : "div"}
|
||||
href={href}
|
||||
target={href ? "_blank" : undefined}
|
||||
rel={href ? SAFE_NEW_TAB_REL : undefined}
|
||||
className={combineClasses(
|
||||
"mediaRequests-stats-users-user-wrapper",
|
||||
`mediaRequests-stats-users-user-${user.id}`,
|
||||
classes.card,
|
||||
classes.userCard,
|
||||
)}
|
||||
key={`${user.integration.id}:${user.id}`}
|
||||
p="xs"
|
||||
radius={board.itemRadius}
|
||||
>
|
||||
<Group gap={4} wrap="nowrap" miw={0}>
|
||||
<Tooltip label={user.integration.name}>
|
||||
<Avatar
|
||||
className="mediaRequests-stats-users-user-avatar"
|
||||
size={20}
|
||||
src={user.avatar}
|
||||
bd={`2px solid ${user.integration.kind === "overseerr" ? OVERSEERR_COLOR : JELLYSEERR_COLOR}`}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Text className="mediaRequests-stats-users-user-userName" size="sm" truncate="end">
|
||||
{user.displayName}
|
||||
<Group
|
||||
className="mediaRequests-stats-users-user-group"
|
||||
h="100%"
|
||||
p={0}
|
||||
gap="sm"
|
||||
justify="space-between"
|
||||
>
|
||||
<Group gap={4} wrap="nowrap" miw={0}>
|
||||
<Tooltip label={user.integration.name}>
|
||||
<Avatar
|
||||
className="mediaRequests-stats-users-user-avatar"
|
||||
size={20}
|
||||
src={user.avatar}
|
||||
bd={`2px solid ${user.integration.kind === "overseerr" ? OVERSEERR_COLOR : JELLYSEERR_COLOR}`}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Text className="mediaRequests-stats-users-user-userName" size="sm" truncate="end">
|
||||
{user.displayName}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
<Text className="mediaRequests-stats-users-user-request-count" size="md" fw={500}>
|
||||
{user.requestCount}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
<Text className="mediaRequests-stats-users-user-request-count" size="md" fw={500}>
|
||||
{user.requestCount}
|
||||
</Text>
|
||||
</Group>
|
||||
</Card>
|
||||
))}
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</ScrollArea>
|
||||
{displayMode === "advanced" && (
|
||||
{showIntegrationLegend && (
|
||||
<Group justify="center" gap={4}>
|
||||
{requestStats.integrations.map((integration) => (
|
||||
<Badge key={integration.id} size="xs" variant="light">
|
||||
|
||||
@@ -5,16 +5,17 @@ import { openMediaRequestSearch } from "@homarr/spotlight";
|
||||
|
||||
import { createWidgetDefinition } from "../../definition";
|
||||
|
||||
const createOptions = () => ({});
|
||||
|
||||
export const { componentLoader, definition } = createWidgetDefinition("mediaRequests-requestStats", {
|
||||
supportsAdvancedFocus: false,
|
||||
icon: IconChartBar,
|
||||
queryKey: [["widget", "mediaRequests", "getStats"]],
|
||||
createOptions() {
|
||||
return {};
|
||||
},
|
||||
createOptions,
|
||||
contextActions: ({ integrationIds }) => [
|
||||
{
|
||||
key: "search",
|
||||
label: (t) => t("search.mode.media.action.search.label"),
|
||||
label: "search.mode.media.action.search.label",
|
||||
icon: IconSearch,
|
||||
onClick: () => {
|
||||
openMediaRequestSearch({ integrationIds });
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { getQueryKey } from "@trpc/react-query";
|
||||
import {
|
||||
Box,
|
||||
Center,
|
||||
Divider,
|
||||
Group,
|
||||
@@ -24,7 +26,9 @@ import type { TablerIcon } from "@homarr/ui";
|
||||
import { views } from ".";
|
||||
import { WidgetEmptyState } from "../common/empty-state";
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { setWidgetRuntimeQueries } 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";
|
||||
import { StatisticsPanel } from "./panels/statistics.panel";
|
||||
@@ -46,7 +50,7 @@ export default function MediaTranscodingWidget({
|
||||
width,
|
||||
height,
|
||||
displayMode,
|
||||
widgetStateRef,
|
||||
widgetRuntimeRef,
|
||||
}: WidgetComponentProps<"mediaTranscoding">) {
|
||||
const isAdvanced = displayMode === "advanced";
|
||||
const queuePageSize = getQueuePageSize(height, isAdvanced);
|
||||
@@ -61,8 +65,11 @@ export default function MediaTranscodingWidget({
|
||||
pageSize: queuePageSize,
|
||||
page: requestPagination.page,
|
||||
};
|
||||
setWidgetRuntimeQueries(widgetStateRef, [{ path: ["widget", "mediaTranscoding", "getDataAsync"], input }]);
|
||||
const { data: transcodingData } = clientApi.widget.mediaTranscoding.getDataAsync.useQuery(input);
|
||||
useWidgetRuntimeQueries(widgetRuntimeRef, [
|
||||
getQueryKey(clientApi.widget.mediaTranscoding.getDataAsync, input, "query"),
|
||||
]);
|
||||
const transcodingQuery = clientApi.widget.mediaTranscoding.getDataAsync.useQuery(input);
|
||||
const transcodingData = getUsableWidgetQueryData(transcodingQuery);
|
||||
|
||||
const [view, setView] = useState<View>(options.defaultView);
|
||||
const t = useI18n("widget.mediaTranscoding");
|
||||
@@ -89,10 +96,17 @@ 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">
|
||||
<Stack gap="xs" h="100%" p="xs" pos="relative">
|
||||
{queryIndicator}
|
||||
<ScrollArea h="100%" style={{ flex: 1 }}>
|
||||
<SimpleGrid cols={width >= 1100 ? 3 : width >= 700 ? 2 : 1} spacing="sm">
|
||||
<AdvancedPanel title={t("tab.workers")} icon={IconCpu2}>
|
||||
@@ -106,8 +120,15 @@ export default function MediaTranscodingWidget({
|
||||
</AdvancedPanel>
|
||||
</SimpleGrid>
|
||||
</ScrollArea>
|
||||
<Group gap="xs" justify="space-between" wrap="nowrap">
|
||||
<Pagination total={queuePageCount} value={queuePage} onChange={handleQueuePageChange} size="xs" />
|
||||
<Group gap="xs" justify={queuePageCount > 1 ? "space-between" : "flex-end"} wrap="nowrap">
|
||||
{queuePageCount > 1 && (
|
||||
<QueuePaginationControls
|
||||
total={queuePageCount}
|
||||
value={queuePage}
|
||||
onChange={handleQueuePageChange}
|
||||
layout={footerLayout}
|
||||
/>
|
||||
)}
|
||||
<HealthCheckStatus statistics={transcodingData.data.statistics} />
|
||||
</Group>
|
||||
</Stack>
|
||||
@@ -115,7 +136,8 @@ export default function MediaTranscodingWidget({
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap={4} h="100%">
|
||||
<Stack gap={4} h="100%" pos="relative">
|
||||
{queryIndicator}
|
||||
{view === "workers" ? (
|
||||
<WorkersPanel workers={transcodingData.data.workers} isTiny={isTiny} />
|
||||
) : view === "queue" ? (
|
||||
@@ -124,7 +146,7 @@ export default function MediaTranscodingWidget({
|
||||
<StatisticsPanel statistics={transcodingData.data.statistics} />
|
||||
)}
|
||||
<Divider />
|
||||
<Group gap="xs" mb={4} ms={4} me={8}>
|
||||
<Group gap="xs" mb={4} ms={4} me={8} wrap="nowrap">
|
||||
<SegmentedControl
|
||||
data={views.map((value) => {
|
||||
const Icon = viewIcons[value];
|
||||
@@ -132,12 +154,12 @@ export default function MediaTranscodingWidget({
|
||||
label: (
|
||||
<Center style={{ gap: 4 }}>
|
||||
<Icon size={12} />
|
||||
{isTiny ? (
|
||||
<VisuallyHidden>{t(`tab.${value}`)}</VisuallyHidden>
|
||||
) : (
|
||||
{footerLayout.showTabLabels ? (
|
||||
<Text span size="xs">
|
||||
{t(`tab.${value}`)}
|
||||
</Text>
|
||||
) : (
|
||||
<VisuallyHidden>{t(`tab.${value}`)}</VisuallyHidden>
|
||||
)}
|
||||
</Center>
|
||||
),
|
||||
@@ -152,26 +174,27 @@ export default function MediaTranscodingWidget({
|
||||
}
|
||||
}}
|
||||
size="xs"
|
||||
style={{ minWidth: 0, flexShrink: footerLayout.showTabLabels ? 1 : 0 }}
|
||||
/>
|
||||
|
||||
<Group gap="xs" ml="auto">
|
||||
{view === "queue" && (
|
||||
<Group gap="xs" ml="auto" wrap="nowrap">
|
||||
{view === "queue" && queuePageCount > 1 && (
|
||||
<>
|
||||
<Pagination.Root total={queuePageCount} value={queuePage} onChange={handleQueuePageChange} size="xs">
|
||||
<Group gap={2} justify="center">
|
||||
{!isTiny && <Pagination.First disabled={queuePage === 1} />}
|
||||
<Pagination.Previous disabled={queuePage === 1} />
|
||||
<Pagination.Next disabled={queuePage === queuePageCount} />
|
||||
{!isTiny && <Pagination.Last disabled={queuePage === queuePageCount} />}
|
||||
</Group>
|
||||
</Pagination.Root>
|
||||
<Text size="xs">
|
||||
{t("currentIndex", {
|
||||
start: String(transcodingData.data.queue.startIndex + 1),
|
||||
end: String(transcodingData.data.queue.endIndex + 1),
|
||||
total: String(transcodingData.data.queue.totalCount),
|
||||
})}
|
||||
</Text>
|
||||
<QueuePaginationControls
|
||||
total={queuePageCount}
|
||||
value={queuePage}
|
||||
onChange={handleQueuePageChange}
|
||||
layout={footerLayout}
|
||||
/>
|
||||
{footerLayout.showPageRange && (
|
||||
<Text size="xs" style={{ whiteSpace: "nowrap" }}>
|
||||
{t("currentIndex", {
|
||||
start: String(transcodingData.data.queue.startIndex + 1),
|
||||
end: String(transcodingData.data.queue.endIndex + 1),
|
||||
total: String(transcodingData.data.queue.totalCount),
|
||||
})}
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -187,6 +210,32 @@ export const getQueuePageSize = (height: number, isAdvanced: boolean): number =>
|
||||
return Math.max(3, Math.min(15, Math.floor((height - 84) / 28)));
|
||||
};
|
||||
|
||||
export const getTranscodingFooterLayout = (width: number, height: number) => ({
|
||||
showTabLabels: width >= 420 && height >= 160,
|
||||
showPageEdges: width >= 360,
|
||||
showPageItems: width >= 600,
|
||||
showPageRange: width >= 760,
|
||||
});
|
||||
|
||||
interface QueuePaginationControlsProps {
|
||||
total: number;
|
||||
value: number;
|
||||
onChange: (page: number) => void;
|
||||
layout: ReturnType<typeof getTranscodingFooterLayout>;
|
||||
}
|
||||
|
||||
const QueuePaginationControls = ({ total, value, onChange, layout }: QueuePaginationControlsProps) => (
|
||||
<Pagination.Root total={total} value={value} onChange={onChange} size="xs">
|
||||
<Group gap={2} justify="center" wrap="nowrap">
|
||||
{layout.showPageEdges && <Pagination.First disabled={value === 1} />}
|
||||
<Pagination.Previous disabled={value === 1} />
|
||||
{layout.showPageItems && <Pagination.Items />}
|
||||
<Pagination.Next disabled={value === total} />
|
||||
{layout.showPageEdges && <Pagination.Last disabled={value === total} />}
|
||||
</Group>
|
||||
</Pagination.Root>
|
||||
);
|
||||
|
||||
interface QueuePaginationState {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { getQueuePageSize, resolveQueuePagination } from "./component";
|
||||
import { getQueuePageSize, getTranscodingFooterLayout, resolveQueuePagination } from "./component";
|
||||
import { toRingProgressSections } from "./panels/statistics.panel";
|
||||
|
||||
describe("media transcoding layout", () => {
|
||||
@@ -14,6 +14,23 @@ describe("media transcoding layout", () => {
|
||||
expect(getQueuePageSize(200, true)).toBe(25);
|
||||
});
|
||||
|
||||
test("reveals footer detail only as space becomes available", () => {
|
||||
expect(getTranscodingFooterLayout(280, 220)).toEqual({
|
||||
showTabLabels: false,
|
||||
showPageEdges: false,
|
||||
showPageItems: false,
|
||||
showPageRange: false,
|
||||
});
|
||||
expect(getTranscodingFooterLayout(600, 220)).toEqual({
|
||||
showTabLabels: true,
|
||||
showPageEdges: true,
|
||||
showPageItems: true,
|
||||
showPageRange: false,
|
||||
});
|
||||
expect(getTranscodingFooterLayout(760, 220).showPageRange).toBe(true);
|
||||
expect(getTranscodingFooterLayout(760, 120).showTabLabels).toBe(false);
|
||||
});
|
||||
|
||||
test("resets the queue when display mode or page size changes", () => {
|
||||
const compact = { page: 7, pageSize: 10, isAdvanced: false };
|
||||
|
||||
|
||||
@@ -14,9 +14,8 @@ export default function MinecraftServerStatusWidget({
|
||||
options,
|
||||
width,
|
||||
height,
|
||||
displayMode,
|
||||
}: WidgetComponentProps<"minecraftServerStatus">) {
|
||||
const { data: result, isPending } = clientApi.widget.minecraft.getServerStatus.useQuery(options);
|
||||
const { data: result, isPending, error } = clientApi.widget.minecraft.getServerStatus.useQuery(options);
|
||||
const t = useI18n();
|
||||
|
||||
if (isPending) {
|
||||
@@ -28,14 +27,16 @@ export default function MinecraftServerStatusWidget({
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
if (error && !result) throw error;
|
||||
if (!result) return <WidgetEmptyState />;
|
||||
const { data } = result;
|
||||
|
||||
const title = options.title.trim().length > 0 ? options.title : options.domain;
|
||||
const isAdvanced = displayMode === "advanced";
|
||||
const isDense = !isAdvanced && (width < 220 || height < 120);
|
||||
const showServerIcon = isAdvanced || (!isDense && height >= 144);
|
||||
const iconSize = Math.max(40, Math.min(isAdvanced ? 128 : 80, width * 0.45, height * 0.45));
|
||||
const isDense = width < 220 || height < 120;
|
||||
const showServerIcon = !isDense && height >= 144;
|
||||
const showMetadata = width >= 260 && height >= 180;
|
||||
const showCapacity = width >= 180 && height >= 140;
|
||||
const iconSize = Math.max(40, Math.min(80, width * 0.45, height * 0.45));
|
||||
const playerPercent = data.online && data.players.max > 0 ? (data.players.online / data.players.max) * 100 : 0;
|
||||
|
||||
return (
|
||||
@@ -44,7 +45,7 @@ export default function MinecraftServerStatusWidget({
|
||||
h="100%"
|
||||
w="100%"
|
||||
direction="column"
|
||||
p={isAdvanced ? "lg" : isDense ? "xs" : "sm"}
|
||||
p={isDense ? "xs" : "sm"}
|
||||
justify="center"
|
||||
align="center"
|
||||
>
|
||||
@@ -63,11 +64,11 @@ export default function MinecraftServerStatusWidget({
|
||||
? t("widget.minecraftServerStatus.status.online")
|
||||
: t("widget.minecraftServerStatus.status.offline")}
|
||||
</VisuallyHidden>
|
||||
<Text size={isAdvanced ? "xl" : "md"} fw="bold" truncate="end">
|
||||
<Text size={showMetadata ? "lg" : "md"} fw="bold" truncate="end">
|
||||
{title}
|
||||
</Text>
|
||||
</Group>
|
||||
{isAdvanced && (
|
||||
{showMetadata && (
|
||||
<Group gap="xs" mt="xs" wrap="wrap" justify="center">
|
||||
<Badge variant="light">{options.domain}</Badge>
|
||||
{options.isBedrockServer && (
|
||||
@@ -97,18 +98,18 @@ export default function MinecraftServerStatusWidget({
|
||||
<IconCube size={iconSize} color="var(--mantine-color-gray-5)" />
|
||||
</Box>
|
||||
))}
|
||||
<Stack gap={4} w={isAdvanced ? "min(100%, 420px)" : "auto"} align="stretch">
|
||||
<Stack gap={4} w={showCapacity ? "min(100%, 420px)" : "auto"} align="stretch">
|
||||
<Group gap={5} c="dimmed" align="center" justify="center">
|
||||
<IconUsersGroup size={isAdvanced ? "1.25rem" : "1rem"} />
|
||||
<Text size={isAdvanced ? "lg" : isDense ? "sm" : "md"}>
|
||||
<IconUsersGroup size={showMetadata ? "1.25rem" : "1rem"} />
|
||||
<Text size={showMetadata ? "lg" : isDense ? "sm" : "md"}>
|
||||
{formatNumber(data.players.online, 1)} / {formatNumber(data.players.max, 1)}
|
||||
</Text>
|
||||
</Group>
|
||||
{isAdvanced && <Progress value={playerPercent} color={playerPercent >= 90 ? "orange" : "teal"} />}
|
||||
{showCapacity && <Progress value={playerPercent} color={playerPercent >= 90 ? "orange" : "teal"} />}
|
||||
</Stack>
|
||||
</>
|
||||
)}
|
||||
{isAdvanced && !data.online && (
|
||||
{showMetadata && !data.online && (
|
||||
<Text mt="md" c="dimmed" size="sm">
|
||||
{options.domain}
|
||||
</Text>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { createWidgetDefinition, widgetQueryInputMatches } from "../../definitio
|
||||
import { optionsBuilder } from "../../options";
|
||||
|
||||
export const { componentLoader, definition } = createWidgetDefinition("minecraftServerStatus", {
|
||||
supportsAdvancedFocus: false,
|
||||
icon: IconBrandMinecraft,
|
||||
queryKey: [["widget", "minecraft", "getServerStatus"]],
|
||||
queryMatcher: ({ input }, scope) =>
|
||||
|
||||
@@ -11,6 +11,7 @@ import { clientApi } from "@homarr/api/client";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { WidgetComponentProps } from "../../definition";
|
||||
import { getNetworkControllerStatusLayout } from "./layout";
|
||||
import { WifiVariant } from "./variants/wifi-variant";
|
||||
import { WiredVariant } from "./variants/wired-variant";
|
||||
|
||||
@@ -36,12 +37,11 @@ export default function NetworkControllerNetworkStatusWidget({
|
||||
const tCommon = useScopedI18n("common");
|
||||
|
||||
const data = useMemo(() => (summaries ?? []).flatMap(({ summary }) => summary), [summaries]);
|
||||
const isAdvanced = displayMode === "advanced";
|
||||
const countWifiGuests = data.reduce((sum, summary) => sum + summary.wifi.guests, 0);
|
||||
const countWifiUsers = data.reduce((sum, summary) => sum + summary.wifi.users, 0);
|
||||
const countLanGuests = data.reduce((sum, summary) => sum + summary.lan.guests, 0);
|
||||
const countLanUsers = data.reduce((sum, summary) => sum + summary.lan.users, 0);
|
||||
const useHorizontalStats = !isAdvanced && height < 150 && width >= 200;
|
||||
const layout = getNetworkControllerStatusLayout({ width, height, displayMode, content: options.content });
|
||||
|
||||
if (error && summaries === undefined) throw error;
|
||||
|
||||
@@ -56,25 +56,25 @@ export default function NetworkControllerNetworkStatusWidget({
|
||||
}
|
||||
|
||||
return (
|
||||
<Box p={isAdvanced ? "md" : height < 120 ? "xs" : "sm"} h="100%">
|
||||
<SimpleGrid cols={isAdvanced && width >= 560 ? 2 : 1} h="100%" spacing="sm">
|
||||
{(isAdvanced || options.content === "wifi") && (
|
||||
<Card p={isAdvanced ? "md" : 0} withBorder={isAdvanced}>
|
||||
<Box p={layout.padding} h="100%">
|
||||
<SimpleGrid cols={layout.columns} h="100%" spacing="sm">
|
||||
{layout.showWifi && (
|
||||
<Card p={layout.cardPadding} withBorder={layout.withBorder}>
|
||||
<WifiVariant
|
||||
countGuests={countWifiGuests}
|
||||
countUsers={countWifiUsers}
|
||||
compact={!isAdvanced}
|
||||
horizontal={useHorizontalStats}
|
||||
compact={layout.compact}
|
||||
horizontal={layout.horizontalStats}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
{(isAdvanced || options.content === "wired") && (
|
||||
<Card p={isAdvanced ? "md" : 0} withBorder={isAdvanced}>
|
||||
{layout.showWired && (
|
||||
<Card p={layout.cardPadding} withBorder={layout.withBorder}>
|
||||
<WiredVariant
|
||||
countGuests={countLanGuests}
|
||||
countUsers={countLanUsers}
|
||||
compact={!isAdvanced}
|
||||
horizontal={useHorizontalStats}
|
||||
compact={layout.compact}
|
||||
horizontal={layout.horizontalStats}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { getNetworkControllerStatusLayout } from "./layout";
|
||||
|
||||
describe("network controller status layout", () => {
|
||||
test.each([
|
||||
{
|
||||
name: "minimum compact",
|
||||
input: { width: 160, height: 90, displayMode: "compact" as const, content: "wifi" as const },
|
||||
expected: { padding: "xs", columns: 1, showWifi: true, showWired: false, horizontalStats: false },
|
||||
},
|
||||
{
|
||||
name: "short compact",
|
||||
input: { width: 200, height: 149, displayMode: "compact" as const, content: "wired" as const },
|
||||
expected: { padding: "sm", columns: 1, showWifi: false, showWired: true, horizontalStats: true },
|
||||
},
|
||||
{
|
||||
name: "typical compact",
|
||||
input: { width: 320, height: 220, displayMode: "compact" as const, content: "wifi" as const },
|
||||
expected: { padding: "sm", columns: 1, showWifi: true, showWired: false, horizontalStats: false },
|
||||
},
|
||||
{
|
||||
name: "advanced below wide boundary",
|
||||
input: { width: 559, height: 320, displayMode: "advanced" as const, content: "wifi" as const },
|
||||
expected: { padding: "md", columns: 1, showWifi: true, showWired: true, horizontalStats: false },
|
||||
},
|
||||
{
|
||||
name: "wide advanced",
|
||||
input: { width: 560, height: 320, displayMode: "advanced" as const, content: "wired" as const },
|
||||
expected: { padding: "md", columns: 2, showWifi: true, showWired: true, horizontalStats: false },
|
||||
},
|
||||
{
|
||||
name: "advanced at 200 percent zoom",
|
||||
input: { width: 280, height: 320, displayMode: "advanced" as const, content: "wired" as const },
|
||||
expected: { padding: "md", columns: 1, showWifi: true, showWired: true, horizontalStats: false },
|
||||
},
|
||||
])("selects the $name tier", ({ input, expected }) => {
|
||||
expect(getNetworkControllerStatusLayout(input)).toMatchObject(expected);
|
||||
});
|
||||
|
||||
test("keeps breakpoint boundaries explicit", () => {
|
||||
expect(
|
||||
getNetworkControllerStatusLayout({ width: 200, height: 150, displayMode: "compact", content: "wifi" }),
|
||||
).toMatchObject({ padding: "sm", horizontalStats: false });
|
||||
expect(
|
||||
getNetworkControllerStatusLayout({ width: 199, height: 119, displayMode: "compact", content: "wifi" }),
|
||||
).toMatchObject({ padding: "xs", horizontalStats: false });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { WidgetComponentProps } from "../../definition";
|
||||
|
||||
type NetworkStatusContent = WidgetComponentProps<"networkControllerStatus">["options"]["content"];
|
||||
type NetworkStatusDisplayMode = WidgetComponentProps<"networkControllerStatus">["displayMode"];
|
||||
|
||||
interface NetworkControllerStatusLayoutInput {
|
||||
width: number;
|
||||
height: number;
|
||||
displayMode: NetworkStatusDisplayMode;
|
||||
content: NetworkStatusContent;
|
||||
}
|
||||
|
||||
export interface NetworkControllerStatusLayout {
|
||||
padding: "xs" | "sm" | "md";
|
||||
columns: 1 | 2;
|
||||
showWifi: boolean;
|
||||
showWired: boolean;
|
||||
cardPadding: 0 | "md";
|
||||
withBorder: boolean;
|
||||
compact: boolean;
|
||||
horizontalStats: boolean;
|
||||
}
|
||||
|
||||
export const getNetworkControllerStatusLayout = ({
|
||||
width,
|
||||
height,
|
||||
displayMode,
|
||||
content,
|
||||
}: NetworkControllerStatusLayoutInput): NetworkControllerStatusLayout => {
|
||||
const isAdvanced = displayMode === "advanced";
|
||||
|
||||
return {
|
||||
padding: isAdvanced ? "md" : height < 120 ? "xs" : "sm",
|
||||
columns: isAdvanced && width >= 560 ? 2 : 1,
|
||||
showWifi: isAdvanced || content === "wifi",
|
||||
showWired: isAdvanced || content === "wired",
|
||||
cardPadding: isAdvanced ? "md" : 0,
|
||||
withBorder: isAdvanced,
|
||||
compact: !isAdvanced,
|
||||
horizontalStats: !isAdvanced && height < 150 && width >= 200,
|
||||
};
|
||||
};
|
||||
@@ -11,6 +11,8 @@ import { clientApi } from "@homarr/api/client";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { WidgetComponentProps } from "../../definition";
|
||||
import { getBinaryStatusKey } from "../../common/locale";
|
||||
import { getUsableWidgetQueryData } from "../../common/query-state";
|
||||
|
||||
dayjs.extend(objectSupport);
|
||||
dayjs.extend(relativeTime);
|
||||
@@ -20,17 +22,23 @@ export default function NetworkControllerSummaryWidget({
|
||||
integrationIds,
|
||||
width,
|
||||
height,
|
||||
displayMode,
|
||||
}: WidgetComponentProps<"networkControllerSummary">) {
|
||||
const { data: summaries = [], isPending } = clientApi.widget.networkController.summary.useQuery({
|
||||
const summaryQuery = clientApi.widget.networkController.summary.useQuery({
|
||||
integrationIds,
|
||||
});
|
||||
const summaries = getUsableWidgetQueryData(summaryQuery) ?? [];
|
||||
const { isPending } = summaryQuery;
|
||||
|
||||
const t = useI18n();
|
||||
const statusLabels = {
|
||||
enabled: t("widget.dnsHoleSummary.status.enabled"),
|
||||
disabled: t("widget.dnsHoleSummary.status.disabled"),
|
||||
unknown: t("widget.dnsHoleSummary.status.unknown"),
|
||||
};
|
||||
|
||||
const isAdvanced = displayMode === "advanced";
|
||||
const isDense = !isAdvanced && height < 160;
|
||||
const columns = isAdvanced && width >= 720 ? 2 : 1;
|
||||
const isDense = height < 160;
|
||||
const showDetails = height >= 180 && width >= 240;
|
||||
const columns = width >= 720 ? 2 : 1;
|
||||
|
||||
if (isPending || summaries.length === 0) {
|
||||
return (
|
||||
@@ -43,12 +51,12 @@ export default function NetworkControllerSummaryWidget({
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollArea h="100%" p={isAdvanced ? "md" : isDense ? "xs" : "sm"}>
|
||||
<ScrollArea h="100%" p={isDense ? "xs" : "sm"}>
|
||||
<SimpleGrid cols={columns} spacing="sm">
|
||||
{summaries.map(({ integration, summary }) => (
|
||||
<Card key={integration.id} p={isAdvanced ? "md" : "xs"} withBorder={summaries.length > 1 || isAdvanced}>
|
||||
<Card key={integration.id} p={isDense ? "xs" : "sm"} withBorder={summaries.length > 1}>
|
||||
<Stack gap="xs">
|
||||
{(isAdvanced || summaries.length > 1) && (
|
||||
{summaries.length > 1 && (
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Text fw={600} size="sm" truncate="end">
|
||||
{integration.name}
|
||||
@@ -59,24 +67,66 @@ export default function NetworkControllerSummaryWidget({
|
||||
</Group>
|
||||
)}
|
||||
<List spacing={isDense ? 2 : "xs"} center size={isDense ? "sm" : undefined}>
|
||||
<List.Item icon={<StatusIcon status={summary.wanStatus} size={isDense ? 16 : 20} />}>WAN</List.Item>
|
||||
<List.Item icon={<StatusIcon status={summary.www.status} size={isDense ? 16 : 20} />}>
|
||||
<List.Item
|
||||
icon={
|
||||
<StatusIcon
|
||||
status={summary.wanStatus}
|
||||
label={statusLabels[getBinaryStatusKey(summary.wanStatus)]}
|
||||
size={isDense ? 16 : 20}
|
||||
/>
|
||||
}
|
||||
>
|
||||
WAN
|
||||
</List.Item>
|
||||
<List.Item
|
||||
icon={
|
||||
<StatusIcon
|
||||
status={summary.www.status}
|
||||
label={statusLabels[getBinaryStatusKey(summary.www.status)]}
|
||||
size={isDense ? 16 : 20}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Text>
|
||||
WWW
|
||||
<Text c="dimmed" size="sm" ms="xs" span>
|
||||
{summary.www.latency}ms{isAdvanced ? ` · ${summary.www.ping}ms` : ""}
|
||||
{summary.www.latency}ms{showDetails ? ` · ${summary.www.ping}ms` : ""}
|
||||
</Text>
|
||||
</Text>
|
||||
</List.Item>
|
||||
<List.Item icon={<StatusIcon status={summary.wifi.status} size={isDense ? 16 : 20} />}>
|
||||
Wi-Fi{isAdvanced ? ` · ${summary.wifi.users + summary.wifi.guests}` : ""}
|
||||
<List.Item
|
||||
icon={
|
||||
<StatusIcon
|
||||
status={summary.wifi.status}
|
||||
label={statusLabels[getBinaryStatusKey(summary.wifi.status)]}
|
||||
size={isDense ? 16 : 20}
|
||||
/>
|
||||
}
|
||||
>
|
||||
Wi-Fi{showDetails ? ` · ${summary.wifi.users + summary.wifi.guests}` : ""}
|
||||
</List.Item>
|
||||
{isAdvanced && (
|
||||
<List.Item icon={<StatusIcon status={summary.lan.status} size={20} />}>
|
||||
{showDetails && (
|
||||
<List.Item
|
||||
icon={
|
||||
<StatusIcon
|
||||
status={summary.lan.status}
|
||||
label={statusLabels[getBinaryStatusKey(summary.lan.status)]}
|
||||
size={20}
|
||||
/>
|
||||
}
|
||||
>
|
||||
LAN · {summary.lan.users + summary.lan.guests}
|
||||
</List.Item>
|
||||
)}
|
||||
<List.Item icon={<StatusIcon status={summary.vpn.status} size={isDense ? 16 : 20} />}>
|
||||
<List.Item
|
||||
icon={
|
||||
<StatusIcon
|
||||
status={summary.vpn.status}
|
||||
label={statusLabels[getBinaryStatusKey(summary.vpn.status)]}
|
||||
size={isDense ? 16 : 20}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Text>
|
||||
VPN
|
||||
<Text c="dimmed" size="sm" ms="xs" span>
|
||||
@@ -93,13 +143,13 @@ export default function NetworkControllerSummaryWidget({
|
||||
);
|
||||
}
|
||||
|
||||
const StatusIcon = ({ status, size }: { status?: "enabled" | "disabled"; size: number }) => {
|
||||
const StatusIcon = ({ status, label, size }: { status?: "enabled" | "disabled"; label: string; size: number }) => {
|
||||
const mantineTheme = useMantineTheme();
|
||||
if (status === "enabled") {
|
||||
return <IconCircleCheckFilled aria-label={status} size={size} color={mantineTheme.colors.green[6]} />;
|
||||
return <IconCircleCheckFilled aria-label={label} size={size} color={mantineTheme.colors.green[6]} />;
|
||||
}
|
||||
if (status === "disabled") {
|
||||
return <IconCircleXFilled aria-label={status} size={size} color={mantineTheme.colors.red[6]} />;
|
||||
return <IconCircleXFilled aria-label={label} size={size} color={mantineTheme.colors.red[6]} />;
|
||||
}
|
||||
return <IconCircleXFilled aria-label="unknown" size={size} color={mantineTheme.colors.gray[6]} />;
|
||||
return <IconCircleXFilled aria-label={label} size={size} color={mantineTheme.colors.gray[6]} />;
|
||||
};
|
||||
@@ -6,6 +6,7 @@ import { createWidgetDefinition } from "../../definition";
|
||||
import { optionsBuilder } from "../../options";
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("networkControllerSummary", {
|
||||
supportsAdvancedFocus: false,
|
||||
icon: IconTopologyFull,
|
||||
queryKey: [["widget", "networkController"]],
|
||||
refetchInterval: null,
|
||||
|
||||
@@ -5,6 +5,7 @@ import { optionsBuilder } from "../options";
|
||||
import { defaultContent } from "./default-content";
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("notebook", {
|
||||
supportsAdvancedFocus: false,
|
||||
icon: IconNotes,
|
||||
createOptions() {
|
||||
return optionsBuilder.from(
|
||||
|
||||
@@ -97,7 +97,6 @@ export function Notebook({
|
||||
isEditMode,
|
||||
boardId,
|
||||
itemId,
|
||||
displayMode,
|
||||
height,
|
||||
}: WidgetComponentProps<"notebook">) {
|
||||
const [content, setContent] = useState(options.content);
|
||||
@@ -146,8 +145,8 @@ export function Notebook({
|
||||
previousContentRef.current = contentUpdate;
|
||||
setOptions({ newOptions: { content: contentUpdate } });
|
||||
return true;
|
||||
} catch (error) {
|
||||
setSaveError(error instanceof Error ? error.message : t("widget.notebook.saveFailed"));
|
||||
} catch {
|
||||
setSaveError(t("widget.notebook.saveFailed"));
|
||||
return false;
|
||||
} finally {
|
||||
savingRef.current = false;
|
||||
@@ -366,7 +365,7 @@ export function Notebook({
|
||||
},
|
||||
content: {
|
||||
backgroundColor: "transparent",
|
||||
padding: height < 120 && displayMode !== "advanced" ? "0.25rem" : "0.5rem",
|
||||
padding: height < 120 ? "0.25rem" : "0.5rem",
|
||||
height: "100%",
|
||||
},
|
||||
typographyStylesProvider: {
|
||||
@@ -376,8 +375,9 @@ export function Notebook({
|
||||
>
|
||||
<RichTextEditor.Toolbar
|
||||
style={{
|
||||
display:
|
||||
isEditing && !isSaving && options.showToolbar === true && displayMode === "advanced" ? "flex" : "none",
|
||||
display: isEditing && !isSaving && options.showToolbar === true ? "flex" : "none",
|
||||
maxHeight: height < 180 ? "4.5rem" : "10rem",
|
||||
overflowY: "auto",
|
||||
}}
|
||||
>
|
||||
<RichTextEditor.ControlsGroup>
|
||||
@@ -505,8 +505,8 @@ export function Notebook({
|
||||
<RichTextEditor.Content />
|
||||
</ScrollArea>
|
||||
</RichTextEditor>
|
||||
{displayMode === "advanced" && (
|
||||
<Group pos="absolute" bottom={4} right={8} gap="xs">
|
||||
{(saveError || height >= 180) && (
|
||||
<Group pos="absolute" bottom={4} right={8} gap="xs" style={{ pointerEvents: saveError ? undefined : "none" }}>
|
||||
{saveError && (
|
||||
<Tooltip label={saveError} multiline>
|
||||
<Text
|
||||
@@ -521,16 +521,18 @@ export function Notebook({
|
||||
</Text>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("widget.notebook.documentStats", documentStats)}
|
||||
</Text>
|
||||
{height >= 180 && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("widget.notebook.documentStats", documentStats)}
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
)}
|
||||
{canChange && (
|
||||
<Stack pos="absolute" top={7} right={7} gap={7} style={{ zIndex: 1 }}>
|
||||
<ActionIcon
|
||||
className={`homarr-notebook-action ${actionTargetClasses.root}`}
|
||||
data-visible={isEditing || displayMode === "advanced" || undefined}
|
||||
data-visible={isEditing || undefined}
|
||||
title={isEditing ? t("common.action.save") : t("common.action.edit")}
|
||||
aria-label={isEditing ? t("common.action.save") : t("common.action.edit")}
|
||||
color={primaryColor}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { Avatar, Badge, Card, Flex, Group, ScrollArea, SimpleGrid, Stack, Text, Tooltip } from "@mantine/core";
|
||||
import { Avatar, Badge, Card, Flex, Group, ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core";
|
||||
import { IconClock } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
@@ -10,18 +10,21 @@ import { useTimeAgo } from "@homarr/common";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url";
|
||||
import { getUsableWidgetQueryData } from "../common/query-state";
|
||||
|
||||
export default function NotificationsWidget({
|
||||
options,
|
||||
integrationIds,
|
||||
width,
|
||||
height,
|
||||
displayMode,
|
||||
}: WidgetComponentProps<"notifications">) {
|
||||
const { data: notificationIntegrations = [], isPending } = clientApi.widget.notifications.getNotifications.useQuery({
|
||||
const notificationsQuery = clientApi.widget.notifications.getNotifications.useQuery({
|
||||
...options,
|
||||
integrationIds,
|
||||
});
|
||||
const notificationIntegrations = getUsableWidgetQueryData(notificationsQuery) ?? [];
|
||||
const { isPending } = notificationsQuery;
|
||||
|
||||
const t = useI18n();
|
||||
|
||||
@@ -43,22 +46,21 @@ export default function NotificationsWidget({
|
||||
const failedIntegrations = notificationIntegrations.filter(
|
||||
(integration): integration is typeof integration & { error: string } => Boolean(integration.error),
|
||||
);
|
||||
const isAdvanced = displayMode === "advanced";
|
||||
const isDense = !isAdvanced && (width < 280 || height < 180);
|
||||
const bodyLineClamp = isAdvanced ? 12 : height < 112 ? 1 : isDense ? 2 : 4;
|
||||
const columns = isAdvanced && width >= 720 ? 2 : 1;
|
||||
const isDense = width < 280 || height < 180;
|
||||
const isRoomy = width >= 360 && height >= 220;
|
||||
const bodyLineClamp = height < 112 ? 1 : isDense ? 2 : height >= 300 ? 8 : 4;
|
||||
const columns = width >= 720 ? 2 : 1;
|
||||
const spacing = isRoomy ? "sm" : "xs";
|
||||
|
||||
return (
|
||||
<ScrollArea className="scroll-area-w100" w="100%" h="100%" p={isAdvanced ? "md" : "xs"}>
|
||||
<ScrollArea className="scroll-area-w100" w="100%" h="100%" p="xs">
|
||||
<Stack w="100%" gap="xs">
|
||||
{failedIntegrations.length > 0 && (
|
||||
<Group gap={4} wrap="wrap">
|
||||
{failedIntegrations.map((integration) => (
|
||||
<Tooltip key={integration.integration.id} label={integration.error} multiline maw={360}>
|
||||
<Badge color="red" variant="light" size="xs">
|
||||
{integration.integration.name}: {t("common.error")}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
<Badge key={integration.integration.id} color="red" variant="light" size="xs">
|
||||
{integration.integration.name}: {t("common.error")}
|
||||
</Badge>
|
||||
))}
|
||||
</Group>
|
||||
)}
|
||||
@@ -69,60 +71,64 @@ export default function NotificationsWidget({
|
||||
</Text>
|
||||
</Flex>
|
||||
) : sortedNotifications.length > 0 ? (
|
||||
<SimpleGrid cols={columns} spacing={isAdvanced ? "md" : "xs"} verticalSpacing={isAdvanced ? "md" : "xs"}>
|
||||
{sortedNotifications.map((notification) => (
|
||||
<Card
|
||||
key={notification.compositeKey}
|
||||
component={notification.href ? "a" : "div"}
|
||||
href={notification.href}
|
||||
target={notification.href ? "_blank" : undefined}
|
||||
rel={notification.href ? "noopener noreferrer" : undefined}
|
||||
radius={board.itemRadius}
|
||||
w="100%"
|
||||
p={isAdvanced ? "md" : isDense ? 6 : "xs"}
|
||||
bg={isAdvanced ? undefined : "transparent"}
|
||||
style={{
|
||||
color: "inherit",
|
||||
textDecoration: "none",
|
||||
borderBottom: isAdvanced
|
||||
? undefined
|
||||
: "1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4))",
|
||||
}}
|
||||
>
|
||||
<Flex gap={isDense ? "xs" : "sm"} align="flex-start" w="100%">
|
||||
{!options.hideLogos && notification.source?.iconUrl && (
|
||||
<Avatar
|
||||
src={notification.source.iconUrl}
|
||||
alt={notification.source.name}
|
||||
size={isDense ? "xs" : "sm"}
|
||||
radius={board.itemRadius}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Flex gap={isAdvanced ? "sm" : isDense ? 4 : 6} direction="column" w="100%" miw={0}>
|
||||
{notification.title && (
|
||||
<Text fz={isAdvanced ? "md" : "sm"} fw={600} lh={1.25} lineClamp={isDense ? 1 : 2}>
|
||||
{notification.title}
|
||||
</Text>
|
||||
<SimpleGrid cols={columns} spacing={spacing} verticalSpacing={spacing}>
|
||||
{sortedNotifications.map((notification) => {
|
||||
const href = getSafeApplicationUrl(notification.href);
|
||||
return (
|
||||
<Card
|
||||
key={notification.compositeKey}
|
||||
component={href ? "a" : "div"}
|
||||
href={href}
|
||||
target={href ? "_blank" : undefined}
|
||||
rel={href ? SAFE_NEW_TAB_REL : undefined}
|
||||
radius={board.itemRadius}
|
||||
w="100%"
|
||||
p={isRoomy ? "sm" : isDense ? 6 : "xs"}
|
||||
bg={columns > 1 ? undefined : "transparent"}
|
||||
style={{
|
||||
color: "inherit",
|
||||
textDecoration: "none",
|
||||
borderBottom:
|
||||
columns > 1
|
||||
? undefined
|
||||
: "1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4))",
|
||||
}}
|
||||
>
|
||||
<Flex gap={isDense ? "xs" : "sm"} align="flex-start" w="100%">
|
||||
{!options.hideLogos && notification.source?.iconUrl && (
|
||||
<Avatar
|
||||
src={notification.source.iconUrl}
|
||||
alt={notification.source.name}
|
||||
size={isDense ? "xs" : "sm"}
|
||||
radius={board.itemRadius}
|
||||
/>
|
||||
)}
|
||||
<Text
|
||||
c="dimmed"
|
||||
size={isDense ? "xs" : "sm"}
|
||||
lineClamp={bodyLineClamp}
|
||||
style={{ whiteSpace: "pre-line" }}
|
||||
>
|
||||
{notification.body}
|
||||
</Text>
|
||||
|
||||
<InfoDisplay
|
||||
date={notification.time}
|
||||
source={isAdvanced ? (notification.source?.name ?? notification.integrationName) : undefined}
|
||||
dense={isDense}
|
||||
/>
|
||||
<Flex gap={isRoomy ? "sm" : isDense ? 4 : 6} direction="column" w="100%" miw={0}>
|
||||
{notification.title && (
|
||||
<Text fz={isRoomy ? "md" : "sm"} fw={600} lh={1.25} lineClamp={isDense ? 1 : 2}>
|
||||
{notification.title}
|
||||
</Text>
|
||||
)}
|
||||
<Text
|
||||
c="dimmed"
|
||||
size={isDense ? "xs" : "sm"}
|
||||
lineClamp={bodyLineClamp}
|
||||
style={{ whiteSpace: "pre-line" }}
|
||||
>
|
||||
{notification.body}
|
||||
</Text>
|
||||
|
||||
<InfoDisplay
|
||||
date={notification.time}
|
||||
source={isRoomy ? (notification.source?.name ?? notification.integrationName) : undefined}
|
||||
dense={isDense}
|
||||
/>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Card>
|
||||
))}
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</SimpleGrid>
|
||||
) : (
|
||||
<Flex justify="center" align="center" mih={96} p="sm">
|
||||
|
||||
@@ -75,7 +75,6 @@ export default function PaperlessNgxWidget({
|
||||
options,
|
||||
width,
|
||||
height,
|
||||
displayMode = "compact",
|
||||
}: WidgetComponentProps<"paperlessNgx">) {
|
||||
const t = useScopedI18n("widget.paperlessNgx");
|
||||
const tCommon = useScopedI18n("common");
|
||||
@@ -116,16 +115,15 @@ export default function PaperlessNgxWidget({
|
||||
.map(([, statKey]) => statKey)
|
||||
.filter((statKey) => !(showHero && gridHiddenWhenHeroShown.has(statKey)));
|
||||
|
||||
const advanced = displayMode === "advanced";
|
||||
const gridCols = advanced ? Math.max(1, Math.min(5, visibleStatKeys.length)) : getGridCols(width);
|
||||
const ringSize = advanced ? 112 : getRingSize(width);
|
||||
const gridCols = Math.max(1, Math.min(visibleStatKeys.length, getGridCols(width, height)));
|
||||
const ringSize = getRingSize(Math.min(width, height * 2));
|
||||
const iconSize = getIconSize(width);
|
||||
const ringLabelSize = getRingLabelSize(ringSize);
|
||||
const hasContent = showHero || visibleStatKeys.length > 0;
|
||||
|
||||
const heroLayoutClass =
|
||||
heroLayoutBySecondaryStats[String(visibleStatKeys.length > 0) as keyof typeof heroLayoutBySecondaryStats];
|
||||
const showInboxRing = options.showInboxRing && (advanced || height >= 120);
|
||||
const showInboxRing = options.showInboxRing && height >= 120;
|
||||
const heroRingClass = heroVariantByRing[String(showInboxRing) as keyof typeof heroVariantByRing];
|
||||
|
||||
const visibleHeroParts = Object.entries(heroPartVisibility).filter(
|
||||
@@ -163,11 +161,7 @@ export default function PaperlessNgxWidget({
|
||||
} as const;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classes.root}
|
||||
data-display-mode={displayMode}
|
||||
style={advanced ? { padding: 16, gap: 16 } : undefined}
|
||||
>
|
||||
<div className={classes.root}>
|
||||
{showHero && (
|
||||
<div className={`${classes.hero} ${heroLayoutClass} ${heroRingClass}`}>
|
||||
{visibleHeroParts.map(([partKey]) => (
|
||||
@@ -210,9 +204,9 @@ function computeInboxProgress(total: number, inbox: number): number {
|
||||
return Math.round((inbox / total) * 100);
|
||||
}
|
||||
|
||||
function getGridCols(width: number): number {
|
||||
function getGridCols(width: number, height: number): number {
|
||||
const match = gridColsByWidth.find(({ minWidth }) => width >= minWidth);
|
||||
return match?.cols ?? 1;
|
||||
return Math.min(match?.cols ?? 1, height < 140 ? 2 : 5);
|
||||
}
|
||||
|
||||
function getRingSize(width: number): number {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { createWidgetDefinition } from "../definition";
|
||||
import { optionsBuilder } from "../options";
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("paperlessNgx", {
|
||||
supportsAdvancedFocus: false,
|
||||
icon: IconFileText,
|
||||
refetchInterval: null,
|
||||
supportedIntegrations: ["paperlessNgx"],
|
||||
|
||||
@@ -75,17 +75,15 @@ const getLayoutMode = (width: number, height: number): LayoutMode => {
|
||||
return "comfortable";
|
||||
};
|
||||
|
||||
export default function PatchMonWidget({
|
||||
integrationIds,
|
||||
options,
|
||||
width,
|
||||
height,
|
||||
displayMode = "compact",
|
||||
}: WidgetComponentProps<"patchmon">) {
|
||||
export default function PatchMonWidget({ integrationIds, options, width, height }: WidgetComponentProps<"patchmon">) {
|
||||
const t = useScopedI18n("widget.patchmon");
|
||||
const integrationId = integrationIds[0] ?? "";
|
||||
const { data: stats } = clientApi.widget.patchmon.getStats.useQuery({ integrationId }, { staleTime: 60 * 1000 });
|
||||
const { data: stats, error } = clientApi.widget.patchmon.getStats.useQuery(
|
||||
{ integrationId },
|
||||
{ staleTime: 60 * 1000 },
|
||||
);
|
||||
|
||||
if (error && !stats) throw error;
|
||||
if (!stats) return <WidgetEmptyState />;
|
||||
|
||||
const statValues: Record<PatchMonStatKey, number> = {
|
||||
@@ -104,20 +102,18 @@ export default function PatchMonWidget({
|
||||
.map(([, statKey]) => statKey);
|
||||
|
||||
const colorContext = { totalHosts: stats.totalHosts };
|
||||
const advanced = displayMode === "advanced";
|
||||
const layout = advanced ? "spacious" : getLayoutMode(width, height);
|
||||
const isLandscape = !advanced && width / height > 2.5;
|
||||
const layout = getLayoutMode(width, height);
|
||||
const isLandscape = width / height > 2.5;
|
||||
const isTinySquare = width < 160 && height < 160;
|
||||
const isShortLandscape = width >= 160 && height < 160;
|
||||
const isCompactSurface = !advanced && (isTinySquare || isShortLandscape);
|
||||
const isCompactSurface = isTinySquare || isShortLandscape;
|
||||
const isNarrow = width < 180;
|
||||
const osDisplayMode = options.osDisplayMode as "bars" | "donut";
|
||||
const showOsData = options.showOsDistribution && stats.osDistribution.length > 0;
|
||||
|
||||
const showIcons = layout !== "mini";
|
||||
const showLabels = layout !== "mini";
|
||||
const showHero =
|
||||
options.showComplianceHero && (advanced || !isLandscape || isCompactSurface) && width >= 90 && height >= 90;
|
||||
const showHero = options.showComplianceHero && (!isLandscape || isCompactSurface) && width >= 90 && height >= 90;
|
||||
const showHeroText = showHero && shouldShowComplianceHeroText(width);
|
||||
const showHeroRingOnly = showHero && !showHeroText;
|
||||
const compactPrimaryContent = isCompactSurface
|
||||
@@ -128,10 +124,10 @@ export default function PatchMonWidget({
|
||||
})
|
||||
: null;
|
||||
const showStats = visibleStatKeys.length > 0 && !isCompactSurface;
|
||||
const showOsSection = showOsData && !isCompactSurface && (advanced || height > 200);
|
||||
const showOsSection = showOsData && !isCompactSurface && height > 200;
|
||||
const showCompactFooter = isCompactSurface && height >= 110;
|
||||
const showFooter = advanced || (isCompactSurface ? showCompactFooter : !isLandscape && height > 220);
|
||||
const showOsLegend = osDisplayMode === "donut" && (advanced || (width >= 380 && height >= 300));
|
||||
const showFooter = isCompactSurface ? showCompactFooter : !isLandscape && height > 220;
|
||||
const showOsLegend = osDisplayMode === "donut" && width >= 380 && height >= 300;
|
||||
|
||||
const hasContent = compactPrimaryContent !== null || showStats || showOsSection || showHero;
|
||||
|
||||
@@ -280,10 +276,10 @@ export default function PatchMonWidget({
|
||||
</div>
|
||||
);
|
||||
|
||||
const needsScroll = !advanced && (layout === "mini" || layout === "compact");
|
||||
const needsScroll = layout === "mini" || layout === "compact";
|
||||
|
||||
return (
|
||||
<div className={classes.root} data-display-mode={displayMode}>
|
||||
<div className={classes.root}>
|
||||
{showHero && (
|
||||
<ComplianceHero
|
||||
compliancePercent={compliancePercent}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { PATCHMON_STAT_COLOR_PRESETS } from "./stat-colors";
|
||||
import { patchmonOptionsSuperRefine } from "./threshold-validation";
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("patchmon", {
|
||||
supportsAdvancedFocus: false,
|
||||
icon: IconShieldCheck,
|
||||
supportedIntegrations: ["patchmon"],
|
||||
integrationsRequired: true,
|
||||
|
||||
@@ -38,6 +38,7 @@ import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { MaskedOrNormalImage } from "@homarr/ui";
|
||||
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url";
|
||||
import classes from "./component.module.scss";
|
||||
import type { ReleasesRepository, ReleasesRepositoryResponse } from "./releases-repository";
|
||||
import { getReleasesQueryStaleTimeMs } from "./query-options";
|
||||
@@ -48,7 +49,7 @@ const formatRelativeDate = (value: string): string => {
|
||||
return isMonths ? value.toUpperCase() : isOtherUnits ? value.toLowerCase() : value;
|
||||
};
|
||||
|
||||
export default function ReleasesWidget({ options, itemId, width, displayMode }: WidgetComponentProps<"releases">) {
|
||||
export default function ReleasesWidget({ options, itemId, width }: WidgetComponentProps<"releases">) {
|
||||
const t = useScopedI18n("widget.releases");
|
||||
const now = useNow();
|
||||
const formatter = useFormatter();
|
||||
@@ -59,7 +60,7 @@ export default function ReleasesWidget({ options, itemId, width, displayMode }:
|
||||
key: "releases-viewed-versions",
|
||||
defaultValue: {},
|
||||
});
|
||||
const isAdvanced = displayMode === "advanced";
|
||||
const columns = Math.max(1, Math.floor(width / 420));
|
||||
|
||||
const relativeDateOptions = useMemo(
|
||||
() => ({
|
||||
@@ -187,11 +188,7 @@ export default function ReleasesWidget({ options, itemId, width, displayMode }:
|
||||
|
||||
return (
|
||||
<ScrollArea h="100%" className="releases">
|
||||
<SimpleGrid
|
||||
cols={isAdvanced ? Math.max(1, Math.floor(width / 420)) : 1}
|
||||
spacing={isAdvanced ? "sm" : 0}
|
||||
p={isAdvanced ? "xs" : 0}
|
||||
>
|
||||
<SimpleGrid cols={columns} spacing={columns > 1 ? "sm" : 0} p={columns > 1 ? "xs" : 0}>
|
||||
{repositories.map((repository: ReleasesRepositoryResponse) => {
|
||||
const isActive = expandedRepositoryId === repository.id;
|
||||
const hasError = repository.error !== undefined;
|
||||
@@ -207,7 +204,7 @@ export default function ReleasesWidget({ options, itemId, width, displayMode }:
|
||||
)}
|
||||
gap={0}
|
||||
style={
|
||||
isAdvanced
|
||||
columns > 1
|
||||
? {
|
||||
border: "1px solid var(--mantine-color-default-border)",
|
||||
borderRadius: "var(--mantine-radius-sm)",
|
||||
@@ -324,7 +321,7 @@ export default function ReleasesWidget({ options, itemId, width, displayMode }:
|
||||
</Group>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
{(options.showDetails || isAdvanced) && (
|
||||
{options.showDetails && (
|
||||
<DetailsDisplay
|
||||
repository={repository}
|
||||
isExpanded={isActive}
|
||||
@@ -582,6 +579,8 @@ const ExpandedDisplay = ({
|
||||
const t = useScopedI18n("widget.releases");
|
||||
const now = useNow();
|
||||
const formatter = useFormatter();
|
||||
const releaseUrl = getSafeApplicationUrl(repository.releaseUrl);
|
||||
const repositoryUrl = releaseUrl ?? getSafeApplicationUrl(repository.projectUrl);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -651,19 +650,19 @@ const ExpandedDisplay = ({
|
||||
</Group>
|
||||
</Button>
|
||||
|
||||
{(repository.releaseUrl ?? repository.projectUrl) && (
|
||||
{repositoryUrl && (
|
||||
<Button
|
||||
className="releases-repository-expanded-openButton"
|
||||
variant="light"
|
||||
component="a"
|
||||
href={repository.releaseUrl ?? repository.projectUrl}
|
||||
href={repositoryUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
rel={SAFE_NEW_TAB_REL}
|
||||
>
|
||||
<Group className="releases-repository-expanded-openButton-wrapper" gap={5} justify="center" align="center">
|
||||
<IconExternalLink className="releases-repository-expanded-openButton-icon" size="1.5em" />
|
||||
<Text className="releases-repository-expanded-openButton-text">
|
||||
{repository.releaseUrl ? t("openReleasePage") : t("openProjectPage")}
|
||||
{releaseUrl ? t("openReleasePage") : t("openProjectPage")}
|
||||
</Text>
|
||||
</Group>
|
||||
</Button>
|
||||
@@ -682,8 +681,9 @@ const ExpandedDisplay = ({
|
||||
c="red"
|
||||
style={{ whiteSpace: "pre-wrap" }}
|
||||
>
|
||||
{repository.error.message ??
|
||||
(repository.error.code ? t(`error.messages.${repository.error.code}` as never) : null)}
|
||||
{repository.error.code
|
||||
? t(`error.messages.${repository.error.code}` as never)
|
||||
: t("error.messages.unexpected")}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -13,6 +13,7 @@ const relativeDateSchema = z
|
||||
.or(z.literal(""));
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("releases", {
|
||||
supportsAdvancedFocus: false,
|
||||
icon: IconRocket,
|
||||
queryKey: [["widget", "releases", "getLatest"]],
|
||||
queryMatcher: ({ input }, scope) => widgetQueryInputMatches(input, { itemId: scope.itemId }),
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useRequiredBoard } from "@homarr/boards/context";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url";
|
||||
import classes from "./component.module.scss";
|
||||
|
||||
const useLiveFeed = (input: RouterInputs["widget"]["rssFeed"]["getFeeds"]) => {
|
||||
@@ -33,7 +34,7 @@ const useLiveFeed = (input: RouterInputs["widget"]["rssFeed"]["getFeeds"]) => {
|
||||
};
|
||||
};
|
||||
|
||||
export default function RssFeed({ options, width, height, displayMode }: WidgetComponentProps<"rssFeed">) {
|
||||
export default function RssFeed({ options, width, height }: WidgetComponentProps<"rssFeed">) {
|
||||
const {
|
||||
entries: feedEntries,
|
||||
failedFeedCount,
|
||||
@@ -53,64 +54,68 @@ export default function RssFeed({ options, width, height, displayMode }: WidgetC
|
||||
|
||||
const languageDir = options.enableRtl ? "RTL" : "LTR";
|
||||
|
||||
const isAdvanced = displayMode === "advanced";
|
||||
const isDense = !isAdvanced && (width < 420 || height < 180);
|
||||
const isTiny = !isAdvanced && (width < 260 || height < 110);
|
||||
const columns = isAdvanced && width >= 720 ? 2 : 1;
|
||||
const descriptionLines = isAdvanced ? Math.max(options.textLinesClamp, 8) : isDense ? 1 : options.textLinesClamp;
|
||||
const isDense = width < 420 || height < 180;
|
||||
const isTiny = width < 260 || height < 110;
|
||||
const isRoomy = width >= 420 && height >= 240;
|
||||
const columns = width >= 720 && height >= 260 ? 2 : 1;
|
||||
const descriptionLines = isRoomy ? Math.max(options.textLinesClamp, 6) : isDense ? 1 : options.textLinesClamp;
|
||||
const spacing = isRoomy ? "sm" : "xs";
|
||||
|
||||
return (
|
||||
<ScrollArea className="scroll-area-w100" w="100%" h="100%" p={isAdvanced ? "md" : isTiny ? 4 : "xs"}>
|
||||
<ScrollArea className="scroll-area-w100" w="100%" h="100%" p={isTiny ? 4 : "xs"}>
|
||||
{warning && (
|
||||
<Alert role="presentation" color="orange" icon={<IconAlertTriangle aria-hidden size={16} />} p="xs" mb="xs">
|
||||
<output>{warning}</output>
|
||||
</Alert>
|
||||
)}
|
||||
<SimpleGrid cols={columns} w="100%" spacing={isAdvanced ? "md" : "xs"} verticalSpacing={isAdvanced ? "md" : "xs"}>
|
||||
{feedEntries.map((feedEntry) => (
|
||||
<Card
|
||||
key={feedEntry.id}
|
||||
className={classes.entry}
|
||||
component={"a"}
|
||||
href={getSafeExternalUrl(feedEntry.link, feedEntry.feedUrl)}
|
||||
radius={board.itemRadius}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
w="100%"
|
||||
p={isAdvanced ? "md" : isDense ? 6 : "xs"}
|
||||
title={feedEntry.title}
|
||||
>
|
||||
<Group wrap="nowrap" align="flex-start" gap={isDense ? "xs" : "md"}>
|
||||
{feedEntry.enclosure !== undefined && options.showPosterImage && !isTiny && (
|
||||
<Image
|
||||
className={classes.poster}
|
||||
src={feedEntry.enclosure}
|
||||
alt=""
|
||||
w={isAdvanced ? 180 : isDense ? 64 : 96}
|
||||
h={isAdvanced ? 120 : isDense ? 64 : 96}
|
||||
radius="sm"
|
||||
fit="cover"
|
||||
/>
|
||||
)}
|
||||
|
||||
<Flex gap={isAdvanced ? "sm" : 6} direction="column" w="100%" miw={0}>
|
||||
<Text dir={languageDir} fz={isAdvanced ? "md" : "sm"} fw={600} lh={1.25} lineClamp={2}>
|
||||
{feedEntry.title}
|
||||
</Text>
|
||||
{!options.hideDescription && feedEntry.description && !isTiny && (
|
||||
<Text dir={languageDir} c="dimmed" size="sm" lineClamp={descriptionLines}>
|
||||
{feedDescriptionToText(feedEntry.description)}
|
||||
</Text>
|
||||
<SimpleGrid cols={columns} w="100%" spacing={spacing} verticalSpacing={spacing}>
|
||||
{feedEntries.map((feedEntry) => {
|
||||
const href = getSafeApplicationUrl(feedEntry.link, { baseUrl: feedEntry.feedUrl });
|
||||
return (
|
||||
<Card
|
||||
key={feedEntry.id}
|
||||
className={classes.entry}
|
||||
component={href ? "a" : "div"}
|
||||
href={href}
|
||||
radius={board.itemRadius}
|
||||
target={href ? "_blank" : undefined}
|
||||
rel={href ? SAFE_NEW_TAB_REL : undefined}
|
||||
w="100%"
|
||||
p={isDense ? 6 : "xs"}
|
||||
title={feedEntry.title}
|
||||
>
|
||||
<Group wrap="nowrap" align="flex-start" gap={isDense ? "xs" : "md"}>
|
||||
{feedEntry.enclosure !== undefined && options.showPosterImage && !isTiny && (
|
||||
<Image
|
||||
className={classes.poster}
|
||||
src={feedEntry.enclosure}
|
||||
alt=""
|
||||
w={isRoomy ? 140 : isDense ? 64 : 96}
|
||||
h={isRoomy ? 96 : isDense ? 64 : 96}
|
||||
radius="sm"
|
||||
fit="cover"
|
||||
/>
|
||||
)}
|
||||
|
||||
<InfoDisplay
|
||||
source={isAdvanced || !isDense ? getHostname(feedEntry.feedUrl) : undefined}
|
||||
date={feedEntry.published ? dayjs(feedEntry.published).fromNow() : undefined}
|
||||
/>
|
||||
</Flex>
|
||||
</Group>
|
||||
</Card>
|
||||
))}
|
||||
<Flex gap={isRoomy ? "sm" : 6} direction="column" w="100%" miw={0}>
|
||||
<Text dir={languageDir} fz={isRoomy ? "md" : "sm"} fw={600} lh={1.25} lineClamp={2}>
|
||||
{feedEntry.title}
|
||||
</Text>
|
||||
{!options.hideDescription && feedEntry.description && !isTiny && (
|
||||
<Text dir={languageDir} c="dimmed" size="sm" lineClamp={descriptionLines}>
|
||||
{feedDescriptionToText(feedEntry.description)}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<InfoDisplay
|
||||
source={!isDense ? getHostname(feedEntry.feedUrl) : undefined}
|
||||
date={feedEntry.published ? dayjs(feedEntry.published).fromNow() : undefined}
|
||||
/>
|
||||
</Flex>
|
||||
</Group>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</SimpleGrid>
|
||||
</ScrollArea>
|
||||
);
|
||||
@@ -134,29 +139,6 @@ export const getHostname = (url: string): string => {
|
||||
}
|
||||
};
|
||||
|
||||
export const getSafeExternalUrl = (value: unknown, baseUrl?: string): string | undefined => {
|
||||
if (typeof value !== "string") return undefined;
|
||||
try {
|
||||
let sanitizedBaseUrl: string | undefined;
|
||||
if (baseUrl) {
|
||||
const parsedBaseUrl = new URL(baseUrl);
|
||||
parsedBaseUrl.username = "";
|
||||
parsedBaseUrl.password = "";
|
||||
parsedBaseUrl.search = "";
|
||||
parsedBaseUrl.hash = "";
|
||||
sanitizedBaseUrl = parsedBaseUrl.toString();
|
||||
}
|
||||
|
||||
const url = new URL(value, sanitizedBaseUrl);
|
||||
if (url.protocol !== "http:" && url.protocol !== "https:") return undefined;
|
||||
url.username = "";
|
||||
url.password = "";
|
||||
return url.toString();
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const InfoDisplay = ({ date, source }: { date?: string; source?: string }) => (
|
||||
<Group gap={5} align="center" wrap="nowrap">
|
||||
{date && <IconClock size="1rem" color="var(--mantine-color-dimmed)" />}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { feedDescriptionToText, getHostname, getSafeExternalUrl } from "./component";
|
||||
import { feedDescriptionToText, getHostname } from "./component";
|
||||
|
||||
describe("RSS feed display helpers", () => {
|
||||
test("renders feed markup as inert text", () => {
|
||||
@@ -13,22 +13,4 @@ describe("RSS feed display helpers", () => {
|
||||
expect(getHostname("not a URL")).toBe("not a URL");
|
||||
expect(getHostname("https://example.com/feed.xml")).toBe("example.com");
|
||||
});
|
||||
|
||||
test("only exposes credential-free HTTP links from untrusted feed entries", () => {
|
||||
expect(getSafeExternalUrl("https://example.com/story")).toBe("https://example.com/story");
|
||||
expect(getSafeExternalUrl("/story", "https://example.com/feed.xml")).toBe("https://example.com/story");
|
||||
expect(getSafeExternalUrl("/story", "https://user:password@example.com/feed.xml")).toBe(
|
||||
"https://example.com/story",
|
||||
);
|
||||
expect(getSafeExternalUrl("https://user:password@example.com/story")).toBe("https://example.com/story");
|
||||
expect(getSafeExternalUrl(undefined, "https://example.com/feed?token=secret")).toBeUndefined();
|
||||
expect(getSafeExternalUrl("#story", "https://example.com/feed?token=secret#old")).toBe(
|
||||
"https://example.com/feed#story",
|
||||
);
|
||||
expect(getSafeExternalUrl("?story=1", "https://example.com/feed?token=secret")).toBe(
|
||||
"https://example.com/feed?story=1",
|
||||
);
|
||||
expect(getSafeExternalUrl("javascript:alert(1)")).toBeUndefined();
|
||||
expect(getSafeExternalUrl("not a URL")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,7 @@ import { optionsBuilder } from "../options";
|
||||
* - https://www.jsonfeed.org/version/1.1/
|
||||
*/
|
||||
export const { definition, componentLoader } = createWidgetDefinition("rssFeed", {
|
||||
supportsAdvancedFocus: false,
|
||||
icon: IconRss,
|
||||
queryKey: [["widget", "rssFeed", "getFeeds"]],
|
||||
queryMatcher: ({ input }, scope) =>
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
|
||||
import type { QueryKey } from "@tanstack/react-query";
|
||||
|
||||
import type { WidgetRuntimeActions, WidgetRuntimeRef } from "./definition";
|
||||
import { normalizeWidgetQuery } from "./definition";
|
||||
|
||||
export const useWidgetRuntimeQueries = (
|
||||
widgetRuntimeRef: WidgetRuntimeRef | undefined,
|
||||
queryKeys: readonly QueryKey[],
|
||||
) => {
|
||||
useEffect(() => {
|
||||
if (!widgetRuntimeRef) return;
|
||||
const runtime = widgetRuntimeRef.current;
|
||||
const queries = queryKeys.flatMap((queryKey) => {
|
||||
const query = normalizeWidgetQuery(queryKey);
|
||||
return query ? [query] : [];
|
||||
});
|
||||
runtime.queries = queries;
|
||||
return () => {
|
||||
if (runtime.queries === queries) runtime.queries = [];
|
||||
};
|
||||
}, [queryKeys, widgetRuntimeRef]);
|
||||
};
|
||||
|
||||
export const useWidgetRuntimeActions = (
|
||||
widgetRuntimeRef: WidgetRuntimeRef | undefined,
|
||||
actions: WidgetRuntimeActions,
|
||||
) => {
|
||||
useEffect(() => {
|
||||
if (!widgetRuntimeRef) return;
|
||||
const runtime = widgetRuntimeRef.current;
|
||||
runtime.actions = actions;
|
||||
return () => {
|
||||
if (runtime.actions === actions) runtime.actions = {};
|
||||
};
|
||||
}, [actions, widgetRuntimeRef]);
|
||||
};
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
Stack,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Tooltip,
|
||||
UnstyledButton,
|
||||
VisuallyHidden,
|
||||
} from "@mantine/core";
|
||||
@@ -106,7 +105,7 @@ export default function SmartHomeTriggerAutomationWidget({
|
||||
cursor: !isEditMode && integrationId && canInteract ? "pointer" : "initial",
|
||||
pointerEvents: isEditMode ? "none" : undefined,
|
||||
}}
|
||||
aria-description={error?.message}
|
||||
aria-description={error ? t("widget.smartHome-executeAutomation.error.executeFailed") : undefined}
|
||||
w="100%"
|
||||
h="100%"
|
||||
>
|
||||
@@ -123,11 +122,9 @@ export default function SmartHomeTriggerAutomationWidget({
|
||||
{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>
|
||||
<Text size="xs" c="red" ta="center" lineClamp={3}>
|
||||
{t("widget.smartHome-executeAutomation.error.executeFailed")}
|
||||
</Text>
|
||||
</Center>
|
||||
</Overlay>
|
||||
)}
|
||||
@@ -142,11 +139,9 @@ export default function SmartHomeTriggerAutomationWidget({
|
||||
{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="red">
|
||||
{t("widget.smartHome-executeAutomation.error.executeFailed")}
|
||||
</Text>
|
||||
) : (
|
||||
<Text ta="center" size="xs" c="dimmed">
|
||||
{lastExecutedAt
|
||||
|
||||
@@ -7,6 +7,7 @@ import { clientApi } from "@homarr/api/client";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { getUsableWidgetQueryData } from "../common/query-state";
|
||||
import { WidgetEmptyState } from "../common/empty-state";
|
||||
import { AveragesSection } from "./averages";
|
||||
import { combineSpeedtestDashboards, getCompactSections } from "./helpers";
|
||||
@@ -21,7 +22,8 @@ export default function SpeedtestTrackerWidget({
|
||||
displayMode = "compact",
|
||||
}: WidgetComponentProps<"speedtestTracker">) {
|
||||
const t = useScopedI18n("widget.speedtestTracker");
|
||||
const { data: dashboardData = [] } = clientApi.widget.speedtestTracker.getDashboard.useQuery({ integrationIds });
|
||||
const dashboardData =
|
||||
getUsableWidgetQueryData(clientApi.widget.speedtestTracker.getDashboard.useQuery({ integrationIds })) ?? [];
|
||||
const [advancedRange, setAdvancedRange] = useState("24");
|
||||
|
||||
const combined = useMemo(
|
||||
|
||||
@@ -19,7 +19,7 @@ describe("stock summary", () => {
|
||||
|
||||
describe("stock layout", () => {
|
||||
it("keeps compact widgets focused on symbol and price", () => {
|
||||
expect(getStockLayout(320, 100, false)).toMatchObject({
|
||||
expect(getStockLayout(320, 100)).toMatchObject({
|
||||
showName: false,
|
||||
showChange: false,
|
||||
showRange: false,
|
||||
@@ -28,12 +28,12 @@ describe("stock layout", () => {
|
||||
});
|
||||
|
||||
it("reveals secondary data only when both axes have room", () => {
|
||||
expect(getStockLayout(320, 160, false)).toMatchObject({
|
||||
expect(getStockLayout(320, 160)).toMatchObject({
|
||||
showName: false,
|
||||
showChange: true,
|
||||
showRange: true,
|
||||
});
|
||||
expect(getStockLayout(320, 220, false)).toMatchObject({
|
||||
expect(getStockLayout(320, 220)).toMatchObject({
|
||||
showName: true,
|
||||
showChange: true,
|
||||
showRange: true,
|
||||
@@ -41,8 +41,8 @@ describe("stock layout", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("always exposes the complete advanced summary", () => {
|
||||
expect(getStockLayout(120, 80, true)).toEqual({
|
||||
it("exposes the complete summary when the widget is roomy", () => {
|
||||
expect(getStockLayout(640, 360)).toEqual({
|
||||
showName: true,
|
||||
showChange: true,
|
||||
showRange: true,
|
||||
|
||||
@@ -38,22 +38,12 @@ export interface StockLayout {
|
||||
priceOrder: 1 | 2;
|
||||
}
|
||||
|
||||
export function getStockLayout(width: number, height: number, isAdvanced: boolean): StockLayout {
|
||||
if (isAdvanced) {
|
||||
return {
|
||||
showName: true,
|
||||
showChange: true,
|
||||
showRange: true,
|
||||
graphHeight: "75%",
|
||||
priceOrder: 1,
|
||||
};
|
||||
}
|
||||
|
||||
export function getStockLayout(width: number, height: number): StockLayout {
|
||||
return {
|
||||
showName: width >= 300 && height >= 190,
|
||||
showChange: width >= 260 && height >= 130,
|
||||
showRange: width >= 220 && height >= 130,
|
||||
graphHeight: height >= 220 ? "68%" : "48%",
|
||||
graphHeight: height >= 320 ? "75%" : height >= 220 ? "68%" : "48%",
|
||||
priceOrder: width >= 280 && height >= 120 ? 1 : 2,
|
||||
};
|
||||
}
|
||||
@@ -71,13 +61,14 @@ export function getStockSummary(priceHistory: number[], previousClose: number):
|
||||
};
|
||||
}
|
||||
|
||||
export default function StockPriceWidget({ options, width, height, displayMode }: WidgetComponentProps<"stockPrice">) {
|
||||
export default function StockPriceWidget({ options, width, height }: WidgetComponentProps<"stockPrice">) {
|
||||
const t = useScopedI18n("widget.stockPrice");
|
||||
const locale = useCurrentIntlLocale();
|
||||
const numberFormatter = new Intl.NumberFormat(locale);
|
||||
const theme = useMantineTheme();
|
||||
const { data: result } = clientApi.widget.stockPrice.getPriceHistory.useQuery(options);
|
||||
const { data: result, error } = clientApi.widget.stockPrice.getPriceHistory.useQuery(options);
|
||||
|
||||
if (error && !result) throw error;
|
||||
if (!result) return <WidgetEmptyState />;
|
||||
const { data } = result;
|
||||
|
||||
@@ -87,7 +78,7 @@ export default function StockPriceWidget({ options, width, height, displayMode }
|
||||
const stockValuesChangePercentage = summary.changePercentage === null ? null : round(summary.changePercentage);
|
||||
const stockGraphValues = summary.graphValues;
|
||||
const trendColor = stockValuesChange > 0 ? "green.7" : stockValuesChange < 0 ? "red.7" : "gray.6";
|
||||
const layout = getStockLayout(width, height, displayMode === "advanced");
|
||||
const layout = getStockLayout(width, height);
|
||||
|
||||
return (
|
||||
<Flex h="100%" w="100%">
|
||||
|
||||
@@ -12,6 +12,7 @@ const timeRangeOptions = stockPriceTimeFrames.range;
|
||||
const timeIntervalOptions = stockPriceTimeFrames.interval;
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("stockPrice", {
|
||||
supportsAdvancedFocus: false,
|
||||
icon: IconBuildingBank,
|
||||
queryKey: [["widget", "stockPrice", "getPriceHistory"]],
|
||||
queryMatcher: ({ input }, scope) =>
|
||||
|
||||
@@ -9,6 +9,7 @@ import { formatBytesPair } from "@homarr/common";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { WidgetEmptyState } from "../common/empty-state";
|
||||
import { getUsableWidgetQueryData } from "../common/query-state";
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { filterStorageVolumes, normalizeStorageDeviceName } from "../filter-storage-volumes";
|
||||
import { NoIntegrationDataError } from "../errors/no-data-integration";
|
||||
@@ -57,7 +58,7 @@ interface SystemDiskCardProps {
|
||||
showBackgroundBar: boolean;
|
||||
integrationName?: string;
|
||||
secondaryText?: string;
|
||||
isAdvanced: boolean;
|
||||
showSecondaryText: boolean;
|
||||
showTemperature: boolean;
|
||||
}
|
||||
|
||||
@@ -70,7 +71,7 @@ const SystemDiskCard = ({
|
||||
showBackgroundBar,
|
||||
integrationName,
|
||||
secondaryText,
|
||||
isAdvanced,
|
||||
showSecondaryText,
|
||||
showTemperature,
|
||||
}: SystemDiskCardProps) => {
|
||||
const board = useRequiredBoard();
|
||||
@@ -98,8 +99,10 @@ const SystemDiskCard = ({
|
||||
|
||||
const unhealthyLabel = t("widget.systemDisks.status.unhealthy");
|
||||
const hasHiddenTemperature = temperature !== null && temperature !== undefined && !showTemperature;
|
||||
const hasHiddenSecondaryText = Boolean(secondaryText && secondaryText !== displayText && !showSecondaryText);
|
||||
const tooltipLabel = [
|
||||
healthy ? displayText : `${displayText} (${unhealthyLabel})`,
|
||||
hasHiddenSecondaryText ? secondaryText : null,
|
||||
hasHiddenTemperature ? `${temperature}°C` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
@@ -108,7 +111,7 @@ const SystemDiskCard = ({
|
||||
return (
|
||||
<Tooltip
|
||||
label={tooltipLabel}
|
||||
disabled={(valueFits && !hasHiddenTemperature) || isAdvanced}
|
||||
disabled={valueFits && !hasHiddenTemperature && !hasHiddenSecondaryText}
|
||||
position="top"
|
||||
withinPortal
|
||||
>
|
||||
@@ -132,7 +135,7 @@ const SystemDiskCard = ({
|
||||
<span>{displayText}</span>
|
||||
{!healthy && <span style={{ marginLeft: 5 }}>{unhealthyLabel}</span>}
|
||||
</Text>
|
||||
{isAdvanced && secondaryText && (
|
||||
{showSecondaryText && secondaryText && secondaryText !== displayText && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{secondaryText}
|
||||
</Text>
|
||||
@@ -166,10 +169,10 @@ export default function SystemResources({
|
||||
options,
|
||||
width,
|
||||
height,
|
||||
displayMode: surfaceMode,
|
||||
}: WidgetComponentProps<"systemDisks">) {
|
||||
const queryInput = { integrationIds };
|
||||
const { data = [] } = clientApi.widget.healthMonitoring.getSystemHealthStatus.useQuery(queryInput);
|
||||
const data =
|
||||
getUsableWidgetQueryData(clientApi.widget.healthMonitoring.getSystemHealthStatus.useQuery(queryInput)) ?? [];
|
||||
|
||||
if (data.length === 0) return <WidgetEmptyState />;
|
||||
|
||||
@@ -193,8 +196,7 @@ export default function SystemResources({
|
||||
if (data.every((entry) => entry.healthInfo.fileSystem.length === 0)) throw new NoIntegrationDataError();
|
||||
if (disks.length === 0) return <WidgetEmptyState />;
|
||||
|
||||
const isAdvanced = surfaceMode === "advanced";
|
||||
const minimumCardWidth = isAdvanced ? 320 : 260;
|
||||
const minimumCardWidth = 260;
|
||||
const columns = Math.max(1, Math.min(disks.length, Math.floor(width / minimumCardWidth)));
|
||||
const cellWidth = width / columns;
|
||||
|
||||
@@ -210,10 +212,10 @@ export default function SystemResources({
|
||||
temperature={options.showTemperatureIfAvailable ? smartItem?.temperature : undefined}
|
||||
healthy={smartItem?.healthy ?? true} // fall back to healthy if no information is available
|
||||
showBackgroundBar={options.showBackgroundBar}
|
||||
integrationName={isAdvanced || data.length > 1 ? integrationName : undefined}
|
||||
integrationName={data.length > 1 ? integrationName : undefined}
|
||||
secondaryText={getAbsoluteText(item)}
|
||||
isAdvanced={isAdvanced}
|
||||
showTemperature={isAdvanced || (height >= 100 && cellWidth >= 220)}
|
||||
showSecondaryText={height >= 160 && cellWidth >= 260}
|
||||
showTemperature={height >= 100 && cellWidth >= 220}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { optionsBuilder } from "../options";
|
||||
import { createStorageVolumeMultiSelectOptions } from "../storage-volume-options";
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("systemDisks", {
|
||||
supportsAdvancedFocus: false,
|
||||
icon: IconServer2,
|
||||
queryKey: [["widget", "healthMonitoring"]],
|
||||
refetchInterval: 5,
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { Alert, Badge, Center, Group, ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core";
|
||||
import { IconAlertTriangle } from "@tabler/icons-react";
|
||||
import dayjs from "dayjs";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { useCurrentIntlLocale, useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { DynamicSelectOption } from "../_inputs/widget-dynamic-select-input";
|
||||
import { formatLocalizedTime } from "../common/locale";
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
|
||||
export default function TimetableWidget({
|
||||
@@ -55,11 +55,12 @@ const TimetableWidgetInner = ({ station, baseUrl, itemId, displayMode, width, he
|
||||
});
|
||||
const t = useScopedI18n("widget.timetable");
|
||||
const tCommon = useScopedI18n("common");
|
||||
const locale = useCurrentIntlLocale();
|
||||
|
||||
if (error && timetable === undefined) throw error;
|
||||
|
||||
const entries = timetable?.entries ?? [];
|
||||
const staleTime = timetable ? dayjs(timetable.timestamp).format("HH:mm:ss") : undefined;
|
||||
const staleTime = timetable ? formatLocalizedTime(timetable.timestamp, locale, { includeSeconds: true }) : undefined;
|
||||
const staleWarning = error && staleTime ? t("warning.stale", { time: staleTime }) : undefined;
|
||||
const compactStaleWarning = error && staleTime ? t("warning.staleCompact") : undefined;
|
||||
const isAdvanced = displayMode === "advanced";
|
||||
@@ -104,6 +105,7 @@ const TimetableWidgetInner = ({ station, baseUrl, itemId, displayMode, width, he
|
||||
dense={isDense}
|
||||
showLine={showLine}
|
||||
showPlatform={showPlatform}
|
||||
locale={locale}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
@@ -117,7 +119,7 @@ const TimetableWidgetInner = ({ station, baseUrl, itemId, displayMode, width, he
|
||||
</ScrollArea>
|
||||
{displayMode === "advanced" && timetable && (
|
||||
<Text size="xs" c="dimmed" ta="right">
|
||||
{dayjs(timetable.timestamp).format("HH:mm:ss")}
|
||||
{formatLocalizedTime(timetable.timestamp, locale, { includeSeconds: true })}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
@@ -137,11 +139,13 @@ function DepartureRow({
|
||||
dense,
|
||||
showLine,
|
||||
showPlatform,
|
||||
locale,
|
||||
}: {
|
||||
entry: TimetableEntryView;
|
||||
dense: boolean;
|
||||
showLine: boolean;
|
||||
showPlatform: boolean;
|
||||
locale: string;
|
||||
}) {
|
||||
return (
|
||||
<Group justify="space-between" w="100%" wrap="nowrap">
|
||||
@@ -159,7 +163,7 @@ function DepartureRow({
|
||||
</Badge>
|
||||
)}
|
||||
<Text size={dense ? "xs" : "sm"} fw={dense ? 600 : undefined} style={{ whiteSpace: "nowrap" }}>
|
||||
{dayjs(entry.timestamp).format("HH:mm")}{" "}
|
||||
{formatLocalizedTime(entry.timestamp, locale)}{" "}
|
||||
{entry.delay >= 1 && (
|
||||
<Text size={dense ? "xs" : "sm"} span c="red">
|
||||
+{entry.delay}'
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { TracearrDashboardData } from "@homarr/integrations/types";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { getUsableWidgetQueryData } from "../common/query-state";
|
||||
import { NoIntegrationDataError } from "../errors/no-data-integration";
|
||||
import { formatTotalTracearrBitrate } from "./bitrate";
|
||||
import { RecentActivityList } from "./recent-activity-section";
|
||||
@@ -67,7 +68,8 @@ interface TracearrContentProps {
|
||||
|
||||
function TracearrContent({ integrationIds, options, width, height, displayMode }: TracearrContentProps) {
|
||||
const t = useScopedI18n("widget.tracearr");
|
||||
const { data: dashboardData = [] } = clientApi.widget.tracearr.getDashboard.useQuery({ integrationIds });
|
||||
const dashboardData =
|
||||
getUsableWidgetQueryData(clientApi.widget.tracearr.getDashboard.useQuery({ integrationIds })) ?? [];
|
||||
|
||||
// Merge data from all integrations
|
||||
const combinedData = dashboardData.reduce<TracearrDashboardData>(
|
||||
|
||||
@@ -17,6 +17,7 @@ import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { WidgetEmptyState } from "../common/empty-state";
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { getUsableWidgetQueryData } from "../common/query-state";
|
||||
import { NoIntegrationDataError } from "../errors/no-data-integration";
|
||||
import classes from "./component.module.css";
|
||||
|
||||
@@ -65,7 +66,7 @@ function TraefikWidgetContent({
|
||||
displayMode = "compact",
|
||||
}: WidgetComponentProps<"traefik">) {
|
||||
const t = useScopedI18n("widget.traefik");
|
||||
const { data } = clientApi.widget.traefik.getDashboard.useQuery({ integrationIds });
|
||||
const data = getUsableWidgetQueryData(clientApi.widget.traefik.getDashboard.useQuery({ integrationIds }));
|
||||
|
||||
if (!data) return <WidgetEmptyState />;
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { BarChart, LineChart } from "@mantine/charts";
|
||||
import { Box, Group, ScrollArea, SimpleGrid, Stack, Text, useMantineColorScheme } from "@mantine/core";
|
||||
import { Box, Group, ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { formatDuration } from "@homarr/common";
|
||||
@@ -12,6 +12,8 @@ import { UmamiEventsContent } from "./umami-events-content";
|
||||
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";
|
||||
|
||||
interface UmamiContentProps {
|
||||
integrationIds: string[];
|
||||
@@ -43,46 +45,31 @@ export function UmamiContent({
|
||||
displayMode,
|
||||
}: UmamiContentProps) {
|
||||
const t = useScopedI18n("widget.umami");
|
||||
const tCommon = useScopedI18n("common");
|
||||
const locale = useCurrentIntlLocale();
|
||||
const { colorScheme } = useMantineColorScheme();
|
||||
const tickColor = colorScheme === "dark" ? "#c1c2c5" : "#495057";
|
||||
const tickColor = "var(--mantine-color-dimmed)";
|
||||
|
||||
const {
|
||||
data: results,
|
||||
isPending: isStatsPending,
|
||||
error: statsError,
|
||||
} = clientApi.widget.umami.getVisitorStats.useQuery(
|
||||
const statsQuery = clientApi.widget.umami.getVisitorStats.useQuery(
|
||||
{ integrationIds, websiteId, timeFrame, eventName },
|
||||
umamiQueryOptions,
|
||||
);
|
||||
const results = getUsableWidgetQueryData(statsQuery);
|
||||
|
||||
const activeVisitorsInput = { integrationId: integrationIds[0] ?? "", websiteId };
|
||||
const { data: activeVisitors = 0 } = clientApi.widget.umami.getActiveVisitors.useQuery(
|
||||
activeVisitorsInput,
|
||||
umamiQueryOptions,
|
||||
);
|
||||
const activeVisitorsQuery = clientApi.widget.umami.getActiveVisitors.useQuery(activeVisitorsInput, umamiQueryOptions);
|
||||
const activeVisitors = activeVisitorsQuery.data;
|
||||
|
||||
const { data: multiEventSeries } = clientApi.widget.umami.getMultiEventTimeSeries.useQuery(
|
||||
const multiEventQuery = clientApi.widget.umami.getMultiEventTimeSeries.useQuery(
|
||||
{ integrationId: integrationIds[0] ?? "", websiteId, timeFrame, eventNames: [...eventNames].toSorted() },
|
||||
{ enabled: viewMode === "events" && eventNames.length > 0, ...umamiQueryOptions },
|
||||
);
|
||||
const multiEventSeries = multiEventQuery.data;
|
||||
|
||||
const multiEventTotal = multiEventSeries
|
||||
? multiEventSeries.flatMap(({ dataPoints }) => dataPoints).reduce((sum, { y }) => sum + y, 0)
|
||||
: undefined;
|
||||
|
||||
const firstResult = results?.[0];
|
||||
if (isStatsPending) {
|
||||
return (
|
||||
<Stack align="center" justify="center" h="100%">
|
||||
<Text c="dimmed" size="sm">
|
||||
{tCommon("action.loading")}
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
if (statsError && results === undefined) throw statsError;
|
||||
if (isInitialWidgetQueryPending(statsQuery)) return <WidgetQueryLoadingState />;
|
||||
if (!firstResult) {
|
||||
return (
|
||||
<Stack align="center" justify="center" h="100%">
|
||||
@@ -212,7 +199,7 @@ export function UmamiContent({
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack gap={isDense ? 2 : 4} p={isDense ? 4 : "xs"} h="100%">
|
||||
<Stack gap={isDense ? 2 : 4} p={isDense ? 4 : "xs"} h="100%" pos="relative">
|
||||
<Group justify="space-between" align="baseline" wrap="nowrap">
|
||||
<Text size="xs" c="dimmed" truncate="end" style={{ maxWidth: "55%" }}>
|
||||
{visitorStats.domain} ({formatTimeFrameLabel(timeFrame, t)})
|
||||
@@ -255,9 +242,11 @@ export function UmamiContent({
|
||||
</Group>
|
||||
{showSecondaryStats && (
|
||||
<Group wrap="wrap" style={{ columnGap: 12, rowGap: 4 }}>
|
||||
<Text size="xs" c="green">
|
||||
● {activeVisitors.toLocaleString(locale)} {t("active")}
|
||||
</Text>
|
||||
{activeVisitors !== undefined && (
|
||||
<Text size="xs" c="green">
|
||||
● {activeVisitors.toLocaleString(locale)} {t("active")}
|
||||
</Text>
|
||||
)}
|
||||
<Text size="xs" c="dimmed">
|
||||
{visitorStats.totalPageviews.toLocaleString(locale)} {t("pageviews")}
|
||||
</Text>
|
||||
@@ -279,6 +268,14 @@ export function UmamiContent({
|
||||
<Box 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>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { BarChart, LineChart } from "@mantine/charts";
|
||||
import { Box, Group, Stack, Text, useMantineColorScheme } from "@mantine/core";
|
||||
import { Box, Group, Stack, Text } from "@mantine/core";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import type { UmamiEventSeries } from "@homarr/integrations/types";
|
||||
import { useCurrentIntlLocale, useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { EVENT_COLORS, formatXLabel, umamiQueryOptions } from "./umami-utils";
|
||||
import { getUsableWidgetQueryData } from "../common/query-state";
|
||||
|
||||
interface UmamiEventsContentProps {
|
||||
integrationIds: string[];
|
||||
@@ -28,13 +29,15 @@ export function UmamiEventsContent({
|
||||
}: UmamiEventsContentProps) {
|
||||
const t = useScopedI18n("widget.umami");
|
||||
const locale = useCurrentIntlLocale();
|
||||
const { colorScheme } = useMantineColorScheme();
|
||||
const tickColor = colorScheme === "dark" ? "#c1c2c5" : "#495057";
|
||||
const tickColor = "var(--mantine-color-dimmed)";
|
||||
|
||||
const { data: series = [] } = clientApi.widget.umami.getMultiEventTimeSeries.useQuery(
|
||||
{ integrationId: integrationIds[0] ?? "", websiteId, timeFrame, eventNames: [...eventNames].toSorted() },
|
||||
umamiQueryOptions,
|
||||
);
|
||||
const series =
|
||||
getUsableWidgetQueryData(
|
||||
clientApi.widget.umami.getMultiEventTimeSeries.useQuery(
|
||||
{ integrationId: integrationIds[0] ?? "", websiteId, timeFrame, eventNames: [...eventNames].toSorted() },
|
||||
umamiQueryOptions,
|
||||
),
|
||||
) ?? [];
|
||||
|
||||
if (eventNames.length === 0) {
|
||||
return (
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { UmamiMetricItem } from "@homarr/integrations/types";
|
||||
import { useCurrentIntlLocale, useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { umamiQueryOptions } from "./umami-utils";
|
||||
import { getUsableWidgetQueryData } from "../common/query-state";
|
||||
|
||||
interface UmamiTopListProps {
|
||||
integrationIds: string[];
|
||||
@@ -17,19 +18,25 @@ interface UmamiTopListProps {
|
||||
|
||||
export function UmamiTopPagesContent({ integrationIds, websiteId, timeFrame, limit }: UmamiTopListProps) {
|
||||
const t = useScopedI18n("widget.umami");
|
||||
const { data = [] } = clientApi.widget.umami.getTopPages.useQuery(
|
||||
{ integrationId: integrationIds[0] ?? "", websiteId, timeFrame, limit },
|
||||
umamiQueryOptions,
|
||||
);
|
||||
const data =
|
||||
getUsableWidgetQueryData(
|
||||
clientApi.widget.umami.getTopPages.useQuery(
|
||||
{ integrationId: integrationIds[0] ?? "", websiteId, timeFrame, limit },
|
||||
umamiQueryOptions,
|
||||
),
|
||||
) ?? [];
|
||||
return <UmamiTopList items={data} heading={t("option.viewMode.option.topPages")} emptyLabel={t("topPages.direct")} />;
|
||||
}
|
||||
|
||||
export function UmamiTopReferrersContent({ integrationIds, websiteId, timeFrame, limit }: UmamiTopListProps) {
|
||||
const t = useScopedI18n("widget.umami");
|
||||
const { data = [] } = clientApi.widget.umami.getTopReferrers.useQuery(
|
||||
{ integrationId: integrationIds[0] ?? "", websiteId, timeFrame, limit },
|
||||
umamiQueryOptions,
|
||||
);
|
||||
const data =
|
||||
getUsableWidgetQueryData(
|
||||
clientApi.widget.umami.getTopReferrers.useQuery(
|
||||
{ integrationId: integrationIds[0] ?? "", websiteId, timeFrame, limit },
|
||||
umamiQueryOptions,
|
||||
),
|
||||
) ?? [];
|
||||
return (
|
||||
<UmamiTopList
|
||||
items={data}
|
||||
|
||||
@@ -22,6 +22,7 @@ import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { WidgetEmptyState } from "../common/empty-state";
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { getUsableWidgetQueryData } from "../common/query-state";
|
||||
import { NoIntegrationSelectedError } from "../errors/no-integration-selected";
|
||||
|
||||
const statusColors: Record<UpsStatus, string> = {
|
||||
@@ -66,7 +67,7 @@ interface UpsContentProps {
|
||||
|
||||
function UpsContent({ integrationIds, options, width, height, displayMode }: UpsContentProps) {
|
||||
const t = useScopedI18n("widget.ups");
|
||||
const { data } = clientApi.widget.ups.getSummaries.useQuery({ integrationIds });
|
||||
const data = getUsableWidgetQueryData(clientApi.widget.ups.getSummaries.useQuery({ integrationIds }));
|
||||
|
||||
if (!data) return <WidgetEmptyState />;
|
||||
|
||||
|
||||
Loaded 100 of 165 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user