Replace entire codebase with homarr-labs/homarr

This commit is contained in:
Thomas Camlong
2026-01-15 21:54:44 +01:00
parent c5bc3b1559
commit 4fdd1fe351
4666 changed files with 409577 additions and 147434 deletions

View File

@@ -0,0 +1,29 @@
import type { WidgetKind } from "@homarr/definitions";
import { useScopedI18n } from "@homarr/translation/client";
import type { WidgetOptionOfType, WidgetOptionType } from "../options";
export interface CommonWidgetInputProps<TKey extends WidgetOptionType> {
kind: WidgetKind;
property: string;
options: Omit<WidgetOptionOfType<TKey>, "defaultValue" | "type">;
initialOptions: Record<string, unknown>;
}
type UseWidgetInputTranslationReturnType = (key: "label" | "description") => string;
/**
* Short description why as and unknown convertions are used below:
* Typescript was not smart enought to work with the generic of the WidgetKind to only allow properties that are relying within that specified kind.
* This does not mean, that the function useWidgetInputTranslation can be called with invalid arguments without type errors and rather means that the above widget.<kind>.option.<property> string
* is not recognized as valid argument for the scoped i18n hook. Because the typesafety should remain outside the usage of those methods I (Meierschlumpf) decided to provide this fully typesafe useWidgetInputTranslation method.
*
* Some notes about it:
* - The label translation can be used for every input, especially considering that all options should have defined a label for themself. The description translation should only be used when withDescription
* is defined for the option. The method does sadly not reconize issues with those definitions. So it does not yell at you when you somewhere show the label without having it defined in the translations.
*/
export const useWidgetInputTranslation = (kind: WidgetKind, property: string): UseWidgetInputTranslationReturnType => {
return useScopedI18n(
`widget.${kind}.option.${property}` as never, // Because the type is complex and not recognized by typescript, we need to cast it to never to make it work.
) as unknown as UseWidgetInputTranslationReturnType;
};

View File

@@ -0,0 +1,8 @@
"use client";
import { createFormContext } from "@homarr/form";
import type { WidgetEditModalState } from "../modals/widget-edit-modal";
export const [FormProvider, useFormContext, useForm] =
createFormContext<Omit<WidgetEditModalState, "advancedOptions">>();

View File

@@ -0,0 +1,30 @@
import type { WidgetOptionType } from "../options";
import { WidgetAppInput } from "./widget-app-input";
import { WidgetLocationInput } from "./widget-location-input";
import { WidgetMultiTextInput } from "./widget-multi-text-input";
import { WidgetMultiReleasesRepositoriesInput } from "./widget-multiReleasesRepositories-input";
import { WidgetMultiSelectInput } from "./widget-multiselect-input";
import { WidgetNumberInput } from "./widget-number-input";
import { WidgetSelectInput } from "./widget-select-input";
import { WidgetSliderInput } from "./widget-slider-input";
import { WidgetSortedItemListInput } from "./widget-sortable-item-list-input";
import { WidgetSwitchInput } from "./widget-switch-input";
import { WidgetTextInput } from "./widget-text-input";
const mapping = {
text: WidgetTextInput,
location: WidgetLocationInput,
multiSelect: WidgetMultiSelectInput,
multiText: WidgetMultiTextInput,
number: WidgetNumberInput,
select: WidgetSelectInput,
slider: WidgetSliderInput,
switch: WidgetSwitchInput,
app: WidgetAppInput,
sortableItemList: WidgetSortedItemListInput,
multiReleasesRepositories: WidgetMultiReleasesRepositoriesInput,
} satisfies Record<WidgetOptionType, unknown>;
export const getInputForType = <TType extends WidgetOptionType>(type: TType) => {
return mapping[type];
};

View File

@@ -0,0 +1,122 @@
"use client";
import { memo, useMemo } from "react";
import type { SelectProps } from "@mantine/core";
import { Anchor, Button, Group, Loader, Select, SimpleGrid, Text } from "@mantine/core";
import { IconCheck, IconRocket } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import { useModalAction } from "@homarr/modals";
import { QuickAddAppModal } from "@homarr/modals-collection";
import { useI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui";
import type { CommonWidgetInputProps } from "./common";
import { useWidgetInputTranslation } from "./common";
import { useFormContext } from "./form";
export const WidgetAppInput = ({ property, kind }: CommonWidgetInputProps<"app">) => {
const t = useI18n();
const tInput = useWidgetInputTranslation(kind, property);
const form = useFormContext();
const { data: apps, isPending, refetch } = clientApi.app.selectable.useQuery();
const { data: session } = useSession();
const canCreateApps = session?.user.permissions.includes("app-create") ?? false;
const { openModal } = useModalAction(QuickAddAppModal);
const currentApp = useMemo(
() => apps?.find((app) => app.id === form.values.options.appId),
[apps, form.values.options.appId],
);
return (
<SimpleGrid cols={{ base: 1, md: canCreateApps ? 2 : 1 }} spacing={{ base: "md" }} style={{ alignItems: "center" }}>
<Select
label={tInput("label")}
searchable
leftSection={<MemoizedLeftSection isPending={isPending} currentApp={currentApp} />}
nothingFoundMessage={t("widget.common.app.noData")}
renderOption={renderSelectOption}
data={
apps?.map((app) => ({
label: app.name,
value: app.id,
iconUrl: app.iconUrl,
})) ?? []
}
inputWrapperOrder={["label", "input", "description", "error"]}
description={
<Text size="xs">
{t.rich("widget.common.app.description", {
here: () => (
<Anchor size="xs" component={Link} target="_blank" href="/manage/apps/new">
{t("common.here")}
</Anchor>
),
})}
</Text>
}
styles={{ root: { flex: "1" } }}
{...form.getInputProps(`options.${property}`)}
/>
{canCreateApps && (
<Button
mt={3}
rightSection={<IconRocket size="1.5rem" />}
variant="default"
onClick={() =>
openModal({
onClose(createdAppId) {
void refetch().then(() => {
form.setFieldValue(`options.${property}`, createdAppId);
});
},
})
}
>
{t("widget.common.app.quickCreate")}
</Button>
)}
</SimpleGrid>
);
};
const iconProps = {
stroke: 1.5,
color: "currentColor",
opacity: 0.6,
size: 18,
};
const renderSelectOption: SelectProps["renderOption"] = ({ option, checked }) => (
<Group flex="1" gap="xs">
{"iconUrl" in option && typeof option.iconUrl === "string" ? (
<img width={20} height={20} src={option.iconUrl} alt={option.label} />
) : null}
{option.label}
{checked && <IconCheck style={{ marginInlineStart: "auto" }} {...iconProps} />}
</Group>
);
interface LeftSectionProps {
isPending: boolean;
currentApp: RouterOutputs["app"]["selectable"][number] | undefined;
}
const size = 20;
const LeftSection = ({ isPending, currentApp }: LeftSectionProps) => {
if (isPending) {
return <Loader size={size} />;
}
if (currentApp) {
return <img width={size} height={size} src={currentApp.iconUrl} alt={currentApp.name} />;
}
return null;
};
const MemoizedLeftSection = memo(LeftSection);

View File

@@ -0,0 +1,237 @@
"use client";
import { useCallback } from "react";
import {
ActionIcon,
Alert,
Anchor,
Button,
Fieldset,
Group,
Loader,
NumberInput,
Stack,
Table,
Text,
TextInput,
Tooltip,
} from "@mantine/core";
import { IconClick, IconListSearch } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { createModal, useModalAction } from "@homarr/modals";
import { useScopedI18n } from "@homarr/translation/client";
import type { OptionLocation } from "../options";
import type { CommonWidgetInputProps } from "./common";
import { useWidgetInputTranslation } from "./common";
import { useFormContext } from "./form";
export const WidgetLocationInput = ({ property, kind }: CommonWidgetInputProps<"location">) => {
const t = useWidgetInputTranslation(kind, property);
const tLocation = useScopedI18n("widget.common.location");
const form = useFormContext();
const { openModal } = useModalAction(LocationSearchModal);
const inputProps = form.getInputProps(`options.${property}`);
const value = inputProps.value as OptionLocation;
const selectionEnabled = value.name.length > 1;
const handleChange = inputProps.onChange as LocationOnChange;
const unknownLocation = tLocation("unknownLocation");
const onLocationSelect = useCallback(
(location: OptionLocation) => {
handleChange(location);
form.clearFieldError(`options.${property}.latitude`);
form.clearFieldError(`options.${property}.longitude`);
},
[form, handleChange, property],
);
const onSearch = useCallback(() => {
if (!selectionEnabled) return;
openModal({
query: value.name,
onLocationSelect,
});
}, [selectionEnabled, value.name, onLocationSelect, openModal]);
form.watch(`options.${property}.latitude`, ({ value }) => {
if (typeof value !== "number") return;
form.setFieldValue(`options.${property}.name`, unknownLocation);
});
form.watch(`options.${property}.longitude`, ({ value }) => {
if (typeof value !== "number") return;
form.setFieldValue(`options.${property}.name`, unknownLocation);
});
return (
<Fieldset legend={t("label")}>
<Stack gap="xs">
<Group wrap="nowrap" align="end">
<TextInput w="100%" label={tLocation("query")} {...form.getInputProps(`options.${property}.name`)} />
<Tooltip hidden={selectionEnabled} label={tLocation("disabledTooltip")}>
<div>
<Button
disabled={!selectionEnabled}
onClick={onSearch}
variant="light"
leftSection={<IconListSearch size={16} />}
>
{tLocation("search")}
</Button>
</div>
</Tooltip>
</Group>
<Group grow>
<NumberInput
decimalScale={5}
label={tLocation("latitude")}
hideControls
{...form.getInputProps(`options.${property}.latitude`)}
/>
<NumberInput
decimalScale={5}
label={tLocation("longitude")}
hideControls
{...form.getInputProps(`options.${property}.longitude`)}
/>
</Group>
</Stack>
</Fieldset>
);
};
type LocationOnChange = (
location: Pick<OptionLocation, "name"> & {
latitude: OptionLocation["latitude"] | "";
longitude: OptionLocation["longitude"] | "";
},
) => void;
interface LocationSearchInnerProps {
query: string;
onLocationSelect: (location: OptionLocation) => void;
}
const LocationSearchModal = createModal<LocationSearchInnerProps>(({ actions, innerProps }) => {
const t = useScopedI18n("widget.common.location.table");
const tCommon = useScopedI18n("common");
const { data, isPending, error } = clientApi.location.searchCity.useQuery({
query: innerProps.query,
});
if (error) {
return (
<Alert title={tCommon("error")} color="red">
{error.message}
</Alert>
);
}
return (
<Stack>
<Table striped>
<Table.Thead>
<Table.Tr>
<Table.Th style={{ width: "70%" }}>{t("header.city")}</Table.Th>
<Table.Th style={{ width: "50%" }}>{t("header.country")}</Table.Th>
<Table.Th>{t("header.coordinates")}</Table.Th>
<Table.Th>{t("header.population")}</Table.Th>
<Table.Th style={{ width: 40 }} />
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{isPending && (
<Table.Tr>
<Table.Td colSpan={5}>
<Group justify="center">
<Loader />
</Group>
</Table.Td>
</Table.Tr>
)}
{data?.results.map((city) => (
<LocationSelectTableRow
key={city.id}
city={city}
onLocationSelect={innerProps.onLocationSelect}
closeModal={actions.closeModal}
/>
))}
</Table.Tbody>
</Table>
<Group justify="right">
<Button variant="light" onClick={actions.closeModal}>
{tCommon("action.cancel")}
</Button>
</Group>
</Stack>
);
}).withOptions({
defaultTitle(t) {
return t("widget.common.location.search");
},
size: "xl",
});
interface LocationSearchTableRowProps {
city: RouterOutputs["location"]["searchCity"]["results"][number];
onLocationSelect: (location: OptionLocation) => void;
closeModal: () => void;
}
const LocationSelectTableRow = ({ city, onLocationSelect, closeModal }: LocationSearchTableRowProps) => {
const t = useScopedI18n("widget.common.location.table");
const onSelect = useCallback(() => {
onLocationSelect({
name: city.name,
latitude: city.latitude,
longitude: city.longitude,
});
closeModal();
}, [city, onLocationSelect, closeModal]);
const formatter = Intl.NumberFormat("en", { notation: "compact" });
return (
<Table.Tr>
<Table.Td>
<Text style={{ whiteSpace: "nowrap" }}>{city.name}</Text>
</Table.Td>
<Table.Td>
<Text style={{ whiteSpace: "nowrap" }}>{city.country}</Text>
</Table.Td>
<Table.Td>
<Anchor target="_blank" href={`https://www.google.com/maps/place/${city.latitude},${city.longitude}`}>
<Text style={{ whiteSpace: "nowrap" }}>
{city.latitude}, {city.longitude}
</Text>
</Anchor>
</Table.Td>
<Table.Td>
{city.population ? (
<Text style={{ whiteSpace: "nowrap" }}>{formatter.format(city.population)}</Text>
) : (
<Text c="gray"> {t("population.fallback")}</Text>
)}
</Table.Td>
<Table.Td>
<Tooltip
label={t("action.select", {
city: city.name,
countryCode: city.country_code ?? "??",
})}
>
<ActionIcon color="red" variant="subtle" onClick={onSelect}>
<IconClick size={16} />
</ActionIcon>
</Tooltip>
</Table.Td>
</Table.Tr>
);
};

View File

@@ -0,0 +1,108 @@
import React, { useState } from "react";
import { Combobox, Pill, PillsInput, useCombobox } from "@mantine/core";
import { useScopedI18n } from "@homarr/translation/client";
import type { CommonWidgetInputProps } from "./common";
import { useWidgetInputTranslation } from "./common";
import { useFormContext } from "./form";
export const WidgetMultiTextInput = ({ property, kind, options }: CommonWidgetInputProps<"multiText">) => {
const t = useWidgetInputTranslation(kind, property);
const tCommon = useScopedI18n("common");
const combobox = useCombobox({
onDropdownClose: () => combobox.resetSelectedOption(),
onDropdownOpen: () => combobox.updateSelectedOptionIndex("active"),
});
const [search, setSearch] = useState("");
const form = useFormContext();
const inputProps = form.getInputProps(`options.${property}`);
const values = inputProps.value as string[];
const onChange = inputProps.onChange as (values: string[]) => void;
const handleRemove = (optionIndex: number) => {
onChange(values.filter((_, index) => index !== optionIndex));
};
const currentValidationResult = React.useMemo(() => {
if (!options.validate) {
return {
success: false,
result: null,
};
}
const validationResult = options.validate.safeParse(search);
return {
success: validationResult.success,
result: validationResult,
};
}, [options.validate, search]);
const error = React.useMemo(() => {
/* hide the error when nothing is being typed since "" is not valid but is not an explicit error */
if (!currentValidationResult.success && currentValidationResult.result && search.length !== 0) {
return currentValidationResult.result.error?.issues[0]?.message;
}
return null;
}, [currentValidationResult, search]);
const handleAddSearch = () => {
if (search.length === 0 || !currentValidationResult.success) {
return;
}
if (values.includes(search)) {
return;
}
onChange([...values, search]);
setSearch("");
};
return (
<Combobox store={combobox}>
<Combobox.DropdownTarget>
<PillsInput
label={t("label")}
description={options.withDescription ? t("description") : undefined}
onClick={() => combobox.openDropdown()}
error={error}
>
<Pill.Group>
{values.map((option, index) => (
<Pill key={option} onRemove={() => handleRemove(index)} withRemoveButton>
{option}
</Pill>
))}
<Combobox.EventsTarget>
<PillsInput.Field
onFocus={() => combobox.openDropdown()}
onBlur={() => {
handleAddSearch();
combobox.closeDropdown();
}}
value={search}
placeholder={tCommon("multiText.placeholder")}
onChange={(event) => {
combobox.updateSelectedOptionIndex();
setSearch(event.currentTarget.value);
}}
onKeyDown={(event) => {
if (event.key === "Backspace" && search.length === 0) {
event.preventDefault();
onChange(values.slice(0, -1));
} else if (event.key === "Enter") {
event.preventDefault();
handleAddSearch();
}
}}
/>
</Combobox.EventsTarget>
</Pill.Group>
</PillsInput>
</Combobox.DropdownTarget>
</Combobox>
);
};

View File

@@ -0,0 +1,820 @@
"use client";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import {
Accordion,
ActionIcon,
Button,
Checkbox,
Code,
Divider,
Fieldset,
Group,
Image,
Loader,
Select,
Stack,
Text,
TextInput,
Title,
Tooltip,
} from "@mantine/core";
import type { FormErrors } from "@mantine/form";
import { useDebouncedValue } from "@mantine/hooks";
import {
IconAlertTriangleFilled,
IconBrandDocker,
IconCopy,
IconCopyCheckFilled,
IconEdit,
IconPackageImport,
IconPlus,
IconTrash,
IconTriangleFilled,
IconZoomScan,
} from "@tabler/icons-react";
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 { findBestIconMatch, IconPicker } from "@homarr/forms-collection";
import { createModal, useModalAction } from "@homarr/modals";
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";
interface FormValidation {
hasErrors: boolean;
errors: FormErrors;
}
interface Integration extends IntegrationSelectOption {
iconUrl: string;
}
export const WidgetMultiReleasesRepositoriesInput = ({
property,
kind,
}: CommonWidgetInputProps<"multiReleasesRepositories">) => {
const t = useWidgetInputTranslation(kind, property);
const tRepository = useScopedI18n("widget.releases.option.repositories");
const form = useFormContext();
const repositories = form.values.options[property] as ReleasesRepository[];
const { openModal: openEditModal } = useModalAction(RepositoryEditModal);
const { openModal: openImportModal } = useModalAction(RepositoryImportModal);
const versionFilterPrecisionOptions = useMemo(
() => [tRepository("versionFilter.precision.options.none"), "#", "#.#", "#.#.#", "#.#.#.#", "#.#.#.#.#"],
[tRepository],
);
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 onRepositorySave = useCallback(
(repository: ReleasesRepository, index: number): FormValidation => {
form.setFieldValue(`options.${property}.${index}.providerIntegrationId`, repository.providerIntegrationId);
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);
const formValidation = form.validate();
const fieldErrors: FormErrors = Object.entries(formValidation.errors).reduce((acc, [key, value]) => {
if (key.startsWith(`options.${property}.${index}.`)) {
acc[key] = value;
}
return acc;
}, {} as FormErrors);
return {
hasErrors: Object.keys(fieldErrors).length > 0,
errors: fieldErrors,
};
},
[form, property],
);
const addNewRepository = () => {
const repository: ReleasesRepository = {
id: createId(),
identifier: "",
};
form.setValues((previous) => {
const previousValues = previous.options?.[property] as ReleasesRepository[];
return {
...previous,
options: {
...previous.options,
[property]: [...previousValues, repository],
},
};
});
const index = repositories.length;
openEditModal({
fieldPath: `options.${property}.${index}`,
repository,
onRepositorySave: (saved) => onRepositorySave(saved, index),
onRepositoryCancel: () => onRepositoryRemove(index),
versionFilterPrecisionOptions,
integrations,
});
};
const onRepositoryRemove = (index: number) => {
form.setValues((previous) => {
const previousValues = previous.options?.[property] as ReleasesRepository[];
return {
...previous,
options: {
...previous.options,
[property]: previousValues.filter((_, i) => i !== index),
},
};
});
};
return (
<Fieldset legend={t("label")}>
<Stack gap="5">
<Group grow>
<Button leftSection={<IconPlus />} onClick={addNewRepository}>
{tRepository("addRepository.label")}
</Button>
<Tooltip label={tRepository("importRepositories.onlyAdminCanImport")} disabled={isAdmin} withArrow>
<Button
disabled={!isAdmin}
leftSection={<IconBrandDocker stroke={1.25} />}
onClick={() =>
openImportModal({
repositories,
integrations,
versionFilterPrecisionOptions,
onConfirm: (selectedRepositories) => {
if (!selectedRepositories.length) return;
form.setValues((previous) => {
const previousValues = previous.options?.[property] as ReleasesRepository[];
return {
...previous,
options: {
...previous.options,
[property]: [...previousValues, ...selectedRepositories],
},
};
});
},
isAdmin,
})
}
>
{tRepository("importRepositories.label")}
</Button>
</Tooltip>
</Group>
<Divider my="sm" />
{repositories.map((repository, index) => {
const integration = repository.providerIntegrationId
? integrations[repository.providerIntegrationId]
: undefined;
return (
<Stack key={repository.id} gap={5}>
<Group align="center" gap="xs">
<Image
src={repository.iconUrl ?? integration?.iconUrl ?? null}
style={{
height: "1.2em",
width: "1.2em",
}}
/>
<Text c="dimmed" fw={100} size="xs">
{integration?.name ?? ""}
</Text>
<Group justify="space-between" align="center" style={{ flex: 1 }} gap={5}>
<Text size="sm" style={{ flex: 1, whiteSpace: "nowrap" }}>
{/* eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing */}
{repository.name || repository.identifier}
</Text>
</Group>
<Button
onClick={() =>
openEditModal({
fieldPath: `options.${property}.${index}`,
repository,
onRepositorySave: (saved) => onRepositorySave(saved, index),
versionFilterPrecisionOptions,
integrations,
})
}
variant="light"
leftSection={<IconEdit size={15} />}
size="xs"
>
{tRepository("edit.label")}
</Button>
<ActionIcon variant="transparent" color="red" onClick={() => onRepositoryRemove(index)}>
<IconTrash size={15} />
</ActionIcon>
</Group>
{Object.keys(form.errors).filter((key) => key.startsWith(`options.${property}.${index}.`)).length > 0 && (
<Group align="center" justify="center" gap="xs" bg="red.1">
<IconTriangleFilled size={15} color="var(--mantine-color-red-filled)" />
<Text size="sm" c="red">
{tRepository("invalid")}
</Text>
</Group>
)}
<Divider my="sm" size="xs" mt={5} mb={5} />
</Stack>
);
})}
</Stack>
</Fieldset>
);
};
const formatVersionFilterRegex = (versionFilter: ReleasesVersionFilter | undefined) => {
if (!versionFilter) return undefined;
const escapedPrefix = versionFilter.prefix ? escapeForRegEx(versionFilter.prefix) : "";
const precision = "[0-9]+\\.".repeat(versionFilter.precision).slice(0, -2);
const escapedSuffix = versionFilter.suffix ? escapeForRegEx(versionFilter.suffix) : "";
return `^${escapedPrefix}${precision}${escapedSuffix}$`;
};
const formatIdentifierName = (identifier: string) => {
const unformattedName = identifier.split("/").pop();
return unformattedName?.replace(/[-_]/g, " ").replace(/(?:^\w|[A-Z]|\b\w)/g, (char) => char.toUpperCase()) ?? "";
};
interface RepositoryEditProps {
fieldPath: string;
repository: ReleasesRepository;
onRepositorySave: (repository: ReleasesRepository) => FormValidation;
onRepositoryCancel?: () => void;
versionFilterPrecisionOptions: string[];
integrations: Record<string, Integration>;
}
const RepositoryEditModal = createModal<RepositoryEditProps>(({ innerProps, actions }) => {
const tRepository = useScopedI18n("widget.releases.option.repositories");
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);
// Debounce the name value with 200ms delay
const [debouncedName] = useDebouncedValue(tempRepository.name, 800);
const handleConfirm = useCallback(() => {
setLoading(true);
const validation = innerProps.onRepositorySave(tempRepository);
setFormErrors(validation.errors);
if (!validation.hasErrors) {
actions.closeModal();
}
setLoading(false);
}, [innerProps, tempRepository, actions]);
const handleCancel = useCallback(() => {
if (innerProps.onRepositoryCancel) {
innerProps.onRepositoryCancel();
}
actions.closeModal();
}, [innerProps, actions]);
const handleChange = useCallback((changedValue: Partial<ReleasesRepository>) => {
setTempRepository((prev) => ({ ...prev, ...changedValue }));
}, []);
// Auto-select icon based on identifier formatted name with debounced search
const { data: iconsData } = clientApi.icon.findIcons.useQuery(
{
searchText: debouncedName,
},
{
enabled: autoSetIcon && (debouncedName?.length ?? 0) > 3,
},
);
useEffect(() => {
if (autoSetIcon && debouncedName && !tempRepository.iconUrl && iconsData?.icons) {
const bestMatch = findBestIconMatch(debouncedName, iconsData.icons);
if (bestMatch) {
handleChange({ iconUrl: bestMatch });
}
}
}, [debouncedName, iconsData, tempRepository, handleChange, autoSetIcon]);
return (
<Stack>
<Group align="start" wrap="nowrap" grow preventGrowOverflow={false}>
<div style={{ flex: 0.3 }}>
<WidgetIntegrationSelect
canSelectMultiple={false}
withAsterisk
label={tRepository("provider.label")}
data={integrationSelectOptions}
value={tempRepository.providerIntegrationId ? [tempRepository.providerIntegrationId] : []}
error={formErrors[`${innerProps.fieldPath}.providerIntegrationId`] as string}
onChange={(value) => {
handleChange({ providerIntegrationId: value.length > 0 ? value.pop() : undefined });
}}
/>
</div>
<TextInput
withAsterisk
label={tRepository("identifier.label")}
value={tempRepository.identifier}
onChange={(event) => {
const name =
tempRepository.name === undefined ||
formatIdentifierName(tempRepository.identifier) === tempRepository.name
? formatIdentifierName(event.currentTarget.value)
: tempRepository.name;
handleChange({
identifier: event.currentTarget.value,
name,
});
if (event.currentTarget.value) setAutoSetIcon(true);
}}
error={formErrors[`${innerProps.fieldPath}.identifier`]}
style={{ flex: 0.7 }}
/>
</Group>
<Group align="center" wrap="nowrap" grow preventGrowOverflow={false}>
<TextInput
label={tRepository("name.label")}
value={tempRepository.name ?? ""}
onChange={(event) => {
handleChange({ name: event.currentTarget.value });
if (event.currentTarget.value) setAutoSetIcon(true);
}}
error={formErrors[`${innerProps.fieldPath}.name`]}
style={{ flex: 0.3 }}
/>
<div style={{ flex: 0.7 }}>
<IconPicker
withAsterisk={false}
value={tempRepository.iconUrl ?? ""}
onChange={(url) => {
if (url === "") {
setAutoSetIcon(false);
handleChange({ iconUrl: undefined });
} else {
handleChange({ iconUrl: url });
}
}}
error={formErrors[`${innerProps.fieldPath}.iconUrl`] as string}
/>
</div>
</Group>
<Fieldset legend={tRepository("versionFilter.label")}>
<Group justify="stretch" align="center" grow>
<TextInput
label={tRepository("versionFilter.prefix.label")}
value={tempRepository.versionFilter?.prefix ?? ""}
onChange={(event) => {
handleChange({
versionFilter: {
...(tempRepository.versionFilter ?? { precision: 0 }),
prefix: event.currentTarget.value,
},
});
}}
error={formErrors[`${innerProps.fieldPath}.versionFilter.prefix`]}
disabled={!tempRepository.versionFilter}
/>
<Select
label={tRepository("versionFilter.precision.label")}
data={Object.entries(innerProps.versionFilterPrecisionOptions).map(([key, value]) => ({
value: key,
label: value,
}))}
value={tempRepository.versionFilter?.precision.toString() ?? "0"}
onChange={(value) => {
const precision = value ? parseInt(value) : 0;
handleChange({
versionFilter:
isNaN(precision) || precision <= 0
? undefined
: {
...(tempRepository.versionFilter ?? {}),
precision,
},
});
}}
error={formErrors[`${innerProps.fieldPath}.versionFilter.precision`]}
/>
<TextInput
label={tRepository("versionFilter.suffix.label")}
value={tempRepository.versionFilter?.suffix ?? ""}
onChange={(event) => {
handleChange({
versionFilter: {
...(tempRepository.versionFilter ?? { precision: 0 }),
suffix: event.currentTarget.value,
},
});
}}
error={formErrors[`${innerProps.fieldPath}.versionFilter.suffix`]}
disabled={!tempRepository.versionFilter}
/>
</Group>
<Text size="xs" c="dimmed">
{tRepository("versionFilter.regex.label")}:{" "}
{formatVersionFilterRegex(tempRepository.versionFilter) ??
tRepository("versionFilter.precision.options.none")}
</Text>
</Fieldset>
<Divider my={"sm"} />
<Group justify="flex-end">
<Button variant="default" onClick={handleCancel} color="gray.5">
{tRepository("editForm.cancel.label")}
</Button>
<Button data-autofocus onClick={handleConfirm} loading={loading}>
{tRepository("editForm.confirm.label")}
</Button>
</Group>
</Stack>
);
}).withOptions({
defaultTitle(t) {
return t("widget.releases.option.repositories.editForm.title");
},
size: "xl",
});
interface ReleasesRepositoryImport extends ReleasesRepository {
alreadyImported: boolean;
}
interface ImportRepositorySelectProps {
repository: ReleasesRepositoryImport;
checked: boolean;
integration?: Integration;
versionFilterPrecisionOptions: string[];
disabled: boolean;
onImageSelectionChanged?: (isSelected: boolean) => void;
}
const ImportRepositorySelect = ({
repository,
checked,
integration,
versionFilterPrecisionOptions,
disabled = false,
onImageSelectionChanged = undefined,
}: ImportRepositorySelectProps) => {
const tRepository = useScopedI18n("widget.releases.option.repositories");
return (
<Group gap="xl" justify="space-between">
<Group gap="md" align="center">
<Checkbox
checked={checked}
disabled={disabled}
readOnly={disabled}
onChange={() => {
if (onImageSelectionChanged) {
onImageSelectionChanged(!checked);
}
}}
label={
<Group align="center">
<Image
src={repository.iconUrl}
style={{
height: "1.2em",
width: "1.2em",
}}
/>
<Text>{repository.identifier}</Text>
</Group>
}
/>
{repository.versionFilter && (
<Group gap={5}>
<Text c="dimmed" size="xs">
{tRepository("versionFilter.label")}:
</Text>
<Code>{repository.versionFilter.prefix && repository.versionFilter.prefix}</Code>
<Code color="var(--mantine-primary-color-light)" fw={700}>
{versionFilterPrecisionOptions[repository.versionFilter.precision]}
</Code>
<Code>{repository.versionFilter.suffix && repository.versionFilter.suffix}</Code>
</Group>
)}
</Group>
<Tooltip label={tRepository("noProvider.tooltip")} disabled={integration !== undefined} withArrow>
<Group>
{integration ? (
<MaskedImage
color="dimmed"
imageUrl={integration.iconUrl}
style={{
height: "1em",
width: "1em",
}}
/>
) : (
<IconAlertTriangleFilled />
)}
<Text ff="monospace" c="dimmed" size="sm">
{integration?.name ?? tRepository("noProvider.label")}
</Text>
</Group>
</Tooltip>
</Group>
);
};
interface RepositoryImportProps {
repositories: ReleasesRepository[];
integrations: Record<string, Integration>;
versionFilterPrecisionOptions: string[];
onConfirm: (selectedRepositories: ReleasesRepositoryImport[]) => void;
isAdmin: boolean;
}
const RepositoryImportModal = createModal<RepositoryImportProps>(({ innerProps, actions }) => {
const tRepository = useScopedI18n("widget.releases.option.repositories");
const [loading, setLoading] = useState(false);
const [selectedImages, setSelectedImages] = useState([] as ReleasesRepositoryImport[]);
const docker = clientApi.docker.getContainers.useQuery(undefined, {
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
enabled: innerProps.isAdmin,
});
const importRepositories: ReleasesRepositoryImport[] = useMemo(
() =>
docker.data?.containers.reduce<ReleasesRepositoryImport[]>((acc, container) => {
const [maybeSource, maybeIdentifierAndVersion] = container.image.split(/\/(.*)/);
const hasSource = maybeSource && maybeSource in containerImageToProviderKind;
const source = hasSource ? maybeSource : "docker.io";
const [identifier, version] =
hasSource && maybeIdentifierAndVersion ? maybeIdentifierAndVersion.split(":") : container.image.split(":");
if (!identifier) return acc;
const providerKind = containerImageToProviderKind[source] ?? "dockerHub";
const integrationId = Object.values(innerProps.integrations).find(
(integration) => integration.kind === providerKind,
)?.id;
if (acc.some((item) => item.providerIntegrationId === integrationId && item.identifier === identifier))
return acc;
acc.push({
id: createId(),
providerIntegrationId: integrationId,
identifier,
iconUrl: container.iconUrl ?? undefined,
name: formatIdentifierName(identifier),
versionFilter: version ? parseImageVersionToVersionFilter(version) : undefined,
alreadyImported: innerProps.repositories.some(
(item) => item.providerIntegrationId === integrationId && item.identifier === identifier,
),
});
return acc;
}, []) ?? [],
[docker.data, innerProps.repositories, innerProps.integrations],
);
const handleConfirm = useCallback(() => {
setLoading(true);
innerProps.onConfirm(selectedImages);
setLoading(false);
actions.closeModal();
}, [innerProps, selectedImages, actions]);
const allImagesImported = useMemo(
() => importRepositories.every((repository) => repository.alreadyImported),
[importRepositories],
);
const anyImagesImported = useMemo(
() => importRepositories.some((repository) => repository.alreadyImported),
[importRepositories],
);
return (
<Stack>
{docker.isPending ? (
<Stack justify="center" align="center">
<Loader size="xl" />
<Title order={3}>{tRepository("importRepositories.loading")}</Title>
</Stack>
) : importRepositories.length === 0 ? (
<Stack justify="center" align="center">
<IconBrandDocker stroke={1} size={128} />
<Title order={3}>{tRepository("importRepositories.noImagesFound")}</Title>
</Stack>
) : (
<Stack>
<Accordion defaultValue={!allImagesImported ? "foundImages" : anyImagesImported ? "alreadyImported" : ""}>
<Accordion.Item value="foundImages">
<Accordion.Control disabled={allImagesImported} icon={<IconZoomScan />}>
<Group>
{tRepository("importRepositories.listFoundImages")}
{allImagesImported && (
<Text c="dimmed" size="sm">
{tRepository("importRepositories.allImagesAlreadyImported")}
</Text>
)}
</Group>
</Accordion.Control>
<Accordion.Panel>
{!allImagesImported && (
<Stack justify="center" gap="xs">
<Group>
<Button
leftSection={<IconCopyCheckFilled size="1em" />}
onClick={() =>
setSelectedImages(importRepositories.filter((repository) => !repository.alreadyImported))
}
size="xs"
>
{tRepository("importRepositories.selectAll")}
</Button>
<Button
leftSection={<IconCopy size="1em" />}
onClick={() => setSelectedImages([])}
size="xs"
variant="default"
color="gray.5"
>
{tRepository("importRepositories.deselectAll")}
</Button>
</Group>
<Divider />
{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))
}
/>
);
})}
</Stack>
)}
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value="alreadyImported">
<Accordion.Control disabled={!anyImagesImported} icon={<IconPackageImport />}>
{tRepository("importRepositories.listAlreadyImportedImages")}
</Accordion.Control>
<Accordion.Panel>
{anyImagesImported && (
<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
/>
);
})}
</Stack>
)}
</Accordion.Panel>
</Accordion.Item>
</Accordion>
</Stack>
)}
<Group justify="flex-end">
<Button variant="default" onClick={actions.closeModal} color="gray.5">
{tRepository("editForm.cancel.label")}
</Button>
<Button onClick={handleConfirm} loading={loading} disabled={selectedImages.length === 0}>
{tRepository("editForm.confirm.label")}
</Button>
</Group>
</Stack>
);
}).withOptions({
defaultTitle(t) {
return t("widget.releases.option.repositories.importForm.title");
},
size: "xl",
});
const containerImageToProviderKind: Record<string, IntegrationKind> = {
"ghcr.io": "github",
"docker.io": "dockerHub",
"lscr.io": "linuxServerIO",
"quay.io": "quay",
};
const parseImageVersionToVersionFilter = (imageVersion: string): ReleasesVersionFilter | undefined => {
const version = /(?<=\D|^)\d+(?:\.\d+)*(?![\d.])/.exec(imageVersion)?.[0];
if (!version) return undefined;
const [prefix, suffix] = imageVersion.split(version);
return {
prefix,
precision: version.split(".").length,
suffix,
};
};

