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:
517 files changed
+30974
-14645
No files matched your search
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
};
|
||||
@@ -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 />;
|
||||
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
))
|
||||
|
||||
@@ -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"));
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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] });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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(
|
||||
() =>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"));
|
||||
@@ -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
Reference in new issue
Block a user