Merge branch 'dev' into feat/openwebui-integration

Resolve conflicts by keeping both openWebUi and dev-side registry entries
(integration/widget kinds, creators, docs sitemap). Take dev's next.config
turbopack settings. Reset SQLite migration base to dev's 0040_add_widget_secret;
the integration.creator_id migrations are regenerated for all dialects in a
follow-up commit.
This commit is contained in:
Thomas Camlong committed 2026-07-11 18:11:03 +02:00
commit 411c809c55
517 files changed
+30974 -14645

No files matched your search

+2 -1
View File
@@ -8,9 +8,10 @@ export interface CommonWidgetInputProps<TKey extends WidgetOptionType> {
property: string;
options: Omit<WidgetOptionOfType<TKey>, "defaultValue" | "type">;
initialOptions: Record<string, unknown>;
itemId?: string;
}
type UseWidgetInputTranslationReturnType = (key: "label" | "description") => string;
type UseWidgetInputTranslationReturnType = (key: "label" | "description" | "placeholder") => string;
/**
* Short description why as and unknown convertions are used below:
+2
View File
@@ -17,6 +17,7 @@ import { WidgetUmamiEventInput } from "./widget-umami-event-input";
import { WidgetUmamiEventNamesInput } from "./widget-umami-event-names-input";
import { WidgetUmamiWebsiteInput } from "./widget-umami-website-input";
import { WidgetIntegrationSelectInput } from "./widget-integration-select-input";
import { WidgetIntegrationMultiSelectInput } from "./widget-integration-multi-select-input";
const mapping = {
anchorNote: WidgetAnchorNoteInput,
@@ -37,6 +38,7 @@ const mapping = {
umamiWebsite: WidgetUmamiWebsiteInput,
customWidgetSelect: WidgetCustomWidgetSelectInput,
integrationSelect: WidgetIntegrationSelectInput,
integrationMultiSelect: WidgetIntegrationMultiSelectInput,
} satisfies Record<WidgetOptionType, unknown>;
export const getInputForType = <TType extends WidgetOptionType>(type: TType) => {
@@ -30,9 +30,6 @@ export const WidgetAnchorNoteInput = ({ property, kind, options }: CommonWidgetI
{
enabled: Boolean(integrationId),
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
retry: false,
},
);
@@ -24,7 +24,11 @@ export const WidgetDynamicSelectInput = ({ property, kind, options }: CommonWidg
const inputProps = form.getInputProps(`options.${property}`);
const [search, setSearch] = useState("");
const [debouncedSearch] = useDebouncedValue(search, 300);
const { isPending, options: selectOptions } = options.useOptions(debouncedSearch, form.values.integrationIds);
const { isPending, options: selectOptions } = options.useOptions(
debouncedSearch,
form.values.integrationIds,
form.values.options,
);
const currentOption = inputProps.value as DynamicSelectOption | null;
const onChange = inputProps.onChange as (value: DynamicSelectOption | null) => void;
@@ -0,0 +1,60 @@
"use client";
import { MultiSelect, Text } from "@mantine/core";
import { useScopedI18n } from "@homarr/translation/client";
import type { CommonWidgetInputProps } from "./common";
import { useWidgetInputTranslation } from "./common";
import { useFormContext } from "./form";
const useIntegrationSelectTranslation = () => useScopedI18n("widget.integrationSelect");
export const WidgetIntegrationMultiSelectInput = ({
property,
kind,
options,
}: CommonWidgetInputProps<"integrationMultiSelect">) => {
const t = useIntegrationSelectTranslation();
const tInput = useWidgetInputTranslation(kind, property);
const form = useFormContext();
const integrationIds = form.values.integrationIds as string[];
const inputProps = form.getInputProps(`options.${property}`);
const selectedValues = (inputProps.value as string[] | undefined) ?? [];
const { data: selectData = [], isPending, isError } = options.useOptions(integrationIds);
const data = [
...selectData,
...selectedValues
.filter((selectedValue) => !selectData.some((option) => option.value === selectedValue))
.map((selectedValue) => ({ value: selectedValue, label: selectedValue })),
];
if (integrationIds.length === 0) {
return (
<Text size="sm" c="dimmed">
{t("configureIntegrationFirst")}
</Text>
);
}
const description = options.withDescription ? tInput("description") : undefined;
const placeholder = isPending ? t("loading") : tInput("placeholder");
const loadError = isError ? t("loadError") : undefined;
return (
<MultiSelect
{...inputProps}
label={tInput("label")}
description={description}
placeholder={placeholder}
searchable
clearable
nothingFoundMessage={t("noResults")}
data={data}
disabled={isError}
error={loadError ?? inputProps.error}
/>
);
};
@@ -12,6 +12,7 @@ import {
Group,
Image,
Loader,
PasswordInput,
Select,
Stack,
Text,
@@ -27,6 +28,7 @@ import {
IconCopy,
IconCopyCheckFilled,
IconEdit,
IconKey,
IconPackageImport,
IconPlus,
IconTrash,
@@ -38,16 +40,21 @@ import { escapeForRegEx } from "@tiptap/react";
import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import { createId } from "@homarr/common";
import { getIconUrl } from "@homarr/definitions";
import type { IntegrationKind } from "@homarr/definitions";
import type { ReleaseProviderKind } from "@homarr/definitions";
import {
getReleaseProviderDefaultUrl,
getReleaseProviderIconUrl,
getReleaseProviderName,
normalizeReleaseProviderIdentifier,
releaseProviderKinds,
} from "@homarr/definitions";
import { findBestIconMatch, IconPicker } from "@homarr/forms-collection";
import { createModal, useModalAction } from "@homarr/modals";
import { showErrorNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import { MaskedImage } from "@homarr/ui";
import type { ReleasesRepository, ReleasesVersionFilter } from "../releases/releases-repository";
import { WidgetIntegrationSelect } from "../widget-integration-select";
import type { IntegrationSelectOption } from "../widget-integration-select";
import type { CommonWidgetInputProps } from "./common";
import { useWidgetInputTranslation } from "./common";
import { useFormContext } from "./form";
@@ -57,13 +64,10 @@ interface FormValidation {
errors: FormErrors;
}
interface Integration extends IntegrationSelectOption {
iconUrl: string;
}
export const WidgetMultiReleasesRepositoriesInput = ({
property,
kind,
itemId,
}: CommonWidgetInputProps<"multiReleasesRepositories">) => {
const t = useWidgetInputTranslation(kind, property);
const tRepository = useScopedI18n("widget.releases.option.repositories");
@@ -78,38 +82,19 @@ export const WidgetMultiReleasesRepositoriesInput = ({
const { data: session } = useSession();
const isAdmin = session?.user.permissions.includes("admin") ?? false;
const integrationsApi = clientApi.integration.allOfGivenCategory.useQuery(
{
category: "releasesProvider",
},
{
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
},
);
const integrations = useMemo(
() =>
integrationsApi.data?.reduce<Record<string, Integration>>((acc, integration) => {
acc[integration.id] = {
id: integration.id,
name: integration.name,
url: integration.url,
kind: integration.kind,
iconUrl: getIconUrl(integration.kind),
};
return acc;
}, {}) ?? {},
[integrationsApi],
const providerOptions = useMemo(
() => releaseProviderKinds.map((provider) => ({ value: provider, label: getReleaseProviderName(provider) })),
[],
);
const onRepositorySave = useCallback(
(repository: ReleasesRepository, index: number): FormValidation => {
form.setFieldValue(`options.${property}.${index}.providerIntegrationId`, repository.providerIntegrationId);
form.setFieldValue(`options.${property}.${index}.provider`, repository.provider);
form.setFieldValue(`options.${property}.${index}.identifier`, repository.identifier);
form.setFieldValue(`options.${property}.${index}.name`, repository.name);
form.setFieldValue(`options.${property}.${index}.versionFilter`, repository.versionFilter);
form.setFieldValue(`options.${property}.${index}.iconUrl`, repository.iconUrl);
form.setFieldValue(`options.${property}.${index}.providerUrl`, repository.providerUrl);
const formValidation = form.validate();
const fieldErrors: FormErrors = Object.entries(formValidation.errors).reduce((acc, [key, value]) => {
@@ -152,7 +137,7 @@ export const WidgetMultiReleasesRepositoriesInput = ({
onRepositorySave: (saved) => onRepositorySave(saved, index),
onRepositoryCancel: () => onRepositoryRemove(index),
versionFilterPrecisionOptions,
integrations,
providerOptions,
});
};
@@ -183,7 +168,6 @@ export const WidgetMultiReleasesRepositoriesInput = ({
onClick={() =>
openImportModal({
repositories,
integrations,
versionFilterPrecisionOptions,
onConfirm: (selectedRepositories) => {
if (!selectedRepositories.length) return;
@@ -210,22 +194,22 @@ export const WidgetMultiReleasesRepositoriesInput = ({
<Divider my="sm" />
{repositories.map((repository, index) => {
const integration = repository.providerIntegrationId
? integrations[repository.providerIntegrationId]
: undefined;
const providerName = repository.provider ? getReleaseProviderName(repository.provider) : "";
return (
<Stack key={repository.id} gap={5}>
<Group align="center" gap="xs">
<Image
src={repository.iconUrl ?? integration?.iconUrl ?? null}
src={
repository.iconUrl ?? (repository.provider ? getReleaseProviderIconUrl(repository.provider) : null)
}
style={{
height: "1.2em",
width: "1.2em",
}}
/>
<Text c="dimmed" fw={100} size="xs">
{integration?.name ?? ""}
<Text c="dimmed" size="xs">
{providerName}
</Text>
<Group justify="space-between" align="center" style={{ flex: 1 }} gap={5}>
@@ -242,7 +226,7 @@ export const WidgetMultiReleasesRepositoriesInput = ({
repository,
onRepositorySave: (saved) => onRepositorySave(saved, index),
versionFilterPrecisionOptions,
integrations,
providerOptions,
})
}
variant="light"
@@ -269,10 +253,141 @@ export const WidgetMultiReleasesRepositoriesInput = ({
);
})}
</Stack>
{itemId && <ProviderTokensSection itemId={itemId} repositories={repositories} />}
</Fieldset>
);
};
const providersWithAuth: ReleaseProviderKind[] = [
"github",
"gitHubContainerRegistry",
"dockerHub",
"gitlab",
"npm",
"codeberg",
];
const ProviderTokensSection = ({ itemId, repositories }: { itemId: string; repositories: ReleasesRepository[] }) => {
const tRepository = useScopedI18n("widget.releases.option.repositories");
const { data: configuredKinds = [], refetch } = clientApi.widget.secrets.getConfiguredKinds.useQuery({ itemId });
const setSecret = clientApi.widget.secrets.setSecret.useMutation({
onSuccess: () => refetch(),
onError: (error) => {
showErrorNotification({
title: "Failed to save token",
message: error.message,
});
},
});
const deleteSecret = clientApi.widget.secrets.deleteSecret.useMutation({
onSuccess: () => refetch(),
onError: (error) => {
showErrorNotification({
title: "Failed to delete token",
message: error.message,
});
},
});
const authProviders = useMemo(() => {
const usedProviders = repositories.map((r) => r.provider).filter((p): p is ReleaseProviderKind => p !== undefined);
const configuredProviders = configuredKinds.filter((k): k is ReleaseProviderKind =>
providersWithAuth.includes(k as ReleaseProviderKind),
);
return [...new Set([...usedProviders, ...configuredProviders])].filter((p) => providersWithAuth.includes(p));
}, [repositories, configuredKinds]);
if (authProviders.length === 0) return null;
return (
<>
<Divider my="sm" label={tRepository("tokens.label")} labelPosition="center" />
<Stack gap="xs">
{authProviders.map((provider) => (
<ProviderTokenInput
key={provider}
provider={provider}
hasToken={configuredKinds.includes(provider)}
onSave={async (value) => {
await setSecret.mutateAsync({ itemId, kind: provider, value });
}}
onDelete={async () => {
await deleteSecret.mutateAsync({ itemId, kind: provider });
}}
/>
))}
</Stack>
</>
);
};
const ProviderTokenInput = ({
provider,
hasToken,
onSave,
onDelete,
}: {
provider: ReleaseProviderKind;
hasToken: boolean;
onSave: (value: string) => Promise<void>;
onDelete: () => Promise<void>;
}) => {
const tRepository = useScopedI18n("widget.releases.option.repositories");
const [value, setValue] = useState("");
const [editing, setEditing] = useState(false);
const [saving, setSaving] = useState(false);
const handleSave = async () => {
if (!value.trim()) return;
setSaving(true);
try {
await onSave(value.trim());
setValue("");
setEditing(false);
} finally {
setSaving(false);
}
};
const handleDelete = async () => {
setSaving(true);
try {
await onDelete();
setValue("");
setEditing(false);
} finally {
setSaving(false);
}
};
return (
<Group gap="xs" align="end">
<PasswordInput
label={`${getReleaseProviderName(provider)} ${tRepository("tokens.token")}`}
placeholder={hasToken ? tRepository("tokens.configured") : tRepository("tokens.notConfigured")}
value={value}
onChange={(e) => {
setValue(e.currentTarget.value);
setEditing(true);
}}
style={{ flex: 1 }}
size="xs"
leftSection={<IconKey size={14} />}
/>
{editing && value.trim() && (
<Button size="xs" onClick={handleSave} loading={saving}>
{tRepository("tokens.save")}
</Button>
)}
{hasToken && !editing && (
<ActionIcon variant="light" color="red" size="sm" onClick={handleDelete} loading={saving}>
<IconTrash size={14} />
</ActionIcon>
)}
</Group>
);
};
const formatVersionFilterRegex = (versionFilter: ReleasesVersionFilter | undefined) => {
if (!versionFilter) return undefined;
@@ -294,7 +409,7 @@ interface RepositoryEditProps {
onRepositorySave: (repository: ReleasesRepository) => FormValidation;
onRepositoryCancel?: () => void;
versionFilterPrecisionOptions: string[];
integrations: Record<string, Integration>;
providerOptions: { value: ReleaseProviderKind; label: string }[];
}
const RepositoryEditModal = createModal<RepositoryEditProps>(({ innerProps, actions }) => {
@@ -302,11 +417,6 @@ const RepositoryEditModal = createModal<RepositoryEditProps>(({ innerProps, acti
const [loading, setLoading] = useState(false);
const [tempRepository, setTempRepository] = useState(() => ({ ...innerProps.repository }));
const [formErrors, setFormErrors] = useState<FormErrors>({});
const integrationSelectOptions: IntegrationSelectOption[] = useMemo(
() => Object.values(innerProps.integrations),
[innerProps.integrations],
);
// Allows user to not select an icon by removing the url from the input,
// will only try and get an icon if the name or identifier changes
const [autoSetIcon, setAutoSetIcon] = useState(false);
@@ -361,15 +471,14 @@ const RepositoryEditModal = createModal<RepositoryEditProps>(({ innerProps, acti
<Stack>
<Group align="start" wrap="nowrap" grow preventGrowOverflow={false}>
<div style={{ flex: 0.3 }}>
<WidgetIntegrationSelect
canSelectMultiple={false}
<Select
withAsterisk
label={tRepository("provider.label")}
data={integrationSelectOptions}
value={tempRepository.providerIntegrationId ? [tempRepository.providerIntegrationId] : []}
error={formErrors[`${innerProps.fieldPath}.providerIntegrationId`] as string}
data={innerProps.providerOptions}
value={tempRepository.provider ?? null}
error={formErrors[`${innerProps.fieldPath}.provider`] as string}
onChange={(value) => {
handleChange({ providerIntegrationId: value.length > 0 ? value.pop() : undefined });
handleChange({ provider: value ? (value as ReleaseProviderKind) : undefined });
}}
/>
</div>
@@ -397,6 +506,16 @@ const RepositoryEditModal = createModal<RepositoryEditProps>(({ innerProps, acti
/>
</Group>
<TextInput
label={tRepository("providerUrl.label")}
placeholder={tempRepository.provider ? getReleaseProviderDefaultUrl(tempRepository.provider) : undefined}
value={tempRepository.providerUrl ?? ""}
onChange={(event) => {
handleChange({ providerUrl: event.currentTarget.value || undefined });
}}
error={formErrors[`${innerProps.fieldPath}.providerUrl`]}
/>
<Group align="center" wrap="nowrap" grow preventGrowOverflow={false}>
<TextInput
label={tRepository("name.label")}
@@ -513,7 +632,6 @@ interface ReleasesRepositoryImport extends ReleasesRepository {
interface ImportRepositorySelectProps {
repository: ReleasesRepositoryImport;
checked: boolean;
integration?: Integration;
versionFilterPrecisionOptions: string[];
disabled: boolean;
onImageSelectionChanged?: (isSelected: boolean) => void;
@@ -522,12 +640,12 @@ interface ImportRepositorySelectProps {
const ImportRepositorySelect = ({
repository,
checked,
integration,
versionFilterPrecisionOptions,
disabled = false,
onImageSelectionChanged = undefined,
}: ImportRepositorySelectProps) => {
const tRepository = useScopedI18n("widget.releases.option.repositories");
const provider = repository.provider;
return (
<Group gap="xl" justify="space-between">
@@ -570,12 +688,12 @@ const ImportRepositorySelect = ({
)}
</Group>
<Tooltip label={tRepository("noProvider.tooltip")} disabled={integration !== undefined} withArrow>
<Tooltip label={tRepository("noProvider.tooltip")} disabled={provider !== undefined} withArrow>
<Group>
{integration ? (
{provider ? (
<MaskedImage
color="dimmed"
imageUrl={integration.iconUrl}
imageUrl={getReleaseProviderIconUrl(provider)}
style={{
height: "1em",
width: "1em",
@@ -586,7 +704,7 @@ const ImportRepositorySelect = ({
)}
<Text ff="monospace" c="dimmed" size="sm">
{integration?.name ?? tRepository("noProvider.label")}
{provider ? getReleaseProviderName(provider) : tRepository("noProvider.label")}
</Text>
</Group>
</Tooltip>
@@ -596,7 +714,6 @@ const ImportRepositorySelect = ({
interface RepositoryImportProps {
repositories: ReleasesRepository[];
integrations: Record<string, Integration>;
versionFilterPrecisionOptions: string[];
onConfirm: (selectedRepositories: ReleasesRepositoryImport[]) => void;
isAdmin: boolean;
@@ -609,8 +726,6 @@ const RepositoryImportModal = createModal<RepositoryImportProps>(({ innerProps,
const docker = clientApi.docker.getContainers.useQuery(undefined, {
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
enabled: innerProps.isAdmin,
});
@@ -625,28 +740,25 @@ const RepositoryImportModal = createModal<RepositoryImportProps>(({ innerProps,
if (!identifier) return acc;
const providerKind = containerImageToProviderKind[source] ?? "dockerHub";
const integrationId = Object.values(innerProps.integrations).find(
(integration) => integration.kind === providerKind,
)?.id;
const provider = containerImageToProviderKind[source] ?? "dockerHub";
const normalizedIdentifier = normalizeReleaseProviderIdentifier(provider, identifier);
if (acc.some((item) => item.providerIntegrationId === integrationId && item.identifier === identifier))
return acc;
if (acc.some((item) => item.provider === provider && item.identifier === normalizedIdentifier)) return acc;
acc.push({
id: createId(),
providerIntegrationId: integrationId,
identifier,
provider,
identifier: normalizedIdentifier,
iconUrl: container.iconUrl ?? undefined,
name: formatIdentifierName(identifier),
name: formatIdentifierName(normalizedIdentifier),
versionFilter: version ? parseImageVersionToVersionFilter(version) : undefined,
alreadyImported: innerProps.repositories.some(
(item) => item.providerIntegrationId === integrationId && item.identifier === identifier,
(item) => item.provider === provider && item.identifier === normalizedIdentifier,
),
});
return acc;
}, []) ?? [],
[docker.data, innerProps.repositories, innerProps.integrations],
[docker.data, innerProps.repositories],
);
const handleConfirm = useCallback(() => {
@@ -722,27 +834,20 @@ const RepositoryImportModal = createModal<RepositoryImportProps>(({ innerProps,
{importRepositories
.filter((repository) => !repository.alreadyImported)
.map((repository) => {
const integration = repository.providerIntegrationId
? innerProps.integrations[repository.providerIntegrationId]
: undefined;
return (
<ImportRepositorySelect
key={repository.id}
repository={repository}
checked={selectedImages.includes(repository)}
integration={integration}
versionFilterPrecisionOptions={innerProps.versionFilterPrecisionOptions}
disabled={false}
onImageSelectionChanged={(isSelected) =>
isSelected
? setSelectedImages([...selectedImages, repository])
: setSelectedImages(selectedImages.filter((img) => img !== repository))
}
/>
);
})}
.map((repository) => (
<ImportRepositorySelect
key={repository.id}
repository={repository}
checked={selectedImages.includes(repository)}
versionFilterPrecisionOptions={innerProps.versionFilterPrecisionOptions}
disabled={false}
onImageSelectionChanged={(isSelected) =>
isSelected
? setSelectedImages([...selectedImages, repository])
: setSelectedImages(selectedImages.filter((img) => img !== repository))
}
/>
))}
</Stack>
)}
</Accordion.Panel>
@@ -756,22 +861,15 @@ const RepositoryImportModal = createModal<RepositoryImportProps>(({ innerProps,
<Stack justify="center" gap="xs">
{importRepositories
.filter((repository) => repository.alreadyImported)
.map((repository) => {
const integration = repository.providerIntegrationId
? innerProps.integrations[repository.providerIntegrationId]
: undefined;
return (
<ImportRepositorySelect
key={repository.id}
repository={repository}
integration={integration}
versionFilterPrecisionOptions={innerProps.versionFilterPrecisionOptions}
checked
disabled
/>
);
})}
.map((repository) => (
<ImportRepositorySelect
key={repository.id}
repository={repository}
versionFilterPrecisionOptions={innerProps.versionFilterPrecisionOptions}
checked
disabled
/>
))}
</Stack>
)}
</Accordion.Panel>
@@ -798,8 +896,8 @@ const RepositoryImportModal = createModal<RepositoryImportProps>(({ innerProps,
size: "xl",
});
const containerImageToProviderKind: Record<string, IntegrationKind> = {
"ghcr.io": "github",
const containerImageToProviderKind: Record<string, ReleaseProviderKind> = {
"ghcr.io": "gitHubContainerRegistry",
"docker.io": "dockerHub",
"lscr.io": "linuxServerIO",
"quay.io": "quay",
+4 -12
View File
@@ -145,18 +145,10 @@ interface AnchorNoteWidgetContentProps {
const AnchorNoteWidgetContent = ({ options, integrationId, noteId }: AnchorNoteWidgetContentProps) => {
const t = useScopedI18n("widget.anchorNote");
const { data: note, refetch } = clientApi.widget.anchorNotes.getNote.useQuery(
{
integrationId,
noteId,
},
{
staleTime: 15 * 1000,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
retry: false,
},
);
const { data: note, refetch } = clientApi.widget.anchorNotes.getNote.useQuery({
integrationId,
noteId,
});
const { mutateAsync: updateNoteAsync, isPending: isUpdating } = clientApi.widget.anchorNotes.updateNote.useMutation();
const [isEditing, setIsEditing] = useState(false);
+2
View File
@@ -8,6 +8,8 @@
transition: scale 0.2s ease-in-out;
user-select: none;
-webkit-touch-callout: none;
-webkit-user-drag: none;
touch-action: manipulation;
}
.appWithUrl:hover > .appIcon {
+4 -10
View File
@@ -23,16 +23,9 @@ 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,
},
{
refetchOnWindowFocus: false,
refetchOnReconnect: false,
retry: false,
},
);
const { data: app } = clientApi.app.byId.useQuery({
id: options.appId,
});
useRegisterSpotlightContextResults(
`app-${app?.id ?? options.appId}`,
app?.href
@@ -87,6 +80,7 @@ export default function AppWidget({ options, isEditMode, height, width }: Widget
justify="center"
align="center"
gap={isColumnLayout ? 0 : "sm"}
onContextMenu={isEditMode ? (e) => e.preventDefault() : undefined}
>
<Stack gap={0}>
{options.showTitle && (
@@ -11,20 +11,7 @@ interface PingIndicatorProps {
export const PingIndicator = ({ appId }: PingIndicatorProps) => {
const t = useI18n();
const { data: pingResult } = clientApi.widget.app.ping.useQuery(
{ id: appId },
{ refetchOnMount: false, refetchOnWindowFocus: false },
);
const utils = clientApi.useUtils();
clientApi.widget.app.updatedPing.useSubscription(
{ id: appId },
{
onData(data) {
utils.widget.app.ping.setData({ id: appId }, data);
},
},
);
const { data: pingResult } = clientApi.widget.app.ping.useQuery({ id: appId }, { refetchOnMount: false });
if (!pingResult) return <PingDot icon={IconMinus} color="gray" tooltip={`${t("common.action.loading")}…`} />;
@@ -28,20 +28,7 @@ const ArchiveTeamWarriorWidgetContent = ({
integrationId: string;
options: WidgetComponentProps<"archiveTeamWarrior">["options"];
}) => {
const [data] = clientApi.widget.archiveTeamWarrior.getStatus.useSuspenseQuery({
integrationId,
});
const utils = clientApi.useUtils();
clientApi.widget.archiveTeamWarrior.subscribeStatus.useSubscription(
{ integrationId },
{
onData: (next) => {
utils.widget.archiveTeamWarrior.getStatus.setData({ integrationId }, next);
},
},
);
const [data] = clientApi.widget.archiveTeamWarrior.getStatus.useSuspenseQuery({ integrationId });
const status = data.status;
const projectName = status.project?.title ?? status.selectedProject ?? "No project selected";
@@ -7,10 +7,9 @@ import type { WidgetComponentProps } from "../definition";
import { AudioStatsContent } from "./audio-stats-content";
export default function AudioStatsWidget({ integrationIds, options, width }: WidgetComponentProps<"audioStats">) {
const { data: response } = clientApi.widget.audioStats.getStats.useQuery(
{ integrationId: integrationIds[0] ?? "" },
{ staleTime: 5 * 60 * 1000 },
);
const { data: response } = clientApi.widget.audioStats.getStats.useQuery({
integrationId: integrationIds[0] ?? "",
});
if (!response) return <WidgetEmptyState />;
@@ -0,0 +1,77 @@
.root {
container-type: size;
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
padding: clamp(6px, 3cqi, 14px);
gap: clamp(4px, 2cqi, 10px);
overflow: hidden;
}
.grid {
display: grid;
flex: 1;
min-height: 0;
gap: clamp(4px, 2cqi, 10px);
grid-template-columns: repeat(var(--stat-cols, 2), minmax(0, 1fr));
grid-auto-rows: 1fr;
align-content: stretch;
}
.statTile {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 0;
min-width: 0;
gap: clamp(2px, 1.5cqi, 5px);
padding: clamp(4px, 2.5cqi, 10px);
border-radius: var(--mantine-radius-md);
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
overflow: hidden;
}
.statTileWarning {
background-color: light-dark(var(--mantine-color-orange-0), var(--mantine-color-dark-5));
}
.statIcon {
color: var(--mantine-color-dimmed);
flex-shrink: 0;
}
.statLabel {
font-size: clamp(8px, 2.5cqi, 12px);
color: var(--mantine-color-dimmed);
line-height: 1.2;
text-align: center;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.statValue {
font-size: clamp(14px, 5.5cqi, 24px);
font-weight: 700;
color: var(--mantine-primary-color-filled);
line-height: 1.1;
text-align: center;
}
.statValueWarning {
color: var(--mantine-color-orange-6);
}
.emptyState {
display: flex;
flex: 1;
align-items: center;
justify-content: center;
min-height: 0;
color: var(--mantine-color-dimmed);
font-size: clamp(11px, 3cqi, 14px);
text-align: center;
}
+103
View File
@@ -0,0 +1,103 @@
"use client";
import type { CSSProperties } from "react";
import { Text } from "@mantine/core";
import { IconAlertTriangle, IconDeviceTv, IconMovie, IconPlugConnected } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useScopedI18n } from "@homarr/translation/client";
import { WidgetEmptyState } from "../common/empty-state";
import type { WidgetComponentProps } from "../definition";
import classes from "./component.module.css";
const statVisibilityByOption = {
showMissingEpisodes: "episodes",
showMissingMovies: "movies",
showProviderIssues: "providers",
showHealthIssues: "status",
} as const;
const statIcons = {
episodes: IconDeviceTv,
movies: IconMovie,
providers: IconPlugConnected,
status: IconAlertTriangle,
} as const;
const gridColsByWidth = [
{ minWidth: 380, cols: 2 },
{ minWidth: 0, cols: 1 },
] as const;
const iconSizeByWidth = [
{ minWidth: 320, size: 22 },
{ minWidth: 200, size: 18 },
{ minWidth: 0, size: 16 },
] as const;
export default function BazarrWidget({ integrationIds, options, width }: WidgetComponentProps<"bazarr">) {
const t = useScopedI18n("widget.bazarr");
const { data: badges } = clientApi.widget.bazarr.getBadges.useQuery(
{ integrationId: integrationIds[0] ?? "" },
{ enabled: Boolean(integrationIds[0]) },
);
if (!badges) return <WidgetEmptyState />;
const statValues = {
episodes: badges.episodes,
movies: badges.movies,
providers: badges.providers,
status: badges.status,
} as const;
const visibleStatKeys = Object.entries(statVisibilityByOption)
.filter(([optionKey]) => options[optionKey as keyof typeof options])
.map(([, statKey]) => statKey);
const gridCols = getGridCols(width);
const iconSize = getIconSize(width);
if (visibleStatKeys.length === 0) {
return (
<div className={classes.root}>
<div className={classes.emptyState}>
<Text size="sm" c="dimmed">
—
</Text>
</div>
</div>
);
}
return (
<div className={classes.root}>
<div className={classes.grid} style={{ "--stat-cols": gridCols } as CSSProperties}>
{visibleStatKeys.map((statKey) => {
const Icon = statIcons[statKey];
const value = statValues[statKey];
const isWarning = (statKey === "providers" || statKey === "status") && value > 0;
return (
<div key={statKey} className={`${classes.statTile} ${isWarning ? classes.statTileWarning : ""}`}>
<Icon className={classes.statIcon} size={iconSize} stroke={1.5} />
<span className={`${classes.statValue} ${isWarning ? classes.statValueWarning : ""}`}>{value}</span>
<span className={classes.statLabel}>{t(statKey)}</span>
</div>
);
})}
</div>
</div>
);
}
function getGridCols(width: number): number {
const match = gridColsByWidth.find(({ minWidth }) => width >= minWidth);
return match?.cols ?? 1;
}
function getIconSize(width: number): number {
const match = iconSizeByWidth.find(({ minWidth }) => width >= minWidth);
return match?.size ?? 16;
}
+36
View File
@@ -0,0 +1,36 @@
import { IconBadgeCc, IconServerOff } from "@tabler/icons-react";
import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("bazarr", {
icon: IconBadgeCc,
supportedIntegrations: ["bazarr"],
integrationsRequired: true,
createOptions() {
return optionsBuilder.from((factory) => ({
showMissingEpisodes: factory.switch({
defaultValue: true,
withDescription: true,
}),
showMissingMovies: factory.switch({
defaultValue: true,
withDescription: true,
}),
showProviderIssues: factory.switch({
defaultValue: true,
withDescription: true,
}),
showHealthIssues: factory.switch({
defaultValue: true,
withDescription: true,
}),
}));
},
errors: {
INTERNAL_SERVER_ERROR: {
icon: IconServerOff,
message: (t) => t("widget.bazarr.error.internalServerError"),
},
},
}).withDynamicImport(() => import("./component"));
@@ -41,36 +41,7 @@ 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, {
staleTime: 10_000,
gcTime: 48 * 60 * 60 * 1000,
refetchInterval: 10_000,
retry: false,
});
const utils = clientApi.useUtils();
clientApi.widget.beszel.subscribeAlerts.useSubscription(alertsInput, {
enabled: !isEditMode,
onData(data) {
utils.widget.beszel.getAlerts.setData(alertsInput, (prev) => {
if (!prev) return prev;
return prev.map((r) =>
r.integrationId === data.integrationId
? {
...r,
alerts: data.alerts.alerts,
history: data.alerts.history,
updatedAt: data.timestamp,
}
: r,
);
});
},
});
const { data: results = [], error: alertsError, isPending } = clientApi.widget.beszel.getAlerts.useQuery(alertsInput);
const systemNameMap = useMemo(() => {
const map: Record<string, string> = {};
@@ -27,10 +27,18 @@ import classes from "./component.module.css";
import type { WidgetComponentProps } from "../definition";
import type { BeszelSystemRow } from "../beszel/_shared/types";
import { statusColorMap, thresholdColor } from "../beszel/_shared/colors";
import { formatByteRate, formatLoadAvg, formatPercent, formatTemp, formatUptime } from "../beszel/_shared/format";
import { useBeszelFilteredSystems, useBeszelSystemsSubscription } from "../beszel/_shared/hooks";
import {
formatByteRate,
formatLoadAvg,
formatPercent,
formatTemp,
formatUptime,
getProgressTrackSize,
} from "../beszel/_shared/format";
import { useBeszelFilteredSystems } from "../beszel/_shared/hooks";
import { BeszelIntegrationErrorIndicator } from "../beszel/_shared/error-indicator";
import { BeszelSystemStatsModal } from "../beszel/_shared/system-stats-modal";
import { DiskUsage } from "../beszel/_shared/disk-usage";
interface SizeConfig {
iconSize: number;
@@ -106,22 +114,14 @@ const getMaxVisibleMetrics = (cellHeight: number, size: SizeConfig): number => {
return Math.max(1, Math.floor(available / size.rowHeight));
};
const MIN_CELL_WIDTH = 120;
const MIN_CELL_WIDTH = 180;
const MIN_CELL_HEIGHT = 80;
const getColCount = (width: number, itemCount: number): number => {
const getColCount = (width: number, height: number, itemCount: number): number => {
if (itemCount <= 1) return 1;
const maxCols = Math.min(itemCount, Math.max(1, Math.floor(width / MIN_CELL_WIDTH)));
let best = 1;
for (let c = 1; c <= maxCols; c++) {
const emptyCells = (c - (itemCount % c)) % c;
const bestEmpty = (best - (itemCount % best)) % best;
if (emptyCells < bestEmpty || (emptyCells === bestEmpty && c > best)) {
best = c;
}
}
return best;
const aspectRatio = width / Math.max(height, MIN_CELL_HEIGHT);
return Math.min(maxCols, Math.max(1, Math.ceil(Math.sqrt(itemCount * aspectRatio))));
};
interface MetricRowProps {
@@ -133,20 +133,25 @@ interface MetricRowProps {
}
const MetricValue = ({ value, progress, size }: Pick<MetricRowProps, "value" | "progress" | "size">) => (
<Group gap={6} wrap="nowrap" style={{ flex: 1 }}>
<Text size={size.fontSize} fw={500} miw={size.valueMiw} ta="right">
<Group gap={6} wrap="nowrap" style={{ flex: 1, minWidth: 0, marginLeft: "auto" }}>
<Text size={size.fontSize} fw={500} w={size.valueMiw} ta="left" style={{ whiteSpace: "nowrap", flexShrink: 0 }}>
{value}
</Text>
{progress && (
<Progress value={progress.value} color={progress.color} size={size.progressSize} style={{ flex: 1 }} />
<Progress
value={progress.value}
color={progress.color}
size={getProgressTrackSize(size.progressSize)}
style={{ flex: 1 }}
/>
)}
</Group>
);
const MetricRow = ({ icon, label, value, progress, size }: MetricRowProps) => (
<Group gap="xs" wrap="nowrap" style={{ minHeight: size.rowHeight }}>
<Group gap="xs" wrap="nowrap" justify="space-between" style={{ minHeight: size.rowHeight }}>
{icon}
<Text size={size.fontSize} c="dimmed" miw={size.labelMiw}>
<Text size={size.fontSize} c="dimmed" w={size.labelMiw} style={{ whiteSpace: "nowrap", flexShrink: 0 }}>
{label}
</Text>
<MetricValue value={value} progress={progress} size={size} />
@@ -195,14 +200,13 @@ const metricRenderers = [
{
key: "showDisk",
render: (s: BeszelSystemRow, t: SystemCardProps["t"], sz: SizeConfig) => (
<MetricRow
key="disk"
icon={<HardDrive size={sz.iconSize} />}
label={t("metric.disk")}
value={formatPercent(s.disk)}
progress={{ value: s.disk, color: thresholdColor(s.disk) }}
size={sz}
/>
<Group key="disk" gap="xs" wrap="nowrap" justify="space-between" style={{ minHeight: sz.rowHeight }}>
<HardDrive size={sz.iconSize} />
<Text size={sz.fontSize} c="dimmed" w={sz.labelMiw} style={{ whiteSpace: "nowrap", flexShrink: 0 }}>
{t("metric.disk")}
</Text>
<DiskUsage system={s} fontSize={sz.fontSize} progressSize={sz.progressSize} valueMiw={sz.valueMiw} />
</Group>
),
visible: (s: BeszelSystemRow, o: SystemCardProps["options"]) => o.showDisk,
},
@@ -369,12 +373,8 @@ export default function BeszelSystemGridWidget({
data: results = [],
error: systemsError,
isPending,
} = clientApi.widget.beszel.getSystems.useQuery(
{ integrationIds },
{ staleTime: 10_000, gcTime: 48 * 60 * 60 * 1000, refetchInterval: 10_000, retry: false },
);
} = clientApi.widget.beszel.getSystems.useQuery({ integrationIds });
useBeszelSystemsSubscription(integrationIds, !isEditMode);
const filteredSystems = useBeszelFilteredSystems(results, options.statusFilter);
if (systemsError) throw systemsError;
@@ -403,7 +403,7 @@ export default function BeszelSystemGridWidget({
);
}
const cols = getColCount(width, filteredSystems.length);
const cols = getColCount(width, height, filteredSystems.length);
const rows = Math.ceil(filteredSystems.length / cols) || 1;
const rawCellHeight = height / rows;
const scrollEnabled = rawCellHeight < MIN_CELL_HEIGHT;
@@ -6,6 +6,7 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("beszelSystemGrid", {
icon: IconLayoutGrid,
queryKey: [["widget", "beszel"]],
supportedIntegrations: ["beszel", "mock"],
integrationsRequired: true,
createOptions() {
@@ -1,53 +1,21 @@
"use client";
import { useCallback, useMemo } from "react";
import { Button, Box, Center, Loader, Menu, ScrollArea, Select, SimpleGrid, Stack, Text } from "@mantine/core";
import { IconPlugConnectedX, IconServer, IconQuestionMark, IconServerOff } from "@tabler/icons-react";
import { Box, Button, Center, Loader, Menu, ScrollArea, Select, Stack, Text } from "@mantine/core";
import { IconQuestionMark, IconServer, IconServerOff } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import { constructBoardPermissions } from "@homarr/auth/shared";
import { useOptionalBoard } from "@homarr/boards/context";
import type { BeszelContainerStatsRecord, BeszelSystemStatsRecord } from "@homarr/integrations/types";
import { useScopedI18n } from "@homarr/translation/client";
import classes from "./component.module.css";
import type { WidgetComponentProps } from "../definition";
import { containerColors } from "../beszel/_shared/colors";
import type { BeszelTimePeriod } from "../beszel/_shared/chart";
import {
BeszelChartPanel,
CPU_Y_AXIS_DOMAIN,
useContainerNames,
useDockerChartData,
useSystemChartData,
} from "../beszel/_shared/chart";
import {
chartAxisFormatters,
formatByteRate,
formatGB,
formatPercent,
formatStorageBytes,
} from "../beszel/_shared/format";
import { BeszelIntegrationErrorIndicator } from "../beszel/_shared/error-indicator";
import { makeTooltipProps } from "../beszel/_shared/tooltip";
import { useLiveStats } from "../beszel/_shared/use-live-stats";
const tooltipPercent = makeTooltipProps(formatPercent);
const tooltipGB = makeTooltipProps(formatGB, true);
const tooltipRate = makeTooltipProps(formatByteRate, true);
const tooltipPercentTotal = makeTooltipProps(formatPercent, true);
const tooltipBytesTotal = makeTooltipProps(formatStorageBytes, true);
const CHART_HEIGHT = 180;
const MB = 1024 * 1024;
const gridColumns = [1, 2] as const;
function statsWhenShown<T>(show: boolean | undefined, data: T | undefined): T | undefined {
if (!show || !data) return undefined;
return data;
}
import { BeszelStatsView } from "../beszel/_shared/stats-view";
export default function BeszelSystemStatsWidget({
options,
@@ -67,20 +35,15 @@ export default function BeszelSystemStatsWidget({
data: systemsResult = [],
isPending: systemsPending,
error: systemsError,
} = clientApi.widget.beszel.getSystems.useQuery(
{ integrationIds },
{ staleTime: 10_000, gcTime: 48 * 60 * 60 * 1000, retry: false },
);
} = clientApi.widget.beszel.getSystems.useQuery({ integrationIds });
const systems = useMemo(
() => systemsResult.flatMap((r) => r.systems.map((s) => ({ value: s.id, label: s.name }))),
() => systemsResult.flatMap((result) => result.systems.map((system) => ({ value: system.id, label: system.name }))),
[systemsResult],
);
const selectedSystem = options.systemId || systems[0]?.value || "";
const selectedLabel = systems.find((s) => s.value === selectedSystem)?.label;
const systemExists = systems.some((s) => s.value === selectedSystem);
const systemReady = !systemsPending && (selectedSystem === "" || systemExists);
const selectedLabel = systems.find((system) => system.value === selectedSystem)?.label;
const systemExists = systems.some((system) => system.value === selectedSystem);
const handleSelectSystem = useCallback(
(value: string) => {
@@ -92,122 +55,13 @@ export default function BeszelSystemStatsWidget({
[setOptions, hasChangeAccess, boardId, itemId, saveItemOptions],
);
const showDocker = options.showDockerCpu || options.showDockerMemory || options.showDockerNetwork;
const isLive = options.timePeriod === "1m";
const { data: statsResult, error: statsError } = clientApi.widget.beszel.getSystemStats.useQuery(
{
integrationIds,
systemId: selectedSystem,
timePeriod: options.timePeriod as "1m" | "1h" | "12h" | "24h" | "1w" | "30d",
includeDocker: showDocker,
},
{
staleTime: 10_000,
gcTime: 48 * 60 * 60 * 1000,
refetchInterval: isLive ? false : 10_000,
enabled: !isLive && systemReady && selectedSystem !== "",
retry: false,
},
);
const { data: liveData, error: liveError } = useLiveStats(integrationIds, selectedSystem, isLive && systemReady);
let activeStats:
| { systemStats: BeszelSystemStatsRecord[]; containerStats: BeszelContainerStatsRecord[]; error?: string }
| null
| undefined = statsResult;
if (isLive) {
activeStats = liveData;
}
const mappers = useMemo(
() => ({
cpu: (s: { cpu: number }) => ({ [t("chart.cpu.series")]: s.cpu }),
memory: (s: { mu: number; mb: number }) => ({
[t("chart.memory.series")]: s.mu,
[t("chart.memory.cache")]: s.mb ?? 0,
}),
disk: (s: { du: number }) => ({ [t("chart.disk.series")]: s.du }),
diskIO: (s: { dr?: number; dw?: number }) => ({
[t("chart.diskIO.read")]: (s.dr ?? 0) * MB,
[t("chart.diskIO.write")]: (s.dw ?? 0) * MB,
}),
network: (s: { ns: number; nr: number; b?: [number, number] }) => ({
[t("chart.network.sent")]: s.b?.[0] ?? s.ns,
[t("chart.network.recv")]: s.b?.[1] ?? s.nr,
}),
}),
[t],
);
const series = useMemo(
() => ({
cpu: [{ name: t("chart.cpu.series"), color: "teal.6" }],
memory: [
{ name: t("chart.memory.series"), color: "teal.5" },
{ name: t("chart.memory.cache"), color: "teal.8" },
],
disk: [{ name: t("chart.disk.series"), color: "grape.6" }],
diskIO: [
{ name: t("chart.diskIO.write"), color: "orange.6" },
{ name: t("chart.diskIO.read"), color: "blue.6" },
],
network: [
{ name: t("chart.network.sent"), color: "blue.6" },
{ name: t("chart.network.recv"), color: "teal.6" },
],
}),
[t],
);
const systemStats = activeStats?.systemStats;
const containerStatsRaw = activeStats?.containerStats;
const timePeriod = options.timePeriod as BeszelTimePeriod;
const cpuData = useSystemChartData(statsWhenShown(options.showCpu, systemStats), mappers.cpu, timePeriod);
const memoryData = useSystemChartData(statsWhenShown(options.showMemory, systemStats), mappers.memory, timePeriod);
const diskData = useSystemChartData(statsWhenShown(options.showDisk, systemStats), mappers.disk, timePeriod);
const diskIOData = useSystemChartData(statsWhenShown(options.showDiskIO, systemStats), mappers.diskIO, timePeriod);
const networkData = useSystemChartData(statsWhenShown(options.showNetwork, systemStats), mappers.network, timePeriod);
const containerNames = useContainerNames(statsWhenShown(showDocker, containerStatsRaw));
const dockerCpuData = useDockerChartData(
statsWhenShown(options.showDockerCpu, containerStatsRaw),
containerNames,
"cpu",
timePeriod,
);
const dockerMemData = useDockerChartData(
statsWhenShown(options.showDockerMemory, containerStatsRaw),
containerNames,
"memory",
timePeriod,
);
const dockerNetData = useDockerChartData(
statsWhenShown(options.showDockerNetwork, containerStatsRaw),
containerNames,
"network",
timePeriod,
);
const containerSeries = useMemo(
() => containerNames.map((name, i) => ({ name, color: containerColors[i % containerColors.length] as string })),
[containerNames],
);
const cols = gridColumns[Number(width > 600)];
if (systemsError) throw systemsError;
if (!isLive && statsError) throw statsError;
if (systemsPending) {
if (systemsPending)
return (
<Center h="100%">
<Loader size="sm" />
</Center>
);
}
if (systems.length === 0) {
return (
@@ -225,7 +79,7 @@ export default function BeszelSystemStatsWidget({
);
}
if (!systemsPending && !systemExists && systems.length > 0) {
if (!systemExists) {
return (
<Center h="100%">
<Stack align="center" gap="sm" p="md">
@@ -245,22 +99,6 @@ export default function BeszelSystemStatsWidget({
);
}
if (isLive && liveError && !liveData) {
return (
<Center h="100%">
<Stack align="center" gap="sm" p="md">
<IconPlugConnectedX size={40} />
<Text size="sm" c="dimmed" ta="center">
{t("error.liveConnectionFailed")}
</Text>
<Button size="xs" variant="light" onClick={() => setOptions({ newOptions: { timePeriod: "1h" } })}>
{t("error.switchToHistorical")}
</Button>
</Stack>
</Center>
);
}
return (
<Box h="100%" pos="relative">
<Box pos="absolute" top={4} right={8} style={{ zIndex: 1 }}>
@@ -269,7 +107,7 @@ export default function BeszelSystemStatsWidget({
<ScrollArea
h="100%"
className={classes.beszelStatsContainer}
style={{ pointerEvents: (isEditMode && "none") || undefined }}
style={{ pointerEvents: isEditMode ? "none" : undefined }}
>
<Stack gap="md" p="sm">
{!isEditMode && systems.length > 1 && (
@@ -285,161 +123,37 @@ export default function BeszelSystemStatsWidget({
</Button>
</Menu.Target>
<Menu.Dropdown>
{systems.map((s) => (
{systems.map((system) => (
<Menu.Item
key={s.value}
key={system.value}
fz="xs"
fw={s.value === selectedSystem ? 600 : 400}
c={s.value !== selectedSystem ? "dimmed" : undefined}
onClick={() => handleSelectSystem(s.value)}
fw={system.value === selectedSystem ? 600 : 400}
c={system.value !== selectedSystem ? "dimmed" : undefined}
onClick={() => handleSelectSystem(system.value)}
>
{s.label}
{system.label}
</Menu.Item>
))}
</Menu.Dropdown>
</Menu>
)}
{!isLive && !activeStats && systemReady && selectedSystem !== "" && (
<Center h={CHART_HEIGHT}>
<Loader size="sm" />
</Center>
)}
{!isLive && activeStats && "error" in activeStats && activeStats.error && (
<Center h={CHART_HEIGHT}>
<Stack align="center" gap="xs">
<IconServerOff size={24} opacity={0.5} />
<Text size="sm" c="dimmed">
{activeStats.error}
</Text>
</Stack>
</Center>
)}
{activeStats && (!("error" in activeStats) || !activeStats.error) && (
<SimpleGrid cols={cols} spacing="md">
{options.showCpu && cpuData.length > 0 && (
<BeszelChartPanel
title={t("chart.cpu.title")}
subtitle={t("chart.cpu.subtitle")}
chartProps={{
h: CHART_HEIGHT,
data: cpuData,
series: series.cpu,
yAxisFormatter: chartAxisFormatters.percent,
yAxisDomain: CPU_Y_AXIS_DOMAIN,
tooltipProps: tooltipPercent,
}}
/>
)}
{options.showMemory && memoryData.length > 0 && (
<BeszelChartPanel
title={t("chart.memory.title")}
subtitle={t("chart.memory.subtitle")}
chartProps={{
h: CHART_HEIGHT,
data: memoryData,
type: "stacked",
series: series.memory,
yAxisFormatter: chartAxisFormatters.gb,
tooltipProps: tooltipGB,
}}
/>
)}
{options.showDisk && diskData.length > 0 && (
<BeszelChartPanel
title={t("chart.disk.title")}
subtitle={t("chart.disk.subtitle")}
chartProps={{
h: CHART_HEIGHT,
data: diskData,
series: series.disk,
yAxisFormatter: chartAxisFormatters.gb,
tooltipProps: tooltipGB,
}}
/>
)}
{options.showDiskIO && diskIOData.length > 0 && (
<BeszelChartPanel
title={t("chart.diskIO.title")}
subtitle={t("chart.diskIO.subtitle")}
chartProps={{
h: CHART_HEIGHT,
data: diskIOData,
series: series.diskIO,
yAxisFormatter: chartAxisFormatters.rate,
tooltipProps: tooltipRate,
}}
/>
)}
{options.showNetwork && networkData.length > 0 && (
<BeszelChartPanel
title={t("chart.network.title")}
subtitle={t("chart.network.subtitle")}
chartProps={{
h: CHART_HEIGHT,
data: networkData,
series: series.network,
yAxisFormatter: chartAxisFormatters.rate,
tooltipProps: tooltipRate,
}}
/>
)}
{showDocker && containerSeries.length > 0 && (
<>
{options.showDockerCpu && dockerCpuData.length > 0 && (
<BeszelChartPanel
title={t("chart.dockerCpu.title")}
subtitle={t("chart.dockerCpu.subtitle")}
chartProps={{
h: CHART_HEIGHT,
data: dockerCpuData,
type: "stacked",
series: containerSeries,
yAxisFormatter: chartAxisFormatters.percent,
tooltipProps: tooltipPercentTotal,
}}
/>
)}
{options.showDockerMemory && dockerMemData.length > 0 && (
<BeszelChartPanel
title={t("chart.dockerMemory.title")}
subtitle={t("chart.dockerMemory.subtitle")}
chartProps={{
h: CHART_HEIGHT,
data: dockerMemData,
type: "stacked",
series: containerSeries,
yAxisFormatter: chartAxisFormatters.bytes,
tooltipProps: tooltipBytesTotal,
}}
/>
)}
{options.showDockerNetwork && dockerNetData.length > 0 && (
<BeszelChartPanel
title={t("chart.dockerNetwork.title")}
subtitle={t("chart.dockerNetwork.subtitle")}
chartProps={{
h: CHART_HEIGHT,
data: dockerNetData,
series: containerSeries,
yAxisFormatter: chartAxisFormatters.rate,
tooltipProps: tooltipRate,
}}
/>
)}
</>
)}
</SimpleGrid>
)}
<BeszelStatsView
integrationIds={integrationIds}
systemId={selectedSystem}
timePeriod={options.timePeriod as BeszelTimePeriod}
columns={width > 600 ? 2 : 1}
visibility={{
cpu: options.showCpu,
memory: options.showMemory,
disk: options.showDisk,
diskIO: options.showDiskIO,
network: options.showNetwork,
dockerCpu: options.showDockerCpu,
dockerMemory: options.showDockerMemory,
dockerNetwork: options.showDockerNetwork,
}}
onSwitchToHistorical={() => setOptions({ newOptions: { timePeriod: "1h" } })}
/>
</Stack>
</ScrollArea>
</Box>
@@ -16,6 +16,7 @@ const timePeriodOptions = [
export const { definition, componentLoader } = createWidgetDefinition("beszelSystemStats", {
icon: IconChartAreaLine,
queryKey: [["widget", "beszel"]],
supportedIntegrations: ["beszel", "mock"],
integrationsRequired: true,
createOptions() {
@@ -28,10 +29,7 @@ export const { definition, componentLoader } = createWidgetDefinition("beszelSys
data = [],
isPending,
isError,
} = clientApi.widget.beszel.getSystems.useQuery(
{ integrationIds },
{ enabled: integrationIds.length > 0, staleTime: 30_000 },
);
} = clientApi.widget.beszel.getSystems.useQuery({ integrationIds }, { enabled: integrationIds.length > 0 });
const selectData = data.flatMap((r) => r.systems.map((s) => ({ value: s.id, label: s.name })));
return { data: selectData, isPending, isError };
},
@@ -26,10 +26,18 @@ import { useScopedI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition";
import type { BeszelSystemRow } from "../beszel/_shared/types";
import { loadAvgColor, statusColorMap, thresholdColor } from "../beszel/_shared/colors";
import { formatByteRate, formatLoadAvg, formatPercent, formatTemp, formatUptime } from "../beszel/_shared/format";
import { useBeszelFilteredSystems, useBeszelSystemsSubscription } from "../beszel/_shared/hooks";
import {
formatByteRate,
formatLoadAvg,
formatPercent,
formatTemp,
formatUptime,
getProgressTrackSize,
} from "../beszel/_shared/format";
import { useBeszelFilteredSystems } from "../beszel/_shared/hooks";
import { BeszelIntegrationErrorIndicator } from "../beszel/_shared/error-indicator";
import { BeszelSystemStatsModal } from "../beszel/_shared/system-stats-modal";
import { DiskUsage } from "../beszel/_shared/disk-usage";
const directionMultiplier: Record<string, number> = { asc: 1, desc: -1 };
@@ -74,13 +82,9 @@ export default function BeszelSystemTableWidget({
data: results = [],
error: systemsError,
isPending,
} = clientApi.widget.beszel.getSystems.useQuery(
{ integrationIds },
{ staleTime: 10_000, gcTime: 48 * 60 * 60 * 1000, refetchInterval: 10_000, retry: false },
);
} = clientApi.widget.beszel.getSystems.useQuery({ integrationIds });
const size = getSizeConfig(width);
useBeszelSystemsSubscription(integrationIds, !isEditMode);
const filteredSystems = useBeszelFilteredSystems(results, options.statusFilter);
const [sortStatus, setSortStatus] = useState<DataTableSortStatus<SystemRowWithKey>>({
@@ -101,11 +105,16 @@ export default function BeszelSystemTableWidget({
}, [filteredSystems, sortStatus]);
const PercentCell = ({ value }: { value: number }) => (
<Group gap={4} wrap="nowrap" style={{ flex: 1 }}>
<Text size={size.fontSize} fw={500} miw={size.valueMiw} ta="right" style={{ whiteSpace: "nowrap" }}>
<Group gap={8} wrap="nowrap" style={{ flex: 1 }}>
<Text size={size.fontSize} fw={500} w={size.valueMiw} ta="left" style={{ whiteSpace: "nowrap", flexShrink: 0 }}>
{formatPercent(value)}
</Text>
<Progress value={value} color={thresholdColor(value)} size={size.progressSize} style={{ flex: 1 }} />
<Progress
value={value}
color={thresholdColor(value)}
size={getProgressTrackSize(size.progressSize)}
style={{ flex: 1 }}
/>
</Group>
);
@@ -160,7 +169,15 @@ export default function BeszelSystemTableWidget({
</Group>
),
sortable: true,
render: (record) => <PercentCell value={record.disk} />,
render: (record) => (
<DiskUsage
system={record}
fontSize={size.fontSize}
progressSize={size.progressSize}
valueMiw={size.valueMiw}
valueGap={8}
/>
),
},
options.showGpu && {
accessor: "gpu",
@@ -25,6 +25,7 @@ const sortDirectionOptions = [
export const { definition, componentLoader } = createWidgetDefinition("beszelSystemTable", {
icon: IconTable,
queryKey: [["widget", "beszel"]],
supportedIntegrations: ["beszel", "mock"],
integrationsRequired: true,
createOptions() {
@@ -0,0 +1,87 @@
import { describe, expect, test } from "vitest";
import type { BeszelSystemStatsRecord } from "@homarr/integrations/types";
import { buildDiskChartData, padLiveTimeGrid } from "./chart";
const record = (
created: string,
du: number,
efs: BeszelSystemStatsRecord["stats"]["efs"],
): BeszelSystemStatsRecord => ({
id: created,
system: "system-1",
type: "1m",
created,
updated: created,
stats: {
cpu: 0,
m: 0,
mu: 0,
mp: 0,
mb: 0,
s: 0,
su: 0,
d: 938.8,
du,
dp: 51.15,
efs,
},
});
describe("buildDiskChartData", () => {
test("keeps root and extra filesystem usage in GiB without stacking or byte conversion", () => {
const data = buildDiskChartData(
[
record("2026-07-11T13:46:00.000Z", 455.81, {
sda: { d: 1906.8, du: 1656.45, r: 0, w: 0 },
sdb: { d: 5587.04, du: 3936.86, r: 0, w: 0 },
sdc: { d: 3724.18, du: 1589.84, r: 0, w: 0 },
}),
],
["sda", "sdb", "sdc"],
"Root",
"1h",
);
expect(data).toHaveLength(1);
expect(data[0]).toMatchObject({ Root: 455.81, sda: 1656.45, sdb: 3936.86, sdc: 1589.84 });
});
test("uses zero for a filesystem missing from an individual sample and orders historical records oldest first", () => {
const data = buildDiskChartData(
[
record("2026-07-11T13:47:00.000Z", 2, { sda: { d: 10, du: 4, r: 0, w: 0 } }),
record("2026-07-11T13:46:00.000Z", 1, undefined),
],
["sda"],
"Root",
"1h",
);
expect(data.map((point) => ({ Root: point.Root, sda: point.sda }))).toEqual([
{ Root: 1, sda: 0 },
{ Root: 2, sda: 4 },
]);
});
});
describe("padLiveTimeGrid", () => {
test("keeps a fixed 60-second window with the newest sample at the right edge", () => {
const data = padLiveTimeGrid([{ time: "13:51:30", rawTime: "2026-07-11T13:51:30.217Z", CPU: 18.07 }]);
expect(data).toHaveLength(60);
expect(data[0]).not.toHaveProperty("CPU");
expect(data[58]).not.toHaveProperty("CPU");
expect(data[59]).toMatchObject({ CPU: 18.07 });
});
test("leaves null gaps for missing seconds instead of stretching existing points", () => {
const data = padLiveTimeGrid([
{ time: "13:51:28", rawTime: "2026-07-11T13:51:28.000Z", CPU: 10 },
{ time: "13:51:30", rawTime: "2026-07-11T13:51:30.000Z", CPU: 20 },
]);
expect(data.slice(-3).map((point) => point.CPU)).toEqual([10, undefined, 20]);
});
});
@@ -30,6 +30,7 @@ function prepareRecords<T>(records: T[], timePeriod: BeszelTimePeriod) {
}
function padTimeGrid(data: Record<string, unknown>[], timePeriod: BeszelTimePeriod) {
if (timePeriod === "1m") return padLiveTimeGrid(data);
const days = periodDays[timePeriod];
if (!days) return data;
@@ -50,6 +51,38 @@ 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) {
if (data.length === 0) return data;
const end = dayjs(
data.reduce((latest, point) => {
const timestamp = new Date(point.rawTime as string).getTime();
return Number.isFinite(timestamp) ? Math.max(latest, timestamp) : latest;
}, 0),
).startOf("second");
const pointsBySecond = new Map(
data.map(
(point) =>
[
dayjs(point.rawTime as string)
.startOf("second")
.valueOf(),
point,
] as const,
),
);
return Array.from({ length: pointCount }, (_, index) => {
const timestamp = end.subtract(pointCount - index - 1, "second");
return (
pointsBySecond.get(timestamp.valueOf()) ?? {
time: timestamp.format(timeFormats["1m"]),
rawTime: timestamp.toISOString(),
}
);
});
}
const yAxisBase = { tickMargin: 0, tick: { fontSize: 10 } } as const;
const chartStyle = { minWidth: 0, minHeight: 1 } as const;
const panelStyle = { minWidth: 0, overflow: "hidden" } as const;
@@ -174,6 +207,40 @@ const defaultContainerExtractors: Record<string, ContainerExtractor> = {
network: (c) => (c?.b ? c.b[0] + c.b[1] : (c?.ns ?? 0) + (c?.nr ?? 0)),
};
export const useDiskChartData = (
systemStats: BeszelSystemStatsRecord[] | undefined,
efsPaths: string[],
rootSeriesName: string,
timePeriod: BeszelTimePeriod = "1h",
) =>
useMemo(
() => buildDiskChartData(systemStats, efsPaths, rootSeriesName, timePeriod),
[systemStats, efsPaths, rootSeriesName, timePeriod],
);
export const buildDiskChartData = (
systemStats: BeszelSystemStatsRecord[] | undefined,
efsPaths: string[],
rootSeriesName: string,
timePeriod: BeszelTimePeriod = "1h",
) => {
if (!systemStats?.length) return [];
const { fmt, ordered } = prepareRecords(systemStats, timePeriod);
const mapped = ordered.map((record) => {
const point: Record<string, unknown> = {
time: fmt(record.created),
rawTime: record.created,
[rootSeriesName]: record.stats.du,
};
const efs = record.stats.efs ?? {};
for (const path of efsPaths) {
point[path] = efs[path]?.du ?? 0;
}
return point;
});
return padTimeGrid(mapped, timePeriod);
};
export const useDockerChartData = (
containerStats: BeszelContainerStatsRecord[] | undefined,
containerNames: string[],
@@ -0,0 +1,105 @@
import { Box, Group, HoverCard, Progress, Stack, Text, UnstyledButton } from "@mantine/core";
import type { BeszelSystemRow } from "./types";
import { thresholdColor } from "./colors";
import { formatPercent, getProgressTrackSize } from "./format";
interface DiskUsageProps {
system: BeszelSystemRow;
fontSize: string;
progressSize: "xs" | "sm";
valueMiw: number;
valueGap?: number;
}
const mountLabel = (path: string) =>
path === "/" ? "ROOT" : (path.split("/").filter(Boolean).at(-1) ?? path).toUpperCase();
const severityColor = (value: number) => `var(--mantine-color-${thresholdColor(value)}-6)`;
export const DiskUsage = ({ system, fontSize, progressSize, valueMiw, valueGap = 6 }: DiskUsageProps) => {
const filesystems = Object.entries(system.extraFilesystems).filter(([path]) => path !== "/");
const trackSize = getProgressTrackSize(progressSize);
const dotSize = progressSize === "xs" ? 2 : 3;
const dotGap = 2;
return (
<Group gap={valueGap} wrap="nowrap" style={{ flex: 1, minWidth: 0, marginLeft: "auto" }}>
<Text size={fontSize} fw={500} w={valueMiw} ta="left" style={{ whiteSpace: "nowrap", flexShrink: 0 }}>
{formatPercent(system.disk)}
</Text>
<HoverCard
position="right"
withArrow
shadow="md"
openDelay={200}
closeDelay={100}
disabled={filesystems.length === 0}
>
<HoverCard.Target>
{filesystems.length > 0 ? (
<UnstyledButton
aria-label={`Show usage for ${filesystems.length + 1} filesystems`}
style={{
display: "flex",
alignItems: "center",
flex: 1,
minWidth: 24,
cursor: "pointer",
}}
>
<Box pos="relative" style={{ flex: 1, minWidth: 24 }}>
<Progress value={system.disk} color={thresholdColor(system.disk)} size={trackSize} />
<Group
gap={dotGap}
wrap="nowrap"
aria-hidden
pos="absolute"
top="50%"
right={Math.max(2, dotGap)}
style={{ transform: "translateY(-50%)" }}
>
{filesystems.map(([path, value]) => (
<Box
key={path}
w={dotSize}
h={dotSize}
style={{
borderRadius: "50%",
backgroundColor: severityColor(value),
boxShadow: "0 0 0 0.5px var(--mantine-color-body)",
flex: "0 0 auto",
}}
/>
))}
</Group>
</Box>
</UnstyledButton>
) : (
<Box style={{ display: "flex", alignItems: "center", flex: 1, minWidth: 24 }}>
<Box pos="relative" style={{ flex: 1, minWidth: 24 }}>
<Progress value={system.disk} color={thresholdColor(system.disk)} size={trackSize} />
</Box>
</Box>
)}
</HoverCard.Target>
<HoverCard.Dropdown p={8}>
<Stack gap={6} miw={145}>
{[["/", system.disk] as const, ...filesystems].map(([path, value]) => (
<Stack key={path} gap={1}>
<Text size="10px" c="dimmed" fw={500} lh={1.2}>
{mountLabel(path)}
</Text>
<Group gap={8} wrap="nowrap">
<Text size="sm" fw={600} miw={48} lh={1.25}>
{formatPercent(value)}
</Text>
<Progress value={value} color={thresholdColor(value)} size="xs" style={{ flex: 1 }} />
</Group>
</Stack>
))}
</Stack>
</HoverCard.Dropdown>
</HoverCard>
</Group>
);
};
@@ -0,0 +1,21 @@
import { describe, expect, test } from "vitest";
import { chartAxisFormatters, formatGB, getProgressTrackSize } from "./format";
describe("Beszel storage formatting", () => {
test("keeps smaller values in GB", () => {
expect(formatGB(455.81)).toBe("455.81 GB");
expect(chartAxisFormatters.gb(455.81)).toBe("456G");
});
test("promotes large GiB values to TB", () => {
expect(formatGB(3323)).toBe("3.25 TB");
expect(formatGB(3936.86)).toBe("3.84 TB");
expect(chartAxisFormatters.gb(3323)).toBe("3.2T");
});
test("maps progress sizes consistently", () => {
expect(getProgressTrackSize("xs")).toBe(6);
expect(getProgressTrackSize("sm")).toBe(9);
});
});
@@ -29,14 +29,22 @@ export const formatStorageBytes = (bytes: number): string => formatScaled(bytes,
export const formatGB = (value: number): string => {
if (!value || !Number.isFinite(value)) return "0 GB";
if (Math.abs(value) >= 1024) return `${(value / 1024).toFixed(2)} TB`;
return `${value.toFixed(2)} GB`;
};
export const formatPercent = (value: number): string => `${value.toFixed(1)}%`;
export const getProgressTrackSize = (size: "xs" | "sm"): number => (size === "xs" ? 6 : 9);
export const chartAxisFormatters = {
percent: formatPercent,
gb: (value: number) => (!value ? "0" : `${Number(value).toFixed(0)}G`),
gb: (value: number) => {
const numericValue = Number(value);
if (!numericValue) return "0";
if (Math.abs(numericValue) >= 1024) return `${(numericValue / 1024).toFixed(1)}T`;
return `${numericValue.toFixed(0)}G`;
},
mb: (value: number) => (!value ? "0" : `${Number(value).toFixed(0)}M`),
bytes: (value: number) => formatScaledCompact(Number(value), byteUnits, "0"),
rate: (value: number) => formatScaledCompact(Number(value), rateUnits, "0"),
@@ -2,30 +2,10 @@
import { useMemo } from "react";
import { clientApi } from "@homarr/api/client";
import type { BeszelSystemRow } from "./types";
type SystemRowWithKey = BeszelSystemRow & { _key: string };
export const useBeszelSystemsSubscription = (integrationIds: string[], enabled = true) => {
const utils = clientApi.useUtils();
clientApi.widget.beszel.subscribeSystems.useSubscription(
{ integrationIds },
{
enabled,
onData(data) {
utils.widget.beszel.getSystems.setData({ integrationIds }, (prev) => {
if (!prev) return prev;
return prev.map((r) =>
r.integrationId === data.integrationId ? { ...r, systems: data.systems, updatedAt: data.timestamp } : r,
);
});
},
},
);
};
export const useBeszelFilteredSystems = (
results: { integrationId: string; systems: BeszelSystemRow[] }[],
statusFilter: string,
@@ -0,0 +1,339 @@
"use client";
import { useMemo } from "react";
import { Button, Center, Group, SimpleGrid, Skeleton, Stack, Text } from "@mantine/core";
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 { containerColors } from "./colors";
import type { BeszelTimePeriod } from "./chart";
import {
BeszelChartPanel,
CPU_Y_AXIS_DOMAIN,
useContainerNames,
useDiskChartData,
useDockerChartData,
useSystemChartData,
} from "./chart";
import { chartAxisFormatters, formatByteRate, formatGB, formatPercent, formatStorageBytes } from "./format";
import { makeTooltipProps } from "./tooltip";
import { useLiveStats } from "./use-live-stats";
const CHART_HEIGHT = 180;
const MB = 1024 * 1024;
const tooltipPercent = makeTooltipProps(formatPercent);
const tooltipGB = makeTooltipProps(formatGB, true);
const tooltipRate = makeTooltipProps(formatByteRate, true);
const tooltipPercentTotal = makeTooltipProps(formatPercent, true);
const tooltipBytesTotal = makeTooltipProps(formatStorageBytes, true);
const ChartSkeleton = () => (
<Stack gap={4} style={{ minWidth: 0 }}>
<Group gap="xs">
<Skeleton h={14} w={72} radius="sm" />
<Skeleton h={10} w={110} radius="sm" />
</Group>
<Skeleton h={CHART_HEIGHT} radius="sm" />
</Stack>
);
export interface BeszelStatsVisibility {
cpu: boolean;
memory: boolean;
disk: boolean;
diskIO: boolean;
network: boolean;
dockerCpu: boolean;
dockerMemory: boolean;
dockerNetwork: boolean;
}
interface BeszelStatsViewProps {
integrationIds: string[];
systemId: string;
timePeriod: BeszelTimePeriod;
visibility: BeszelStatsVisibility;
columns: 1 | 2;
onSwitchToHistorical?: () => void;
}
const whenVisible = <T,>(visible: boolean, data: T | undefined) => (visible ? data : undefined);
export function BeszelStatsView({
integrationIds,
systemId,
timePeriod,
visibility,
columns,
onSwitchToHistorical,
}: BeszelStatsViewProps) {
const t = useScopedI18n("widget.beszelSystemStats");
const showDocker = visibility.dockerCpu || visibility.dockerMemory || visibility.dockerNetwork;
const isLive = timePeriod === "1m";
const { data: historicalData, error: historicalError } = clientApi.widget.beszel.getSystemStats.useQuery(
{ integrationIds, systemId, timePeriod, includeDocker: showDocker },
{ refetchInterval: isLive ? false : 5_000, enabled: !isLive && systemId !== "" },
);
const { data: liveData, error: liveError } = useLiveStats(integrationIds, systemId, isLive && systemId !== "");
const activeStats:
| { systemStats: BeszelSystemStatsRecord[]; containerStats: BeszelContainerStatsRecord[]; error?: string }
| null
| undefined = isLive ? liveData : historicalData;
const mappers = useMemo(
() => ({
cpu: (s: { cpu: number }) => ({ [t("chart.cpu.series")]: s.cpu }),
memory: (s: { mu: number; mb: number }) => ({
[t("chart.memory.series")]: s.mu,
[t("chart.memory.cache")]: s.mb ?? 0,
}),
diskIO: (s: { dr?: number; dw?: number }) => ({
[t("chart.diskIO.read")]: (s.dr ?? 0) * MB,
[t("chart.diskIO.write")]: (s.dw ?? 0) * MB,
}),
network: (s: { ns?: number; nr?: number; b?: [number, number] }) => ({
[t("chart.network.sent")]: s.b?.[0] ?? s.ns ?? 0,
[t("chart.network.recv")]: s.b?.[1] ?? s.nr ?? 0,
}),
}),
[t],
);
const series = useMemo(
() => ({
cpu: [{ name: t("chart.cpu.series"), color: "teal.6" }],
memory: [
{ name: t("chart.memory.series"), color: "teal.5" },
{ name: t("chart.memory.cache"), color: "teal.8" },
],
diskIO: [
{ name: t("chart.diskIO.write"), color: "orange.6" },
{ name: t("chart.diskIO.read"), color: "blue.6" },
],
network: [
{ name: t("chart.network.sent"), color: "blue.6" },
{ name: t("chart.network.recv"), color: "teal.6" },
],
}),
[t],
);
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 efsPaths = useMemo(() => {
const paths = new Set<string>();
for (const record of systemStats ?? []) {
for (const path of Object.keys(record.stats.efs ?? {})) paths.add(path);
}
return [...paths];
}, [systemStats]);
const rootSeriesName = t("chart.disk.series");
const diskData = useDiskChartData(whenVisible(visibility.disk, systemStats), efsPaths, rootSeriesName, timePeriod);
const diskSeries = useMemo(
() => [
{ name: rootSeriesName, color: "grape.6" },
...efsPaths.map((path, index) => ({
name: path,
color: containerColors[(index + 1) % containerColors.length] as string,
})),
],
[efsPaths, rootSeriesName],
);
const containerNames = useContainerNames(whenVisible(showDocker, containerStats));
const containerSeries = useMemo(
() =>
containerNames.map((name, index) => ({ name, color: containerColors[index % containerColors.length] as string })),
[containerNames],
);
const dockerCpuData = useDockerChartData(
whenVisible(visibility.dockerCpu, containerStats),
containerNames,
"cpu",
timePeriod,
);
const dockerMemoryData = useDockerChartData(
whenVisible(visibility.dockerMemory, containerStats),
containerNames,
"memory",
timePeriod,
);
const dockerNetworkData = useDockerChartData(
whenVisible(visibility.dockerNetwork, containerStats),
containerNames,
"network",
timePeriod,
);
if (historicalError) throw historicalError;
if (isLive && liveError) {
return (
<Center h={CHART_HEIGHT}>
<Stack align="center" gap="sm" p="md">
<IconPlugConnectedX size={40} />
<Text size="sm" c="dimmed" ta="center">
{t("error.liveConnectionFailed")}
</Text>
{onSwitchToHistorical && (
<Button size="xs" variant="light" onClick={onSwitchToHistorical}>
{t("error.switchToHistorical")}
</Button>
)}
</Stack>
</Center>
);
}
if (!activeStats) {
const visibleChartCount = Object.values(visibility).filter(Boolean).length;
return (
<SimpleGrid cols={columns} spacing="md" aria-label="Loading system statistics">
{Array.from({ length: visibleChartCount }, (_, index) => (
<ChartSkeleton key={index} />
))}
</SimpleGrid>
);
}
if ("error" in activeStats && activeStats.error) {
return (
<Center h={CHART_HEIGHT}>
<Stack align="center" gap="xs">
<IconServerOff size={24} opacity={0.5} />
<Text size="sm" c="dimmed">
{activeStats.error}
</Text>
</Stack>
</Center>
);
}
return (
<SimpleGrid cols={columns} spacing="md">
{visibility.cpu && cpuData.length > 0 && (
<BeszelChartPanel
title={t("chart.cpu.title")}
subtitle={t("chart.cpu.subtitle")}
chartProps={{
h: CHART_HEIGHT,
data: cpuData,
series: series.cpu,
yAxisFormatter: chartAxisFormatters.percent,
yAxisDomain: CPU_Y_AXIS_DOMAIN,
tooltipProps: tooltipPercent,
}}
/>
)}
{visibility.memory && memoryData.length > 0 && (
<BeszelChartPanel
title={t("chart.memory.title")}
subtitle={t("chart.memory.subtitle")}
chartProps={{
h: CHART_HEIGHT,
data: memoryData,
type: "stacked",
series: series.memory,
yAxisFormatter: chartAxisFormatters.gb,
tooltipProps: tooltipGB,
}}
/>
)}
{visibility.disk && diskData.length > 0 && (
<BeszelChartPanel
title={t("chart.disk.title")}
subtitle={t("chart.disk.subtitle")}
chartProps={{
h: CHART_HEIGHT,
data: diskData,
type: "stacked",
series: diskSeries,
yAxisFormatter: chartAxisFormatters.gb,
tooltipProps: tooltipGB,
}}
/>
)}
{visibility.diskIO && diskIOData.length > 0 && (
<BeszelChartPanel
title={t("chart.diskIO.title")}
subtitle={t("chart.diskIO.subtitle")}
chartProps={{
h: CHART_HEIGHT,
data: diskIOData,
series: series.diskIO,
yAxisFormatter: chartAxisFormatters.rate,
tooltipProps: tooltipRate,
}}
/>
)}
{visibility.network && networkData.length > 0 && (
<BeszelChartPanel
title={t("chart.network.title")}
subtitle={t("chart.network.subtitle")}
chartProps={{
h: CHART_HEIGHT,
data: networkData,
series: series.network,
yAxisFormatter: chartAxisFormatters.rate,
tooltipProps: tooltipRate,
}}
/>
)}
{showDocker && containerSeries.length > 0 && (
<>
{visibility.dockerCpu && dockerCpuData.length > 0 && (
<BeszelChartPanel
title={t("chart.dockerCpu.title")}
subtitle={t("chart.dockerCpu.subtitle")}
chartProps={{
h: CHART_HEIGHT,
data: dockerCpuData,
type: "stacked",
series: containerSeries,
yAxisFormatter: chartAxisFormatters.percent,
tooltipProps: tooltipPercentTotal,
}}
/>
)}
{visibility.dockerMemory && dockerMemoryData.length > 0 && (
<BeszelChartPanel
title={t("chart.dockerMemory.title")}
subtitle={t("chart.dockerMemory.subtitle")}
chartProps={{
h: CHART_HEIGHT,
data: dockerMemoryData,
type: "stacked",
series: containerSeries,
yAxisFormatter: chartAxisFormatters.bytes,
tooltipProps: tooltipBytesTotal,
}}
/>
)}
{visibility.dockerNetwork && dockerNetworkData.length > 0 && (
<BeszelChartPanel
title={t("chart.dockerNetwork.title")}
subtitle={t("chart.dockerNetwork.subtitle")}
chartProps={{
h: CHART_HEIGHT,
data: dockerNetworkData,
series: containerSeries,
yAxisFormatter: chartAxisFormatters.rate,
tooltipProps: tooltipRate,
}}
/>
)}
</>
)}
</SimpleGrid>
);
}
@@ -1,25 +1,15 @@
"use client";
import { useMemo, useState } from "react";
import { SegmentedControl, SimpleGrid, Stack } from "@mantine/core";
import { useState } from "react";
import { SegmentedControl, Stack } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { createModal } from "@homarr/modals";
import { useScopedI18n } from "@homarr/translation/client";
import type { BeszelTimePeriod } from "./chart";
import { BeszelChartPanel, CPU_Y_AXIS_DOMAIN, useSystemChartData } from "./chart";
import { chartAxisFormatters, formatByteRate, formatGB, formatPercent } from "./format";
import { makeTooltipProps } from "./tooltip";
const tooltipPercent = makeTooltipProps(formatPercent);
const tooltipGB = makeTooltipProps(formatGB, true);
const tooltipRate = makeTooltipProps(formatByteRate, true);
const CHART_HEIGHT = 180;
const MB = 1024 * 1024;
import { BeszelStatsView } from "./stats-view";
const timePeriodOptions: { value: BeszelTimePeriod; label: string }[] = [
{ value: "1m", label: "Live" },
{ value: "1h", label: "1H" },
{ value: "12h", label: "12H" },
{ value: "24h", label: "24H" },
@@ -27,144 +17,41 @@ const timePeriodOptions: { value: BeszelTimePeriod; label: string }[] = [
{ value: "30d", label: "30D" },
];
const allCharts = {
cpu: true,
memory: true,
disk: true,
diskIO: true,
network: true,
dockerCpu: true,
dockerMemory: true,
dockerNetwork: true,
} as const;
interface BeszelSystemStatsModalProps {
integrationId: string;
systemId: string;
}
export const BeszelSystemStatsModal = createModal<BeszelSystemStatsModalProps>(({ innerProps }) => {
const t = useScopedI18n("widget.beszelSystemStats");
const [timePeriod, setTimePeriod] = useState<BeszelTimePeriod>("1h");
const { data } = clientApi.widget.beszel.getSystemStats.useQuery(
{
integrationIds: [innerProps.integrationId],
systemId: innerProps.systemId,
timePeriod,
includeDocker: false,
},
{ staleTime: 10_000, gcTime: 48 * 60 * 60 * 1000, refetchInterval: 10_000 },
);
const mappers = useMemo(
() => ({
cpu: (s: { cpu: number }) => ({ [t("chart.cpu.series")]: s.cpu }),
memory: (s: { mu: number; mb: number }) => ({
[t("chart.memory.series")]: s.mu,
[t("chart.memory.cache")]: s.mb ?? 0,
}),
disk: (s: { du: number }) => ({ [t("chart.disk.series")]: s.du }),
diskIO: (s: { dr?: number; dw?: number }) => ({
[t("chart.diskIO.read")]: (s.dr ?? 0) * MB,
[t("chart.diskIO.write")]: (s.dw ?? 0) * MB,
}),
network: (s: { ns: number; nr: number; b?: [number, number] }) => ({
[t("chart.network.sent")]: s.b?.[0] ?? s.ns,
[t("chart.network.recv")]: s.b?.[1] ?? s.nr,
}),
}),
[t],
);
const series = useMemo(
() => ({
cpu: [{ name: t("chart.cpu.series"), color: "teal.6" }],
memory: [
{ name: t("chart.memory.series"), color: "teal.5" },
{ name: t("chart.memory.cache"), color: "teal.8" },
],
disk: [{ name: t("chart.disk.series"), color: "grape.6" }],
diskIO: [
{ name: t("chart.diskIO.write"), color: "orange.6" },
{ name: t("chart.diskIO.read"), color: "blue.6" },
],
network: [
{ name: t("chart.network.sent"), color: "blue.6" },
{ name: t("chart.network.recv"), color: "teal.6" },
],
}),
[t],
);
const systemStats = data?.systemStats;
const cpuData = useSystemChartData(systemStats, mappers.cpu, timePeriod);
const memoryData = useSystemChartData(systemStats, mappers.memory, timePeriod);
const diskData = useSystemChartData(systemStats, mappers.disk, timePeriod);
const diskIOData = useSystemChartData(systemStats, mappers.diskIO, timePeriod);
const networkData = useSystemChartData(systemStats, mappers.network, timePeriod);
return (
<Stack gap="md">
<SegmentedControl
size="xs"
value={timePeriod}
onChange={(v) => setTimePeriod(v as BeszelTimePeriod)}
onChange={(value) => setTimePeriod(value as BeszelTimePeriod)}
data={timePeriodOptions}
/>
<SimpleGrid cols={2} spacing="md">
{cpuData.length > 0 && (
<BeszelChartPanel
title={t("chart.cpu.title")}
chartProps={{
h: CHART_HEIGHT,
data: cpuData,
series: series.cpu,
yAxisFormatter: chartAxisFormatters.percent,
yAxisDomain: CPU_Y_AXIS_DOMAIN,
tooltipProps: tooltipPercent,
}}
/>
)}
{memoryData.length > 0 && (
<BeszelChartPanel
title={t("chart.memory.title")}
chartProps={{
h: CHART_HEIGHT,
data: memoryData,
type: "stacked",
series: series.memory,
yAxisFormatter: chartAxisFormatters.gb,
tooltipProps: tooltipGB,
}}
/>
)}
{diskData.length > 0 && (
<BeszelChartPanel
title={t("chart.disk.title")}
chartProps={{
h: CHART_HEIGHT,
data: diskData,
series: series.disk,
yAxisFormatter: chartAxisFormatters.gb,
tooltipProps: tooltipGB,
}}
/>
)}
{diskIOData.length > 0 && (
<BeszelChartPanel
title={t("chart.diskIO.title")}
chartProps={{
h: CHART_HEIGHT,
data: diskIOData,
series: series.diskIO,
yAxisFormatter: chartAxisFormatters.rate,
tooltipProps: tooltipRate,
}}
/>
)}
{networkData.length > 0 && (
<BeszelChartPanel
title={t("chart.network.title")}
chartProps={{
h: CHART_HEIGHT,
data: networkData,
series: series.network,
yAxisFormatter: chartAxisFormatters.rate,
tooltipProps: tooltipRate,
}}
/>
)}
</SimpleGrid>
<BeszelStatsView
integrationIds={[innerProps.integrationId]}
systemId={innerProps.systemId}
timePeriod={timePeriod}
visibility={allCharts}
columns={2}
onSwitchToHistorical={() => setTimePeriod("1h")}
/>
</Stack>
);
}).withOptions({
@@ -1,92 +1,65 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { skipToken } from "@tanstack/react-query";
import { clientApi } from "@homarr/api/client";
import type { BeszelContainerStatsRecord, BeszelSystemStatsRecord } from "@homarr/integrations/types";
const MAX_BUFFER = 30;
const FLUSH_INTERVAL_MS = 3000;
interface LiveStatsData {
systemStats: BeszelSystemStatsRecord[];
containerStats: BeszelContainerStatsRecord[];
}
function appendToBuffer(
prev: LiveStatsData | null,
incoming: { stats: BeszelSystemStatsRecord; containerStats: BeszelContainerStatsRecord | null },
): LiveStatsData {
const systemStats = [...(prev?.systemStats ?? []), incoming.stats].slice(-MAX_BUFFER);
const prevContainer = prev?.containerStats ?? [];
if (!incoming.containerStats) {
return { systemStats, containerStats: prevContainer };
}
return { systemStats, containerStats: [...prevContainer, incoming.containerStats].slice(-MAX_BUFFER) };
}
// Beszel emits roughly one record per second. Keep a sliding one-minute window
// without synthesizing points for missed updates.
const MAX_BUFFER = 60;
export const useLiveStats = (integrationIds: string[], systemId: string, enabled: boolean) => {
const [data, setData] = useState<LiveStatsData | null>(null);
const [systemStats, setSystemStats] = useState<BeszelSystemStatsRecord[]>([]);
const [containerStats, setContainerStats] = useState<BeszelContainerStatsRecord[]>([]);
const [error, setError] = useState<Error | null>(null);
const bufferRef = useRef<LiveStatsData | null>(null);
const dirtyRef = useRef(false);
const generationRef = useRef(0);
const integrationKey = [...integrationIds].sort().join(",");
useEffect(() => {
generationRef.current += 1;
setData(null);
setError(null);
bufferRef.current = null;
dirtyRef.current = false;
}, [systemId, enabled, integrationKey]);
const onData = useCallback(
(incoming: { stats: BeszelSystemStatsRecord; containerStats: BeszelContainerStatsRecord | null }) => {
const gen = generationRef.current;
queueMicrotask(() => {
if (generationRef.current !== gen) return;
setError(null);
const isFirst = !bufferRef.current;
bufferRef.current = appendToBuffer(bufferRef.current, incoming);
dirtyRef.current = true;
if (isFirst) {
setData(bufferRef.current);
}
});
},
[],
);
const onError = useCallback((err: unknown) => {
if (err instanceof Error) {
setError(err);
return;
}
setError(new Error(String(err)));
// Append to buffer, trimming to MAX_BUFFER
const appendSystemStats = useCallback((record: BeszelSystemStatsRecord) => {
setSystemStats((prev) => {
const next = [...prev, record];
return next.length > MAX_BUFFER ? next.slice(-MAX_BUFFER) : next;
});
}, []);
const subscriptionInput = useMemo(() => ({ integrationIds, systemId }), [integrationKey, systemId]);
const subscriptionEnabled = enabled && systemId !== "";
const appendContainerStats = useCallback((record: BeszelContainerStatsRecord) => {
setContainerStats((prev) => {
const next = [...prev, record];
return next.length > MAX_BUFFER ? next.slice(-MAX_BUFFER) : next;
});
}, []);
clientApi.widget.beszel.subscribeSystemStats.useSubscription(
enabled && systemId !== "" ? { integrationIds, systemId } : skipToken,
{
onData(event) {
setError(null);
if (event.type === "system_stats") {
appendSystemStats(event.record);
} else {
appendContainerStats(event.record);
}
},
onError(err) {
setError(err instanceof Error ? err : new Error(String(err)));
},
},
);
// Reset buffers when system changes or integration changes
const prevKeyRef = useRef<string>("");
const currentKey = `${integrationIds.join(",")}:${systemId}:${enabled}`;
useEffect(() => {
if (!subscriptionEnabled) return;
const gen = generationRef.current;
const id = setInterval(() => {
if (generationRef.current !== gen) return;
if (!dirtyRef.current || !bufferRef.current) return;
dirtyRef.current = false;
setData(bufferRef.current);
}, FLUSH_INTERVAL_MS);
return () => clearInterval(id);
}, [subscriptionEnabled, integrationKey, systemId]);
if (prevKeyRef.current !== currentKey) {
prevKeyRef.current = currentKey;
setSystemStats([]);
setContainerStats([]);
setError(null);
}
}, [currentKey]);
clientApi.widget.beszel.subscribeSystemStats.useSubscription(subscriptionInput, {
enabled: subscriptionEnabled,
onData,
onError,
});
const data = systemStats.length > 0 || containerStats.length > 0 ? { systemStats, containerStats } : null;
return { data, error };
};
+33 -7
View File
@@ -1,4 +1,4 @@
import { useState } from "react";
import { useCallback, useState } from "react";
import { Box, Container, Flex, Popover, Text, useMantineTheme } from "@mantine/core";
import { useElementSize } from "@mantine/hooks";
@@ -13,10 +13,12 @@ interface CalendarDayProps {
disabled: boolean;
rootWidth: number;
rootHeight: number;
onOpen: (close: () => void) => void;
}
export const CalendarDay = ({ date, events, disabled, rootHeight, rootWidth }: CalendarDayProps) => {
export const CalendarDay = ({ date, events, disabled, rootHeight, rootWidth, onOpen }: CalendarDayProps) => {
const [opened, setOpened] = useState(false);
const [pinned, setPinned] = useState(false);
const { primaryColor } = useMantineTheme();
const { ref, height } = useElementSize();
const board = useRequiredBoard();
@@ -29,6 +31,19 @@ export const CalendarDay = ({ date, events, disabled, rootHeight, rootWidth }: C
const isTooSmallForIndicators = height < 30;
const handleMouseEnter = useCallback(() => {
if (disabled) return;
onOpen(() => {
setOpened(false);
setPinned(false);
});
setOpened(true);
}, [disabled, onOpen]);
const handleMouseLeave = useCallback(() => {
if (!pinned) setOpened(false);
}, [pinned]);
return (
<Popover
position="bottom"
@@ -39,7 +54,10 @@ export const CalendarDay = ({ date, events, disabled, rootHeight, rootWidth }: C
transitionProps={{
transition: "pop",
}}
onChange={setOpened}
onChange={(value) => {
setOpened(value);
if (!value) setPinned(false);
}}
opened={opened}
disabled={disabled}
>
@@ -59,10 +77,10 @@ export const CalendarDay = ({ date, events, disabled, rootHeight, rootWidth }: C
cursor: disabled ? "default" : "pointer",
}}
onClick={() => {
if (disabled) return;
setOpened((prev) => !prev);
if (!disabled) setPinned((prev) => !prev);
}}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<Text ta={"center"} size={shouldScaleDown ? "xs" : "md"} lh={1}>
{date.getDate()}
@@ -71,7 +89,15 @@ export const CalendarDay = ({ date, events, disabled, rootHeight, rootWidth }: C
</Container>
</Popover.Target>
{/* Popover has some offset on the left side, padding is removed because of scrollarea paddings */}
<Popover.Dropdown maw="calc(100vw - 24px)" w={512} pe={4} pb={0} style={{ overflow: "hidden" }}>
<Popover.Dropdown
maw="calc(100vw - 24px)"
w={512}
pe={4}
pb={0}
style={{ overflow: "hidden" }}
onMouseEnter={() => setOpened(true)}
onMouseLeave={handleMouseLeave}
>
<CalendarEventList events={events} />
</Popover.Dropdown>
</Popover>
+8 -22
View File
@@ -1,6 +1,6 @@
"use client";
import { useMemo, useState } from "react";
import { useCallback, useMemo, useRef, useState } from "react";
import { useParams } from "next/navigation";
import { useMantineTheme } from "@mantine/core";
import { Calendar } from "@mantine/dates";
@@ -39,27 +39,7 @@ const FetchCalendar = ({ month, setMonth, isEditMode, integrationIds, options }:
releaseType: options.releaseType,
showUnmonitored: options.showUnmonitored,
};
const { data } = clientApi.widget.calendar.findAllEvents.useQuery(input, {
staleTime: 60 * 1000,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
retry: false,
});
const utils = clientApi.useUtils();
clientApi.widget.calendar.subscribeToEvents.useSubscription(input, {
onData(data) {
utils.widget.calendar.findAllEvents.setData(input, (old) => {
return old?.map((item) => {
if (item.integration.id !== data.integration.id) return item;
return {
...item,
events: data.events,
};
});
});
},
});
const { data } = clientApi.widget.calendar.findAllEvents.useQuery(input);
const events = useMemo(() => data?.flatMap((item) => item.events) ?? [], [data]);
@@ -85,6 +65,11 @@ const CalendarBase = ({ isEditMode, events, month, setMonth, options }: Calendar
const isSmall = width < 256;
const normalizedEvents = useMemo(() => splitEvents(events), [events]);
const activeCloseRef = useRef<(() => void) | null>(null);
const onDayOpen = useCallback((close: () => void) => {
activeCloseRef.current?.();
activeCloseRef.current = close;
}, []);
return (
<Calendar
@@ -161,6 +146,7 @@ const CalendarBase = ({ isEditMode, events, month, setMonth, options }: Calendar
disabled={isEditMode || eventsForDate.length === 0}
rootWidth={width}
rootHeight={height}
onOpen={onDayOpen}
/>
);
}}
+1 -21
View File
@@ -31,27 +31,7 @@ interface CoolifyContentProps {
}
function CoolifyContent({ integrationIds, options, width }: CoolifyContentProps) {
const { data: instancesData = [] } = clientApi.widget.coolify.getInstancesInfo.useQuery(
{ integrationIds },
{ staleTime: 30 * 1000 },
);
const utils = clientApi.useUtils();
clientApi.widget.coolify.subscribeInstancesInfo.useSubscription(
{ integrationIds },
{
onData(newData) {
utils.widget.coolify.getInstancesInfo.setData({ integrationIds }, (prevData) => {
if (!prevData) return prevData;
return prevData.map((instance) =>
instance.integrationId === newData.integrationId
? { ...instance, instanceInfo: newData.instanceInfo, updatedAt: newData.timestamp }
: instance,
);
});
},
},
);
const { data: instancesData = [] } = clientApi.widget.coolify.getInstancesInfo.useQuery({ integrationIds });
const isTiny = width < 256;
const [firstInstance] = instancesData;
+34 -2
View File
@@ -1,5 +1,6 @@
import type React from "react";
import type { LoaderComponent } from "next/dynamic";
import type { QueryClient } from "@tanstack/react-query";
import type { QueryClient, QueryKey } from "@tanstack/react-query";
import type { DefaultErrorData } from "@trpc/server/unstable-core-do-not-import";
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
@@ -9,7 +10,35 @@ import type { stringOrTranslation } from "@homarr/translation";
import type { TablerIcon } from "@homarr/ui";
import type { WidgetImports } from ".";
import type { inferOptionsFromCreator, WidgetOptionsRecord } from "./options";
import type { inferOptionsFromCreator, inferOptionsFromDefinition, WidgetOptionsRecord } from "./options";
export interface WidgetContextMenuAction {
key: string;
label: stringOrTranslation;
icon?: TablerIcon;
onClick: () => void;
hidden?: boolean;
disabled?: boolean;
color?: string;
}
export interface WidgetContextMenuContext {
isEditMode: boolean;
boardId: string | undefined;
itemId: string | undefined;
}
export interface WidgetContextActionProps<
TKind extends WidgetKind,
TOptions extends WidgetOptionsRecord = WidgetOptionsRecord,
> {
kind: TKind;
options: inferOptionsFromDefinition<TOptions>;
setOptions: (partial: Partial<inferOptionsFromDefinition<TOptions>>) => void;
integrationIds: string[];
context: WidgetContextMenuContext;
widgetStateRef: React.MutableRefObject<Record<string, unknown> | null>;
}
const createWithDynamicImport =
<TKind extends WidgetKind, TDefinition extends WidgetDefinition>(kind: TKind, definition: TDefinition) =>
@@ -40,6 +69,7 @@ export const createWidgetDefinition = <TKind extends WidgetKind, TDefinition ext
export interface WidgetDefinition {
icon: TablerIcon;
queryKey?: QueryKey;
supportedIntegrations?: IntegrationKind[];
integrationsRequired?: boolean;
maxIntegrations?: number;
@@ -56,6 +86,7 @@ export interface WidgetDefinition {
}
>
>;
contextActions?: (props: Omit<WidgetContextActionProps<WidgetKind>, "kind">) => WidgetContextMenuAction[];
}
export interface WidgetProps<TKind extends WidgetKind> {
@@ -70,6 +101,7 @@ export type WidgetComponentProps<TKind extends WidgetKind> = WidgetProps<TKind>
setOptions: ({ newOptions }: { newOptions: Partial<inferOptionsFromCreator<WidgetOptionsRecordOf<TKind>>> }) => void;
width: number;
height: number;
widgetStateRef?: React.MutableRefObject<Record<string, unknown> | null>;
};
export type WidgetOptionsRecordOf<TKind extends WidgetKind> = WidgetImports[TKind]["definition"]["createOptions"];
@@ -51,99 +51,16 @@ export default function DnsHoleControlsWidget({
.map(({ id }) => id)
.filter((id) => integrationIds.includes(id));
const { data: summaries = [] } = clientApi.widget.dnsHole.summary.useQuery(
{
integrationIds,
},
{
staleTime: 5 * 1000,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
retry: false,
},
);
const { data: summaries = [] } = clientApi.widget.dnsHole.summary.useQuery({
integrationIds,
});
const utils = clientApi.useUtils();
// Subscribe to summary updates
clientApi.widget.dnsHole.subscribeToSummary.useSubscription(
{
integrationIds,
},
{
onData: (data) => {
utils.widget.dnsHole.summary.setData(
{
integrationIds,
},
(prevData) => {
if (!prevData) return undefined;
const newData = prevData.map((summary) =>
summary.integration.id === data.integration.id
? {
integration: {
...summary.integration,
updatedAt: new Date(),
},
summary: data.summary,
}
: summary,
);
return newData;
},
);
},
},
);
// Mutations for dnsHole state, set to undefined on click, and change again on settle
const { mutate: enableDns } = clientApi.widget.dnsHole.enable.useMutation({
onSettled: (_, error, { integrationId }) => {
utils.widget.dnsHole.summary.setData(
{
integrationIds,
},
(prevData) => {
if (!prevData) return [];
return prevData.map((item) =>
item.integration.id === integrationId
? {
...item,
summary: {
...item.summary,
status: error ? "disabled" : "enabled",
},
}
: item,
);
},
);
},
onSettled: () => void utils.widget.dnsHole.summary.invalidate(),
});
const { mutate: disableDns } = clientApi.widget.dnsHole.disable.useMutation({
onSettled: (_, error, { integrationId }) => {
utils.widget.dnsHole.summary.setData(
{
integrationIds,
},
(prevData) => {
if (!prevData) return [];
return prevData.map((item) =>
item.integration.id === integrationId
? {
...item,
summary: {
...item.summary,
status: error ? "enabled" : "disabled",
},
}
: item,
);
},
);
},
onSettled: () => void utils.widget.dnsHole.summary.invalidate(),
});
const toggleDns = (integrationId: string) => {
const integrationStatus = summaries.find(({ integration }) => integration.id === integrationId);
@@ -20,46 +20,12 @@ import type { widgetKind } from ".";
import type { WidgetComponentProps, WidgetProps } from "../../definition";
export default function DnsHoleSummaryWidget({ options, integrationIds }: WidgetComponentProps<typeof widgetKind>) {
const { data: summaries = [] } = clientApi.widget.dnsHole.summary.useQuery(
{
integrationIds,
},
{
staleTime: 5 * 1000,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
retry: false,
},
);
const utils = clientApi.useUtils();
const { data: summaries = [] } = clientApi.widget.dnsHole.summary.useQuery({
integrationIds,
});
const t = useI18n();
clientApi.widget.dnsHole.subscribeToSummary.useSubscription(
{
integrationIds,
},
{
onData: (data) => {
utils.widget.dnsHole.summary.setData(
{
integrationIds,
},
(prevData) => {
if (!prevData) {
return undefined;
}
const newData = prevData.map((item) =>
item.integration.id === data.integration.id ? { ...item, summary: data.summary } : item,
);
return newData;
},
);
},
},
);
const data = useMemo(() => summaries.flatMap(({ summary }) => summary), [summaries]);
return (
+22 -10
View File
@@ -1,10 +1,10 @@
"use client";
import { useMemo } from "react";
import { ActionIcon, Avatar, Badge, Group, Stack, Text, Tooltip } from "@mantine/core";
import { ActionIcon, Avatar, Badge, Center, Group, Stack, Text, Tooltip } from "@mantine/core";
import type { IconProps } from "@tabler/icons-react";
import { IconBrandDocker, IconPlayerPlay, IconPlayerStop, IconRotateClockwise } from "@tabler/icons-react";
import type { MRT_ColumnDef } from "mantine-react-table";
import type { MRT_ColumnDef, MRT_VisibilityState } from "mantine-react-table";
import { MantineReactTable } from "mantine-react-table";
import type { RouterOutputs } from "@homarr/api";
@@ -184,20 +184,13 @@ export default function DockerWidget({ options, width, isEditMode }: WidgetCompo
const t = useScopedI18n("docker");
const isTiny = width <= 256;
const utils = clientApi.useUtils();
const { data } = clientApi.docker.getContainers.useQuery(undefined, {
staleTime: 20 * 1000,
refetchInterval: 30_000,
});
const containers = data?.containers ?? [];
const timestamp = useMemo(() => data?.timestamp ?? new Date(), [data?.timestamp]);
const relativeTime = useTimeAgo(timestamp);
clientApi.docker.subscribeContainers.useSubscription(undefined, {
onData(data) {
utils.docker.getContainers.setData(undefined, { containers: data, timestamp: new Date() });
},
});
const totalContainers = containers.length;
const totals = useMemo(() => {
@@ -213,6 +206,15 @@ export default function DockerWidget({ options, width, isEditMode }: WidgetCompo
const columns = useMemo(() => createColumns(t), [t]);
const columnVisibility: MRT_VisibilityState = {
name: options.columns.includes("name"),
state: options.columns.includes("state"),
host: options.columns.includes("host"),
cpuUsage: options.columns.includes("cpuUsage"),
memoryUsage: options.columns.includes("memoryUsage"),
actions: options.columns.includes("actions"),
};
const table = useTranslatedMantineReactTable({
columns,
data: containers,
@@ -233,6 +235,9 @@ export default function DockerWidget({ options, width, isEditMode }: WidgetCompo
sorting: [{ id: options.defaultSort, desc: options.descendingDefaultSort }],
density: "xs",
},
state: {
columnVisibility,
},
mantinePaperProps: {
flex: 1,
withBorder: false,
@@ -260,6 +265,13 @@ export default function DockerWidget({ options, width, isEditMode }: WidgetCompo
},
});
if (options.columns.length === 0)
return (
<Center h="100%">
<Text>{t("error.noColumns")}</Text>
</Center>
);
return (
<Stack gap={0} h="100%" display="flex">
<MantineReactTable table={table} />
+19
View File
@@ -12,10 +12,29 @@ const columnsList = [
"memoryUsage",
] as const satisfies (keyof RouterOutputs["docker"]["getContainers"]["containers"][number])[];
const allColumnsList = ["name", "state", "host", "cpuUsage", "memoryUsage", "actions"] as const;
const columnTranslationKeyMap = {
name: "docker.field.name.label",
state: "docker.field.state.label",
host: "docker.field.host.label",
cpuUsage: "docker.field.stats.cpu.label",
memoryUsage: "docker.field.stats.memory.label",
actions: "docker.action.title",
} as const satisfies Record<(typeof allColumnsList)[number], string>;
export const { definition, componentLoader } = createWidgetDefinition("dockerContainers", {
icon: IconBrandDocker,
createOptions() {
return optionsBuilder.from((factory) => ({
columns: factory.multiSelect({
defaultValue: [...allColumnsList],
options: allColumnsList.map((value) => ({
value,
label: (t) => t(columnTranslationKeyMap[value]),
})),
searchable: true,
}),
enableRowSorting: factory.switch({
defaultValue: false,
}),
+14 -48
View File
@@ -88,19 +88,10 @@ export default function DownloadClientsWidget({
integrationIds.includes(id) ? [id] : [],
);
const { data: currentItems = [] } = clientApi.widget.downloads.getJobsAndStatuses.useQuery(
{
integrationIds,
limitPerIntegration: options.limitPerIntegration,
},
{
staleTime: 5 * 1000,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
retry: false,
},
);
const utils = clientApi.useUtils();
const { data: currentItems = [] } = clientApi.widget.downloads.getJobsAndStatuses.useQuery({
integrationIds,
limitPerIntegration: options.limitPerIntegration,
});
const t = useScopedI18n("widget.downloads");
const tCommon = useScopedI18n("common");
@@ -121,40 +112,13 @@ export default function DownloadClientsWidget({
return statuses;
}, [currentItems]);
//Get API mutation functions
const { mutate: mutateResumeItem } = clientApi.widget.downloads.resumeItem.useMutation();
const { mutate: mutatePauseItem } = clientApi.widget.downloads.pauseItem.useMutation();
const { mutate: mutateDeleteItem } = clientApi.widget.downloads.deleteItem.useMutation();
const utils = clientApi.useUtils();
const invalidateDownloads = { onSettled: () => void utils.widget.downloads.getJobsAndStatuses.invalidate() };
const { mutate: mutateResumeItem } = clientApi.widget.downloads.resumeItem.useMutation(invalidateDownloads);
const { mutate: mutatePauseItem } = clientApi.widget.downloads.pauseItem.useMutation(invalidateDownloads);
const { mutate: mutateDeleteItem } = clientApi.widget.downloads.deleteItem.useMutation(invalidateDownloads);
//Subscribe to dynamic data changes
clientApi.widget.downloads.subscribeToJobsAndStatuses.useSubscription(
{
integrationIds,
limitPerIntegration: options.limitPerIntegration,
},
{
onData: (data) => {
utils.widget.downloads.getJobsAndStatuses.setData(
{ integrationIds, limitPerIntegration: options.limitPerIntegration },
(prevData) => {
return prevData?.map((item) => {
if (item.integration.id !== data.integration.id) return item;
return {
data: data.data,
integration: {
...data.integration,
updatedAt: new Date(),
},
};
});
},
);
},
},
);
//Flatten Data array for which each element has it's integration, data (base + calculated) and actions. Memoized on data subscription
//Flatten Data array for which each element has its integration, data (base + calculated) and actions
const data = useMemo<ExtendedDownloadClientItem[]>(
() =>
currentItems
@@ -785,8 +749,10 @@ const ClientsControl = ({ clients, filters, setFilters, availableStatuses }: Cli
"/s",
);
const { mutate: mutateResumeQueue } = clientApi.widget.downloads.resume.useMutation();
const { mutate: mutatePauseQueue } = clientApi.widget.downloads.pause.useMutation();
const utils = clientApi.useUtils();
const invalidateDownloads = { onSettled: () => void utils.widget.downloads.getJobsAndStatuses.invalidate() };
const { mutate: mutateResumeQueue } = clientApi.widget.downloads.resume.useMutation(invalidateDownloads);
const { mutate: mutatePauseQueue } = clientApi.widget.downloads.pause.useMutation(invalidateDownloads);
const [opened, { open, close }] = useDisclosure(false);
const t = useScopedI18n("widget.downloads");
return (
@@ -0,0 +1,146 @@
import { describe, expect, test } from "vitest";
import {
filterStorageVolumes,
normalizeStorageDeviceName,
toScopedStorageVolumeValue,
} from "./filter-storage-volumes";
import { matchFileSystemAndSmart } from "./health-monitoring/system-health";
describe("normalizeStorageDeviceName", () => {
test("strips partition suffixes from block device paths", () => {
expect(normalizeStorageDeviceName("/dev/sda1")).toBe("/dev/sda");
expect(normalizeStorageDeviceName("/dev/sda")).toBe("/dev/sda");
});
test("preserves Synology-style volume names", () => {
expect(normalizeStorageDeviceName("volume_1")).toBe("volume_1");
expect(normalizeStorageDeviceName("volume_2")).toBe("volume_2");
});
test("strips NVMe partition suffixes without collapsing the namespace id", () => {
expect(normalizeStorageDeviceName("/dev/nvme0n1")).toBe("/dev/nvme0n1");
expect(normalizeStorageDeviceName("/dev/nvme0n1p2")).toBe("/dev/nvme0n1");
expect(normalizeStorageDeviceName("/dev/nvme0n2p1")).toBe("/dev/nvme0n2");
});
test("does not collapse unrelated md devices", () => {
expect(normalizeStorageDeviceName("/dev/md0")).toBe("/dev/md0");
expect(normalizeStorageDeviceName("/dev/md1")).toBe("/dev/md1");
});
});
describe("toScopedStorageVolumeValue", () => {
test("scopes unscoped volume names to the query integration", () => {
expect(toScopedStorageVolumeValue("nas-a", "volume_1")).toBe("nas-a:volume_1");
});
test("re-scopes already-prefixed values using the query integration", () => {
expect(toScopedStorageVolumeValue("nas-b", "nas-a:volume_1")).toBe("nas-b:volume_1");
});
});
describe("filterStorageVolumes", () => {
const integrationId = "nas-1";
test("matches SMART entries using normalized partition identifiers", () => {
const entries = [
{ deviceName: "/dev/sda1", used: "1", available: "2", percentage: 50 },
{ deviceName: "/dev/sdb1", used: "1", available: "2", percentage: 50 },
];
const smartEntries = [
{ deviceName: "/dev/sda", temperature: 35, overallStatus: "GOOD" },
{ deviceName: "/dev/sdb", temperature: 40, overallStatus: "GOOD" },
];
const visibleVolumes = [`${integrationId}:/dev/sda1`];
expect(filterStorageVolumes(entries, visibleVolumes, integrationId)).toHaveLength(1);
expect(filterStorageVolumes(smartEntries, visibleVolumes, integrationId)).toHaveLength(1);
});
test("keeps exact Synology volume matches", () => {
const entries = [{ deviceName: "volume_1" }, { deviceName: "volume_2" }];
const visibleVolumes = [`${integrationId}:volume_1`];
expect(filterStorageVolumes(entries, visibleVolumes, integrationId)).toEqual([{ deviceName: "volume_1" }]);
});
test("does not apply another integration's scoped selection", () => {
const entries = [{ deviceName: "volume_1" }, { deviceName: "volume_2" }];
const visibleVolumes = ["nas-b:volume_1"];
expect(filterStorageVolumes(entries, visibleVolumes, integrationId)).toEqual([]);
});
test("does not treat different md devices as the same volume", () => {
const entries = [{ deviceName: "/dev/md0" }, { deviceName: "/dev/md1" }];
const visibleVolumes = [`${integrationId}:/dev/md0`];
expect(filterStorageVolumes(entries, visibleVolumes, integrationId)).toEqual([{ deviceName: "/dev/md0" }]);
});
});
describe("matchFileSystemAndSmart", () => {
test("joins partitioned filesystem entries with base SMART device names", () => {
const result = matchFileSystemAndSmart(
[{ deviceName: "/dev/sda1", used: "100", available: "900", percentage: 10 }],
[{ deviceName: "/dev/sda", temperature: 42, overallStatus: "GOOD" }],
);
expect(result).toEqual([
{
deviceName: "/dev/sda",
used: "100",
available: "900",
percentage: 10,
temperature: 42,
overallStatus: "GOOD",
},
]);
});
test("joins Synology volume names without stripping volume suffixes", () => {
const result = matchFileSystemAndSmart(
[{ deviceName: "volume_1", used: "100", available: "900", percentage: 10 }],
[{ deviceName: "volume_1", temperature: 39, overallStatus: "normal" }],
);
expect(result[0]).toMatchObject({
deviceName: "volume_1",
temperature: 39,
overallStatus: "normal",
});
});
test("joins NVMe partitions with their base device SMART data", () => {
const result = matchFileSystemAndSmart(
[{ deviceName: "/dev/nvme0n1p2", used: "100", available: "900", percentage: 10 }],
[{ deviceName: "/dev/nvme0n1", temperature: 41, overallStatus: "GOOD" }],
);
expect(result).toEqual([
{
deviceName: "/dev/nvme0n1",
used: "100",
available: "900",
percentage: 10,
temperature: 41,
overallStatus: "GOOD",
},
]);
});
test("does not join SMART data from a different md device", () => {
const result = matchFileSystemAndSmart(
[{ deviceName: "/dev/md0", used: "100", available: "900", percentage: 10 }],
[{ deviceName: "/dev/md1", temperature: 41, overallStatus: "GOOD" }],
);
expect(result[0]).toMatchObject({
deviceName: "/dev/md0",
temperature: null,
overallStatus: "",
});
});
});
@@ -0,0 +1,67 @@
interface StorageVolumeEntry {
deviceName: string;
}
const partitionSuffixPatterns: ReadonlyArray<{ pattern: RegExp; baseGroupIndex: number }> = [
// SCSI/SATA/VirtIO: /dev/sda1 -> /dev/sda
{ pattern: /^(\/dev\/(?:sd|vd|hd|xvd)[a-z]+)[0-9]+$/, baseGroupIndex: 1 },
// NVMe: /dev/nvme0n1p2 -> /dev/nvme0n1
{ pattern: /^(\/dev\/nvme[0-9]+n[0-9]+)p[0-9]+$/, baseGroupIndex: 1 },
// eMMC: /dev/mmcblk0p1 -> /dev/mmcblk0
{ pattern: /^(\/dev\/mmcblk[0-9]+)p[0-9]+$/, baseGroupIndex: 1 },
];
export const normalizeStorageDeviceName = (deviceName: string): string => {
for (const { pattern, baseGroupIndex } of partitionSuffixPatterns) {
const match = deviceName.match(pattern);
if (match) {
return match[baseGroupIndex] ?? deviceName;
}
}
return deviceName;
};
export const toScopedStorageVolumeValue = (integrationId: string, value: string): string => {
const separatorIndex = value.indexOf(":");
const volumeName = separatorIndex === -1 ? value : value.slice(separatorIndex + 1);
return `${integrationId}:${volumeName}`;
};
const storageDeviceNamesMatch = (leftDeviceName: string, rightDeviceName: string): boolean => {
return (
leftDeviceName === rightDeviceName ||
normalizeStorageDeviceName(leftDeviceName) === normalizeStorageDeviceName(rightDeviceName)
);
};
const matchesVisibleStorageVolume = (
visibleVolume: string,
integrationId: string,
deviceName: string,
): boolean => {
const separatorIndex = visibleVolume.indexOf(":");
if (separatorIndex === -1) {
return storageDeviceNamesMatch(visibleVolume, deviceName);
}
const visibleIntegrationId = visibleVolume.slice(0, separatorIndex);
const visibleVolumeName = visibleVolume.slice(separatorIndex + 1);
return visibleIntegrationId === integrationId && storageDeviceNamesMatch(visibleVolumeName, deviceName);
};
export const filterStorageVolumes = <TEntry extends StorageVolumeEntry>(
entries: TEntry[],
visibleStorageVolumes: string[] | undefined,
integrationId: string,
): TEntry[] => {
if (!visibleStorageVolumes || visibleStorageVolumes.length === 0) {
return entries;
}
return entries.filter((entry) =>
visibleStorageVolumes.some((visibleVolume) =>
matchesVisibleStorageVolume(visibleVolume, integrationId, entry.deviceName),
),
);
};
+10 -166
View File
@@ -29,10 +29,16 @@ export default function FirewallWidget({ integrationIds, width, itemId }: Widget
}
}, []);
const firewallsCpuData = useUpdatingCpuStatus(integrationIds);
const firewallsMemoryData = useUpdatingMemoryStatus(integrationIds);
const firewallsVersionData = useUpdatingVersionStatus(integrationIds);
const firewallsInterfacesData = useUpdatingInterfacesStatus(integrationIds);
const { data: firewallsCpuData = [] } = clientApi.widget.firewall.getFirewallCpuStatus.useQuery({ integrationIds });
const { data: firewallsMemoryData = [] } = clientApi.widget.firewall.getFirewallMemoryStatus.useQuery({
integrationIds,
});
const { data: firewallsVersionData = [] } = clientApi.widget.firewall.getFirewallVersionStatus.useQuery({
integrationIds,
});
const { data: firewallsInterfacesData = [] } = clientApi.widget.firewall.getFirewallInterfacesStatus.useQuery({
integrationIds,
});
const initialSelectedFirewall = firewallsVersionData[0] ? firewallsVersionData[0].integration.id : "undefined";
const isTiny = width < 256;
@@ -182,168 +188,6 @@ export default function FirewallWidget({ integrationIds, width, itemId }: Widget
);
}
export const useUpdatingCpuStatus = (integrationIds: string[]) => {
const utils = clientApi.useUtils();
const { data: firewallsCpuData = [] } = clientApi.widget.firewall.getFirewallCpuStatus.useQuery(
{
integrationIds,
},
{
staleTime: 5 * 1000,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
retry: false,
},
);
clientApi.widget.firewall.subscribeFirewallCpuStatus.useSubscription(
{
integrationIds,
},
{
onData: (data) => {
utils.widget.firewall.getFirewallCpuStatus.setData(
{
integrationIds,
},
(prevData) => {
if (!prevData) {
return undefined;
}
return prevData.map((item) =>
item.integration.id === data.integration.id ? { ...item, summary: data.summary } : item,
);
},
);
},
},
);
return firewallsCpuData;
};
export const useUpdatingMemoryStatus = (integrationIds: string[]) => {
const utils = clientApi.useUtils();
const { data: firewallsMemoryData = [] } = clientApi.widget.firewall.getFirewallMemoryStatus.useQuery(
{
integrationIds,
},
{
staleTime: 15 * 1000,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
retry: false,
},
);
clientApi.widget.firewall.subscribeFirewallMemoryStatus.useSubscription(
{
integrationIds,
},
{
onData: (data) => {
utils.widget.firewall.getFirewallMemoryStatus.setData(
{
integrationIds,
},
(prevData) => {
if (!prevData) {
return undefined;
}
return prevData.map((item) =>
item.integration.id === data.integration.id ? { ...item, summary: data.summary } : item,
);
},
);
},
},
);
return firewallsMemoryData;
};
export const useUpdatingVersionStatus = (integrationIds: string[]) => {
const utils = clientApi.useUtils();
const { data: firewallsVersionData = [] } = clientApi.widget.firewall.getFirewallVersionStatus.useQuery(
{
integrationIds,
},
{
staleTime: 60 * 60 * 1000,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
retry: false,
},
);
clientApi.widget.firewall.subscribeFirewallVersionStatus.useSubscription(
{
integrationIds,
},
{
onData: (data) => {
utils.widget.firewall.getFirewallVersionStatus.setData(
{
integrationIds,
},
(prevData) => {
if (!prevData) {
return undefined;
}
return prevData.map((item) =>
item.integration.id === data.integration.id ? { ...item, summary: data.summary } : item,
);
},
);
},
},
);
return firewallsVersionData;
};
export const useUpdatingInterfacesStatus = (integrationIds: string[]) => {
const utils = clientApi.useUtils();
const { data: firewallsInterfacesData = [] } = clientApi.widget.firewall.getFirewallInterfacesStatus.useQuery(
{
integrationIds,
},
{
staleTime: 30 * 1000,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
retry: false,
},
);
clientApi.widget.firewall.subscribeFirewallInterfacesStatus.useSubscription(
{
integrationIds,
},
{
onData: (data) => {
utils.widget.firewall.getFirewallInterfacesStatus.setData(
{
integrationIds,
},
(prevData) => {
if (!prevData) {
return undefined;
}
return prevData.map((item) =>
item.integration.id === data.integration.id ? { ...item, summary: data.summary } : item,
);
},
);
},
},
);
return firewallsInterfacesData;
};
export function formatBitsPerSec(bytes: number, decimals: number): string {
if (bytes === 0) return "0 b/s";
@@ -35,27 +35,9 @@ export const ClusterHealthMonitoring = ({
width,
}: WidgetComponentProps<"healthMonitoring"> & { integrationId: string }) => {
const t = useI18n();
const { data: healthData } = clientApi.widget.healthMonitoring.getClusterHealthStatus.useQuery(
{
integrationId,
},
{
staleTime: 5 * 1000,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
retry: false,
},
);
const utils = clientApi.useUtils();
clientApi.widget.healthMonitoring.subscribeClusterHealthStatus.useSubscription(
{ integrationId },
{
onData(data) {
utils.widget.healthMonitoring.getClusterHealthStatus.setData({ integrationId }, data);
},
},
);
const { data: healthData } = clientApi.widget.healthMonitoring.getClusterHealthStatus.useQuery({
integrationId,
});
if (!healthData) return <WidgetEmptyState />;
@@ -4,6 +4,7 @@ import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options";
import { createStorageVolumeMultiSelectOptions } from "../storage-volume-options";
export const { definition, componentLoader } = createWidgetDefinition("healthMonitoring", {
icon: IconHeartRateMonitor,
@@ -28,6 +29,7 @@ export const { definition, componentLoader } = createWidgetDefinition("healthMon
fileSystem: factory.switch({
defaultValue: true,
}),
visibleStorageVolumes: factory.integrationMultiSelect(createStorageVolumeMultiSelectOptions()),
visibleClusterSections: factory.multiSelect({
options: [
{
@@ -77,6 +79,11 @@ export const { definition, componentLoader } = createWidgetDefinition("healthMon
return integrationKinds.every((kind) => kind === "proxmox") || integrationKinds.length === 0;
},
},
visibleStorageVolumes: {
shouldHide(_, integrationKinds) {
return integrationKinds.length === 0 || !integrationKinds.every((kind) => kind === "synology");
},
},
showUptime: {
shouldHide(_, integrationKinds) {
// Uptime is only shown on cluster health tab
@@ -104,7 +111,7 @@ export const { definition, componentLoader } = createWidgetDefinition("healthMon
},
);
},
supportedIntegrations: getIntegrationKindsByCategory("healthMonitoring"),
supportedIntegrations: getIntegrationKindsByCategory("healthMonitoring").filter((kind) => kind !== "patchmon"),
errors: {
INTERNAL_SERVER_ERROR: {
icon: IconServerOff,
@@ -36,8 +36,9 @@ import { humanFileSize } from "@homarr/common";
import type { TranslationFunction } from "@homarr/translation";
import { useI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition";
import { filterStorageVolumes, normalizeStorageDeviceName } from "../filter-storage-volumes";
import { WidgetEmptyState } from "../common/empty-state";
import type { WidgetComponentProps } from "../definition";
import { CpuRing } from "./rings/cpu-ring";
import { CpuTempRing } from "./rings/cpu-temp-ring";
import { GpuRing } from "./rings/gpu-ring";
@@ -52,39 +53,12 @@ export const SystemHealthMonitoring = ({
width,
}: WidgetComponentProps<"healthMonitoring">) => {
const t = useI18n();
const { data: healthData = [] } = clientApi.widget.healthMonitoring.getSystemHealthStatus.useQuery(
{
integrationIds,
},
{
staleTime: 5 * 1000,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
retry: false,
},
);
const { data: healthData = [] } = clientApi.widget.healthMonitoring.getSystemHealthStatus.useQuery({
integrationIds,
});
const [opened, { open, close }] = useDisclosure(false);
const utils = clientApi.useUtils();
const board = useRequiredBoard();
clientApi.widget.healthMonitoring.subscribeSystemHealthStatus.useSubscription(
{ integrationIds },
{
onData(data) {
utils.widget.healthMonitoring.getSystemHealthStatus.setData({ integrationIds }, (prevData) => {
if (!prevData) {
return undefined;
}
return prevData.map((item) =>
item.integrationId === data.integrationId
? { ...item, healthInfo: data.healthInfo, updatedAt: data.timestamp }
: item,
);
});
},
},
);
const isTiny = width < 256;
if (healthData.length === 0) return <WidgetEmptyState />;
@@ -92,7 +66,13 @@ export const SystemHealthMonitoring = ({
return (
<Stack h="100%" gap="sm" className="health-monitoring">
{healthData.map(({ integrationId, integrationName, healthInfo }) => {
const disksData = matchFileSystemAndSmart(healthInfo.fileSystem, healthInfo.smart);
const filteredFileSystem = filterStorageVolumes(
healthInfo.fileSystem,
options.visibleStorageVolumes,
integrationId,
);
const filteredSmart = filterStorageVolumes(healthInfo.smart, options.visibleStorageVolumes, integrationId);
const disksData = matchFileSystemAndSmart(filteredFileSystem, filteredSmart);
const memoryUsage = formatMemoryUsage(healthInfo.memAvailableInBytes, healthInfo.memUsedInBytes);
return (
<Stack
@@ -316,8 +296,12 @@ interface SmartData {
export const matchFileSystemAndSmart = (fileSystems: FileSystem[], smartData: SmartData[]) => {
return fileSystems
.map((fileSystem) => {
const baseDeviceName = fileSystem.deviceName.replace(/[0-9]+$/, "");
const smartDisk = smartData.find((smart) => smart.deviceName === baseDeviceName);
const normalizedFileSystemName = normalizeStorageDeviceName(fileSystem.deviceName);
const smartDisk = smartData.find(
(smart) =>
smart.deviceName === fileSystem.deviceName ||
normalizeStorageDeviceName(smart.deviceName) === normalizedFileSystemName,
);
return {
deviceName: smartDisk?.deviceName ?? fileSystem.deviceName,
@@ -1,7 +1,7 @@
"use client";
import type { Dispatch, SetStateAction } from "react";
import { useEffect, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { Box, Center, Group, Image, Stack, Text } from "@mantine/core";
import { IconAlertCircle, IconCalendar } from "@tabler/icons-react";
@@ -23,9 +23,18 @@ export default function ImmichAlbumCarouselWidget({
integrationId: integrationIds[0] ?? "",
albumId: options.albumId ?? "",
},
{ enabled: Boolean(options.albumId), staleTime: 15 * 60 * 1000 },
{ enabled: Boolean(options.albumId) },
);
const photoAssets = useMemo(() => {
const assets = album?.assets.filter((asset) => asset.type === "IMAGE") ?? [];
return options.randomizePhotos ? shuffle(assets) : assets;
}, [album?.assets, options.randomizePhotos]);
useEffect(() => {
setCurrentPhotoIndex(0);
}, [photoAssets]);
if (!options.albumId) {
return <NoAlbumSelected />;
}
@@ -36,8 +45,6 @@ export default function ImmichAlbumCarouselWidget({
return <NoPhotosInAlbum />;
}
const photoAssets = album.assets.filter((asset) => asset.type === "IMAGE");
if (photoAssets.length === 0) {
return <NoPhotosInAlbum />;
}
@@ -53,10 +60,22 @@ export default function ImmichAlbumCarouselWidget({
);
}
function shuffle<T>(items: T[]) {
const shuffled = [...items];
for (let index = shuffled.length - 1; index > 0; index--) {
const randomIndex = Math.floor(Math.random() * (index + 1));
const current = shuffled[index];
const random = shuffled[randomIndex];
if (current === undefined || random === undefined) continue;
shuffled[index] = random;
shuffled[randomIndex] = current;
}
return shuffled;
}
interface CarouselProps {
assets: {
id: string;
deviceAssetId: string;
originalPath: string;
fileModifiedAt: string;
publicLink: string;
@@ -1,6 +1,8 @@
import { IconPhoto } from "@tabler/icons-react";
import z from "zod";
import { clientApi } from "@homarr/api/client";
import { createWidgetDefinition } from "../../definition";
import { optionsBuilder } from "../../options";
@@ -10,9 +12,20 @@ export const { definition, componentLoader } = createWidgetDefinition("immich-al
integrationsRequired: true,
createOptions() {
return optionsBuilder.from((factory) => ({
albumId: factory.text({
defaultValue: "",
albumId: factory.integrationSelect({
withDescription: true,
clearable: true,
useOptions: (integrationIds: string[]) => {
const {
data = [],
isPending,
isError,
} = clientApi.widget.immich.getAlbums.useQuery(
{ integrationId: integrationIds[0] ?? "" },
{ enabled: integrationIds.length > 0, staleTime: 15 * 60 * 1000 },
);
return { data: data.map((album) => ({ value: album.id, label: album.albumName })), isPending, isError };
},
}),
rotationIntervalSeconds: factory.number({
defaultValue: 5,
@@ -23,6 +36,10 @@ export const { definition, componentLoader } = createWidgetDefinition("immich-al
defaultValue: false,
withDescription: true,
}),
randomizePhotos: factory.switch({
defaultValue: false,
withDescription: true,
}),
}));
},
}).withDynamicImport(() => import("./component"));
@@ -17,12 +17,9 @@ export default function ImmichServerStatsWidget({
options,
}: WidgetComponentProps<"immich-serverStats">) {
const t = useI18n();
const { data: stats } = clientApi.widget.immich.getServerStats.useQuery(
{
integrationId: integrationIds[0] ?? "",
},
{ staleTime: 15 * 60 * 1000 },
);
const { data: stats } = clientApi.widget.immich.getServerStats.useQuery({
integrationId: integrationIds[0] ?? "",
});
if (!stats) return <WidgetEmptyState />;
+14 -1
View File
@@ -12,6 +12,7 @@ import * as audioStats from "./audio-stats";
import * as app from "./app";
import * as archiveTeamWarrior from "./archive-team-warrior";
import * as bookmarks from "./bookmarks";
import * as bazarr from "./bazarr";
import * as calendar from "./calendar";
import * as clock from "./clock";
import * as coolify from "./coolify";
@@ -32,11 +33,13 @@ import * as mediaRequestsList from "./media-requests/list";
import * as mediaRequestsStats from "./media-requests/stats";
import * as mediaServer from "./media-server";
import * as mediaTranscoding from "./media-transcoding";
import * as mediaMissing from "./media-missing";
import * as minecraftServerStatus from "./minecraft/server-status";
import * as networkControllerStatus from "./network-controller/network-status";
import * as networkControllerSummary from "./network-controller/summary";
import * as notebook from "./notebook";
import * as paperlessNgx from "./paperless-ngx";
import * as patchmon from "./patchmon";
import * as notifications from "./notifications";
import type { WidgetOptionDefinition } from "./options";
import * as releases from "./releases";
@@ -49,6 +52,7 @@ import * as stockPrice from "./stocks";
import * as systemDisks from "./system-disks";
import * as systemResources from "./system-resources";
import * as timetable from "./timetable";
import * as traefik from "./traefik";
import * as tracearr from "./tracearr";
import * as umami from "./umami";
import * as ups from "./ups";
@@ -62,7 +66,12 @@ import * as weather from "./weather";
import * as customApi from "./custom-api";
import * as openWebUi from "./open-webui";
export type { WidgetDefinition, WidgetOptionsSettings } from "./definition";
export type {
WidgetDefinition,
WidgetContextMenuAction,
WidgetContextActionProps,
WidgetOptionsSettings,
} from "./definition";
export type { WidgetComponentProps };
export type { WidgetOptionDefinition, WidgetOptionType } from "./options";
@@ -85,10 +94,12 @@ export const widgetImports = {
downloads,
"mediaRequests-requestList": mediaRequestsList,
"mediaRequests-requestStats": mediaRequestsStats,
mediaMissing,
networkControllerSummary,
networkControllerStatus,
rssFeed,
bookmarks,
bazarr,
indexerManager,
healthMonitoring,
mediaTranscoding,
@@ -105,6 +116,7 @@ export const widgetImports = {
"immich-serverStats": immichServerStats,
"immich-albumCarousel": immichAlbumCarousel,
paperlessNgx,
patchmon,
tracearr,
speedtestTracker,
uptimeKuma,
@@ -116,6 +128,7 @@ export const widgetImports = {
beszelSystemGrid,
beszelAlerts,
beszelSystemStats,
traefik,
customApi,
openWebUi,
} satisfies WidgetImportRecord;
@@ -18,33 +18,13 @@ export default function IndexerManagerWidget({
height,
}: WidgetComponentProps<"indexerManager">) {
const t = useI18n();
const { data: indexersData = [] } = clientApi.widget.indexerManager.getIndexersStatus.useQuery(
{ integrationIds },
{
staleTime: 5 * 60 * 1000,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
retry: false,
},
);
const { data: indexersData = [] } = clientApi.widget.indexerManager.getIndexersStatus.useQuery({ integrationIds });
const utils = clientApi.useUtils();
const { mutate: testAll, isPending } = clientApi.widget.indexerManager.testAllIndexers.useMutation();
const { mutate: testAll, isPending } = clientApi.widget.indexerManager.testAllIndexers.useMutation({
onSettled: () => void utils.widget.indexerManager.getIndexersStatus.invalidate(),
});
const board = useRequiredBoard();
clientApi.widget.indexerManager.subscribeIndexersStatus.useSubscription(
{ integrationIds },
{
onData(newData) {
utils.widget.indexerManager.getIndexersStatus.setData({ integrationIds }, (previousData) =>
previousData?.map((item) =>
item.integrationId === newData.integrationId ? { ...item, indexers: newData.indexers } : item,
),
);
},
},
);
const hasSmallWidth = width < 256;
const hasSmallHeight = height < 256;
@@ -0,0 +1,49 @@
.card {
position: relative;
overflow: hidden;
display: flex;
text-decoration: none;
color: inherit;
border: 1px solid var(--border-color);
transition:
border-color 120ms ease,
transform 120ms ease,
box-shadow 120ms ease;
@mixin dark {
--border-color: var(--mantine-color-dark-4);
}
@mixin light {
--border-color: var(--mantine-color-gray-3);
}
&:hover {
border-color: var(--mantine-primary-color-filled);
transform: translateY(-1px);
box-shadow: var(--mantine-shadow-sm);
}
}
.backdrop {
position: absolute;
inset: 0;
background-size: cover;
background-position: center;
filter: blur(20px) saturate(1.2);
transform: scale(1.3);
opacity: 0.16;
pointer-events: none;
}
.content {
position: relative;
z-index: 1;
flex: 1;
min-width: 0;
}
.poster {
flex-shrink: 0;
object-fit: cover;
box-shadow: var(--mantine-shadow-sm);
}
@@ -0,0 +1,217 @@
"use client";
import type { ReactNode } from "react";
import { Badge, Box, Center, Group, Image, Paper, RingProgress, ScrollArea, SimpleGrid, Stack, Tabs, Text, ThemeIcon } from "@mantine/core";
import { IconDownload, IconMovie, IconQuestionMark, IconVideo } from "@tabler/icons-react";
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 type { WidgetComponentProps } from "../definition";
import { NoIntegrationDataError } from "../errors/no-data-integration";
import classes from "./component.module.css";
export default function MediaMissingWidget({ integrationIds, options, width, height }: WidgetComponentProps<"mediaMissing">) {
const t = useScopedI18n("widget.mediaMissing");
const pageSize = Number(options.pageSize);
const { data } = clientApi.widget.mediaOrganizer.getData.useQuery(
{ integrationIds, pageSize },
{ staleTime: 60 * 1000, refetchOnWindowFocus: false, refetchOnReconnect: false },
);
if (!data) return <WidgetEmptyState />;
if (data.length === 0) throw new NoIntegrationDataError();
if (!options.showMissing && !options.showQueued)
return (
<Center h="100%" p="sm">
<Text c="dimmed" size="sm" ta="center">
{t("empty.noTabsEnabled")}
</Text>
</Center>
);
const missing = data.flatMap((entry) =>
entry.missing.map((item) => ({ item, integrationId: entry.integrationId })),
);
const queued = data.flatMap((entry) =>
entry.queued.map((item) => ({ item, integrationId: entry.integrationId })),
);
const missingCount = data.reduce((sum, entry) => sum + entry.missingCount, 0);
const queuedCount = data.reduce((sum, entry) => sum + entry.queuedCount, 0);
const isThin = width > 0 && width < 160;
const isShort = height > 0 && height < 180;
const targetCardWidth = isShort ? 130 : 200;
const columns = width > 0 ? Math.max(1, Math.min(Math.floor(width / targetCardWidth), 4)) : 1;
const density: Density = isThin ? "thin" : width > 0 && width / columns < 180 ? "compact" : "comfortable";
const tabLabel = (label: string, shown: number, total: number) =>
isThin ? total : `${label} (${shown}/${total})`;
const renderPanel = (entries: { item: MissingMediaItem | QueuedMediaItem; integrationId: string }[], emptyLabel: string) => (
<ScrollArea h="100%" scrollbarSize={4}>
<Box p="xs">
{entries.length === 0 ? (
<Text size="sm" c="dimmed" ta="center" py="md">
{emptyLabel}
</Text>
) : (
<SimpleGrid cols={columns} spacing="xs" verticalSpacing="xs">
{entries.map(({ item, integrationId }) => (
<MediaCard key={`${integrationId}-${item.type}-${item.id}`} item={item} density={density} />
))}
</SimpleGrid>
)}
</Box>
</ScrollArea>
);
return (
<Tabs
defaultValue={options.showMissing ? "missing" : "queued"}
h="100%"
style={{ display: "flex", flexDirection: "column" }}
>
<Tabs.List grow>
{options.showMissing && (
<Tabs.Tab value="missing" px={isThin ? 6 : undefined} leftSection={<IconQuestionMark size={14} />}>
{tabLabel(t("tab.missing"), missing.length, missingCount)}
</Tabs.Tab>
)}
{options.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 && (
<Tabs.Panel value="missing" flex={1} style={{ overflow: "hidden" }}>
{renderPanel(missing, t("empty.missing"))}
</Tabs.Panel>
)}
{options.showQueued && (
<Tabs.Panel value="queued" flex={1} style={{ overflow: "hidden" }}>
{renderPanel(queued, t("empty.queued"))}
</Tabs.Panel>
)}
</Tabs>
);
}
type Density = "thin" | "compact" | "comfortable";
const CARD_HEIGHT: Record<Density, number> = { thin: 52, compact: 56, comfortable: 68 };
const posterSizes: Record<Density, number> = { thin: 34, compact: 40, comfortable: 52 };
const Poster = ({ src, type, density }: { src?: string | null; type: "movie" | "episode"; density: Density }) => {
const size = posterSizes[density];
const w = Math.round(size * 0.68);
if (src) {
return <Image className={classes.poster} src={src} h={size} w={w} radius="sm" alt="" />;
}
return (
<ThemeIcon className={classes.poster} h={size} w={w} radius="sm" variant="light" color={type === "movie" ? "yellow" : "blue"}>
{type === "movie" ? <IconMovie size={size * 0.5} /> : <IconVideo size={size * 0.5} />}
</ThemeIcon>
);
};
const episodeCode = (item: MissingMediaItem | QueuedMediaItem) =>
item.type === "episode" && item.seasonNumber !== undefined && item.episodeNumber !== undefined
? `S${String(item.seasonNumber).padStart(2, "0")}E${String(item.episodeNumber).padStart(2, "0")}`
: null;
const TypeBadge = ({ item, density }: { item: MissingMediaItem | QueuedMediaItem; density: Density }) => {
const t = useScopedI18n("widget.mediaMissing");
const color = item.type === "movie" ? "yellow" : "blue";
const code = episodeCode(item);
if (density !== "comfortable") {
return (
<Badge size="xs" variant="light" color={color}>
{code ?? t(`type.${item.type}`)}
</Badge>
);
}
return (
<Group gap={4} wrap="nowrap">
<Badge size="xs" variant="light" color={color}>
{t(`type.${item.type}`)}
</Badge>
{code && (
<Badge size="xs" variant="outline" color="gray">
{code}
</Badge>
)}
</Group>
);
};
const primaryTitle = (item: MissingMediaItem | QueuedMediaItem) =>
item.type === "episode" ? (item.seriesTitle ?? item.title) : item.title;
const progressColor = (percent: number) => (percent >= 90 ? "green" : percent >= 40 ? "cyan" : "orange");
const ringSizes: Record<Density, { size: number; thickness: number; fz: string }> = {
thin: { size: 30, thickness: 3, fz: "8px" },
compact: { size: 34, thickness: 3, fz: "9px" },
comfortable: { size: 40, thickness: 4, fz: "10px" },
};
const ProgressRing = ({ percent, density }: { percent: number; density: Density }) => {
const color = progressColor(percent);
const ring = ringSizes[density];
return (
<RingProgress
size={ring.size}
thickness={ring.thickness}
roundCaps
sections={[{ value: percent, color }]}
label={
<Text ta="center" fw={700} fz={ring.fz} lh={1}>
{percent}
</Text>
}
/>
);
};
const CardShell = ({ item, density, children }: { 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]}>
{item.imageUrl && (
<span className={classes.backdrop} style={{ backgroundImage: `url("${item.imageUrl}")` }} aria-hidden />
)}
<div className={classes.content}>{children}</div>
</Paper>
);
const MediaCard = ({ item, density }: { item: MissingMediaItem | QueuedMediaItem; density: Density }) => {
const isQueued = "percentComplete" in item;
return (
<CardShell item={item} density={density}>
<Group gap="xs" wrap="nowrap" align="center" h="100%">
<Poster src={item.imageUrl} type={item.type} density={density} />
<Stack gap={2} style={{ minWidth: 0, flex: 1 }}>
<TypeBadge item={item} density={density} />
<Text fz="xs" fw={600} lineClamp={1} lh={1.25}>
{primaryTitle(item)}
</Text>
{density === "comfortable" && (
<Text fz="xs" c="dimmed" lineClamp={1} lh={1.1}>
{item.type === "episode" ? item.title : item.year}
</Text>
)}
</Stack>
{isQueued && <ProgressRing percent={Math.min(100, Math.max(0, item.percentComplete))} density={density} />}
</Group>
</CardShell>
);
};
@@ -0,0 +1,26 @@
import { IconMovie } from "@tabler/icons-react";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options";
const pageSizeOptions = ["10", "20", "30", "50"] as const;
export const { componentLoader, definition } = createWidgetDefinition("mediaMissing", {
icon: IconMovie,
createOptions() {
return optionsBuilder.from((factory) => ({
showMissing: factory.switch({ defaultValue: true }),
showQueued: factory.switch({ defaultValue: true }),
pageSize: factory.select({
defaultValue: "10",
options: pageSizeOptions.map((value) => ({
value,
label: value,
})),
}),
}));
},
supportedIntegrations: getIntegrationKindsByCategory("mediaOrganizer"),
}).withDynamicImport(() => import("./component"));
@@ -6,7 +6,7 @@ import { IconBook, IconCalendar, IconClock, IconStarFilled } from "@tabler/icons
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { getMantineColor } from "@homarr/common";
import { getMantineColor, toValidDate } from "@homarr/common";
import { getIconUrl } from "@homarr/definitions";
import type { MediaRelease } from "@homarr/integrations/types";
import { mediaTypeConfigurations } from "@homarr/integrations/types";
@@ -18,12 +18,9 @@ import { OverflowBadge } from "@homarr/ui";
import type { WidgetComponentProps } from "../definition";
export default function MediaReleasesWidget({ options, integrationIds }: WidgetComponentProps<"mediaReleases">) {
const { data: releases = [] } = clientApi.widget.mediaRelease.getMediaReleases.useQuery(
{
integrationIds,
},
{ staleTime: 5 * 60 * 1000 },
);
const { data: releases = [] } = clientApi.widget.mediaRelease.getMediaReleases.useQuery({
integrationIds,
});
return (
<Stack p="xs" gap="sm">
@@ -42,6 +39,19 @@ interface ItemProps {
options: WidgetComponentProps<"mediaReleases">["options"];
}
const formatReleaseDate = (value: unknown, locale: string) => {
const date = toValidDate(value);
if (!date) return "—";
return Intl.DateTimeFormat(locale, {
month: "2-digit",
year: "numeric",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hour12: false,
}).format(date);
};
const Item = ({ item, options }: ItemProps) => {
const locale = useCurrentLocale();
const t = useI18n();
@@ -94,17 +104,7 @@ const Item = ({ item, options }: ItemProps) => {
)}
</Stack>
<Group gap={6} style={{ rowGap: 0 }}>
<Info
icon={IconCalendar}
label={Intl.DateTimeFormat(locale, {
month: "2-digit",
year: "numeric",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hour12: false,
}).format(item.releaseDate)}
/>
<Info icon={IconCalendar} label={formatReleaseDate(item.releaseDate, locale)} />
{length !== undefined && (
<>
<InfoDivider />
@@ -19,6 +19,7 @@ import { IconSearch, IconThumbDown, IconThumbUp } from "@tabler/icons-react";
import type { RouterInputs, RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { useRequiredBoard } from "@homarr/boards/context";
import { toValidDate } from "@homarr/common";
import type { MediaRequestStatus } from "@homarr/integrations/types";
import { mediaAvailabilityConfiguration, mediaRequestStatusConfiguration } from "@homarr/integrations/types";
import { openMediaRequestSearch } from "@homarr/spotlight";
@@ -35,44 +36,9 @@ export default function MediaServerWidget({
options,
width,
}: WidgetComponentProps<"mediaRequests-requestList">) {
const { data: mediaRequests } = clientApi.widget.mediaRequests.getLatestRequests.useQuery(
{
integrationIds,
},
{
staleTime: 60 * 1000,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
},
);
const utils = clientApi.useUtils();
clientApi.widget.mediaRequests.subscribeToLatestRequests.useSubscription(
{
integrationIds,
},
{
onData(data) {
utils.widget.mediaRequests.getLatestRequests.setData({ integrationIds }, (prevData) => {
if (!prevData) return [];
const filteredData = prevData.filter(({ integrationId }) => integrationId !== data.integrationId);
const newData = filteredData.concat(
data.requests.map((request) => ({ ...request, integrationId: data.integrationId })),
);
return newData.toSorted((dataA, dataB) => {
if (dataA.status === dataB.status) {
return dataB.createdAt.getTime() - dataA.createdAt.getTime();
}
return (
mediaRequestStatusConfiguration[dataA.status].position -
mediaRequestStatusConfiguration[dataB.status].position
);
});
});
},
},
);
const { data: mediaRequests } = clientApi.widget.mediaRequests.getLatestRequests.useQuery({
integrationIds,
});
if (!mediaRequests) return <WidgetEmptyState />;
if (mediaRequests.length === 0) throw new NoIntegrationDataError();
@@ -169,7 +135,7 @@ const MediaRequestCard = ({ request, isTiny, options }: MediaRequestCardProps) =
<Group justify="space-between" gap="xs" className="mediaRequests-list-item-top-group">
<Group gap="xs">
<Text className="mediaRequests-list-item-media-year" size="xs">
{request.airDate?.getFullYear() ?? t("toBeDetermined")}
{toValidDate(request.airDate)?.getFullYear() ?? t("toBeDetermined")}
</Text>
{!isTiny && (
<Badge
@@ -233,7 +199,10 @@ interface DecisionButtonsProps {
}
const DecisionButtons = ({ requestId, integrationId }: DecisionButtonsProps) => {
const { mutate: mutateRequestAnswer } = clientApi.widget.mediaRequests.answerRequest.useMutation();
const utils = clientApi.useUtils();
const { mutate: mutateRequestAnswer } = clientApi.widget.mediaRequests.answerRequest.useMutation({
onSettled: () => void utils.widget.mediaRequests.invalidate(),
});
const t = useScopedI18n("widget.mediaRequests-requestList");
const handleDecision = (answer: RouterInputs["widget"]["mediaRequests"]["answerRequest"]["answer"]) => {
mutateRequestAnswer({
@@ -1,6 +1,7 @@
import { IconZoomQuestion } from "@tabler/icons-react";
import { IconSearch, IconZoomQuestion } from "@tabler/icons-react";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { openMediaRequestSearch } from "@homarr/spotlight";
import { createWidgetDefinition } from "../../definition";
import { optionsBuilder } from "../../options";
@@ -14,5 +15,15 @@ export const { componentLoader, definition } = createWidgetDefinition("mediaRequ
}),
}));
},
contextActions: ({ integrationIds }) => [
{
key: "search",
label: (t) => t("search.mode.media.action.search.label"),
icon: IconSearch,
onClick: () => {
openMediaRequestSearch({ integrationIds });
},
},
],
supportedIntegrations: getIntegrationKindsByCategory("mediaRequest"),
}).withDynamicImport(() => import("./component"));
@@ -36,16 +36,9 @@ export default function MediaServerWidget({
width,
}: WidgetComponentProps<"mediaRequests-requestStats">) {
const t = useScopedI18n("widget.mediaRequests-requestStats");
const { data: requestStats } = clientApi.widget.mediaRequests.getStats.useQuery(
{
integrationIds,
},
{
staleTime: 60 * 1000,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
},
);
const { data: requestStats } = clientApi.widget.mediaRequests.getStats.useQuery({
integrationIds,
});
const board = useRequiredBoard();
@@ -1,6 +1,7 @@
import { IconChartBar } from "@tabler/icons-react";
import { IconChartBar, IconSearch } from "@tabler/icons-react";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { openMediaRequestSearch } from "@homarr/spotlight";
import { createWidgetDefinition } from "../../definition";
@@ -9,5 +10,15 @@ export const { componentLoader, definition } = createWidgetDefinition("mediaRequ
createOptions() {
return {};
},
contextActions: ({ integrationIds }) => [
{
key: "search",
label: (t) => t("search.mode.media.action.search.label"),
icon: IconSearch,
onClick: () => {
openMediaRequestSearch({ integrationIds });
},
},
],
supportedIntegrations: getIntegrationKindsByCategory("mediaRequest"),
}).withDynamicImport(() => import("./component"));
@@ -18,23 +18,11 @@ import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
import type { WidgetComponentProps } from "../definition";
export default function MediaServerWidget({
options,
integrationIds,
isEditMode,
}: WidgetComponentProps<"mediaServer">) {
const { data: currentStreams = [] } = clientApi.widget.mediaServer.getCurrentStreams.useQuery(
{
integrationIds,
showOnlyPlaying: options.showOnlyPlaying,
},
{
staleTime: 5 * 1000,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
},
);
const utils = clientApi.useUtils();
export default function MediaServerWidget({ options, integrationIds }: WidgetComponentProps<"mediaServer">) {
const { data: currentStreams = [] } = clientApi.widget.mediaServer.getCurrentStreams.useQuery({
integrationIds,
showOnlyPlaying: options.showOnlyPlaying,
});
const t = useScopedI18n("widget.mediaServer");
const columns = useMemo<MRT_ColumnDef<StreamSession>[]>(
@@ -83,32 +71,6 @@ export default function MediaServerWidget({
[t],
);
clientApi.widget.mediaServer.subscribeToCurrentStreams.useSubscription(
{
integrationIds,
showOnlyPlaying: options.showOnlyPlaying,
},
{
enabled: !isEditMode,
onData(data) {
utils.widget.mediaServer.getCurrentStreams.setData(
{ integrationIds, showOnlyPlaying: options.showOnlyPlaying },
(previousData) => {
return previousData?.map((pair) => {
if (pair.integrationId === data.integrationId) {
return {
...pair,
sessions: data.data,
};
}
return pair;
});
},
);
},
},
);
// Only render the flat list of sessions when the currentStreams change
// Otherwise it will always create a new array reference and cause the table to re-render
const flatSessions = useMemo(
@@ -38,18 +38,7 @@ export default function MediaTranscodingWidget({
pageSize: queuePageSize,
page: queuePage,
};
const { data: transcodingData } = clientApi.widget.mediaTranscoding.getDataAsync.useQuery(input, {
staleTime: 5 * 60 * 1000,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
});
const utils = clientApi.useUtils();
clientApi.widget.mediaTranscoding.subscribeData.useSubscription(input, {
onData(data) {
utils.widget.mediaTranscoding.getDataAsync.setData(input, data);
},
});
const { data: transcodingData } = clientApi.widget.mediaTranscoding.getDataAsync.useQuery(input);
const [view, setView] = useState<View>(options.defaultView);
const t = useI18n("widget.mediaTranscoding");
@@ -11,18 +11,7 @@ import { WidgetEmptyState } from "../../common/empty-state";
import type { WidgetComponentProps } from "../../definition";
export default function MinecraftServerStatusWidget({ options }: WidgetComponentProps<"minecraftServerStatus">) {
const { data: result } = clientApi.widget.minecraft.getServerStatus.useQuery(options, {
staleTime: 5 * 60 * 1000,
});
const utils = clientApi.useUtils();
clientApi.widget.minecraft.subscribeServerStatus.useSubscription(options, {
onData(data) {
utils.widget.minecraft.getServerStatus.setData(options, {
data,
timestamp: new Date(),
});
},
});
const { data: result } = clientApi.widget.minecraft.getServerStatus.useQuery(options);
const tStatus = useScopedI18n("widget.minecraftServerStatus.status");
if (!result) return <WidgetEmptyState />;
@@ -19,6 +19,7 @@ import { getInputForType } from "../_inputs";
import { FormProvider, useForm } from "../_inputs/form";
import type { BoardItemAdvancedOptions } from "../../../validation/src/shared";
import type { OptionsBuilderResult } from "../options";
import { OPTIONS_SUPER_REFINE } from "../options";
import type { IntegrationSelectOption } from "../widget-integration-select";
import { WidgetIntegrationSelect } from "../widget-integration-select";
import { WidgetAdvancedOptionsModal } from "./widget-advanced-options-modal";
@@ -38,6 +39,7 @@ interface ModalProps<TSort extends WidgetKind> {
integrationData: IntegrationSelectOption[];
integrationSupport: boolean;
settings: SettingsContextProps;
itemId?: string;
appId?: string;
}
@@ -53,24 +55,29 @@ export const WidgetEditModal = createModal<ModalProps<WidgetKind>>(({ actions, i
});
const { definition } = widgetImports[innerProps.kind];
const options = definition.createOptions(innerProps.settings) as Record<string, OptionsBuilderResult[string]>;
const optionsSuperRefine = (options as Record<symbol, unknown>)[OPTIONS_SUPER_REFINE] as
| ((data: Record<string, unknown>, ctx: z.RefinementCtx) => void)
| undefined;
const optionsFieldSchema = objectEntries(options).reduce(
(acc, [key, value]: [string, { type: string; validate?: z.ZodType<unknown> }]) => {
if (value.validate) {
acc[key] = value.type === "multiText" ? z.array(value.validate).optional() : value.validate;
}
return acc;
},
{} as Record<string, z.ZodType<unknown>>,
);
const optionsSchema = optionsSuperRefine
? z.object(optionsFieldSchema).superRefine(optionsSuperRefine)
: z.object(optionsFieldSchema);
const form = useForm({
mode: "controlled",
initialValues: innerProps.value,
validate: schemaResolver(
z.object({
options: z.object(
objectEntries(options).reduce(
(acc, [key, value]: [string, { type: string; validate?: z.ZodType<unknown> }]) => {
if (value.validate) {
acc[key] = value.type === "multiText" ? z.array(value.validate).optional() : value.validate;
}
return acc;
},
{} as Record<string, z.ZodType<unknown>>,
),
),
options: optionsSchema,
integrationIds: z.array(z.string()),
advancedOptions: z.object({
customCssClasses: z.array(z.string()),
@@ -146,6 +153,7 @@ export const WidgetEditModal = createModal<ModalProps<WidgetKind>>(({ actions, i
property={key}
options={value as never}
initialOptions={innerProps.value.options}
itemId={innerProps.itemId}
/>
);
})}
@@ -21,48 +21,9 @@ export default function NetworkControllerNetworkStatusWidget({
options,
integrationIds,
}: WidgetComponentProps<"networkControllerStatus">) {
const { data: summaries = [] } = clientApi.widget.networkController.summary.useQuery(
{
integrationIds,
},
{
staleTime: 5 * 60 * 1000,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
retry: false,
},
);
const utils = clientApi.useUtils();
clientApi.widget.networkController.subscribeToSummary.useSubscription(
{
integrationIds,
},
{
onData: (data) => {
utils.widget.networkController.summary.setData(
{
integrationIds,
},
(prevData) => {
if (!prevData) {
return undefined;
}
return prevData.map((item) =>
item.integration.id === data.integration.id
? {
...item,
summary: data.summary,
updatedAt: new Date(),
}
: item,
);
},
);
},
},
);
const { data: summaries = [] } = clientApi.widget.networkController.summary.useQuery({
integrationIds,
});
const data = useMemo(() => summaries.flatMap(({ summary }) => summary), [summaries]);
@@ -20,45 +20,12 @@ dayjs.extend(duration);
export default function NetworkControllerSummaryWidget({
integrationIds,
}: WidgetComponentProps<"networkControllerSummary">) {
const { data: summaries = [] } = clientApi.widget.networkController.summary.useQuery(
{
integrationIds,
},
{
staleTime: 5 * 60 * 1000,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
retry: false,
},
);
const utils = clientApi.useUtils();
const { data: summaries = [] } = clientApi.widget.networkController.summary.useQuery({
integrationIds,
});
const t = useI18n();
clientApi.widget.networkController.subscribeToSummary.useSubscription(
{
integrationIds,
},
{
onData: (data) => {
utils.widget.networkController.summary.setData(
{
integrationIds,
},
(prevData) => {
if (!prevData) {
return undefined;
}
return prevData.map((item) =>
item.integration.id === data.integration.id ? { ...item, summary: data.summary } : item,
);
},
);
},
},
);
const data = useMemo(() => summaries.flatMap(({ summary }) => summary), [summaries]);
return (
+15 -1
View File
@@ -1,5 +1,6 @@
"use client";
import type { MouseEvent as ReactMouseEvent } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import {
ActionIcon,
@@ -263,8 +264,21 @@ export function Notebook({ options, setOptions, isEditMode, boardId, itemId }: W
setIsEditing(handleEditToggleCallback);
}, [setIsEditing, handleEditToggleCallback]);
const handleDoubleClick = useCallback(
(event: ReactMouseEvent<HTMLDivElement>) => {
if (!canChange || isEditing) return;
// Ignore double-clicks bubbling up from interactive controls (e.g. the
// edit/save ActionIcon), which would otherwise toggle edit mode twice.
if (event.target instanceof Element && event.target.closest("button, a")) {
return;
}
setIsEditing(handleEditToggleCallback);
},
[canChange, isEditing, setIsEditing, handleEditToggleCallback],
);
return (
<Box h="100%">
<Box h="100%" onDoubleClick={handleDoubleClick}>
<RichTextEditor
p={0}
mt={0}
@@ -1,7 +1,7 @@
"use client";
import { useMemo } from "react";
import { Card, Flex, Group, ScrollArea, Stack, Text } from "@mantine/core";
import { Avatar, Card, Flex, Group, ScrollArea, Stack, Text } from "@mantine/core";
import { IconClock } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
@@ -12,43 +12,10 @@ import { useScopedI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition";
export default function NotificationsWidget({ options, integrationIds }: WidgetComponentProps<"notifications">) {
const { data: notificationIntegrations = [] } = clientApi.widget.notifications.getNotifications.useQuery(
{
...options,
integrationIds,
},
{
staleTime: 5 * 60 * 1000,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
retry: false,
},
);
const utils = clientApi.useUtils();
clientApi.widget.notifications.subscribeNotifications.useSubscription(
{
...options,
integrationIds,
},
{
onData: (data) => {
utils.widget.notifications.getNotifications.setData({ ...options, integrationIds }, (prevData) => {
return prevData?.map((item) => {
if (item.integration.id !== data.integration.id) return item;
return {
data: data.data,
integration: {
...data.integration,
updatedAt: new Date(),
},
};
});
});
},
},
);
const { data: notificationIntegrations = [] } = clientApi.widget.notifications.getNotifications.useQuery({
...options,
integrationIds,
});
const t = useScopedI18n("widget.notifications");
@@ -67,18 +34,39 @@ export default function NotificationsWidget({ options, integrationIds }: WidgetC
<Stack w={"100%"} gap="sm">
{sortedNotifications.length > 0 ? (
sortedNotifications.map((notification) => (
<Card key={notification.id} radius={board.itemRadius} w="100%" p="sm">
<Flex gap="sm" direction="column" w="100%">
{notification.title && (
<Text fz="sm" lh="sm" lineClamp={2}>
{notification.title}
</Text>
<Card
key={notification.id}
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="sm"
style={{ color: "inherit", textDecoration: "none" }}
>
<Flex gap="sm" align="flex-start" w="100%">
{!options.hideLogos && notification.source?.iconUrl && (
<Avatar
src={notification.source.iconUrl}
alt={notification.source.name}
size="sm"
radius={board.itemRadius}
/>
)}
<Text c="dimmed" size="sm" lineClamp={4} style={{ whiteSpace: "pre-line" }}>
{notification.body}
</Text>
<InfoDisplay date={notification.time} />
<Flex gap="sm" direction="column" w="100%">
{notification.title && (
<Text fz="sm" lh="sm" lineClamp={2}>
{notification.title}
</Text>
)}
<Text c="dimmed" size="sm" lineClamp={4} style={{ whiteSpace: "pre-line" }}>
{notification.body}
</Text>
<InfoDisplay date={notification.time} />
</Flex>
</Flex>
</Card>
))
+3 -1
View File
@@ -8,7 +8,9 @@ import { optionsBuilder } from "../options";
export const { componentLoader, definition } = createWidgetDefinition("notifications", {
icon: IconMessage,
createOptions() {
return optionsBuilder.from(() => ({}));
return optionsBuilder.from((factory) => ({
hideLogos: factory.switch({ defaultValue: false }),
}));
},
supportedIntegrations: getIntegrationKindsByCategory("notifications"),
}).withDynamicImport(() => import("./component"));
+32 -2
View File
@@ -2,7 +2,7 @@ import type React from "react";
import type { DraggableAttributes, UniqueIdentifier } from "@dnd-kit/core";
import type { ActionIconProps } from "@mantine/core";
import { z } from "zod/v4";
import type { ZodType } from "zod/v4";
import type { RefinementCtx, ZodType } from "zod/v4";
import type { IntegrationKind } from "@homarr/definitions";
@@ -13,6 +13,7 @@ import type { ReleasesRepository } from "./releases/releases-repository";
interface CommonInput<TType> {
defaultValue?: TType;
withDescription?: boolean;
skipContextMenu?: boolean;
}
interface TextInput extends CommonInput<string> {
@@ -54,6 +55,7 @@ interface DynamicSelectInput extends CommonInput<DynamicSelectOption | null> {
useOptions: (
query: string,
integrationIds: string[],
options: Record<string, unknown>,
) => {
isPending: boolean;
options: DynamicSelectOption[];
@@ -70,6 +72,15 @@ interface IntegrationSelectInput extends CommonInput<string> {
};
}
interface IntegrationMultiSelectInput extends CommonInput<string[]> {
withDescription?: boolean;
useOptions: (integrationIds: string[]) => {
data: { value: string; label: string }[];
isPending: boolean;
isError: boolean;
};
}
interface NumberInput extends CommonInput<number> {
validate: z.ZodNumber;
step?: number;
@@ -91,6 +102,7 @@ const optionsFactory = {
type: "switch" as const,
defaultValue: input?.defaultValue ?? false,
withDescription: input?.withDescription ?? false,
skipContextMenu: input?.skipContextMenu ?? false,
}),
text: (input?: TextInput) => ({
type: "text" as const,
@@ -193,6 +205,12 @@ const optionsFactory = {
searchable: input.searchable ?? true,
useOptions: input.useOptions,
}),
integrationMultiSelect: (input: IntegrationMultiSelectInput) => ({
type: "integrationMultiSelect" as const,
defaultValue: input.defaultValue ?? [],
withDescription: input.withDescription ?? false,
useOptions: input.useOptions,
}),
customWidgetSelect: (input?: CommonInput<string>) => ({
type: "customWidgetSelect" as const,
defaultValue: input?.defaultValue ?? "",
@@ -239,9 +257,12 @@ type ConfigurationInput<TOptions extends WidgetOptionsRecord> = Partial<
Record<keyof TOptions, FieldConfiguration<TOptions>>
>;
export const OPTIONS_SUPER_REFINE = Symbol("optionsSuperRefine");
const createOptions = <TOptions extends WidgetOptionsRecord>(
optionsCallback: (factory: WidgetOptionFactory) => TOptions,
configuration?: ConfigurationInput<TOptions>,
optionsSuperRefine?: (data: inferOptionsFromDefinition<TOptions>, ctx: RefinementCtx) => void,
) => {
const obj = {} as Record<keyof TOptions, unknown>;
const options = optionsCallback(optionsFactory);
@@ -253,9 +274,18 @@ const createOptions = <TOptions extends WidgetOptionsRecord>(
};
}
return obj as {
const result = obj as {
[key in keyof TOptions]: TOptions[key] & FieldConfiguration<TOptions>;
};
if (optionsSuperRefine) {
Object.defineProperty(result, OPTIONS_SUPER_REFINE, {
value: optionsSuperRefine,
enumerable: false,
});
}
return result;
};
type OptionsBuilder = typeof createOptions;
@@ -72,10 +72,9 @@ const heroPartVisibility = {
export default function PaperlessNgxWidget({ integrationIds, options, width }: WidgetComponentProps<"paperlessNgx">) {
const t = useScopedI18n("widget.paperlessNgx");
const { data: stats } = clientApi.widget.paperlessNgx.getStats.useQuery(
{ integrationId: integrationIds[0] ?? "" },
{ staleTime: 5 * 60 * 1000 },
);
const { data: stats } = clientApi.widget.paperlessNgx.getStats.useQuery({
integrationId: integrationIds[0] ?? "",
});
if (!stats) return <WidgetEmptyState />;
@@ -0,0 +1,371 @@
.root {
container-type: size;
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
padding: clamp(6px, 3cqi, 14px);
gap: clamp(4px, 2cqi, 10px);
overflow: hidden;
}
.compactRoot {
padding: clamp(4px, 3cqi, 8px);
gap: 2px;
}
.content {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
gap: clamp(4px, 2cqi, 8px);
}
.hero {
display: flex;
align-items: center;
justify-content: space-between;
gap: clamp(8px, 3cqi, 18px);
flex-shrink: 0;
padding-bottom: clamp(4px, 2cqi, 10px);
border-bottom: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4));
}
.heroExpanded {
flex: 1;
border-bottom: none;
justify-content: center;
padding-bottom: 0;
}
.heroTextOnly {
justify-content: center;
}
.heroRingOnly {
justify-content: center;
padding-bottom: 0;
border-bottom: none;
}
.heroWithText {
justify-content: center;
width: 100%;
}
.heroWithText .heroText {
flex: 0 1 auto;
}
.heroExpanded.heroRingOnly {
flex: 1;
}
.heroTextOnly .heroText {
align-items: center;
text-align: center;
}
.heroTextOnly .heroValue {
font-size: clamp(22px, 10cqi, 36px);
}
.heroText {
display: flex;
flex-direction: column;
gap: clamp(2px, 1cqi, 4px);
min-width: 0;
flex: 1;
}
.heroLabel {
font-size: clamp(9px, 2.8cqi, 13px);
color: var(--mantine-color-dimmed);
line-height: 1.2;
}
.heroValue {
font-size: clamp(18px, 8cqi, 32px);
font-weight: 700;
line-height: 1.1;
}
.ring {
flex-shrink: 0;
}
.compactPrimary {
display: flex;
flex: 1;
align-items: center;
justify-content: center;
min-height: 0;
min-width: 0;
}
.statsSection {
flex: 1;
min-height: 0;
}
.statsSectionCompact {
flex: 0 1 auto;
}
.grid {
display: grid;
height: 100%;
min-height: 0;
gap: clamp(4px, 2cqi, 10px);
grid-template-columns: repeat(var(--stat-cols, 2), minmax(0, 1fr));
grid-auto-rows: 1fr;
align-content: stretch;
}
.statTile {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 0;
min-width: 0;
gap: clamp(2px, 1.5cqmin, 5px);
padding: clamp(4px, 2.5cqi, 10px);
border-radius: var(--mantine-radius-md);
background-color: var(--stat-bg, light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6)));
overflow: hidden;
}
.statIcon {
flex-shrink: 0;
}
.statLabel {
font-size: clamp(8px, 2.5cqmin, 12px);
color: var(--mantine-color-dimmed);
line-height: 1.2;
text-align: center;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.statValue {
font-size: clamp(14px, 5.5cqmin, 24px);
font-weight: 700;
line-height: 1.1;
text-align: center;
}
.compactStatGrid {
display: grid;
flex: 1;
grid-template-columns: repeat(var(--compact-stat-cols, 1), minmax(0, 1fr));
grid-auto-rows: minmax(0, 1fr);
gap: clamp(3px, 2cqi, 6px);
min-width: 0;
min-height: 0;
width: 100%;
}
.compactStatTile {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-width: 0;
min-height: 0;
padding: clamp(4px, 3cqi, 8px);
border-radius: var(--mantine-radius-md);
background-color: var(--stat-bg, light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6)));
overflow: hidden;
}
.compactStatValue {
font-size: clamp(22px, 17cqmin, 42px);
font-weight: 700;
line-height: 1;
text-align: center;
}
.compactStatLabel {
max-width: 100%;
overflow: hidden;
color: var(--mantine-color-dimmed);
font-size: clamp(8px, 2.4cqmin, 11px);
line-height: 1.1;
text-align: center;
text-overflow: ellipsis;
white-space: nowrap;
}
.emptyState {
display: flex;
flex: 1;
align-items: center;
justify-content: center;
min-height: 0;
color: var(--mantine-color-dimmed);
font-size: clamp(11px, 3cqi, 14px);
text-align: center;
}
.osSectionWrapper {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.osSectionWithStats {
padding-top: clamp(4px, 2cqi, 8px);
border-top: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4));
}
.osSection {
min-height: 0;
}
.osHeading {
flex-shrink: 0;
}
.osScroll {
flex: 1;
min-height: 0;
}
.osRow {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.osLabel {
font-size: clamp(9px, 2.5cqmin, 12px);
line-height: 1.2;
}
.osBarGroup {
width: 100%;
min-width: 0;
}
.osBar {
flex: 1;
min-width: 0;
}
.osCount {
flex: 0 0 auto;
min-width: 1.5rem;
text-align: right;
font-size: clamp(9px, 2.5cqmin, 12px);
}
.compactOsBars {
display: flex;
flex: 1;
flex-direction: column;
justify-content: center;
gap: clamp(3px, 2cqmin, 5px);
min-width: 0;
min-height: 0;
width: 100%;
}
.compactOsBar {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.compactOsLabel {
max-width: 100%;
overflow: hidden;
font-size: clamp(9px, 2.5cqmin, 12px);
line-height: 1.1;
text-overflow: ellipsis;
white-space: nowrap;
}
.osDonutContent {
display: flex;
align-items: center;
justify-content: center;
flex: 1;
min-height: 0;
max-width: 100%;
overflow: visible;
}
.osDonutContentWithLegend {
justify-content: space-evenly;
gap: clamp(8px, 3cqi, 16px);
}
.osDonut {
display: flex;
align-items: center;
justify-content: center;
flex: 0 1 auto;
min-width: 0;
max-width: 100%;
overflow: visible;
}
.osLegend {
display: flex;
flex: 1 1 0;
flex-direction: column;
gap: 4px;
min-width: 0;
max-width: 50%;
}
.osLegendItem {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
font-size: clamp(9px, 2.2cqmin, 12px);
line-height: 1.2;
}
.osLegendSwatch {
width: 8px;
height: 8px;
flex: 0 0 auto;
border-radius: var(--mantine-radius-xl);
background-color: var(--os-color);
}
.osLegendLabel {
min-width: 0;
flex: 1 1 auto;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.osLegendValue {
flex: 0 0 auto;
}
.footer {
flex-shrink: 0;
max-width: 100%;
overflow: hidden;
font-size: clamp(9px, 2.2cqmin, 12px);
line-height: 1.2;
text-overflow: ellipsis;
white-space: nowrap;
}
.compactFooter {
font-size: clamp(8px, 2.2cqmin, 10px);
}
+458
View File
@@ -0,0 +1,458 @@
"use client";
import type { CSSProperties } from "react";
import { RingProgress, ScrollArea, Text, Tooltip } from "@mantine/core";
import {
IconCircleCheck,
IconClockCheck,
IconDatabase,
IconPackages,
IconRefreshAlert,
IconServer,
IconShieldExclamation,
IconShieldX,
} from "@tabler/icons-react";
import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
import { clientApi } from "@homarr/api/client";
import { useScopedI18n } from "@homarr/translation/client";
import type { TablerIcon } from "@homarr/ui";
import { WidgetEmptyState } from "../common/empty-state";
import type { WidgetComponentProps } from "../definition";
import classes from "./component.module.css";
import { getGridCols, shouldShowComplianceHeroText } from "./layout-utils";
import { OsDistributionSection } from "./os-distribution-section";
import type { PatchMonStatKey } from "./stat-colors";
import { resolveStatColor, severityToIconColor, severityToMantineBgColor, severityToMantineColor } from "./stat-colors";
dayjs.extend(relativeTime);
const statVisibilityByOption = {
showTotalHosts: "totalHosts",
showHostsNeedingUpdates: "hostsNeedingUpdates",
showSecurityUpdates: "securityUpdates",
showUpToDateHosts: "upToDateHosts",
showHostsWithSecurityUpdates: "hostsWithSecurityUpdates",
showRecentUpdates24h: "recentUpdates24h",
showTotalOutdatedPackages: "totalOutdatedPackages",
showTotalRepos: "totalRepos",
} as const;
const statIcons: Record<PatchMonStatKey, TablerIcon> = {
totalHosts: IconServer,
hostsNeedingUpdates: IconRefreshAlert,
securityUpdates: IconShieldExclamation,
upToDateHosts: IconCircleCheck,
hostsWithSecurityUpdates: IconShieldX,
recentUpdates24h: IconClockCheck,
totalOutdatedPackages: IconPackages,
totalRepos: IconDatabase,
};
const iconSizeByWidth = [
{ minWidth: 320, size: 22 },
{ minWidth: 200, size: 18 },
{ minWidth: 0, size: 16 },
] as const;
const ringSizeByWidth = [
{ minWidth: 400, size: 88 },
{ minWidth: 320, size: 72 },
{ minWidth: 220, size: 56 },
{ minWidth: 0, size: 40 },
] as const;
type LayoutMode = "mini" | "compact" | "comfortable" | "spacious";
type CompactPrimaryContent = "hero" | "os" | "stats" | null;
const getLayoutMode = (width: number, height: number): LayoutMode => {
const minDim = Math.min(width, height);
if (minDim < 140) return "mini";
if (minDim < 220) return "compact";
if (width >= 380 && height >= 350) return "spacious";
return "comfortable";
};
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 });
if (!stats) return <WidgetEmptyState />;
const statValues: Record<PatchMonStatKey, number> = {
totalHosts: stats.totalHosts,
hostsNeedingUpdates: stats.hostsNeedingUpdates,
securityUpdates: stats.securityUpdates,
upToDateHosts: stats.upToDateHosts,
hostsWithSecurityUpdates: stats.hostsWithSecurityUpdates,
recentUpdates24h: stats.recentUpdates24h,
totalOutdatedPackages: stats.totalOutdatedPackages,
totalRepos: stats.totalRepos,
};
const visibleStatKeys = Object.entries(statVisibilityByOption)
.filter(([optionKey]) => options[optionKey as keyof typeof options])
.map(([, statKey]) => statKey);
const colorContext = { totalHosts: stats.totalHosts };
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 = 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 && (!isLandscape || isCompactSurface) && width >= 90 && height >= 90;
const showHeroText = showHero && shouldShowComplianceHeroText(width);
const showHeroRingOnly = showHero && !showHeroText;
const compactPrimaryContent = isCompactSurface
? getCompactPrimaryContent({
showHero,
showOsDistribution: showOsData,
visibleStatKeys,
})
: null;
const showStats = visibleStatKeys.length > 0 && !isCompactSurface;
const showOsSection = showOsData && !isCompactSurface && height > 200;
const showCompactFooter = isCompactSurface && height >= 110;
const showFooter = isCompactSurface ? showCompactFooter : !isLandscape && height > 220;
const showOsLegend = osDisplayMode === "donut" && width >= 380 && height >= 300;
const hasContent = compactPrimaryContent !== null || showStats || showOsSection || showHero;
const gridCols = isLandscape
? Math.min(visibleStatKeys.length, 4)
: Math.min(getGridCols(width), visibleStatKeys.length || 1);
const iconSize = getIconSize(width);
const ringSize = getRingSize(width, height, showHeroRingOnly);
const compliancePercent = stats.totalHosts > 0 ? Math.round((stats.upToDateHosts / stats.totalHosts) * 100) : 100;
const complianceSeverity = resolveStatColor("upToDateHosts", stats.upToDateHosts, colorContext, options);
const complianceColor = complianceSeverity === "neutral" ? "blue" : complianceSeverity;
const timeAgo = dayjs(stats.lastUpdated).fromNow();
const compactTimeAgo = getCompactTimeAgo(stats.lastUpdated);
const footerText = isNarrow ? compactTimeAgo : t("lastUpdated", { time: timeAgo });
if (isCompactSurface) {
const compactContentHeight = showCompactFooter ? height - 18 : height;
const compactStatCols = Math.max(1, Math.floor(width / 120));
const compactStatKeys = visibleStatKeys.slice(0, Math.min(visibleStatKeys.length, compactStatCols * 2));
const compactContent =
compactPrimaryContent === "hero" ? (
<ComplianceHero
compliancePercent={compliancePercent}
complianceColor={complianceColor}
upToDateHosts={stats.upToDateHosts}
totalHosts={stats.totalHosts}
ringSize={getRingSize(width, compactContentHeight, showHeroRingOnly)}
showText={showHeroText}
expanded
compact
getLabel={t}
/>
) : compactPrimaryContent === "os" ? (
<div className={classes.compactPrimary}>
<OsDistributionSection
entries={stats.osDistribution}
limit={Number(options.osDistributionLimit)}
showOsVersion={options.showOsVersion}
displayMode={osDisplayMode}
width={width}
height={compactContentHeight}
showLegend={false}
compact
/>
</div>
) : compactPrimaryContent === "stats" ? (
<CompactStatGrid
statKeys={compactStatKeys}
statValues={statValues}
colorContext={colorContext}
options={options}
columns={compactStatCols}
showIcon={width >= 180 || height >= 140}
getLabel={t}
/>
) : null;
if (!compactContent) {
return (
<div className={`${classes.root} ${classes.compactRoot}`}>
<div className={classes.emptyState}>
<Text size="sm" c="dimmed">
—
</Text>
</div>
</div>
);
}
return (
<div className={`${classes.root} ${classes.compactRoot}`}>
{compactContent}
{showCompactFooter && (
<Text size="xs" c="dimmed" ta="center" className={`${classes.footer} ${classes.compactFooter}`}>
{footerText}
</Text>
)}
</div>
);
}
if (!hasContent) {
return (
<div className={classes.root}>
<div className={classes.emptyState}>
<Text size="sm" c="dimmed">
—
</Text>
</div>
</div>
);
}
const statGridContent = showStats && (
<div className={`${classes.statsSection} ${showOsSection ? classes.statsSectionCompact : ""}`}>
<div className={classes.grid} style={{ "--stat-cols": gridCols } as CSSProperties}>
{visibleStatKeys.map((statKey) => {
const severity = resolveStatColor(statKey, statValues[statKey], colorContext, options);
const Icon = statIcons[statKey];
const tile = (
<div
key={statKey}
className={classes.statTile}
style={{ "--stat-bg": severityToMantineBgColor(severity) } as CSSProperties}
>
{showIcons && (
<Icon className={classes.statIcon} size={iconSize} stroke={1.5} color={severityToIconColor(severity)} />
)}
<span className={classes.statValue}>
<Text component="span" fw={700} size="inherit" c={severityToMantineColor(severity)}>
{statValues[statKey]}
</Text>
</span>
{showLabels && <span className={classes.statLabel}>{t(statKey)}</span>}
</div>
);
if (!showLabels) {
return (
<Tooltip key={statKey} label={t(statKey)} withArrow>
{tile}
</Tooltip>
);
}
return tile;
})}
</div>
</div>
);
const osContent = showOsSection && (
<div className={`${classes.osSectionWrapper} ${visibleStatKeys.length > 0 ? classes.osSectionWithStats : ""}`}>
<OsDistributionSection
entries={stats.osDistribution}
limit={Number(options.osDistributionLimit)}
showOsVersion={options.showOsVersion}
displayMode={osDisplayMode}
width={width}
height={height}
showLegend={showOsLegend}
/>
</div>
);
const needsScroll = layout === "mini" || layout === "compact";
return (
<div className={classes.root}>
{showHero && (
<ComplianceHero
compliancePercent={compliancePercent}
complianceColor={complianceColor}
upToDateHosts={stats.upToDateHosts}
totalHosts={stats.totalHosts}
ringSize={ringSize}
showText={showHeroText}
expanded={!showStats && !showOsSection}
getLabel={t}
/>
)}
{(statGridContent || osContent) &&
(needsScroll ? (
<ScrollArea style={{ flex: 1, minHeight: 0 }} scrollbars="y">
<div className={classes.content}>
{statGridContent}
{osContent}
</div>
</ScrollArea>
) : (
<div className={classes.content}>
{statGridContent}
{osContent}
</div>
))}
{showFooter && (
<Text size="xs" c="dimmed" ta={isNarrow ? "center" : "right"} className={classes.footer}>
{footerText}
</Text>
)}
</div>
);
}
function ComplianceHero({
compliancePercent,
complianceColor,
upToDateHosts,
totalHosts,
ringSize,
showText,
expanded = false,
compact = false,
getLabel,
}: {
compliancePercent: number;
complianceColor: string;
upToDateHosts: number;
totalHosts: number;
ringSize: number;
showText: boolean;
expanded?: boolean;
compact?: boolean;
getLabel: (key: "compliance" | "complianceRatio", params?: { upToDate: number; total: number }) => string;
}) {
const heroClasses = [
compact && classes.compactPrimary,
classes.hero,
expanded && classes.heroExpanded,
showText ? classes.heroWithText : classes.heroRingOnly,
]
.filter(Boolean)
.join(" ");
return (
<div className={heroClasses}>
{showText && (
<div className={classes.heroText}>
<span className={classes.heroLabel}>{getLabel("compliance")}</span>
<span className={classes.heroValue}>
{getLabel("complianceRatio", { upToDate: upToDateHosts, total: totalHosts })}
</span>
</div>
)}
<RingProgress
className={classes.ring}
size={ringSize}
thickness={Math.max(4, Math.round(ringSize / 10))}
roundCaps
sections={[{ value: compliancePercent, color: complianceColor }]}
label={
<Text ta="center" size="xs" fw={700}>
{compliancePercent}%
</Text>
}
/>
</div>
);
}
function getIconSize(width: number): number {
const match = iconSizeByWidth.find(({ minWidth }) => width >= minWidth);
return match?.size ?? 16;
}
function getRingSize(width: number, height: number, compact: boolean): number {
if (compact) {
return Math.max(36, Math.min(64, width - 28, height - 28));
}
const match = ringSizeByWidth.find(({ minWidth }) => width >= minWidth);
return match?.size ?? 44;
}
function getCompactPrimaryContent({
showHero,
showOsDistribution,
visibleStatKeys,
}: {
showHero: boolean;
showOsDistribution: boolean;
visibleStatKeys: PatchMonStatKey[];
}): CompactPrimaryContent {
if (showHero) return "hero";
if (showOsDistribution) return "os";
return visibleStatKeys.length > 0 ? "stats" : null;
}
function getCompactTimeAgo(lastUpdated: string): string {
const minutes = Math.max(0, dayjs().diff(dayjs(lastUpdated), "minute"));
if (minutes < 1) return "now";
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
return `${days}d ago`;
}
function CompactStatGrid({
statKeys,
statValues,
colorContext,
options,
columns,
showIcon,
getLabel,
}: {
statKeys: PatchMonStatKey[];
statValues: Record<PatchMonStatKey, number>;
colorContext: { totalHosts: number };
options: WidgetComponentProps<"patchmon">["options"];
columns: number;
showIcon: boolean;
getLabel: (key: PatchMonStatKey) => string;
}) {
return (
<div className={classes.compactStatGrid} style={{ "--compact-stat-cols": columns } as CSSProperties}>
{statKeys.map((statKey) => {
const severity = resolveStatColor(statKey, statValues[statKey], colorContext, options);
const Icon = statIcons[statKey];
const label = getLabel(statKey);
return (
<Tooltip key={statKey} label={label} withArrow>
<div
className={classes.compactStatTile}
style={{ "--stat-bg": severityToMantineBgColor(severity) } as CSSProperties}
>
{showIcon && (
<Icon className={classes.statIcon} size={18} stroke={1.5} color={severityToIconColor(severity)} />
)}
<span className={classes.compactStatValue}>
<Text component="span" fw={700} size="inherit" c={severityToMantineColor(severity)}>
{statValues[statKey]}
</Text>
</span>
<span className={classes.compactStatLabel}>{label}</span>
</div>
</Tooltip>
);
})}
</div>
);
}
+234
View File
@@ -0,0 +1,234 @@
import { IconServerOff, IconShieldCheck } from "@tabler/icons-react";
import { z } from "zod/v4";
import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options";
import { PATCHMON_STAT_COLOR_PRESETS } from "./stat-colors";
import { patchmonOptionsSuperRefine } from "./threshold-validation";
export const { definition, componentLoader } = createWidgetDefinition("patchmon", {
icon: IconShieldCheck,
supportedIntegrations: ["patchmon"],
integrationsRequired: true,
createOptions() {
return optionsBuilder.from(
(factory) => ({
showComplianceHero: factory.switch({
defaultValue: true,
withDescription: true,
}),
showTotalHosts: factory.switch({
defaultValue: true,
withDescription: true,
}),
showHostsNeedingUpdates: factory.switch({
defaultValue: true,
withDescription: true,
}),
showSecurityUpdates: factory.switch({
defaultValue: true,
withDescription: true,
}),
showUpToDateHosts: factory.switch({
defaultValue: false,
withDescription: true,
}),
showHostsWithSecurityUpdates: factory.switch({
defaultValue: false,
withDescription: true,
}),
showRecentUpdates24h: factory.switch({
defaultValue: false,
withDescription: true,
}),
showTotalOutdatedPackages: factory.switch({
defaultValue: false,
withDescription: true,
}),
showTotalRepos: factory.switch({
defaultValue: false,
withDescription: true,
}),
enableThresholdColors: factory.switch({
defaultValue: true,
withDescription: true,
}),
useCustomThresholds: factory.switch({
defaultValue: false,
withDescription: true,
}),
hostsNeedingUpdatesThresholdMode: factory.select({
defaultValue: PATCHMON_STAT_COLOR_PRESETS.hostsNeedingUpdates.mode,
withDescription: true,
options: [
{ value: "absolute", label: (t) => t("widget.patchmon.option.thresholdMode.option.absolute") },
{ value: "percent", label: (t) => t("widget.patchmon.option.thresholdMode.option.percent") },
] as const,
}),
hostsNeedingUpdatesWarningAt: factory.number({
defaultValue: PATCHMON_STAT_COLOR_PRESETS.hostsNeedingUpdates.warningAt,
withDescription: true,
step: 1,
validate: z.number().int().min(0),
}),
hostsNeedingUpdatesCriticalAt: factory.number({
defaultValue: PATCHMON_STAT_COLOR_PRESETS.hostsNeedingUpdates.criticalAt,
withDescription: true,
step: 1,
validate: z.number().int().min(0),
}),
securityUpdatesThresholdMode: factory.select({
defaultValue: PATCHMON_STAT_COLOR_PRESETS.securityUpdates.mode,
withDescription: true,
options: [
{ value: "absolute", label: (t) => t("widget.patchmon.option.thresholdMode.option.absolute") },
{ value: "percent", label: (t) => t("widget.patchmon.option.thresholdMode.option.percent") },
] as const,
}),
securityUpdatesWarningAt: factory.number({
defaultValue: PATCHMON_STAT_COLOR_PRESETS.securityUpdates.warningAt,
withDescription: true,
step: 1,
validate: z.number().int().min(0),
}),
securityUpdatesCriticalAt: factory.number({
defaultValue: PATCHMON_STAT_COLOR_PRESETS.securityUpdates.criticalAt,
withDescription: true,
step: 1,
validate: z.number().int().min(0),
}),
hostsWithSecurityUpdatesThresholdMode: factory.select({
defaultValue: PATCHMON_STAT_COLOR_PRESETS.hostsWithSecurityUpdates.mode,
withDescription: true,
options: [
{ value: "absolute", label: (t) => t("widget.patchmon.option.thresholdMode.option.absolute") },
{ value: "percent", label: (t) => t("widget.patchmon.option.thresholdMode.option.percent") },
] as const,
}),
hostsWithSecurityUpdatesWarningAt: factory.number({
defaultValue: PATCHMON_STAT_COLOR_PRESETS.hostsWithSecurityUpdates.warningAt,
withDescription: true,
step: 1,
validate: z.number().int().min(0),
}),
hostsWithSecurityUpdatesCriticalAt: factory.number({
defaultValue: PATCHMON_STAT_COLOR_PRESETS.hostsWithSecurityUpdates.criticalAt,
withDescription: true,
step: 1,
validate: z.number().int().min(0),
}),
upToDateHostsThresholdMode: factory.select({
defaultValue: PATCHMON_STAT_COLOR_PRESETS.upToDateHosts.mode,
withDescription: true,
options: [
{ value: "absolute", label: (t) => t("widget.patchmon.option.thresholdMode.option.absolute") },
{ value: "percent", label: (t) => t("widget.patchmon.option.thresholdMode.option.percent") },
] as const,
}),
upToDateHostsWarningAt: factory.number({
defaultValue: PATCHMON_STAT_COLOR_PRESETS.upToDateHosts.warningAt,
withDescription: true,
step: 1,
validate: z.number().int().min(0),
}),
upToDateHostsCriticalAt: factory.number({
defaultValue: PATCHMON_STAT_COLOR_PRESETS.upToDateHosts.criticalAt,
withDescription: true,
step: 1,
validate: z.number().int().min(0),
}),
totalOutdatedPackagesThresholdMode: factory.select({
defaultValue: PATCHMON_STAT_COLOR_PRESETS.totalOutdatedPackages.mode,
withDescription: true,
options: [
{ value: "absolute", label: (t) => t("widget.patchmon.option.thresholdMode.option.absolute") },
{ value: "percent", label: (t) => t("widget.patchmon.option.thresholdMode.option.percent") },
] as const,
}),
totalOutdatedPackagesWarningAt: factory.number({
defaultValue: PATCHMON_STAT_COLOR_PRESETS.totalOutdatedPackages.warningAt,
withDescription: true,
step: 1,
validate: z.number().int().min(0),
}),
totalOutdatedPackagesCriticalAt: factory.number({
defaultValue: PATCHMON_STAT_COLOR_PRESETS.totalOutdatedPackages.criticalAt,
withDescription: true,
step: 1,
validate: z.number().int().min(0),
}),
showOsDistribution: factory.switch({
defaultValue: false,
withDescription: true,
}),
osDisplayMode: factory.select({
defaultValue: "bars",
withDescription: true,
options: [
{ value: "bars", label: (t) => t("widget.patchmon.option.osDisplayMode.option.bars") },
{ value: "donut", label: (t) => t("widget.patchmon.option.osDisplayMode.option.donut") },
] as const,
}),
osDistributionLimit: factory.select({
defaultValue: "0",
withDescription: true,
options: [
{ value: "3", label: (t) => t("widget.patchmon.option.osDistributionLimit.option.3") },
{ value: "5", label: (t) => t("widget.patchmon.option.osDistributionLimit.option.5") },
{ value: "10", label: (t) => t("widget.patchmon.option.osDistributionLimit.option.10") },
{ value: "0", label: (t) => t("widget.patchmon.option.osDistributionLimit.option.all") },
] as const,
}),
showOsVersion: factory.switch({
defaultValue: true,
withDescription: true,
}),
}),
{
useCustomThresholds: {
shouldHide: (options) => !options.enableThresholdColors,
},
hostsNeedingUpdatesThresholdMode: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds },
hostsNeedingUpdatesWarningAt: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds },
hostsNeedingUpdatesCriticalAt: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds },
securityUpdatesThresholdMode: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds },
securityUpdatesWarningAt: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds },
securityUpdatesCriticalAt: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds },
hostsWithSecurityUpdatesThresholdMode: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds },
hostsWithSecurityUpdatesWarningAt: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds },
hostsWithSecurityUpdatesCriticalAt: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds },
upToDateHostsThresholdMode: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds },
upToDateHostsWarningAt: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds },
upToDateHostsCriticalAt: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds },
totalOutdatedPackagesThresholdMode: {
shouldHide: (options) =>
!options.enableThresholdColors || !options.useCustomThresholds || !options.showTotalOutdatedPackages,
},
totalOutdatedPackagesWarningAt: {
shouldHide: (options) =>
!options.enableThresholdColors || !options.useCustomThresholds || !options.showTotalOutdatedPackages,
},
totalOutdatedPackagesCriticalAt: {
shouldHide: (options) =>
!options.enableThresholdColors || !options.useCustomThresholds || !options.showTotalOutdatedPackages,
},
osDisplayMode: {
shouldHide: (options) => !options.showOsDistribution,
},
osDistributionLimit: {
shouldHide: (options) => !options.showOsDistribution,
},
showOsVersion: {
shouldHide: (options) => !options.showOsDistribution,
},
},
patchmonOptionsSuperRefine,
);
},
errors: {
INTERNAL_SERVER_ERROR: {
icon: IconServerOff,
message: (t) => t("widget.patchmon.error.internalServerError"),
},
},
}).withDynamicImport(() => import("./component"));
@@ -0,0 +1,15 @@
const gridColsByWidth = [
{ minWidth: 500, cols: 4 },
{ minWidth: 380, cols: 3 },
{ minWidth: 220, cols: 2 },
{ minWidth: 0, cols: 1 },
] as const;
export function getGridCols(width: number): number {
const match = gridColsByWidth.find(({ minWidth }) => width >= minWidth);
return match?.cols ?? 1;
}
export function shouldShowComplianceHeroText(width: number): boolean {
return getGridCols(width) >= 2;
}
@@ -0,0 +1,167 @@
"use client";
import type { CSSProperties } from "react";
import { DonutChart } from "@mantine/charts";
import { Group, Progress, ScrollArea, Stack, Text } from "@mantine/core";
import type { PatchMonOsDistributionEntry } from "@homarr/integrations";
import { useScopedI18n } from "@homarr/translation/client";
import classes from "./component.module.css";
const OS_COLORS = [
"blue.6",
"orange.6",
"green.6",
"grape.6",
"cyan.6",
"pink.6",
"yellow.6",
"teal.6",
] as const;
interface OsDistributionSectionProps {
entries: PatchMonOsDistributionEntry[];
limit: number;
showOsVersion: boolean;
displayMode: "bars" | "donut";
width: number;
height: number;
showLegend: boolean;
compact?: boolean;
}
export const formatOsLabel = (entry: PatchMonOsDistributionEntry, showVersion: boolean) =>
showVersion && entry.osVersion ? `${entry.name} ${entry.osVersion}` : entry.name;
const getMantineColorVariable = (color: string) => `var(--mantine-color-${color.replace(".", "-")})`;
export function OsDistributionSection({
entries,
limit,
showOsVersion,
displayMode,
width,
height,
showLegend,
compact = false,
}: OsDistributionSectionProps) {
const t = useScopedI18n("widget.patchmon");
if (entries.length === 0) {
return null;
}
const visibleEntries = limit > 0 ? entries.slice(0, limit) : entries;
if (displayMode === "donut") {
const chartData = visibleEntries.map((entry, i) => ({
name: formatOsLabel(entry, showOsVersion),
value: entry.count,
color: OS_COLORS[i % OS_COLORS.length] as string,
}));
const donutSize = compact
? Math.max(36, Math.min(width - 18, height - 10, 96))
: Math.max(48, Math.min(width - 24, height * 0.55, showLegend ? 140 : 180));
return (
<Stack gap={4} className={classes.osSection} h="100%">
{!compact && (
<Text size="xs" c="dimmed" fw={500} className={classes.osHeading}>
{t("osDistribution")}
</Text>
)}
<div className={`${classes.osDonutContent} ${showLegend ? classes.osDonutContentWithLegend : ""}`}>
<div className={classes.osDonut}>
<DonutChart
data={chartData}
size={donutSize}
withTooltip
tooltipDataSource="segment"
paddingAngle={2}
/>
</div>
{showLegend && (
<div className={classes.osLegend}>
{chartData.map((entry) => (
<div key={entry.name} className={classes.osLegendItem}>
<span
className={classes.osLegendSwatch}
style={{ "--os-color": getMantineColorVariable(entry.color) } as CSSProperties}
/>
<span className={classes.osLegendLabel} title={entry.name}>
{entry.name}
</span>
<Text component="span" size="xs" fw={600} className={classes.osLegendValue}>
{entry.value}
</Text>
</div>
))}
</div>
)}
</div>
</Stack>
);
}
const maxCount = Math.max(...visibleEntries.map((entry) => entry.count), 1);
if (compact) {
const maxRows = Math.max(2, Math.min(visibleEntries.length, Math.floor(height / 28), width < 160 ? 2 : 4));
const compactEntries = visibleEntries.slice(0, maxRows);
if (compactEntries.length === 0) return null;
return (
<div className={classes.compactOsBars}>
{compactEntries.map((entry, i) => (
<div key={`${entry.name}-${entry.osVersion ?? ""}`} className={classes.compactOsBar}>
<Text size="xs" className={classes.compactOsLabel} truncate="end" title={formatOsLabel(entry, showOsVersion)}>
{formatOsLabel(entry, showOsVersion)}
</Text>
<Group gap={6} wrap="nowrap" className={classes.osBarGroup}>
<Progress
value={(entry.count / maxCount) * 100}
size="sm"
radius="sm"
className={classes.osBar}
color={OS_COLORS[i % OS_COLORS.length]}
/>
<Text size="xs" fw={700} className={classes.osCount}>
{entry.count}
</Text>
</Group>
</div>
))}
</div>
);
}
return (
<Stack gap={4} className={classes.osSection} h="100%">
<Text size="xs" c="dimmed" fw={500} className={classes.osHeading}>
{t("osDistribution")}
</Text>
<ScrollArea className={classes.osScroll} scrollbars="y">
<Stack gap={6} pr={4}>
{visibleEntries.map((entry, i) => {
const barValue = (entry.count / maxCount) * 100;
const barColor = OS_COLORS[i % OS_COLORS.length] as string;
return (
<div key={`${entry.name}-${entry.osVersion ?? ""}`} className={classes.osRow}>
<Text size="xs" className={classes.osLabel} truncate="end" title={formatOsLabel(entry, showOsVersion)}>
{formatOsLabel(entry, showOsVersion)}
</Text>
<Group gap={6} wrap="nowrap" className={classes.osBarGroup}>
<Progress value={barValue} size="sm" radius="sm" className={classes.osBar} color={barColor} />
<Text size="xs" fw={600} className={classes.osCount}>
{entry.count}
</Text>
</Group>
</div>
);
})}
</Stack>
</ScrollArea>
</Stack>
);
}
@@ -0,0 +1,215 @@
import type { WidgetComponentProps } from "../definition";
import { clampPercentThreshold } from "./threshold-validation";
export type PatchMonStatKey =
| "totalHosts"
| "hostsNeedingUpdates"
| "securityUpdates"
| "upToDateHosts"
| "hostsWithSecurityUpdates"
| "recentUpdates24h"
| "totalOutdatedPackages"
| "totalRepos";
export type ColorablePatchMonStatKey = Extract<
PatchMonStatKey,
| "hostsNeedingUpdates"
| "securityUpdates"
| "hostsWithSecurityUpdates"
| "upToDateHosts"
| "totalOutdatedPackages"
>;
export type StatSeverity = "green" | "yellow" | "red" | "neutral";
export type ThresholdMode = "absolute" | "percent";
export interface StatColorContext {
totalHosts: number;
}
export interface StatThresholdRule {
direction: "lowerIsBetter" | "higherIsBetter";
mode: ThresholdMode;
warningAt: number;
criticalAt: number;
skipYellow?: boolean;
}
type PatchMonColorOptions = WidgetComponentProps<"patchmon">["options"];
export const COLORABLE_PATCHMON_STATS = [
"hostsNeedingUpdates",
"securityUpdates",
"hostsWithSecurityUpdates",
"upToDateHosts",
"totalOutdatedPackages",
] as const satisfies readonly ColorablePatchMonStatKey[];
export const NEUTRAL_PATCHMON_STATS = ["totalHosts", "totalRepos", "recentUpdates24h"] as const;
export const PATCHMON_STAT_COLOR_PRESETS: Record<ColorablePatchMonStatKey, StatThresholdRule> = {
hostsNeedingUpdates: {
direction: "lowerIsBetter",
mode: "absolute",
warningAt: 1,
criticalAt: 5,
},
securityUpdates: {
direction: "lowerIsBetter",
mode: "absolute",
warningAt: 1,
criticalAt: 10,
},
hostsWithSecurityUpdates: {
direction: "lowerIsBetter",
mode: "absolute",
warningAt: 1,
criticalAt: 1,
skipYellow: true,
},
upToDateHosts: {
direction: "higherIsBetter",
mode: "percent",
warningAt: 90,
criticalAt: 90,
},
totalOutdatedPackages: {
direction: "lowerIsBetter",
mode: "absolute",
warningAt: 1,
criticalAt: 50,
},
};
const thresholdModeOptionKey = {
hostsNeedingUpdates: "hostsNeedingUpdatesThresholdMode",
securityUpdates: "securityUpdatesThresholdMode",
hostsWithSecurityUpdates: "hostsWithSecurityUpdatesThresholdMode",
upToDateHosts: "upToDateHostsThresholdMode",
totalOutdatedPackages: "totalOutdatedPackagesThresholdMode",
} as const;
const warningAtOptionKey = {
hostsNeedingUpdates: "hostsNeedingUpdatesWarningAt",
securityUpdates: "securityUpdatesWarningAt",
hostsWithSecurityUpdates: "hostsWithSecurityUpdatesWarningAt",
upToDateHosts: "upToDateHostsWarningAt",
totalOutdatedPackages: "totalOutdatedPackagesWarningAt",
} as const;
const criticalAtOptionKey = {
hostsNeedingUpdates: "hostsNeedingUpdatesCriticalAt",
securityUpdates: "securityUpdatesCriticalAt",
hostsWithSecurityUpdates: "hostsWithSecurityUpdatesCriticalAt",
upToDateHosts: "upToDateHostsCriticalAt",
totalOutdatedPackages: "totalOutdatedPackagesCriticalAt",
} as const;
const isColorableStat = (statKey: PatchMonStatKey): statKey is ColorablePatchMonStatKey =>
COLORABLE_PATCHMON_STATS.includes(statKey as ColorablePatchMonStatKey);
const getComparableValue = (
value: number,
mode: ThresholdMode,
context: StatColorContext,
direction: StatThresholdRule["direction"],
): number => {
if (mode === "absolute") {
return value;
}
if (context.totalHosts <= 0) {
if (direction === "higherIsBetter") {
return 100;
}
return value > 0 ? 100 : 0;
}
return (value / context.totalHosts) * 100;
};
const resolveLowerIsBetter = (comparable: number, rule: StatThresholdRule): StatSeverity => {
if (comparable < rule.warningAt) {
return "green";
}
if (rule.skipYellow || comparable >= rule.criticalAt) {
return "red";
}
return "yellow";
};
const resolveHigherIsBetter = (
value: number,
comparable: number,
context: StatColorContext,
rule: StatThresholdRule,
): StatSeverity => {
if (context.totalHosts > 0 && value >= context.totalHosts) {
return "green";
}
if (comparable >= rule.warningAt) {
return "green";
}
if (rule.skipYellow || comparable < rule.criticalAt) {
return "red";
}
return "yellow";
};
const getThresholdRule = (statKey: ColorablePatchMonStatKey, options: PatchMonColorOptions): StatThresholdRule => {
if (!options.useCustomThresholds) {
return PATCHMON_STAT_COLOR_PRESETS[statKey];
}
const mode = options[thresholdModeOptionKey[statKey]] as ThresholdMode;
const warningAt = clampPercentThreshold(options[warningAtOptionKey[statKey]] as number, mode);
const criticalAt = clampPercentThreshold(options[criticalAtOptionKey[statKey]] as number, mode);
const preset = PATCHMON_STAT_COLOR_PRESETS[statKey];
return {
direction: preset.direction,
mode,
warningAt,
criticalAt,
skipYellow: preset.skipYellow && warningAt === criticalAt,
};
};
export const resolveStatColor = (
statKey: PatchMonStatKey,
value: number,
context: StatColorContext,
options: PatchMonColorOptions,
): StatSeverity => {
if (!options.enableThresholdColors || !isColorableStat(statKey)) {
return "neutral";
}
const rule = getThresholdRule(statKey, options);
const comparable = getComparableValue(value, rule.mode, context, rule.direction);
if (rule.direction === "higherIsBetter") {
return resolveHigherIsBetter(value, comparable, context, rule);
}
return resolveLowerIsBetter(comparable, rule);
};
export const severityToMantineColor = (severity: StatSeverity): string =>
severity === "neutral" ? "var(--mantine-primary-color-filled)" : severity;
export const severityToMantineBgColor = (severity: StatSeverity): string => {
if (severity === "neutral") return "light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6))";
return `var(--mantine-color-${severity}-light)`;
};
export const severityToIconColor = (severity: StatSeverity): string => {
if (severity === "neutral") return "var(--mantine-color-dimmed)";
return `var(--mantine-color-${severity}-6)`;
};
@@ -0,0 +1,36 @@
import { describe, expect, test } from "vitest";
import { getGridCols, shouldShowComplianceHeroText } from "../layout-utils";
describe("getGridCols", () => {
test("returns 1 column for narrow widths", () => {
expect(getGridCols(128)).toBe(1);
expect(getGridCols(219)).toBe(1);
});
test("returns 2 columns at 220px breakpoint", () => {
expect(getGridCols(220)).toBe(2);
expect(getGridCols(256)).toBe(2);
});
test("returns 3 columns at 380px breakpoint", () => {
expect(getGridCols(380)).toBe(3);
});
test("returns 4 columns at 500px breakpoint", () => {
expect(getGridCols(500)).toBe(4);
});
});
describe("shouldShowComplianceHeroText", () => {
test("returns false for 1-column-wide layouts", () => {
expect(shouldShowComplianceHeroText(128)).toBe(false);
expect(shouldShowComplianceHeroText(219)).toBe(false);
});
test("returns true for 2+-column-wide layouts", () => {
expect(shouldShowComplianceHeroText(220)).toBe(true);
expect(shouldShowComplianceHeroText(256)).toBe(true);
expect(shouldShowComplianceHeroText(384)).toBe(true);
});
});
@@ -0,0 +1,171 @@
import { describe, expect, test } from "vitest";
import type { WidgetComponentProps } from "../../definition";
import { resolveStatColor } from "../stat-colors";
const defaultOptions = {
enableThresholdColors: true,
useCustomThresholds: false,
showTotalHosts: true,
showHostsNeedingUpdates: true,
showSecurityUpdates: true,
showUpToDateHosts: false,
showHostsWithSecurityUpdates: false,
showRecentUpdates24h: false,
showTotalOutdatedPackages: false,
showTotalRepos: false,
showOsDistribution: false,
osDistributionLimit: "0",
showOsVersion: true,
hostsNeedingUpdatesThresholdMode: "absolute",
hostsNeedingUpdatesWarningAt: 1,
hostsNeedingUpdatesCriticalAt: 5,
securityUpdatesThresholdMode: "absolute",
securityUpdatesWarningAt: 1,
securityUpdatesCriticalAt: 10,
hostsWithSecurityUpdatesThresholdMode: "absolute",
hostsWithSecurityUpdatesWarningAt: 1,
hostsWithSecurityUpdatesCriticalAt: 1,
upToDateHostsThresholdMode: "percent",
upToDateHostsWarningAt: 90,
upToDateHostsCriticalAt: 90,
totalOutdatedPackagesThresholdMode: "absolute",
totalOutdatedPackagesWarningAt: 1,
totalOutdatedPackagesCriticalAt: 50,
showComplianceHero: true,
osDisplayMode: "bars",
} satisfies WidgetComponentProps<"patchmon">["options"];
const context = { totalHosts: 100 };
describe("resolveStatColor presets", () => {
test("hostsNeedingUpdates uses green, yellow, and red boundaries", () => {
expect(resolveStatColor("hostsNeedingUpdates", 0, context, defaultOptions)).toBe("green");
expect(resolveStatColor("hostsNeedingUpdates", 1, context, defaultOptions)).toBe("yellow");
expect(resolveStatColor("hostsNeedingUpdates", 4, context, defaultOptions)).toBe("yellow");
expect(resolveStatColor("hostsNeedingUpdates", 5, context, defaultOptions)).toBe("red");
});
test("securityUpdates uses green and yellow for low counts", () => {
expect(resolveStatColor("securityUpdates", 0, context, defaultOptions)).toBe("green");
expect(resolveStatColor("securityUpdates", 1, context, defaultOptions)).toBe("yellow");
expect(resolveStatColor("securityUpdates", 10, context, defaultOptions)).toBe("red");
});
test("hostsWithSecurityUpdates skips yellow", () => {
expect(resolveStatColor("hostsWithSecurityUpdates", 0, context, defaultOptions)).toBe("green");
expect(resolveStatColor("hostsWithSecurityUpdates", 1, context, defaultOptions)).toBe("red");
});
test("upToDateHosts uses percent of total hosts", () => {
expect(resolveStatColor("upToDateHosts", 100, context, defaultOptions)).toBe("green");
expect(resolveStatColor("upToDateHosts", 95, context, defaultOptions)).toBe("green");
expect(resolveStatColor("upToDateHosts", 90, context, defaultOptions)).toBe("green");
expect(resolveStatColor("upToDateHosts", 80, context, defaultOptions)).toBe("red");
});
test("neutral stats ignore threshold coloring", () => {
expect(resolveStatColor("totalHosts", 100, context, defaultOptions)).toBe("neutral");
expect(resolveStatColor("totalRepos", 12, context, defaultOptions)).toBe("neutral");
expect(resolveStatColor("recentUpdates24h", 34, context, defaultOptions)).toBe("neutral");
});
});
describe("resolveStatColor options", () => {
test("returns neutral when threshold colors are disabled", () => {
expect(
resolveStatColor("hostsNeedingUpdates", 10, context, {
...defaultOptions,
enableThresholdColors: false,
}),
).toBe("neutral");
});
test("supports custom absolute thresholds", () => {
expect(
resolveStatColor("hostsNeedingUpdates", 4, context, {
...defaultOptions,
useCustomThresholds: true,
hostsNeedingUpdatesWarningAt: 5,
hostsNeedingUpdatesCriticalAt: 10,
}),
).toBe("green");
});
test("supports custom percent thresholds for lower-is-better stats", () => {
expect(
resolveStatColor("hostsNeedingUpdates", 5, context, {
...defaultOptions,
useCustomThresholds: true,
hostsNeedingUpdatesThresholdMode: "percent",
hostsNeedingUpdatesWarningAt: 10,
hostsNeedingUpdatesCriticalAt: 20,
}),
).toBe("green");
expect(
resolveStatColor("hostsNeedingUpdates", 15, context, {
...defaultOptions,
useCustomThresholds: true,
hostsNeedingUpdatesThresholdMode: "percent",
hostsNeedingUpdatesWarningAt: 10,
hostsNeedingUpdatesCriticalAt: 20,
}),
).toBe("yellow");
});
test("handles zero total hosts for percent-based stats", () => {
expect(resolveStatColor("upToDateHosts", 0, { totalHosts: 0 }, defaultOptions)).toBe("green");
expect(
resolveStatColor("hostsNeedingUpdates", 1, { totalHosts: 0 }, {
...defaultOptions,
useCustomThresholds: true,
hostsNeedingUpdatesThresholdMode: "percent",
}),
).toBe("red");
});
test("clamps invalid percent thresholds when resolving colors", () => {
expect(
resolveStatColor("hostsNeedingUpdates", 50, context, {
...defaultOptions,
useCustomThresholds: true,
hostsNeedingUpdatesThresholdMode: "percent",
hostsNeedingUpdatesWarningAt: 40,
hostsNeedingUpdatesCriticalAt: 200,
}),
).toBe("yellow");
});
test("supports custom absolute thresholds for higher-is-better stats", () => {
expect(
resolveStatColor("upToDateHosts", 85, context, {
...defaultOptions,
useCustomThresholds: true,
upToDateHostsThresholdMode: "absolute",
upToDateHostsWarningAt: 80,
upToDateHostsCriticalAt: 60,
}),
).toBe("green");
expect(
resolveStatColor("upToDateHosts", 70, context, {
...defaultOptions,
useCustomThresholds: true,
upToDateHostsThresholdMode: "absolute",
upToDateHostsWarningAt: 80,
upToDateHostsCriticalAt: 60,
}),
).toBe("yellow");
expect(
resolveStatColor("upToDateHosts", 50, context, {
...defaultOptions,
useCustomThresholds: true,
upToDateHostsThresholdMode: "absolute",
upToDateHostsWarningAt: 80,
upToDateHostsCriticalAt: 60,
}),
).toBe("red");
});
});
@@ -0,0 +1,97 @@
import { describe, expect, test } from "vitest";
import { z } from "zod/v4";
import { clampPercentThreshold, patchmonOptionsSuperRefine } from "../threshold-validation";
const baseOptions = {
enableThresholdColors: true,
useCustomThresholds: true,
hostsNeedingUpdatesThresholdMode: "percent",
hostsNeedingUpdatesWarningAt: 10,
hostsNeedingUpdatesCriticalAt: 20,
securityUpdatesThresholdMode: "absolute",
securityUpdatesWarningAt: 1,
securityUpdatesCriticalAt: 10,
hostsWithSecurityUpdatesThresholdMode: "absolute",
hostsWithSecurityUpdatesWarningAt: 1,
hostsWithSecurityUpdatesCriticalAt: 1,
upToDateHostsThresholdMode: "percent",
upToDateHostsWarningAt: 90,
upToDateHostsCriticalAt: 90,
totalOutdatedPackagesThresholdMode: "absolute",
totalOutdatedPackagesWarningAt: 1,
totalOutdatedPackagesCriticalAt: 50,
};
const optionsSchema = z.object({
hostsNeedingUpdatesWarningAt: z.number().int().min(0),
hostsNeedingUpdatesCriticalAt: z.number().int().min(0),
securityUpdatesWarningAt: z.number().int().min(0),
securityUpdatesCriticalAt: z.number().int().min(0),
hostsWithSecurityUpdatesWarningAt: z.number().int().min(0),
hostsWithSecurityUpdatesCriticalAt: z.number().int().min(0),
upToDateHostsWarningAt: z.number().int().min(0),
upToDateHostsCriticalAt: z.number().int().min(0),
totalOutdatedPackagesWarningAt: z.number().int().min(0),
totalOutdatedPackagesCriticalAt: z.number().int().min(0),
enableThresholdColors: z.boolean(),
useCustomThresholds: z.boolean(),
hostsNeedingUpdatesThresholdMode: z.enum(["absolute", "percent"]),
securityUpdatesThresholdMode: z.enum(["absolute", "percent"]),
hostsWithSecurityUpdatesThresholdMode: z.enum(["absolute", "percent"]),
upToDateHostsThresholdMode: z.enum(["absolute", "percent"]),
totalOutdatedPackagesThresholdMode: z.enum(["absolute", "percent"]),
}).superRefine(patchmonOptionsSuperRefine);
describe("patchmonOptionsSuperRefine", () => {
test("accepts percent thresholds within 0-100", () => {
const result = optionsSchema.safeParse(baseOptions);
expect(result.success).toBe(true);
});
test("rejects percent thresholds above 100", () => {
const result = optionsSchema.safeParse({
...baseOptions,
hostsNeedingUpdatesWarningAt: 101,
});
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues.some((issue) => issue.path.includes("hostsNeedingUpdatesWarningAt"))).toBe(true);
}
});
test("allows absolute thresholds above 100", () => {
const result = optionsSchema.safeParse({
...baseOptions,
hostsNeedingUpdatesThresholdMode: "absolute",
hostsNeedingUpdatesWarningAt: 150,
hostsNeedingUpdatesCriticalAt: 200,
});
expect(result.success).toBe(true);
});
test("skips validation when custom thresholds are disabled", () => {
const result = optionsSchema.safeParse({
...baseOptions,
useCustomThresholds: false,
hostsNeedingUpdatesWarningAt: 500,
});
expect(result.success).toBe(true);
});
});
describe("clampPercentThreshold", () => {
test("clamps percent values to 0-100", () => {
expect(clampPercentThreshold(150, "percent")).toBe(100);
expect(clampPercentThreshold(-5, "percent")).toBe(0);
expect(clampPercentThreshold(42.9, "percent")).toBe(42);
});
test("leaves absolute values unchanged", () => {
expect(clampPercentThreshold(150, "absolute")).toBe(150);
});
});
@@ -0,0 +1,59 @@
import type { RefinementCtx } from "zod/v4";
import { z } from "zod/v4";
import type { ThresholdMode } from "./stat-colors";
const percentThresholdSchema = z.number().int().min(0).max(100);
export const PATCHMON_THRESHOLD_PAIRS = [
{
mode: "hostsNeedingUpdatesThresholdMode",
warningAt: "hostsNeedingUpdatesWarningAt",
criticalAt: "hostsNeedingUpdatesCriticalAt",
},
{
mode: "securityUpdatesThresholdMode",
warningAt: "securityUpdatesWarningAt",
criticalAt: "securityUpdatesCriticalAt",
},
{
mode: "hostsWithSecurityUpdatesThresholdMode",
warningAt: "hostsWithSecurityUpdatesWarningAt",
criticalAt: "hostsWithSecurityUpdatesCriticalAt",
},
{
mode: "upToDateHostsThresholdMode",
warningAt: "upToDateHostsWarningAt",
criticalAt: "upToDateHostsCriticalAt",
},
{
mode: "totalOutdatedPackagesThresholdMode",
warningAt: "totalOutdatedPackagesWarningAt",
criticalAt: "totalOutdatedPackagesCriticalAt",
},
] as const;
export const clampPercentThreshold = (value: number, mode: ThresholdMode): number =>
mode === "percent" ? Math.min(100, Math.max(0, Math.trunc(value))) : value;
export const patchmonOptionsSuperRefine = (data: Record<string, unknown>, ctx: RefinementCtx) => {
if (!data.enableThresholdColors || !data.useCustomThresholds) {
return;
}
for (const { mode, warningAt, criticalAt } of PATCHMON_THRESHOLD_PAIRS) {
if (data[mode] !== "percent") {
continue;
}
for (const key of [warningAt, criticalAt] as const) {
const result = percentThresholdSchema.safeParse(data[key]);
if (!result.success) {
for (const issue of result.error.issues) {
ctx.addIssue({ ...issue, path: [key] });
}
}
}
}
};
+39 -35
View File
@@ -20,8 +20,9 @@ import ReactMarkdown from "react-markdown";
import { clientApi } from "@homarr/api/client";
import { useRequiredBoard } from "@homarr/boards/context";
import { isDateWithin, isNullOrWhitespace, splitToChunksWithNItems } from "@homarr/common";
import { getIconUrl } from "@homarr/definitions";
import { createId, isDateWithin, isNullOrWhitespace, splitToChunksWithNItems } from "@homarr/common";
import type { ReleaseProviderKind } from "@homarr/definitions";
import { getReleaseProviderIconUrl, getReleaseProviderName } from "@homarr/definitions";
import { useScopedI18n } from "@homarr/translation/client";
import { MaskedOrNormalImage } from "@homarr/ui";
@@ -35,7 +36,7 @@ const formatRelativeDate = (value: string): string => {
return isMonths ? value.toUpperCase() : isOtherUnits ? value.toLowerCase() : value;
};
export default function ReleasesWidget({ options }: WidgetComponentProps<"releases">) {
export default function ReleasesWidget({ options, itemId }: WidgetComponentProps<"releases">) {
const t = useScopedI18n("widget.releases");
const now = useNow();
const formatter = useFormatter();
@@ -55,11 +56,16 @@ export default function ReleasesWidget({ options }: WidgetComponentProps<"releas
[options.newReleaseWithin, options.staleReleaseWithin],
);
// Group repositories by integration
const normalizedRepositories = useMemo(
() => options.repositories.map((repo) => (repo.id ? repo : { ...repo, id: createId() })),
[options.repositories],
);
// Group repositories by provider
const groupedRepositories = useMemo(() => {
return options.repositories.reduce(
return normalizedRepositories.reduce(
(acc, repo) => {
const key = repo.providerIntegrationId;
const key = repo.provider;
if (!key) return acc;
acc[key] ??= [];
@@ -67,28 +73,30 @@ export default function ReleasesWidget({ options }: WidgetComponentProps<"releas
return acc;
},
{} as Record<string, ReleasesRepository[]>,
{} as Partial<Record<ReleaseProviderKind, ReleasesRepository[]>>,
);
}, [options.repositories]);
}, [normalizedRepositories]);
// For each group, split into chunks of 5
const batchedRepositories = useMemo(() => {
return Object.entries(groupedRepositories).flatMap(([integrationId, group]) =>
return Object.entries(groupedRepositories).flatMap(([provider, group]) =>
splitToChunksWithNItems(group, 5).map((chunk) => ({
integrationId,
provider: provider as ReleaseProviderKind,
repositories: chunk,
})),
);
}, [groupedRepositories]);
const queryResults = clientApi.useQueries((t) =>
batchedRepositories.flatMap(({ integrationId, repositories }) =>
t.widget.releases.getLatest({
integrationId,
const queryResults = clientApi.useQueries((trpc) =>
batchedRepositories.flatMap(({ provider, repositories }) =>
trpc.widget.releases.getLatest({
itemId,
repositories: repositories.map((repository) => ({
id: repository.id,
provider,
identifier: repository.identifier,
versionFilter: repository.versionFilter,
providerUrl: repository.providerUrl,
})),
}),
),
@@ -100,15 +108,15 @@ export default function ReleasesWidget({ options }: WidgetComponentProps<"releas
);
const repositories = useMemo(() => {
const formattedResults = options.repositories
const formattedResults = normalizedRepositories
.map((repository) => {
if (!repository.providerIntegrationId) return { ...repository, error: { code: "noProviderSeleceted" } };
if (!repository.provider) return { ...repository, error: { code: "noProviderSelected" } };
const repositoryResult = results.flat().find(({ id }) => id === repository.id);
if (!repositoryResult) return { ...repository, error: { code: "noProviderResponse" } };
if (!repositoryResult.success) return { ...repository, error: repositoryResult.error };
const { data: release, integration } = repositoryResult;
const { data: release, provider } = repositoryResult;
const isReleaseWithin = (relativeDate: string) =>
Boolean(relativeDate) && isDateWithin(release.latestReleaseAt, relativeDate);
@@ -116,7 +124,7 @@ export default function ReleasesWidget({ options }: WidgetComponentProps<"releas
return {
...repository,
...release,
integration: { name: integration.name, iconUrl: getIconUrl(integration.kind) },
providerMetadata: { name: getReleaseProviderName(provider), iconUrl: getReleaseProviderIconUrl(provider) },
isNewRelease: isReleaseWithin(relativeDateOptions.newReleaseWithin),
isStaleRelease: !isReleaseWithin(relativeDateOptions.staleReleaseWithin),
viewed: releasesViewedList[repository.id] === release.latestRelease,
@@ -139,7 +147,7 @@ export default function ReleasesWidget({ options }: WidgetComponentProps<"releas
return formattedResults;
}, [
results,
options.repositories,
normalizedRepositories,
options.showOnlyHighlighted,
options.topReleases,
relativeDateOptions.newReleaseWithin,
@@ -173,7 +181,7 @@ export default function ReleasesWidget({ options }: WidgetComponentProps<"releas
className={combineClasses(
"releases-repository",
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
`releases-repository-${repository.integration?.name ?? "error"}-${repository.name || repository.identifier.replace(/[^a-zA-Z0-9]/g, "_")}`,
`releases-repository-${repository.providerMetadata?.name ?? "error"}-${repository.name || repository.identifier.replace(/[^a-zA-Z0-9]/g, "_")}`,
classes.releasesRepository,
)}
gap={0}
@@ -187,7 +195,7 @@ export default function ReleasesWidget({ options }: WidgetComponentProps<"releas
>
<MaskedOrNormalImage
className="releases-repository-header-icon"
imageUrl={repository.iconUrl ?? repository.integration?.iconUrl}
imageUrl={repository.iconUrl ?? repository.providerMetadata?.iconUrl}
hasColor={hasIconColor}
style={{
width: "1em",
@@ -247,7 +255,7 @@ export default function ReleasesWidget({ options }: WidgetComponentProps<"releas
!hasError &&
formatter.relativeTime(repository.latestReleaseAt, {
now,
style: "narrow",
style: "long",
})}
</Text>
{hasError ? (
@@ -529,28 +537,23 @@ const ExpandedDisplay = ({
p={10}
>
<Group className="releases-repository-expanded-header" justify="space-between" align="center" gap="xs">
<Text className="releases-repository-expanded-header-identifier" size="xs" c="iconColor" ff="monospace">
<Text className="releases-repository-expanded-header-identifier" size="xs" c="dimmed" ff="monospace">
{repository.identifier}
</Text>
{repository.integration && (
{repository.providerMetadata && (
<Group className="releases-repository-expanded-header-provider-wrapper" gap={5} align="center">
<MaskedOrNormalImage
className="releases-repository-expanded-header-provider-icon"
imageUrl={repository.integration.iconUrl}
imageUrl={repository.providerMetadata.iconUrl}
hasColor={hasIconColor}
style={{
width: "1em",
aspectRatio: "1/1",
}}
/>
<Text
className="releases-repository-expanded-header-provider-name"
size="xs"
c="iconColor"
ff="monospace"
>
{repository.integration.name}
<Text className="releases-repository-expanded-header-provider-name" size="xs" c="dimmed" ff="monospace">
{repository.providerMetadata.name}
</Text>
</Group>
)}
@@ -564,7 +567,7 @@ const ExpandedDisplay = ({
<Text className="releases-repository-expanded-createdAt-date" span fw={700}>
{formatter.relativeTime(repository.createdAt, {
now,
style: "narrow",
style: "long",
})}
</Text>
</Text>
@@ -614,7 +617,7 @@ const ExpandedDisplay = ({
{repository.error && (
<>
<Divider className="releases-repository-expanded-error-divider" mx="30%" />
<Title className="releases-repository-expanded-error-title" order={4} ta="center">
<Title className="releases-repository-expanded-error-title" order={4} ta="center" c="red">
{t("error.label")}
</Title>
<Text
@@ -624,7 +627,8 @@ const ExpandedDisplay = ({
c="red"
style={{ whiteSpace: "pre-wrap" }}
>
{repository.error.code ? t(`error.messages.${repository.error.code}` as never) : repository.error.message}
{repository.error.message ??
(repository.error.code ? t(`error.messages.${repository.error.code}` as never) : null)}
</Text>
</>
)}
+16 -2
View File
@@ -1,6 +1,9 @@
import { IconRocket } from "@tabler/icons-react";
import { z } from "zod/v4";
import { createId } from "@homarr/common";
import { releaseProviderKinds } from "@homarr/definitions";
import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options";
@@ -39,10 +42,20 @@ export const { definition, componentLoader } = createWidgetDefinition("releases"
validate: z.number().min(0),
}),
repositories: factory.multiReleasesRepositories({
defaultValue: [],
defaultValue: [
{
id: createId(),
provider: "github" as const,
identifier: "homarr-labs/homarr",
name: "Homarr",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/homarr.png",
versionFilter: { prefix: "v", precision: 3 },
},
],
validate: z.array(
z.object({
providerIntegrationId: z.string().optional(),
id: z.string().default(() => createId()),
provider: z.enum(releaseProviderKinds).optional(),
identifier: z.string().min(1),
name: z.string().optional(),
versionFilter: z
@@ -53,6 +66,7 @@ export const { definition, componentLoader } = createWidgetDefinition("releases"
})
.optional(),
iconUrl: z.string().optional(),
providerUrl: z.string().url().optional(),
}),
),
}),
@@ -1,3 +1,5 @@
import type { ReleaseProviderKind } from "@homarr/definitions";
export interface ReleasesVersionFilter {
prefix?: string;
precision: number;
@@ -6,11 +8,12 @@ export interface ReleasesVersionFilter {
export interface ReleasesRepository {
id: string;
providerIntegrationId?: string;
provider?: ReleaseProviderKind;
identifier: string;
name?: string;
versionFilter?: ReleasesVersionFilter;
iconUrl?: string;
providerUrl?: string;
}
export interface ReleasesRepositoryResponse extends ReleasesRepository {
@@ -32,7 +35,7 @@ export interface ReleasesRepositoryResponse extends ReleasesRepository {
forksCount?: number;
openIssues?: number;
integration?: {
providerMetadata?: {
name: string;
iconUrl?: string;
};
+1 -23
View File
@@ -12,29 +12,7 @@ import type { WidgetComponentProps } from "../definition";
import classes from "./component.module.scss";
const useLiveFeedEntries = (input: RouterInputs["widget"]["rssFeed"]["getFeeds"]) => {
const { data: feedEntries = [] } = clientApi.widget.rssFeed.getFeeds.useQuery(input, {
staleTime: 5 * 60 * 1000,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
retry: false,
});
const utils = clientApi.useUtils();
clientApi.widget.rssFeed.subscribeFeeds.useSubscription(input, {
onData(updatedData) {
utils.widget.rssFeed.getFeeds.setData(input, (oldData) => {
return oldData
?.filter((entry) => entry.feedUrl !== updatedData.url)
.concat(updatedData.entries)
.toSorted((entryA, entryB) => {
return entryA.published && entryB.published
? new Date(entryB.published).getTime() - new Date(entryA.published).getTime()
: 0;
})
.slice(0, input.maximumAmountPosts);
});
},
});
const { data: feedEntries = [] } = clientApi.widget.rssFeed.getFeeds.useQuery(input);
return feedEntries;
};
@@ -23,20 +23,13 @@ export default function SmartHomeEntityStateWidget({
entityId: options.entityId,
integrationId,
};
const { data: entityState } = clientApi.widget.smartHome.entityState.useQuery(input, {
staleTime: 60 * 1000,
});
const { data: entityState } = clientApi.widget.smartHome.entityState.useQuery(input);
const utils = clientApi.useUtils();
clientApi.widget.smartHome.subscribeEntityState.useSubscription(input, {
onData(data) {
utils.widget.smartHome.entityState.setData(input, data.state);
},
const { mutate } = clientApi.widget.smartHome.switchEntity.useMutation({
onSettled: () => void utils.widget.smartHome.entityState.invalidate(input),
});
const { mutate } = clientApi.widget.smartHome.switchEntity.useMutation();
const attribute = options.entityUnit.length > 0 ? " " + options.entityUnit : "";
const handleClick = useCallback(() => {
@@ -14,34 +14,9 @@ import { mergeStats } from "./helpers";
import { LatestResultSection } from "./latest-result";
import { RecentResultsSection } from "./recent-results";
export default function SpeedtestTrackerWidget({
options,
integrationIds,
isEditMode,
}: WidgetComponentProps<"speedtestTracker">) {
export default function SpeedtestTrackerWidget({ options, integrationIds }: WidgetComponentProps<"speedtestTracker">) {
const t = useScopedI18n("widget.speedtestTracker");
const { data: dashboardData = [] } = clientApi.widget.speedtestTracker.getDashboard.useQuery(
{ integrationIds },
{ staleTime: 10 * 60 * 1000 },
);
const utils = clientApi.useUtils();
clientApi.widget.speedtestTracker.subscribeToDashboard.useSubscription(
{ integrationIds },
{
enabled: !isEditMode,
onData(newData) {
utils.widget.speedtestTracker.getDashboard.setData({ integrationIds }, (prevData) => {
if (!prevData) return prevData;
return prevData.map((instance) =>
instance.integrationId === newData.integrationId
? { ...instance, dashboard: newData.dashboard, updatedAt: newData.timestamp }
: instance,
);
});
},
},
);
const { data: dashboardData = [] } = clientApi.widget.speedtestTracker.getDashboard.useQuery({ integrationIds });
const combined = useMemo(
() =>
+1 -3
View File
@@ -25,9 +25,7 @@ function calculateChangePercentage(currentPrice: number, previousClose: number)
export default function StockPriceWidget({ options, width, height }: WidgetComponentProps<"stockPrice">) {
const t = useScopedI18n("widget.stockPrice");
const theme = useMantineTheme();
const { data: result } = clientApi.widget.stockPrice.getPriceHistory.useQuery(options, {
staleTime: 5 * 60 * 1000,
});
const { data: result } = clientApi.widget.stockPrice.getPriceHistory.useQuery(options);
if (!result) return <WidgetEmptyState />;
const { data } = result;
@@ -0,0 +1,39 @@
import { clientApi } from "@homarr/api/client";
import { toScopedStorageVolumeValue } from "./filter-storage-volumes";
export const createStorageVolumeMultiSelectOptions = () => ({
defaultValue: [] as string[],
withDescription: true,
useOptions: (integrationIds: string[]) => {
const queryResults = clientApi.useQueries((queryBuilder) =>
integrationIds.map((integrationId) =>
queryBuilder.widget.healthMonitoring.listStorageVolumes({
integrationId,
}),
),
);
const isPending = queryResults.some((queryResult) => queryResult.isPending);
const isError = queryResults.every((queryResult) => queryResult.isError);
const options = queryResults.flatMap((queryResult, index) => {
const integrationId = integrationIds[index];
if (!integrationId) {
return [];
}
return (queryResult.data ?? []).map((option) => ({
label: option.label,
value: toScopedStorageVolumeValue(integrationId, option.value),
}));
});
const uniqueOptions = [...new Map(options.map((option) => [option.value, option])).values()];
return {
data: uniqueOptions,
isPending,
isError: isError && uniqueOptions.length === 0,
};
},
});
+17 -14
View File
@@ -10,6 +10,7 @@ import { useI18n } from "@homarr/translation/client";
import { WidgetEmptyState } from "../common/empty-state";
import type { WidgetComponentProps } from "../definition";
import { filterStorageVolumes } from "../filter-storage-volumes";
import { NoIntegrationDataError } from "../errors/no-data-integration";
type DisplayMode = WidgetComponentProps<"systemDisks">["options"]["displayMode"];
@@ -118,28 +119,30 @@ const SystemDiskCard = ({
export default function SystemResources({ integrationIds, options }: WidgetComponentProps<"systemDisks">) {
const queryInput = { integrationIds };
const { data = [] } = clientApi.widget.healthMonitoring.getSystemHealthStatus.useQuery(queryInput, {
staleTime: 5 * 1000,
});
const utils = clientApi.useUtils();
clientApi.widget.healthMonitoring.subscribeSystemHealthStatus.useSubscription(queryInput, {
onData(data) {
utils.widget.healthMonitoring.getSystemHealthStatus.setData(queryInput, (oldData) =>
oldData?.map((item) => (item.integrationId === data.integrationId ? { ...item, ...data } : item)),
);
},
});
const { data = [] } = clientApi.widget.healthMonitoring.getSystemHealthStatus.useQuery(queryInput);
const lastItem = data.at(-1);
if (!lastItem) return <WidgetEmptyState />;
const { fileSystem, smart } = lastItem.healthInfo;
if (fileSystem.length === 0) {
const rawFileSystem = lastItem.healthInfo.fileSystem;
if (rawFileSystem.length === 0) {
throw new NoIntegrationDataError();
}
const fileSystem = filterStorageVolumes(
rawFileSystem,
options.visibleStorageVolumes,
lastItem.integrationId,
);
const smart = filterStorageVolumes(
lastItem.healthInfo.smart,
options.visibleStorageVolumes,
lastItem.integrationId,
);
if (fileSystem.length === 0) return <WidgetEmptyState />;
return (
<Stack gap="xs" p="xs" h="100%">
{fileSystem.map((item) => {
+22 -11
View File
@@ -2,21 +2,32 @@ import { IconServer2 } from "@tabler/icons-react";
import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options";
import { createStorageVolumeMultiSelectOptions } from "../storage-volume-options";
export const { definition, componentLoader } = createWidgetDefinition("systemDisks", {
icon: IconServer2,
supportedIntegrations: ["dashDot", "openmediavault", "truenas", "unraid"],
supportedIntegrations: ["dashDot", "openmediavault", "truenas", "unraid", "synology"],
createOptions() {
return optionsBuilder.from((factory) => ({
showTemperatureIfAvailable: factory.switch({ defaultValue: true }),
displayMode: factory.select({
options: (["percentage", "absolute", "free"] as const).map((value) => ({
value,
label: (t) => t(`widget.systemDisks.option.displayMode.option.${value}.label`),
})),
defaultValue: "percentage",
return optionsBuilder.from(
(factory) => ({
showTemperatureIfAvailable: factory.switch({ defaultValue: true }),
displayMode: factory.select({
options: (["percentage", "absolute", "free"] as const).map((value) => ({
value,
label: (t) => t(`widget.systemDisks.option.displayMode.option.${value}.label`),
})),
defaultValue: "percentage",
}),
showBackgroundBar: factory.switch({ defaultValue: true }),
visibleStorageVolumes: factory.integrationMultiSelect(createStorageVolumeMultiSelectOptions()),
}),
showBackgroundBar: factory.switch({ defaultValue: true }),
}));
{
visibleStorageVolumes: {
shouldHide(_, integrationKinds) {
return integrationKinds.length === 0 || !integrationKinds.every((kind) => kind === "synology");
},
},
},
);
},
}).withDynamicImport(() => import("./component"));
@@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import { useEffect, useRef, useState } from "react";
import { Box, Group, Stack } from "@mantine/core";
import { useElementSize } from "@mantine/hooks";
@@ -15,56 +15,49 @@ import { NetworkTrafficChart } from "./chart/network-traffic";
const MAX_QUEUE_SIZE = 15;
const toChartItem = (healthInfo: {
cpuUtilization: number;
memUsedInBytes: number;
gpu: { processorUtilization: number }[];
network: { up: number; down: number } | null;
}) => ({
cpu: healthInfo.cpuUtilization,
memory: healthInfo.memUsedInBytes,
gpu:
healthInfo.gpu.length > 0
? healthInfo.gpu.reduce((acc, g) => acc + g.processorUtilization, 0) / healthInfo.gpu.length
: 0,
network: healthInfo.network,
});
export default function SystemResources({ integrationIds, options }: WidgetComponentProps<"systemResources">) {
const { ref, width } = useElementSize();
const { data = [] } = clientApi.widget.healthMonitoring.getSystemHealthStatus.useQuery(
{
integrationIds,
},
{
staleTime: 5 * 1000,
},
);
const { data = [], dataUpdatedAt } = clientApi.widget.healthMonitoring.getSystemHealthStatus.useQuery({
integrationIds,
});
const memoryCapacityInBytes =
(data[0]?.healthInfo.memAvailableInBytes ?? 0) + (data[0]?.healthInfo.memUsedInBytes ?? 0);
const [items, setItems] = useState<
{ cpu: number; memory: number; gpu: number; network: { up: number; down: number } | null }[]
>(
data.map((item) => ({
cpu: item.healthInfo.cpuUtilization,
memory: item.healthInfo.memUsedInBytes,
gpu:
item.healthInfo.gpu.length > 0
? item.healthInfo.gpu.reduce((acc, g) => acc + g.processorUtilization, 0) / item.healthInfo.gpu.length
: 0,
network: item.healthInfo.network,
})),
);
>(() => (data[0] ? [toChartItem(data[0].healthInfo)] : []));
clientApi.widget.healthMonitoring.subscribeSystemHealthStatus.useSubscription(
{
integrationIds,
},
{
onData(data) {
setItems((previousItems) => {
const next = {
cpu: data.healthInfo.cpuUtilization,
memory: data.healthInfo.memUsedInBytes,
gpu:
data.healthInfo.gpu.length > 0
? data.healthInfo.gpu.reduce((acc, g) => acc + g.processorUtilization, 0) / data.healthInfo.gpu.length
: 0,
network: data.healthInfo.network,
};
const prevIntegrationIds = useRef(integrationIds);
useEffect(() => {
if (prevIntegrationIds.current === integrationIds) return;
prevIntegrationIds.current = integrationIds;
setItems([]);
}, [integrationIds]);
return [...previousItems, next].slice(-MAX_QUEUE_SIZE);
});
},
},
);
const lastUpdatedAt = useRef(dataUpdatedAt);
useEffect(() => {
if (dataUpdatedAt === lastUpdatedAt.current) return;
lastUpdatedAt.current = dataUpdatedAt;
const firstItem = data[0];
if (!firstItem) return;
setItems((prev) => [...prev, toChartItem(firstItem.healthInfo)].slice(-MAX_QUEUE_SIZE));
}, [dataUpdatedAt, data]);
const showNetwork =
items.length === 0 || (items.every((item) => item.network !== null) && options.visibleCharts.includes("network"));
@@ -14,7 +14,7 @@ const labelDisplayModeOptions = {
export const { definition, componentLoader } = createWidgetDefinition("systemResources", {
icon: IconGraphFilled,
supportedIntegrations: ["dashDot", "openmediavault", "truenas", "unraid", "glances"],
supportedIntegrations: ["dashDot", "openmediavault", "truenas", "unraid", "glances", "synology"],
createOptions() {
return optionsBuilder.from((factory) => ({
hasShadow: factory.switch({ defaultValue: true }),
+9 -17
View File
@@ -7,35 +7,27 @@ import { useScopedI18n } from "@homarr/translation/client";
import type { DynamicSelectOption } from "../_inputs/widget-dynamic-select-input";
import type { WidgetComponentProps } from "../definition";
export default function TimetableWidget({ options, integrationIds }: WidgetComponentProps<"timetable">) {
// It will always have at least one integration as otherwise the NoIntegrationSelectedError would be thrown in item-content.tsx
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const integrationId = integrationIds[0]!;
export default function TimetableWidget({ options }: WidgetComponentProps<"timetable">) {
const t = useScopedI18n("widget.timetable");
if (!options.station) {
return <Center h="100%">{t("noStation")}</Center>;
}
return <TimetableWidgetInner station={options.station} integrationId={integrationId} />;
return <TimetableWidgetInner station={options.station} baseUrl={options.baseUrl} />;
}
interface TimetableWidgetInnerProps {
station: DynamicSelectOption;
integrationId: string;
baseUrl: string;
}
const TimetableWidgetInner = ({ station, integrationId }: TimetableWidgetInnerProps) => {
const { data: timetable } = clientApi.widget.timetable.getTimetable.useQuery(
{
integrationId,
stationId: station.value,
limit: 10,
},
{
staleTime: 60 * 1000,
},
);
const TimetableWidgetInner = ({ station, baseUrl }: TimetableWidgetInnerProps) => {
const { data: timetable } = clientApi.widget.timetable.getTimetable.useQuery({
baseUrl,
stationId: station.value,
limit: 10,
});
const t = useScopedI18n("widget.timetable");
return (
+10 -11
View File
@@ -1,4 +1,5 @@
import { IconBusStop } from "@tabler/icons-react";
import { z } from "zod/v4";
import { clientApi } from "@homarr/api/client";
@@ -7,20 +8,18 @@ import { optionsBuilder } from "../options";
export const { componentLoader, definition } = createWidgetDefinition("timetable", {
icon: IconBusStop,
supportedIntegrations: ["searchCh"],
createOptions() {
return optionsBuilder.from((factory) => ({
baseUrl: factory.text({
defaultValue: "https://search.ch",
validate: z.string().url(),
}),
station: factory.dynamicSelect({
useOptions(query, integrationIds) {
const { data: stations, isPending } = clientApi.widget.timetable.searchStations.useQuery(
{
integrationId: integrationIds[0] ?? "",
query,
},
{
enabled: integrationIds.length > 0,
},
);
useOptions(query, _integrationIds, options) {
const { data: stations, isPending } = clientApi.widget.timetable.searchStations.useQuery({
baseUrl: typeof options.baseUrl === "string" ? options.baseUrl : "https://search.ch",
query,
});
return {
isPending,
+4 -31
View File
@@ -13,50 +13,23 @@ import { StatsBar } from "./stats-section";
import { StreamsList } from "./streams-section";
import { ViolationsList } from "./violations-section";
export default function TracearrWidget({
options,
integrationIds,
width,
isEditMode,
}: WidgetComponentProps<"tracearr">) {
export default function TracearrWidget({ options, integrationIds, width }: WidgetComponentProps<"tracearr">) {
if (integrationIds.length === 0) {
throw new NoIntegrationDataError();
}
return <TracearrContent integrationIds={integrationIds} options={options} width={width} isEditMode={isEditMode} />;
return <TracearrContent integrationIds={integrationIds} options={options} width={width} />;
}
interface TracearrContentProps {
integrationIds: string[];
options: WidgetComponentProps<"tracearr">["options"];
width: number;
isEditMode: boolean;
}
function TracearrContent({ integrationIds, options, width, isEditMode }: TracearrContentProps) {
function TracearrContent({ integrationIds, options, width }: TracearrContentProps) {
const t = useScopedI18n("widget.tracearr");
const { data: dashboardData = [] } = clientApi.widget.tracearr.getDashboard.useQuery(
{ integrationIds },
{ staleTime: 5 * 1000 },
);
const utils = clientApi.useUtils();
clientApi.widget.tracearr.subscribeToDashboard.useSubscription(
{ integrationIds },
{
enabled: !isEditMode,
onData(newData) {
utils.widget.tracearr.getDashboard.setData({ integrationIds }, (prevData) => {
if (!prevData) return prevData;
return prevData.map((instance) =>
instance.integrationId === newData.integrationId
? { ...instance, dashboard: newData.dashboard, updatedAt: newData.timestamp }
: instance,
);
});
},
},
);
const { data: dashboardData = [] } = clientApi.widget.tracearr.getDashboard.useQuery({ integrationIds });
// Merge data from all integrations
const combined = dashboardData.reduce<TracearrDashboardData>(
@@ -0,0 +1,125 @@
.root {
container-type: inline-size;
display: flex;
flex-direction: column;
min-height: 100%;
padding: clamp(6px, 3cqi, 12px);
gap: clamp(6px, 3cqi, 12px);
}
.titleBlock {
min-width: 0;
}
.title {
font-size: clamp(12px, 4cqi, 15px);
font-weight: 700;
line-height: 1.2;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.subtitle {
font-size: clamp(9px, 3cqi, 12px);
color: var(--mantine-color-dimmed);
line-height: 1.2;
}
.hero {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: clamp(6px, 3cqi, 10px);
}
.metric {
display: flex;
align-items: center;
min-width: 0;
gap: clamp(5px, 2cqi, 8px);
padding: clamp(6px, 3cqi, 10px);
border-radius: var(--mantine-radius-sm);
background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
}
.metricIcon {
flex-shrink: 0;
color: var(--mantine-color-teal-6);
}
.metricText {
min-width: 0;
}
.metricValue {
font-size: clamp(16px, 7cqi, 24px);
font-weight: 800;
line-height: 1;
}
.metricLabel {
font-size: clamp(8px, 2.8cqi, 11px);
color: var(--mantine-color-dimmed);
line-height: 1.2;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.protocolGrid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(110px, 1fr));
gap: clamp(6px, 3cqi, 10px);
}
.protocolCard {
min-width: 0;
padding: clamp(6px, 3cqi, 10px);
border: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4));
border-radius: var(--mantine-radius-sm);
}
.protocolTitle {
font-size: clamp(10px, 3.5cqi, 13px);
font-weight: 700;
line-height: 1.2;
text-transform: uppercase;
}
.protocolRows {
display: flex;
flex-direction: column;
gap: 4px;
margin-top: 6px;
}
.protocolRow {
display: flex;
align-items: center;
justify-content: space-between;
min-width: 0;
gap: 6px;
}
.protocolLabel {
min-width: 0;
color: var(--mantine-color-dimmed);
font-size: clamp(9px, 3cqi, 12px);
}
.protocolLabel p {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.counts {
flex-shrink: 0;
font-size: clamp(10px, 3.5cqi, 13px);
}
.entryPoints {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
+259
View File
@@ -0,0 +1,259 @@
"use client";
import type { ReactNode } from "react";
import { Badge, Group, ScrollArea, Text } from "@mantine/core";
import {
IconAlertTriangle,
IconCircleCheck,
IconDoorEnter,
IconRoute,
IconServer,
IconStack2,
} from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import type { TraefikDashboardData, TraefikProtocolSummary, TraefikResourceSummary } from "@homarr/integrations/types";
import { useScopedI18n } from "@homarr/translation/client";
import { WidgetEmptyState } from "../common/empty-state";
import type { WidgetComponentProps } from "../definition";
import { NoIntegrationDataError } from "../errors/no-data-integration";
import classes from "./component.module.css";
const emptySummary: TraefikResourceSummary = {
total: 0,
enabled: 0,
warnings: 0,
errors: 0,
};
const emptyProtocol: TraefikProtocolSummary = {
routers: emptySummary,
services: emptySummary,
middlewares: emptySummary,
};
const emptyDashboard: TraefikDashboardData = {
version: null,
entryPoints: [],
http: emptyProtocol,
tcp: emptyProtocol,
udp: {
routers: emptySummary,
services: emptySummary,
},
};
type ProtocolKey = "http" | "tcp" | "udp";
type ResourceKey = "routers" | "services" | "middlewares";
export default function TraefikWidget({ integrationIds, options, width }: WidgetComponentProps<"traefik">) {
if (integrationIds.length === 0) {
throw new NoIntegrationDataError();
}
const t = useScopedI18n("widget.traefik");
const { data } = clientApi.widget.traefik.getDashboard.useQuery({ integrationIds });
if (!data) return <WidgetEmptyState />;
const combined = data.reduce<TraefikDashboardData>(
(acc, item) => ({
version: acc.version ?? item.dashboard.version,
entryPoints: [...acc.entryPoints, ...item.dashboard.entryPoints],
http: combineProtocol(acc.http, item.dashboard.http),
tcp: combineProtocol(acc.tcp, item.dashboard.tcp),
udp: {
routers: combineSummary(acc.udp.routers, item.dashboard.udp.routers),
services: combineSummary(acc.udp.services, item.dashboard.udp.services),
},
}),
emptyDashboard,
);
const totalRouters = combined.http.routers.total + combined.tcp.routers.total + combined.udp.routers.total;
const totalErrors = getProtocolErrors(combined.http) + getProtocolErrors(combined.tcp) + getUdpErrors(combined.udp);
const totalWarnings =
getProtocolWarnings(combined.http) + getProtocolWarnings(combined.tcp) + getUdpWarnings(combined.udp);
const protocolKeys: ProtocolKey[] = ["http"];
if (options.showTcp) protocolKeys.push("tcp");
if (options.showUdp) protocolKeys.push("udp");
return (
<ScrollArea h="100%">
<div className={classes.root}>
<Group justify="space-between" gap="xs" wrap="nowrap">
<div className={classes.titleBlock}>
<Text className={classes.title}>{data.length === 1 ? data[0]?.integrationName : t("instances")}</Text>
<Text className={classes.subtitle}>{combined.version ? `v${combined.version}` : t("versionUnknown")}</Text>
</div>
<HealthBadge errors={totalErrors} warnings={totalWarnings} />
</Group>
<div className={classes.hero}>
<SummaryMetric
icon={<IconRoute size={getHeroIconSize(width)} />}
label={t("summary.routers")}
value={totalRouters}
/>
<SummaryMetric
icon={<IconDoorEnter size={getHeroIconSize(width)} />}
label={t("summary.entryPoints")}
value={dedupe(combined.entryPoints).length}
/>
</div>
<div className={classes.protocolGrid}>
{protocolKeys.map((protocol) => (
<ProtocolCard key={protocol} protocol={protocol} data={combined[protocol]} />
))}
</div>
{options.showEntryPoints && combined.entryPoints.length > 0 && (
<div className={classes.entryPoints}>
{dedupe(combined.entryPoints)
.slice(0, getEntryPointLimit(width))
.map((entryPoint) => (
<Badge key={entryPoint} variant="light" size="sm" radius="sm">
{entryPoint}
</Badge>
))}
</div>
)}
</div>
</ScrollArea>
);
}
function ProtocolCard({ protocol, data }: { protocol: ProtocolKey; data: TraefikDashboardData[ProtocolKey] }) {
const t = useScopedI18n("widget.traefik");
const rows: { key: ResourceKey; icon: typeof IconRoute; summary: TraefikResourceSummary }[] = [
{ key: "routers", icon: IconRoute, summary: data.routers },
{ key: "services", icon: IconServer, summary: data.services },
];
if ("middlewares" in data) {
rows.push({ key: "middlewares", icon: IconStack2, summary: data.middlewares });
}
return (
<div className={classes.protocolCard}>
<Text className={classes.protocolTitle}>{protocol.toUpperCase()}</Text>
<div className={classes.protocolRows}>
{rows.map(({ key, icon: Icon, summary }) => (
<div key={key} className={classes.protocolRow}>
<Group gap={4} wrap="nowrap" className={classes.protocolLabel}>
<Icon size={14} />
<Text>{t(`resource.${key}`)}</Text>
</Group>
<StatusCount summary={summary} />
</div>
))}
</div>
</div>
);
}
function StatusCount({ summary }: { summary: TraefikResourceSummary }) {
return (
<Group gap={4} wrap="nowrap" className={classes.counts}>
<Text fw={700}>{summary.total}</Text>
{summary.errors > 0 && (
<Text c="red" fw={700}>
{summary.errors}
</Text>
)}
{summary.warnings > 0 && (
<Text c="yellow" fw={700}>
{summary.warnings}
</Text>
)}
</Group>
);
}
function SummaryMetric({ icon, label, value }: { icon: ReactNode; label: string; value: number }) {
return (
<div className={classes.metric}>
<div className={classes.metricIcon}>{icon}</div>
<div className={classes.metricText}>
<Text className={classes.metricValue}>{value}</Text>
<Text className={classes.metricLabel}>{label}</Text>
</div>
</div>
);
}
function HealthBadge({ errors, warnings }: { errors: number; warnings: number }) {
const t = useScopedI18n("widget.traefik");
if (errors > 0) {
return (
<Badge color="red" variant="light" leftSection={<IconAlertTriangle size={12} />} radius="sm">
{t("status.errors", { count: errors })}
</Badge>
);
}
if (warnings > 0) {
return (
<Badge color="yellow" variant="light" leftSection={<IconAlertTriangle size={12} />} radius="sm">
{t("status.warnings", { count: warnings })}
</Badge>
);
}
return (
<Badge color="green" variant="light" leftSection={<IconCircleCheck size={12} />} radius="sm">
{t("status.healthy")}
</Badge>
);
}
function combineProtocol(left: TraefikProtocolSummary, right: TraefikProtocolSummary): TraefikProtocolSummary {
return {
routers: combineSummary(left.routers, right.routers),
services: combineSummary(left.services, right.services),
middlewares: combineSummary(left.middlewares, right.middlewares),
};
}
function combineSummary(left: TraefikResourceSummary, right: TraefikResourceSummary): TraefikResourceSummary {
return {
total: left.total + right.total,
enabled: left.enabled + right.enabled,
warnings: left.warnings + right.warnings,
errors: left.errors + right.errors,
};
}
function getProtocolErrors(protocol: TraefikProtocolSummary) {
return protocol.routers.errors + protocol.services.errors + protocol.middlewares.errors;
}
function getProtocolWarnings(protocol: TraefikProtocolSummary) {
return protocol.routers.warnings + protocol.services.warnings + protocol.middlewares.warnings;
}
function getUdpErrors(protocol: TraefikDashboardData["udp"]) {
return protocol.routers.errors + protocol.services.errors;
}
function getUdpWarnings(protocol: TraefikDashboardData["udp"]) {
return protocol.routers.warnings + protocol.services.warnings;
}
function dedupe(values: string[]) {
return Array.from(new Set(values));
}
function getHeroIconSize(width: number) {
if (width < 220) return 16;
if (width < 340) return 18;
return 22;
}
function getEntryPointLimit(width: number) {
if (width < 220) return 2;
if (width < 360) return 4;
return 8;
}
+28
View File
@@ -0,0 +1,28 @@
import { IconRoute, IconRouteOff } from "@tabler/icons-react";
import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("traefik", {
icon: IconRoute,
createOptions() {
return optionsBuilder.from((factory) => ({
showTcp: factory.switch({
defaultValue: true,
}),
showUdp: factory.switch({
defaultValue: true,
}),
showEntryPoints: factory.switch({
defaultValue: true,
}),
}));
},
supportedIntegrations: ["traefik"],
errors: {
INTERNAL_SERVER_ERROR: {
icon: IconRouteOff,
message: (t) => t("widget.traefik.error.internalServerError"),
},
},
}).withDynamicImport(() => import("./component"));
+8 -18
View File
@@ -10,7 +10,7 @@ import { useScopedI18n } from "@homarr/translation/client";
import { UmamiEventsContent } from "./umami-events-content";
import { UmamiTopPagesContent, UmamiTopReferrersContent } from "./umami-top-list";
import { formatTimeFrameLabel, formatXLabel } from "./umami-utils";
import { formatTimeFrameLabel, formatXLabel, umamiQueryOptions } from "./umami-utils";
import type { TimeFrame } from "./umami-utils";
interface UmamiContentProps {
@@ -41,29 +41,19 @@ export function UmamiContent({
const tickColor = colorScheme === "dark" ? "#c1c2c5" : "#495057";
const { data: results = [] } = clientApi.widget.umami.getVisitorStats.useQuery(
{
integrationIds,
websiteId,
timeFrame,
eventName,
},
{ staleTime: 5 * 60 * 1000 },
{ integrationIds, websiteId, timeFrame, eventName },
umamiQueryOptions,
);
const activeVisitorsInput = { integrationId: integrationIds[0] ?? "", websiteId };
const { data: activeVisitors = 0 } = clientApi.widget.umami.getActiveVisitors.useQuery(activeVisitorsInput, {
staleTime: 30 * 1000,
});
const utils = clientApi.useUtils();
clientApi.widget.umami.subscribeActiveVisitors.useSubscription(activeVisitorsInput, {
onData(count) {
utils.widget.umami.getActiveVisitors.setData(activeVisitorsInput, () => count);
},
});
const { data: activeVisitors = 0 } = clientApi.widget.umami.getActiveVisitors.useQuery(
activeVisitorsInput,
umamiQueryOptions,
);
const { data: multiEventSeries } = clientApi.widget.umami.getMultiEventTimeSeries.useQuery(
{ integrationId: integrationIds[0] ?? "", websiteId, timeFrame, eventNames: [...eventNames].toSorted() },
{ enabled: viewMode === "events" && eventNames.length > 0, staleTime: 5 * 60 * 1000 },
{ enabled: viewMode === "events" && eventNames.length > 0, ...umamiQueryOptions },
);
const multiEventTotal = multiEventSeries
@@ -7,7 +7,7 @@ import { clientApi } from "@homarr/api/client";
import type { UmamiEventSeries } from "@homarr/integrations/types";
import { useScopedI18n } from "@homarr/translation/client";
import { EVENT_COLORS, formatXLabel } from "./umami-utils";
import { EVENT_COLORS, formatXLabel, umamiQueryOptions } from "./umami-utils";
interface UmamiEventsContentProps {
integrationIds: string[];
@@ -29,13 +29,8 @@ export function UmamiEventsContent({
const tickColor = colorScheme === "dark" ? "#c1c2c5" : "#495057";
const { data: series = [] } = clientApi.widget.umami.getMultiEventTimeSeries.useQuery(
{
integrationId: integrationIds[0] ?? "",
websiteId,
timeFrame,
eventNames: [...eventNames].toSorted(),
},
{ staleTime: 5 * 60 * 1000 },
{ integrationId: integrationIds[0] ?? "", websiteId, timeFrame, eventNames: [...eventNames].toSorted() },
umamiQueryOptions,
);
if (eventNames.length === 0) {
Loaded 100 of 517 files, more files were not shown because too many files have changed in this diff. Show more