View File

@@ -0,0 +1,33 @@
"use client";
import { MultiSelect } from "@mantine/core";
import { translateIfNecessary } from "@homarr/translation";
import { useI18n } from "@homarr/translation/client";
import type { CommonWidgetInputProps } from "./common";
import { useWidgetInputTranslation } from "./common";
import { useFormContext } from "./form";
export const WidgetMultiSelectInput = ({ property, kind, options }: CommonWidgetInputProps<"multiSelect">) => {
const t = useI18n();
const tWidget = useWidgetInputTranslation(kind, property);
const form = useFormContext();
return (
<MultiSelect
label={tWidget("label")}
data={options.options.map((option) =>
typeof option === "string"
? option
: {
value: option.value,
label: translateIfNecessary(t, option.label) ?? option.value,
},
)}
description={options.withDescription ? tWidget("description") : undefined}
searchable={options.searchable}
{...form.getInputProps(`options.${property}`)}
/>
);
};

View File

@@ -0,0 +1,23 @@
"use client";
import { NumberInput } from "@mantine/core";
import type { CommonWidgetInputProps } from "./common";
import { useWidgetInputTranslation } from "./common";
import { useFormContext } from "./form";
export const WidgetNumberInput = ({ property, kind, options }: CommonWidgetInputProps<"number">) => {
const t = useWidgetInputTranslation(kind, property);
const form = useFormContext();
return (
<NumberInput
label={t("label")}
description={options.withDescription ? t("description") : undefined}
min={options.validate.minValue ?? undefined}
max={options.validate.maxValue ?? undefined}
step={options.step}
{...form.getInputProps(`options.${property}`)}
/>
);
};

View File

@@ -0,0 +1,79 @@
"use client";
import { Group, Select } from "@mantine/core";
import { IconCheck } from "@tabler/icons-react";
import { translateIfNecessary } from "@homarr/translation";
import type { stringOrTranslation } from "@homarr/translation";
import { useI18n } from "@homarr/translation/client";
import type { TablerIcon } from "@homarr/ui";
import type { CommonWidgetInputProps } from "./common";
import { useWidgetInputTranslation } from "./common";
import { useFormContext } from "./form";
export type SelectOption =
| {
icon?: TablerIcon;
value: string;
label: stringOrTranslation;
}
| string;
export type inferSelectOptionValue<TOption extends SelectOption> = TOption extends {
value: infer TValue;
}
? TValue
: TOption;
const getIconFor = (options: SelectOption[], value: string) => {
const current = options.find((option) => (typeof option === "string" ? option : option.value) === value);
if (!current) return null;
if (typeof current === "string") return null;
return current.icon;
};
export const WidgetSelectInput = ({ property, kind, options }: CommonWidgetInputProps<"select">) => {
const t = useI18n();
const tWidget = useWidgetInputTranslation(kind, property);
const form = useFormContext();
const inputProps = form.getInputProps(`options.${property}`);
const CurrentIcon = getIconFor(options.options, inputProps.value as string);
return (
<Select
label={tWidget("label")}
data={options.options.map((option) =>
typeof option === "string"
? option
: {
value: option.value,
label: translateIfNecessary(t, option.label) ?? option.value,
},
)}
leftSection={CurrentIcon && <CurrentIcon size={16} stroke={1.5} />}
renderOption={({ option, checked }) => {
const Icon = getIconFor(options.options, option.value);
return (
<Group flex="1" gap="xs">
{Icon && <Icon color="currentColor" opacity={0.6} size={18} stroke={1.5} />}
{option.label}
{checked && (
<IconCheck
style={{ marginInlineStart: "auto" }}
color="currentColor"
opacity={0.6}
size={18}
stroke={1.5}
/>
)}
</Group>
);
}}
description={options.withDescription ? tWidget("description") : undefined}
searchable={options.searchable}
{...inputProps}
/>
);
};

View File

@@ -0,0 +1,27 @@
"use client";
import { InputWrapper, Slider } from "@mantine/core";
import type { CommonWidgetInputProps } from "./common";
import { useWidgetInputTranslation } from "./common";
import { useFormContext } from "./form";
export const WidgetSliderInput = ({ property, kind, options }: CommonWidgetInputProps<"slider">) => {
const t = useWidgetInputTranslation(kind, property);
const form = useFormContext();
return (
<InputWrapper
label={t("label")}
description={options.withDescription ? t("description") : undefined}
inputWrapperOrder={["label", "input", "description", "error"]}
>
<Slider
min={options.validate.minValue ?? undefined}
max={options.validate.maxValue ?? undefined}
step={options.step}
{...form.getInputProps(`options.${property}`)}
/>
</InputWrapper>
);
};

View File

@@ -0,0 +1,233 @@
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { UniqueIdentifier } from "@dnd-kit/core";
import {
closestCenter,
DndContext,
KeyboardSensor,
MouseSensor,
TouchSensor,
useSensor,
useSensors,
} from "@dnd-kit/core";
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import type { ActionIconProps } from "@mantine/core";
import { ActionIcon, Card, Center, Fieldset, Loader, Stack } from "@mantine/core";
import { IconGripHorizontal } from "@tabler/icons-react";
import { useWidgetInputTranslation } from "./common";
import type { CommonWidgetInputProps } from "./common";
import { useFormContext } from "./form";
export const WidgetSortedItemListInput = <TItem, TOptionValue extends UniqueIdentifier>({
property,
options,
initialOptions,
kind,
}: CommonWidgetInputProps<"sortableItemList">) => {
const t = useWidgetInputTranslation(kind, property);
const form = useFormContext();
const initialValues = useMemo(() => initialOptions[property] as TOptionValue[], [initialOptions, property]);
const values = form.values.options[property] as TOptionValue[];
const { data, isLoading, error } = options.useData(initialValues);
const dataMap = useMemo(
() => new Map(data?.map((item) => [options.uniqueIdentifier(item), item as TItem])),
[data, options],
);
const [tempMap, setTempMap] = useState<Map<TOptionValue, TItem>>(new Map());
const [activeId, setActiveId] = useState<TOptionValue | null>(null);
const sensors = useSensors(
useSensor(MouseSensor),
useSensor(TouchSensor),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
);
const isFirstAnnouncement = useRef(true);
const getIndex = (id: TOptionValue) => values.indexOf(id);
const activeIndex = activeId ? getIndex(activeId) : -1;
useEffect(() => {
if (!activeId) {
isFirstAnnouncement.current = true;
}
}, [activeId]);
const getItem = useCallback(
(id: TOptionValue) => {
if (!tempMap.has(id)) {
return dataMap.get(id);
}
return tempMap.get(id);
},
[tempMap, dataMap],
);
const updateItems = (callback: (prev: TOptionValue[]) => TOptionValue[]) => {
form.setFieldValue(`options.${property}`, callback);
};
const addItem = (item: TItem) => {
setTempMap((prev) => {
prev.set(options.uniqueIdentifier(item) as TOptionValue, item);
return prev;
});
updateItems((values) => [...values, options.uniqueIdentifier(item) as TOptionValue]);
};
return (
<Fieldset legend={t("label")}>
<Stack>
<options.addButton addItem={addItem} values={values} />
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={({ active }) => {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (!active) {
return;
}
setActiveId(active.id as TOptionValue);
}}
onDragEnd={({ over }) => {
setActiveId(null);
if (over) {
const overIndex = getIndex(over.id as TOptionValue);
if (activeIndex !== overIndex) {
updateItems((items) => arrayMove(items, activeIndex, overIndex));
}
}
}}
onDragCancel={() => setActiveId(null)}
>
<SortableContext items={values} strategy={verticalListSortingStrategy}>
<Stack gap="xs">
<React.Fragment>
{values.map((value, index) => {
const item = getItem(value);
const removeItem = () => {
form.setValues((previous) => {
const previousValues = previous.options?.[property] as TOptionValue[];
return {
...previous,
options: {
...previous.options,
[property]: previousValues.filter((id) => id !== value),
},
};
});
};
if (!item) {
return null;
}
return (
<MemoizedItem
key={value}
id={value}
index={index}
item={item}
removeItem={removeItem}
options={options}
/>
);
})}
{isLoading && (
<Center h={256}>
<Loader />
</Center>
)}
{error ? <Center h={256}>{JSON.stringify(error)}</Center> : null}
</React.Fragment>
</Stack>
</SortableContext>
</DndContext>
</Stack>
</Fieldset>
);
};
interface ItemProps<TItem, TOptionValue extends UniqueIdentifier> {
id: TOptionValue;
item: TItem;
index: number;
removeItem: () => void;
options: CommonWidgetInputProps<"sortableItemList">["options"];
}
const Item = <TItem, TOptionValue extends UniqueIdentifier>({
id,
index,
item,
removeItem,
options,
}: ItemProps<TItem, TOptionValue>) => {
const { attributes, isDragging, listeners, setNodeRef, setActivatorNodeRef, transform, transition } = useSortable({
id,
});
const Handle = (props: Partial<ActionIconProps>) => {
return (
<ActionIcon
variant="transparent"
color="gray"
{...props}
{...listeners}
ref={setActivatorNodeRef}
style={{ cursor: "grab" }}
>
<IconGripHorizontal />
</ActionIcon>
);
};
return (
<Card
withBorder
shadow="xs"
padding="sm"
radius="md"
style={
{
transition: [transition].filter(Boolean).join(", "),
"--translate-x": transform ? `${Math.round(transform.x)}px` : undefined,
"--translate-y": transform ? `${Math.round(transform.y)}px` : undefined,
"--scale-x": transform?.scaleX ? `${transform.scaleX}` : undefined,
"--scale-y": transform?.scaleY ? `${transform.scaleY}` : undefined,
"--index": index,
transform:
"translate3d(var(--translate-x, 0), var(--translate-y, 0), 0) scaleX(var(--scale-x, 1)) scaleY(var(--scale-y, 1))",
transformOrigin: "0 0",
...(isDragging
? {
opacity: "var(--dragging-opacity, 0.5)",
zIndex: 0,
}
: {}),
} as React.CSSProperties
}
ref={setNodeRef}
>
<options.itemComponent
key={index}
item={item}
removeItem={removeItem}
rootAttributes={attributes}
handle={Handle}
/>
</Card>
);
};
const MemoizedItem = memo(Item);

View File

@@ -0,0 +1,20 @@
"use client";
import { Switch } from "@mantine/core";
import type { CommonWidgetInputProps } from "./common";
import { useWidgetInputTranslation } from "./common";
import { useFormContext } from "./form";
export const WidgetSwitchInput = ({ property, kind, options }: CommonWidgetInputProps<"switch">) => {
const t = useWidgetInputTranslation(kind, property);
const form = useFormContext();
return (
<Switch
label={t("label")}
description={options.withDescription ? t("description") : undefined}
{...form.getInputProps(`options.${property}`, { type: "checkbox" })}
/>
);
};

View File

@@ -0,0 +1,20 @@
"use client";
import { TextInput } from "@mantine/core";
import type { CommonWidgetInputProps } from "./common";
import { useWidgetInputTranslation } from "./common";
import { useFormContext } from "./form";
export const WidgetTextInput = ({ property, kind, options }: CommonWidgetInputProps<"text">) => {
const t = useWidgetInputTranslation(kind, property);
const form = useFormContext();
return (
<TextInput
label={t("label")}
description={options.withDescription ? t("description") : undefined}
{...form.getInputProps(`options.${property}`)}
/>
);
};

View File

@@ -0,0 +1,17 @@
.appIcon {
max-height: 100%;
max-width: 100%;
overflow: auto;
flex: 1 !important;
object-fit: contain;
scale: 0.8;
transition: scale 0.2s ease-in-out;
}
.appWithUrl:hover > .appIcon {
scale: 0.9;
}
.appWithUrl:hover > div.appIcon {
background-color: var(--mantine-color-iconColor-filled-hover);
}

View File

@@ -0,0 +1,161 @@
"use client";
import type { PropsWithChildren } from "react";
import { Fragment, Suspense } from "react";
import { Flex, rem, Stack, Text, Tooltip, UnstyledButton } from "@mantine/core";
import { IconLoader } from "@tabler/icons-react";
import combineClasses from "clsx";
import { clientApi } from "@homarr/api/client";
import { useRequiredBoard } from "@homarr/boards/context";
import { useSettings } from "@homarr/settings";
import { useRegisterSpotlightContextResults } from "@homarr/spotlight";
import { useI18n } from "@homarr/translation/client";
import { MaskedOrNormalImage } from "@homarr/ui";
import type { WidgetComponentProps } from "../definition";
import classes from "./app.module.css";
import { PingDot } from "./ping/ping-dot";
import { PingIndicator } from "./ping/ping-indicator";
export default function AppWidget({ options, isEditMode, height, width }: WidgetComponentProps<"app">) {
const t = useI18n();
const settings = useSettings();
const board = useRequiredBoard();
const [app] = clientApi.app.byId.useSuspenseQuery(
{
id: options.appId,
},
{
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
retry: false,
},
);
useRegisterSpotlightContextResults(
`app-${app.id}`,
app.href
? [
{
id: app.id,
name: app.name,
icon: app.iconUrl,
interaction() {
return {
type: "link",
// We checked above that app.href is defined
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
href: app.href!,
newTab: options.openInNewTab,
};
},
},
]
: [],
[app, options.openInNewTab],
);
const isTiny = height < 100 || width < 100;
const isColumnLayout = options.layout.startsWith("column");
return (
<AppLink
href={app.href ?? undefined}
openInNewTab={options.openInNewTab}
enabled={Boolean(app.href) && !isEditMode}
>
<Tooltip.Floating
label={app.description?.split("\n").map((line, index) => (
<Fragment key={index}>
{line}
<br />
</Fragment>
))}
position="right-start"
multiline
disabled={options.descriptionDisplayMode !== "tooltip" || !app.description || isEditMode}
styles={{ tooltip: { maxWidth: 300 } }}
>
<Flex
p={isTiny ? 4 : "sm"}
className={combineClasses("app-flex-wrapper", app.name, app.id, app.href && classes.appWithUrl)}
h="100%"
w="100%"
direction={options.layout}
justify="center"
align="center"
gap={isColumnLayout ? 0 : "sm"}
>
<Stack gap={0}>
{options.showTitle && (
<Text
className="app-title"
fw={700}
size={isTiny ? rem(8) : "sm"}
ta={isColumnLayout ? "center" : undefined}
>
{app.name}
</Text>
)}
{options.descriptionDisplayMode === "normal" && (
<Text
className="app-description"
size={isTiny ? rem(8) : "sm"}
ta={isColumnLayout ? "center" : undefined}
c="dimmed"
lineClamp={4}
>
{app.description?.split("\n").map((line, index) => (
<Fragment key={index}>
{line}
<br />
</Fragment>
))}
</Text>
)}
</Stack>
<MaskedOrNormalImage
imageUrl={app.iconUrl}
hasColor={board.iconColor !== null}
alt={app.name}
className={combineClasses(classes.appIcon, "app-icon")}
style={{
height: "100%",
width: "100%",
minWidth: "20%",
maxWidth: isColumnLayout ? undefined : "50%",
}}
/>
</Flex>
</Tooltip.Floating>
{options.pingEnabled && !settings.forceDisableStatus && !board.disableStatus && app.href ? (
<Suspense fallback={<PingDot icon={IconLoader} color="blue" tooltip={`${t("common.action.loading")}`} />}>
<PingIndicator href={app.pingUrl ?? app.href} />
</Suspense>
) : null}
</AppLink>
);
}
interface AppLinkProps {
href: string | undefined;
openInNewTab: boolean;
enabled: boolean;
}
const AppLink = ({ href, openInNewTab, enabled, children }: PropsWithChildren<AppLinkProps>) =>
enabled ? (
<UnstyledButton
component="a"
href={href}
target={openInNewTab ? "_blank" : undefined}
rel="noreferrer"
h="100%"
w="100%"
>
{children}
</UnstyledButton>
) : (
children
);

View File

@@ -0,0 +1,104 @@
import {
IconApps,
IconDeviceDesktopX,
IconEyeOff,
IconLayoutBottombarExpand,
IconLayoutNavbarExpand,
IconLayoutSidebarLeftExpand,
IconLayoutSidebarRightExpand,
IconTextScan2,
IconTooltip,
} from "@tabler/icons-react";
import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("app", {
icon: IconApps,
createOptions(settings) {
return optionsBuilder.from(
(factory) => ({
appId: factory.app(),
openInNewTab: factory.switch({ defaultValue: true }),
showTitle: factory.switch({ defaultValue: true }),
descriptionDisplayMode: factory.select({
options: [
{
label(t) {
return t("widget.app.option.descriptionDisplayMode.option.normal");
},
value: "normal",
icon: IconTextScan2,
},
{
label(t) {
return t("widget.app.option.descriptionDisplayMode.option.tooltip");
},
value: "tooltip",
icon: IconTooltip,
},
{
label(t) {
return t("widget.app.option.descriptionDisplayMode.option.hidden");
},
value: "hidden",
icon: IconEyeOff,
},
],
defaultValue: "hidden",
searchable: true,
withDescription: true,
}),
layout: factory.select({
options: [
{
label(t) {
return t("widget.app.option.layout.option.column");
},
value: "column",
icon: IconLayoutNavbarExpand,
},
{
label(t) {
return t("widget.app.option.layout.option.column-reverse");
},
value: "column-reverse",
icon: IconLayoutBottombarExpand,
},
{
label(t) {
return t("widget.app.option.layout.option.row");
},
value: "row",
icon: IconLayoutSidebarLeftExpand,
},
{
label(t) {
return t("widget.app.option.layout.option.row-reverse");
},
value: "row-reverse",
icon: IconLayoutSidebarRightExpand,
},
],
defaultValue: "column",
searchable: true,
}),
pingEnabled: factory.switch({ defaultValue: settings.enableStatusByDefault }),
}),
{
pingEnabled: {
shouldHide() {
return settings.forceDisableStatus;
},
},
},
);
},
errors: {
NOT_FOUND: {
icon: IconDeviceDesktopX,
message: (t) => t("widget.app.error.notFound.label"),
hideLogsLink: true,
},
},
}).withDynamicImport(() => import("./component"));

View File

@@ -0,0 +1,34 @@
import type { MantineColor } from "@mantine/core";
import { Box, Tooltip } from "@mantine/core";
import { useSettings } from "@homarr/settings";
import type { TablerIcon } from "@homarr/ui";
interface PingDotProps {
icon: TablerIcon;
color: MantineColor;
tooltip: string;
}
export const PingDot = ({ color, tooltip, ...props }: PingDotProps) => {
const { pingIconsEnabled } = useSettings();
return (
<Box bottom={10} right={10} pos="absolute" display={"flex"}>
<Tooltip multiline label={tooltip} maw={350}>
{pingIconsEnabled ? (
<props.icon style={{ width: 12, height: 12 }} strokeWidth={4} color={color} />
) : (
<Box
bg={color}
style={{
borderRadius: "100%",
}}
w={10}
h={10}
></Box>
)}
</Tooltip>
</Box>
);
};

View File

@@ -0,0 +1,48 @@
import { useState } from "react";
import { IconCheck, IconX } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { PingDot } from "./ping-dot";
interface PingIndicatorProps {
href: string;
}
export const PingIndicator = ({ href }: PingIndicatorProps) => {
const [ping] = clientApi.widget.app.ping.useSuspenseQuery(
{
url: href,
},
{
refetchOnMount: false,
refetchOnWindowFocus: false,
},
);
const [pingResult, setPingResult] = useState<RouterOutputs["widget"]["app"]["ping"]>(ping);
clientApi.widget.app.updatedPing.useSubscription(
{ url: href },
{
onData(data) {
setPingResult(data);
},
},
);
const isError = "error" in pingResult || pingResult.statusCode >= 500;
return (
<PingDot
icon={isError ? IconX : IconCheck}
color={isError ? "red" : "green"}
tooltip={
"statusCode" in pingResult
? `${pingResult.statusCode} - ${pingResult.durationMs.toFixed(0)}ms`
: pingResult.error
}
/>
);
};

View File

@@ -0,0 +1,25 @@
import { trpc } from "@homarr/api/server";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { db, inArray } from "@homarr/db";
import { apps } from "@homarr/db/schema";
import type { Prefetch } from "../definition";
const logger = createLogger({ module: "appWidgetPrefetch" });
const prefetchAllAsync: Prefetch<"app"> = async (queryClient, items) => {
const appIds = items.map((item) => item.options.appId);
const distinctAppIds = [...new Set(appIds)];
const dbApps = await db.query.apps.findMany({
where: inArray(apps.id, distinctAppIds),
});
for (const app of dbApps) {
queryClient.setQueryData(trpc.app.byId.queryKey({ id: app.id }), app);
}
logger.info("Successfully prefetched apps for app widget", { count: dbApps.length });
};
export default prefetchAllAsync;

View File

@@ -0,0 +1,30 @@
"use client";
import { Button } from "@mantine/core";
import { useModalAction } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client";
import type { SortableItemListInput } from "../options";
import { AppSelectModal } from "./app-select-modal";
export const BookmarkAddButton: SortableItemListInput<
{
name: string;
description: string | null;
id: string;
iconUrl: string;
href: string | null;
pingUrl: string | null;
},
string
>["AddButton"] = ({ addItem, values }) => {
const { openModal } = useModalAction(AppSelectModal);
const t = useI18n();
return (
<Button onClick={() => openModal({ onSelect: addItem, presentAppIds: values })}>
{t("widget.bookmarks.option.items.add")}
</Button>
);
};

View File

@@ -0,0 +1,114 @@
"use client";
import { memo, useState } from "react";
import type { SelectProps } from "@mantine/core";
import { Button, Group, Loader, Select, Stack } from "@mantine/core";
import { IconCheck } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { useForm } from "@homarr/form";
import { createModal } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client";
interface InnerProps {
presentAppIds: string[];
onSelect: (props: RouterOutputs["app"]["selectable"][number]) => void | Promise<void>;
confirmLabel?: string;
}
interface AppSelectFormType {
id: string;
}
export const AppSelectModal = createModal<InnerProps>(({ actions, innerProps }) => {
const t = useI18n();
const { data: apps, isPending } = clientApi.app.selectable.useQuery();
const [loading, setLoading] = useState(false);
const form = useForm<AppSelectFormType>();
const handleSubmitAsync = async (values: AppSelectFormType) => {
const currentApp = apps?.find((app) => app.id === values.id);
if (!currentApp) return;
setLoading(true);
await innerProps.onSelect(currentApp);
setLoading(false);
actions.closeModal();
};
const confirmLabel = innerProps.confirmLabel ?? t("common.action.add");
const currentApp = apps?.find((app) => app.id === form.values.id);
return (
<form onSubmit={form.onSubmit((values) => void handleSubmitAsync(values))}>
<Stack>
<Select
{...form.getInputProps("id")}
label={t("app.action.select.label")}
searchable
clearable
leftSection={<MemoizedLeftSection isPending={isPending} currentApp={currentApp} />}
nothingFoundMessage={t("app.action.select.notFound")}
renderOption={renderSelectOption}
limit={5}
data={
apps
?.filter((app) => !innerProps.presentAppIds.includes(app.id))
.map((app) => ({
label: app.name,
value: app.id,
iconUrl: app.iconUrl,
})) ?? []
}
/>
<Group justify="end">
<Button variant="default" onClick={actions.closeModal}>
{t("common.action.cancel")}
</Button>
<Button type="submit" loading={loading}>
{confirmLabel}
</Button>
</Group>
</Stack>
</form>
);
}).withOptions({
defaultTitle: (t) => t("app.action.select.label"),
});
const iconProps = {
stroke: 1.5,
color: "currentColor",
opacity: 0.6,
size: 18,
};
const renderSelectOption: SelectProps["renderOption"] = ({ option, checked }) => (
<Group flex="1" gap="xs">
{"iconUrl" in option && typeof option.iconUrl === "string" ? (
<img width={20} height={20} src={option.iconUrl} alt={option.label} />
) : null}
{option.label}
{checked && <IconCheck style={{ marginInlineStart: "auto" }} {...iconProps} />}
</Group>
);
interface LeftSectionProps {
isPending: boolean;
currentApp: RouterOutputs["app"]["selectable"][number] | undefined;
}
const size = 20;
const LeftSection = ({ isPending, currentApp }: LeftSectionProps) => {
if (isPending) {
return <Loader size={size} />;
}
if (currentApp) {
return <img width={size} height={size} src={currentApp.iconUrl} alt={currentApp.name} />;
}
return null;
};
const MemoizedLeftSection = memo(LeftSection);

View File

@@ -0,0 +1,15 @@
.card:hover {
background-color: var(--mantine-color-primaryColor-light-hover);
}
[data-mantine-color-scheme="light"] .card-grid {
background-color: var(--mantine-color-gray-1);
}
[data-mantine-color-scheme="dark"] .card-grid {
background-color: var(--mantine-color-dark-7);
}
.card:hover > div > div.bookmarkIcon {
background-color: var(--mantine-color-iconColor-filled-hover);
}

View File

@@ -0,0 +1,290 @@
"use client";
import { Anchor, Card, Flex, Group, Stack, Text, Title, UnstyledButton } from "@mantine/core";
import combineClasses from "clsx";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { useRequiredBoard } from "@homarr/boards/context";
import { useRegisterSpotlightContextResults } from "@homarr/spotlight";
import { MaskedOrNormalImage } from "@homarr/ui";
import type { WidgetComponentProps } from "../definition";
import classes from "./bookmark.module.css";
export default function BookmarksWidget({ options, itemId }: WidgetComponentProps<"bookmarks">) {
const board = useRequiredBoard();
const [data] = clientApi.app.byIds.useSuspenseQuery(options.items, {
select(data) {
return data.sort((appA, appB) => options.items.indexOf(appA.id) - options.items.indexOf(appB.id));
},
});
useRegisterSpotlightContextResults(
`bookmark-${itemId}`,
data
.filter((app) => app.href !== null)
.map((app) => ({
id: app.id,
name: app.name,
icon: app.iconUrl,
interaction() {
return {
type: "link",
// We checked above that app.href is defined
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
href: app.href!,
newTab: false,
};
},
})),
[data],
);
return (
<Stack h="100%" gap="sm" p="sm">
{options.title.length > 0 && (
<Title order={4} px="0.25rem">
{options.title}
</Title>
)}
{(options.layout === "grid" || options.layout === "gridHorizontal") && (
<GridLayout
data={data}
itemDirection={options.layout === "gridHorizontal" ? "horizontal" : "vertical"}
hideTitle={options.hideTitle}
hideIcon={options.hideIcon}
hideHostname={options.hideHostname}
openNewTab={options.openNewTab}
hasIconColor={board.iconColor !== null}
/>
)}
{options.layout !== "grid" && options.layout !== "gridHorizontal" && (
<FlexLayout
data={data}
direction={options.layout}
hideTitle={options.hideTitle}
hideIcon={options.hideIcon}
hideHostname={options.hideHostname}
openNewTab={options.openNewTab}
hasIconColor={board.iconColor !== null}
/>
)}
</Stack>
);
}
interface FlexLayoutProps {
data: RouterOutputs["app"]["byIds"];
direction: "row" | "column";
hideTitle: boolean;
hideIcon: boolean;
hideHostname: boolean;
openNewTab: boolean;
hasIconColor: boolean;
}
const FlexLayout = ({
data,
direction,
hideTitle,
hideIcon,
hideHostname,
openNewTab,
hasIconColor,
}: FlexLayoutProps) => {
const board = useRequiredBoard();
return (
<Flex direction={direction} gap="0" w="100%">
{data.map((app) => (
<div key={app.id} style={{ display: "flex", flex: "1", flexDirection: direction }}>
<UnstyledButton
component="a"
href={app.href ?? undefined}
target={openNewTab ? "_blank" : "_self"}
rel="noopener noreferrer"
key={app.id}
w="100%"
>
<Card radius={board.itemRadius} className={classes.card} w="100%" display="flex" p={4} h="100%">
{direction === "row" ? (
<VerticalItem
app={app}
hideTitle={hideTitle}
hideIcon={hideIcon}
hideHostname={hideHostname}
hasIconColor={hasIconColor}
/>
) : (
<HorizontalItem
app={app}
hideTitle={hideTitle}
hideIcon={hideIcon}
hideHostname={hideHostname}
hasIconColor={hasIconColor}
/>
)}
</Card>
</UnstyledButton>
</div>
))}
</Flex>
);
};
interface GridLayoutProps {
data: RouterOutputs["app"]["byIds"];
hideTitle: boolean;
hideIcon: boolean;
hideHostname: boolean;
openNewTab: boolean;
itemDirection: "horizontal" | "vertical";
hasIconColor: boolean;
}
const GridLayout = ({
data,
hideTitle,
hideIcon,
hideHostname,
openNewTab,
itemDirection,
hasIconColor,
}: GridLayoutProps) => {
const board = useRequiredBoard();
return (
<Flex miw="100%" gap={4} wrap="wrap" style={{ flex: 1 }}>
{data.map((app) => (
<UnstyledButton
component="a"
href={app.href ?? undefined}
target={openNewTab ? "_blank" : "_self"}
rel="noopener noreferrer"
key={app.id}
flex="1"
>
<Card
h="100%"
className={combineClasses(classes.card, classes["card-grid"])}
radius={board.itemRadius}
p="xs"
>
{itemDirection === "horizontal" ? (
<HorizontalItem
app={app}
hideTitle={hideTitle}
hideIcon={hideIcon}
hideHostname={hideHostname}
hasIconColor={hasIconColor}
/>
) : (
<VerticalItem
app={app}
hideTitle={hideTitle}
hideIcon={hideIcon}
hideHostname={hideHostname}
hasIconColor={hasIconColor}
/>
)}
</Card>
</UnstyledButton>
))}
</Flex>
);
};
const VerticalItem = ({
app,
hideTitle,
hideIcon,
hideHostname,
hasIconColor,
}: {
app: RouterOutputs["app"]["byIds"][number];
hideTitle: boolean;
hideIcon: boolean;
hideHostname: boolean;
hasIconColor: boolean;
}) => {
return (
<Stack h="100%" miw={16} gap="sm" justify={"center"}>
{!hideTitle && (
<Text fw={700} ta="center" size="xs">
{app.name}
</Text>
)}
{!hideIcon && (
<MaskedOrNormalImage
imageUrl={app.iconUrl}
hasColor={hasIconColor}
alt={app.name}
className={classes.bookmarkIcon}
style={{
width: hideHostname && hideTitle ? "min(max(100%, 16px), 40px)" : 40,
height: hideHostname && hideTitle ? "min(max(100%, 16px), 40px)" : 40,
overflow: "auto",
flex: "unset",
marginLeft: "auto",
marginRight: "auto",
}}
/>
)}
{!hideHostname && (
<Anchor ta="center" component="span" size="xs">
{app.href ? new URL(app.href).hostname : undefined}
</Anchor>
)}
</Stack>
);
};
const HorizontalItem = ({
app,
hideTitle,
hideIcon,
hideHostname,
hasIconColor,
}: {
app: RouterOutputs["app"]["byIds"][number];
hideTitle: boolean;
hideIcon: boolean;
hideHostname: boolean;
hasIconColor: boolean;
}) => {
return (
<Group wrap="nowrap" gap="xs" h="100%" justify="start">
{!hideIcon && (
<MaskedOrNormalImage
imageUrl={app.iconUrl}
hasColor={hasIconColor}
alt={app.name}
className={classes.bookmarkIcon}
style={{
overflow: "auto",
width: hideHostname ? 16 : 24,
height: hideHostname ? 16 : 24,
flex: "unset",
}}
/>
)}
{!(hideTitle && hideHostname) && (
<>
<Stack justify="space-between" gap={0}>
{!hideTitle && (
<Text fw={700} size="xs" lineClamp={hideHostname ? 2 : 1}>
{app.name}
</Text>
)}
{!hideHostname && (
<Anchor component="span" size="xs">
{app.href ? new URL(app.href).hostname : undefined}
</Anchor>
)}
</Stack>
</>
)}
</Group>
);
};

View File

