Replace entire codebase with homarr-labs/homarr
This commit is contained in:
29
packages/widgets/src/_inputs/common.tsx
Normal file
29
packages/widgets/src/_inputs/common.tsx
Normal 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;
|
||||
};
|
||||
8
packages/widgets/src/_inputs/form.ts
Normal file
8
packages/widgets/src/_inputs/form.ts
Normal 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">>();
|
||||
30
packages/widgets/src/_inputs/index.ts
Normal file
30
packages/widgets/src/_inputs/index.ts
Normal 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];
|
||||
};
|
||||
122
packages/widgets/src/_inputs/widget-app-input.tsx
Normal file
122
packages/widgets/src/_inputs/widget-app-input.tsx
Normal 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);
|
||||
237
packages/widgets/src/_inputs/widget-location-input.tsx
Normal file
237
packages/widgets/src/_inputs/widget-location-input.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
108
packages/widgets/src/_inputs/widget-multi-text-input.tsx
Normal file
108
packages/widgets/src/_inputs/widget-multi-text-input.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
33
packages/widgets/src/_inputs/widget-multiselect-input.tsx
Normal file
33
packages/widgets/src/_inputs/widget-multiselect-input.tsx
Normal 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}`)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
23
packages/widgets/src/_inputs/widget-number-input.tsx
Normal file
23
packages/widgets/src/_inputs/widget-number-input.tsx
Normal 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}`)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
79
packages/widgets/src/_inputs/widget-select-input.tsx
Normal file
79
packages/widgets/src/_inputs/widget-select-input.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
27
packages/widgets/src/_inputs/widget-slider-input.tsx
Normal file
27
packages/widgets/src/_inputs/widget-slider-input.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
233
packages/widgets/src/_inputs/widget-sortable-item-list-input.tsx
Normal file
233
packages/widgets/src/_inputs/widget-sortable-item-list-input.tsx
Normal 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);
|
||||
20
packages/widgets/src/_inputs/widget-switch-input.tsx
Normal file
20
packages/widgets/src/_inputs/widget-switch-input.tsx
Normal 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" })}
|
||||
/>
|
||||
);
|
||||
};
|
||||
20
packages/widgets/src/_inputs/widget-text-input.tsx
Normal file
20
packages/widgets/src/_inputs/widget-text-input.tsx
Normal 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}`)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
17
packages/widgets/src/app/app.module.css
Normal file
17
packages/widgets/src/app/app.module.css
Normal 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);
|
||||
}
|
||||
161
packages/widgets/src/app/component.tsx
Normal file
161
packages/widgets/src/app/component.tsx
Normal 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
|
||||
);
|
||||
104
packages/widgets/src/app/index.ts
Normal file
104
packages/widgets/src/app/index.ts
Normal 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"));
|
||||
34
packages/widgets/src/app/ping/ping-dot.tsx
Normal file
34
packages/widgets/src/app/ping/ping-dot.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
48
packages/widgets/src/app/ping/ping-indicator.tsx
Normal file
48
packages/widgets/src/app/ping/ping-indicator.tsx
Normal 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
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
25
packages/widgets/src/app/prefetch.ts
Normal file
25
packages/widgets/src/app/prefetch.ts
Normal 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;
|
||||
30
packages/widgets/src/bookmarks/add-button.tsx
Normal file
30
packages/widgets/src/bookmarks/add-button.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
114
packages/widgets/src/bookmarks/app-select-modal.tsx
Normal file
114
packages/widgets/src/bookmarks/app-select-modal.tsx
Normal 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);
|
||||
15
packages/widgets/src/bookmarks/bookmark.module.css
Normal file
15
packages/widgets/src/bookmarks/bookmark.module.css
Normal 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);
|
||||
}
|
||||
290
packages/widgets/src/bookmarks/component.tsx
Normal file
290
packages/widgets/src/bookmarks/component.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
62
packages/widgets/src/bookmarks/index.tsx
Normal file
62
packages/widgets/src/bookmarks/index.tsx
Normal 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"));
|
||||
32
packages/widgets/src/bookmarks/prefetch.ts
Normal file
32
packages/widgets/src/bookmarks/prefetch.ts
Normal 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;
|
||||
@@ -0,0 +1,3 @@
|
||||
.badge {
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
171
packages/widgets/src/calendar/calendar-event-list.tsx
Normal file
171
packages/widgets/src/calendar/calendar-event-list.tsx
Normal 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);
|
||||
};
|
||||
66
packages/widgets/src/calendar/calendar.spec.ts
Normal file
66
packages/widgets/src/calendar/calendar.spec.ts
Normal 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,
|
||||
});
|
||||
106
packages/widgets/src/calendar/calender-day.tsx
Normal file
106
packages/widgets/src/calendar/calender-day.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
5
packages/widgets/src/calendar/component.module.css
Normal file
5
packages/widgets/src/calendar/component.module.css
Normal file
@@ -0,0 +1,5 @@
|
||||
.calendar div[data-month-level] {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
211
packages/widgets/src/calendar/component.tsx
Normal file
211
packages/widgets/src/calendar/component.tsx
Normal 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;
|
||||
};
|
||||
36
packages/widgets/src/calendar/index.ts
Normal file
36
packages/widgets/src/calendar/index.ts
Normal 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"));
|
||||
80
packages/widgets/src/clock/component.tsx
Normal file
80
packages/widgets/src/clock/component.tsx
Normal 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;
|
||||
};
|
||||
72
packages/widgets/src/clock/index.ts
Normal file
72
packages/widgets/src/clock/index.ts
Normal 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"));
|
||||
84
packages/widgets/src/definition.ts
Normal file
84
packages/widgets/src/definition.ts
Normal 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">;
|
||||
};
|
||||
}
|
||||
105
packages/widgets/src/dns-hole/controls/TimerModal.tsx
Normal file
105
packages/widgets/src/dns-hole/controls/TimerModal.tsx
Normal 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;
|
||||
@@ -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);
|
||||
}
|
||||
479
packages/widgets/src/dns-hole/controls/component.tsx
Normal file
479
packages/widgets/src/dns-hole/controls/component.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
26
packages/widgets/src/dns-hole/controls/index.ts
Normal file
26
packages/widgets/src/dns-hole/controls/index.ts
Normal 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"));
|
||||
226
packages/widgets/src/dns-hole/summary/component.tsx
Normal file
226
packages/widgets/src/dns-hole/summary/component.tsx
Normal 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,
|
||||
},
|
||||
};
|
||||
};
|
||||
33
packages/widgets/src/dns-hole/summary/index.ts
Normal file
33
packages/widgets/src/dns-hole/summary/index.ts
Normal 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"));
|
||||
276
packages/widgets/src/docker/component.tsx
Normal file
276
packages/widgets/src/docker/component.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
40
packages/widgets/src/docker/index.ts
Normal file
40
packages/widgets/src/docker/index.ts
Normal 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"));
|
||||
951
packages/widgets/src/downloads/component.tsx
Normal file
951
packages/widgets/src/downloads/component.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
122
packages/widgets/src/downloads/index.ts
Normal file
122
packages/widgets/src/downloads/index.ts
Normal 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"));
|
||||
36
packages/widgets/src/errors/base-component.tsx
Normal file
36
packages/widgets/src/errors/base-component.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
10
packages/widgets/src/errors/base.ts
Normal file
10
packages/widgets/src/errors/base.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
81
packages/widgets/src/errors/component.tsx
Normal file
81
packages/widgets/src/errors/component.tsx
Normal 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;
|
||||
};
|
||||
2
packages/widgets/src/errors/index.ts
Normal file
2
packages/widgets/src/errors/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./no-integration-selected";
|
||||
export * from "./base";
|
||||
19
packages/widgets/src/errors/no-data-integration.tsx
Normal file
19
packages/widgets/src/errors/no-data-integration.tsx
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
19
packages/widgets/src/errors/no-integration-selected.tsx
Normal file
19
packages/widgets/src/errors/no-integration-selected.tsx
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
397
packages/widgets/src/firewall/component.tsx
Normal file
397
packages/widgets/src/firewall/component.tsx
Normal 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;
|
||||
}
|
||||
27
packages/widgets/src/firewall/firewall-menu.tsx
Normal file
27
packages/widgets/src/firewall/firewall-menu.tsx
Normal 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>
|
||||
);
|
||||
41
packages/widgets/src/firewall/firewall-version.tsx
Normal file
41
packages/widgets/src/firewall/firewall-version.tsx
Normal 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";
|
||||
}
|
||||
}
|
||||
7
packages/widgets/src/firewall/firewall.module.css
Normal file
7
packages/widgets/src/firewall/firewall.module.css
Normal 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);
|
||||
}
|
||||
20
packages/widgets/src/firewall/index.ts
Normal file
20
packages/widgets/src/firewall/index.ts
Normal 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"));
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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} />;
|
||||
}
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
58
packages/widgets/src/health-monitoring/component.tsx
Normal file
58
packages/widgets/src/health-monitoring/component.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
105
packages/widgets/src/health-monitoring/index.ts
Normal file
105
packages/widgets/src/health-monitoring/index.ts
Normal 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"));
|
||||
30
packages/widgets/src/health-monitoring/rings/cpu-ring.tsx
Normal file
30
packages/widgets/src/health-monitoring/rings/cpu-ring.tsx
Normal 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))),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
47
packages/widgets/src/health-monitoring/rings/memory-ring.tsx
Normal file
47
packages/widgets/src/health-monitoring/rings/memory-ring.tsx
Normal 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 },
|
||||
};
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
331
packages/widgets/src/health-monitoring/system-health.tsx
Normal file
331
packages/widgets/src/health-monitoring/system-health.tsx
Normal 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));
|
||||
};
|
||||
8
packages/widgets/src/iframe/component.module.css
Normal file
8
packages/widgets/src/iframe/component.module.css
Normal file
@@ -0,0 +1,8 @@
|
||||
.iframe {
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
background: none;
|
||||
background-color: transparent;
|
||||
}
|
||||
118
packages/widgets/src/iframe/component.tsx
Normal file
118
packages/widgets/src/iframe/component.tsx
Normal 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>;
|
||||
22
packages/widgets/src/iframe/index.ts
Normal file
22
packages/widgets/src/iframe/index.ts
Normal 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"));
|
||||
3
packages/widgets/src/import.ts
Normal file
3
packages/widgets/src/import.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import type { WidgetKind } from "@homarr/definitions";
|
||||
|
||||
export type WidgetImportRecord = Record<WidgetKind, unknown>;
|
||||
130
packages/widgets/src/index.tsx
Normal file
130
packages/widgets/src/index.tsx
Normal 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>,
|
||||
);
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
143
packages/widgets/src/indexer-manager/component.tsx
Normal file
143
packages/widgets/src/indexer-manager/component.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
24
packages/widgets/src/indexer-manager/index.ts
Normal file
24
packages/widgets/src/indexer-manager/index.ts
Normal 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"));
|
||||
206
packages/widgets/src/media-releases/component.tsx
Normal file
206
packages/widgets/src/media-releases/component.tsx
Normal 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;
|
||||
};
|
||||
35
packages/widgets/src/media-releases/index.ts
Normal file
35
packages/widgets/src/media-releases/index.ts
Normal 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"));
|
||||
255
packages/widgets/src/media-requests/list/component.tsx
Normal file
255
packages/widgets/src/media-requests/list/component.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
18
packages/widgets/src/media-requests/list/index.ts
Normal file
18
packages/widgets/src/media-requests/list/index.ts
Normal 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"));
|
||||
@@ -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);
|
||||
}
|
||||
172
packages/widgets/src/media-requests/stats/component.tsx
Normal file
172
packages/widgets/src/media-requests/stats/component.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
13
packages/widgets/src/media-requests/stats/index.ts
Normal file
13
packages/widgets/src/media-requests/stats/index.ts
Normal 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"));
|
||||
335
packages/widgets/src/media-server/component.tsx
Normal file
335
packages/widgets/src/media-server/component.tsx
Normal 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(),
|
||||
},
|
||||
});
|
||||
16
packages/widgets/src/media-server/index.ts
Normal file
16
packages/widgets/src/media-server/index.ts
Normal 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"));
|
||||
116
packages/widgets/src/media-transcoding/component.tsx
Normal file
116
packages/widgets/src/media-transcoding/component.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
24
packages/widgets/src/media-transcoding/index.ts
Normal file
24
packages/widgets/src/media-transcoding/index.ts
Normal 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"));
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
108
packages/widgets/src/media-transcoding/panels/workers.panel.tsx
Normal file
108
packages/widgets/src/media-transcoding/panels/workers.panel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
76
packages/widgets/src/minecraft/server-status/component.tsx
Normal file
76
packages/widgets/src/minecraft/server-status/component.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
16
packages/widgets/src/minecraft/server-status/index.ts
Normal file
16
packages/widgets/src/minecraft/server-status/index.ts
Normal 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"));
|
||||
1
packages/widgets/src/modals/index.ts
Normal file
1
packages/widgets/src/modals/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./widget-edit-modal";
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
160
packages/widgets/src/modals/widget-edit-modal.tsx
Normal file
160
packages/widgets/src/modals/widget-edit-modal.tsx
Normal 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",
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"));
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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
Reference in New Issue
Block a user