@@ -0,0 +1,62 @@
import { ActionIcon, Avatar, Group, Stack, Text } from "@mantine/core";
import { IconBookmark, IconX } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options";
import { BookmarkAddButton } from "./add-button";
export const { definition, componentLoader } = createWidgetDefinition("bookmarks", {
icon: IconBookmark,
createOptions() {
return optionsBuilder.from((factory) => ({
title: factory.text(),
layout: factory.select({
options: (["grid", "gridHorizontal", "row", "column"] as const).map((value) => ({
value,
label: (t) => t(`widget.bookmarks.option.layout.option.${value}.label`),
})),
defaultValue: "column",
}),
hideTitle: factory.switch({ defaultValue: false }),
hideIcon: factory.switch({ defaultValue: false }),
hideHostname: factory.switch({ defaultValue: false }),
openNewTab: factory.switch({ defaultValue: true }),
items: factory.sortableItemList<RouterOutputs["app"]["all"][number], string>({
ItemComponent: ({ item, handle: Handle, removeItem, rootAttributes }) => {
return (
<Group {...rootAttributes} tabIndex={0} justify="space-between" wrap="nowrap">
<Group wrap="nowrap">
<Handle />
<Group>
<Avatar src={item.iconUrl} alt={item.name} />
<Stack gap={0}>
<Text>{item.name}</Text>
</Stack>
</Group>
</Group>
<ActionIcon variant="transparent" color="red" onClick={removeItem}>
<IconX size={20} />
</ActionIcon>
</Group>
);
},
AddButton: BookmarkAddButton,
uniqueIdentifier: (item) => item.id,
useData: (initialIds) => {
const { data, error, isLoading } = clientApi.app.byIds.useQuery(initialIds);
return {
data,
error,
isLoading,
};
},
}),
}));
},
}).withDynamicImport(() => import("./component"));

View File

@@ -0,0 +1,32 @@
import { trpc } from "@homarr/api/server";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { db, inArray } from "@homarr/db";
import { apps } from "@homarr/db/schema";
import type { Prefetch } from "../definition";
const logger = createLogger({ module: "bookmarksWidgetPrefetch" });
const prefetchAllAsync: Prefetch<"bookmarks"> = async (queryClient, items) => {
const appIds = items.flatMap((item) => item.options.items);
const distinctAppIds = [...new Set(appIds)];
const dbApps = await db.query.apps.findMany({
where: inArray(apps.id, distinctAppIds),
});
for (const item of items) {
if (item.options.items.length === 0) {
continue;
}
queryClient.setQueryData(
trpc.app.byIds.queryKey(item.options.items),
dbApps.filter((app) => item.options.items.includes(app.id)),
);
}
logger.info("Successfully prefetched apps for bookmarks", { count: dbApps.length });
};
export default prefetchAllAsync;

View File

@@ -0,0 +1,3 @@
.badge {
transform: translateX(-50%);
}

View File

@@ -0,0 +1,171 @@
import {
Badge,
Box,
Button,
darken,
Group,
Image,
lighten,
ScrollArea,
Stack,
Text,
useMantineColorScheme,
} from "@mantine/core";
import { IconClock, IconPin } from "@tabler/icons-react";
import dayjs from "dayjs";
import { isNullOrWhitespace } from "@homarr/common";
import type { CalendarEvent } from "@homarr/integrations/types";
import { useI18n } from "@homarr/translation/client";
import classes from "./calendar-event-list.module.css";
interface CalendarEventListProps {
events: CalendarEvent[];
}
export const CalendarEventList = ({ events }: CalendarEventListProps) => {
const { colorScheme } = useMantineColorScheme();
const t = useI18n();
return (
<ScrollArea
offsetScrollbars
pt={5}
w="100%"
styles={{
viewport: {
maxHeight: 450,
},
}}
>
<Stack>
{events.map((event, eventIndex) => (
<Group key={`event-${eventIndex}`} align={"stretch"} wrap="nowrap">
{event.image !== null && (
<Box pos="relative">
<Image
src={event.image.src}
w={70}
mah={150}
style={{
aspectRatio: event.image.aspectRatio
? `${event.image.aspectRatio.width} / ${event.image.aspectRatio.height}`
: "1/1",
}}
radius="sm"
fallbackSrc="https://placehold.co/400x400?text=No%20image"
/>
{event.image.badge !== undefined && (
<Badge pos="absolute" bottom={-6} left="50%" w="90%" className={classes.badge}>
{event.image.badge.content}
</Badge>
)}
</Box>
)}
<Stack style={{ flexGrow: 1 }} gap={0}>
<Group justify="space-between" align="start" mb="xs" wrap="nowrap">
<Stack gap={0}>
{event.subTitle !== null && (
<Text lineClamp={1} size="sm">
{event.subTitle}
</Text>
)}
<Text fw={"bold"} lineClamp={1} size="sm">
{event.title}
</Text>
</Stack>
{event.metadata?.type === "radarr" && (
<Group wrap="nowrap">
<Text c="dimmed" size="sm">
{t(`widget.calendar.option.releaseType.options.${event.metadata.releaseType}`)}
</Text>
</Group>
)}
<Group gap={3} wrap="nowrap" align={"center"}>
<IconClock opacity={0.7} size={"1rem"} />
{isAllDay(event) ? (
<Text c={"dimmed"} size={"sm"}>
{t("widget.calendar.duration.allDay")}
</Text>
) : (
<>
<Text c={"dimmed"} size={"sm"}>
{dayjs(event.startDate).format("HH:mm")}
</Text>
{event.endDate !== null && (
<>
-{" "}
<Text c={"dimmed"} size={"sm"}>
{dayjs(event.endDate).format("HH:mm")}
</Text>
</>
)}
</>
)}
</Group>
</Group>
{event.location !== null && (
<Group gap={4} mb={isNullOrWhitespace(event.description) ? 0 : "sm"}>
<IconPin opacity={0.7} size={"1rem"} />
<Text size={"xs"} c={"dimmed"} lineClamp={1}>
{event.location}
</Text>
</Group>
)}
{!isNullOrWhitespace(event.description) && (
<Text size={"xs"} c={"dimmed"} lineClamp={2}>
{event.description}
</Text>
)}
{event.links.length > 0 && (
<Group pt={5} gap={5} mt={"auto"} wrap="nowrap">
{event.links
.filter((link) => link.href)
.map((link) => (
<Button
key={link.href}
component={"a"}
href={link.href.toString()}
target={"_blank"}
size={"xs"}
radius={"xl"}
variant={link.color ? undefined : "default"}
styles={{
root: {
backgroundColor: link.color,
color: link.isDark && colorScheme === "dark" ? "white" : "black",
"&:hover": link.color
? {
backgroundColor: link.isDark ? lighten(link.color, 0.1) : darken(link.color, 0.1),
}
: undefined,
},
}}
leftSection={link.logo ? <Image src={link.logo} fit="contain" w={20} h={20} /> : undefined}
>
<Text>{link.name}</Text>
</Button>
))}
</Group>
)}
</Stack>
</Group>
))}
</Stack>
</ScrollArea>
);
};
const isAllDay = (event: Pick<CalendarEvent, "startDate" | "endDate">) => {
if (!event.endDate) return false;
const start = dayjs(event.startDate);
const end = dayjs(event.endDate);
return start.startOf("day").isSame(start) && end.endOf("day").isSame(end);
};

View File

@@ -0,0 +1,66 @@
import { describe, expect, test } from "vitest";
import type { CalendarEvent } from "@homarr/integrations/types";
import { splitEvents } from "./component";
describe("splitEvents should split multi-day events into multiple single-day events", () => {
test("2 day all-day event should be split up into two all-day events", () => {
const event = createEvent(new Date(2025, 0, 1), new Date(2025, 0, 3));
const result = splitEvents([event]);
expect(result).toHaveLength(2);
expect(result[0]?.startDate).toEqual(event.startDate);
expect(result[0]?.endDate).toEqual(new Date(new Date(2025, 0, 2).getTime() - 1));
expect(result[1]?.startDate).toEqual(new Date(2025, 0, 2));
// Because we want to end the event on the previous day, we have not the same endDate.
// Otherwise there would be three single-day events, with the last being from 0:00 - 0:00
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
expect(result[1]?.endDate).toEqual(new Date(event.endDate!.getTime() - 1));
});
test("2 day partial event should be split up into two events", () => {
const event = createEvent(new Date(2025, 0, 1, 15), new Date(2025, 0, 2, 9));
const result = splitEvents([event]);
expect(result).toHaveLength(2);
expect(result[0]?.startDate).toEqual(event.startDate);
expect(result[0]?.endDate).toEqual(new Date(new Date(2025, 0, 2).getTime() - 1));
expect(result[1]?.startDate).toEqual(new Date(2025, 0, 2));
expect(result[1]?.endDate).toEqual(event.endDate);
});
test("one day partial event should only have one event after split", () => {
const event = createEvent(new Date(2025, 0, 1), new Date(2025, 0, 2));
const result = splitEvents([event]);
expect(result).toHaveLength(1);
});
test("without endDate should not be split", () => {
const event = createEvent(new Date(2025, 0, 1));
const result = splitEvents([event]);
expect(result).toHaveLength(1);
});
test("startDate after endDate should not cause infinite loop", () => {
const event = createEvent(new Date(2025, 0, 2), new Date(2025, 0, 1));
const result = splitEvents([event]);
expect(result).toHaveLength(0);
});
});
const createEvent = (startDate: Date, endDate: Date | null = null): CalendarEvent => ({
title: "Test",
subTitle: null,
description: null,
startDate,
endDate,
image: null,
indicatorColor: "red",
links: [],
location: null,
});

View File

@@ -0,0 +1,106 @@
import { useState } from "react";
import { Box, Container, Flex, Popover, Text, useMantineTheme } from "@mantine/core";
import { useElementSize } from "@mantine/hooks";
import { useRequiredBoard } from "@homarr/boards/context";
import type { CalendarEvent } from "@homarr/integrations/types";
import { CalendarEventList } from "./calendar-event-list";
interface CalendarDayProps {
date: Date;
events: CalendarEvent[];
disabled: boolean;
rootWidth: number;
rootHeight: number;
}
export const CalendarDay = ({ date, events, disabled, rootHeight, rootWidth }: CalendarDayProps) => {
const [opened, setOpened] = useState(false);
const { primaryColor } = useMantineTheme();
const { ref, height } = useElementSize();
const board = useRequiredBoard();
const mantineTheme = useMantineTheme();
const actualItemRadius = mantineTheme.radius[board.itemRadius];
const minAxisSize = Math.min(rootWidth, rootHeight);
const shouldScaleDown = minAxisSize < 350;
const isSmall = rootHeight < 256;
const isTooSmallForIndicators = height < 30;
return (
<Popover
position="bottom"
withArrow
withinPortal
radius="lg"
shadow="sm"
transitionProps={{
transition: "pop",
}}
onChange={setOpened}
opened={opened}
disabled={disabled}
>
<Popover.Target>
<Container
h="100%"
w="100%"
p={0}
pt={isSmall ? 0 : 10}
pb={isSmall ? 0 : 10}
m={0}
ref={ref}
bd={`2px solid ${opened && !disabled ? primaryColor : "transparent"}`}
style={{
alignContent: "center",
borderRadius: actualItemRadius,
cursor: disabled ? "default" : "pointer",
}}
onClick={() => {
if (disabled) return;
setOpened((prev) => !prev);
}}
>
<Text ta={"center"} size={shouldScaleDown ? "xs" : "md"} lh={1}>
{date.getDate()}
</Text>
{!isTooSmallForIndicators && <NotificationIndicator events={events} isSmall={isSmall} />}
</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" }}>
<CalendarEventList events={events} />
</Popover.Dropdown>
</Popover>
);
};
interface NotificationIndicatorProps {
events: CalendarEvent[];
isSmall: boolean;
}
const NotificationIndicator = ({ events, isSmall }: NotificationIndicatorProps) => {
const notificationEvents = [...new Set(events.map((event) => event.indicatorColor))].filter(String);
/* position bottom is lower when small to not be on top of number*/
return (
<Flex
w="75%"
align={"center"}
pos={"absolute"}
gap={3}
bottom={isSmall ? 4 : 10}
left={"12.5%"}
p={0}
direction={"row"}
justify={"center"}
>
{notificationEvents.map((notificationEvent) => {
return <Box key={notificationEvent} bg={notificationEvent} h={4} w={4} p={0} style={{ borderRadius: 999 }} />;
})}
</Flex>
);
};

View File

@@ -0,0 +1,5 @@
.calendar div[data-month-level] {
width: 100%;
display: flex;
flex-direction: column;
}

View File

@@ -0,0 +1,211 @@
"use client";
import { useMemo, useState } from "react";
import { useParams } from "next/navigation";
import { useMantineTheme } from "@mantine/core";
import { Calendar } from "@mantine/dates";
import { useElementSize } from "@mantine/hooks";
import dayjs from "dayjs";
import { clientApi } from "@homarr/api/client";
import { useRequiredBoard } from "@homarr/boards/context";
import type { CalendarEvent } from "@homarr/integrations/types";
import { useSettings } from "@homarr/settings";
import type { WidgetComponentProps } from "../definition";
import { CalendarDay } from "./calender-day";
import classes from "./component.module.css";
export default function CalendarWidget(props: WidgetComponentProps<"calendar">) {
const [month, setMonth] = useState(new Date());
if (props.integrationIds.length === 0) {
return <CalendarBase {...props} events={[]} month={month} setMonth={setMonth} />;
}
return <FetchCalendar month={month} setMonth={setMonth} {...props} />;
}
interface FetchCalendarProps extends WidgetComponentProps<"calendar"> {
month: Date;
setMonth: (date: Date) => void;
}
const FetchCalendar = ({ month, setMonth, isEditMode, integrationIds, options }: FetchCalendarProps) => {
const input = {
integrationIds,
month: month.getMonth(),
year: month.getFullYear(),
releaseType: options.releaseType,
showUnmonitored: options.showUnmonitored,
};
const [data] = clientApi.widget.calendar.findAllEvents.useSuspenseQuery(input, {
refetchOnMount: false,
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 events = useMemo(() => data.flatMap((item) => item.events), [data]);
return <CalendarBase isEditMode={isEditMode} events={events} month={month} setMonth={setMonth} options={options} />;
};
interface CalendarBaseProps {
isEditMode: boolean;
events: CalendarEvent[];
month: Date;
setMonth: (date: Date) => void;
options: WidgetComponentProps<"calendar">["options"];
}
const CalendarBase = ({ isEditMode, events, month, setMonth, options }: CalendarBaseProps) => {
const params = useParams();
const locale = params.locale as string;
const { firstDayOfWeek } = useSettings();
const board = useRequiredBoard();
const mantineTheme = useMantineTheme();
const actualItemRadius = mantineTheme.radius[board.itemRadius];
const { ref, width, height } = useElementSize();
const isSmall = width < 256;
const normalizedEvents = useMemo(() => splitEvents(events), [events]);
return (
<Calendar
defaultDate={new Date()}
onPreviousMonth={(month) => setMonth(new Date(month))}
onNextMonth={(month) => setMonth(new Date(month))}
highlightToday
locale={locale}
hideWeekdays={false}
date={month}
maxLevel="month"
firstDayOfWeek={firstDayOfWeek}
static={isEditMode}
className={classes.calendar}
w="100%"
h="100%"
ref={ref}
styles={{
calendarHeaderControl: {
pointerEvents: isEditMode ? "none" : undefined,
borderRadius: "md",
height: isSmall ? "1.5rem" : undefined,
width: isSmall ? "1.5rem" : undefined,
},
calendarHeaderLevel: {
pointerEvents: "none",
fontSize: isSmall ? "0.75rem" : undefined,
height: "100%",
},
levelsGroup: {
height: "100%",
padding: "md",
},
calendarHeader: {
maxWidth: "unset",
marginBottom: 0,
},
monthCell: {
textAlign: "center",
position: "relative",
},
day: {
borderRadius: actualItemRadius,
width: "100%",
height: "100%",
position: "absolute",
top: 0,
left: 0,
bottom: 0,
right: 0,
},
month: {
height: "100%",
},
weekday: {
padding: 0,
},
weekdaysRow: {
height: 22,
},
}}
renderDay={(tileDate) => {
const eventsForDate = normalizedEvents
.filter((event) => dayjs(event.startDate).isSame(tileDate, "day"))
.filter(
(event) => event.metadata?.type !== "radarr" || options.releaseType.includes(event.metadata.releaseType),
)
.sort((eventA, eventB) => eventA.startDate.getTime() - eventB.startDate.getTime());
return (
<CalendarDay
// new Date() does not work here, because for timezones like UTC-7 it will
// show one day earlier (probably due to the time being set to 00:00)
// see https://github.com/homarr-labs/homarr/pull/3120
date={dayjs(tileDate).toDate()}
events={eventsForDate}
disabled={isEditMode || eventsForDate.length === 0}
rootWidth={width}
rootHeight={height}
/>
);
}}
/>
);
};
/**
* Splits multi-day events into multiple single-day events.
* @param events The events to split.
* @returns The split events.
*/
export const splitEvents = (events: CalendarEvent[]): CalendarEvent[] => {
const splitEvents: CalendarEvent[] = [];
for (const event of events) {
if (!event.endDate) {
splitEvents.push(event);
continue;
}
if (dayjs(event.startDate).isSame(event.endDate, "day")) {
splitEvents.push(event);
continue;
}
if (dayjs(event.startDate).isAfter(event.endDate)) {
// Invalid event, skip it
continue;
}
// Event spans multiple days, split it
let currentStart = dayjs(event.startDate);
while (currentStart.isBefore(event.endDate)) {
splitEvents.push({
...event,
startDate: currentStart.toDate(),
endDate: currentStart.endOf("day").isAfter(event.endDate) ? event.endDate : currentStart.endOf("day").toDate(),
});
currentStart = currentStart.add(1, "day").startOf("day");
}
}
return splitEvents;
};

View File

@@ -0,0 +1,36 @@
import { IconCalendar } from "@tabler/icons-react";
import { z } from "zod/v4";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { radarrReleaseTypes } from "@homarr/integrations/types";
import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("calendar", {
icon: IconCalendar,
createOptions() {
return optionsBuilder.from((factory) => ({
releaseType: factory.multiSelect({
defaultValue: ["inCinemas", "digitalRelease"],
options: radarrReleaseTypes.map((value) => ({
value,
label: (t) => t(`widget.calendar.option.releaseType.options.${value}`),
})),
}),
filterPastMonths: factory.number({
validate: z.number().min(2).max(9999),
defaultValue: 2,
}),
filterFutureMonths: factory.number({
validate: z.number().min(2).max(9999),
defaultValue: 2,
}),
showUnmonitored: factory.switch({
defaultValue: false,
}),
}));
},
supportedIntegrations: getIntegrationKindsByCategory("calendar"),
integrationsRequired: false,
}).withDynamicImport(() => import("./component"));

View File

@@ -0,0 +1,80 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { Stack, Text, Title } from "@mantine/core";
import dayjs from "dayjs";
import advancedFormat from "dayjs/plugin/advancedFormat";
import timezones from "dayjs/plugin/timezone";
import utc from "dayjs/plugin/utc";
import type { WidgetComponentProps } from "../definition";
dayjs.extend(advancedFormat);
dayjs.extend(utc);
dayjs.extend(timezones);
export default function ClockWidget({ options, width }: WidgetComponentProps<"clock">) {
const secondsFormat = options.showSeconds ? ":ss" : "";
const timeFormat = options.is24HourFormat ? `HH:mm${secondsFormat}` : `hh:mm${secondsFormat} A`;
const dateFormat = options.dateFormat;
const customTimeFormat = options.customTimeFormat;
const customDateFormat = options.customDateFormat;
const timezone = options.useCustomTimezone ? options.timezone : Intl.DateTimeFormat().resolvedOptions().timeZone;
const time = useCurrentTime(options);
const sizing = width < 128 ? "xs" : width < 196 ? "sm" : "md";
return (
<Stack className="clock-text-stack" h="100%" align="center" justify="center" gap={sizing}>
{options.customTitleToggle && (
<Text className="clock-customTitle-text" size={sizing} ta="center">
{options.customTitle}
</Text>
)}
<Title className="clock-time-text" fw={700} order={sizing === "md" ? 2 : sizing === "sm" ? 4 : 6} lh="1">
{options.customTimeFormat
? dayjs(time).tz(timezone).format(customTimeFormat)
: dayjs(time).tz(timezone).format(timeFormat)}
</Title>
{options.showDate && (
<Text className="clock-date-text" size={sizing} lineClamp={1}>
{options.customDateFormat
? dayjs(time).tz(timezone).format(customDateFormat)
: dayjs(time).tz(timezone).format(dateFormat)}
</Text>
)}
</Stack>
);
}
interface UseCurrentTimeProps {
showSeconds: boolean;
}
const useCurrentTime = ({ showSeconds }: UseCurrentTimeProps) => {
const [time, setTime] = useState(new Date());
const timeoutRef = useRef<NodeJS.Timeout>(null);
const intervalRef = useRef<NodeJS.Timeout>(null);
const intervalMultiplier = useMemo(() => (showSeconds ? 1 : 60), [showSeconds]);
useEffect(() => {
setTime(new Date());
timeoutRef.current = setTimeout(
() => {
setTime(new Date());
intervalRef.current = setInterval(() => {
setTime(new Date());
}, intervalMultiplier * 1000);
},
intervalMultiplier * 1000 - (1000 * (showSeconds ? 0 : dayjs().second()) + dayjs().millisecond()),
);
return () => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
if (intervalRef.current) clearInterval(intervalRef.current);
};
}, [intervalMultiplier, showSeconds]);
return time;
};

View File

@@ -0,0 +1,72 @@
import { IconClock } from "@tabler/icons-react";
import dayjs from "dayjs";
import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("clock", {
icon: IconClock,
createOptions() {
return optionsBuilder.from(
(factory) => ({
customTitleToggle: factory.switch({
defaultValue: false,
withDescription: true,
}),
customTitle: factory.text({
defaultValue: "",
}),
is24HourFormat: factory.switch({
defaultValue: true,
withDescription: true,
}),
showSeconds: factory.switch({
defaultValue: false,
}),
useCustomTimezone: factory.switch({ defaultValue: false }),
timezone: factory.select({
options: Intl.supportedValuesOf("timeZone").map((value) => value),
defaultValue: "Europe/London",
searchable: true,
withDescription: true,
}),
showDate: factory.switch({
defaultValue: true,
}),
dateFormat: factory.select({
options: [
{ value: "dddd, MMMM D", label: dayjs().format("dddd, MMMM D") },
{ value: "dddd, D MMMM", label: dayjs().format("dddd, D MMMM") },
{ value: "MMM D", label: dayjs().format("MMM D") },
{ value: "D MMM", label: dayjs().format("D MMM") },
{ value: "DD/MM/YYYY", label: dayjs().format("DD/MM/YYYY") },
{ value: "MM/DD/YYYY", label: dayjs().format("MM/DD/YYYY") },
{ value: "DD/MM", label: dayjs().format("DD/MM") },
{ value: "MM/DD", label: dayjs().format("MM/DD") },
],
defaultValue: "dddd, MMMM D",
withDescription: true,
}),
customTimeFormat: factory.text({
defaultValue: "",
withDescription: true,
}),
customDateFormat: factory.text({
defaultValue: "",
withDescription: true,
}),
}),
{
customTitle: {
shouldHide: (options) => !options.customTitleToggle,
},
timezone: {
shouldHide: (options) => !options.useCustomTimezone,
},
dateFormat: {
shouldHide: (options) => !options.showDate,
},
},
);
},
}).withDynamicImport(() => import("./component"));

View File

@@ -0,0 +1,84 @@
import type { LoaderComponent } from "next/dynamic";
import type { QueryClient } from "@tanstack/react-query";
import type { DefaultErrorData } from "@trpc/server/unstable-core-do-not-import";
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
import type { ServerSettings } from "@homarr/server-settings";
import type { SettingsContextProps } from "@homarr/settings/creator";
import type { stringOrTranslation } from "@homarr/translation";
import type { TablerIcon } from "@homarr/ui";
import type { WidgetImports } from ".";
import type { inferOptionsFromCreator, WidgetOptionsRecord } from "./options";
const createWithDynamicImport =
<TKind extends WidgetKind, TDefinition extends WidgetDefinition>(kind: TKind, definition: TDefinition) =>
(componentLoader: () => LoaderComponent<WidgetComponentProps<TKind>>) => ({
definition: {
...definition,
kind,
},
kind,
componentLoader,
});
export type PrefetchLoader<TKind extends WidgetKind> = () => Promise<{ default: Prefetch<TKind> }>;
export type Prefetch<TKind extends WidgetKind> = (
queryClient: QueryClient,
items: {
options: inferOptionsFromCreator<WidgetOptionsRecordOf<TKind>>;
integrationIds: string[];
}[],
) => Promise<void>;
export const createWidgetDefinition = <TKind extends WidgetKind, TDefinition extends WidgetDefinition>(
kind: TKind,
definition: TDefinition,
) => ({
withDynamicImport: createWithDynamicImport(kind, definition),
});
export interface WidgetDefinition {
icon: TablerIcon;
supportedIntegrations?: IntegrationKind[];
integrationsRequired?: boolean;
createOptions: (
settings: Pick<SettingsContextProps, "enableStatusByDefault" | "forceDisableStatus">,
) => WidgetOptionsRecord;
errors?: Partial<
Record<
DefaultErrorData["code"],
{
icon: TablerIcon;
message: stringOrTranslation;
hideLogsLink?: boolean;
}
>
>;
}
export interface WidgetProps<TKind extends WidgetKind> {
options: inferOptionsFromCreator<WidgetOptionsRecordOf<TKind>>;
integrationIds: string[];
itemId: string | undefined; // undefined when in preview mode
}
export type WidgetComponentProps<TKind extends WidgetKind> = WidgetProps<TKind> & {
boardId: string | undefined; // undefined when in preview mode
isEditMode: boolean;
setOptions: ({ newOptions }: { newOptions: Partial<inferOptionsFromCreator<WidgetOptionsRecordOf<TKind>>> }) => void;
width: number;
height: number;
};
export type WidgetOptionsRecordOf<TKind extends WidgetKind> = WidgetImports[TKind]["definition"]["createOptions"];
/**
* The following type should only include values that can be available for user (including anonymous).
* Because they need to be provided to the client to for example set certain default values.
*/
export interface WidgetOptionsSettings {
server: {
board: Pick<ServerSettings["board"], "enableStatusByDefault" | "forceDisableStatus">;
};
}

View File

@@ -0,0 +1,105 @@
import { useRef, useState } from "react";
import type { NumberInputHandlers } from "@mantine/core";
import { ActionIcon, Button, Flex, Group, Modal, NumberInput, rem, Stack, Text } from "@mantine/core";
import { IconClockPause } from "@tabler/icons-react";
import { useI18n } from "@homarr/translation/client";
interface TimerModalProps {
opened: boolean;
close: () => void;
selectedIntegrationIds: string[];
disableDns: (data: { duration: number; integrationId: string }) => void;
}
const TimerModal = ({ opened, close, selectedIntegrationIds, disableDns }: TimerModalProps) => {
const t = useI18n();
const [hours, setHours] = useState(0);
const [minutes, setMinutes] = useState(0);
const hoursHandlers = useRef<NumberInputHandlers>(null);
const minutesHandlers = useRef<NumberInputHandlers>(null);
const handleSetTimer = () => {
const duration = hours * 3600 + minutes * 60;
selectedIntegrationIds.forEach((integrationId) => {
disableDns({ duration, integrationId });
});
setHours(0);
setMinutes(0);
close();
};
return (
<Modal
withinPortal
radius="lg"
shadow="sm"
size="sm"
opened={opened}
onClose={() => {
close();
setHours(0);
setMinutes(0);
}}
title={t("widget.dnsHoleControls.controls.setTimer")}
>
<Flex direction="column" align="center" justify="center">
<Stack align="flex-end">
<Group>
<Text>{t("widget.dnsHoleControls.controls.hours")}</Text>
<ActionIcon size={35} variant="default" onClick={() => hoursHandlers.current?.decrement()}>
</ActionIcon>
<NumberInput
hideControls
value={hours}
onChange={(val) => setHours(Number(val))}
handlersRef={hoursHandlers}
max={999}
min={0}
step={1}
styles={{ input: { width: rem(54), textAlign: "center" } }}
/>
<ActionIcon size={35} variant="default" onClick={() => hoursHandlers.current?.increment()}>
+
</ActionIcon>
</Group>
<Group>
<Text>{t("widget.dnsHoleControls.controls.minutes")}</Text>
<ActionIcon size={35} variant="default" onClick={() => minutesHandlers.current?.decrement()}>
</ActionIcon>
<NumberInput
hideControls
value={minutes}
onChange={(val) => setMinutes(Number(val))}
handlersRef={minutesHandlers}
max={59}
min={0}
step={1}
styles={{ input: { width: rem(54), textAlign: "center" } }}
/>
<ActionIcon size={35} variant="default" onClick={() => minutesHandlers.current?.increment()}>
+
</ActionIcon>
</Group>
</Stack>
<Text ta="center" c="dimmed" my={5}>
{t("widget.dnsHoleControls.controls.unlimited")}
</Text>
<Button
variant="light"
color="red"
leftSection={<IconClockPause size={20} />}
h="2rem"
w="12rem"
onClick={handleSetTimer}
>
{t("widget.dnsHoleControls.controls.set")}
</Button>
</Flex>
</Modal>
);
};
export default TimerModal;

View File

@@ -0,0 +1,7 @@
[data-mantine-color-scheme="light"] .card {
background-color: var(--mantine-color-gray-1);
}
[data-mantine-color-scheme="dark"] .card {
background-color: var(--mantine-color-dark-7);
}

View File

@@ -0,0 +1,479 @@
"use client";
import "../../widgets-common.css";
import { useState } from "react";
import {
ActionIcon,
Badge,
Button,
Card,
Flex,
Group,
Indicator,
ScrollArea,
Stack,
Text,
Tooltip,
UnstyledButton,
} from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { IconCircleFilled, IconClockPause, IconPlayerPlay, IconPlayerStop } from "@tabler/icons-react";
import combineClasses from "clsx";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { useIntegrationsWithInteractAccess } from "@homarr/auth/client";
import { useRequiredBoard } from "@homarr/boards/context";
import { useIntegrationConnected } from "@homarr/common";
import { integrationDefs } from "@homarr/definitions";
import type { TranslationFunction } from "@homarr/translation";
import { useI18n } from "@homarr/translation/client";
import { MaskedOrNormalImage } from "@homarr/ui";
import type { widgetKind } from ".";
import type { WidgetComponentProps } from "../../definition";
import classes from "./component.module.css";
import TimerModal from "./TimerModal";
const dnsLightStatus = (enabled: boolean | undefined) =>
`var(--mantine-color-${typeof enabled === "undefined" ? "blue" : enabled ? "green" : "red"}-6`;
export default function DnsHoleControlsWidget({
options,
integrationIds,
isEditMode,
width,
}: WidgetComponentProps<typeof widgetKind>) {
const board = useRequiredBoard();
// DnsHole integrations with interaction permissions
const integrationsWithInteractions = useIntegrationsWithInteractAccess()
.map(({ id }) => id)
.filter((id) => integrationIds.includes(id));
const [summaries] = clientApi.widget.dnsHole.summary.useSuspenseQuery(
{
integrationIds,
},
{
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
retry: false,
},
);
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,
);
},
);
},
});
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,
);
},
);
},
});
const toggleDns = (integrationId: string) => {
const integrationStatus = summaries.find(({ integration }) => integration.id === integrationId);
if (!integrationStatus?.summary.status) return;
utils.widget.dnsHole.summary.setData(
{
integrationIds,
},
(prevData) => {
if (!prevData) return [];
return prevData.map((item) =>
item.integration.id === integrationId
? {
...item,
summary: {
...item.summary,
status: undefined,
},
}
: item,
);
},
);
if (integrationStatus.summary.status === "enabled") {
disableDns({ integrationId, duration: 0 });
} else {
enableDns({ integrationId });
}
};
// make lists of enabled and disabled interactable integrations (with permissions, not disconnected and not processing)
const integrationsSummaries = summaries.reduce(
(acc, { summary, integration: { id } }) =>
integrationsWithInteractions.includes(id) && summary.status != null ? (acc[summary.status].push(id), acc) : acc,
{ enabled: [] as string[], disabled: [] as string[] },
);
const t = useI18n();
// Timer modal setup
const [selectedIntegrationIds, setSelectedIntegrationIds] = useState<string[]>([]);
const [opened, { close, open }] = useDisclosure(false);
const controlAllButtonsVisible = options.showToggleAllButtons && integrationsWithInteractions.length > 0;
return (
<Stack
className="dns-hole-controls-stack"
justify="space-between"
h="100%"
p="sm"
gap="sm"
style={{ pointerEvents: isEditMode ? "none" : undefined }}
>
{controlAllButtonsVisible && (
<Flex className="dns-hole-controls-buttons" gap="sm">
<Tooltip label={t("widget.dnsHoleControls.controls.enableAll")}>
<Button
size="xs"
p={0}
className="dns-hole-controls-enable-all-button"
onClick={() => integrationsSummaries.disabled.forEach((integrationId) => toggleDns(integrationId))}
disabled={integrationsSummaries.disabled.length === 0}
variant="light"
color="green"
bd={0}
radius={board.itemRadius}
flex={1}
>
<IconPlayerPlay className="dns-hole-controls-enable-all-icon" size={16} />
</Button>
</Tooltip>
<Tooltip label={t("widget.dnsHoleControls.controls.setTimer")}>
<Button
size="xs"
p={0}
className="dns-hole-controls-timer-all-button"
onClick={() => {
setSelectedIntegrationIds(integrationsSummaries.enabled);
open();
}}
disabled={integrationsSummaries.enabled.length === 0}
variant="light"
color="yellow"
bd={0}
radius={board.itemRadius}
flex={1}
>
<IconClockPause className="dns-hole-controls-timer-all-icon" size={16} />
</Button>
</Tooltip>
<Tooltip label={t("widget.dnsHoleControls.controls.disableAll")}>
<Button
size="xs"
p={0}
className="dns-hole-controls-disable-all-button"
onClick={() => integrationsSummaries.enabled.forEach((integrationId) => toggleDns(integrationId))}
disabled={integrationsSummaries.enabled.length === 0}
variant="light"
color="red"
bd={0}
radius={board.itemRadius}
flex={1}
>
<IconPlayerStop className="dns-hole-controls-disable-all-icon" size={16} />
</Button>
</Tooltip>
</Flex>
)}
<ScrollArea.Autosize className="dns-hole-controls-integration-list-scroll-area flexed-scroll-area">
<Stack
className="dns-hole-controls-integration-list"
gap="sm"
flex={1}
justify={controlAllButtonsVisible ? "flex-end" : "space-evenly"}
>
{summaries.map((summary) => (
<ControlsCard
key={summary.integration.id}
integrationsWithInteractions={integrationsWithInteractions}
toggleDns={toggleDns}
data={summary}
setSelectedIntegrationIds={setSelectedIntegrationIds}
open={open}
t={t}
hasIconColor={board.iconColor !== null}
rootWidth={width}
/>
))}
</Stack>
</ScrollArea.Autosize>
<TimerModal
opened={opened}
close={close}
selectedIntegrationIds={selectedIntegrationIds}
disableDns={disableDns}
/>
</Stack>
);
}
interface ControlsCardProps {
integrationsWithInteractions: string[];
toggleDns: (integrationId: string) => void;
data: RouterOutputs["widget"]["dnsHole"]["summary"][number];
setSelectedIntegrationIds: (integrationId: string[]) => void;
open: () => void;
t: TranslationFunction;
hasIconColor: boolean;
rootWidth: number;
}
const ControlsCard: React.FC<ControlsCardProps> = ({
integrationsWithInteractions,
toggleDns,
data,
setSelectedIntegrationIds,
open,
t,
hasIconColor,
rootWidth,
}) => {
const isConnected = useIntegrationConnected(data.integration.updatedAt, { timeout: 30000 });
const isEnabled = data.summary.status ? data.summary.status === "enabled" : undefined;
const isInteractPermitted = integrationsWithInteractions.includes(data.integration.id);
// Use all factors to infer the state of the action buttons
const controlEnabled = isInteractPermitted && isEnabled !== undefined && isConnected;
const board = useRequiredBoard();
const iconUrl = integrationDefs[data.integration.kind].iconUrl;
const layout = rootWidth < 256 ? "sm" : "md";
return (
<Indicator
disabled={!isConnected || layout !== "sm"}
color={dnsLightStatus(isEnabled)}
position="top-end"
offset={14}
>
<Card
className={combineClasses(
"dns-hole-controls-integration-item-outer-shell",
`dns-hole-controls-integration-item-${data.integration.id}`,
`dns-hole-controls-integration-item-${data.integration.name}`,
classes.card,
)}
key={data.integration.id}
p="sm"
py={8}
radius={board.itemRadius}
>
<Flex className="dns-hole-controls-item-container" gap="md" align="center" direction="row" w="100%">
{layout === "md" && (
<MaskedOrNormalImage
imageUrl={iconUrl}
hasColor={hasIconColor}
alt={data.integration.name}
className="dns-hole-controls-item-icon"
style={{
height: 30,
width: 30,
filter: !isConnected ? "grayscale(100%)" : undefined,
}}
/>
)}
<Flex className="dns-hole-controls-item-data-stack" direction="column" w="100%" gap={5}>
<Group gap="xs" align="center" wrap="nowrap">
{layout === "sm" && (
<MaskedOrNormalImage
imageUrl={iconUrl}
hasColor={hasIconColor}
alt={data.integration.name}
className="dns-hole-controls-item-icon"
style={{
height: 16,
width: 16,
filter: !isConnected ? "grayscale(100%)" : undefined,
}}
/>
)}
<Text className="dns-hole-controls-item-integration-name" fz="sm">
{data.integration.name}
</Text>
</Group>
<Flex className="dns-hole-controls-item-controls" direction="row" gap="sm" w="100%">
{layout === "sm" && (
<Group gap="xs" grow wrap="nowrap" w="100%">
{!isEnabled ? (
<ActionIcon
onClick={() => toggleDns(data.integration.id)}
disabled={!controlEnabled}
size="sm"
color="green"
variant="light"
>
<IconPlayerPlay size={12} />
</ActionIcon>
) : (
<ActionIcon
onClick={() => toggleDns(data.integration.id)}
disabled={!controlEnabled}
size="sm"
color="red"
variant="light"
>
<IconPlayerStop size={12} />
</ActionIcon>
)}
<ActionIcon
onClick={() => {
setSelectedIntegrationIds([data.integration.id]);
open();
}}
size="sm"
color="yellow"
variant="light"
>
<IconClockPause size={12} />
</ActionIcon>
</Group>
)}
{layout === "md" && (
<UnstyledButton
className="dns-hole-controls-item-toggle-button"
disabled={!controlEnabled}
display="contents"
style={{ cursor: controlEnabled ? "pointer" : "default" }}
onClick={() => toggleDns(data.integration.id)}
>
<Badge
className={`dns-hole-controls-item-toggle-button-styling${controlEnabled ? " hoverable-component clickable-component" : ""}`}
bd="1px solid var(--border-color)"
px="sm"
h="lg"
color="var(--background-color)"
c="var(--mantine-color-text)"
styles={{ section: { marginInlineEnd: "sm" }, root: { cursor: "inherit" } }}
leftSection={
isConnected && (
<IconCircleFilled
className="dns-hole-controls-item-status-icon"
color={dnsLightStatus(isEnabled)}
size={16}
/>
)
}
>
{t(
`widget.dnsHoleControls.controls.${
!isConnected
? "disconnected"
: typeof isEnabled === "undefined"
? "processing"
: isEnabled
? "enabled"
: "disabled"
}`,
)}
</Badge>
</UnstyledButton>
)}
</Flex>
</Flex>
{layout === "md" && (
<ActionIcon
className="dns-hole-controls-item-timer-button"
display={isInteractPermitted ? undefined : "none"}
disabled={!controlEnabled || !isEnabled}
color="yellow"
size={30}
radius={board.itemRadius}
bd={0}
ms={"auto"}
variant="subtle"
onClick={() => {
setSelectedIntegrationIds([data.integration.id]);
open();
}}
>
<IconClockPause className="dns-hole-controls-item-timer-icon" size={20} />
</ActionIcon>
)}
</Flex>
</Card>
</Indicator>
);
};

View File

@@ -0,0 +1,26 @@
import { IconDeviceGamepad, IconServerOff } from "@tabler/icons-react";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { createWidgetDefinition } from "../../definition";
import { optionsBuilder } from "../../options";
export const widgetKind = "dnsHoleControls";
export const { definition, componentLoader } = createWidgetDefinition(widgetKind, {
icon: IconDeviceGamepad,
createOptions() {
return optionsBuilder.from((factory) => ({
showToggleAllButtons: factory.switch({
defaultValue: true,
}),
}));
},
supportedIntegrations: getIntegrationKindsByCategory("dnsHole"),
errors: {
INTERNAL_SERVER_ERROR: {
icon: IconServerOff,
message: (t) => t("widget.dnsHoleControls.error.internalServerError"),
},
},
}).withDynamicImport(() => import("./component"));

View File

@@ -0,0 +1,226 @@
"use client";
import { useMemo } from "react";
import type { BoxProps } from "@mantine/core";
import { Avatar, AvatarGroup, Card, Flex, SimpleGrid, Stack, Text, Tooltip, TooltipFloating } from "@mantine/core";
import { useElementSize } from "@mantine/hooks";
import { IconBarrierBlock, IconPercentage, IconSearch, IconWorldWww } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useRequiredBoard } from "@homarr/boards/context";
import { formatNumber } from "@homarr/common";
import { integrationDefs } from "@homarr/definitions";
import type { DnsHoleSummary } from "@homarr/integrations/types";
import type { stringOrTranslation, TranslationFunction } from "@homarr/translation";
import { translateIfNecessary } from "@homarr/translation";
import { useI18n } from "@homarr/translation/client";
import type { TablerIcon } from "@homarr/ui";
import type { widgetKind } from ".";
import type { WidgetComponentProps, WidgetProps } from "../../definition";
export default function DnsHoleSummaryWidget({ options, integrationIds }: WidgetComponentProps<typeof widgetKind>) {
const [summaries] = clientApi.widget.dnsHole.summary.useSuspenseQuery(
{
integrationIds,
},
{
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
retry: false,
},
);
const utils = clientApi.useUtils();
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 (
<SimpleGrid cols={2} spacing="xs" h="100%" p={"xs"} {...boxPropsByLayout(options.layout)}>
{data.length > 0 ? (
stats.map((item) => (
<StatCard key={item.color} item={item} usePiHoleColors={options.usePiHoleColors} data={data} t={t} />
))
) : (
<Stack h="100%" w="100%" justify="center" align="center" gap="sm" p="sm">
<AvatarGroup spacing="md">
{summaries.map(({ integration }) => (
<Tooltip key={integration.id} label={integration.name}>
<Avatar h={30} w={30} src={integrationDefs[integration.kind].iconUrl} />
</Tooltip>
))}
</AvatarGroup>
<Text fz="md" ta="center">
{t("widget.dnsHoleSummary.error.integrationsDisconnected")}
</Text>
</Stack>
)}
</SimpleGrid>
);
}
const stats = [
{
icon: IconBarrierBlock,
value: (data, size) =>
formatNumber(
data.reduce((count, { adsBlockedToday }) => count + adsBlockedToday, 0),
size === "sm" ? 0 : 2,
),
label: (t) => t("widget.dnsHoleSummary.data.adsBlockedToday"),
color: "rgba(240, 82, 60, 0.4)", // RED
},
{
icon: IconPercentage,
value: (data, size) => {
const totalCount = data.reduce((count, { dnsQueriesToday }) => count + dnsQueriesToday, 0);
const blocked = data.reduce((count, { adsBlockedToday }) => count + adsBlockedToday, 0);
return `${formatNumber(totalCount === 0 ? 0 : (blocked / totalCount) * 100, size === "sm" ? 0 : 2)}%`;
},
label: (t) => t("widget.dnsHoleSummary.data.adsBlockedTodayPercentage"),
color: "rgba(255, 165, 20, 0.4)", // YELLOW
},
{
icon: IconSearch,
value: (data, size) =>
formatNumber(
data.reduce((count, { dnsQueriesToday }) => count + dnsQueriesToday, 0),
size === "sm" ? 0 : 2,
),
label: (t) => t("widget.dnsHoleSummary.data.dnsQueriesToday"),
color: "rgba(0, 175, 218, 0.4)", // BLUE
},
{
icon: IconWorldWww,
value: (data, size) => {
// We use a suffix to indicate that there might be more domains in the at least two lists.
const suffix = data.length >= 2 ? "+" : "";
return (
formatNumber(
data.reduce((count, { domainsBeingBlocked }) => count + domainsBeingBlocked, 0),
size === "sm" ? 0 : 2,
) + suffix
);
},
tooltip: (data, t) => (data.length >= 2 ? t("widget.dnsHoleSummary.domainsTooltip") : undefined),
label: (t) => t("widget.dnsHoleSummary.data.domainsBeingBlocked"),
color: "rgba(0, 176, 96, 0.4)", // GREEN
},
] satisfies StatItem[];
interface StatItem {
icon: TablerIcon;
value: (summaries: DnsHoleSummary[], size: "sm" | "md") => string;
tooltip?: (summaries: DnsHoleSummary[], t: TranslationFunction) => string | undefined;
label: stringOrTranslation;
color: string;
}
interface StatCardProps {
item: StatItem;
data: DnsHoleSummary[];
usePiHoleColors: boolean;
t: TranslationFunction;
}
const StatCard = ({ item, data, usePiHoleColors, t }: StatCardProps) => {
const { ref, height, width } = useElementSize();
const isLong = width > height + 20;
const canStackText = height > 32;
const hideLabel = (height <= 32 && width <= 256) || (height <= 64 && width <= 92);
const tooltip = item.tooltip?.(data, t);
const board = useRequiredBoard();
return (
<TooltipFloating label={tooltip} disabled={!tooltip} w={250} multiline>
<Card
ref={ref}
className="summary-card"
p="sm"
radius={board.itemRadius}
bg={usePiHoleColors ? item.color : "rgba(96, 96, 96, 0.1)"}
style={{
flex: 1,
}}
>
<Flex
className="summary-card-elements"
h="100%"
w="100%"
align="center"
justify="center"
direction={isLong ? "row" : "column"}
gap={0}
>
<item.icon className="summary-card-icon" size={24} style={{ minWidth: 24, minHeight: 24 }} />
<Flex
className="summary-card-texts"
justify="center"
align="center"
direction={isLong && !canStackText ? "row" : "column"}
style={{
flex: isLong ? 1 : undefined,
}}
w="100%"
gap={isLong ? 4 : 0}
wrap="wrap"
>
<Text className="summary-card-value text-flash" ta="center" size="lg" fw="bold" maw="100%">
{item.value(data, width <= 64 ? "sm" : "md")}
</Text>
{!hideLabel && (
<Text className="summary-card-label" ta="center" size="xs" maw="100%">
{translateIfNecessary(t, item.label)}
</Text>
)}
</Flex>
</Flex>
</Card>
</TooltipFloating>
);
};
const boxPropsByLayout = (layout: WidgetProps<"dnsHoleSummary">["options"]["layout"]): BoxProps => {
if (layout === "grid") {
return {
display: "grid",
style: {
gridTemplateColumns: "1fr 1fr",
gridTemplateRows: "1fr 1fr",
},
};
}
return {
display: "flex",
style: {
flexDirection: layout,
},
};
};

View File

@@ -0,0 +1,33 @@
import { IconAd, IconServerOff } from "@tabler/icons-react";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { createWidgetDefinition } from "../../definition";
import { optionsBuilder } from "../../options";
export const widgetKind = "dnsHoleSummary";
export const { definition, componentLoader } = createWidgetDefinition(widgetKind, {
icon: IconAd,
createOptions() {
return optionsBuilder.from((factory) => ({
usePiHoleColors: factory.switch({
defaultValue: true,
}),
layout: factory.select({
options: (["grid", "row", "column"] as const).map((value) => ({
value,
label: (t) => t(`widget.dnsHoleSummary.option.layout.option.${value}.label`),
})),
defaultValue: "grid",
}),
}));
},
supportedIntegrations: getIntegrationKindsByCategory("dnsHole"),
errors: {
INTERNAL_SERVER_ERROR: {
icon: IconServerOff,
message: (t) => t("widget.dnsHoleSummary.error.internalServerError"),
},
},
}).withDynamicImport(() => import("./component"));

View File

@@ -0,0 +1,276 @@
"use client";
import { useMemo } from "react";
import { ActionIcon, Avatar, Badge, 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 { MantineReactTable } from "mantine-react-table";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { humanFileSize, useTimeAgo } from "@homarr/common";
import type { ContainerState } from "@homarr/docker";
import { containerStateColorMap } from "@homarr/docker/shared";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
import type { WidgetComponentProps } from "../definition";
const ContainerStateBadge = ({ state }: { state: ContainerState }) => {
const t = useScopedI18n("docker.field.state.option");
return (
<Badge size="xs" radius="sm" variant="light" color={containerStateColorMap[state]}>
{t(state)}
</Badge>
);
};
const memoryUsageColor = (number: number, state: string) => {
const mbUsage = number / 1024 / 1024;
if (mbUsage === 0 && state !== "running") return "red";
if (mbUsage < 128) return "green";
if (mbUsage < 256) return "yellow";
if (mbUsage < 512) return "orange";
return "red";
};
const cpuUsageColor = (number: number, state: string) => {
if (number === 0 && state !== "running") return "red";
if (number < 40) return "green";
if (number < 60) return "yellow";
if (number < 90) return "orange";
return "red";
};
const safeValue = (value?: number, fallback = 0) => (value !== undefined && !isNaN(value) ? value : fallback);
const actionIconIconStyle: IconProps["style"] = {
height: "var(--ai-icon-size)",
width: "var(--ai-icon-size)",
};
const createColumns = (
t: ReturnType<typeof useScopedI18n<"docker">>,
): MRT_ColumnDef<RouterOutputs["docker"]["getContainers"]["containers"][number]>[] => [
{
id: "name",
accessorKey: "name",
header: t("field.name.label"),
Cell({ renderedCellValue, row }) {
return (
<Group gap="xs" wrap="nowrap">
<Avatar variant="outline" radius="md" size={20} src={row.original.iconUrl} />
<Text p="0.5" size="sm" style={{ overflow: "hidden", textOverflow: "ellipsis" }}>
{renderedCellValue}
</Text>
</Group>
);
},
},
{
id: "state",
accessorKey: "state",
size: 100,
header: t("field.state.label"),
Cell({ row }) {
return <ContainerStateBadge state={row.original.state} />;
},
},
{
id: "cpuUsage",
sortingFn: (rowA, rowB) => {
const cpuUsageA = safeValue(rowA.original.cpuUsage);
const cpuUsageB = safeValue(rowB.original.cpuUsage);
return cpuUsageA - cpuUsageB;
},
accessorKey: "cpuUsage",
size: 80,
header: t("field.stats.cpu.label"),
Cell({ row }) {
const cpuUsage = safeValue(row.original.cpuUsage);
return (
<Text size="xs" c={cpuUsageColor(cpuUsage, row.original.state)}>
{cpuUsage.toFixed(2)}%
</Text>
);
},
},
{
id: "memoryUsage",
sortingFn: (rowA, rowB) => {
const memoryUsageA = safeValue(rowA.original.memoryUsage);
const memoryUsageB = safeValue(rowB.original.memoryUsage);
return memoryUsageA - memoryUsageB;
},
accessorKey: "memoryUsage",
size: 80,
header: t("field.stats.memory.label"),
Cell({ row }) {
const bytesUsage = safeValue(row.original.memoryUsage);
return (
<Text size="xs" c={memoryUsageColor(bytesUsage, row.original.state)}>
{humanFileSize(bytesUsage)}
</Text>
);
},
},
{
id: "actions",
accessorKey: "actions",
size: 80,
header: t("action.title"),
enableSorting: false,
Cell({ row }) {
const utils = clientApi.useUtils();
// eslint-disable-next-line no-restricted-syntax
const onSettled = async () => {
await utils.docker.getContainers.invalidate();
};
const { mutateAsync: startContainer } = clientApi.docker.startAll.useMutation({ onSettled });
const { mutateAsync: stopContainer } = clientApi.docker.stopAll.useMutation({ onSettled });
const { mutateAsync: restartContainer } = clientApi.docker.restartAll.useMutation({ onSettled });
const handleActionAsync = async (action: "start" | "stop" | "restart") => {
const mutation = action === "start" ? startContainer : action === "stop" ? stopContainer : restartContainer;
await mutation(
{ ids: [row.original.id] },
{
onSuccess() {
showSuccessNotification({
title: t(`action.${action}.notification.success.title`),
message: t(`action.${action}.notification.success.message`),
});
},
onError() {
showErrorNotification({
title: t(`action.${action}.notification.error.title`),
message: t(`action.${action}.notification.error.message`),
});
},
},
);
};
return (
<Group wrap="nowrap" gap="xs">
<Tooltip label={row.original.state === "running" ? t("action.stop.label") : t("action.start.label")}>
<ActionIcon
variant="subtle"
size="xs"
radius="100%"
onClick={() => handleActionAsync(row.original.state === "running" ? "stop" : "start")}
>
{row.original.state === "running" ? (
<IconPlayerStop style={actionIconIconStyle} />
) : (
<IconPlayerPlay style={actionIconIconStyle} />
)}
</ActionIcon>
</Tooltip>
<Tooltip label={t("action.restart.label")}>
<ActionIcon variant="subtle" size="xs" radius="100%" onClick={() => handleActionAsync("restart")}>
<IconRotateClockwise style={actionIconIconStyle} />
</ActionIcon>
</Tooltip>
</Group>
);
},
},
];
export default function DockerWidget({ options, width, isEditMode }: WidgetComponentProps<"dockerContainers">) {
const t = useScopedI18n("docker");
const isTiny = width <= 256;
const utils = clientApi.useUtils();
const [{ containers, timestamp }] = clientApi.docker.getContainers.useSuspenseQuery();
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 columns = useMemo(() => createColumns(t), [t]);
const table = useTranslatedMantineReactTable({
columns,
data: containers,
enablePagination: false,
enableTopToolbar: false,
enableBottomToolbar: false,
enableColumnActions: false,
enableSorting: options.enableRowSorting && !isEditMode,
enableStickyHeader: false,
enableColumnOrdering: false,
enableRowSelection: false,
enableFullScreenToggle: false,
enableGlobalFilter: false,
enableDensityToggle: false,
enableFilters: false,
enableHiding: false,
initialState: {
sorting: [{ id: options.defaultSort, desc: options.descendingDefaultSort }],
density: "xs",
},
mantinePaperProps: {
flex: 1,
withBorder: false,
shadow: undefined,
},
mantineTableProps: {
className: "docker-widget-table",
style: {
tableLayout: "fixed",
},
},
mantineTableHeadProps: {
fz: "xs",
},
mantineTableHeadCellProps: {
p: 4,
},
mantineTableBodyCellProps: {
p: 4,
},
mantineTableContainerProps: {
style: {
height: "100%",
},
},
});
return (
<Stack gap={0} h="100%" display="flex">
<MantineReactTable table={table} />
{!isTiny && (
<Group
justify="space-between"
style={{
borderTop: "0.0625rem solid var(--border-color)",
}}
p={4}
>
<Group gap={4}>
<IconBrandDocker size={20} />
<Text size="sm">{t("table.footer", { count: totalContainers.toString() })}</Text>
</Group>
<Text size="sm">{t("table.updated", { when: relativeTime })}</Text>
</Group>
)}
</Stack>
);
}

View File

@@ -0,0 +1,40 @@
import { IconBrandDocker, IconServerOff } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options";
const columnsList = [
"name",
"state",
"cpuUsage",
"memoryUsage",
] as const satisfies (keyof RouterOutputs["docker"]["getContainers"]["containers"][number])[];
export const { definition, componentLoader } = createWidgetDefinition("dockerContainers", {
icon: IconBrandDocker,
createOptions() {
return optionsBuilder.from((factory) => ({
enableRowSorting: factory.switch({
defaultValue: false,
}),
defaultSort: factory.select({
defaultValue: "name",
options: columnsList.map((value) => ({
value,
label: (t) => t(`widget.dockerContainers.option.defaultSort.option.${value}`),
})),
}),
descendingDefaultSort: factory.switch({
defaultValue: false,
}),
}));
},
errors: {
INTERNAL_SERVER_ERROR: {
icon: IconServerOff,
message: (t) => t("widget.dockerContainers.error.internalServerError"),
},
},
}).withDynamicImport(() => import("./component"));

View File

@@ -0,0 +1,951 @@
"use client";
import "../widgets-common.css";
import { useCallback, useMemo, useState } from "react";
import type { MantineStyleProp } from "@mantine/core";
import {
ActionIcon,
Avatar,
AvatarGroup,
Button,
Center,
Chip,
Divider,
Group,
Modal,
Paper,
Popover,
Progress,
Space,
Stack,
Text,
Title,
Tooltip,
} from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import {
IconAlertTriangle,
IconCirclesRelation,
IconFilter,
IconInfinity,
IconInfoCircle,
IconPlayerPause,
IconPlayerPlay,
IconTrash,
IconX,
} from "@tabler/icons-react";
import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
import type { MRT_ColumnDef, MRT_VisibilityState } from "mantine-react-table";
import { MantineReactTable, useMantineReactTable } from "mantine-react-table";
import { clientApi } from "@homarr/api/client";
import { useIntegrationsWithInteractAccess } from "@homarr/auth/client";
import { humanFileSize, useIntegrationConnected } from "@homarr/common";
import { getIconUrl, getIntegrationKindsByCategory } from "@homarr/definitions";
import type { ExtendedClientStatus, ExtendedDownloadClientItem } from "@homarr/integrations";
import { useScopedI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition";
dayjs.extend(relativeTime);
interface QuickFilter {
integrationKinds: string[];
statuses: ExtendedDownloadClientItem["state"][];
}
//Ratio table for relative width between columns
const columnsRatios: Record<keyof ExtendedDownloadClientItem, number> = {
actions: 2,
added: 4,
category: 1,
downSpeed: 3,
id: 1,
index: 1,
integration: 1,
name: 8,
progress: 4,
ratio: 2,
received: 3,
sent: 3,
size: 3,
state: 3,
time: 4,
type: 2,
upSpeed: 3,
};
export default function DownloadClientsWidget({
isEditMode,
integrationIds,
options,
setOptions,
}: WidgetComponentProps<"downloads">) {
const integrationsWithInteractions = useIntegrationsWithInteractAccess().flatMap(({ id }) =>
integrationIds.includes(id) ? [id] : [],
);
const [currentItems] = clientApi.widget.downloads.getJobsAndStatuses.useSuspenseQuery(
{
integrationIds,
limitPerIntegration: options.limitPerIntegration,
},
{
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
retry: false,
},
);
const utils = clientApi.useUtils();
//Translations
const t = useScopedI18n("widget.downloads");
const tCommon = useScopedI18n("common");
//Item modal state and selection
const [clickedIndex, setClickedIndex] = useState<number>(0);
const [opened, { open, close }] = useDisclosure(false);
//User quick settings for filters
const [quickFilters, setQuickFilters] = useState<QuickFilter>({ integrationKinds: [], statuses: [] });
const availableStatuses = useMemo<QuickFilter["statuses"]>(() => {
//Redefine list of available statuses from current items
const statuses = Array.from(new Set(currentItems.flatMap(({ data }) => data.items.map(({ state }) => state))));
//Reset user filters accordingly to remove unavailable statuses
setQuickFilters(({ integrationKinds: names, statuses: prevStatuses }) => {
return { integrationKinds: names, statuses: prevStatuses.filter((status) => statuses.includes(status)) };
});
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();
//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
const data = useMemo<ExtendedDownloadClientItem[]>(
() =>
currentItems
//Insure it is only using selected integrations
.filter(({ integration }) => integrationIds.includes(integration.id))
//Construct normalized items list
.flatMap((pair) =>
//Apply user white/black list
pair.data.items
.filter(
({ category }) =>
options.filterIsWhitelist ===
options.categoryFilter.some((filter) =>
(Array.isArray(category) ? category : [category]).includes(filter),
),
)
//Filter completed items following widget option
.filter(
({ type, progress, upSpeed }) =>
(type === "torrent" &&
((progress === 1 &&
options.showCompletedTorrent &&
(upSpeed ?? 0) >= Number(options.activeTorrentThreshold) * 1024) ||
progress !== 1)) ||
(type === "usenet" && ((progress === 1 && options.showCompletedUsenet) || progress !== 1)) ||
(type === "miscellaneous" && ((progress === 1 && options.showCompletedHttp) || progress !== 1)),
)
//Filter following user quick setting
.filter(
({ state }) =>
(quickFilters.integrationKinds.length === 0 ||
quickFilters.integrationKinds.includes(pair.integration.name)) &&
(quickFilters.statuses.length === 0 || quickFilters.statuses.includes(state)),
)
//Add extrapolated data and actions if user is allowed interaction
.map((item): ExtendedDownloadClientItem => {
const received = item.received ?? Math.floor(item.size * item.progress);
const integrationIds = [pair.integration.id];
return {
integration: pair.integration,
...item,
category: item.category !== undefined && item.category.length > 0 ? item.category : undefined,
received,
ratio: item.sent !== undefined ? item.sent / item.size : undefined,
//Only add if permission to use mutations
actions: integrationsWithInteractions.includes(pair.integration.id)
? {
resume: () => mutateResumeItem({ integrationIds, item }),
pause: () => mutatePauseItem({ integrationIds, item }),
delete: ({ fromDisk }) => mutateDeleteItem({ integrationIds, item, fromDisk }),
}
: undefined,
};
}),
)
//flatMap already sorts by integration by nature, add sorting by integration type (usenet | torrent)
.sort(({ type: typeA }, { type: typeB }) => typeA.length - typeB.length),
[
currentItems,
integrationIds,
integrationsWithInteractions,
mutateDeleteItem,
mutatePauseItem,
mutateResumeItem,
options.activeTorrentThreshold,
options.categoryFilter,
options.filterIsWhitelist,
options.showCompletedTorrent,
options.showCompletedUsenet,
options.showCompletedHttp,
quickFilters,
],
);
//Flatten Clients Array for which each elements has the integration and general client infos.
const clients = useMemo<ExtendedClientStatus[]>(
() =>
currentItems
.filter(({ integration }) => integrationIds.includes(integration.id))
.flatMap(({ integration, data }): ExtendedClientStatus => {
const interact = integrationsWithInteractions.includes(integration.id);
const isTorrent = getIntegrationKindsByCategory("torrent").some((kind) => kind === integration.kind);
/** Derived from current items */
const { totalUp, totalDown } = data.items
.filter(
({ category }) =>
!options.applyFilterToRatio ||
!data.status.types.includes("torrent") ||
options.filterIsWhitelist ===
options.categoryFilter.some((filter) =>
(Array.isArray(category) ? category : [category]).includes(filter),
),
)
.reduce(
({ totalUp, totalDown }, { sent, size, progress }) => ({
totalUp: isTorrent ? (totalUp ?? 0) + (sent ?? 0) : undefined,
totalDown: totalDown + size * progress,
}),
{ totalDown: 0, totalUp: isTorrent ? 0 : undefined },
);
return {
integration,
interact,
status: {
totalUp,
totalDown,
ratio: totalUp === undefined ? undefined : totalUp / totalDown,
...data.status,
},
};
})
.sort(
({ status: statusA }, { status: statusB }) =>
(statusA?.types.length ?? Infinity) - (statusB?.types.length ?? Infinity),
),
[
currentItems,
integrationIds,
integrationsWithInteractions,
options.applyFilterToRatio,
options.categoryFilter,
options.filterIsWhitelist,
],
);
//Check existing types between torrents and usenet
const integrationTypes: ExtendedDownloadClientItem["type"][] = [];
if (data.some(({ type }) => type === "torrent")) integrationTypes.push("torrent");
if (data.some(({ type }) => type === "usenet")) integrationTypes.push("usenet");
if (data.some(({ type }) => type === "miscellaneous")) integrationTypes.push("miscellaneous");
//Set the visibility of columns depending on widget settings and available data/integrations.
const columnVisibility: MRT_VisibilityState = {
id: options.columns.includes("id"),
actions: options.columns.includes("actions") && integrationsWithInteractions.length > 0,
added: options.columns.includes("added"),
category: options.columns.includes("category"),
downSpeed: options.columns.includes("downSpeed"),
index: options.columns.includes("index"),
integration: options.columns.includes("integration") && clients.length > 1,
name: options.columns.includes("name"),
progress: options.columns.includes("progress"),
ratio: options.columns.includes("ratio") && integrationTypes.includes("torrent"),
received: options.columns.includes("received"),
sent: options.columns.includes("sent") && integrationTypes.includes("torrent"),
size: options.columns.includes("size"),
state: options.columns.includes("state"),
time: options.columns.includes("time"),
type: options.columns.includes("type") && integrationTypes.length > 1,
upSpeed: options.columns.includes("upSpeed") && integrationTypes.includes("torrent"),
} satisfies Record<keyof ExtendedDownloadClientItem, boolean>;
//Default styling behavior for stopping interaction when editing. (Applied everywhere except the table header)
const editStyle: MantineStyleProp = {
pointerEvents: isEditMode ? "none" : undefined,
};
//Base element in common with all columns
const columnsDefBase = useCallback(
({
key,
showHeader,
}: {
key: keyof ExtendedDownloadClientItem;
showHeader: boolean;
}): MRT_ColumnDef<ExtendedDownloadClientItem> => {
return {
id: key,
accessorKey: key,
header: key,
size: columnsRatios[key],
Header: () =>
showHeader ? (
<Text fz="xs" fw={700}>
{t(`items.${key}.columnTitle`)}
</Text>
) : null,
};
},
[t],
);
//Make columns and cell elements, Memoized to data with deps on data and EditMode
const columns = useMemo<MRT_ColumnDef<ExtendedDownloadClientItem>[]>(
() => [
{
...columnsDefBase({ key: "actions", showHeader: false }),
enableSorting: false,
Cell: ({ cell, row }) => {
const actions = cell.getValue<ExtendedDownloadClientItem["actions"]>();
const pausedAction = row.original.state === "paused" ? "resume" : "pause";
const [opened, { open, close }] = useDisclosure(false);
return actions ? (
<Group wrap="nowrap" gap="xs">
<Tooltip label={t(`actions.item.${pausedAction}`)}>
<ActionIcon size="xs" variant="light" radius="100%" onClick={actions[pausedAction]}>
{pausedAction === "resume" ? <IconPlayerPlay /> : <IconPlayerPause />}
</ActionIcon>
</Tooltip>
<Tooltip label={t("actions.item.delete.title")}>
<ActionIcon size="xs" color="red" radius="100%" onClick={open}>
<IconTrash />
</ActionIcon>
</Tooltip>
<Modal opened={opened} onClose={close} title={t("actions.item.delete.modalTitle")} size="auto" centered>
<Group>
<Button
color="red"
onClick={() => {
close();
actions.delete({ fromDisk: false });
}}
>
{t("actions.item.delete.entry")}
</Button>
<Button
color="red"
onClick={() => {
close();
actions.delete({ fromDisk: true });
}}
leftSection={<IconAlertTriangle />}
>
{t("actions.item.delete.entryAndFiles")}
</Button>
<Button color="green" onClick={close}>
{tCommon("action.cancel")}
</Button>
</Group>
</Modal>
</Group>
) : (
<ActionIcon size="xs" radius="100%" disabled>
<IconX />
</ActionIcon>
);
},
},
{
...columnsDefBase({ key: "added", showHeader: true }),
sortUndefined: "last",
Cell: ({ cell }) => {
const added = cell.getValue<ExtendedDownloadClientItem["added"]>();
return <Text size="xs">{added !== undefined ? dayjs(added).fromNow() : "unknown"}</Text>;
},
},
{
...columnsDefBase({ key: "category", showHeader: false }),
sortUndefined: "last",
Cell: ({ cell }) => {
const category = cell.getValue<ExtendedDownloadClientItem["category"]>();
return (
category !== undefined && (
<Tooltip label={category}>
<IconInfoCircle size={16} />
</Tooltip>
)
);
},
},
{
...columnsDefBase({ key: "downSpeed", showHeader: true }),
sortUndefined: "last",
Cell: ({ cell }) => {
const downSpeed = cell.getValue<ExtendedDownloadClientItem["downSpeed"]>();
return downSpeed ? <Text size="xs">{humanFileSize(downSpeed, "/s")}</Text> : null;
},
},
{
...columnsDefBase({ key: "id", showHeader: false }),
enableSorting: false,
Cell: ({ cell }) => {
const id = cell.getValue<ExtendedDownloadClientItem["id"]>();
return (
<Tooltip label={id}>
<IconCirclesRelation size={16} />
</Tooltip>
);
},
},
{
...columnsDefBase({ key: "index", showHeader: true }),
Cell: ({ cell }) => {
const index = cell.getValue<ExtendedDownloadClientItem["index"]>();
return <Text size="xs">{index}</Text>;
},
},
{
...columnsDefBase({ key: "integration", showHeader: false }),
Cell: ({ cell }) => {
const integration = cell.getValue<ExtendedDownloadClientItem["integration"]>();
return (
<Tooltip label={integration.name}>
<Avatar size="xs" radius={0} src={getIconUrl(integration.kind)} />
</Tooltip>
);
},
},
{
...columnsDefBase({ key: "name", showHeader: true }),
Cell: ({ cell }) => {
const name = cell.getValue<ExtendedDownloadClientItem["name"]>();
return (
<Text size="xs" lineClamp={1} style={{ wordBreak: "break-all" }}>
{name}
</Text>
);
},
},
{
...columnsDefBase({ key: "progress", showHeader: true }),
Cell: ({ cell, row }) => {
const progress = cell.getValue<ExtendedDownloadClientItem["progress"]>();
return (
<Group align="center" gap="xs" wrap="nowrap" w="100%">
<Text size="xs">
{new Intl.NumberFormat("en", {
style: "percent",
notation: "compact",
unitDisplay: "narrow",
roundingMode: "floor",
}).format(progress)}
</Text>
<Progress
w="100%"
value={Math.floor(progress * 100)}
color={row.original.state === "paused" ? "yellow" : progress === 1 ? "green" : "blue"}
radius="lg"
/>
</Group>
);
},
},
{
...columnsDefBase({ key: "ratio", showHeader: true }),
sortUndefined: "last",
Cell: ({ cell }) => {
const ratio = cell.getValue<ExtendedDownloadClientItem["ratio"]>();
return ratio !== undefined && <Text size="xs">{ratio.toFixed(ratio >= 100 ? 0 : ratio >= 10 ? 1 : 2)}</Text>;
},
},
{
...columnsDefBase({ key: "received", showHeader: true }),
Cell: ({ cell }) => {
const received = cell.getValue<ExtendedDownloadClientItem["received"]>();
return <Text size="xs">{humanFileSize(received)}</Text>;
},
},
{
...columnsDefBase({ key: "sent", showHeader: true }),
sortUndefined: "last",
Cell: ({ cell }) => {
const sent = cell.getValue<ExtendedDownloadClientItem["sent"]>();
return sent && <Text size="xs">{humanFileSize(sent)}</Text>;
},
},
{
...columnsDefBase({ key: "size", showHeader: true }),
Cell: ({ cell }) => {
const size = cell.getValue<ExtendedDownloadClientItem["size"]>();
return <Text size="xs">{humanFileSize(size)}</Text>;
},
},
{
...columnsDefBase({ key: "state", showHeader: true }),
enableSorting: false,
Cell: ({ cell }) => {
const state = cell.getValue<ExtendedDownloadClientItem["state"]>();
return <Text size="xs">{t(`states.${state}`)}</Text>;
},
},
{
...columnsDefBase({ key: "time", showHeader: true }),
Cell: ({ cell }) => {
const time = cell.getValue<ExtendedDownloadClientItem["time"]>();
return time === 0 ? <IconInfinity size={16} /> : <Text size="xs">{dayjs().add(time).fromNow()}</Text>;
},
},
{
...columnsDefBase({ key: "type", showHeader: true }),
Cell: ({ cell }) => {
const type = cell.getValue<ExtendedDownloadClientItem["type"]>();
return <Text size="xs">{type}</Text>;
},
},
{
...columnsDefBase({ key: "upSpeed", showHeader: true }),
sortUndefined: "last",
Cell: ({ cell }) => {
const upSpeed = cell.getValue<ExtendedDownloadClientItem["upSpeed"]>();
return upSpeed && <Text size="xs">{humanFileSize(upSpeed, "/s")}</Text>;
},
},
],
[columnsDefBase, t, tCommon],
);
//Table build and config
const table = useMantineReactTable({
columns,
data,
enablePagination: false,
enableTopToolbar: false,
enableBottomToolbar: false,
enableColumnActions: false,
enableSorting: options.enableRowSorting && !isEditMode,
enableMultiSort: true,
enableStickyHeader: false,
enableColumnOrdering: isEditMode,
enableRowVirtualization: true,
rowVirtualizerOptions: { overscan: 5 },
mantinePaperProps: { flex: 1, withBorder: false, shadow: undefined },
mantineTableContainerProps: { style: { height: "100%" } },
mantineTableProps: {
className: "downloads-widget-table",
},
mantineTableBodyProps: { style: editStyle },
mantineTableHeadCellProps: {
p: 4,
},
mantineTableBodyCellProps: ({ cell, row }) => ({
onClick: () => {
setClickedIndex(row.index);
if (cell.column.id !== "actions") open();
},
p: 4,
}),
onColumnOrderChange: (order) => {
//Order has a tendency to add the disabled column at the end of the the real ordered array
const columnOrder = (order as typeof options.columns).filter((column) => options.columns.includes(column));
setOptions({ newOptions: { columns: columnOrder } });
},
initialState: {
sorting: [{ id: options.defaultSort, desc: options.descendingDefaultSort }],
columnVisibility: {
actions: false,
added: false,
category: false,
downSpeed: false,
id: false,
index: false,
integration: false,
name: false,
progress: false,
ratio: false,
received: false,
sent: false,
size: false,
state: false,
time: false,
type: false,
upSpeed: false,
} satisfies Record<keyof ExtendedDownloadClientItem, boolean>,
columnOrder: options.columns,
},
state: {
columnVisibility,
columnOrder: options.columns,
},
});
//Used for Global Torrent Ratio
const globalTraffic = clients
.filter(({ integration: { kind } }) =>
getIntegrationKindsByCategory("torrent").some((integrationKind) => integrationKind === kind),
)
.reduce(
({ up, down }, { status }) => ({
up: up + (status?.totalUp ?? 0),
down: down + (status?.totalDown ?? 0),
}),
{ up: 0, down: 0 },
);
if (options.columns.length === 0)
return (
<Center h="100%">
<Text>{t("errors.noColumns")}</Text>
</Center>
);
//The actual widget
return (
<Stack gap={0} h="100%" display="flex">
<MantineReactTable table={table} />
<Group
p={4}
justify={integrationTypes.includes("torrent") ? "space-between" : "end"}
style={{
borderTop: "0.0625rem solid var(--border-color)",
}}
>
{integrationTypes.includes("torrent") && (
<Group>
<Text size="xs" fw="bold">{`${t("globalRatio")}:`}</Text>
<Text size="xs">{(globalTraffic.up / globalTraffic.down).toFixed(2)}</Text>
</Group>
)}
<ClientsControl
clients={clients}
filters={quickFilters}
setFilters={setQuickFilters}
availableStatuses={availableStatuses}
/>
</Group>
<ItemInfoModal items={data} currentIndex={clickedIndex} opened={opened} onClose={close} />
</Stack>
);
}
interface ItemInfoModalProps {
items: ExtendedDownloadClientItem[];
currentIndex: number;
opened: boolean;
onClose: () => void;
}
const ItemInfoModal = ({ items, currentIndex, opened, onClose }: ItemInfoModalProps) => {
const item = useMemo<ExtendedDownloadClientItem | undefined>(() => items[currentIndex], [items, currentIndex]);
const t = useScopedI18n("widget.downloads.states");
//The use case for "No item found" should be impossible, hence no translation
return (
<Modal opened={opened} onClose={onClose} centered title={item?.id ?? "ERROR"} size="auto">
{item === undefined ? (
<Center>{"No item found"}</Center>
) : (
<Stack align="center">
<Title>{item.name}</Title>
<Group>
<Avatar src={getIconUrl(item.integration.kind)} />
<Text>{`${item.integration.name} (${item.integration.kind})`}</Text>
</Group>
<NormalizedLine itemKey="index" values={item.index} />
<NormalizedLine itemKey="type" values={item.type} />
<NormalizedLine itemKey="state" values={t(item.state)} />
{item.type !== "miscellaneous" && (
<NormalizedLine
itemKey="upSpeed"
values={item.upSpeed === undefined ? undefined : humanFileSize(item.upSpeed, "/s")}
/>
)}
<NormalizedLine
itemKey="downSpeed"
values={item.downSpeed === undefined ? undefined : humanFileSize(item.downSpeed, "/s")}
/>
{item.type !== "miscellaneous" && (
<NormalizedLine itemKey="sent" values={item.sent === undefined ? undefined : humanFileSize(item.sent)} />
)}
<NormalizedLine itemKey="received" values={humanFileSize(item.received)} />
<NormalizedLine itemKey="size" values={humanFileSize(item.size)} />
<NormalizedLine
itemKey="progress"
values={new Intl.NumberFormat("en", {
style: "percent",
notation: "compact",
unitDisplay: "narrow",
}).format(item.progress)}
/>
{item.type !== "miscellaneous" && <NormalizedLine itemKey="ratio" values={item.ratio} />}
<NormalizedLine itemKey="added" values={item.added === undefined ? "unknown" : dayjs(item.added).format()} />
<NormalizedLine
itemKey="time"
values={item.time !== 0 ? dayjs().add(item.time, "milliseconds").fromNow() : "∞"}
/>
<NormalizedLine itemKey="category" values={item.category} />
</Stack>
)}
</Modal>
);
};
const NormalizedLine = ({
itemKey,
values,
}: {
itemKey: Exclude<keyof ExtendedDownloadClientItem, "integration" | "actions" | "name" | "id">;
values?: number | string | string[];
}) => {
const t = useScopedI18n("widget.downloads.items");
if (typeof values !== "number" && (values === undefined || values.length === 0)) return null;
return (
<Group w="100%" display="flex" align="top" justify="space-between" wrap="nowrap">
<Text>{`${t(`${itemKey}.detailsTitle`)}:`}</Text>
{Array.isArray(values) ? (
<Stack>
{values.map((value) => (
<Text key={value}>{value}</Text>
))}
</Stack>
) : (
<Text>{values}</Text>
)}
</Group>
);
};
interface ClientsControlProps {
clients: ExtendedClientStatus[];
filters: QuickFilter;
setFilters: (filters: QuickFilter) => void;
availableStatuses: QuickFilter["statuses"];
}
const ClientsControl = ({ clients, filters, setFilters, availableStatuses }: ClientsControlProps) => {
const integrationsStatuses = clients.reduce(
(acc, { status, integration: { id }, interact }) =>
status && interact ? (acc[status.paused ? "paused" : "active"].push(id), acc) : acc,
{ paused: [] as string[], active: [] as string[] },
);
const someInteract = clients.some(({ interact }) => interact);
const totalSpeed = humanFileSize(
clients.reduce((count, { status }) => count + (status?.rates.down ?? 0), 0),
"/s",
);
const { mutate: mutateResumeQueue } = clientApi.widget.downloads.resume.useMutation();
const { mutate: mutatePauseQueue } = clientApi.widget.downloads.pause.useMutation();
const [opened, { open, close }] = useDisclosure(false);
const t = useScopedI18n("widget.downloads");
return (
<Group gap={5}>
<Popover withinPortal={false} offset={0}>
<Popover.Target>
<ActionIcon size="xs" radius="lg" variant="light">
<IconFilter />
</ActionIcon>
</Popover.Target>
<Popover.Dropdown>
<Stack gap="md" align="center">
<Text fw="700">{t("items.integration.columnTitle")}</Text>
<Chip.Group
multiple
value={filters.integrationKinds}
onChange={(names) => setFilters({ ...filters, integrationKinds: names })}
>
{clients.map(({ integration }) => (
<Chip key={integration.id} value={integration.name}>
{integration.name}
</Chip>
))}
</Chip.Group>
<Text fw="700">{t("items.state.columnTitle")}</Text>
<Chip.Group
multiple
value={filters.statuses}
onChange={(statuses) => setFilters({ ...filters, statuses: statuses as typeof filters.statuses })}
>
{availableStatuses.map((status) => (
<Chip key={status} value={status}>
{t(`states.${status}`)}
</Chip>
))}
</Chip.Group>
</Stack>
</Popover.Dropdown>
</Popover>
<AvatarGroup>
{clients.map((client) => (
<ClientAvatar key={client.integration.id} client={client} />
))}
</AvatarGroup>
{someInteract && (
<Tooltip label={t("actions.clients.resume")}>
<ActionIcon
size="xs"
radius="lg"
disabled={integrationsStatuses.paused.length === 0}
variant="light"
onClick={() => mutateResumeQueue({ integrationIds: integrationsStatuses.paused })}
>
<IconPlayerPlay />
</ActionIcon>
</Tooltip>
)}
<Button
h={20}
size="xs"
variant="light"
radius="lg"
fw="500"
onClick={open}
styles={{ label: { height: "fit-content" } }}
>
{totalSpeed}
</Button>
{someInteract && (
<Tooltip label={t("actions.clients.pause")}>
<ActionIcon
size="xs"
radius="xl"
disabled={integrationsStatuses.active.length === 0}
variant="light"
onClick={() => mutatePauseQueue({ integrationIds: integrationsStatuses.active })}
>
<IconPlayerPause />
</ActionIcon>
</Tooltip>
)}
<Modal opened={opened} onClose={close} title={t("actions.clients.modalTitle")} centered size="auto">
<Stack gap="10px">
{clients.map((client) => (
<Stack key={client.integration.id} gap="10px">
<Divider />
<Group wrap="nowrap" w="100%">
<Paper withBorder radius={999}>
<Group gap={5} pl={10} pr={15} fz={16} w={275} justify="space-between" wrap="nowrap">
<Avatar radius={0} src={getIconUrl(client.integration.kind)} />
{client.status ? (
<Tooltip disabled={client.status.ratio === undefined} label={client.status.ratio?.toFixed(2)}>
<Stack gap={0} pt={5} h={60} justify="center" flex={1}>
{client.status.rates.up !== undefined ? (
<Group display="flex" justify="center" c="green" w="100%" gap={5}>
<Text flex={1} ta="right">
{`${humanFileSize(client.status.rates.up, "/s")}`}
</Text>
<Text>{"-"}</Text>
<Text flex={1} ta="left">
{humanFileSize(client.status.totalUp ?? 0)}
</Text>
</Group>
) : undefined}
<Group display="flex" justify="center" c="blue" w="100%" gap={5}>
<Text flex={1} ta="right">
{`${humanFileSize(client.status.rates.down, "/s")}`}
</Text>
<Text>{"-"}</Text>
<Text flex={1} ta="left">
{humanFileSize(Math.floor(client.status.totalDown ?? 0))}
</Text>
</Group>
</Stack>
</Tooltip>
) : (
<Text c="red" ta="center">
{t("errors.noCommunications")}
</Text>
)}
</Group>
</Paper>
<Text lineClamp={1} fz={22}>
{client.integration.name}
</Text>
<Space flex={1} />
{client.status && client.interact ? (
<Tooltip label={t(`actions.client.${client.status.paused ? "resume" : "pause"}`)}>
<ActionIcon
radius={999}
variant="light"
size="lg"
onClick={() => {
(client.status?.paused ? mutateResumeQueue : mutatePauseQueue)({
integrationIds: [client.integration.id],
});
}}
>
{client.status.paused ? <IconPlayerPlay /> : <IconPlayerPause />}
</ActionIcon>
</Tooltip>
) : (
<ActionIcon radius={999} variant="light" size="lg" disabled>
<IconX />
</ActionIcon>
)}
</Group>
</Stack>
))}
</Stack>
</Modal>
</Group>
);
};
interface ClientAvatarProps {
client: ExtendedClientStatus;
}
const ClientAvatar = ({ client }: ClientAvatarProps) => {
const isConnected = useIntegrationConnected(client.integration.updatedAt, { timeout: 30000 });
return (
<Avatar
key={client.integration.id}
src={getIconUrl(client.integration.kind)}
style={{ filter: !isConnected ? "grayscale(100%)" : undefined }}
size="sm"
p={5}
/>
);
};

View File

@@ -0,0 +1,122 @@
import { IconDownload } from "@tabler/icons-react";
import { z } from "zod/v4";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import type { ExtendedDownloadClientItem } from "@homarr/integrations";
import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options";
const columnsList = [
"id",
"actions",
"added",
"category",
"downSpeed",
"index",
"integration",
"name",
"progress",
"ratio",
"received",
"sent",
"size",
"state",
"time",
"type",
"upSpeed",
] as const satisfies (keyof ExtendedDownloadClientItem)[];
const sortingExclusion = ["actions", "id", "state"] as const satisfies readonly (typeof columnsList)[number][];
const columnsSort = columnsList.filter((column) =>
sortingExclusion.some((exclusion) => exclusion !== column),
) as Exclude<typeof columnsList, (typeof sortingExclusion)[number]>;
export const { definition, componentLoader } = createWidgetDefinition("downloads", {
icon: IconDownload,
createOptions() {
return optionsBuilder.from(
(factory) => ({
columns: factory.multiSelect({
defaultValue: ["integration", "name", "progress", "time", "actions"],
options: columnsList.map((value) => ({
value,
label: (t) => t(`widget.downloads.items.${value}.columnTitle`),
})),
searchable: true,
}),
enableRowSorting: factory.switch({
defaultValue: false,
}),
defaultSort: factory.select({
defaultValue: "type",
options: columnsSort.map((value) => ({
value,
label: (t) => t(`widget.downloads.items.${value}.columnTitle`),
})),
}),
descendingDefaultSort: factory.switch({
defaultValue: false,
}),
showCompletedUsenet: factory.switch({
defaultValue: true,
}),
showCompletedTorrent: factory.switch({
defaultValue: true,
}),
showCompletedHttp: factory.switch({
defaultValue: true,
}),
activeTorrentThreshold: factory.number({
//in KiB/s
validate: z.number().min(0),
defaultValue: 0,
step: 1,
}),
categoryFilter: factory.multiText({
defaultValue: [] as string[],
validate: z.string(),
}),
filterIsWhitelist: factory.switch({
defaultValue: false,
}),
applyFilterToRatio: factory.switch({
defaultValue: true,
}),
limitPerIntegration: factory.number({
defaultValue: 50,
validate: z.number().min(1),
withDescription: true,
}),
}),
{
defaultSort: {
shouldHide: (options) => !options.enableRowSorting,
},
descendingDefaultSort: {
shouldHide: (options) => !options.enableRowSorting,
},
showCompletedUsenet: {
shouldHide: (_, integrationKinds) =>
!getIntegrationKindsByCategory("usenet").some((kinds) => integrationKinds.includes(kinds)),
},
showCompletedTorrent: {
shouldHide: (_, integrationKinds) =>
!getIntegrationKindsByCategory("torrent").some((kinds) => integrationKinds.includes(kinds)),
},
showCompletedHttp: {
shouldHide: (_, integrationKinds) =>
!getIntegrationKindsByCategory("miscellaneous").some((kinds) => integrationKinds.includes(kinds)),
},
activeTorrentThreshold: {
shouldHide: (_, integrationKinds) =>
!getIntegrationKindsByCategory("torrent").some((kinds) => integrationKinds.includes(kinds)),
},
applyFilterToRatio: {
shouldHide: (_, integrationKinds) =>
!getIntegrationKindsByCategory("torrent").some((kinds) => integrationKinds.includes(kinds)),
},
},
);
},
supportedIntegrations: getIntegrationKindsByCategory("downloadClient"),
}).withDynamicImport(() => import("./component"));

View File

@@ -0,0 +1,36 @@
import { Anchor, Button, Stack, Text } from "@mantine/core";
import type { stringOrTranslation } from "@homarr/translation";
import { translateIfNecessary } from "@homarr/translation";
import { useI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui";
import type { TablerIcon } from "@homarr/ui";
export interface BaseWidgetErrorProps {
icon: TablerIcon;
message: stringOrTranslation;
showLogsLink?: boolean;
onRetry: () => void;
}
export const BaseWidgetError = (props: BaseWidgetErrorProps) => {
const t = useI18n();
return (
<Stack h="100%" align="center" justify="center" gap="md">
<props.icon size={40} />
<Stack gap={0}>
<Text ta="center">{translateIfNecessary(t, props.message)}</Text>
{props.showLogsLink && (
<Anchor component={Link} href="/manage/tools/logs" target="_blank" ta="center" size="sm">
{t("common.action.checkLogs")}
</Anchor>
)}
</Stack>
<Button onClick={props.onRetry} size="sm" variant="light">
{t("common.action.tryAgain")}
</Button>
</Stack>
);
};

View File

@@ -0,0 +1,10 @@
import type { stringOrTranslation } from "@homarr/translation";
import type { TablerIcon } from "@homarr/ui";
export abstract class ErrorBoundaryError extends Error {
public abstract getErrorBoundaryData(): {
icon: TablerIcon;
message: stringOrTranslation;
showLogsLink: boolean;
};
}

View File

@@ -0,0 +1,81 @@
import { useMemo } from "react";
import { IconExclamationCircle, IconShield } from "@tabler/icons-react";
import { TRPCClientError } from "@trpc/client";
import type { DefaultErrorData } from "@trpc/server/unstable-core-do-not-import";
import type { WidgetKind } from "@homarr/definitions";
import type { WidgetDefinition } from "..";
import { widgetImports } from "..";
import { ErrorBoundaryError } from "./base";
import type { BaseWidgetErrorProps } from "./base-component";
import { BaseWidgetError } from "./base-component";
interface WidgetErrorProps {
kind: WidgetKind;
error: unknown;
resetErrorBoundary: () => void;
}
export const WidgetError = ({ error, resetErrorBoundary, kind }: WidgetErrorProps) => {
const currentDefinition = useMemo(() => widgetImports[kind].definition, [kind]);
if (error instanceof ErrorBoundaryError) {
return <BaseWidgetError {...error.getErrorBoundaryData()} onRetry={resetErrorBoundary} />;
}
const widgetTrpcErrorData = handleWidgetTrpcError(error, currentDefinition);
if (widgetTrpcErrorData) {
return <BaseWidgetError {...widgetTrpcErrorData} onRetry={resetErrorBoundary} />;
}
const trpcErrorData = handleCommonTrpcError(error);
if (trpcErrorData) {
return <BaseWidgetError {...trpcErrorData} onRetry={resetErrorBoundary} />;
}
return (
<BaseWidgetError
icon={IconExclamationCircle}
message={(error as { toString: () => string }).toString()}
onRetry={resetErrorBoundary}
/>
);
};
const handleWidgetTrpcError = (
error: unknown,
currentDefinition: WidgetDefinition,
): Omit<BaseWidgetErrorProps, "onRetry"> | null => {
if (!(error instanceof TRPCClientError && "code" in error.data)) return null;
const errorData = error.data as DefaultErrorData;
if (!("errors" in currentDefinition) || currentDefinition.errors === undefined) return null;
const errors: Exclude<WidgetDefinition["errors"], undefined> = currentDefinition.errors;
const errorDefinition = errors[errorData.code];
if (!errorDefinition) return null;
return {
...errorDefinition,
showLogsLink: !errorDefinition.hideLogsLink,
};
};
const handleCommonTrpcError = (error: unknown): Omit<BaseWidgetErrorProps, "onRetry"> | null => {
if (!(error instanceof TRPCClientError && "code" in error.data)) return null;
const errorData = error.data as DefaultErrorData;
if (errorData.code === "UNAUTHORIZED" || errorData.code === "FORBIDDEN") {
return {
icon: IconShield,
message: "You don't have permission to access this widget",
showLogsLink: false,
};
}
return null;
};

View File

@@ -0,0 +1,2 @@
export * from "./no-integration-selected";
export * from "./base";

View File

@@ -0,0 +1,19 @@
import { IconDatabaseOff } from "@tabler/icons-react";
import type { TranslationFunction } from "@homarr/translation";
import { ErrorBoundaryError } from "./base";
export class NoIntegrationDataError extends ErrorBoundaryError {
constructor() {
super("No integration data available");
}
public getErrorBoundaryData() {
return {
icon: IconDatabaseOff,
message: (t: TranslationFunction) => t("widget.common.error.noData"),
showLogsLink: false,
};
}
}

View File

@@ -0,0 +1,19 @@
import { IconPlugX } from "@tabler/icons-react";
import type { TranslationFunction } from "@homarr/translation";
import { ErrorBoundaryError } from "./base";
export class NoIntegrationSelectedError extends ErrorBoundaryError {
constructor() {
super("No integration selected");
}
public getErrorBoundaryData() {
return {
icon: IconPlugX,
message: (t: TranslationFunction) => t("widget.common.error.noIntegration"),
showLogsLink: false,
};
}
}

View File

@@ -0,0 +1,397 @@
"use client";
import { useCallback, useState } from "react";
import { Accordion, Box, Center, Flex, Group, RingProgress, ScrollArea, Text } from "@mantine/core";
import { useLocalStorage } from "@mantine/hooks";
import { IconArrowBarDown, IconArrowBarUp, IconBrain, IconCpu, IconTopologyBus } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import type { FirewallInterface, FirewallInterfacesSummary } from "@homarr/integrations";
import { useI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition";
import { FirewallMenu } from "./firewall-menu";
import { FirewallVersion } from "./firewall-version";
export interface Firewall {
label: string;
value: string;
}
export default function FirewallWidget({ integrationIds, width, itemId }: WidgetComponentProps<"firewall">) {
const [selectedFirewall, setSelectedFirewall] = useState<string>("");
const handleSelect = useCallback((value: string | null) => {
if (value !== null) {
setSelectedFirewall(value);
} else {
setSelectedFirewall("default_value");
}
}, []);
const firewallsCpuData = useUpdatingCpuStatus(integrationIds);
const firewallsMemoryData = useUpdatingMemoryStatus(integrationIds);
const firewallsVersionData = useUpdatingVersionStatus(integrationIds);
const firewallsInterfacesData = useUpdatingInterfacesStatus(integrationIds);
const initialSelectedFirewall = firewallsVersionData[0] ? firewallsVersionData[0].integration.id : "undefined";
const isTiny = width < 256;
const [accordionValue, setAccordionValue] = useLocalStorage<string | null>({
key: `homarr-${itemId}-firewall`,
defaultValue: "interfaces",
});
const dropdownItems = firewallsVersionData.map((firewall) => ({
label: firewall.integration.name,
value: firewall.integration.id,
}));
const t = useI18n();
return (
<ScrollArea h="100%">
<Group justify="space-between" w="100%" style={{ padding: "8px" }}>
<FirewallMenu
onChange={handleSelect}
selectedFirewall={selectedFirewall || initialSelectedFirewall}
dropdownItems={dropdownItems}
isTiny={isTiny}
/>
<FirewallVersion
firewallsVersionData={firewallsVersionData}
selectedFirewall={selectedFirewall || initialSelectedFirewall}
isTiny={isTiny}
/>
</Group>
<Flex justify="center" align="center" wrap="wrap">
{/* Render CPU and Memory data */}
{firewallsCpuData
.filter(({ integration }) => integration.id === (selectedFirewall || initialSelectedFirewall))
.map(({ summary, integration }) => (
<RingProgress
key={`${integration.name}-cpu`}
roundCaps
size={isTiny ? 50 : 100}
thickness={isTiny ? 4 : 8}
label={
<Center style={{ flexDirection: "column" }}>
<Text size={isTiny ? "8px" : "xs"}>{`${summary.total.toFixed(2)}%`}</Text>
<IconCpu size={isTiny ? 8 : 16} />
</Center>
}
sections={[
{
value: Number(summary.total.toFixed(1)),
color: summary.total > 50 ? (summary.total < 75 ? "yellow" : "red") : "green",
},
]}
/>
))}
{firewallsMemoryData
.filter(({ integration }) => integration.id === (selectedFirewall || initialSelectedFirewall))
.map(({ summary, integration }) => (
<RingProgress
key={`${integration.name}-memory`}
roundCaps
size={isTiny ? 50 : 100}
thickness={isTiny ? 4 : 8}
label={
<Center style={{ flexDirection: "column" }}>
<Text size={isTiny ? "8px" : "xs"}>{`${summary.percent.toFixed(1)}%`}</Text>
<IconBrain size={isTiny ? 8 : 16} />
</Center>
}
sections={[
{
value: Number(summary.percent.toFixed(1)),
color: summary.percent > 50 ? (summary.percent < 75 ? "yellow" : "red") : "green",
},
]}
/>
))}
</Flex>
{firewallsInterfacesData
.filter(({ integration }) => integration.id === (selectedFirewall || initialSelectedFirewall))
.map(({ summary }) => (
<Accordion key="interfaces" value={accordionValue} onChange={setAccordionValue}>
<Accordion.Item value="interfaces">
<Accordion.Control icon={isTiny ? null : <IconTopologyBus size={16} />}>
<Text size={isTiny ? "8px" : "xs"}> {t("widget.firewall.widget.interfaces.title")} </Text>
</Accordion.Control>
<Accordion.Panel>
<Flex direction="column" key="interfaces">
{Array.isArray(summary) && summary.every((item) => Array.isArray(item.data)) ? (
calculateBandwidth(summary).data.map(({ name, receive, transmit }) => (
<Flex
key={name}
direction={isTiny ? "column" : "row"}
style={{
width: "100%",
padding: isTiny ? "2px" : "0px",
}}
>
<Flex w={isTiny ? "100%" : "33%"} style={{ justifyContent: "flex-start" }}>
<Text
size={isTiny ? "8px" : "xs"}
color="lightblue"
style={{
maxWidth: "100px",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
textAlign: "left",
}}
>
{name}
</Text>
</Flex>
<Flex
align="center"
gap="4"
w={isTiny ? "100%" : "33%"}
style={{ justifyContent: "flex-start" }}
>
<IconArrowBarUp size={isTiny ? "8" : "12"} color="lightgreen" />
<Text size={isTiny ? "8px" : "xs"} color="lightgreen" style={{ textAlign: "left" }}>
{formatBitsPerSec(transmit, 2)}
</Text>
</Flex>
<Flex
align="center"
gap="4"
w={isTiny ? "100%" : "33%"}
style={{ justifyContent: "flex-start" }}
>
<IconArrowBarDown size={isTiny ? "8" : "12"} color="yellow" />
<Text size={isTiny ? "8px" : "xs"} color="yellow" style={{ textAlign: "left" }}>
{formatBitsPerSec(receive, 2)}
</Text>
</Flex>
</Flex>
))
) : (
<Box>No data available</Box>
)}
</Flex>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
))}
</ScrollArea>
);
}
export const useUpdatingCpuStatus = (integrationIds: string[]) => {
const utils = clientApi.useUtils();
const [firewallsCpuData] = clientApi.widget.firewall.getFirewallCpuStatus.useSuspenseQuery(
{
integrationIds,
},
{
refetchOnMount: false,
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 [firewallsMemoryData] = clientApi.widget.firewall.getFirewallMemoryStatus.useSuspenseQuery(
{
integrationIds,
},
{
refetchOnMount: false,
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 [firewallsVersionData] = clientApi.widget.firewall.getFirewallVersionStatus.useSuspenseQuery(
{
integrationIds,
},
{
refetchOnMount: false,
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 [firewallsInterfacesData] = clientApi.widget.firewall.getFirewallInterfacesStatus.useSuspenseQuery(
{
integrationIds,
},
{
refetchOnMount: false,
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";
const kilobyte = 1024;
const sizes = ["b/s", "kb/s", "Mb/s", "Gb/s", "Tb/s", "Pb/s", "Eb/s", "Zb/s", "Yb/s"];
const i = Math.floor(Math.log(bytes) / Math.log(kilobyte));
return `${parseFloat((bytes / Math.pow(kilobyte, i)).toFixed(decimals))} ${sizes[i]}`;
}
export function calculateBandwidth(data: FirewallInterfacesSummary[]): { data: FirewallInterface[] } {
const result = {
data: [] as FirewallInterface[],
timestamp: new Date().toISOString(),
};
if (data.length > 1) {
const firstData = data[0];
const secondData = data[1];
if (firstData && secondData) {
const time1 = new Date(firstData.timestamp);
const time2 = new Date(secondData.timestamp);
const timeDiffInSeconds = (time1.getTime() - time2.getTime()) / 1000;
firstData.data.forEach((iface) => {
const ifaceName = iface.name;
const recv1 = iface.receive;
const trans1 = iface.transmit;
const iface2 = secondData.data.find((i) => i.name === ifaceName);
if (iface2) {
const recv2 = iface2.receive;
const trans2 = iface2.transmit;
const recvDiff = recv1 - recv2;
const transDiff = trans1 - trans2;
result.data.push({
name: ifaceName,
receive: (8 * recvDiff) / timeDiffInSeconds,
transmit: (8 * transDiff) / timeDiffInSeconds,
});
}
});
}
}
return result;
}

View File

@@ -0,0 +1,27 @@
import { Box, Select } from "@mantine/core";
import type { Firewall } from "./component";
interface FirewallMenuProps {
onChange: (value: string | null) => void;
dropdownItems: Firewall[];
selectedFirewall: string;
isTiny: boolean;
}
export const FirewallMenu = ({ onChange, isTiny, dropdownItems, selectedFirewall }: FirewallMenuProps) => (
<Box>
<Select
value={selectedFirewall}
onChange={onChange}
size={isTiny ? "8px" : "xs"}
color="lightgray"
data={dropdownItems}
styles={{
input: {
minHeight: "24px",
},
}}
/>
</Box>
);

View File

@@ -0,0 +1,41 @@
import { Badge, Box } from "@mantine/core";
import type { FirewallVersionSummary } from "@homarr/integrations";
interface FirewallVersionProps {
firewallsVersionData: {
integration: FirewallIntegration;
summary: FirewallVersionSummary;
}[];
selectedFirewall: string;
isTiny: boolean;
}
export interface FirewallIntegration {
id: string;
name: string;
kind: string;
updatedAt: Date;
}
export const FirewallVersion = ({ firewallsVersionData, selectedFirewall, isTiny }: FirewallVersionProps) => (
<Box>
<Badge autoContrast variant="outline" color="lightgray" size={isTiny ? "8px" : "xs"} style={{ minHeight: "24px" }}>
{firewallsVersionData
.filter(({ integration }) => integration.id === selectedFirewall)
.map(({ summary, integration }) => (
<span key={integration.id}>{formatVersion(summary.version)}</span>
))}
</Badge>
</Box>
);
function formatVersion(inputString: string): string {
const regex = /([\d._]+)/;
const match = regex.exec(inputString);
if (match?.[1]) {
return match[1];
} else {
return "Unknown Version";
}
}

View File

@@ -0,0 +1,7 @@
[data-mantine-color-scheme="light"] .card {
background-color: var(--mantine-color-gray-1);
}
[data-mantine-color-scheme="dark"] .card {
background-color: var(--mantine-color-dark-7);
}

View File

@@ -0,0 +1,20 @@
import { IconWall, IconWallOff } from "@tabler/icons-react";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("firewall", {
icon: IconWall,
createOptions() {
return optionsBuilder.from(() => ({}));
},
supportedIntegrations: getIntegrationKindsByCategory("firewall"),
errors: {
INTERNAL_SERVER_ERROR: {
icon: IconWallOff,
message: (t) => t("widget.firewall.error.internalServerError"),
},
},
}).withDynamicImport(() => import("./component"));

View File

@@ -0,0 +1,228 @@
import { Accordion, Center, Flex, Group, RingProgress, Stack, Text } from "@mantine/core";
import { IconBrain, IconCpu, IconCube, IconDatabase, IconDeviceLaptop, IconServer } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import type { Resource } from "@homarr/integrations/types";
import { useI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../../definition";
import { formatUptime } from "../system-health";
import { ResourceAccordionItem } from "./resource-accordion-item";
import { ResourceTable } from "./resource-table";
const addBadgeColor = ({
activeCount,
totalCount,
sectionIndicatorRequirement,
}: {
activeCount: number;
totalCount: number;
sectionIndicatorRequirement: WidgetComponentProps<"healthMonitoring">["options"]["sectionIndicatorRequirement"];
}) => ({
color: activeCount === totalCount || (sectionIndicatorRequirement === "any" && activeCount >= 1) ? "green" : "orange",
activeCount,
totalCount,
});
const running = (total: number, current: Resource) => {
return current.isRunning ? total + 1 : total;
};
export const ClusterHealthMonitoring = ({
integrationId,
options,
width,
}: WidgetComponentProps<"healthMonitoring"> & { integrationId: string }) => {
const t = useI18n();
const [healthData] = clientApi.widget.healthMonitoring.getClusterHealthStatus.useSuspenseQuery(
{
integrationId,
},
{
refetchOnMount: false,
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 activeNodes = healthData.nodes.reduce(running, 0);
const activeVMs = healthData.vms.reduce(running, 0);
const activeLXCs = healthData.lxcs.reduce(running, 0);
const activeStorage = healthData.storages.reduce(running, 0);
const usedMem = healthData.nodes.reduce((sum, item) => (item.isRunning ? item.memory.used + sum : sum), 0);
const maxMem = healthData.nodes.reduce((sum, item) => (item.isRunning ? item.memory.total + sum : sum), 0);
const maxCpu = healthData.nodes.reduce((sum, item) => (item.isRunning ? item.cpu.cores + sum : sum), 0);
const usedCpu = healthData.nodes.reduce(
(sum, item) => (item.isRunning ? item.cpu.utilization * item.cpu.cores + sum : sum),
0,
);
const uptime = healthData.nodes.reduce((sum, { uptime }) => (sum > uptime ? sum : uptime), 0);
const cpuPercent = maxCpu ? (usedCpu / maxCpu) * 100 : 0;
const memPercent = maxMem ? (usedMem / maxMem) * 100 : 0;
const defaultValue = [options.visibleClusterSections.at(0) ?? "node"];
const isTiny = width < 256;
return (
<Stack h="100%" p="xs" gap={isTiny ? "xs" : "md"}>
{options.showUptime && (
<Group justify="center" wrap="nowrap">
<Text fz={isTiny ? 8 : "xs"} tt="uppercase" fw={700} c="dimmed" ta="center">
{formatUptime(uptime, t)}
</Text>
</Group>
)}
<SummaryHeader
cpu={{
value: cpuPercent,
hidden: !options.cpu,
}}
memory={{
value: memPercent,
hidden: !options.memory,
}}
isTiny={isTiny}
/>
{options.visibleClusterSections.length >= 1 && (
<Accordion variant="contained" chevronPosition="right" multiple defaultValue={defaultValue}>
{options.visibleClusterSections.includes("node") && (
<ResourceAccordionItem
value="node"
title={t("widget.healthMonitoring.cluster.resource.node.name")}
icon={IconServer}
badge={addBadgeColor({
activeCount: activeNodes,
totalCount: healthData.nodes.length,
sectionIndicatorRequirement: options.sectionIndicatorRequirement,
})}
isTiny={isTiny}
>
<ResourceTable type="node" data={healthData.nodes} isTiny={isTiny} />
</ResourceAccordionItem>
)}
{options.visibleClusterSections.includes("qemu") && (
<ResourceAccordionItem
value="qemu"
title={t("widget.healthMonitoring.cluster.resource.qemu.name")}
icon={IconDeviceLaptop}
badge={addBadgeColor({
activeCount: activeVMs,
totalCount: healthData.vms.length,
sectionIndicatorRequirement: options.sectionIndicatorRequirement,
})}
isTiny={isTiny}
>
<ResourceTable type="qemu" data={healthData.vms} isTiny={isTiny} />
</ResourceAccordionItem>
)}
{options.visibleClusterSections.includes("lxc") && (
<ResourceAccordionItem
value="lxc"
title={t("widget.healthMonitoring.cluster.resource.lxc.name")}
icon={IconCube}
badge={addBadgeColor({
activeCount: activeLXCs,
totalCount: healthData.lxcs.length,
sectionIndicatorRequirement: options.sectionIndicatorRequirement,
})}
isTiny={isTiny}
>
<ResourceTable type="lxc" data={healthData.lxcs} isTiny={isTiny} />
</ResourceAccordionItem>
)}
{options.visibleClusterSections.includes("storage") && (
<ResourceAccordionItem
value="storage"
title={t("widget.healthMonitoring.cluster.resource.storage.name")}
icon={IconDatabase}
badge={addBadgeColor({
activeCount: activeStorage,
totalCount: healthData.storages.length,
sectionIndicatorRequirement: options.sectionIndicatorRequirement,
})}
isTiny={isTiny}
>
<ResourceTable type="storage" data={healthData.storages} isTiny={isTiny} />
</ResourceAccordionItem>
)}
</Accordion>
)}
</Stack>
);
};
interface SummaryHeaderProps {
cpu: { value: number; hidden: boolean };
memory: { value: number; hidden: boolean };
isTiny: boolean;
}
const SummaryHeader = ({ cpu, memory, isTiny }: SummaryHeaderProps) => {
const t = useI18n();
if (cpu.hidden && memory.hidden) return null;
return (
<Center>
<Group wrap="wrap" justify="center" gap="xs">
{!cpu.hidden && (
<Flex direction="row">
<RingProgress
roundCaps
size={isTiny ? 32 : 48}
thickness={isTiny ? 2 : 4}
label={
<Center>
<IconCpu size={isTiny ? 12 : 20} />
</Center>
}
sections={[{ value: cpu.value, color: cpu.value > 75 ? "orange" : "green" }]}
/>
<Stack align="center" justify="center" gap={0}>
<Text fw={500} size={isTiny ? "xs" : "sm"}>
{t("widget.healthMonitoring.cluster.summary.cpu")}
</Text>
<Text size={isTiny ? "8px" : "xs"}>{cpu.value.toFixed(1)}%</Text>
</Stack>
</Flex>
)}
{!memory.hidden && (
<Flex>
<RingProgress
roundCaps
size={isTiny ? 32 : 48}
thickness={isTiny ? 2 : 4}
label={
<Center>
<IconBrain size={isTiny ? 12 : 20} />
</Center>
}
sections={[{ value: memory.value, color: memory.value > 75 ? "orange" : "green" }]}
/>
<Stack align="center" justify="center" gap={0}>
<Text size={isTiny ? "xs" : "sm"} fw={500}>
{t("widget.healthMonitoring.cluster.summary.memory")}
</Text>
<Text size={isTiny ? "8px" : "xs"}>{memory.value.toFixed(1)}%</Text>
</Stack>
</Flex>
)}
</Group>
</Center>
);
};

View File

@@ -0,0 +1,40 @@
import type { PropsWithChildren } from "react";
import type { MantineColor } from "@mantine/core";
import { Accordion, Badge, Group, Text } from "@mantine/core";
import type { TablerIcon } from "@homarr/ui";
interface ResourceAccordionItemProps {
value: string;
title: string;
icon: TablerIcon;
badge: {
color: MantineColor;
activeCount: number;
totalCount: number;
};
isTiny: boolean;
}
export const ResourceAccordionItem = ({
value,
title,
icon: Icon,
badge,
children,
isTiny,
}: PropsWithChildren<ResourceAccordionItemProps>) => {
return (
<Accordion.Item value={value}>
<Accordion.Control icon={isTiny ? null : <Icon size={16} />}>
<Group style={{ rowGap: "0" }} gap="xs">
<Text size="xs">{title}</Text>
<Badge variant="dot" color={badge.color} size="xs">
{badge.activeCount} / {badge.totalCount}
</Badge>
</Group>
</Accordion.Control>
<Accordion.Panel>{children}</Accordion.Panel>
</Accordion.Item>
);
};

View File

@@ -0,0 +1,210 @@
import type { PropsWithChildren } from "react";
import { Badge, Center, Divider, Flex, Group, List, Popover, RingProgress, Stack, Text } from "@mantine/core";
import {
IconArrowNarrowDown,
IconArrowNarrowUp,
IconBrain,
IconClockHour3,
IconCpu,
IconDatabase,
IconDeviceLaptop,
IconHeartBolt,
IconNetwork,
IconQuestionMark,
IconServer,
} from "@tabler/icons-react";
import dayjs from "dayjs";
import duration from "dayjs/plugin/duration";
import { capitalize, humanFileSize } from "@homarr/common";
import type { ComputeResource, Resource, StorageResource } from "@homarr/integrations/types";
import { useScopedI18n } from "@homarr/translation/client";
dayjs.extend(duration);
interface ResourcePopoverProps {
item: Resource;
}
export const ResourcePopover = ({ item, children }: PropsWithChildren<ResourcePopoverProps>) => {
return (
<Popover
withArrow
withinPortal
radius="lg"
shadow="sm"
transitionProps={{
transition: "pop",
}}
>
{children}
<Popover.Dropdown>
<ResourceTypeEntryDetails item={item} />
</Popover.Dropdown>
</Popover>
);
};
export const ResourceTypeEntryDetails = ({ item }: { item: Resource }) => {
const t = useScopedI18n("widget.healthMonitoring.cluster.popover");
return (
<Stack gap={0}>
<Group wrap="nowrap" align="start" justify="apart">
<Group wrap="nowrap" align="center">
<ResourceIcon type={item.type} size={35} />
<Stack gap={0}>
<Text fw={700} size="md">
{item.name}
</Text>
<Text c={item.isRunning ? "green" : "yellow"}>{capitalize(item.status)}</Text>
</Stack>
</Group>
<Group align="end">
{item.type === "node" && <RightSection label={t("rightSection.node")} value={item.node} />}
{item.type === "lxc" && <RightSection label={t("rightSection.vmId")} value={item.vmId} />}
{item.type === "qemu" && <RightSection label={t("rightSection.vmId")} value={item.vmId} />}
{item.type === "storage" && <RightSection label={t("rightSection.plugin")} value={item.storagePlugin} />}
</Group>
</Group>
<Divider mt={0} mb="xs" />
{item.type !== "storage" && <ComputeResourceDetails item={item} />}
{item.type === "storage" && <StorageResourceDetails item={item} />}
</Stack>
);
};
interface RightSectionProps {
label: string;
value: string | number;
}
const RightSection = ({ label, value }: RightSectionProps) => {
return (
<Stack align="end" gap={0}>
<Text fw={200} size="sm">
{label}
</Text>
<Text c="dimmed" size="xs">
{value}
</Text>
</Stack>
);
};
const ComputeResourceDetails = ({ item }: { item: ComputeResource }) => {
const t = useScopedI18n("widget.healthMonitoring.cluster.popover.detail");
return (
<List>
<List.Item icon={<IconCpu size={16} />}>
{t("cpu")} - {item.cpu.cores}
</List.Item>
<List.Item icon={<IconBrain size={16} />}>
{t("memory")} - {humanFileSize(item.memory.used)} / {humanFileSize(item.memory.total)}
</List.Item>
<List.Item icon={<IconDatabase size={16} />}>
{t("storage")} - {humanFileSize(item.storage.used)} / {humanFileSize(item.storage.total)}
</List.Item>
<List.Item icon={<IconClockHour3 size={16} />}>
{t("uptime")} - {dayjs(dayjs().add(-item.uptime, "seconds")).fromNow(true)}
</List.Item>
{item.haState && (
<List.Item icon={<IconHeartBolt size={16} />}>
{t("haState")} - {capitalize(item.haState)}
</List.Item>
)}
<NetStats item={item} />
<DiskStats item={item} />
</List>
);
};
const StorageResourceDetails = ({ item }: { item: StorageResource }) => {
const t = useScopedI18n("widget.healthMonitoring.cluster.popover.detail");
const storagePercent = item.total ? (item.used / item.total) * 100 : 0;
return (
<Stack gap={0}>
<Center>
<RingProgress
roundCaps
size={100}
thickness={10}
label={<Text ta="center">{storagePercent.toFixed(1)}%</Text>}
sections={[{ value: storagePercent, color: storagePercent > 75 ? "orange" : "green" }]}
/>
<Group align="center" gap={0}>
<Text>
{t("storage")} - {humanFileSize(item.used)} / {humanFileSize(item.total)}
</Text>
</Group>
</Center>
<Flex gap="sm" mt={0} justify="end">
<StorageType item={item} />
</Flex>
</Stack>
);
};
const DiskStats = ({ item }: { item: ComputeResource }) => {
if (!item.storage.read || !item.storage.write) {
return null;
}
return (
<List.Item icon={<IconDatabase size={16} />}>
<Group gap="sm">
<Group gap={0}>
<Text>{humanFileSize(item.storage.write)}</Text>
<IconArrowNarrowDown size={14} />
</Group>
<Group gap={0}>
<Text>{humanFileSize(item.storage.read)}</Text>
<IconArrowNarrowUp size={14} />
</Group>
</Group>
</List.Item>
);
};
const NetStats = ({ item }: { item: ComputeResource }) => {
if (!item.network.in || !item.network.out) {
return null;
}
return (
<List.Item icon={<IconNetwork size={16} />}>
<Group gap="sm">
<Group gap={0}>
<Text>{humanFileSize(item.network.in)}</Text>
<IconArrowNarrowDown size={14} />
</Group>
<Group gap={0}>
<Text>{humanFileSize(item.network.out)}</Text>
<IconArrowNarrowUp size={14} />
</Group>
</Group>
</List.Item>
);
};
const StorageType = ({ item }: { item: StorageResource }) => {
const t = useScopedI18n("widget.healthMonitoring.cluster.popover.detail.storageType");
if (item.isShared) {
return <Badge color="blue">{t("shared")}</Badge>;
} else {
return <Badge>{t("local")}</Badge>;
}
};
const ResourceIcon = ({ type, size }: { type: Resource["type"]; size: number }) => {
switch (type) {
case "node":
return <IconServer size={size} />;
case "lxc":
return <IconDeviceLaptop size={size} />;
case "qemu":
return <IconDeviceLaptop size={size} />;
case "storage":
return <IconDatabase size={size} />;
default:
console.error(`Unknown resource type: ${type as string}`);
return <IconQuestionMark size={size} />;
}
};

View File

@@ -0,0 +1,78 @@
import { Group, Indicator, Popover, Table, TableTbody, TableThead, TableTr, Text } from "@mantine/core";
import type { Resource } from "@homarr/integrations/types";
import { useI18n } from "@homarr/translation/client";
import { ResourcePopover } from "./resource-popover";
interface ResourceTableProps {
type: Resource["type"];
data: Resource[];
isTiny: boolean;
}
export const ResourceTable = ({ type, data, isTiny }: ResourceTableProps) => {
const t = useI18n();
return (
<Table highlightOnHover>
<TableThead>
<TableTr fz={isTiny ? "8px" : "xs"}>
<Table.Th ta="start" p={0}>
{t("widget.healthMonitoring.cluster.table.header.name")}
</Table.Th>
{type !== "storage" ? (
<Table.Th ta="start" p={0}>
{t("widget.healthMonitoring.cluster.table.header.cpu")}
</Table.Th>
) : null}
{type !== "storage" ? (
<Table.Th ta="start" p={0}>
{t("widget.healthMonitoring.cluster.table.header.memory")}
</Table.Th>
) : null}
{type === "storage" ? (
<Table.Th ta="start" p={0}>
{t("widget.healthMonitoring.cluster.table.header.node")}
</Table.Th>
) : null}
</TableTr>
</TableThead>
<TableTbody>
{data
.sort((itemA, itemB) => {
const nodeResult = itemA.node.localeCompare(itemB.node);
if (nodeResult !== 0) return nodeResult;
return itemA.name.localeCompare(itemB.name);
})
.map((item) => {
return (
<ResourcePopover key={item.id} item={item}>
<Popover.Target>
<TableTr fz={isTiny ? "8px" : "xs"}>
<td>
<Group wrap="nowrap" gap={isTiny ? 8 : "xs"}>
<Indicator size={isTiny ? 4 : 8} children={null} color={item.isRunning ? "green" : "yellow"} />
<Text lineClamp={1} fz={isTiny ? "8px" : "xs"}>
{item.name}
</Text>
</Group>
</td>
{item.type === "storage" ? (
<td style={{ WebkitLineClamp: "1" }}>{item.node}</td>
) : (
<>
<td style={{ whiteSpace: "nowrap" }}>{(item.cpu.utilization * 100).toFixed(1)}%</td>
<td style={{ whiteSpace: "nowrap" }}>
{(item.memory.total ? (item.memory.used / item.memory.total) * 100 : 0).toFixed(1)}%
</td>
</>
)}
</TableTr>
</Popover.Target>
</ResourcePopover>
);
})}
</TableTbody>
</Table>
);
};

View File

@@ -0,0 +1,58 @@
"use client";
import { ScrollArea, Tabs } from "@mantine/core";
import dayjs from "dayjs";
import duration from "dayjs/plugin/duration";
import { clientApi } from "@homarr/api/client";
import type { IntegrationKind } from "@homarr/definitions";
import { useI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition";
import { ClusterHealthMonitoring } from "./cluster/cluster-health";
import { SystemHealthMonitoring } from "./system-health";
dayjs.extend(duration);
const isClusterIntegration = (integration: { kind: IntegrationKind }) =>
integration.kind === "proxmox" || integration.kind === "mock";
export default function HealthMonitoringWidget(props: WidgetComponentProps<"healthMonitoring">) {
const [integrations] = clientApi.integration.byIds.useSuspenseQuery(props.integrationIds);
const t = useI18n();
const clusterIntegrationId = integrations.find(isClusterIntegration)?.id;
if (!clusterIntegrationId) {
return <SystemHealthMonitoring {...props} />;
}
const otherIntegrationIds = integrations
// We want to have the mock integration also in the system tab, so we use it for both
.filter((integration) => integration.kind !== "proxmox")
.map((integration) => integration.id);
if (otherIntegrationIds.length === 0) {
return <ClusterHealthMonitoring {...props} integrationId={clusterIntegrationId} />;
}
return (
<ScrollArea h="100%">
<Tabs defaultValue={props.options.defaultTab} variant="outline">
<Tabs.List grow>
<Tabs.Tab value="system" fz="xs">
<b>{t("widget.healthMonitoring.tab.system")}</b>
</Tabs.Tab>
<Tabs.Tab value="cluster" fz="xs">
<b>{t("widget.healthMonitoring.tab.cluster")}</b>
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="system">
<SystemHealthMonitoring {...props} integrationIds={otherIntegrationIds} />
</Tabs.Panel>
<Tabs.Panel value="cluster">
<ClusterHealthMonitoring integrationId={clusterIntegrationId} {...props} />
</Tabs.Panel>
</Tabs>
</ScrollArea>
);
}

View File

@@ -0,0 +1,105 @@
import { IconHeartRateMonitor, IconServerOff } from "@tabler/icons-react";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("healthMonitoring", {
icon: IconHeartRateMonitor,
createOptions() {
return optionsBuilder.from(
(factory) => ({
fahrenheit: factory.switch({
defaultValue: false,
}),
cpu: factory.switch({
defaultValue: true,
}),
memory: factory.switch({
defaultValue: true,
}),
showUptime: factory.switch({
defaultValue: true,
}),
fileSystem: factory.switch({
defaultValue: true,
}),
visibleClusterSections: factory.multiSelect({
options: [
{
value: "node",
label: (t) => t("widget.healthMonitoring.cluster.resource.node.name"),
},
{
value: "qemu",
label: (t) => t("widget.healthMonitoring.cluster.resource.qemu.name"),
},
{
value: "lxc",
label: (t) => t("widget.healthMonitoring.cluster.resource.lxc.name"),
},
{
value: "storage",
label: (t) => t("widget.healthMonitoring.cluster.resource.storage.name"),
},
] as const,
defaultValue: ["node", "qemu", "lxc", "storage"] as const,
}),
defaultTab: factory.select({
defaultValue: "system",
options: [
{ value: "system", label: "System" },
{ value: "cluster", label: "Cluster" },
] as const,
}),
sectionIndicatorRequirement: factory.select({
defaultValue: "all",
options: [
{ value: "all", label: "All active" },
{ value: "any", label: "Any active" },
] as const,
}),
}),
{
fahrenheit: {
shouldHide(_, integrationKinds) {
// File system is only shown on system health tab
return integrationKinds.every((kind) => kind === "proxmox") || integrationKinds.length === 0;
},
},
fileSystem: {
shouldHide(_, integrationKinds) {
// File system is only shown on system health tab
return integrationKinds.every((kind) => kind === "proxmox") || integrationKinds.length === 0;
},
},
showUptime: {
shouldHide(_, integrationKinds) {
// Uptime is only shown on cluster health tab
return !integrationKinds.includes("proxmox");
},
},
sectionIndicatorRequirement: {
shouldHide(_, integrationKinds) {
// Section indicator requirement is only shown on cluster health tab
return !integrationKinds.includes("proxmox");
},
},
visibleClusterSections: {
shouldHide(_, integrationKinds) {
// Cluster sections are only shown on cluster health tab
return !integrationKinds.includes("proxmox");
},
},
},
);
},
supportedIntegrations: getIntegrationKindsByCategory("healthMonitoring"),
errors: {
INTERNAL_SERVER_ERROR: {
icon: IconServerOff,
message: (t) => t("widget.healthMonitoring.error.internalServerError"),
},
},
}).withDynamicImport(() => import("./component"));

View File

@@ -0,0 +1,30 @@
import { Center, RingProgress, Text } from "@mantine/core";
import { IconCpu } from "@tabler/icons-react";
import { progressColor } from "../system-health";
export const CpuRing = ({ cpuUtilization, isTiny }: { cpuUtilization: number; isTiny: boolean }) => {
return (
<RingProgress
className="health-monitoring-cpu"
roundCaps
size={isTiny ? 50 : 100}
thickness={isTiny ? 4 : 8}
label={
<Center style={{ flexDirection: "column" }}>
<Text
className="health-monitoring-cpu-utilization-value"
size={isTiny ? "8px" : "xs"}
>{`${cpuUtilization.toFixed(2)}%`}</Text>
<IconCpu className="health-monitoring-cpu-utilization-icon" size={isTiny ? 8 : 16} />
</Center>
}
sections={[
{
value: Number(cpuUtilization.toFixed(2)),
color: progressColor(Number(cpuUtilization.toFixed(2))),
},
]}
/>
);
};

View File

@@ -0,0 +1,41 @@
import { Center, RingProgress, Text } from "@mantine/core";
import { IconCpu } from "@tabler/icons-react";
import { progressColor } from "../system-health";
export const CpuTempRing = ({
fahrenheit,
cpuTemp,
isTiny,
}: {
fahrenheit: boolean;
cpuTemp: number | undefined;
isTiny: boolean;
}) => {
if (!cpuTemp) {
return null;
}
return (
<RingProgress
className="health-monitoring-cpu-temperature"
roundCaps
size={isTiny ? 50 : 100}
thickness={isTiny ? 4 : 8}
label={
<Center style={{ flexDirection: "column" }}>
<Text className="health-monitoring-cpu-temp-value" size={isTiny ? "8px" : "xs"}>
{fahrenheit ? `${(cpuTemp * 1.8 + 32).toFixed(1)}°F` : `${cpuTemp.toFixed(1)}°C`}
</Text>
<IconCpu className="health-monitoring-cpu-temp-icon" size={isTiny ? 8 : 16} />
</Center>
}
sections={[
{
value: cpuTemp,
color: progressColor(cpuTemp),
},
]}
/>
);
};

View File

@@ -0,0 +1,47 @@
import { Center, RingProgress, Text } from "@mantine/core";
import { IconBrain } from "@tabler/icons-react";
import { progressColor } from "../system-health";
export const MemoryRing = ({ available, used, isTiny }: { available: number; used: number; isTiny: boolean }) => {
const memoryUsage = formatMemoryUsage(available, used);
return (
<RingProgress
className="health-monitoring-memory"
roundCaps
size={isTiny ? 50 : 100}
thickness={isTiny ? 4 : 8}
label={
<Center style={{ flexDirection: "column" }}>
<Text className="health-monitoring-memory-value" size={isTiny ? "8px" : "xs"}>
{memoryUsage.memUsed.GB}GiB
</Text>
<IconBrain className="health-monitoring-memory-icon" size={isTiny ? 8 : 16} />
</Center>
}
sections={[
{
value: Number(memoryUsage.memUsed.percent),
color: progressColor(Number(memoryUsage.memUsed.percent)),
tooltip: `${memoryUsage.memUsed.percent}%`,
},
]}
/>
);
};
export const formatMemoryUsage = (memFree: number, memUsed: number) => {
const totalMemory = memFree + memUsed;
const memFreeGB = (memFree / 1024 ** 3).toFixed(2);
const memUsedGB = (memUsed / 1024 ** 3).toFixed(2);
const memFreePercent = Math.round((memFree / totalMemory) * 100);
const memUsedPercent = Math.round((memUsed / totalMemory) * 100);
const memTotalGB = (totalMemory / 1024 ** 3).toFixed(2);
return {
memFree: { percent: memFreePercent, GB: memFreeGB },
memUsed: { percent: memUsedPercent, GB: memUsedGB },
memTotal: { GB: memTotalGB },
};
};

View File

@@ -0,0 +1,7 @@
[data-mantine-color-scheme="light"] .card {
background-color: var(--mantine-color-gray-1);
}
[data-mantine-color-scheme="dark"] .card {
background-color: var(--mantine-color-dark-7);
}

View File

@@ -0,0 +1,331 @@
"use client";
import {
ActionIcon,
Box,
Card,
Divider,
Flex,
Group,
Indicator,
List,
Modal,
Progress,
Stack,
Text,
Tooltip,
} from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import {
IconBrain,
IconClock,
IconCpu,
IconCpu2,
IconFileReport,
IconInfoCircle,
IconServer,
IconTemperature,
IconVersions,
} from "@tabler/icons-react";
import combineClasses from "clsx";
import dayjs from "dayjs";
import duration from "dayjs/plugin/duration";
import { clientApi } from "@homarr/api/client";
import { useRequiredBoard } from "@homarr/boards/context";
import type { TranslationFunction } from "@homarr/translation";
import { useI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition";
import { CpuRing } from "./rings/cpu-ring";
import { CpuTempRing } from "./rings/cpu-temp-ring";
import { formatMemoryUsage, MemoryRing } from "./rings/memory-ring";
import classes from "./system-health.module.css";
dayjs.extend(duration);
export const SystemHealthMonitoring = ({
options,
integrationIds,
width,
}: WidgetComponentProps<"healthMonitoring">) => {
const t = useI18n();
const [healthData] = clientApi.widget.healthMonitoring.getSystemHealthStatus.useSuspenseQuery(
{
integrationIds,
},
{
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
retry: false,
},
);
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;
return (
<Stack h="100%" gap="sm" className="health-monitoring">
{healthData.map(({ integrationId, integrationName, healthInfo, updatedAt }) => {
const disksData = matchFileSystemAndSmart(healthInfo.fileSystem, healthInfo.smart);
const memoryUsage = formatMemoryUsage(healthInfo.memAvailableInBytes, healthInfo.memUsedInBytes);
return (
<Stack
gap="sm"
key={integrationId}
h="100%"
className={`health-monitoring-information health-monitoring-${integrationName}`}
p="sm"
pos="relative"
>
<Box className="health-monitoring-information-card-section" pos="absolute" top={8} right={8}>
<Indicator
className="health-monitoring-updates-reboot-indicator"
inline
processing
styles={{ indicator: { pointerEvents: "none" } }}
color={healthInfo.rebootRequired ? "red" : healthInfo.availablePkgUpdates > 0 ? "blue" : "gray"}
position="top-end"
size={16}
label={healthInfo.availablePkgUpdates > 0 ? healthInfo.availablePkgUpdates : undefined}
disabled={!healthInfo.rebootRequired && healthInfo.availablePkgUpdates === 0}
>
<ActionIcon
className="health-monitoring-information-icon-avatar"
variant={"light"}
color="var(--mantine-color-text)"
size="sm"
radius={board.itemRadius}
>
<IconInfoCircle className="health-monitoring-information-icon" size={30} onClick={open} />
</ActionIcon>
</Indicator>
<Modal
opened={opened}
onClose={close}
size="auto"
title={t("widget.healthMonitoring.popover.information")}
centered
>
<Stack gap="10px" className="health-monitoring-modal-stack">
<Divider />
<List className="health-monitoring-information-list" center spacing="xs">
<List.Item className="health-monitoring-information-processor" icon={<IconCpu2 size={30} />}>
{t("widget.healthMonitoring.popover.processor", { cpuModelName: healthInfo.cpuModelName })}
</List.Item>
<List.Item className="health-monitoring-information-memory" icon={<IconBrain size={30} />}>
{t("widget.healthMonitoring.popover.memory", { memory: memoryUsage.memTotal.GB })}
</List.Item>
<List.Item className="health-monitoring-information-memory" icon={<IconBrain size={30} />}>
{t("widget.healthMonitoring.popover.memoryAvailable", {
memoryAvailable: memoryUsage.memFree.GB,
percent: String(memoryUsage.memFree.percent),
})}
</List.Item>
<List.Item className="health-monitoring-information-version" icon={<IconVersions size={30} />}>
{t("widget.healthMonitoring.popover.version", {
version: healthInfo.version,
})}
</List.Item>
<List.Item className="health-monitoring-information-uptime" icon={<IconClock size={30} />}>
{formatUptime(healthInfo.uptime, t)}
</List.Item>
{healthInfo.loadAverage && (
<>
<List.Item className="health-monitoring-information-load-average" icon={<IconCpu size={30} />}>
{t("widget.healthMonitoring.popover.loadAverage")}
</List.Item>
<List m="xs" withPadding center spacing="xs" icon={<IconCpu size={30} />}>
<List.Item className="health-monitoring-information-load-average-1min">
{t("widget.healthMonitoring.popover.minute")} {healthInfo.loadAverage["1min"]}%
</List.Item>
<List.Item className="health-monitoring-information-load-average-5min">
{t("widget.healthMonitoring.popover.minutes", { count: "5" })}{" "}
{healthInfo.loadAverage["5min"]}%
</List.Item>
<List.Item className="health-monitoring-information-load-average-15min">
{t("widget.healthMonitoring.popover.minutes", { count: "15" })}{" "}
{healthInfo.loadAverage["15min"]}%
</List.Item>
</List>
</>
)}
</List>
</Stack>
</Modal>
</Box>
<Flex className="health-monitoring-information-card-elements" justify="center" align="center" wrap="wrap">
{options.cpu && <CpuRing cpuUtilization={healthInfo.cpuUtilization} isTiny={isTiny} />}
{options.cpu && (
<CpuTempRing fahrenheit={options.fahrenheit} cpuTemp={healthInfo.cpuTemp} isTiny={isTiny} />
)}
{options.memory && (
<MemoryRing
available={healthInfo.memAvailableInBytes}
used={healthInfo.memUsedInBytes}
isTiny={isTiny}
/>
)}
</Flex>
{
<Text className="health-monitoring-status-update-time" c="dimmed" size="xs" ta="center">
{t("widget.healthMonitoring.popover.lastSeen", { lastSeen: dayjs(updatedAt).fromNow() })}
</Text>
}
{options.fileSystem &&
disksData.map((disk) => {
return (
<Card
className={combineClasses(
`health-monitoring-disk-card health-monitoring-disk-card-${integrationName}`,
classes.card,
)}
style={{ overflow: "visible" }}
key={disk.deviceName}
radius={board.itemRadius}
p="xs"
>
<Stack gap="sm">
<Group
className="health-monitoring-disk-status"
justify="space-between"
align="center"
wrap="wrap"
gap={8}
>
<Group gap={4} wrap="nowrap">
<IconServer className="health-monitoring-disk-icon" size="1rem" />
<Text className="dihealth-monitoring-disk-name" size="xs">
{disk.deviceName}
</Text>
</Group>
<Group gap={4} wrap="nowrap">
<IconTemperature className="health-monitoring-disk-temperature-icon" size="1rem" />
<Text className="health-monitoring-disk-temperature-value" size="xs">
{options.fahrenheit
? `${(disk.temperature * 1.8 + 32).toFixed(1)}°F`
: `${disk.temperature}°C`}
</Text>
</Group>
<Group gap={4} wrap="nowrap">
<IconFileReport className="health-monitoring-disk-status-icon" size="1rem" />
<Text className="health-monitoring-disk-status-value" size="xs">
{disk.overallStatus ? disk.overallStatus : "N/A"}
</Text>
</Group>
</Group>
<Progress.Root className="health-monitoring-disk-use" radius={board.itemRadius} h="md">
<Tooltip label={disk.used}>
<Progress.Section
value={disk.percentage}
color={progressColor(disk.percentage)}
className="health-monitoring-disk-use-percentage"
>
<Progress.Label className="health-monitoring-disk-use-value" fz="xs">
{t("widget.healthMonitoring.popover.used")}
</Progress.Label>
</Progress.Section>
</Tooltip>
<Tooltip
label={
Number(disk.available) / 1024 ** 4 >= 1
? `${(Number(disk.available) / 1024 ** 4).toFixed(2)} TiB`
: `${(Number(disk.available) / 1024 ** 3).toFixed(2)} GiB`
}
>
<Progress.Section
className="health-monitoring-disk-available-percentage"
value={100 - disk.percentage}
color="default"
>
<Progress.Label className="health-monitoring-disk-available-value" fz="xs">
{t("widget.healthMonitoring.popover.available")}
</Progress.Label>
</Progress.Section>
</Tooltip>
</Progress.Root>
</Stack>
</Card>
);
})}
</Stack>
);
})}
</Stack>
);
};
export const formatUptime = (uptimeInSeconds: number, t: TranslationFunction) => {
const uptimeDuration = dayjs.duration(uptimeInSeconds, "seconds");
const months = uptimeDuration.months();
const days = uptimeDuration.days();
const hours = uptimeDuration.hours();
const minutes = uptimeDuration.minutes();
return t("widget.healthMonitoring.popover.uptime", {
months: String(months),
days: String(days),
hours: String(hours),
minutes: String(minutes),
});
};
export const progressColor = (percentage: number) => {
if (percentage < 40) return "green";
else if (percentage < 60) return "yellow";
else if (percentage < 90) return "orange";
else return "red";
};
interface FileSystem {
deviceName: string;
used: string;
available: string;
percentage: number;
}
interface SmartData {
deviceName: string;
temperature: number | null;
overallStatus: string;
}
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);
return {
deviceName: smartDisk?.deviceName ?? fileSystem.deviceName,
used: fileSystem.used,
available: fileSystem.available,
percentage: fileSystem.percentage,
temperature: smartDisk?.temperature ?? 0,
overallStatus: smartDisk?.overallStatus ?? "",
};
})
.sort((fileSystemA, fileSystemB) => fileSystemA.deviceName.localeCompare(fileSystemB.deviceName));
};

View File

@@ -0,0 +1,8 @@
.iframe {
border-radius: var(--mantine-radius-sm);
width: 100%;
height: 100%;
border: none;
background: none;
background-color: transparent;
}

View File

@@ -0,0 +1,118 @@
"use client";
import { Box, Stack, Text, Title } from "@mantine/core";
import { IconBrowserOff, IconProtocol } from "@tabler/icons-react";
import { objectEntries } from "@homarr/common";
import { useI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition";
import classes from "./component.module.css";
export default function IFrameWidget({ options, isEditMode }: WidgetComponentProps<"iframe">) {
const t = useI18n();
const { embedUrl, allowScrolling, ...permissions } = options;
const allowedPermissions = getAllowedPermissions(permissions);
const sandboxFlags = getSandboxFlags(permissions);
if (embedUrl.trim() === "") return <NoUrl />;
if (!isSupportedProtocol(embedUrl)) {
return <UnsupportedProtocol />;
}
return (
<Box h="100%" w="100%">
<iframe
style={isEditMode ? { userSelect: "none", pointerEvents: "none" } : undefined}
className={classes.iframe}
src={embedUrl}
title="widget iframe"
allow={allowedPermissions}
scrolling={allowScrolling ? "yes" : "no"}
sandbox={sandboxFlags.join(" ")}
>
<Text>{t("widget.iframe.error.noBrowerSupport")}</Text>
</iframe>
</Box>
);
}
const supportedProtocols = ["http", "https"];
const isSupportedProtocol = (url: string) => {
try {
const parsedUrl = new URL(url);
return supportedProtocols.map((protocol) => `${protocol}:`).includes(`${parsedUrl.protocol}`);
} catch {
return false;
}
};
const NoUrl = () => {
const t = useI18n();
return (
<Stack align="center" justify="center" h="100%">
<IconBrowserOff />
<Title order={4}>{t("widget.iframe.error.noUrl")}</Title>
</Stack>
);
};
const UnsupportedProtocol = () => {
const t = useI18n();
return (
<Stack align="center" justify="center" h="100%">
<IconProtocol />
<Title order={4} ta="center">
{t("widget.iframe.error.unsupportedProtocol", {
supportedProtocols: supportedProtocols.map((protocol) => protocol).join(", "),
})}
</Title>
</Stack>
);
};
const getAllowedPermissions = (
permissions: Omit<WidgetComponentProps<"iframe">["options"], "embedUrl" | "allowScrolling">,
) => {
return (
objectEntries(permissions)
.filter(([_key, value]) => value)
// * means it applies to all origins
.map(([key]) => `${permissionMapping[key]} *`)
.join("; ")
);
};
const getSandboxFlags = (
permissions: Omit<WidgetComponentProps<"iframe">["options"], "embedUrl" | "allowScrolling">,
) => {
const baseSandbox = [
"allow-scripts",
"allow-same-origin",
"allow-forms",
"allow-popups",
"allow-top-navigation-by-user-activation",
];
if (permissions.allowFullScreen) {
baseSandbox.push("allow-presentation");
}
if (permissions.allowPayment) {
baseSandbox.push("allow-popups-to-escape-sandbox");
}
return baseSandbox;
};
const permissionMapping = {
allowAutoPlay: "autoplay",
allowCamera: "camera",
allowFullScreen: "fullscreen",
allowGeolocation: "geolocation",
allowMicrophone: "microphone",
allowPayment: "payment",
} satisfies Record<keyof Omit<WidgetComponentProps<"iframe">["options"], "embedUrl" | "allowScrolling">, string>;

View File

@@ -0,0 +1,22 @@
import { IconBrowser } from "@tabler/icons-react";
import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("iframe", {
icon: IconBrowser,
createOptions() {
return optionsBuilder.from((factory) => ({
embedUrl: factory.text(),
allowFullScreen: factory.switch(),
allowScrolling: factory.switch({
defaultValue: true,
}),
allowPayment: factory.switch(),
allowAutoPlay: factory.switch(),
allowMicrophone: factory.switch(),
allowCamera: factory.switch(),
allowGeolocation: factory.switch(),
}));
},
}).withDynamicImport(() => import("./component"));

View File

@@ -0,0 +1,3 @@
import type { WidgetKind } from "@homarr/definitions";
export type WidgetImportRecord = Record<WidgetKind, unknown>;

View File

@@ -0,0 +1,130 @@
import type { ComponentType } from "react";
import type { Loader } from "next/dynamic";
import dynamic from "next/dynamic";
import { Center, Loader as UiLoader } from "@mantine/core";
import { objectEntries } from "@homarr/common";
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
import type { SettingsContextProps } from "@homarr/settings/creator";
import * as app from "./app";
import * as bookmarks from "./bookmarks";
import * as calendar from "./calendar";
import * as clock from "./clock";
import type { WidgetComponentProps } from "./definition";
import * as dnsHoleControls from "./dns-hole/controls";
import * as dnsHoleSummary from "./dns-hole/summary";
import * as dockerContainers from "./docker";
import * as downloads from "./downloads";
import * as firewall from "./firewall";
import * as healthMonitoring from "./health-monitoring";
import * as iframe from "./iframe";
import type { WidgetImportRecord } from "./import";
import * as indexerManager from "./indexer-manager";
import * as mediaReleases from "./media-releases";
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 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 notifications from "./notifications";
import type { WidgetOptionDefinition } from "./options";
import * as releases from "./releases";
import * as rssFeed from "./rssFeed";
import * as smartHomeEntityState from "./smart-home/entity-state";
import * as smartHomeExecuteAutomation from "./smart-home/execute-automation";
import * as stockPrice from "./stocks";
import * as systemResources from "./system-resources";
import * as video from "./video";
import * as weather from "./weather";
export type { WidgetDefinition, WidgetOptionsSettings } from "./definition";
export type { WidgetComponentProps };
export const widgetImports = {
clock,
weather,
app,
notebook,
iframe,
video,
dnsHoleSummary,
dnsHoleControls,
"smartHome-entityState": smartHomeEntityState,
"smartHome-executeAutomation": smartHomeExecuteAutomation,
stockPrice,
mediaServer,
calendar,
downloads,
"mediaRequests-requestList": mediaRequestsList,
"mediaRequests-requestStats": mediaRequestsStats,
networkControllerSummary,
networkControllerStatus,
rssFeed,
bookmarks,
indexerManager,
healthMonitoring,
mediaTranscoding,
minecraftServerStatus,
dockerContainers,
releases,
firewall,
notifications,
mediaReleases,
systemResources,
} satisfies WidgetImportRecord;
export type WidgetImports = typeof widgetImports;
export type WidgetImportKey = keyof WidgetImports;
const loadedComponents = new Map<WidgetKind, ComponentType<WidgetComponentProps<WidgetKind>>>();
export const loadWidgetDynamic = <TKind extends WidgetKind>(kind: TKind) => {
const existingComponent = loadedComponents.get(kind);
if (existingComponent) return existingComponent;
const newlyLoadedComponent = dynamic<WidgetComponentProps<TKind>>(
widgetImports[kind].componentLoader as Loader<WidgetComponentProps<TKind>>,
{
loading: () => (
<Center w="100%" h="100%">
<UiLoader />
</Center>
),
},
);
loadedComponents.set(kind, newlyLoadedComponent as never);
return newlyLoadedComponent;
};
export type inferSupportedIntegrations<TKind extends WidgetKind> = (WidgetImports[TKind]["definition"] extends {
supportedIntegrations: string[];
}
? WidgetImports[TKind]["definition"]["supportedIntegrations"]
: string[])[number];
export type inferSupportedIntegrationsStrict<TKind extends WidgetKind> = (WidgetImports[TKind]["definition"] extends {
supportedIntegrations: IntegrationKind[];
}
? WidgetImports[TKind]["definition"]["supportedIntegrations"]
: never[])[number];
export const reduceWidgetOptionsWithDefaultValues = (
kind: WidgetKind,
settings: Pick<SettingsContextProps, "enableStatusByDefault" | "forceDisableStatus">,
currentValue: Record<string, unknown> = {},
) => {
const definition = widgetImports[kind].definition;
const options = definition.createOptions(settings) as Record<string, WidgetOptionDefinition>;
return objectEntries(options).reduce(
(prev, [key, value]) => ({
...prev,
[key]: currentValue[key] ?? value.defaultValue,
}),
{} as Record<string, unknown>,
);
};

View File

@@ -0,0 +1,7 @@
[data-mantine-color-scheme="light"] .card {
background-color: var(--mantine-color-gray-1);
}
[data-mantine-color-scheme="dark"] .card {
background-color: var(--mantine-color-dark-7);
}

View File

@@ -0,0 +1,143 @@
"use client";
import { ActionIcon, Anchor, Button, Card, Flex, Group, ScrollArea, Stack, Text } from "@mantine/core";
import { IconCircleCheck, IconCircleX, IconReportSearch, IconTestPipe } from "@tabler/icons-react";
import combineClasses from "clsx";
import { clientApi } from "@homarr/api/client";
import { useRequiredBoard } from "@homarr/boards/context";
import { useI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition";
import classes from "./component.module.css";
export default function IndexerManagerWidget({
options,
integrationIds,
width,
height,
}: WidgetComponentProps<"indexerManager">) {
const t = useI18n();
const [indexersData] = clientApi.widget.indexerManager.getIndexersStatus.useSuspenseQuery(
{ integrationIds },
{
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
retry: false,
},
);
const utils = clientApi.useUtils();
const { mutate: testAll, isPending } = clientApi.widget.indexerManager.testAllIndexers.useMutation();
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;
return (
<Flex className="indexer-manager-container" h="100%" direction="column" gap="sm" p="sm" align="center">
<Group className="indexer-manager-title" align="center" gap="xs" wrap="nowrap">
<IconReportSearch
className="indexer-manager-title-icon"
size={hasSmallWidth ? 16 : 20}
style={{ minWidth: hasSmallWidth ? 16 : 20 }}
/>
<Text size={hasSmallWidth ? "xs" : "md"} fw="bold">
{t("widget.indexerManager.title")}
</Text>
{hasSmallHeight && (
<ActionIcon
className="indexer-manager-test-action-icon"
size="sm"
radius={board.itemRadius}
variant="light"
loading={isPending}
loaderProps={{ type: "dots" }}
onClick={() => {
testAll({ integrationIds });
}}
>
<IconTestPipe size={12} />
</ActionIcon>
)}
</Group>
<Card
className={combineClasses("indexer-manager-list-container", classes.card)}
w="100%"
p="xs"
radius={board.itemRadius}
flex={1}
>
<ScrollArea className="indexer-manager-list-scroll-area" h="100%" scrollbars="y">
{indexersData.map(({ integrationId, indexers }) => (
<Stack gap={4} className={`indexer-manager-${integrationId}-list-container`} p={0} key={integrationId}>
{indexers.map((indexer) => (
<Group
className={`indexer-manager-line indexer-manager-${indexer.name}`}
key={indexer.id}
justify="space-between"
gap="xs"
wrap="nowrap"
>
<Anchor
className="indexer-manager-line-anchor"
href={indexer.url}
target={options.openIndexerSiteInNewTab ? "_blank" : "_self"}
>
<Text className="indexer-manager-line-anchor-text" c="dimmed" size={hasSmallWidth ? "xs" : "sm"}>
{indexer.name}
</Text>
</Anchor>
{indexer.status === false || indexer.enabled === false ? (
<IconCircleX
className="indexer-manager-line-status-icon indexer-manager-line-icon-disabled"
color="#d9534f"
size={hasSmallWidth ? 12 : 16}
/>
) : (
<IconCircleCheck
className="indexer-manager-line-status-icon indexer-manager-line-icon-enabled"
color="#2ecc71"
size={hasSmallWidth ? 12 : 16}
/>
)}
</Group>
))}
</Stack>
))}
</ScrollArea>
</Card>
{!hasSmallHeight && (
<Button
className="indexer-manager-test-button"
w="100%"
size="xs"
radius={board.itemRadius}
variant="light"
leftSection={<IconTestPipe size={"1rem"} />}
loading={isPending}
loaderProps={{ type: "dots" }}
onClick={() => {
testAll({ integrationIds });
}}
>
{t("widget.indexerManager.testAll")}
</Button>
)}
</Flex>
);
}

View File

@@ -0,0 +1,24 @@
import { IconReportSearch, IconServerOff } from "@tabler/icons-react";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("indexerManager", {
icon: IconReportSearch,
createOptions() {
return optionsBuilder.from((factory) => ({
openIndexerSiteInNewTab: factory.switch({
defaultValue: true,
}),
}));
},
supportedIntegrations: getIntegrationKindsByCategory("indexerManager"),
errors: {
INTERNAL_SERVER_ERROR: {
icon: IconServerOff,
message: (t) => t("widget.indexerManager.error.internalServerError"),
},
},
}).withDynamicImport(() => import("./component"));

View File

@@ -0,0 +1,206 @@
"use client";
import { Fragment } from "react";
import { Avatar, Badge, Box, Divider, Group, Image, Stack, Text, TooltipFloating, UnstyledButton } from "@mantine/core";
import { IconBook, IconCalendar, IconClock, IconStarFilled } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { getMantineColor } from "@homarr/common";
import { getIconUrl } from "@homarr/definitions";
import type { MediaRelease } from "@homarr/integrations/types";
import { mediaTypeConfigurations } from "@homarr/integrations/types";
import type { TranslationFunction } from "@homarr/translation";
import { useCurrentLocale, useI18n } from "@homarr/translation/client";
import type { TablerIcon } from "@homarr/ui";
import { OverflowBadge } from "@homarr/ui";
import type { WidgetComponentProps } from "../definition";
export default function MediaReleasesWidget({ options, integrationIds }: WidgetComponentProps<"mediaReleases">) {
const [releases] = clientApi.widget.mediaRelease.getMediaReleases.useSuspenseQuery({
integrationIds,
});
return (
<Stack p="xs" gap="sm">
{releases.map((item, index) => (
<Fragment key={item.id}>
{index !== 0 && options.layout === "poster" && <Divider />}
<Item item={item} options={options} />
</Fragment>
))}
</Stack>
);
}
interface ItemProps {
item: RouterOutputs["widget"]["mediaRelease"]["getMediaReleases"][number];
options: WidgetComponentProps<"mediaReleases">["options"];
}
const Item = ({ item, options }: ItemProps) => {
const locale = useCurrentLocale();
const t = useI18n();
const length = formatLength(item.length, item.type, t);
return (
<TooltipFloating
label={item.description}
w={300}
multiline
disabled={item.description === undefined || item.description.trim() === "" || !options.showDescriptionTooltip}
>
<UnstyledButton
component="a"
href={item.href}
target="_blank"
rel="noopener noreferrer"
pos="relative"
p={options.layout === "poster" ? 0 : 4}
>
{options.layout === "backdrop" && (
<Box
w="100%"
h="100%"
pos="absolute"
top={0}
left={0}
style={{
backgroundImage: `url(${item.imageUrls.backdrop})`,
borderRadius: 8,
backgroundRepeat: "no-repeat",
backgroundSize: "cover",
backgroundPosition: "center",
opacity: 0.2,
}}
/>
)}
<Group justify="space-between" h="100%" wrap="nowrap">
<Group align="start" wrap="nowrap" style={{ zIndex: 0 }}>
{options.layout === "poster" && <Image w={60} src={item.imageUrls.poster} alt={item.title} />}
<Stack gap={4}>
<Stack gap={0}>
<Text size="sm" fw="bold" lineClamp={2}>
{item.title}
</Text>
{item.subtitle !== undefined && (
<Text size="sm" lineClamp={1}>
{item.subtitle}
</Text>
)}
</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)}
/>
{length !== undefined && (
<>
<InfoDivider />
<Info icon={length.type === "duration" ? IconClock : IconBook} label={length.label} />
</>
)}
{item.producer !== undefined && (
<>
<InfoDivider />
<Info label={item.producer} />
</>
)}
{item.rating !== undefined && (
<>
<InfoDivider />
<Info icon={IconStarFilled} label={item.rating} />
</>
)}
{item.price !== undefined && (
<>
<InfoDivider />
<Info label={`$${item.price.toFixed(2)}`} />
</>
)}
</Group>
{item.tags.length > 0 && (
<OverflowBadge
size="xs"
groupGap={4}
data={item.tags}
overflowCount={3}
disablePopover
style={{ cursor: "pointer" }}
/>
)}
</Stack>
</Group>
{(options.showType || options.showSource) && (
<Stack justify="space-between" align="end" h="100%" style={{ zIndex: 0 }}>
{options.showType && (
<Badge
w="max-content"
size="xs"
color={mediaTypeConfigurations[item.type].color}
style={{ cursor: "pointer" }}
>
{item.type}
</Badge>
)}
{options.showSource && (
<Avatar size="sm" radius="xl" src={getIconUrl(item.integration.kind)} alt={item.integration.name} />
)}
</Stack>
)}
</Group>
</UnstyledButton>
</TooltipFloating>
);
};
interface IconAndLabelProps {
icon?: TablerIcon;
label: string;
}
const InfoDivider = () => (
<Text size="xs" c="dimmed">
</Text>
);
const Info = ({ icon: Icon, label }: IconAndLabelProps) => {
return (
<Group gap={4}>
{Icon && <Icon size={12} color={getMantineColor("gray", 5)} />}
<Text size="xs" c="gray.5">
{label}
</Text>
</Group>
);
};
const formatLength = (length: number | undefined, type: MediaRelease["type"], t: TranslationFunction) => {
if (!length) return undefined;
if (type === "movie" || type === "tv" || type === "video" || type === "music" || type === "article") {
return {
type: "duration" as const,
label: t("widget.mediaReleases.length.duration", {
length: Math.round(length / 60).toString(),
}),
};
}
if (type === "book") {
return {
type: "page" as const,
label: length.toString(),
};
}
return undefined;
};

View File

@@ -0,0 +1,35 @@
import { IconTicket } from "@tabler/icons-react";
import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("mediaReleases", {
icon: IconTicket,
createOptions() {
return optionsBuilder.from((factory) => ({
layout: factory.select({
defaultValue: "backdrop",
options: [
{
value: "backdrop",
label: (t) => t("widget.mediaReleases.option.layout.option.backdrop.label"),
},
{
value: "poster",
label: (t) => t("widget.mediaReleases.option.layout.option.poster.label"),
},
],
}),
showDescriptionTooltip: factory.switch({
defaultValue: true,
}),
showType: factory.switch({
defaultValue: true,
}),
showSource: factory.switch({
defaultValue: true,
}),
}));
},
supportedIntegrations: ["mock", "emby", "jellyfin", "plex"],
}).withDynamicImport(() => import("./component"));

View File

@@ -0,0 +1,255 @@
"use client";
import { ActionIcon, Anchor, Avatar, Badge, Card, Group, Image, ScrollArea, Stack, Text, Tooltip } from "@mantine/core";
import { 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 type { MediaRequestStatus } from "@homarr/integrations/types";
import { mediaAvailabilityConfiguration, mediaRequestStatusConfiguration } from "@homarr/integrations/types";
import { useScopedI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../../definition";
import { NoIntegrationDataError } from "../../errors/no-data-integration";
export default function MediaServerWidget({
integrationIds,
isEditMode,
options,
width,
}: WidgetComponentProps<"mediaRequests-requestList">) {
const [mediaRequests] = clientApi.widget.mediaRequests.getLatestRequests.useSuspenseQuery(
{
integrationIds,
},
{
refetchOnMount: false,
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.sort((dataA, dataB) => {
if (dataA.status === dataB.status) {
return dataB.createdAt.getTime() - dataA.createdAt.getTime();
}
return (
mediaRequestStatusConfiguration[dataA.status].position -
mediaRequestStatusConfiguration[dataB.status].position
);
});
});
},
},
);
if (mediaRequests.length === 0) throw new NoIntegrationDataError();
return (
<ScrollArea
className="mediaRequests-list-scrollArea"
scrollbarSize="md"
style={{ pointerEvents: isEditMode ? "none" : undefined }}
>
<Stack className="mediaRequests-list-list" gap="xs" p="sm">
{mediaRequests.map((mediaRequest) => (
<MediaRequestCard
key={`${mediaRequest.integrationId}-${mediaRequest.id}`}
request={mediaRequest}
isTiny={width <= 256}
options={options}
/>
))}
</Stack>
</ScrollArea>
);
}
interface MediaRequestCardProps {
request: RouterOutputs["widget"]["mediaRequests"]["getLatestRequests"][number];
isTiny: boolean;
options: WidgetComponentProps<"mediaRequests-requestList">["options"];
}
const MediaRequestCard = ({ request, isTiny, options }: MediaRequestCardProps) => {
const board = useRequiredBoard();
const t = useScopedI18n("widget.mediaRequests-requestList");
return (
<Card
className={`mediaRequests-list-item-wrapper mediaRequests-list-item-${request.type} mediaRequests-list-item-${request.status}`}
radius={board.itemRadius}
p="xs"
withBorder
>
<Image
className="mediaRequests-list-item-background"
src={request.backdropImageUrl}
pos="absolute"
w="100%"
h="100%"
opacity={0.2}
top={0}
left={0}
alt=""
/>
<Group
className="mediaRequests-list-item-contents"
h="100%"
style={{ zIndex: 1 }}
justify="space-between"
wrap="nowrap"
gap={0}
>
<Group className="mediaRequests-list-item-left-side" h="100%" gap="md" wrap="nowrap" flex={1}>
{!isTiny && (
<Image
className="mediaRequests-list-item-poster"
src={request.posterImagePath}
h={40}
w="auto"
radius={"md"}
/>
)}
<Stack gap={0} w="100%">
<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")}
</Text>
{!isTiny && (
<Badge
className="mediaRequests-list-item-media-status"
color={mediaAvailabilityConfiguration[request.availability].color}
variant="light"
size="xs"
>
{t(`availability.${request.availability}`)}
</Badge>
)}
</Group>
<Group className="mediaRequests-list-item-request-user" gap={4} wrap="nowrap">
<Avatar
className="mediaRequests-list-item-request-user-avatar"
src={request.requestedBy?.avatar}
size="xs"
/>
<Anchor
className="mediaRequests-list-item-request-user-name"
href={request.requestedBy?.link}
c="var(--mantine-color-text)"
target={options.linksTargetNewTab ? "_blank" : "_self"}
fz="xs"
lineClamp={1}
style={{ wordBreak: "break-all" }}
>
{(request.requestedBy?.displayName ?? "") || "unknown"}
</Anchor>
</Group>
</Group>
<Group gap="xs" justify="space-between" className="mediaRequests-list-item-bottom-group">
<Anchor
className="mediaRequests-list-item-info-second-line mediaRequests-list-item-media-title"
href={request.href}
c="var(--mantine-color-text)"
target={options.linksTargetNewTab ? "_blank" : "_self"}
fz={isTiny ? "xs" : "sm"}
fw={"bold"}
title={request.name}
lineClamp={1}
>
{request.name || "unknown"}
</Anchor>
{request.status === "pending" ? (
<DecisionButtons requestId={request.id} integrationId={request.integrationId} />
) : (
<StatusBadge status={request.status} />
)}
</Group>
</Stack>
</Group>
</Group>
</Card>
);
};
interface DecisionButtonsProps {
requestId: number;
integrationId: string;
}
const DecisionButtons = ({ requestId, integrationId }: DecisionButtonsProps) => {
const { mutate: mutateRequestAnswer } = clientApi.widget.mediaRequests.answerRequest.useMutation();
const t = useScopedI18n("widget.mediaRequests-requestList");
const handleDecision = (answer: RouterInputs["widget"]["mediaRequests"]["answerRequest"]["answer"]) => {
mutateRequestAnswer({
integrationId,
requestId,
answer,
});
};
return (
<Group className="mediaRequests-list-item-pending-buttons" gap="sm">
<Tooltip label={t("pending.approve")}>
<ActionIcon
className="mediaRequests-list-item-pending-button-approve"
variant="light"
color="green"
size="xs"
radius="md"
onClick={() => {
handleDecision("approve");
}}
>
<IconThumbUp size={16} />
</ActionIcon>
</Tooltip>
<Tooltip label={t("pending.decline")}>
<ActionIcon
className="mediaRequests-list-item-pending-button-decline"
variant="light"
color="red"
size="xs"
radius="md"
onClick={() => {
handleDecision("decline");
}}
>
<IconThumbDown size={16} />
</ActionIcon>
</Tooltip>
</Group>
);
};
interface StatusBadgeProps {
status: MediaRequestStatus;
}
const StatusBadge = ({ status }: StatusBadgeProps) => {
const tStatus = useScopedI18n("widget.mediaRequests-requestList.status");
return (
<Badge size="xs" color={mediaRequestStatusConfiguration[status].color} variant="light">
{tStatus(status)}
</Badge>
);
};

View File

@@ -0,0 +1,18 @@
import { IconZoomQuestion } from "@tabler/icons-react";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { createWidgetDefinition } from "../../definition";
import { optionsBuilder } from "../../options";
export const { componentLoader, definition } = createWidgetDefinition("mediaRequests-requestList", {
icon: IconZoomQuestion,
createOptions() {
return optionsBuilder.from((factory) => ({
linksTargetNewTab: factory.switch({
defaultValue: true,
}),
}));
},
supportedIntegrations: getIntegrationKindsByCategory("mediaRequest"),
}).withDynamicImport(() => import("./component"));

View File

@@ -0,0 +1,7 @@
[data-mantine-color-scheme="light"] .card {
background-color: var(--mantine-color-gray-1);
}
[data-mantine-color-scheme="dark"] .card {
background-color: var(--mantine-color-dark-7);
}

View File

@@ -0,0 +1,172 @@
"use client";
import { Avatar, Card, Grid, Group, Stack, Text, Tooltip } from "@mantine/core";
import type { Icon } from "@tabler/icons-react";
import {
IconDeviceTv,
IconHourglass,
IconLoaderQuarter,
IconMovie,
IconPlayerPlay,
IconReceipt,
IconThumbDown,
IconThumbUp,
} from "@tabler/icons-react";
import combineClasses from "clsx";
import { clientApi } from "@homarr/api/client";
import { useRequiredBoard } from "@homarr/boards/context";
import type { RequestStats } from "@homarr/integrations/types";
import { useScopedI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../../definition";
import { NoIntegrationDataError } from "../../errors/no-data-integration";
import classes from "./component.module.css";
export default function MediaServerWidget({
integrationIds,
isEditMode,
width,
}: WidgetComponentProps<"mediaRequests-requestStats">) {
const t = useScopedI18n("widget.mediaRequests-requestStats");
const [requestStats] = clientApi.widget.mediaRequests.getStats.useSuspenseQuery(
{
integrationIds,
},
{
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
},
);
const board = useRequiredBoard();
if (requestStats.users.length === 0 && requestStats.stats.length === 0) throw new NoIntegrationDataError();
const data = [
{
name: "approved",
icon: IconThumbUp,
number: requestStats.stats.reduce((count, { approved }) => count + approved, 0),
},
{
name: "pending",
icon: IconHourglass,
number: requestStats.stats.reduce((count, { pending }) => count + pending, 0),
},
{
name: "processing",
icon: IconLoaderQuarter,
number: requestStats.stats.reduce((count, { processing }) => count + processing, 0),
},
{
name: "declined",
icon: IconThumbDown,
number: requestStats.stats.reduce((count, { declined }) => count + declined, 0),
},
{
name: "available",
icon: IconPlayerPlay,
number: requestStats.stats.reduce((count, { available }) => count + available, 0),
},
{
name: "tv",
icon: IconDeviceTv,
number: requestStats.stats.reduce((count, { tv }) => count + tv, 0),
},
{
name: "movie",
icon: IconMovie,
number: requestStats.stats.reduce((count, { movie }) => count + movie, 0),
},
{
name: "total",
icon: IconReceipt,
number: requestStats.stats.reduce((count, { total }) => count + total, 0),
},
] satisfies { name: keyof RequestStats; icon: Icon; number: number }[];
const isTiny = width < 256;
return (
<Stack
className="mediaRequests-stats-layout"
h="100%"
gap="xs"
p="sm"
align="center"
justify="space-between"
style={{ pointerEvents: isEditMode ? "none" : undefined }}
>
<Stack gap={4} w="100%">
<Text className="mediaRequests-stats-stats-title" fw="bold" ta="center" size={isTiny ? "xs" : "sm"}>
{t("titles.stats.main")}
</Text>
<Grid className="mediaRequests-stats-stats-grid" gutter={4} w="100%">
{data.map((stat) => (
<Grid.Col
className={combineClasses("mediaRequests-stats-stat-wrapper", `mediaRequests-stats-stat-${stat.name}`)}
key={stat.name}
span={isTiny ? 6 : 3}
>
<Tooltip label={t(`titles.stats.${stat.name}`)}>
<Card p={0} radius={board.itemRadius} className={classes.card}>
<Group className="mediaRequests-stats-stat-stack" justify="center" align="center" gap="xs" w="100%">
<stat.icon className="mediaRequests-stats-stat-icon" size={16} />
<Text className="mediaRequests-stats-stat-value" size="md">
{stat.number}
</Text>
</Group>
</Card>
</Tooltip>
</Grid.Col>
))}
</Grid>
</Stack>
<Stack gap={4} w="100%">
<Text className="mediaRequests-stats-users-title" fw="bold" ta="center" size={isTiny ? "xs" : "sm"}>
{t("titles.users.main")} ({t("titles.users.requests")})
</Text>
<Stack className="mediaRequests-stats-users-wrapper" flex={1} w="100%" gap={4} style={{ overflow: "hidden" }}>
{requestStats.users.slice(0, 10).map((user) => (
<Card
component="a"
href={user.link}
target="_blank"
rel="noopener noreferrer"
className={combineClasses(
"mediaRequests-stats-users-user-wrapper",
`mediaRequests-stats-users-user-${user.id}`,
classes.card,
)}
key={user.id}
p="xs"
radius={board.itemRadius}
>
<Group className="mediaRequests-stats-users-user-group" h="100%" p={0} gap="sm" justify="space-between">
<Group gap={4}>
<Tooltip label={user.integration.name}>
<Avatar
className="mediaRequests-stats-users-user-avatar"
size={20}
src={user.avatar}
bd={`2px solid ${user.integration.kind === "overseerr" ? "#ECB000" : "#6677CC"}`}
/>
</Tooltip>
<Text className="mediaRequests-stats-users-user-userName" size="sm">
{user.displayName}
</Text>
</Group>
<Text className="mediaRequests-stats-users-user-request-count" size="md" fw={500}>
{user.requestCount}
</Text>
</Group>
</Card>
))}
</Stack>
</Stack>
</Stack>
);
}

View File

@@ -0,0 +1,13 @@
import { IconChartBar } from "@tabler/icons-react";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { createWidgetDefinition } from "../../definition";
export const { componentLoader, definition } = createWidgetDefinition("mediaRequests-requestStats", {
icon: IconChartBar,
createOptions() {
return {};
},
supportedIntegrations: getIntegrationKindsByCategory("mediaRequest"),
}).withDynamicImport(() => import("./component"));

View File

@@ -0,0 +1,335 @@
"use client";
import type { ReactNode } from "react";
import { Fragment, useMemo } from "react";
import { Avatar, Divider, Flex, Group, Stack, Text, Title } from "@mantine/core";
import { IconDeviceTv, IconHeadphones, IconMovie, IconVideo } from "@tabler/icons-react";
import type { MRT_ColumnDef } from "mantine-react-table";
import { MantineReactTable } from "mantine-react-table";
import { clientApi } from "@homarr/api/client";
import { objectEntries } from "@homarr/common";
import { getIconUrl, integrationDefs } from "@homarr/definitions";
import type { StreamSession } from "@homarr/integrations";
import { createModal, useModalAction } from "@homarr/modals";
import { useScopedI18n } from "@homarr/translation/client";
import type { TablerIcon } from "@homarr/ui";
import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
import type { WidgetComponentProps } from "../definition";
export default function MediaServerWidget({
options,
integrationIds,
isEditMode,
}: WidgetComponentProps<"mediaServer">) {
const [currentStreams] = clientApi.widget.mediaServer.getCurrentStreams.useSuspenseQuery(
{
integrationIds,
showOnlyPlaying: options.showOnlyPlaying,
},
{
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
},
);
const utils = clientApi.useUtils();
const t = useScopedI18n("widget.mediaServer");
const columns = useMemo<MRT_ColumnDef<StreamSession>[]>(
() => [
{
accessorKey: "sessionName",
header: t("items.name"),
Cell: ({ row }) => (
<Text size="xs" style={{ overflow: "hidden", textOverflow: "ellipsis" }}>
{row.original.sessionName}
</Text>
),
},
{
accessorKey: "user.username",
header: t("items.user"),
Cell: ({ row }) => (
<Group gap="xs">
<Avatar size={20} src={row.original.user.profilePictureUrl} />
<Text size="xs">{row.original.user.username}</Text>
</Group>
),
},
{
accessorKey: "currentlyPlaying", // currentlyPlaying.name can be undefined which results in a warning. This is why we use currentlyPlaying instead of currentlyPlaying.name
header: t("items.currentlyPlaying"),
Cell: ({ row }) => {
if (!row.original.currentlyPlaying) return null;
const Icon = mediaTypeIconMap[row.original.currentlyPlaying.type];
return (
<Group gap="xs" align="center">
<Icon size={16} />
<Text size="xs" lineClamp={1}>
{row.original.currentlyPlaying.name}
</Text>
</Group>
);
},
},
],
[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(
() =>
currentStreams.flatMap((pair) =>
pair.sessions.map((session) => ({
...session,
integrationKind: pair.integrationKind,
integrationName: integrationDefs[pair.integrationKind].name,
integrationIcon: getIconUrl(pair.integrationKind),
})),
),
[currentStreams],
);
const { openModal } = useModalAction(ItemInfoModal);
const table = useTranslatedMantineReactTable({
columns,
data: flatSessions,
enablePagination: false,
enableTopToolbar: false,
enableBottomToolbar: false,
enableSorting: false,
enableColumnActions: false,
enableStickyHeader: false,
enableColumnOrdering: false,
enableRowSelection: false,
enableFullScreenToggle: false,
enableGlobalFilter: false,
enableDensityToggle: false,
enableFilters: false,
enableHiding: false,
enableColumnPinning: true,
initialState: {
density: "xs",
columnPinning: {
right: ["currentlyPlaying"],
},
},
mantineTableHeadProps: {
fz: "xs",
},
mantineTableHeadCellProps: {
py: 4,
},
mantinePaperProps: {
flex: 1,
withBorder: false,
shadow: undefined,
},
mantineTableProps: {
className: "media-server-widget-table",
style: {
tableLayout: "fixed",
},
},
mantineTableContainerProps: {
style: {
height: "100%",
},
},
mantineTableBodyCellProps: ({ row }) => ({
onClick: () => {
openModal(
{
item: row.original,
},
{
title: row.original.sessionName,
},
);
},
py: 4,
}),
});
const uniqueIntegrations = Array.from(new Set(flatSessions.map((session) => session.integrationKind))).map((kind) => {
const session = flatSessions.find((session) => session.integrationKind === kind);
return {
integrationKind: kind,
integrationIcon: session?.integrationIcon,
integrationName: session?.integrationName,
};
});
return (
<Stack gap={0} h="100%" display="flex">
<MantineReactTable table={table} />
<Group
gap="xs"
h={30}
px="xs"
pr="md"
justify="flex-end"
style={{
borderTop: "1px solid var(--border-color)",
}}
>
{uniqueIntegrations.map((integration) => (
<Group key={integration.integrationKind} gap="xs" align="center">
<Avatar className="media-server-icon" src={integration.integrationIcon} radius={"xs"} size="xs" />
<Text className="media-server-name" size="sm">
{integration.integrationName}
</Text>
</Group>
))}
</Group>
</Stack>
);
}
const ItemInfoModal = createModal<{ item: StreamSession }>(({ innerProps }) => {
const t = useScopedI18n("widget.mediaServer.items");
const Icon = innerProps.item.currentlyPlaying ? mediaTypeIconMap[innerProps.item.currentlyPlaying.type] : null;
const metadata = useMemo(() => {
return innerProps.item.currentlyPlaying?.metadata
? constructMetadata(innerProps.item.currentlyPlaying.metadata)
: null;
}, [innerProps.item.currentlyPlaying?.metadata]);
return (
<Stack align="center">
<Flex direction="column" gap="xs" align="center">
{Icon && innerProps.item.currentlyPlaying !== null && (
<Group gap="sm" align="center">
<Icon size={24} />
<Title order={2}>{innerProps.item.currentlyPlaying.name}</Title>
</Group>
)}
{innerProps.item.currentlyPlaying?.episodeName && (
<Group>
<Title order={4}>{innerProps.item.currentlyPlaying.episodeName}</Title>
{innerProps.item.currentlyPlaying.seasonName && (
<>
{" - "}
<Title order={4}>{innerProps.item.currentlyPlaying.seasonName}</Title>
</>
)}
</Group>
)}
</Flex>
<NormalizedLine
itemKey={t("user")}
value={
<Group gap="sm" align="center">
<Avatar size="sm" src={innerProps.item.user.profilePictureUrl} />{" "}
<Text>{innerProps.item.user.username}</Text>
</Group>
}
/>
<NormalizedLine itemKey={t("name")} value={<Text>{innerProps.item.sessionName}</Text>} />
<NormalizedLine itemKey={t("id")} value={<Text>{innerProps.item.sessionId}</Text>} />
{metadata ? (
<Stack w="100%" gap={0}>
<Divider label={t("metadata.title")} labelPosition="center" mt="lg" mb="sm" />
<Group align="flex-start">
{objectEntries(metadata).map(([key, value], index) => (
<Fragment key={key}>
{index !== 0 && <Divider key={index} orientation="vertical" />}
<Stack gap={4}>
<Text fw="bold">{t(`metadata.${key}.title`)}</Text>
{Object.entries(value)
.filter(([_, value]) => Boolean(value))
.map(([innerKey, value]) => (
<Group justify="space-between" w="100%" key={innerKey} wrap="nowrap">
<Text>{t(`metadata.${key}.${innerKey}` as never)}</Text>
<Text>{value}</Text>
</Group>
))}
</Stack>
</Fragment>
))}
</Group>
</Stack>
) : null}
</Stack>
);
}).withOptions({
defaultTitle() {
return "";
},
size: "lg",
centered: true,
});
const NormalizedLine = ({ itemKey, value }: { itemKey: string; value: ReactNode }) => {
return (
<Group w="100%" align="top" justify="space-between">
<Text>{itemKey}:</Text>
{value}
</Group>
);
};
const mediaTypeIconMap = {
movie: IconMovie,
tv: IconDeviceTv,
video: IconVideo,
audio: IconHeadphones,
} satisfies Record<Exclude<StreamSession["currentlyPlaying"], null>["type"], TablerIcon>;
const constructMetadata = (metadata: Exclude<Exclude<StreamSession["currentlyPlaying"], null>["metadata"], null>) => ({
video: {
resolution: metadata.video.resolution
? `${metadata.video.resolution.width}x${metadata.video.resolution.height}`
: null,
frameRate: metadata.video.frameRate,
},
audio: {
channelCount: metadata.audio.channelCount,
codec: metadata.audio.codec,
},
transcoding: {
container: metadata.transcoding.container,
resolution: metadata.transcoding.resolution
? `${metadata.transcoding.resolution.width}x${metadata.transcoding.resolution.height}`
: null,
target: `${metadata.transcoding.target.videoCodec} ${metadata.transcoding.target.audioCodec}`.trim(),
},
});

View File

@@ -0,0 +1,16 @@
import { IconVideo } from "@tabler/icons-react";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options";
export const { componentLoader, definition } = createWidgetDefinition("mediaServer", {
icon: IconVideo,
createOptions() {
return optionsBuilder.from((factory) => ({
showOnlyPlaying: factory.switch({ defaultValue: true, withDescription: true }),
}));
},
supportedIntegrations: getIntegrationKindsByCategory("mediaService"),
}).withDynamicImport(() => import("./component"));

View File

@@ -0,0 +1,116 @@
"use client";
import { useState } from "react";
import { Center, Divider, Group, Pagination, SegmentedControl, Stack, Text } from "@mantine/core";
import { IconClipboardList, IconCpu2, IconReportAnalytics } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useI18n } from "@homarr/translation/client";
import type { TablerIcon } from "@homarr/ui";
import { views } from ".";
import type { WidgetComponentProps } from "../definition";
import { HealthCheckStatus } from "./health-check-status";
import { QueuePanel } from "./panels/queue.panel";
import { StatisticsPanel } from "./panels/statistics.panel";
import { WorkersPanel } from "./panels/workers.panel";
type View = (typeof views)[number];
const viewIcons = {
workers: IconCpu2,
queue: IconClipboardList,
statistics: IconReportAnalytics,
} satisfies Record<View, TablerIcon>;
export default function MediaTranscodingWidget({
integrationIds,
options,
width,
}: WidgetComponentProps<"mediaTranscoding">) {
const [queuePage, setQueuePage] = useState(1);
const queuePageSize = 10;
const input = {
integrationId: integrationIds[0] ?? "",
pageSize: queuePageSize,
page: queuePage,
};
const [transcodingData] = clientApi.widget.mediaTranscoding.getDataAsync.useSuspenseQuery(input, {
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
});
const utils = clientApi.useUtils();
clientApi.widget.mediaTranscoding.subscribeData.useSubscription(input, {
onData(data) {
utils.widget.mediaTranscoding.getDataAsync.setData(input, data);
},
});
const [view, setView] = useState<View>(options.defaultView);
const totalQueuePages = Math.ceil((transcodingData.data.queue.totalCount || 1) / queuePageSize);
const t = useI18n("widget.mediaTranscoding");
const isTiny = width < 256;
return (
<Stack gap={4} h="100%">
{view === "workers" ? (
<WorkersPanel workers={transcodingData.data.workers} isTiny={isTiny} />
) : view === "queue" ? (
<QueuePanel queue={transcodingData.data.queue} />
) : (
<StatisticsPanel statistics={transcodingData.data.statistics} />
)}
<Divider />
<Group gap="xs" mb={4} ms={4} me={8}>
<SegmentedControl
data={views.map((value) => {
const Icon = viewIcons[value];
return {
label: (
<Center style={{ gap: 4 }}>
<Icon size={12} />
{!isTiny && (
<Text span size="xs">
{t(`tab.${value}`)}
</Text>
)}
</Center>
),
value,
};
})}
value={view}
onChange={(value) => setView(value as View)}
size="xs"
/>
<Group gap="xs" ml="auto">
{view === "queue" && (
<>
<Pagination.Root total={totalQueuePages} value={queuePage} onChange={setQueuePage} size="xs">
<Group gap={2} justify="center">
{!isTiny && <Pagination.First disabled={transcodingData.data.queue.startIndex === 1} />}
<Pagination.Previous disabled={transcodingData.data.queue.startIndex === 1} />
<Pagination.Next disabled={transcodingData.data.queue.startIndex === totalQueuePages} />
{!isTiny && <Pagination.Last disabled={transcodingData.data.queue.startIndex === totalQueuePages} />}
</Group>
</Pagination.Root>
<Text size="xs">
{t("currentIndex", {
start: String(transcodingData.data.queue.startIndex + 1),
end: String(transcodingData.data.queue.endIndex + 1),
total: String(transcodingData.data.queue.totalCount),
})}
</Text>
</>
)}
<HealthCheckStatus statistics={transcodingData.data.statistics} />
</Group>
</Group>
</Stack>
);
}

View File

@@ -0,0 +1,76 @@
import type { MantineColor } from "@mantine/core";
import { Divider, Group, HoverCard, Indicator, RingProgress, Stack, Text } from "@mantine/core";
import { useColorScheme } from "@mantine/hooks";
import { IconHeartbeat } from "@tabler/icons-react";
import type { TdarrStatistics } from "@homarr/integrations";
import { useI18n } from "@homarr/translation/client";
interface HealthCheckStatusProps {
statistics: TdarrStatistics;
}
export function HealthCheckStatus(props: HealthCheckStatusProps) {
const colorScheme = useColorScheme();
const t = useI18n("widget.mediaTranscoding.healthCheck");
const indicatorColor = props.statistics.failedHealthCheckCount
? "red"
: props.statistics.stagedHealthCheckCount
? "yellow"
: "green";
return (
<HoverCard position="bottom" width={250} shadow="sm">
<HoverCard.Target>
<Indicator color={textColor(indicatorColor, colorScheme)} size={6} display="flex">
<IconHeartbeat size={16} />
</Indicator>
</HoverCard.Target>
<HoverCard.Dropdown bg={colorScheme === "light" ? "gray.2" : "dark.8"}>
<Stack gap="sm" align="center">
<Group gap="xs">
<IconHeartbeat size={18} />
<Text size="sm">{t("title")}</Text>
</Group>
<Divider
style={{
alignSelf: "stretch",
}}
/>
<RingProgress
sections={[
{ value: props.statistics.stagedHealthCheckCount, color: textColor("yellow", colorScheme) },
{ value: props.statistics.totalHealthCheckCount, color: textColor("green", colorScheme) },
{ value: props.statistics.failedHealthCheckCount, color: textColor("red", colorScheme) },
]}
/>
<Group display="flex" w="100%">
<Stack style={{ flex: 1 }} gap={0} align="center">
<Text size="xs" c={textColor("yellow", colorScheme)}>
{props.statistics.stagedHealthCheckCount}
</Text>
<Text size="xs">{t("queued")}</Text>
</Stack>
<Stack style={{ flex: 1 }} gap={0} align="center">
<Text size="xs" c={textColor("green", colorScheme)}>
{props.statistics.totalHealthCheckCount}
</Text>
<Text size="xs">{t("status.healthy")}</Text>
</Stack>
<Stack style={{ flex: 1 }} gap={0} align="center">
<Text size="xs" c={textColor("red", colorScheme)}>
{props.statistics.failedHealthCheckCount}
</Text>
<Text size="xs">{t("status.unhealthy")}</Text>
</Stack>
</Group>
</Stack>
</HoverCard.Dropdown>
</HoverCard>
);
}
function textColor(color: MantineColor, theme: "light" | "dark") {
return `${color}.${theme === "light" ? 8 : 5}`;
}

View File

@@ -0,0 +1,24 @@
import { IconTransform } from "@tabler/icons-react";
import { z } from "zod/v4";
import { capitalize } from "@homarr/common";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options";
export const views = ["workers", "queue", "statistics"] as const;
export const { componentLoader, definition } = createWidgetDefinition("mediaTranscoding", {
icon: IconTransform,
createOptions() {
return optionsBuilder.from((factory) => ({
defaultView: factory.select({
defaultValue: "statistics",
options: views.map((view) => ({ label: capitalize(view), value: view })),
}),
queuePageSize: factory.number({ defaultValue: 10, validate: z.number().min(1).max(30) }),
}));
},
supportedIntegrations: getIntegrationKindsByCategory("mediaTranscoding"),
}).withDynamicImport(() => import("./component"));

View File

@@ -0,0 +1,70 @@
import { Center, Group, ScrollArea, Table, TableTd, TableTh, TableTr, Text, Title, Tooltip } from "@mantine/core";
import { IconHeartbeat, IconTransform } from "@tabler/icons-react";
import { humanFileSize } from "@homarr/common";
import type { TdarrQueue } from "@homarr/integrations";
import { useI18n } from "@homarr/translation/client";
interface QueuePanelProps {
queue: TdarrQueue;
}
export function QueuePanel(props: QueuePanelProps) {
const { queue } = props;
const t = useI18n("widget.mediaTranscoding.panel.queue");
if (queue.array.length === 0) {
return (
<Center style={{ flex: "1" }}>
<Title order={6}>{t("empty")}</Title>
</Center>
);
}
return (
<ScrollArea style={{ flex: "1" }}>
<Table style={{ tableLayout: "fixed" }}>
<Table.Thead>
<TableTr>
<TableTh ta="start" py={4}>
<Text size="xs" fw="bold">
{t("table.file")}
</Text>
</TableTh>
<TableTh ta="start" py={4}>
<Text size="xs" fw="bold">
{t("table.size")}
</Text>
</TableTh>
</TableTr>
</Table.Thead>
<Table.Tbody>
{queue.array.map((item) => (
<TableTr key={item.id}>
<TableTd py={2}>
<Group gap={4} wrap="nowrap">
{item.type === "transcode" ? (
<Tooltip label={t("table.transcode")}>
<IconTransform size={12} />
</Tooltip>
) : (
<Tooltip label={t("table.healthCheck")}>
<IconHeartbeat size={12} />
</Tooltip>
)}
<Text lineClamp={1} size="xs">
{item.filePath.split("\\").pop()?.split("/").pop() ?? item.filePath}
</Text>
</Group>
</TableTd>
<TableTd py={2}>
<Text size="xs">{humanFileSize(item.fileSize)}</Text>
</TableTd>
</TableTr>
))}
</Table.Tbody>
</Table>
</ScrollArea>
);
}

View File

@@ -0,0 +1,102 @@
import type { MantineColor, RingProgressProps } from "@mantine/core";
import { Card, Center, Group, RingProgress, ScrollArea, Stack, Text, Title, Tooltip } from "@mantine/core";
import { IconDatabaseHeart, IconFileDescription, IconHeartbeat, IconTransform } from "@tabler/icons-react";
import { useRequiredBoard } from "@homarr/boards/context";
import { humanFileSize } from "@homarr/common";
import type { TdarrPieSegment, TdarrStatistics } from "@homarr/integrations";
import { useI18n } from "@homarr/translation/client";
import type { TablerIcon } from "@homarr/ui";
const PIE_COLORS: MantineColor[] = ["cyan", "grape", "gray", "orange", "pink"];
interface StatisticsPanelProps {
statistics: TdarrStatistics;
}
export function StatisticsPanel(props: StatisticsPanelProps) {
const t = useI18n("widget.mediaTranscoding.panel.statistics");
const allLibs = props.statistics;
// Check if Tdarr hs any Files
if (!(allLibs.totalFileCount > 0)) {
return (
<Center style={{ flex: "1" }}>
<Title order={6}>{t("empty")}</Title>
</Center>
);
}
return (
<ScrollArea h="100%">
<Group wrap="wrap" justify="center" p={4} w="100%" gap="xs">
<StatisticItem icon={IconTransform} label={t("transcodesCount")} value={props.statistics.totalTranscodeCount} />
<StatisticItem
icon={IconHeartbeat}
label={t("healthChecksCount")}
value={props.statistics.totalHealthCheckCount}
/>
<StatisticItem icon={IconFileDescription} label={t("filesCount")} value={props.statistics.totalFileCount} />
<StatisticItem
icon={IconDatabaseHeart}
label={t("savedSpace")}
value={humanFileSize(Math.floor(allLibs.totalSavedSpace))}
/>
</Group>
<Group justify="center" wrap="wrap" grow>
<StatisticRingProgress items={allLibs.transcodeStatus} label={t("transcodes")} />
<StatisticRingProgress items={allLibs.healthCheckStatus} label={t("healthChecks")} />
<StatisticRingProgress items={allLibs.videoCodecs} label={t("videoCodecs")} />
<StatisticRingProgress items={allLibs.videoContainers} label={t("videoContainers")} />
<StatisticRingProgress items={allLibs.videoResolutions} label={t("videoResolutions")} />
</Group>
</ScrollArea>
);
}
interface StatisticRingProgressProps {
items: TdarrPieSegment[];
label: string;
}
const StatisticRingProgress = ({ items, label }: StatisticRingProgressProps) => {
return (
<Stack align="center" gap={0} miw={60}>
<Text size="10px" ta="center" style={{ whiteSpace: "nowrap" }}>
{label}
</Text>
<RingProgress size={60} thickness={6} sections={toRingProgressSections(items)} />
</Stack>
);
};
function toRingProgressSections(segments: TdarrPieSegment[]): RingProgressProps["sections"] {
const total = segments.reduce((prev, curr) => prev + curr.value, 0);
return segments.map((segment, index) => ({
value: (segment.value * 100) / total,
tooltip: `${segment.name}: ${segment.value}`,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
color: PIE_COLORS[index % PIE_COLORS.length]!, // Ensures a valid color in the case that index > PIE_COLORS.length
}));
}
interface StatisticItemProps {
icon: TablerIcon;
value: string | number;
label: string;
}
function StatisticItem(props: StatisticItemProps) {
const board = useRequiredBoard();
return (
<Tooltip label={props.label}>
<Card p={0} withBorder radius={board.itemRadius} miw={48} flex={1}>
<Group justify="center" align="center" gap="xs" w="100%" wrap="nowrap">
<props.icon size={16} style={{ minWidth: 16 }} />
<Text size="md">{props.value}</Text>
</Group>
</Card>
</Tooltip>
);
}

View File

@@ -0,0 +1,108 @@
import {
Center,
Group,
Progress,
ScrollArea,
Table,
TableTd,
TableTh,
TableTr,
Text,
Title,
Tooltip,
} from "@mantine/core";
import { IconHeartbeat, IconTransform } from "@tabler/icons-react";
import type { TdarrWorker } from "@homarr/integrations";
import { useI18n } from "@homarr/translation/client";
interface WorkersPanelProps {
workers: TdarrWorker[];
isTiny: boolean;
}
export function WorkersPanel(props: WorkersPanelProps) {
const t = useI18n("widget.mediaTranscoding.panel.workers");
if (props.workers.length === 0) {
return (
<Center style={{ flex: "1" }}>
<Title order={6}>{t("empty")}</Title>
</Center>
);
}
return (
<ScrollArea style={{ flex: "1" }}>
<Table style={{ tableLayout: "fixed" }}>
<Table.Thead>
<TableTr>
<TableTh ta="start" py={4}>
<Text size="xs" fw="bold">
{t("table.file")}
</Text>
</TableTh>
<TableTh ta="start" py={4} w={50}>
<Text size="xs" fw="bold">
{t("table.eta")}
</Text>
</TableTh>
<TableTh ta="start" py={4}>
<Text size="xs" fw="bold">
{t("table.progress")}
</Text>
</TableTh>
</TableTr>
</Table.Thead>
<Table.Tbody>
{props.workers.map((worker) => {
const fileName = worker.filePath.split("\\").pop()?.split("/").pop() ?? worker.filePath;
return (
<TableTr key={worker.id}>
<TableTd py={2}>
<Group gap="xs" wrap="nowrap">
<div>
{worker.jobType === "transcode" ? (
<Tooltip label={t("table.transcode")}>
<IconTransform size={14} />
</Tooltip>
) : (
<Tooltip label={t("table.healthCheck")}>
<IconHeartbeat size={14} />
</Tooltip>
)}
</div>
<Text lineClamp={1} size="xs" title={fileName}>
{fileName}
</Text>
</Group>
</TableTd>
<TableTd py={2}>
<Text size="xs">{worker.ETA.startsWith("0:") ? worker.ETA.substring(2) : worker.ETA}</Text>
</TableTd>
<TableTd py={2}>
<Group wrap="nowrap" gap="xs">
{!props.isTiny && (
<>
<Text size="xs">{worker.step}</Text>
<Progress
value={worker.percentage}
size="lg"
radius="xl"
style={{
flex: 1,
}}
/>
</>
)}
<Text size="xs">{Math.round(worker.percentage)}%</Text>
</Group>
</TableTd>
</TableTr>
);
})}
</Table.Tbody>
</Table>
</ScrollArea>
);
}

View File

@@ -0,0 +1,76 @@
"use client";
import { Box, Flex, Group, Text, Tooltip } from "@mantine/core";
import { IconCube, IconUsersGroup } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { formatNumber } from "@homarr/common";
import { useScopedI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../../definition";
export default function MinecraftServerStatusWidget({ options }: WidgetComponentProps<"minecraftServerStatus">) {
const [{ data }] = clientApi.widget.minecraft.getServerStatus.useSuspenseQuery(options);
const utils = clientApi.useUtils();
clientApi.widget.minecraft.subscribeServerStatus.useSubscription(options, {
onData(data) {
utils.widget.minecraft.getServerStatus.setData(options, {
data,
timestamp: new Date(),
});
},
});
const tStatus = useScopedI18n("widget.minecraftServerStatus.status");
const title = options.title.trim().length > 0 ? options.title : options.domain;
return (
<Flex
className="minecraftServerStatus-wrapper"
h="100%"
w="100%"
direction="column"
p="sm"
justify="center"
align="center"
>
<Group gap="xs" wrap="nowrap" align="center">
<Tooltip label={data.online ? tStatus("online") : tStatus("offline")}>
<Box miw="md" h="md" bg={data.online ? "teal" : "red"} style={{ borderRadius: "100%" }}></Box>
</Tooltip>
<Text size="md" fw="bold">
{title}
</Text>
</Group>
{data.online && (
<>
{!options.isBedrockServer &&
(data.icon ? (
<img
style={{ flex: 1, transform: "scale(0.8)", objectFit: "contain" }}
alt={`minecraft icon ${options.domain}`}
src={data.icon}
/>
) : (
<Box
style={{
flex: 1,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<IconCube size="3rem" color="var(--mantine-color-gray-5)" />
</Box>
))}
<Group gap={5} c="gray.6" align="center">
<IconUsersGroup size="1rem" />
<Text size="md">
{formatNumber(data.players.online, 1)} / {formatNumber(data.players.max, 1)}
</Text>
</Group>
</>
)}
</Flex>
);
}

View File

@@ -0,0 +1,16 @@
import { IconBrandMinecraft } from "@tabler/icons-react";
import { z } from "zod/v4";
import { createWidgetDefinition } from "../../definition";
import { optionsBuilder } from "../../options";
export const { componentLoader, definition } = createWidgetDefinition("minecraftServerStatus", {
icon: IconBrandMinecraft,
createOptions() {
return optionsBuilder.from((factory) => ({
title: factory.text({ defaultValue: "" }),
domain: factory.text({ defaultValue: "hypixel.net", validate: z.string().nonempty() }),
isBedrockServer: factory.switch({ defaultValue: false }),
}));
},
}).withDynamicImport(() => import("./component"));

View File

@@ -0,0 +1 @@
export * from "./widget-edit-modal";

View File

@@ -0,0 +1,73 @@
"use client";
import { Button, CloseButton, ColorInput, Group, Input, Stack, TextInput, useMantineTheme } from "@mantine/core";
import { useForm } from "@homarr/form";
import { createModal } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client";
import { TextMultiSelect } from "@homarr/ui";
import type { BoardItemAdvancedOptions } from "@homarr/validation/shared";
interface InnerProps {
advancedOptions: BoardItemAdvancedOptions;
onSuccess: (options: BoardItemAdvancedOptions) => void;
}
export const WidgetAdvancedOptionsModal = createModal<InnerProps>(({ actions, innerProps }) => {
const t = useI18n();
const theme = useMantineTheme();
const form = useForm({
initialValues: innerProps.advancedOptions,
});
const handleSubmit = (values: BoardItemAdvancedOptions) => {
innerProps.onSuccess({
...values,
// we want to fallback to null if the title is empty
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
title: values.title?.trim() || null,
});
actions.closeModal();
};
return (
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack>
<TextInput
label={t("item.edit.field.title.label")}
{...form.getInputProps("title")}
rightSection={<Input.ClearButton onClick={() => form.setFieldValue("title", "")} />}
/>
<TextMultiSelect
label={t("item.edit.field.customCssClasses.label")}
{...form.getInputProps("customCssClasses")}
/>
<ColorInput
label={t("item.edit.field.borderColor.label")}
format="hex"
swatches={Object.values(theme.colors).map((color) => color[6])}
rightSection={
<CloseButton
onClick={() => form.setFieldValue("borderColor", "")}
style={{ display: form.getInputProps("borderColor").value ? undefined : "none" }}
/>
}
{...form.getInputProps("borderColor")}
/>
<Group justify="end">
<Button onClick={actions.closeModal} variant="subtle" color="gray">
{t("common.action.cancel")}
</Button>
<Button type="submit">{t("common.action.saveChanges")}</Button>
</Group>
</Stack>
</form>
);
}).withOptions({
defaultTitle(t) {
return t("item.edit.advancedOptions.title");
},
size: "lg",
transitionProps: {
duration: 0,
},
});

View File

@@ -0,0 +1,160 @@
"use client";
import { useState } from "react";
import { Button, Group, Stack } from "@mantine/core";
import { zod4Resolver } from "mantine-form-zod-resolver";
import { z } from "zod/v4";
import { objectEntries } from "@homarr/common";
import type { WidgetKind } from "@homarr/definitions";
import { createModal, useModalAction } from "@homarr/modals";
import type { SettingsContextProps } from "@homarr/settings/creator";
import { useI18n } from "@homarr/translation/client";
import { zodErrorMap } from "@homarr/validation/form/i18n";
import { widgetImports } from "..";
import { getInputForType } from "../_inputs";
import { FormProvider, useForm } from "../_inputs/form";
import type { BoardItemAdvancedOptions } from "../../../validation/src/shared";
import type { OptionsBuilderResult } from "../options";
import type { IntegrationSelectOption } from "../widget-integration-select";
import { WidgetIntegrationSelect } from "../widget-integration-select";
import { WidgetAdvancedOptionsModal } from "./widget-advanced-options-modal";
export interface WidgetEditModalState {
options: Record<string, unknown>;
integrationIds: string[];
advancedOptions: BoardItemAdvancedOptions;
}
interface ModalProps<TSort extends WidgetKind> {
kind: TSort;
value: WidgetEditModalState;
onSuccessfulEdit: (value: WidgetEditModalState) => void;
integrationData: IntegrationSelectOption[];
integrationSupport: boolean;
settings: SettingsContextProps;
}
export const WidgetEditModal = createModal<ModalProps<WidgetKind>>(({ actions, innerProps }) => {
const t = useI18n();
const [advancedOptions, setAdvancedOptions] = useState<BoardItemAdvancedOptions>(innerProps.value.advancedOptions);
// Translate the error messages
z.config({
customError: zodErrorMap(t),
});
const { definition } = widgetImports[innerProps.kind];
const options = definition.createOptions(innerProps.settings) as Record<string, OptionsBuilderResult[string]>;
const form = useForm({
mode: "controlled",
initialValues: innerProps.value,
validate: zod4Resolver(
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>>,
),
),
integrationIds: z.array(z.string()),
advancedOptions: z.object({
customCssClasses: z.array(z.string()),
borderColor: z.string(),
}),
}),
),
validateInputOnBlur: true,
validateInputOnChange: true,
});
const { openModal } = useModalAction(WidgetAdvancedOptionsModal);
return (
<form
onSubmit={form.onSubmit((values) => {
innerProps.onSuccessfulEdit({
...values,
advancedOptions,
});
actions.closeModal();
})}
>
<FormProvider form={form}>
<Stack>
{innerProps.integrationSupport && (
<WidgetIntegrationSelect
label={t("item.edit.field.integrations.label")}
data={innerProps.integrationData}
{...form.getInputProps("integrationIds")}
/>
)}
{Object.entries(options).map(([key, value]) => {
const Input = getInputForType(value.type);
if (
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
!Input ||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
value.shouldHide?.(
form.values.options as never,
innerProps.integrationData
.filter(({ id }) => form.values.integrationIds.includes(id))
.map(({ kind }) => kind),
)
) {
return null;
}
return (
<Input
key={key}
kind={innerProps.kind}
property={key}
options={value as never}
initialOptions={innerProps.value.options}
/>
);
})}
<Group justify="space-between">
<Button
variant="subtle"
onClick={() =>
openModal({
advancedOptions,
onSuccess(options) {
setAdvancedOptions(options);
innerProps.onSuccessfulEdit({
...innerProps.value,
advancedOptions: options,
});
},
})
}
>
{t("item.edit.advancedOptions.label")}
</Button>
<Group justify="end" w={{ base: "100%", xs: "auto" }}>
<Button onClick={actions.closeModal} variant="subtle" color="gray">
{t("common.action.cancel")}
</Button>
<Button type="submit">{t("common.action.saveChanges")}</Button>
</Group>
</Group>
</Stack>
</FormProvider>
</form>
);
}).withOptions({
keepMounted: true,
defaultTitle(t) {
return t("item.edit.title");
},
size: "lg",
});

View File

@@ -0,0 +1,84 @@
"use client";
import { useMemo } from "react";
import { Box } from "@mantine/core";
import dayjs from "dayjs";
import duration from "dayjs/plugin/duration";
import objectSupport from "dayjs/plugin/objectSupport";
import relativeTime from "dayjs/plugin/relativeTime";
import { clientApi } from "@homarr/api/client";
import type { WidgetComponentProps } from "../../definition";
import { WifiVariant } from "./variants/wifi-variant";
import { WiredVariant } from "./variants/wired-variant";
dayjs.extend(objectSupport);
dayjs.extend(relativeTime);
dayjs.extend(duration);
export default function NetworkControllerNetworkStatusWidget({
options,
integrationIds,
}: WidgetComponentProps<"networkControllerStatus">) {
const [summaries] = clientApi.widget.networkController.summary.useSuspenseQuery(
{
integrationIds,
},
{
refetchOnMount: false,
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 = useMemo(() => summaries.flatMap(({ summary }) => summary), [summaries]);
return (
<Box p={"sm"}>
{options.content === "wifi" ? (
<WifiVariant
countGuests={data.reduce((sum, summary) => sum + summary.wifi.guests, 0)}
countUsers={data.reduce((sum, summary) => sum + summary.wifi.users, 0)}
/>
) : (
<WiredVariant
countGuests={data.reduce((sum, summary) => sum + summary.lan.guests, 0)}
countUsers={data.reduce((sum, summary) => sum + summary.lan.users, 0)}
/>
)}
</Box>
);
}

View File

@@ -0,0 +1,28 @@
import { IconServerOff, IconTopologyFull } from "@tabler/icons-react";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { createWidgetDefinition } from "../../definition";
import { optionsBuilder } from "../../options";
export const { definition, componentLoader } = createWidgetDefinition("networkControllerStatus", {
icon: IconTopologyFull,
createOptions() {
return optionsBuilder.from((factory) => ({
content: factory.select({
options: (["wifi", "wired"] as const).map((value) => ({
value,
label: (t) => t(`widget.networkControllerStatus.option.content.option.${value}.label`),
})),
defaultValue: "wifi",
}),
}));
},
supportedIntegrations: getIntegrationKindsByCategory("networkController"),
errors: {
INTERNAL_SERVER_ERROR: {
icon: IconServerOff,
message: (t) => t("widget.networkController.error.internalServerError"),
},
},
}).withDynamicImport(() => import("./component"));

View File

@@ -0,0 +1,14 @@
import { Stack, Text } from "@mantine/core";
export const StatRow = ({ label, value }: { label: string; value: string | number }) => {
return (
<Stack gap={0}>
<Text size={"2xl"} fw={900} lh={1}>
{value}
</Text>
<Text size={"md"} c={"dimmed"}>
{label}
</Text>
</Stack>
);
};

View File

@@ -0,0 +1,24 @@
import { Group, Stack, Text } from "@mantine/core";
import { IconWifi } from "@tabler/icons-react";
import { useScopedI18n } from "@homarr/translation/client";
import { StatRow } from "./stat-row";
export const WifiVariant = ({ countGuests, countUsers }: { countUsers: number; countGuests: number }) => {
const t = useScopedI18n("widget.networkControllerStatus.card");
return (
<>
<Group gap={"xs"} wrap={"nowrap"} mb={"md"}>
<IconWifi size={24} />
<Text size={"md"} fw={"bold"}>
{t("variants.wifi.name")}
</Text>
</Group>
<Stack gap={"lg"}>
<StatRow label={t("users.label")} value={countUsers} />
<StatRow label={t("guests.label")} value={countGuests} />
</Stack>
</>
);
};

Some files were not shown because too many files have changed in this diff Show More