chore(release): automatic release v0.1.0

This commit is contained in:
homarr-releases[bot]
2024-10-18 19:13:17 +00:00
committed by GitHub
127 changed files with 4468 additions and 1518 deletions

View File

@@ -20,8 +20,6 @@ DB_URL='FULL_PATH_TO_YOUR_SQLITE_DB_FILE'
# DB_PASSWORD='password' # DB_PASSWORD='password'
# DB_NAME='name-of-database' # DB_NAME='name-of-database'
# @see https://next-auth.js.org/configuration/options#nextauth_url
AUTH_URL='http://localhost:3000'
# You can generate the secret via 'openssl rand -base64 32' on Unix # You can generate the secret via 'openssl rand -base64 32' on Unix
# @see https://next-auth.js.org/configuration/options#secret # @see https://next-auth.js.org/configuration/options#secret

View File

@@ -37,17 +37,17 @@
"@homarr/ui": "workspace:^0.1.0", "@homarr/ui": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0", "@homarr/validation": "workspace:^0.1.0",
"@homarr/widgets": "workspace:^0.1.0", "@homarr/widgets": "workspace:^0.1.0",
"@mantine/colors-generator": "^7.13.2", "@mantine/colors-generator": "^7.13.3",
"@mantine/core": "^7.13.2", "@mantine/core": "^7.13.3",
"@mantine/hooks": "^7.13.2", "@mantine/hooks": "^7.13.3",
"@mantine/modals": "^7.13.2", "@mantine/modals": "^7.13.3",
"@mantine/tiptap": "^7.13.2", "@mantine/tiptap": "^7.13.3",
"@million/lint": "1.0.9", "@million/lint": "1.0.11",
"@t3-oss/env-nextjs": "^0.11.1", "@t3-oss/env-nextjs": "^0.11.1",
"@tabler/icons-react": "^3.19.0", "@tabler/icons-react": "^3.19.0",
"@tanstack/react-query": "^5.59.9", "@tanstack/react-query": "^5.59.15",
"@tanstack/react-query-devtools": "^5.59.9", "@tanstack/react-query-devtools": "^5.59.15",
"@tanstack/react-query-next-experimental": "5.59.9", "@tanstack/react-query-next-experimental": "5.59.15",
"@trpc/client": "next", "@trpc/client": "next",
"@trpc/next": "next", "@trpc/next": "next",
"@trpc/react-query": "next", "@trpc/react-query": "next",
@@ -55,22 +55,22 @@
"@xterm/addon-canvas": "^0.7.0", "@xterm/addon-canvas": "^0.7.0",
"@xterm/addon-fit": "0.10.0", "@xterm/addon-fit": "0.10.0",
"@xterm/xterm": "^5.5.0", "@xterm/xterm": "^5.5.0",
"chroma-js": "^3.1.1", "chroma-js": "^3.1.2",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
"flag-icons": "^7.2.3", "flag-icons": "^7.2.3",
"glob": "^11.0.0", "glob": "^11.0.0",
"jotai": "^2.10.0", "jotai": "^2.10.1",
"mantine-react-table": "2.0.0-beta.6", "mantine-react-table": "2.0.0-beta.7",
"next": "^14.2.15", "next": "^14.2.15",
"postcss-preset-mantine": "^1.17.0", "postcss-preset-mantine": "^1.17.0",
"prismjs": "^1.29.0", "prismjs": "^1.29.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-error-boundary": "^4.0.13", "react-error-boundary": "^4.1.1",
"react-simple-code-editor": "^0.14.1", "react-simple-code-editor": "^0.14.1",
"sass": "^1.79.5", "sass": "^1.80.2",
"superjson": "2.2.1", "superjson": "2.2.1",
"swagger-ui-react": "^5.17.14", "swagger-ui-react": "^5.17.14",
"use-deep-compare-effect": "^1.8.1" "use-deep-compare-effect": "^1.8.1"
@@ -80,7 +80,7 @@
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"@types/chroma-js": "2.4.4", "@types/chroma-js": "2.4.4",
"@types/node": "^20.16.11", "@types/node": "^20.16.12",
"@types/prismjs": "^1.26.4", "@types/prismjs": "^1.26.4",
"@types/react": "^18.3.11", "@types/react": "^18.3.11",
"@types/react-dom": "^18.3.1", "@types/react-dom": "^18.3.1",

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View File

@@ -16,7 +16,7 @@ export const CustomMantineProvider = ({ children }: PropsWithChildren) => {
return ( return (
<DirectionProvider> <DirectionProvider>
<MantineProvider <MantineProvider
defaultColorScheme="auto" defaultColorScheme="dark"
colorSchemeManager={manager} colorSchemeManager={manager}
theme={createTheme({ theme={createTheme({
primaryColor: "red", primaryColor: "red",
@@ -62,6 +62,7 @@ function useColorSchemeManager(): MantineColorSchemeManager {
}, },
set: (value) => { set: (value) => {
if (value === "auto") return;
try { try {
if (session) { if (session) {
mutateColorScheme({ colorScheme: value }); mutateColorScheme({ colorScheme: value });

View File

@@ -49,6 +49,7 @@ export const BoardProvider = ({
useEffect(() => { useEffect(() => {
setReadySections((previous) => previous.filter((id) => data.sections.some((section) => section.id === id))); setReadySections((previous) => previous.filter((id) => data.sections.some((section) => section.id === id)));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [data.sections.length, setReadySections]); }, [data.sections.length, setReadySections]);
const markAsReady = useCallback((id: string) => { const markAsReady = useCallback((id: string) => {

View File

@@ -28,7 +28,6 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
layout: createBoardLayout({ layout: createBoardLayout({
headerActions: <BoardContentHeaderActions />, headerActions: <BoardContentHeaderActions />,
getInitialBoardAsync: getInitialBoard, getInitialBoardAsync: getInitialBoard,
isBoardContentPage: true,
}), }),
// eslint-disable-next-line no-restricted-syntax // eslint-disable-next-line no-restricted-syntax
page: async () => { page: async () => {
@@ -50,6 +49,10 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
title: board.metaTitle ?? createMetaTitle(t("board.content.metaTitle", { boardName: board.name })), title: board.metaTitle ?? createMetaTitle(t("board.content.metaTitle", { boardName: board.name })),
icons: { icons: {
icon: board.faviconImageUrl ? board.faviconImageUrl : undefined, icon: board.faviconImageUrl ? board.faviconImageUrl : undefined,
apple: board.faviconImageUrl ? board.faviconImageUrl : undefined,
},
appleWebApp: {
startupImage: { url: board.faviconImageUrl ? board.faviconImageUrl : "/logo/logo.png" },
}, },
}; };
} catch (error) { } catch (error) {

View File

@@ -8,5 +8,4 @@ export default createBoardLayout<{ locale: string; name: string }>({
async getInitialBoardAsync({ name }) { async getInitialBoardAsync({ name }) {
return await api.board.getBoardByName({ name }); return await api.board.getBoardByName({ name });
}, },
isBoardContentPage: false,
}); });

View File

@@ -4,7 +4,6 @@ import { AppShellMain } from "@mantine/core";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { logger } from "@homarr/log"; import { logger } from "@homarr/log";
import { GlobalItemServerDataRunner } from "@homarr/widgets";
import { MainHeader } from "~/components/layout/header"; import { MainHeader } from "~/components/layout/header";
import { BoardLogoWithTitle } from "~/components/layout/logo/board-logo"; import { BoardLogoWithTitle } from "~/components/layout/logo/board-logo";
@@ -18,13 +17,11 @@ import { BoardMantineProvider } from "./(content)/_theme";
interface CreateBoardLayoutProps<TParams extends Params> { interface CreateBoardLayoutProps<TParams extends Params> {
headerActions: JSX.Element; headerActions: JSX.Element;
getInitialBoardAsync: (params: TParams) => Promise<Board>; getInitialBoardAsync: (params: TParams) => Promise<Board>;
isBoardContentPage: boolean;
} }
export const createBoardLayout = <TParams extends Params>({ export const createBoardLayout = <TParams extends Params>({
headerActions, headerActions,
getInitialBoardAsync: getInitialBoard, getInitialBoardAsync: getInitialBoard,
isBoardContentPage,
}: CreateBoardLayoutProps<TParams>) => { }: CreateBoardLayoutProps<TParams>) => {
const Layout = async ({ const Layout = async ({
params, params,
@@ -42,21 +39,19 @@ export const createBoardLayout = <TParams extends Params>({
}); });
return ( return (
<GlobalItemServerDataRunner board={initialBoard} shouldRun={isBoardContentPage}> <BoardProvider initialBoard={initialBoard}>
<BoardProvider initialBoard={initialBoard}> <BoardMantineProvider>
<BoardMantineProvider> <CustomCss />
<CustomCss /> <ClientShell hasNavigation={false}>
<ClientShell hasNavigation={false}> <MainHeader
<MainHeader logo={<BoardLogoWithTitle size="md" hideTitleOnMobile />}
logo={<BoardLogoWithTitle size="md" hideTitleOnMobile />} actions={headerActions}
actions={headerActions} hasNavigation={false}
hasNavigation={false} />
/> <AppShellMain>{children}</AppShellMain>
<AppShellMain>{children}</AppShellMain> </ClientShell>
</ClientShell> </BoardMantineProvider>
</BoardMantineProvider> </BoardProvider>
</BoardProvider>
</GlobalItemServerDataRunner>
); );
}; };

View File

@@ -28,8 +28,7 @@ const fontSans = Inter({
variable: "--font-sans", variable: "--font-sans",
}); });
export const metadata: Metadata = { export const generateMetadata = (): Metadata => ({
metadataBase: new URL("http://localhost:3000"),
title: "Homarr", title: "Homarr",
description: description:
"Simplify the management of your server with Homarr - a sleek, modern dashboard that puts all of your apps and services at your fingertips.", "Simplify the management of your server with Homarr - a sleek, modern dashboard that puts all of your apps and services at your fingertips.",
@@ -40,12 +39,17 @@ export const metadata: Metadata = {
url: "https://homarr.dev", url: "https://homarr.dev",
siteName: "Homarr Documentation", siteName: "Homarr Documentation",
}, },
twitter: { icons: {
card: "summary_large_image", icon: "/logo/logo.png",
site: "@jullerino", apple: "/logo/logo.png",
creator: "@jullerino",
}, },
}; appleWebApp: {
title: "Homarr",
capable: true,
startupImage: { url: "/logo/logo.png" },
statusBarStyle: getColorScheme() === "dark" ? "black-translucent" : "default",
},
});
export const viewport: Viewport = { export const viewport: Viewport = {
themeColor: [ themeColor: [
@@ -56,7 +60,7 @@ export const viewport: Viewport = {
export default async function Layout(props: { children: React.ReactNode; params: { locale: string } }) { export default async function Layout(props: { children: React.ReactNode; params: { locale: string } }) {
const session = await auth(); const session = await auth();
const colorScheme = cookies().get("homarr-color-scheme")?.value ?? "light"; const colorScheme = getColorScheme();
const tCommon = await getScopedI18n("common"); const tCommon = await getScopedI18n("common");
const direction = tCommon("direction"); const direction = tCommon("direction");
@@ -73,7 +77,15 @@ export default async function Layout(props: { children: React.ReactNode; params:
return ( return (
// Instead of ColorSchemScript we use data-mantine-color-scheme to prevent flickering // Instead of ColorSchemScript we use data-mantine-color-scheme to prevent flickering
<html lang="en" dir={direction} data-mantine-color-scheme={colorScheme} suppressHydrationWarning> <html
lang="en"
dir={direction}
data-mantine-color-scheme={colorScheme}
style={{
backgroundColor: colorScheme === "dark" ? "#242424" : "#fff",
}}
suppressHydrationWarning
>
<head> <head>
<Analytics /> <Analytics />
<SearchEngineOptimization /> <SearchEngineOptimization />
@@ -87,3 +99,7 @@ export default async function Layout(props: { children: React.ReactNode; params:
</html> </html>
); );
} }
const getColorScheme = () => {
return cookies().get("homarr-color-scheme")?.value ?? "dark";
};

View File

@@ -9,7 +9,7 @@ import { IconInfoCircle } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client"; import { revalidatePathActionAsync } from "@homarr/common/client";
import type { IntegrationKind, IntegrationSecretKind } from "@homarr/definitions"; import type { IntegrationKind, IntegrationSecretKind } from "@homarr/definitions";
import { getAllSecretKindOptions } from "@homarr/definitions"; import { getAllSecretKindOptions, getIntegrationName } from "@homarr/definitions";
import type { UseFormReturnType } from "@homarr/form"; import type { UseFormReturnType } from "@homarr/form";
import { useZodForm } from "@homarr/form"; import { useZodForm } from "@homarr/form";
import { convertIntegrationTestConnectionError } from "@homarr/integrations/client"; import { convertIntegrationTestConnectionError } from "@homarr/integrations/client";
@@ -32,7 +32,7 @@ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) =>
const router = useRouter(); const router = useRouter();
const form = useZodForm(validation.integration.create.omit({ kind: true }), { const form = useZodForm(validation.integration.create.omit({ kind: true }), {
initialValues: { initialValues: {
name: searchParams.name ?? "", name: searchParams.name ?? getIntegrationName(searchParams.kind),
url: searchParams.url ?? "", url: searchParams.url ?? "",
secrets: secretKinds[0].map((kind) => ({ secrets: secretKinds[0].map((kind) => ({
kind, kind,
@@ -81,7 +81,7 @@ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) =>
return ( return (
<form onSubmit={form.onSubmit((value) => void handleSubmitAsync(value))}> <form onSubmit={form.onSubmit((value) => void handleSubmitAsync(value))}>
<Stack> <Stack>
<TextInput withAsterisk label={t("integration.field.name.label")} {...form.getInputProps("name")} /> <TextInput withAsterisk label={t("integration.field.name.label")} autoFocus {...form.getInputProps("name")} />
<TextInput withAsterisk label={t("integration.field.url.label")} {...form.getInputProps("url")} /> <TextInput withAsterisk label={t("integration.field.url.label")} {...form.getInputProps("url")} />

View File

@@ -161,21 +161,20 @@ const IntegrationList = async ({ integrations, activeTab }: IntegrationListProps
</TableTd> </TableTd>
<TableTd> <TableTd>
<Group justify="end"> <Group justify="end">
{hasFullAccess || {(hasFullAccess || integration.permissions.hasFullAccess) && (
(integration.permissions.hasFullAccess && ( <ActionIconGroup>
<ActionIconGroup> <ActionIcon
<ActionIcon component={Link}
component={Link} href={`/manage/integrations/edit/${integration.id}`}
href={`/manage/integrations/edit/${integration.id}`} variant="subtle"
variant="subtle" color="gray"
color="gray" aria-label={t("page.edit.title", { name: getIntegrationName(integration.kind) })}
aria-label={t("page.edit.title", { name: getIntegrationName(integration.kind) })} >
> <IconPencil size={16} stroke={1.5} />
<IconPencil size={16} stroke={1.5} /> </ActionIcon>
</ActionIcon> <DeleteIntegrationActionButton integration={integration} count={integrations.length} />
<DeleteIntegrationActionButton integration={integration} count={integrations.length} /> </ActionIconGroup>
</ActionIconGroup> )}
))}
</Group> </Group>
</TableTd> </TableTd>
</TableTr> </TableTr>

View File

@@ -47,7 +47,7 @@ export const ApiKeysManagement = ({ apiKeys }: ApiKeysManagementProps) => {
), ),
}, },
], ],
[], [t],
); );
const table = useMantineReactTable({ const table = useMantineReactTable({

View File

@@ -0,0 +1,66 @@
"use client";
import { Button, Group, Stack, Switch } from "@mantine/core";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useZodForm } from "@homarr/form";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import type { z } from "@homarr/validation";
import { validation } from "@homarr/validation";
interface PingIconsEnabledProps {
user: RouterOutputs["user"]["getById"];
}
export const PingIconsEnabled = ({ user }: PingIconsEnabledProps) => {
const t = useI18n();
const { mutate, isPending } = clientApi.user.changePingIconsEnabled.useMutation({
async onSettled() {
await revalidatePathActionAsync(`/manage/users/${user.id}`);
},
onSuccess(_, variables) {
form.setInitialValues({
pingIconsEnabled: variables.pingIconsEnabled,
});
showSuccessNotification({
message: t("user.action.changePingIconsEnabled.notification.success.message"),
});
},
onError() {
showErrorNotification({
message: t("user.action.changePingIconsEnabled.notification.error.message"),
});
},
});
const form = useZodForm(validation.user.pingIconsEnabled, {
initialValues: {
pingIconsEnabled: user.pingIconsEnabled,
},
});
const handleSubmit = (values: FormType) => {
mutate({
id: user.id,
...values,
});
};
return (
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack gap="md">
<Switch {...form.getInputProps("pingIconsEnabled")} label={t("user.field.pingIconsEnabled.label")} />
<Group justify="end">
<Button type="submit" color="teal" loading={isPending}>
{t("common.action.save")}
</Button>
</Group>
</Stack>
</form>
);
};
type FormType = z.infer<typeof validation.user.pingIconsEnabled>;

View File

@@ -61,7 +61,7 @@ export const UserProfileForm = ({ user }: UserProfileFormProps) => {
id: user.id, id: user.id,
}); });
}, },
[user.id, mutate], [isProviderCredentials, mutate, user.id],
); );
return ( return (

View File

@@ -14,6 +14,7 @@ import { canAccessUserEditPage } from "../access";
import { ChangeHomeBoardForm } from "./_components/_change-home-board"; import { ChangeHomeBoardForm } from "./_components/_change-home-board";
import { DeleteUserButton } from "./_components/_delete-user-button"; import { DeleteUserButton } from "./_components/_delete-user-button";
import { FirstDayOfWeek } from "./_components/_first-day-of-week"; import { FirstDayOfWeek } from "./_components/_first-day-of-week";
import { PingIconsEnabled } from "./_components/_ping-icons-enabled";
import { UserProfileAvatarForm } from "./_components/_profile-avatar-form"; import { UserProfileAvatarForm } from "./_components/_profile-avatar-form";
import { UserProfileForm } from "./_components/_profile-form"; import { UserProfileForm } from "./_components/_profile-form";
@@ -99,6 +100,11 @@ export default async function EditUserPage({ params }: Props) {
<FirstDayOfWeek user={user} /> <FirstDayOfWeek user={user} />
</Stack> </Stack>
<Stack mb="lg">
<Title order={2}>{tGeneral("item.accessibility")}</Title>
<PingIconsEnabled user={user} />
</Stack>
{isCredentialsUser && ( {isCredentialsUser && (
<DangerZoneRoot> <DangerZoneRoot>
<DangerZoneItem <DangerZoneItem

View File

@@ -0,0 +1,39 @@
import type { MetadataRoute } from "next";
export default function manifest(): MetadataRoute.Manifest {
return {
name: "Homarr",
short_name: "Homarr",
description: "Your dashboard for managing your server.",
start_url: "/",
display: "standalone",
background_color: "#fff",
theme_color: "#fff",
icons: [
{
src: "/images/pwa/192.maskable.png",
sizes: "192x192",
type: "image/png",
purpose: "any",
},
{
src: "/images/pwa/192.maskable.png",
sizes: "192x192",
type: "image/png",
purpose: "maskable",
},
{
src: "/images/pwa/512.maskable.png",
sizes: "512x512",
type: "image/png",
purpose: "any",
},
{
src: "/images/pwa/512.maskable.png",
sizes: "512x512",
type: "image/png",
purpose: "maskable",
},
],
};
}

View File

@@ -4,7 +4,7 @@ import { QueryErrorResetBoundary } from "@tanstack/react-query";
import combineClasses from "clsx"; import combineClasses from "clsx";
import { ErrorBoundary } from "react-error-boundary"; import { ErrorBoundary } from "react-error-boundary";
import { loadWidgetDynamic, reduceWidgetOptionsWithDefaultValues, useServerDataFor } from "@homarr/widgets"; import { loadWidgetDynamic, reduceWidgetOptionsWithDefaultValues } from "@homarr/widgets";
import { WidgetError } from "@homarr/widgets/errors"; import { WidgetError } from "@homarr/widgets/errors";
import type { Item } from "~/app/[locale]/boards/_types"; import type { Item } from "~/app/[locale]/boards/_types";
@@ -53,7 +53,6 @@ interface InnerContentProps {
const InnerContent = ({ item, ...dimensions }: InnerContentProps) => { const InnerContent = ({ item, ...dimensions }: InnerContentProps) => {
const board = useRequiredBoard(); const board = useRequiredBoard();
const [isEditMode] = useEditMode(); const [isEditMode] = useEditMode();
const serverData = useServerDataFor(item.id);
const Comp = loadWidgetDynamic(item.kind); const Comp = loadWidgetDynamic(item.kind);
const options = reduceWidgetOptionsWithDefaultValues(item.kind, item.options); const options = reduceWidgetOptionsWithDefaultValues(item.kind, item.options);
const newItem = { ...item, options }; const newItem = { ...item, options };
@@ -61,8 +60,6 @@ const InnerContent = ({ item, ...dimensions }: InnerContentProps) => {
const updateOptions = ({ newOptions }: { newOptions: Record<string, unknown> }) => const updateOptions = ({ newOptions }: { newOptions: Record<string, unknown> }) =>
updateItemOptions({ itemId: item.id, newOptions }); updateItemOptions({ itemId: item.id, newOptions });
if (!serverData?.isReady) return null;
return ( return (
<QueryErrorResetBoundary> <QueryErrorResetBoundary>
{({ reset }) => ( {({ reset }) => (
@@ -79,8 +76,6 @@ const InnerContent = ({ item, ...dimensions }: InnerContentProps) => {
<Comp <Comp
options={options as never} options={options as never}
integrationIds={item.integrationIds} integrationIds={item.integrationIds}
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
serverData={serverData?.data as never}
isEditMode={isEditMode} isEditMode={isEditMode}
boardId={board.id} boardId={board.id}
itemId={item.id} itemId={item.id}

View File

@@ -8,7 +8,6 @@ import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { z } from "@homarr/validation"; import { z } from "@homarr/validation";
import type { Item } from "~/app/[locale]/boards/_types"; import type { Item } from "~/app/[locale]/boards/_types";
import { useItemActions } from "./item-actions";
interface InnerProps { interface InnerProps {
gridStack: GridStack; gridStack: GridStack;
@@ -21,7 +20,6 @@ export const ItemMoveModal = createModal<InnerProps>(({ actions, innerProps }) =
const t = useI18n(); const t = useI18n();
// Keep track of the maximum width based on the x offset // Keep track of the maximum width based on the x offset
const maxWidthRef = useRef(innerProps.columnCount - innerProps.item.xOffset); const maxWidthRef = useRef(innerProps.columnCount - innerProps.item.xOffset);
const { moveAndResizeItem } = useItemActions();
const form = useZodForm( const form = useZodForm(
z.object({ z.object({
xOffset: z xOffset: z
@@ -62,7 +60,7 @@ export const ItemMoveModal = createModal<InnerProps>(({ actions, innerProps }) =
}); });
actions.closeModal(); actions.closeModal();
}, },
[moveAndResizeItem], [actions, innerProps.gridStack, innerProps.item.id],
); );
return ( return (

View File

@@ -39,7 +39,7 @@ export const GridStackItem = ({
if (type !== "section") return; if (type !== "section") return;
innerRef.current.gridstackNode.minW = minWidth; innerRef.current.gridstackNode.minW = minWidth;
innerRef.current.gridstackNode.minH = minHeight; innerRef.current.gridstackNode.minH = minHeight;
}, [minWidth, minHeight, innerRef]); }, [minWidth, minHeight, innerRef, type]);
return ( return (
<Box <Box

View File

@@ -215,6 +215,7 @@ export const useGridstack = (section: Omit<Section, "items">, itemIds: string[])
} }
// Only run this effect when the section items change // Only run this effect when the section items change
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [itemIds.length, columnCount]); }, [itemIds.length, columnCount]);
/** /**

View File

@@ -58,7 +58,7 @@ export const UserAvatarMenu = ({ children }: UserAvatarMenuProps) => {
router.refresh(); router.refresh();
}, },
}); });
}, [openModal, router]); }, [logoutUrl, openModal, router]);
return ( return (
<Menu width={300} withArrow withinPortal> <Menu width={300} withArrow withinPortal>

View File

@@ -38,13 +38,13 @@
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
"superjson": "2.2.1", "superjson": "2.2.1",
"undici": "6.20.0" "undici": "6.20.1"
}, },
"devDependencies": { "devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"@types/node": "^20.16.11", "@types/node": "^20.16.12",
"dotenv-cli": "^7.4.2", "dotenv-cli": "^7.4.2",
"eslint": "^9.12.0", "eslint": "^9.12.0",
"prettier": "^3.3.3", "prettier": "^3.3.3",

View File

@@ -29,8 +29,8 @@
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@turbo/gen": "^2.1.3", "@turbo/gen": "^2.1.3",
"@vitejs/plugin-react": "^4.3.2", "@vitejs/plugin-react": "^4.3.2",
"@vitest/coverage-v8": "^2.1.2", "@vitest/coverage-v8": "^2.1.3",
"@vitest/ui": "^2.1.2", "@vitest/ui": "^2.1.3",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"jsdom": "^25.0.1", "jsdom": "^25.0.1",
"prettier": "^3.3.3", "prettier": "^3.3.3",
@@ -38,9 +38,9 @@
"turbo": "^2.1.3", "turbo": "^2.1.3",
"typescript": "^5.6.3", "typescript": "^5.6.3",
"vite-tsconfig-paths": "^5.0.1", "vite-tsconfig-paths": "^5.0.1",
"vitest": "^2.1.2" "vitest": "^2.1.3"
}, },
"packageManager": "pnpm@9.12.1", "packageManager": "pnpm@9.12.2",
"engines": { "engines": {
"node": ">=20.18.0" "node": ">=20.18.0"
}, },

View File

@@ -248,18 +248,11 @@ describe("editProfile shoud update user", () => {
const user = await db.select().from(schema.users).where(eq(schema.users.id, defaultOwnerId)); const user = await db.select().from(schema.users).where(eq(schema.users.id, defaultOwnerId));
expect(user).toHaveLength(1); expect(user).toHaveLength(1);
expect(user[0]).toStrictEqual({ expect(user[0]).containSubset({
id: defaultOwnerId, id: defaultOwnerId,
name: "ABC", name: "ABC",
email: "abc@gmail.com", email: "abc@gmail.com",
emailVerified, emailVerified,
salt: null,
password: null,
image: null,
homeBoardId: null,
provider: "credentials",
colorScheme: "auto",
firstDayOfWeek: 1,
}); });
}); });
@@ -289,18 +282,11 @@ describe("editProfile shoud update user", () => {
const user = await db.select().from(schema.users).where(eq(schema.users.id, defaultOwnerId)); const user = await db.select().from(schema.users).where(eq(schema.users.id, defaultOwnerId));
expect(user).toHaveLength(1); expect(user).toHaveLength(1);
expect(user[0]).toStrictEqual({ expect(user[0]).containSubset({
id: defaultOwnerId, id: defaultOwnerId,
name: "ABC", name: "ABC",
email: "myNewEmail@gmail.com", email: "myNewEmail@gmail.com",
emailVerified: null, emailVerified: null,
salt: null,
password: null,
image: null,
homeBoardId: null,
provider: "credentials",
colorScheme: "auto",
firstDayOfWeek: 1,
}); });
}); });
}); });
@@ -317,40 +303,14 @@ describe("delete should delete user", () => {
{ {
id: createId(), id: createId(),
name: "User 1", name: "User 1",
email: null,
emailVerified: null,
image: null,
password: null,
salt: null,
homeBoardId: null,
provider: "ldap" as const,
colorScheme: "auto" as const,
firstDayOfWeek: 1 as const,
}, },
{ {
id: defaultOwnerId, id: defaultOwnerId,
name: "User 2", name: "User 2",
email: null,
emailVerified: null,
image: null,
password: null,
salt: null,
homeBoardId: null,
colorScheme: "auto" as const,
firstDayOfWeek: 1 as const,
}, },
{ {
id: createId(), id: createId(),
name: "User 3", name: "User 3",
email: null,
emailVerified: null,
image: null,
password: null,
salt: null,
homeBoardId: null,
provider: "oidc" as const,
colorScheme: "auto" as const,
firstDayOfWeek: 1 as const,
}, },
]; ];
@@ -359,6 +319,8 @@ describe("delete should delete user", () => {
await caller.delete(defaultOwnerId); await caller.delete(defaultOwnerId);
const usersInDb = await db.select().from(schema.users); const usersInDb = await db.select().from(schema.users);
expect(usersInDb).toStrictEqual([initialUsers[0], initialUsers[2]]); expect(usersInDb).toHaveLength(2);
expect(usersInDb[0]).containSubset(initialUsers[0]);
expect(usersInDb[1]).containSubset(initialUsers[2]);
}); });
}); });

View File

@@ -209,6 +209,7 @@ export const userRouter = createTRPCRouter({
provider: true, provider: true,
homeBoardId: true, homeBoardId: true,
firstDayOfWeek: true, firstDayOfWeek: true,
pingIconsEnabled: true,
}, },
where: eq(users.id, input.userId), where: eq(users.id, input.userId),
}); });
@@ -376,6 +377,39 @@ export const userRouter = createTRPCRouter({
}) })
.where(eq(users.id, ctx.session.user.id)); .where(eq(users.id, ctx.session.user.id));
}), }),
getPingIconsEnabledOrDefault: publicProcedure.query(async ({ ctx }) => {
if (!ctx.session?.user) {
return false;
}
const user = await ctx.db.query.users.findFirst({
columns: {
id: true,
pingIconsEnabled: true,
},
where: eq(users.id, ctx.session.user.id),
});
return user?.pingIconsEnabled ?? false;
}),
changePingIconsEnabled: protectedProcedure
.input(validation.user.pingIconsEnabled.and(validation.common.byId))
.mutation(async ({ input, ctx }) => {
// Only admins can change other users ping icons enabled
if (!ctx.session.user.permissions.includes("admin") && ctx.session.user.id !== input.id) {
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found",
});
}
await ctx.db
.update(users)
.set({
pingIconsEnabled: input.pingIconsEnabled,
})
.where(eq(users.id, ctx.session.user.id));
}),
getFirstDayOfWeekForUserOrDefault: publicProcedure.query(async ({ ctx }) => { getFirstDayOfWeekForUserOrDefault: publicProcedure.query(async ({ ctx }) => {
if (!ctx.session?.user) { if (!ctx.session?.user) {
return 1 as const; return 1 as const;
@@ -394,7 +428,7 @@ export const userRouter = createTRPCRouter({
changeFirstDayOfWeek: protectedProcedure changeFirstDayOfWeek: protectedProcedure
.input(validation.user.firstDayOfWeek.and(validation.common.byId)) .input(validation.user.firstDayOfWeek.and(validation.common.byId))
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
// Only admins can change other users' passwords // Only admins can change other users first day of week
if (!ctx.session.user.permissions.includes("admin") && ctx.session.user.id !== input.id) { if (!ctx.session.user.permissions.includes("admin") && ctx.session.user.id !== input.id) {
throw new TRPCError({ throw new TRPCError({
code: "NOT_FOUND", code: "NOT_FOUND",

View File

@@ -9,11 +9,12 @@ export const calendarRouter = createTRPCRouter({
findAllEvents: publicProcedure findAllEvents: publicProcedure
.unstable_concat(createManyIntegrationOfOneItemMiddleware("query", ...getIntegrationKindsByCategory("calendar"))) .unstable_concat(createManyIntegrationOfOneItemMiddleware("query", ...getIntegrationKindsByCategory("calendar")))
.query(async ({ ctx }) => { .query(async ({ ctx }) => {
return await Promise.all( const result = await Promise.all(
ctx.integrations.flatMap(async (integration) => { ctx.integrations.flatMap(async (integration) => {
const cache = createItemAndIntegrationChannel<CalendarEvent[]>("calendar", integration.id); const cache = createItemAndIntegrationChannel<CalendarEvent[]>("calendar", integration.id);
return await cache.getAsync(); return await cache.getAsync();
}), }),
); );
return result.filter((item) => item !== null).flatMap((item) => item.data);
}), }),
}); });

View File

@@ -13,15 +13,13 @@ export const healthMonitoringRouter = createTRPCRouter({
return await Promise.all( return await Promise.all(
ctx.integrations.map(async (integration) => { ctx.integrations.map(async (integration) => {
const channel = createItemAndIntegrationChannel<HealthMonitoring>("healthMonitoring", integration.id); const channel = createItemAndIntegrationChannel<HealthMonitoring>("healthMonitoring", integration.id);
const data = await channel.getAsync(); const { data: healthInfo, timestamp } = (await channel.getAsync()) ?? { data: null, timestamp: new Date(0) };
if (!data) {
return null;
}
return { return {
integrationId: integration.id, integrationId: integration.id,
integrationName: integration.name, integrationName: integration.name,
healthInfo: data.data, healthInfo,
timestamp,
}; };
}), }),
); );
@@ -30,7 +28,7 @@ export const healthMonitoringRouter = createTRPCRouter({
subscribeHealthStatus: publicProcedure subscribeHealthStatus: publicProcedure
.unstable_concat(createManyIntegrationMiddleware("query", "openmediavault")) .unstable_concat(createManyIntegrationMiddleware("query", "openmediavault"))
.subscription(({ ctx }) => { .subscription(({ ctx }) => {
return observable<{ integrationId: string; healthInfo: HealthMonitoring }>((emit) => { return observable<{ integrationId: string; healthInfo: HealthMonitoring; timestamp: Date }>((emit) => {
const unsubscribes: (() => void)[] = []; const unsubscribes: (() => void)[] = [];
for (const integration of ctx.integrations) { for (const integration of ctx.integrations) {
const channel = createItemAndIntegrationChannel<HealthMonitoring>("healthMonitoring", integration.id); const channel = createItemAndIntegrationChannel<HealthMonitoring>("healthMonitoring", integration.id);
@@ -38,6 +36,7 @@ export const healthMonitoringRouter = createTRPCRouter({
emit.next({ emit.next({
integrationId: integration.id, integrationId: integration.id,
healthInfo, healthInfo,
timestamp: new Date(0),
}); });
}); });
unsubscribes.push(unsubscribe); unsubscribes.push(unsubscribe);

View File

@@ -36,7 +36,7 @@ export const createSessionAsync = async (
...user, ...user,
email: user.email ?? "", email: user.email ?? "",
permissions: await getCurrentUserPermissionsAsync(db, user.id), permissions: await getCurrentUserPermissionsAsync(db, user.id),
colorScheme: "auto", colorScheme: "dark",
}, },
} as Session; } as Session;
}; };

View File

@@ -34,6 +34,17 @@ export const createConfiguration = (provider: SupportedAuthProvider | "unknown",
}, },
}, },
trustHost: true, trustHost: true,
cookies: {
sessionToken: {
name: sessionTokenCookieName,
options: {
httpOnly: true,
sameSite: "lax",
path: "/",
secure: true,
},
},
},
adapter, adapter,
providers: filterProviders([ providers: filterProviders([
Credentials(createCredentialsConfiguration(db)), Credentials(createCredentialsConfiguration(db)),

View File

@@ -23,8 +23,8 @@
}, },
"prettier": "@homarr/prettier-config", "prettier": "@homarr/prettier-config",
"dependencies": { "dependencies": {
"@auth/core": "^0.37.0", "@auth/core": "^0.37.1",
"@auth/drizzle-adapter": "^1.7.0", "@auth/drizzle-adapter": "^1.7.1",
"@homarr/common": "workspace:^0.1.0", "@homarr/common": "workspace:^0.1.0",
"@homarr/db": "workspace:^0.1.0", "@homarr/db": "workspace:^0.1.0",
"@homarr/definitions": "workspace:^0.1.0", "@homarr/definitions": "workspace:^0.1.0",
@@ -35,7 +35,7 @@
"cookies": "^0.9.1", "cookies": "^0.9.1",
"ldapts": "7.2.1", "ldapts": "7.2.1",
"next": "^14.2.15", "next": "^14.2.15",
"next-auth": "5.0.0-beta.22", "next-auth": "5.0.0-beta.23",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1" "react-dom": "^18.3.1"
}, },

View File

@@ -29,7 +29,7 @@
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"next": "^14.2.15", "next": "^14.2.15",
"react": "^18.3.1", "react": "^18.3.1",
"tldts": "^6.1.50" "tldts": "^6.1.52"
}, },
"devDependencies": { "devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",

View File

@@ -0,0 +1 @@
ALTER TABLE `user` ADD `pingIconsEnabled` boolean DEFAULT false NOT NULL;

File diff suppressed because it is too large Load Diff

View File

@@ -78,6 +78,13 @@
"when": 1728142597094, "when": 1728142597094,
"tag": "0010_melted_pestilence", "tag": "0010_melted_pestilence",
"breakpoints": true "breakpoints": true
},
{
"idx": 11,
"version": "5",
"when": 1728490046896,
"tag": "0011_freezing_banshee",
"breakpoints": true
} }
] ]
} }

View File

@@ -0,0 +1 @@
ALTER TABLE `user` ADD `pingIconsEnabled` integer DEFAULT false NOT NULL;

File diff suppressed because it is too large Load Diff

View File

@@ -78,6 +78,13 @@
"when": 1728142590232, "when": 1728142590232,
"tag": "0010_gorgeous_stingray", "tag": "0010_gorgeous_stingray",
"breakpoints": true "breakpoints": true
},
{
"idx": 11,
"version": "6",
"when": 1728490026154,
"tag": "0011_classy_angel",
"breakpoints": true
} }
] ]
} }

View File

@@ -22,8 +22,10 @@
"lint": "eslint", "lint": "eslint",
"migration:mysql:generate": "drizzle-kit generate --config ./configs/mysql.config.ts", "migration:mysql:generate": "drizzle-kit generate --config ./configs/mysql.config.ts",
"migration:mysql:run": "drizzle-kit migrate --config ./configs/mysql.config.ts", "migration:mysql:run": "drizzle-kit migrate --config ./configs/mysql.config.ts",
"migration:mysql:drop": "drizzle-kit drop --config ./configs/mysql.config.ts",
"migration:sqlite:generate": "drizzle-kit generate --config ./configs/sqlite.config.ts", "migration:sqlite:generate": "drizzle-kit generate --config ./configs/sqlite.config.ts",
"migration:sqlite:run": "drizzle-kit migrate --config ./configs/sqlite.config.ts", "migration:sqlite:run": "drizzle-kit migrate --config ./configs/sqlite.config.ts",
"migration:sqlite:drop": "drizzle-kit drop --config ./configs/sqlite.config.ts",
"push:mysql": "drizzle-kit push --config ./configs/mysql.config.ts", "push:mysql": "drizzle-kit push --config ./configs/mysql.config.ts",
"push:sqlite": "drizzle-kit push --config ./configs/sqlite.config.ts", "push:sqlite": "drizzle-kit push --config ./configs/sqlite.config.ts",
"studio": "drizzle-kit studio --config ./configs/sqlite.config.ts", "studio": "drizzle-kit studio --config ./configs/sqlite.config.ts",
@@ -31,16 +33,16 @@
}, },
"prettier": "@homarr/prettier-config", "prettier": "@homarr/prettier-config",
"dependencies": { "dependencies": {
"@auth/core": "^0.37.0", "@auth/core": "^0.37.1",
"@homarr/common": "workspace:^0.1.0", "@homarr/common": "workspace:^0.1.0",
"@homarr/definitions": "workspace:^0.1.0", "@homarr/definitions": "workspace:^0.1.0",
"@homarr/log": "workspace:^0.1.0", "@homarr/log": "workspace:^0.1.0",
"@paralleldrive/cuid2": "^2.2.2", "@paralleldrive/cuid2": "^2.2.2",
"@testcontainers/mysql": "^10.13.2", "@testcontainers/mysql": "^10.13.2",
"better-sqlite3": "^11.3.0", "better-sqlite3": "^11.4.0",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
"drizzle-kit": "^0.25.0", "drizzle-kit": "^0.26.2",
"drizzle-orm": "^0.34.1", "drizzle-orm": "^0.35.2",
"mysql2": "3.11.3" "mysql2": "3.11.3"
}, },
"devDependencies": { "devDependencies": {

View File

@@ -43,8 +43,9 @@ export const users = mysqlTable("user", {
homeBoardId: varchar("homeBoardId", { length: 64 }).references((): AnyMySqlColumn => boards.id, { homeBoardId: varchar("homeBoardId", { length: 64 }).references((): AnyMySqlColumn => boards.id, {
onDelete: "set null", onDelete: "set null",
}), }),
colorScheme: varchar("colorScheme", { length: 5 }).$type<ColorScheme>().default("auto").notNull(), colorScheme: varchar("colorScheme", { length: 5 }).$type<ColorScheme>().default("dark").notNull(),
firstDayOfWeek: tinyint("firstDayOfWeek").$type<DayOfWeek>().default(1).notNull(), // Defaults to Monday firstDayOfWeek: tinyint("firstDayOfWeek").$type<DayOfWeek>().default(1).notNull(), // Defaults to Monday
pingIconsEnabled: boolean("pingIconsEnabled").default(false).notNull(),
}); });
export const accounts = mysqlTable( export const accounts = mysqlTable(

View File

@@ -44,8 +44,9 @@ export const users = sqliteTable("user", {
homeBoardId: text("homeBoardId").references((): AnySQLiteColumn => boards.id, { homeBoardId: text("homeBoardId").references((): AnySQLiteColumn => boards.id, {
onDelete: "set null", onDelete: "set null",
}), }),
colorScheme: text("colorScheme").$type<ColorScheme>().default("auto").notNull(), colorScheme: text("colorScheme").$type<ColorScheme>().default("dark").notNull(),
firstDayOfWeek: int("firstDayOfWeek").$type<DayOfWeek>().default(1).notNull(), // Defaults to Monday firstDayOfWeek: int("firstDayOfWeek").$type<DayOfWeek>().default(1).notNull(), // Defaults to Monday
pingIconsEnabled: int("pingIconsEnabled", { mode: "boolean" }).default(false).notNull(),
}); });
export const accounts = sqliteTable( export const accounts = sqliteTable(

View File

@@ -1,2 +1,2 @@
export const colorSchemes = ["light", "dark", "auto"] as const; export const colorSchemes = ["light", "dark"] as const;
export type ColorScheme = (typeof colorSchemes)[number]; export type ColorScheme = (typeof colorSchemes)[number];

View File

@@ -24,7 +24,7 @@
"dependencies": { "dependencies": {
"@homarr/translation": "workspace:^0.1.0", "@homarr/translation": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0", "@homarr/validation": "workspace:^0.1.0",
"@mantine/form": "^7.13.2" "@mantine/form": "^7.13.3"
}, },
"devDependencies": { "devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",

View File

@@ -1,7 +1,11 @@
export const radarrReleaseTypes = ["inCinemas", "digitalRelease", "physicalRelease"] as const;
type ReleaseType = (typeof radarrReleaseTypes)[number];
export interface CalendarEvent { export interface CalendarEvent {
name: string; name: string;
subName: string; subName: string;
date: Date; date: Date;
dates?: { type: ReleaseType; date: Date }[];
description?: string; description?: string;
thumbnail?: string; thumbnail?: string;
mediaInformation?: { mediaInformation?: {

View File

@@ -1,8 +1,10 @@
import type { AtLeastOneOf } from "@homarr/common/types";
import { logger } from "@homarr/log"; import { logger } from "@homarr/log";
import { z } from "@homarr/validation"; import { z } from "@homarr/validation";
import { Integration } from "../../base/integration"; import { Integration } from "../../base/integration";
import type { CalendarEvent } from "../../calendar-types"; import type { CalendarEvent } from "../../calendar-types";
import { radarrReleaseTypes } from "../../calendar-types";
export class RadarrIntegration extends Integration { export class RadarrIntegration extends Integration {
/** /**
@@ -37,19 +39,23 @@ export class RadarrIntegration extends Integration {
}); });
const radarrCalendarEvents = await z.array(radarrCalendarEventSchema).parseAsync(await response.json()); const radarrCalendarEvents = await z.array(radarrCalendarEventSchema).parseAsync(await response.json());
return radarrCalendarEvents.map( return radarrCalendarEvents.map((radarrCalendarEvent): CalendarEvent => {
(radarrCalendarEvent): CalendarEvent => ({ const dates = radarrReleaseTypes
.map((type) => (radarrCalendarEvent[type] ? { type, date: radarrCalendarEvent[type] } : undefined))
.filter((date) => date) as AtLeastOneOf<Exclude<CalendarEvent["dates"], undefined>[number]>;
return {
name: radarrCalendarEvent.title, name: radarrCalendarEvent.title,
subName: radarrCalendarEvent.originalTitle, subName: radarrCalendarEvent.originalTitle,
description: radarrCalendarEvent.overview, description: radarrCalendarEvent.overview,
thumbnail: this.chooseBestImageAsURL(radarrCalendarEvent), thumbnail: this.chooseBestImageAsURL(radarrCalendarEvent),
date: radarrCalendarEvent.inCinemas, date: dates[0].date,
dates,
mediaInformation: { mediaInformation: {
type: "movie", type: "movie",
}, },
links: this.getLinksForRadarrCalendarEvent(radarrCalendarEvent), links: this.getLinksForRadarrCalendarEvent(radarrCalendarEvent),
}), };
); });
} }
private getLinksForRadarrCalendarEvent = (event: z.infer<typeof radarrCalendarEventSchema>) => { private getLinksForRadarrCalendarEvent = (event: z.infer<typeof radarrCalendarEventSchema>) => {
@@ -118,7 +124,18 @@ const radarrCalendarEventImageSchema = z.array(
const radarrCalendarEventSchema = z.object({ const radarrCalendarEventSchema = z.object({
title: z.string(), title: z.string(),
originalTitle: z.string(), originalTitle: z.string(),
inCinemas: z.string().transform((value) => new Date(value)), inCinemas: z
.string()
.transform((value) => new Date(value))
.optional(),
physicalRelease: z
.string()
.transform((value) => new Date(value))
.optional(),
digitalRelease: z
.string()
.transform((value) => new Date(value))
.optional(),
overview: z.string().optional(), overview: z.string().optional(),
titleSlug: z.string(), titleSlug: z.string(),
images: radarrCalendarEventImageSchema, images: radarrCalendarEventImageSchema,

View File

@@ -30,7 +30,7 @@
"@homarr/translation": "workspace:^0.1.0", "@homarr/translation": "workspace:^0.1.0",
"@homarr/ui": "workspace:^0.1.0", "@homarr/ui": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0", "@homarr/validation": "workspace:^0.1.0",
"@mantine/core": "^7.13.2", "@mantine/core": "^7.13.3",
"@tabler/icons-react": "^3.19.0", "@tabler/icons-react": "^3.19.0",
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"next": "^14.2.15", "next": "^14.2.15",

View File

@@ -24,8 +24,8 @@
"dependencies": { "dependencies": {
"@homarr/translation": "workspace:^0.1.0", "@homarr/translation": "workspace:^0.1.0",
"@homarr/ui": "workspace:^0.1.0", "@homarr/ui": "workspace:^0.1.0",
"@mantine/core": "^7.13.2", "@mantine/core": "^7.13.3",
"@mantine/hooks": "^7.13.2", "@mantine/hooks": "^7.13.3",
"react": "^18.3.1" "react": "^18.3.1"
}, },
"devDependencies": { "devDependencies": {

View File

@@ -53,7 +53,7 @@ export const ConfirmModal = createModal<Omit<ConfirmModalProps, "title">>(({ act
actions.closeModal(); actions.closeModal();
} }
}, },
[cancelProps?.onClick, onCancel, actions.closeModal], [cancelProps, onCancel, closeOnCancel, actions],
); );
const handleConfirm = useCallback( const handleConfirm = useCallback(
@@ -73,7 +73,7 @@ export const ConfirmModal = createModal<Omit<ConfirmModalProps, "title">>(({ act
} }
setLoading(false); setLoading(false);
}, },
[confirmProps?.onClick, onConfirm, actions.closeModal], [confirmProps, onConfirm, closeOnConfirm, actions],
); );
return ( return (

View File

@@ -38,7 +38,7 @@ export const ModalProvider = ({ children }: PropsWithChildren) => {
(id: string, canceled?: boolean) => { (id: string, canceled?: boolean) => {
dispatch({ type: "CLOSE", modalId: id, canceled }); dispatch({ type: "CLOSE", modalId: id, canceled });
}, },
[stateRef, dispatch], [dispatch],
); );
const openModalInner: ModalContextProps["openModalInner"] = useCallback( const openModalInner: ModalContextProps["openModalInner"] = useCallback(
@@ -63,10 +63,7 @@ export const ModalProvider = ({ children }: PropsWithChildren) => {
[dispatch], [dispatch],
); );
const handleCloseModal = useCallback( const handleCloseModal = useCallback(() => state.current && closeModal(state.current.id), [closeModal, state]);
() => state.current && closeModal(state.current.id),
[closeModal, state.current?.id],
);
const activeModals = state.modals.filter((modal) => modal.id === state.current?.id || modal.props.keepMounted); const activeModals = state.modals.filter((modal) => modal.id === state.current?.id || modal.props.keepMounted);

View File

@@ -24,7 +24,7 @@
"prettier": "@homarr/prettier-config", "prettier": "@homarr/prettier-config",
"dependencies": { "dependencies": {
"@homarr/ui": "workspace:^0.1.0", "@homarr/ui": "workspace:^0.1.0",
"@mantine/notifications": "^7.13.2", "@mantine/notifications": "^7.13.3",
"@tabler/icons-react": "^3.19.0" "@tabler/icons-react": "^3.19.0"
}, },
"devDependencies": { "devDependencies": {

View File

@@ -23,6 +23,7 @@ const optionMapping: OptionMapping = {
}, },
"mediaRequests-requestStats": {}, "mediaRequests-requestStats": {},
calendar: { calendar: {
releaseType: (oldOptions) => [oldOptions.radarrReleaseType],
filterFutureMonths: () => undefined, filterFutureMonths: () => undefined,
filterPastMonths: () => undefined, filterPastMonths: () => undefined,
}, },

View File

@@ -30,11 +30,11 @@
"@homarr/modals-collection": "workspace:^0.1.0", "@homarr/modals-collection": "workspace:^0.1.0",
"@homarr/translation": "workspace:^0.1.0", "@homarr/translation": "workspace:^0.1.0",
"@homarr/ui": "workspace:^0.1.0", "@homarr/ui": "workspace:^0.1.0",
"@mantine/core": "^7.13.2", "@mantine/core": "^7.13.3",
"@mantine/hooks": "^7.13.2", "@mantine/hooks": "^7.13.3",
"@mantine/spotlight": "^7.13.2", "@mantine/spotlight": "^7.13.3",
"@tabler/icons-react": "^3.19.0", "@tabler/icons-react": "^3.19.0",
"jotai": "^2.10.0", "jotai": "^2.10.1",
"next": "^14.2.15", "next": "^14.2.15",
"react": "^18.3.1", "react": "^18.3.1",
"use-deep-compare-effect": "^1.8.1" "use-deep-compare-effect": "^1.8.1"

View File

@@ -24,7 +24,7 @@ export const ChildrenActionItem = ({ childrenOptions, action, query }: ChildrenA
return ( return (
<Spotlight.Action renderRoot={renderRoot} onClick={onClick} className={classes.spotlightAction}> <Spotlight.Action renderRoot={renderRoot} onClick={onClick} className={classes.spotlightAction}>
<action.component {...childrenOptions.option} /> <action.Component {...childrenOptions.option} />
</Spotlight.Action> </Spotlight.Action>
); );
}; };

View File

@@ -48,7 +48,7 @@ export const SpotlightGroupActionItem = <TOption extends Record<string, unknown>
closeSpotlightOnTrigger={interaction.type !== "mode" && interaction.type !== "children"} closeSpotlightOnTrigger={interaction.type !== "mode" && interaction.type !== "children"}
className={classes.spotlightAction} className={classes.spotlightAction}
> >
<group.component {...option} /> <group.Component {...option} />
</Spotlight.Action> </Spotlight.Action>
); );
}; };

View File

@@ -92,7 +92,7 @@ export const Spotlight = () => {
{childrenOptions ? ( {childrenOptions ? (
<Group> <Group>
<childrenOptions.detailComponent options={childrenOptions.option as never} /> <childrenOptions.DetailComponent options={childrenOptions.option as never} />
</Group> </Group>
) : null} ) : null}

View File

@@ -3,13 +3,13 @@ import type { ReactNode } from "react";
import type { inferSearchInteractionDefinition } from "./interaction"; import type { inferSearchInteractionDefinition } from "./interaction";
export interface CreateChildrenOptionsProps<TParentOptions extends Record<string, unknown>> { export interface CreateChildrenOptionsProps<TParentOptions extends Record<string, unknown>> {
detailComponent: ({ options }: { options: TParentOptions }) => ReactNode; DetailComponent: ({ options }: { options: TParentOptions }) => ReactNode;
useActions: (options: TParentOptions, query: string) => ChildrenAction<TParentOptions>[]; useActions: (options: TParentOptions, query: string) => ChildrenAction<TParentOptions>[];
} }
export interface ChildrenAction<TParentOptions extends Record<string, unknown>> { export interface ChildrenAction<TParentOptions extends Record<string, unknown>> {
key: string; key: string;
component: (option: TParentOptions) => JSX.Element; Component: (option: TParentOptions) => JSX.Element;
useInteraction: (option: TParentOptions, query: string) => inferSearchInteractionDefinition<"link" | "javaScript">; useInteraction: (option: TParentOptions, query: string) => inferSearchInteractionDefinition<"link" | "javaScript">;
hide?: boolean | ((option: TParentOptions) => boolean); hide?: boolean | ((option: TParentOptions) => boolean);
} }

View File

@@ -8,7 +8,7 @@ type CommonSearchGroup<TOption extends Record<string, unknown>, TOptionProps ext
// key path is used to define the path to a unique key in the option object // key path is used to define the path to a unique key in the option object
keyPath: keyof TOption; keyPath: keyof TOption;
title: stringOrTranslation; title: stringOrTranslation;
component: (option: TOption) => JSX.Element; Component: (option: TOption) => JSX.Element;
useInteraction: (option: TOption, query: string) => inferSearchInteractionDefinition<SearchInteraction>; useInteraction: (option: TOption, query: string) => inferSearchInteractionDefinition<SearchInteraction>;
onKeyDown?: ( onKeyDown?: (
event: KeyboardEvent, event: KeyboardEvent,

View File

@@ -16,7 +16,7 @@ const searchInteractions = [
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
useActions: CreateChildrenOptionsProps<any>["useActions"]; useActions: CreateChildrenOptionsProps<any>["useActions"];
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
detailComponent: CreateChildrenOptionsProps<any>["detailComponent"]; DetailComponent: CreateChildrenOptionsProps<any>["DetailComponent"];
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
option: any; option: any;
}>(), }>(),

View File

@@ -16,7 +16,7 @@ const appChildrenOptions = createChildrenOptions<App>({
useActions: () => [ useActions: () => [
{ {
key: "open", key: "open",
component: () => { Component: () => {
const t = useI18n(); const t = useI18n();
return ( return (
@@ -34,7 +34,7 @@ const appChildrenOptions = createChildrenOptions<App>({
}, },
{ {
key: "edit", key: "edit",
component: () => { Component: () => {
const t = useI18n(); const t = useI18n();
return ( return (
@@ -47,7 +47,7 @@ const appChildrenOptions = createChildrenOptions<App>({
useInteraction: interaction.link(({ id }) => ({ href: `/manage/apps/edit/${id}` })), useInteraction: interaction.link(({ id }) => ({ href: `/manage/apps/edit/${id}` })),
}, },
], ],
detailComponent: ({ options }) => { DetailComponent: ({ options }) => {
const t = useI18n(); const t = useI18n();
return ( return (
@@ -75,7 +75,7 @@ const appChildrenOptions = createChildrenOptions<App>({
export const appsSearchGroup = createGroup<App>({ export const appsSearchGroup = createGroup<App>({
keyPath: "id", keyPath: "id",
title: (t) => t("search.mode.appIntegrationBoard.group.app.title"), title: (t) => t("search.mode.appIntegrationBoard.group.app.title"),
component: (app) => ( Component: (app) => (
<Group px="md" py="sm"> <Group px="md" py="sm">
<Avatar <Avatar
size="sm" size="sm"

View File

@@ -23,7 +23,7 @@ const boardChildrenOptions = createChildrenOptions<Board>({
const actions: (ChildrenAction<Board> & { hidden?: boolean })[] = [ const actions: (ChildrenAction<Board> & { hidden?: boolean })[] = [
{ {
key: "open", key: "open",
component: () => { Component: () => {
const t = useI18n(); const t = useI18n();
return ( return (
@@ -37,7 +37,7 @@ const boardChildrenOptions = createChildrenOptions<Board>({
}, },
{ {
key: "homeBoard", key: "homeBoard",
component: () => { Component: () => {
const t = useI18n(); const t = useI18n();
return ( return (
@@ -61,7 +61,7 @@ const boardChildrenOptions = createChildrenOptions<Board>({
}, },
{ {
key: "settings", key: "settings",
component: () => { Component: () => {
const t = useI18n(); const t = useI18n();
return ( return (
@@ -78,7 +78,7 @@ const boardChildrenOptions = createChildrenOptions<Board>({
return actions; return actions;
}, },
detailComponent: ({ options: board }) => { DetailComponent: ({ options: board }) => {
const t = useI18n(); const t = useI18n();
return ( return (
@@ -102,7 +102,7 @@ const boardChildrenOptions = createChildrenOptions<Board>({
export const boardsSearchGroup = createGroup<Board>({ export const boardsSearchGroup = createGroup<Board>({
keyPath: "id", keyPath: "id",
title: "Boards", title: "Boards",
component: (board) => ( Component: (board) => (
<Group px="md" py="sm"> <Group px="md" py="sm">
{board.logoImageUrl ? ( {board.logoImageUrl ? (
<img src={board.logoImageUrl} alt={board.name} width={24} height={24} /> <img src={board.logoImageUrl} alt={board.name} width={24} height={24} />

View File

@@ -10,7 +10,7 @@ import { interaction } from "../../lib/interaction";
export const integrationsSearchGroup = createGroup<{ id: string; kind: IntegrationKind; name: string }>({ export const integrationsSearchGroup = createGroup<{ id: string; kind: IntegrationKind; name: string }>({
keyPath: "id", keyPath: "id",
title: (t) => t("search.mode.appIntegrationBoard.group.integration.title"), title: (t) => t("search.mode.appIntegrationBoard.group.integration.title"),
component: (integration) => ( Component: (integration) => (
<Group px="md" py="sm"> <Group px="md" py="sm">
<IntegrationAvatar size="sm" kind={integration.kind} /> <IntegrationAvatar size="sm" kind={integration.kind} />

View File

@@ -30,7 +30,7 @@ export const languageChildrenOptions = createChildrenOptions<Record<string, unkn
) )
.map(({ localeKey, attributes }) => ({ .map(({ localeKey, attributes }) => ({
key: localeKey, key: localeKey,
component() { Component() {
return ( return (
<Group mx="md" my="sm" wrap="nowrap" justify="space-between" w="100%"> <Group mx="md" my="sm" wrap="nowrap" justify="space-between" w="100%">
<Group wrap="nowrap"> <Group wrap="nowrap">
@@ -53,7 +53,7 @@ export const languageChildrenOptions = createChildrenOptions<Record<string, unkn
}, },
})); }));
}, },
detailComponent: () => { DetailComponent: () => {
const t = useI18n(); const t = useI18n();
return ( return (

View File

@@ -20,7 +20,7 @@ export const newIntegrationChildrenOptions = createChildrenOptions<Record<string
) )
.map(([kind, integrationDef]) => ({ .map(([kind, integrationDef]) => ({
key: kind, key: kind,
component() { Component() {
return ( return (
<Group mx="md" my="sm" wrap="nowrap" w="100%"> <Group mx="md" my="sm" wrap="nowrap" w="100%">
<IntegrationAvatar kind={kind} size="sm" /> <IntegrationAvatar kind={kind} size="sm" />
@@ -31,7 +31,7 @@ export const newIntegrationChildrenOptions = createChildrenOptions<Record<string
useInteraction: interaction.link(() => ({ href: `/manage/integrations/new?kind=${kind}` })), useInteraction: interaction.link(() => ({ href: `/manage/integrations/new?kind=${kind}` })),
})); }));
}, },
detailComponent() { DetailComponent() {
const t = useI18n(); const t = useI18n();
return ( return (

View File

@@ -44,7 +44,7 @@ export const commandMode = {
keyPath: "commandKey", keyPath: "commandKey",
title: "Global commands", title: "Global commands",
useInteraction: (option, query) => option.useInteraction(option, query), useInteraction: (option, query) => option.useInteraction(option, query),
component: ({ icon: Icon, name }) => ( Component: ({ icon: Icon, name }) => (
<Group px="md" py="sm"> <Group px="md" py="sm">
<Icon stroke={1.5} /> <Icon stroke={1.5} />
<Text>{name}</Text> <Text>{name}</Text>

View File

@@ -15,7 +15,7 @@ export const searchEnginesChildrenOptions = createChildrenOptions<SearchEngine>(
useActions: () => [ useActions: () => [
{ {
key: "search", key: "search",
component: ({ name }) => { Component: ({ name }) => {
const tChildren = useScopedI18n("search.mode.external.group.searchEngine.children"); const tChildren = useScopedI18n("search.mode.external.group.searchEngine.children");
return ( return (
@@ -30,7 +30,7 @@ export const searchEnginesChildrenOptions = createChildrenOptions<SearchEngine>(
})), })),
}, },
], ],
detailComponent({ options }) { DetailComponent({ options }) {
const tChildren = useScopedI18n("search.mode.external.group.searchEngine.children"); const tChildren = useScopedI18n("search.mode.external.group.searchEngine.children");
return ( return (
<Stack mx="md" my="sm"> <Stack mx="md" my="sm">
@@ -47,7 +47,7 @@ export const searchEnginesChildrenOptions = createChildrenOptions<SearchEngine>(
export const searchEnginesSearchGroups = createGroup<SearchEngine>({ export const searchEnginesSearchGroups = createGroup<SearchEngine>({
keyPath: "short", keyPath: "short",
title: (t) => t("search.mode.external.group.searchEngine.title"), title: (t) => t("search.mode.external.group.searchEngine.title"),
component: ({ iconUrl, name, short, description }) => { Component: ({ iconUrl, name, short, description }) => {
return ( return (
<Group w="100%" wrap="nowrap" justify="space-between" align="center" px="md" py="xs"> <Group w="100%" wrap="nowrap" justify="space-between" align="center" px="md" py="xs">
<Group wrap="nowrap"> <Group wrap="nowrap">

View File

@@ -22,7 +22,7 @@ const helpMode = {
keyPath: "character", keyPath: "character",
title: (t) => t("search.mode.help.group.mode.title"), title: (t) => t("search.mode.help.group.mode.title"),
options: searchModesWithoutHelp.map(({ character, modeKey }) => ({ character, modeKey })), options: searchModesWithoutHelp.map(({ character, modeKey }) => ({ character, modeKey })),
component: ({ modeKey, character }) => { Component: ({ modeKey, character }) => {
const t = useScopedI18n(`search.mode.${modeKey}`); const t = useScopedI18n(`search.mode.${modeKey}`);
return ( return (
@@ -59,7 +59,7 @@ const helpMode = {
}, },
]; ];
}, },
component: (props) => ( Component: (props) => (
<Group px="md" py="xs" w="100%" wrap="nowrap" align="center"> <Group px="md" py="xs" w="100%" wrap="nowrap" align="center">
<props.icon /> <props.icon />
<Text>{props.label}</Text> <Text>{props.label}</Text>

View File

@@ -29,7 +29,7 @@ export const pagesSearchGroup = createGroup<{
}>({ }>({
keyPath: "path", keyPath: "path",
title: (t) => t("search.mode.page.group.page.title"), title: (t) => t("search.mode.page.group.page.title"),
component: ({ name, icon: Icon }) => ( Component: ({ name, icon: Icon }) => (
<Group px="md" py="sm"> <Group px="md" py="sm">
<Icon stroke={1.5} /> <Icon stroke={1.5} />
<Text>{name}</Text> <Text>{name}</Text>

View File

@@ -16,7 +16,7 @@ const groupChildrenOptions = createChildrenOptions<Group>({
useActions: () => [ useActions: () => [
{ {
key: "detail", key: "detail",
component: () => { Component: () => {
const t = useI18n(); const t = useI18n();
return ( return (
<Group mx="md" my="sm"> <Group mx="md" my="sm">
@@ -29,7 +29,7 @@ const groupChildrenOptions = createChildrenOptions<Group>({
}, },
{ {
key: "manageMember", key: "manageMember",
component: () => { Component: () => {
const t = useI18n(); const t = useI18n();
return ( return (
<Group mx="md" my="sm"> <Group mx="md" my="sm">
@@ -42,7 +42,7 @@ const groupChildrenOptions = createChildrenOptions<Group>({
}, },
{ {
key: "managePermission", key: "managePermission",
component: () => { Component: () => {
const t = useI18n(); const t = useI18n();
return ( return (
<Group mx="md" my="sm"> <Group mx="md" my="sm">
@@ -54,7 +54,7 @@ const groupChildrenOptions = createChildrenOptions<Group>({
useInteraction: interaction.link(({ id }) => ({ href: `/manage/users/groups/${id}/permissions` })), useInteraction: interaction.link(({ id }) => ({ href: `/manage/users/groups/${id}/permissions` })),
}, },
], ],
detailComponent: ({ options }) => { DetailComponent: ({ options }) => {
const t = useI18n(); const t = useI18n();
return ( return (
<Stack mx="md" my="sm"> <Stack mx="md" my="sm">
@@ -71,7 +71,7 @@ const groupChildrenOptions = createChildrenOptions<Group>({
export const groupsSearchGroup = createGroup<Group>({ export const groupsSearchGroup = createGroup<Group>({
keyPath: "id", keyPath: "id",
title: "Groups", title: "Groups",
component: ({ name }) => ( Component: ({ name }) => (
<Group px="md" py="sm"> <Group px="md" py="sm">
<Text>{name}</Text> <Text>{name}</Text>
</Group> </Group>

View File

@@ -17,7 +17,7 @@ const userChildrenOptions = createChildrenOptions<User>({
useActions: () => [ useActions: () => [
{ {
key: "detail", key: "detail",
component: () => { Component: () => {
const t = useI18n(); const t = useI18n();
return ( return (
@@ -30,7 +30,7 @@ const userChildrenOptions = createChildrenOptions<User>({
useInteraction: interaction.link(({ id }) => ({ href: `/manage/users/${id}/general` })), useInteraction: interaction.link(({ id }) => ({ href: `/manage/users/${id}/general` })),
}, },
], ],
detailComponent: ({ options }) => { DetailComponent: ({ options }) => {
const t = useI18n(); const t = useI18n();
return ( return (
@@ -49,7 +49,7 @@ const userChildrenOptions = createChildrenOptions<User>({
export const usersSearchGroup = createGroup<User>({ export const usersSearchGroup = createGroup<User>({
keyPath: "id", keyPath: "id",
title: (t) => t("search.mode.userGroup.group.user.title"), title: (t) => t("search.mode.userGroup.group.user.title"),
component: (user) => ( Component: (user) => (
<Group px="md" py="sm"> <Group px="md" py="sm">
<UserAvatar user={user} size="sm" /> <UserAvatar user={user} size="sm" />
<Text>{user.name}</Text> <Text>{user.name}</Text>

View File

@@ -26,7 +26,7 @@
"prettier": "@homarr/prettier-config", "prettier": "@homarr/prettier-config",
"dependencies": { "dependencies": {
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"mantine-react-table": "2.0.0-beta.6", "mantine-react-table": "2.0.0-beta.7",
"next-international": "^1.2.4" "next-international": "^1.2.4"
}, },
"devDependencies": { "devDependencies": {

View File

@@ -48,6 +48,9 @@ export default {
homeBoard: { homeBoard: {
label: "Home board", label: "Home board",
}, },
pingIconsEnabled: {
label: "Use icons for pings",
},
}, },
error: { error: {
usernameTaken: "Username already taken", usernameTaken: "Username already taken",
@@ -116,6 +119,16 @@ export default {
}, },
}, },
}, },
changePingIconsEnabled: {
notification: {
success: {
message: "Ping icons toggled successfully",
},
error: {
message: "Unable to toggle ping icons",
},
},
},
manageAvatar: { manageAvatar: {
changeImage: { changeImage: {
label: "Change image", label: "Change image",
@@ -563,6 +576,7 @@ export default {
tryAgain: "Try again", tryAgain: "Try again",
loading: "Loading", loading: "Loading",
}, },
here: "here",
iconPicker: { iconPicker: {
label: "Icon URL", label: "Icon URL",
header: "Type name or objects to filter for icons... Homarr will search through {countIcons} icons for you.", header: "Type name or objects to filter for icons... Homarr will search through {countIcons} icons for you.",
@@ -570,6 +584,9 @@ export default {
information: { information: {
min: "Min", min: "Min",
max: "Max", max: "Max",
days: "Days",
hours: "Hours",
minutes: "Minutes",
}, },
notification: { notification: {
create: { create: {
@@ -1018,6 +1035,14 @@ export default {
name: "Calendar", name: "Calendar",
description: "Display events from your integrations in a calendar view within a certain relative time period", description: "Display events from your integrations in a calendar view within a certain relative time period",
option: { option: {
releaseType: {
label: "Radarr release type",
options: {
inCinemas: "In cinemas",
digitalRelease: "Digital release",
physicalRelease: "Physical release",
},
},
filterPastMonths: { filterPastMonths: {
label: "Start from", label: "Start from",
}, },
@@ -1097,16 +1122,17 @@ export default {
}, },
popover: { popover: {
information: "Information", information: "Information",
processor: "Processor:", processor: "Processor: {cpuModelName}",
memory: "Memory:", memory: "Memory: {memory}GiB",
version: "Version:", memoryAvailable: "Available: {memoryAvailable}GiB ({percent}%)",
uptime: "Uptime: {days} days, {hours} hours", version: "Version: {version}",
uptime: "Uptime: {days} Days, {hours} Hours, {minutes} Minutes",
loadAverage: "Load average:", loadAverage: "Load average:",
minute: "1 minute:", minute: "1 minute",
minutes: "{count} minutes:", minutes: "{count} minutes",
used: "Used", used: "Used",
diskAvailable: "Available", available: "Available",
memAvailable: "Available:", lastSeen: "Last status update: {lastSeen}",
}, },
memory: {}, memory: {},
error: { error: {
@@ -1136,6 +1162,14 @@ export default {
}, },
}, },
}, },
integration: {
noData: "No integration found",
description: "Click {here} to create a new integration",
},
app: {
noData: "No app found",
description: "Click {here} to create a new app",
},
error: { error: {
action: { action: {
logs: "Check logs for more details", logs: "Check logs for more details",
@@ -1703,6 +1737,7 @@ export default {
language: "Language & Region", language: "Language & Region",
board: "Home board", board: "Home board",
firstDayOfWeek: "First day of the week", firstDayOfWeek: "First day of the week",
accessibility: "Accessibility",
}, },
}, },
security: { security: {

View File

@@ -28,11 +28,11 @@
"@homarr/log": "workspace:^0.1.0", "@homarr/log": "workspace:^0.1.0",
"@homarr/translation": "workspace:^0.1.0", "@homarr/translation": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0", "@homarr/validation": "workspace:^0.1.0",
"@mantine/core": "^7.13.2", "@mantine/core": "^7.13.3",
"@mantine/dates": "^7.13.2", "@mantine/dates": "^7.13.3",
"@mantine/hooks": "^7.13.2", "@mantine/hooks": "^7.13.3",
"@tabler/icons-react": "^3.19.0", "@tabler/icons-react": "^3.19.0",
"mantine-react-table": "2.0.0-beta.6", "mantine-react-table": "2.0.0-beta.7",
"next": "^14.2.15", "next": "^14.2.15",
"react": "^18.3.1" "react": "^18.3.1"
}, },

View File

@@ -34,7 +34,7 @@ export const TablePagination = ({ total }: TablePaginationProps) => {
(control: ControlType) => { (control: ControlType) => {
return getItemProps(calculatePageFor(control, current, total)); return getItemProps(calculatePageFor(control, current, total));
}, },
[current], [current, getItemProps, total],
); );
const handleChange = useCallback( const handleChange = useCallback(
@@ -43,7 +43,7 @@ export const TablePagination = ({ total }: TablePaginationProps) => {
params.set("page", page.toString()); params.set("page", page.toString());
replace(`${pathName}?${params.toString()}`); replace(`${pathName}?${params.toString()}`);
}, },
[pathName, searchParams], [pathName, replace, searchParams],
); );
return ( return (

View File

@@ -108,6 +108,10 @@ const firstDayOfWeekSchema = z.object({
firstDayOfWeek: z.custom<DayOfWeek>((value) => z.number().min(0).max(6).safeParse(value).success), firstDayOfWeek: z.custom<DayOfWeek>((value) => z.number().min(0).max(6).safeParse(value).success),
}); });
const pingIconsEnabledSchema = z.object({
pingIconsEnabled: z.boolean(),
});
export const userSchemas = { export const userSchemas = {
signIn: signInSchema, signIn: signInSchema,
registration: registrationSchema, registration: registrationSchema,
@@ -121,4 +125,5 @@ export const userSchemas = {
changePasswordApi: changePasswordApiSchema, changePasswordApi: changePasswordApiSchema,
changeColorScheme: changeColorSchemeSchema, changeColorScheme: changeColorSchemeSchema,
firstDayOfWeek: firstDayOfWeekSchema, firstDayOfWeek: firstDayOfWeekSchema,
pingIconsEnabled: pingIconsEnabledSchema,
}; };

View File

@@ -38,8 +38,8 @@
"@homarr/translation": "workspace:^0.1.0", "@homarr/translation": "workspace:^0.1.0",
"@homarr/ui": "workspace:^0.1.0", "@homarr/ui": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0", "@homarr/validation": "workspace:^0.1.0",
"@mantine/core": "^7.13.2", "@mantine/core": "^7.13.3",
"@mantine/hooks": "^7.13.2", "@mantine/hooks": "^7.13.3",
"@tabler/icons-react": "^3.19.0", "@tabler/icons-react": "^3.19.0",
"@tiptap/extension-color": "2.8.0", "@tiptap/extension-color": "2.8.0",
"@tiptap/extension-highlight": "2.8.0", "@tiptap/extension-highlight": "2.8.0",
@@ -58,7 +58,7 @@
"@tiptap/starter-kit": "^2.8.0", "@tiptap/starter-kit": "^2.8.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"mantine-react-table": "2.0.0-beta.6", "mantine-react-table": "2.0.0-beta.7",
"next": "^14.2.15", "next": "^14.2.15",
"react": "^18.3.1", "react": "^18.3.1",
"video.js": "^8.18.1" "video.js": "^8.18.1"

View File

@@ -1,19 +1,22 @@
"use client"; "use client";
import { memo, useMemo } from "react"; import { memo, useMemo } from "react";
import Link from "next/link";
import type { SelectProps } from "@mantine/core"; import type { SelectProps } from "@mantine/core";
import { Group, Loader, Select } from "@mantine/core"; import { Anchor, Group, Loader, Select, Text } from "@mantine/core";
import { IconCheck } from "@tabler/icons-react"; import { IconCheck } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api"; import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { useI18n } from "@homarr/translation/client";
import type { CommonWidgetInputProps } from "./common"; import type { CommonWidgetInputProps } from "./common";
import { useWidgetInputTranslation } from "./common"; import { useWidgetInputTranslation } from "./common";
import { useFormContext } from "./form"; import { useFormContext } from "./form";
export const WidgetAppInput = ({ property, kind, options }: CommonWidgetInputProps<"app">) => { export const WidgetAppInput = ({ property, kind }: CommonWidgetInputProps<"app">) => {
const t = useWidgetInputTranslation(kind, property); const t = useI18n();
const tInput = useWidgetInputTranslation(kind, property);
const form = useFormContext(); const form = useFormContext();
const { data: apps, isPending } = clientApi.app.selectable.useQuery(); const { data: apps, isPending } = clientApi.app.selectable.useQuery();
@@ -24,10 +27,11 @@ export const WidgetAppInput = ({ property, kind, options }: CommonWidgetInputPro
return ( return (
<Select <Select
label={t("label")} label={tInput("label")}
searchable searchable
limit={10} limit={10}
leftSection={<MemoizedLeftSection isPending={isPending} currentApp={currentApp} />} leftSection={<MemoizedLeftSection isPending={isPending} currentApp={currentApp} />}
nothingFoundMessage={t("widget.common.app.noData")}
renderOption={renderSelectOption} renderOption={renderSelectOption}
data={ data={
apps?.map((app) => ({ apps?.map((app) => ({
@@ -36,7 +40,18 @@ export const WidgetAppInput = ({ property, kind, options }: CommonWidgetInputPro
iconUrl: app.iconUrl, iconUrl: app.iconUrl,
})) ?? [] })) ?? []
} }
description={options.withDescription ? t("description") : undefined} inputWrapperOrder={["label", "input", "description", "error"]}
description={
<Text size="xs">
{t("widget.common.app.description", {
here: (
<Anchor size="xs" component={Link} target="_blank" href="/manage/apps/new">
{t("common.here")}
</Anchor>
),
})}
</Text>
}
{...form.getInputProps(`options.${property}`)} {...form.getInputProps(`options.${property}`)}
/> />
); );

View File

@@ -46,7 +46,7 @@ export const WidgetLocationInput = ({ property, kind }: CommonWidgetInputProps<"
form.clearFieldError(`options.${property}.latitude`); form.clearFieldError(`options.${property}.latitude`);
form.clearFieldError(`options.${property}.longitude`); form.clearFieldError(`options.${property}.longitude`);
}, },
[handleChange], [form, handleChange, property],
); );
const onSearch = useCallback(() => { const onSearch = useCallback(() => {

View File

@@ -39,7 +39,7 @@ export const WidgetMultiTextInput = ({ property, kind, options }: CommonWidgetIn
success: validationResult.success, success: validationResult.success,
result: validationResult, result: validationResult,
}; };
}, [search]); }, [options.validate, search]);
const error = React.useMemo(() => { const error = React.useMemo(() => {
/* hide the error when nothing is being typed since "" is not valid but is not an explicit error */ /* hide the error when nothing is being typed since "" is not valid but is not an explicit error */

View File

@@ -3,6 +3,7 @@
import type { PropsWithChildren } from "react"; import type { PropsWithChildren } from "react";
import { Suspense } from "react"; import { Suspense } from "react";
import { Flex, Text, Tooltip, UnstyledButton } from "@mantine/core"; import { Flex, Text, Tooltip, UnstyledButton } from "@mantine/core";
import { IconLoader } from "@tabler/icons-react";
import combineClasses from "clsx"; import combineClasses from "clsx";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
@@ -59,7 +60,7 @@ export default function AppWidget({ options, isEditMode }: WidgetComponentProps<
</Flex> </Flex>
</Tooltip.Floating> </Tooltip.Floating>
{options.pingEnabled && app.href ? ( {options.pingEnabled && app.href ? (
<Suspense fallback={<PingDot color="blue" tooltip={`${t("common.action.loading")}`} />}> <Suspense fallback={<PingDot icon={IconLoader} color="blue" tooltip={`${t("common.action.loading")}`} />}>
<PingIndicator href={app.href} /> <PingIndicator href={app.href} />
</Suspense> </Suspense>
) : null} ) : null}

View File

@@ -3,7 +3,7 @@ import { IconApps, IconDeviceDesktopX } from "@tabler/icons-react";
import { createWidgetDefinition } from "../definition"; import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options"; import { optionsBuilder } from "../options";
export const { definition, componentLoader, serverDataLoader } = createWidgetDefinition("app", { export const { definition, componentLoader } = createWidgetDefinition("app", {
icon: IconApps, icon: IconApps,
options: optionsBuilder.from((factory) => ({ options: optionsBuilder.from((factory) => ({
appId: factory.app(), appId: factory.app(),

View File

@@ -1,23 +1,33 @@
import type { MantineColor } from "@mantine/core"; import type { MantineColor } from "@mantine/core";
import { Box, Tooltip } from "@mantine/core"; import { Box, Tooltip } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import type { TablerIcon } from "@homarr/ui";
interface PingDotProps { interface PingDotProps {
icon: TablerIcon;
color: MantineColor; color: MantineColor;
tooltip: string; tooltip: string;
} }
export const PingDot = ({ color, tooltip }: PingDotProps) => { export const PingDot = ({ color, tooltip, ...props }: PingDotProps) => {
const [pingIconsEnabled] = clientApi.user.getPingIconsEnabledOrDefault.useSuspenseQuery();
return ( return (
<Box bottom="2.5cqmin" right="2.5cqmin" pos="absolute"> <Box bottom="2.5cqmin" right="2.5cqmin" pos="absolute">
<Tooltip label={tooltip}> <Tooltip label={tooltip}>
<Box {pingIconsEnabled ? (
bg={color} <props.icon style={{ width: "10cqmin", height: "10cqmin" }} color={color} />
style={{ ) : (
borderRadius: "100%", <Box
}} bg={color}
w="10cqmin" style={{
h="10cqmin" borderRadius: "100%",
></Box> }}
w="10cqmin"
h="10cqmin"
></Box>
)}
</Tooltip> </Tooltip>
</Box> </Box>
); );

View File

@@ -1,4 +1,5 @@
import { useState } from "react"; import { useState } from "react";
import { IconCheck, IconX } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api"; import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
@@ -32,9 +33,12 @@ export const PingIndicator = ({ href }: PingIndicatorProps) => {
}, },
); );
const isError = "error" in pingResult || pingResult.statusCode >= 500;
return ( return (
<PingDot <PingDot
color={"error" in pingResult || pingResult.statusCode >= 500 ? "red" : "green"} icon={isError ? IconX : IconCheck}
color={isError ? "red" : "green"}
tooltip={"statusCode" in pingResult ? pingResult.statusCode.toString() : pingResult.error} tooltip={"statusCode" in pingResult ? pingResult.statusCode.toString() : pingResult.error}
/> />
); );

View File

@@ -15,6 +15,7 @@ import { IconClock } from "@tabler/icons-react";
import dayjs from "dayjs"; import dayjs from "dayjs";
import type { CalendarEvent } from "@homarr/integrations/types"; import type { CalendarEvent } from "@homarr/integrations/types";
import { useI18n } from "@homarr/translation/client";
import classes from "./calendar-event-list.module.css"; import classes from "./calendar-event-list.module.css";
@@ -24,6 +25,7 @@ interface CalendarEventListProps {
export const CalendarEventList = ({ events }: CalendarEventListProps) => { export const CalendarEventList = ({ events }: CalendarEventListProps) => {
const { colorScheme } = useMantineColorScheme(); const { colorScheme } = useMantineColorScheme();
const t = useI18n();
return ( return (
<ScrollArea <ScrollArea
offsetScrollbars offsetScrollbars
@@ -57,14 +59,24 @@ export const CalendarEventList = ({ events }: CalendarEventListProps) => {
{event.subName} {event.subName}
</Text> </Text>
)} )}
<Text fw={"bold"} lineClamp={1}> <Text fw={"bold"} lineClamp={1} size="sm">
{event.name} {event.name}
</Text> </Text>
</Stack> </Stack>
<Group gap={3} wrap="nowrap"> {event.dates ? (
<IconClock opacity={0.7} size={"1rem"} /> <Group wrap="nowrap">
<Text c={"dimmed"}>{dayjs(event.date.toString()).format("HH:mm")}</Text> <Text c="dimmed" size="sm">
</Group> {t(
`widget.calendar.option.releaseType.options.${event.dates.find(({ date }) => event.date === date)?.type ?? "inCinemas"}`,
)}
</Text>
</Group>
) : (
<Group gap={3} wrap="nowrap">
<IconClock opacity={0.7} size={"1rem"} />
<Text c={"dimmed"}>{dayjs(event.date).format("HH:mm")}</Text>
</Group>
)}
</Group> </Group>
{event.description && ( {event.description && (
<Text size={"xs"} c={"dimmed"} lineClamp={2}> <Text size={"xs"} c={"dimmed"} lineClamp={2}>

View File

@@ -6,12 +6,31 @@ import { Calendar } from "@mantine/dates";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import type { CalendarEvent } from "@homarr/integrations/types";
import type { WidgetComponentProps } from "../definition"; import type { WidgetComponentProps } from "../definition";
import { CalendarDay } from "./calender-day"; import { CalendarDay } from "./calender-day";
import classes from "./component.module.css"; import classes from "./component.module.css";
export default function CalendarWidget({ isEditMode, serverData }: WidgetComponentProps<"calendar">) { export default function CalendarWidget({
isEditMode,
integrationIds,
itemId,
options,
}: WidgetComponentProps<"calendar">) {
const [events] = clientApi.widget.calendar.findAllEvents.useSuspenseQuery(
{
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
itemId: itemId!,
integrationIds,
},
{
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
retry: false,
},
);
const [month, setMonth] = useState(new Date()); const [month, setMonth] = useState(new Date());
const params = useParams(); const params = useParams();
const locale = params.locale as string; const locale = params.locale as string;
@@ -67,9 +86,16 @@ export default function CalendarWidget({ isEditMode, serverData }: WidgetCompone
padding: 0, padding: 0,
}, },
}} }}
renderDay={(date) => { renderDay={(tileDate) => {
const eventsForDate = (serverData?.initialData ?? []).filter((event) => dayjs(event.date).isSame(date, "day")); const eventsForDate = events
return <CalendarDay date={date} events={eventsForDate} disabled={isEditMode} />; .map((event) => ({
...event,
date: (event.dates?.filter(({ type }) => options.releaseType.includes(type)) ?? [event]).find(({ date }) =>
dayjs(date).isSame(tileDate, "day"),
)?.date,
}))
.filter((event): event is CalendarEvent => Boolean(event.date));
return <CalendarDay date={tileDate} events={eventsForDate} disabled={isEditMode} />;
}} }}
/> />
); );

View File

@@ -1,14 +1,22 @@
import { IconCalendar } from "@tabler/icons-react"; import { IconCalendar } from "@tabler/icons-react";
import { getIntegrationKindsByCategory } from "@homarr/definitions"; import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { radarrReleaseTypes } from "@homarr/integrations/types";
import { z } from "@homarr/validation"; import { z } from "@homarr/validation";
import { createWidgetDefinition } from "../definition"; import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options"; import { optionsBuilder } from "../options";
export const { definition, componentLoader, serverDataLoader } = createWidgetDefinition("calendar", { export const { definition, componentLoader } = createWidgetDefinition("calendar", {
icon: IconCalendar, icon: IconCalendar,
options: optionsBuilder.from((factory) => ({ options: 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({ filterPastMonths: factory.number({
validate: z.number().min(2).max(9999), validate: z.number().min(2).max(9999),
defaultValue: 2, defaultValue: 2,
@@ -19,6 +27,4 @@ export const { definition, componentLoader, serverDataLoader } = createWidgetDef
}), }),
})), })),
supportedIntegrations: getIntegrationKindsByCategory("calendar"), supportedIntegrations: getIntegrationKindsByCategory("calendar"),
}) }).withDynamicImport(() => import("./component"));
.withServerData(() => import("./serverData"))
.withDynamicImport(() => import("./component"));

View File

@@ -1,35 +0,0 @@
"use server";
import type { RouterOutputs } from "@homarr/api";
import { api } from "@homarr/api/server";
import type { WidgetProps } from "../definition";
export default async function getServerDataAsync({ integrationIds, itemId }: WidgetProps<"calendar">) {
if (!itemId) {
return {
initialData: [],
};
}
try {
const data = await api.widget.calendar.findAllEvents({
integrationIds,
itemId,
});
return {
initialData: data
.filter(
(
item,
): item is Exclude<Exclude<RouterOutputs["widget"]["calendar"]["findAllEvents"][number], null>, undefined> =>
item !== null,
)
.flatMap((item) => item.data),
};
} catch {
return {
initialData: [],
};
}
}

View File

@@ -8,57 +8,22 @@ import type { TablerIcon } from "@homarr/ui";
import type { WidgetImports } from "."; import type { WidgetImports } from ".";
import type { inferOptionsFromDefinition, WidgetOptionsRecord } from "./options"; import type { inferOptionsFromDefinition, WidgetOptionsRecord } from "./options";
type ServerDataLoader<TKind extends WidgetKind> = () => Promise<{
default: (props: WidgetProps<TKind>) => Promise<Record<string, unknown>>;
}>;
const createWithDynamicImport = const createWithDynamicImport =
<
TKind extends WidgetKind,
TDefinition extends WidgetDefinition,
TServerDataLoader extends ServerDataLoader<TKind> | undefined,
>(
kind: TKind,
definition: TDefinition,
serverDataLoader: TServerDataLoader,
) =>
(
componentLoader: () => LoaderComponent<
WidgetComponentProps<TKind> &
(TServerDataLoader extends ServerDataLoader<TKind>
? {
serverData: Awaited<ReturnType<Awaited<ReturnType<TServerDataLoader>>["default"]>>;
}
: never)
>,
) => ({
definition: {
...definition,
kind,
},
kind,
serverDataLoader,
componentLoader,
});
const createWithServerData =
<TKind extends WidgetKind, TDefinition extends WidgetDefinition>(kind: TKind, definition: TDefinition) => <TKind extends WidgetKind, TDefinition extends WidgetDefinition>(kind: TKind, definition: TDefinition) =>
<TServerDataLoader extends ServerDataLoader<TKind>>(serverDataLoader: TServerDataLoader) => ({ (componentLoader: () => LoaderComponent<WidgetComponentProps<TKind>>) => ({
definition: { definition: {
...definition, ...definition,
kind, kind,
}, },
kind, kind,
serverDataLoader, componentLoader,
withDynamicImport: createWithDynamicImport(kind, definition, serverDataLoader),
}); });
export const createWidgetDefinition = <TKind extends WidgetKind, TDefinition extends WidgetDefinition>( export const createWidgetDefinition = <TKind extends WidgetKind, TDefinition extends WidgetDefinition>(
kind: TKind, kind: TKind,
definition: TDefinition, definition: TDefinition,
) => ({ ) => ({
withServerData: createWithServerData(kind, definition), withDynamicImport: createWithDynamicImport(kind, definition),
withDynamicImport: createWithDynamicImport(kind, definition, undefined),
}); });
export interface WidgetDefinition { export interface WidgetDefinition {
@@ -83,15 +48,7 @@ export interface WidgetProps<TKind extends WidgetKind> {
itemId: string | undefined; // undefined when in preview mode itemId: string | undefined; // undefined when in preview mode
} }
type inferServerDataForKind<TKind extends WidgetKind> = WidgetImports[TKind] extends {
serverDataLoader: ServerDataLoader<TKind>;
}
? Awaited<ReturnType<Awaited<ReturnType<WidgetImports[TKind]["serverDataLoader"]>>["default"]>>
: undefined;
export type WidgetComponentProps<TKind extends WidgetKind> = WidgetProps<TKind> & { export type WidgetComponentProps<TKind extends WidgetKind> = WidgetProps<TKind> & {
serverData?: inferServerDataForKind<TKind>;
} & {
boardId: string | undefined; // undefined when in preview mode boardId: string | undefined; // undefined when in preview mode
isEditMode: boolean; isEditMode: boolean;
setOptions: ({ setOptions: ({

View File

@@ -39,16 +39,25 @@ export default function DnsHoleControlsWidget({
options, options,
integrationIds, integrationIds,
isEditMode, isEditMode,
serverData,
}: WidgetComponentProps<typeof widgetKind>) { }: WidgetComponentProps<typeof widgetKind>) {
// DnsHole integrations with interaction permissions // DnsHole integrations with interaction permissions
const integrationsWithInteractions = useIntegrationsWithInteractAccess() const integrationsWithInteractions = useIntegrationsWithInteractAccess()
.map(({ id }) => id) .map(({ id }) => id)
.filter((id) => integrationIds.includes(id)); .filter((id) => integrationIds.includes(id));
// Initial summaries, null summary means disconnected, undefined status means processing const [summaries] = clientApi.widget.dnsHole.summary.useSuspenseQuery(
const [summaries, setSummaries] = useState(serverData?.initialData ?? []); {
widgetKind: "dnsHoleControls",
integrationIds,
},
{
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
retry: false,
},
);
const utils = clientApi.useUtils();
// Subscribe to summary updates // Subscribe to summary updates
clientApi.widget.dnsHole.subscribeToSummary.useSubscription( clientApi.widget.dnsHole.subscribeToSummary.useSubscription(
{ {
@@ -57,8 +66,20 @@ export default function DnsHoleControlsWidget({
}, },
{ {
onData: (data) => { onData: (data) => {
setSummaries((prevSummaries) => utils.widget.dnsHole.summary.setData(
prevSummaries.map((summary) => (summary.integration.id === data.integration.id ? data : summary)), {
widgetKind: "dnsHoleControls",
integrationIds,
},
(prevData) => {
if (!prevData) return undefined;
const newData = prevData.map((summary) =>
summary.integration.id === data.integration.id ? { ...summary, summary: data.summary } : summary,
);
return newData;
},
); );
}, },
}, },
@@ -67,39 +88,77 @@ export default function DnsHoleControlsWidget({
// Mutations for dnsHole state, set to undefined on click, and change again on settle // Mutations for dnsHole state, set to undefined on click, and change again on settle
const { mutate: enableDns } = clientApi.widget.dnsHole.enable.useMutation({ const { mutate: enableDns } = clientApi.widget.dnsHole.enable.useMutation({
onSettled: (_, error, { integrationId }) => { onSettled: (_, error, { integrationId }) => {
setSummaries((prevSummaries) => utils.widget.dnsHole.summary.setData(
prevSummaries.map((data) => ({ {
...data, widgetKind: "dnsHoleControls",
summary: integrationIds,
data.integration.id === integrationId && data.summary },
? { ...data.summary, status: error ? "disabled" : "enabled" } (prevData) => {
: data.summary, if (!prevData) return [];
})),
return prevData.map((item) =>
item.integration.id === integrationId && item.summary
? {
...item,
summary: {
...item.summary,
status: error ? "disabled" : "enabled",
},
}
: item,
);
},
); );
}, },
}); });
const { mutate: disableDns } = clientApi.widget.dnsHole.disable.useMutation({ const { mutate: disableDns } = clientApi.widget.dnsHole.disable.useMutation({
onSettled: (_, error, { integrationId }) => { onSettled: (_, error, { integrationId }) => {
setSummaries((prevSummaries) => utils.widget.dnsHole.summary.setData(
prevSummaries.map((data) => ({ {
...data, widgetKind: "dnsHoleControls",
summary: integrationIds,
data.integration.id === integrationId && data.summary },
? { ...data.summary, status: error ? "enabled" : "disabled" } (prevData) => {
: data.summary, if (!prevData) return [];
})),
return prevData.map((item) =>
item.integration.id === integrationId && item.summary
? {
...item,
summary: {
...item.summary,
status: error ? "enabled" : "disabled",
},
}
: item,
);
},
); );
}, },
}); });
const toggleDns = (integrationId: string) => { const toggleDns = (integrationId: string) => {
const integrationStatus = summaries.find(({ integration }) => integration.id === integrationId); const integrationStatus = summaries.find(({ integration }) => integration.id === integrationId);
if (!integrationStatus?.summary?.status) return; if (!integrationStatus?.summary?.status) return;
setSummaries((prevSummaries) => utils.widget.dnsHole.summary.setData(
prevSummaries.map((data) => ({ {
...data, widgetKind: "dnsHoleControls",
summary: integrationIds,
data.integration.id === integrationId && data.summary ? { ...data.summary, status: undefined } : data.summary, },
})), (prevData) => {
if (!prevData) return [];
return prevData.map((item) =>
item.integration.id === integrationId && item.summary
? {
...item,
summary: {
...item.summary,
status: undefined,
},
}
: item,
);
},
); );
if (integrationStatus.summary.status === "enabled") { if (integrationStatus.summary.status === "enabled") {
disableDns({ integrationId, duration: 0 }); disableDns({ integrationId, duration: 0 });

View File

@@ -7,7 +7,7 @@ import { optionsBuilder } from "../../options";
export const widgetKind = "dnsHoleControls"; export const widgetKind = "dnsHoleControls";
export const { definition, componentLoader, serverDataLoader } = createWidgetDefinition(widgetKind, { export const { definition, componentLoader } = createWidgetDefinition(widgetKind, {
icon: IconDeviceGamepad, icon: IconDeviceGamepad,
options: optionsBuilder.from((factory) => ({ options: optionsBuilder.from((factory) => ({
showToggleAllButtons: factory.switch({ showToggleAllButtons: factory.switch({
@@ -21,6 +21,4 @@ export const { definition, componentLoader, serverDataLoader } = createWidgetDef
message: (t) => t("widget.dnsHoleControls.error.internalServerError"), message: (t) => t("widget.dnsHoleControls.error.internalServerError"),
}, },
}, },
}) }).withDynamicImport(() => import("./component"));
.withServerData(() => import("./serverData"))
.withDynamicImport(() => import("./component"));

View File

@@ -1,29 +0,0 @@
"use server";
import { api } from "@homarr/api/server";
import { widgetKind } from ".";
import type { WidgetProps } from "../../definition";
export default async function getServerDataAsync({ integrationIds }: WidgetProps<typeof widgetKind>) {
if (integrationIds.length === 0) {
return {
initialData: [],
};
}
try {
const currentDns = await api.widget.dnsHole.summary({
widgetKind,
integrationIds,
});
return {
initialData: currentDns,
};
} catch {
return {
initialData: [],
};
}
}

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import { useMemo, useState } from "react"; import { useMemo } from "react";
import type { BoxProps } from "@mantine/core"; import type { BoxProps } from "@mantine/core";
import { Avatar, AvatarGroup, Box, Card, Flex, Stack, Text, Tooltip } from "@mantine/core"; import { Avatar, AvatarGroup, Box, Card, Flex, Stack, Text, Tooltip } from "@mantine/core";
import { useElementSize } from "@mantine/hooks"; import { useElementSize } from "@mantine/hooks";
@@ -20,12 +20,20 @@ import { widgetKind } from ".";
import type { WidgetComponentProps, WidgetProps } from "../../definition"; import type { WidgetComponentProps, WidgetProps } from "../../definition";
import { NoIntegrationSelectedError } from "../../errors"; import { NoIntegrationSelectedError } from "../../errors";
export default function DnsHoleSummaryWidget({ export default function DnsHoleSummaryWidget({ options, integrationIds }: WidgetComponentProps<typeof widgetKind>) {
options, const [summaries] = clientApi.widget.dnsHole.summary.useSuspenseQuery(
integrationIds, {
serverData, widgetKind,
}: WidgetComponentProps<typeof widgetKind>) { integrationIds,
const [summaries, setSummaries] = useState(serverData?.initialData ?? []); },
{
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
retry: false,
},
);
const utils = clientApi.useUtils();
const t = useI18n(); const t = useI18n();
@@ -36,8 +44,21 @@ export default function DnsHoleSummaryWidget({
}, },
{ {
onData: (data) => { onData: (data) => {
setSummaries((prevSummaries) => utils.widget.dnsHole.summary.setData(
prevSummaries.map((summary) => (summary.integration.id === data.integration.id ? data : summary)), {
widgetKind,
integrationIds,
},
(prevData) => {
if (!prevData) {
return undefined;
}
const newData = prevData.map((item) =>
item.integration.id === data.integration.id ? { ...item, summary: data.summary } : item,
);
return newData;
},
); );
}, },
}, },
@@ -46,17 +67,10 @@ export default function DnsHoleSummaryWidget({
const data = useMemo( const data = useMemo(
() => () =>
summaries summaries
.filter( .filter((pair) => Math.abs(dayjs(pair.timestamp).diff()) < 30000)
( .flatMap(({ summary }) => summary)
pair, .filter((summary) => summary !== null),
): pair is { [summaries],
integration: typeof pair.integration;
timestamp: typeof pair.timestamp;
summary: DnsHoleSummary;
} => pair.summary !== null && Math.abs(dayjs(pair.timestamp).diff()) < 30000,
)
.flatMap(({ summary }) => summary),
[summaries, serverData],
); );
if (integrationIds.length === 0) { if (integrationIds.length === 0) {

View File

@@ -7,7 +7,7 @@ import { optionsBuilder } from "../../options";
export const widgetKind = "dnsHoleSummary"; export const widgetKind = "dnsHoleSummary";
export const { definition, componentLoader, serverDataLoader } = createWidgetDefinition(widgetKind, { export const { definition, componentLoader } = createWidgetDefinition(widgetKind, {
icon: IconAd, icon: IconAd,
options: optionsBuilder.from((factory) => ({ options: optionsBuilder.from((factory) => ({
usePiHoleColors: factory.switch({ usePiHoleColors: factory.switch({
@@ -28,6 +28,4 @@ export const { definition, componentLoader, serverDataLoader } = createWidgetDef
message: (t) => t("widget.dnsHoleSummary.error.internalServerError"), message: (t) => t("widget.dnsHoleSummary.error.internalServerError"),
}, },
}, },
}) }).withDynamicImport(() => import("./component"));
.withServerData(() => import("./serverData"))
.withDynamicImport(() => import("./component"));

View File

@@ -1,29 +0,0 @@
"use server";
import { api } from "@homarr/api/server";
import { widgetKind } from ".";
import type { WidgetProps } from "../../definition";
export default async function getServerDataAsync({ integrationIds }: WidgetProps<typeof widgetKind>) {
if (integrationIds.length === 0) {
return {
initialData: [],
};
}
try {
const currentDns = await api.widget.dnsHole.summary({
widgetKind,
integrationIds,
});
return {
initialData: currentDns,
};
} catch {
return {
initialData: [],
};
}
}

View File

@@ -2,7 +2,7 @@
import "../widgets-common.css"; import "../widgets-common.css";
import { useMemo, useState } from "react"; import { useCallback, useMemo, useState } from "react";
import type { MantineStyleProp } from "@mantine/core"; import type { MantineStyleProp } from "@mantine/core";
import { import {
ActionIcon, ActionIcon,
@@ -21,7 +21,7 @@ import {
Title, Title,
Tooltip, Tooltip,
} from "@mantine/core"; } from "@mantine/core";
import { useDisclosure, useListState, useTimeout } from "@mantine/hooks"; import { useDisclosure, useTimeout } from "@mantine/hooks";
import type { IconProps } from "@tabler/icons-react"; import type { IconProps } from "@tabler/icons-react";
import { import {
IconAlertTriangle, IconAlertTriangle,
@@ -40,9 +40,6 @@ import { MantineReactTable, useMantineReactTable } from "mantine-react-table";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { useIntegrationsWithInteractAccess } from "@homarr/auth/client"; import { useIntegrationsWithInteractAccess } from "@homarr/auth/client";
import { humanFileSize } from "@homarr/common"; import { humanFileSize } from "@homarr/common";
import type { Modify } from "@homarr/common/types";
import type { Integration } from "@homarr/db/schema/sqlite";
import type { IntegrationKindByCategory } from "@homarr/definitions";
import { getIconUrl, getIntegrationKindsByCategory } from "@homarr/definitions"; import { getIconUrl, getIntegrationKindsByCategory } from "@homarr/definitions";
import type { import type {
DownloadClientJobsAndStatus, DownloadClientJobsAndStatus,
@@ -91,30 +88,35 @@ export default function DownloadClientsWidget({
isEditMode, isEditMode,
integrationIds, integrationIds,
options, options,
serverData,
setOptions, setOptions,
}: WidgetComponentProps<"downloads">) { }: WidgetComponentProps<"downloads">) {
const integrationsWithInteractions = useIntegrationsWithInteractAccess().flatMap(({ id }) => const integrationsWithInteractions = useIntegrationsWithInteractAccess().flatMap(({ id }) =>
integrationIds.includes(id) ? [id] : [], integrationIds.includes(id) ? [id] : [],
); );
const [currentItems, currentItemsHandlers] = useListState<{ const [currentItems] = clientApi.widget.downloads.getJobsAndStatuses.useSuspenseQuery(
integration: Modify<Integration, { kind: IntegrationKindByCategory<"downloadClient"> }>; {
timestamp: Date; integrationIds,
data: DownloadClientJobsAndStatus | null; },
}>( {
//Automatically invalidate data older than 30 seconds refetchOnMount: false,
serverData?.initialData?.map((item) => refetchOnWindowFocus: false,
dayjs().diff(item.timestamp) < invalidateTime ? item : { ...item, timestamp: new Date(0), data: null }, refetchOnReconnect: false,
) ?? [], retry: false,
select(data) {
return data.map((item) =>
dayjs().diff(item.timestamp) < invalidateTime ? item : { ...item, timestamp: new Date(0), data: null },
);
},
},
); );
const utils = clientApi.useUtils();
//Invalidate all data after no update for 30 seconds using timer //Invalidate all data after no update for 30 seconds using timer
const invalidationTimer = useTimeout( const invalidationTimer = useTimeout(
() => { () => {
currentItemsHandlers.applyWhere( utils.widget.downloads.getJobsAndStatuses.setData({ integrationIds }, (prevData) =>
() => true, prevData?.map((item) => ({ ...item, timestamp: new Date(0), data: null })),
(item) => ({ ...item, timestamp: new Date(0), data: null }),
); );
}, },
invalidateTime, invalidateTime,
@@ -146,20 +148,24 @@ export default function DownloadClientsWidget({
//Don't update already invalid data (new Date (0)) //Don't update already invalid data (new Date (0))
.filter(({ timestamp }) => dayjs().diff(timestamp) > invalidateTime && timestamp > new Date(0)) .filter(({ timestamp }) => dayjs().diff(timestamp) > invalidateTime && timestamp > new Date(0))
.map(({ integration }) => integration.id); .map(({ integration }) => integration.id);
currentItemsHandlers.applyWhere( utils.widget.downloads.getJobsAndStatuses.setData({ integrationIds }, (prevData) =>
({ integration }) => invalidIndexes.includes(integration.id), prevData?.map((item) =>
//Set date to now so it won't update that integration for at least 30 seconds invalidIndexes.includes(item.integration.id) ? item : { ...item, timestamp: new Date(0), data: null },
(item) => ({ ...item, timestamp: new Date(0), data: null }), ),
); );
//Find id to update utils.widget.downloads.getJobsAndStatuses.setData({ integrationIds }, (prevData) => {
const updateIndex = currentItems.findIndex((pair) => pair.integration.id === data.integration.id); const updateIndex = currentItems.findIndex((pair) => pair.integration.id === data.integration.id);
if (updateIndex >= 0) { if (updateIndex >= 0) {
//Update found index //Update found index
currentItemsHandlers.setItem(updateIndex, data); return prevData?.map((pair, index) => (index === updateIndex ? data : pair));
} else if (integrationIds.includes(data.integration.id)) { } else if (integrationIds.includes(data.integration.id)) {
//Append index not found (new integration) //Append index not found (new integration)
currentItemsHandlers.append(data); return [...(prevData ?? []), data];
} }
return undefined;
});
//Reset no update timer //Reset no update timer
invalidationTimer.clear(); invalidationTimer.clear();
invalidationTimer.start(); invalidationTimer.start();
@@ -227,7 +233,19 @@ export default function DownloadClientsWidget({
) )
//flatMap already sorts by integration by nature, add sorting by integration type (usenet | torrent) //flatMap already sorts by integration by nature, add sorting by integration type (usenet | torrent)
.sort(({ type: typeA }, { type: typeB }) => typeA.length - typeB.length), .sort(({ type: typeA }, { type: typeB }) => typeA.length - typeB.length),
[currentItems, integrationIds, options], [
currentItems,
integrationIds,
integrationsWithInteractions,
mutateDeleteItem,
mutatePauseItem,
mutateResumeItem,
options.activeTorrentThreshold,
options.categoryFilter,
options.filterIsWhitelist,
options.showCompletedTorrent,
options.showCompletedUsenet,
],
); );
//Flatten Clients Array for which each elements has the integration and general client infos. //Flatten Clients Array for which each elements has the integration and general client infos.
@@ -272,7 +290,14 @@ export default function DownloadClientsWidget({
({ status: statusA }, { status: statusB }) => ({ status: statusA }, { status: statusB }) =>
(statusA?.type.length ?? Infinity) - (statusB?.type.length ?? Infinity), (statusA?.type.length ?? Infinity) - (statusB?.type.length ?? Infinity),
), ),
[currentItems, integrationIds, options], [
currentItems,
integrationIds,
integrationsWithInteractions,
options.applyFilterToRatio,
options.categoryFilter,
options.filterIsWhitelist,
],
); );
//Check existing types between torrents and usenet //Check existing types between torrents and usenet
@@ -327,37 +352,40 @@ export default function DownloadClientsWidget({
}; };
//Base element in common with all columns //Base element in common with all columns
const columnsDefBase = ({ const columnsDefBase = useCallback(
key, ({
showHeader, key,
align, showHeader,
}: { align,
key: keyof ExtendedDownloadClientItem; }: {
showHeader: boolean; key: keyof ExtendedDownloadClientItem;
align?: "center" | "left" | "right" | "justify" | "char"; showHeader: boolean;
}): MRT_ColumnDef<ExtendedDownloadClientItem> => { align?: "center" | "left" | "right" | "justify" | "char";
const style: MantineStyleProp = { }): MRT_ColumnDef<ExtendedDownloadClientItem> => {
minWidth: 0, const style: MantineStyleProp = {
width: "var(--column-width)", minWidth: 0,
height: "var(--ratio-width)", width: "var(--column-width)",
padding: "var(--space-size)", height: "var(--ratio-width)",
transition: "unset", padding: "var(--space-size)",
"--key-width": columnsRatios[key], transition: "unset",
"--column-width": "calc((var(--key-width)/var(--total-width) * 100cqw))", "--key-width": columnsRatios[key],
}; "--column-width": "calc((var(--key-width)/var(--total-width) * 100cqw))",
return { };
id: key, return {
accessorKey: key, id: key,
header: key, accessorKey: key,
size: columnsRatios[key], header: key,
mantineTableBodyCellProps: { style, align }, size: columnsRatios[key],
mantineTableHeadCellProps: { mantineTableBodyCellProps: { style, align },
style, mantineTableHeadCellProps: {
align: isEditMode ? "center" : align, style,
}, align: isEditMode ? "center" : align,
Header: () => (showHeader && !isEditMode ? <Text fw={700}>{t(`items.${key}.columnTitle`)}</Text> : ""), },
}; Header: () => (showHeader && !isEditMode ? <Text fw={700}>{t(`items.${key}.columnTitle`)}</Text> : ""),
}; };
},
[isEditMode, t],
);
//Make columns and cell elements, Memoized to data with deps on data and EditMode //Make columns and cell elements, Memoized to data with deps on data and EditMode
const columns = useMemo<MRT_ColumnDef<ExtendedDownloadClientItem>[]>( const columns = useMemo<MRT_ColumnDef<ExtendedDownloadClientItem>[]>(
@@ -574,7 +602,7 @@ export default function DownloadClientsWidget({
}, },
}, },
], ],
[clickedIndex, isEditMode, data, integrationIds, options], [columnsDefBase, t, tCommon],
); );
//Table build and config //Table build and config
@@ -698,10 +726,7 @@ interface ItemInfoModalProps {
} }
const ItemInfoModal = ({ items, currentIndex, opened, onClose }: ItemInfoModalProps) => { const ItemInfoModal = ({ items, currentIndex, opened, onClose }: ItemInfoModalProps) => {
const item = useMemo<ExtendedDownloadClientItem | undefined>( const item = useMemo<ExtendedDownloadClientItem | undefined>(() => items[currentIndex], [items, currentIndex]);
() => items[currentIndex],
[items, currentIndex, opened],
);
const t = useScopedI18n("widget.downloads.states"); const t = useScopedI18n("widget.downloads.states");
//The use case for "No item found" should be impossible, hence no translation //The use case for "No item found" should be impossible, hence no translation
return ( return (

View File

@@ -31,7 +31,7 @@ const columnsSort = columnsList.filter((column) =>
sortingExclusion.some((exclusion) => exclusion !== column), sortingExclusion.some((exclusion) => exclusion !== column),
) as Exclude<typeof columnsList, (typeof sortingExclusion)[number]>; ) as Exclude<typeof columnsList, (typeof sortingExclusion)[number]>;
export const { definition, componentLoader, serverDataLoader } = createWidgetDefinition("downloads", { export const { definition, componentLoader } = createWidgetDefinition("downloads", {
icon: IconDownload, icon: IconDownload,
options: optionsBuilder.from( options: optionsBuilder.from(
(factory) => ({ (factory) => ({
@@ -105,6 +105,4 @@ export const { definition, componentLoader, serverDataLoader } = createWidgetDef
}, },
), ),
supportedIntegrations: getIntegrationKindsByCategory("downloadClient"), supportedIntegrations: getIntegrationKindsByCategory("downloadClient"),
}) }).withDynamicImport(() => import("./component"));
.withServerData(() => import("./serverData"))
.withDynamicImport(() => import("./component"));

View File

@@ -1,21 +0,0 @@
"use server";
import { api } from "@homarr/api/server";
import type { WidgetProps } from "../definition";
export default async function getServerDataAsync({ integrationIds }: WidgetProps<"downloads">) {
if (integrationIds.length === 0) {
return {
initialData: undefined,
};
}
const jobsAndStatuses = await api.widget.downloads.getJobsAndStatuses({
integrationIds,
});
return {
initialData: jobsAndStatuses,
};
}

View File

@@ -17,7 +17,7 @@ import {
Text, Text,
Tooltip, Tooltip,
} from "@mantine/core"; } from "@mantine/core";
import { useDisclosure, useElementSize, useListState } from "@mantine/hooks"; import { useDisclosure, useElementSize } from "@mantine/hooks";
import { import {
IconBrain, IconBrain,
IconClock, IconClock,
@@ -29,42 +29,91 @@ import {
IconTemperature, IconTemperature,
IconVersions, IconVersions,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import dayjs from "dayjs";
import duration from "dayjs/plugin/duration";
import { clientApi } from "@homarr/api/client";
import type { HealthMonitoring } from "@homarr/integrations";
import type { TranslationFunction } from "@homarr/translation"; import type { TranslationFunction } from "@homarr/translation";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition"; import type { WidgetComponentProps } from "../definition";
import { NoIntegrationSelectedError } from "../errors"; import { NoIntegrationSelectedError } from "../errors";
export default function HealthMonitoringWidget({ dayjs.extend(duration);
options,
integrationIds, export default function HealthMonitoringWidget({ options, integrationIds }: WidgetComponentProps<"healthMonitoring">) {
serverData,
}: WidgetComponentProps<"healthMonitoring">) {
const t = useI18n(); const t = useI18n();
const [healthData] = useListState(serverData?.initialData ?? []); const [healthData] = clientApi.widget.healthMonitoring.getHealthStatus.useSuspenseQuery(
{
integrationIds,
},
{
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
retry: false,
select: (data) =>
data.filter(
(
health,
): health is {
integrationId: string;
integrationName: string;
healthInfo: HealthMonitoring;
timestamp: Date;
} => health.healthInfo !== null,
),
},
);
const [opened, { open, close }] = useDisclosure(false); const [opened, { open, close }] = useDisclosure(false);
const utils = clientApi.useUtils();
clientApi.widget.healthMonitoring.subscribeHealthStatus.useSubscription(
{ integrationIds },
{
onData(data) {
utils.widget.healthMonitoring.getHealthStatus.setData({ integrationIds }, (prevData) => {
if (!prevData) {
return undefined;
}
const newData = prevData.map((item) =>
item.integrationId === data.integrationId
? { ...item, healthInfo: data.healthInfo, timestamp: new Date(0) }
: item,
);
return newData.filter(
(
health,
): health is {
integrationId: string;
integrationName: string;
healthInfo: HealthMonitoring;
timestamp: Date;
} => health.healthInfo !== null,
);
});
},
},
);
if (integrationIds.length === 0) { if (integrationIds.length === 0) {
throw new NoIntegrationSelectedError(); throw new NoIntegrationSelectedError();
} }
return ( return (
<Box h="100%" className="health-monitoring"> <Stack h="100%" gap="2.5cqmin" className="health-monitoring">
{healthData.map(({ integrationId, integrationName, healthInfo }) => { {healthData.map(({ integrationId, integrationName, healthInfo, timestamp }) => {
const memoryUsage = formatMemoryUsage(healthInfo.memAvailable, healthInfo.memUsed);
const disksData = matchFileSystemAndSmart(healthInfo.fileSystem, healthInfo.smart); const disksData = matchFileSystemAndSmart(healthInfo.fileSystem, healthInfo.smart);
const { ref, width } = useElementSize(); const memoryUsage = formatMemoryUsage(healthInfo.memAvailable, healthInfo.memUsed);
const ringSize = width * 0.95;
const ringThickness = width / 10;
const progressSize = width * 0.2;
return ( return (
<Box <Stack
gap="2.5cqmin"
key={integrationId} key={integrationId}
h="100%" h="100%"
className={`health-monitoring-information health-monitoring-${integrationName}`} className={`health-monitoring-information health-monitoring-${integrationName}`}
p="2.5cqmin"
> >
<Card className="health-monitoring-information-card" m="2.5cqmin" p="2.5cqmin" withBorder> <Card className="health-monitoring-information-card" p="2.5cqmin" withBorder>
<Flex <Flex
className="health-monitoring-information-card-elements" className="health-monitoring-information-card-elements"
h="100%" h="100%"
@@ -102,21 +151,30 @@ export default function HealthMonitoringWidget({
className="health-monitoring-information-processor" className="health-monitoring-information-processor"
icon={<IconCpu2 size="1.5cqmin" />} icon={<IconCpu2 size="1.5cqmin" />}
> >
{t("widget.healthMonitoring.popover.processor")} {healthInfo.cpuModelName} {t("widget.healthMonitoring.popover.processor", { cpuModelName: healthInfo.cpuModelName })}
</List.Item> </List.Item>
<List.Item <List.Item
className="health-monitoring-information-memory" className="health-monitoring-information-memory"
icon={<IconBrain size="1.5cqmin" />} icon={<IconBrain size="1.5cqmin" />}
> >
{t("widget.healthMonitoring.popover.memory")} {memoryUsage.memTotal.GB}GiB -{" "} {t("widget.healthMonitoring.popover.memory", { memory: memoryUsage.memTotal.GB })}
{t("widget.healthMonitoring.popover.memAvailable")} {memoryUsage.memFree.GB}GiB ( </List.Item>
{memoryUsage.memFree.percent}%) <List.Item
className="health-monitoring-information-memory"
icon={<IconBrain size="1.5cqmin" />}
>
{t("widget.healthMonitoring.popover.memoryAvailable", {
memoryAvailable: memoryUsage.memFree.GB,
percent: memoryUsage.memFree.percent,
})}
</List.Item> </List.Item>
<List.Item <List.Item
className="health-monitoring-information-version" className="health-monitoring-information-version"
icon={<IconVersions size="1.5cqmin" />} icon={<IconVersions size="1.5cqmin" />}
> >
{t("widget.healthMonitoring.popover.version")} {healthInfo.version} {t("widget.healthMonitoring.popover.version", {
version: healthInfo.version,
})}
</List.Item> </List.Item>
<List.Item <List.Item
className="health-monitoring-information-uptime" className="health-monitoring-information-uptime"
@@ -147,92 +205,28 @@ export default function HealthMonitoringWidget({
</Stack> </Stack>
</Modal> </Modal>
</Box> </Box>
{options.cpu && ( {options.cpu && <CpuRing cpuUtilization={healthInfo.cpuUtilization} />}
<Box ref={ref} w="100%" h="100%" className="health-monitoring-cpu">
<RingProgress
className="health-monitoring-cpu-utilization"
roundCaps
size={ringSize}
thickness={ringThickness}
label={
<Center style={{ flexDirection: "column" }}>
<Text
className="health-monitoring-cpu-utilization-value"
size="3cqmin"
>{`${healthInfo.cpuUtilization.toFixed(2)}%`}</Text>
<IconCpu className="health-monitoring-cpu-utilization-icon" size="7cqmin" />
</Center>
}
sections={[
{
value: Number(healthInfo.cpuUtilization.toFixed(2)),
color: progressColor(Number(healthInfo.cpuUtilization.toFixed(2))),
},
]}
/>
</Box>
)}
{healthInfo.cpuTemp && options.cpu && ( {healthInfo.cpuTemp && options.cpu && (
<Box ref={ref} w="100%" h="100%" className="health-monitoring-cpu-temperature"> <CpuTempRing fahrenheit={options.fahrenheit} cpuTemp={healthInfo.cpuTemp} />
<RingProgress
ref={ref}
className="health-monitoring-cpu-temp"
roundCaps
size={ringSize}
thickness={ringThickness}
label={
<Center style={{ flexDirection: "column" }}>
<Text className="health-monitoring-cpu-temp-value" size="3cqmin">
{options.fahrenheit
? `${(healthInfo.cpuTemp * 1.8 + 32).toFixed(1)}°F`
: `${healthInfo.cpuTemp}°C`}
</Text>
<IconCpu className="health-monitoring-cpu-temp-icon" size="7cqmin" />
</Center>
}
sections={[
{
value: healthInfo.cpuTemp,
color: progressColor(healthInfo.cpuTemp),
},
]}
/>
</Box>
)}
{options.memory && (
<Box ref={ref} w="100%" h="100%" className="health-monitoring-memory">
<RingProgress
className="health-monitoring-memory-use"
roundCaps
size={ringSize}
thickness={ringThickness}
label={
<Center style={{ flexDirection: "column" }}>
<Text className="health-monitoring-memory-value" size="3cqmin">
{memoryUsage.memUsed.GB}GiB
</Text>
<IconBrain className="health-monitoring-memory-icon" size="7cqmin" />
</Center>
}
sections={[
{
value: Number(memoryUsage.memUsed.percent),
color: progressColor(Number(memoryUsage.memUsed.percent)),
tooltip: `${memoryUsage.memUsed.percent}%`,
},
]}
/>
</Box>
)} )}
{options.memory && <MemoryRing available={healthInfo.memAvailable} used={healthInfo.memUsed} />}
</Flex> </Flex>
<Text
className="health-monitoring-status-update-time"
c="dimmed"
size="3.5cqmin"
ta="center"
mb="2.5cqmin"
>
{t("widget.healthMonitoring.popover.lastSeen", { lastSeen: dayjs(timestamp).fromNow() })}
</Text>
</Card> </Card>
{options.fileSystem && {options.fileSystem &&
disksData.map((disk) => { disksData.map((disk) => {
return ( return (
<Card <Card
className="health-monitoring-disk-card" className={`health-monitoring-disk-card health-monitoring-disk-card-${integrationName}`}
key={disk.deviceName} key={disk.deviceName}
m="2.5cqmin"
p="2.5cqmin" p="2.5cqmin"
withBorder withBorder
> >
@@ -258,14 +252,14 @@ export default function HealthMonitoringWidget({
</Text> </Text>
</Group> </Group>
</Flex> </Flex>
<Progress.Root className="health-monitoring-disk-use" size={progressSize}> <Progress.Root className="health-monitoring-disk-use" h="6cqmin">
<Tooltip label={disk.used}> <Tooltip label={disk.used}>
<Progress.Section <Progress.Section
value={disk.percentage} value={disk.percentage}
color={progressColor(disk.percentage)} color={progressColor(disk.percentage)}
className="health-monitoring-disk-use-percentage" className="health-monitoring-disk-use-percentage"
> >
<Progress.Label className="health-monitoring-disk-use-value"> <Progress.Label className="health-monitoring-disk-use-value" fz="2.5cqmin">
{t("widget.healthMonitoring.popover.used")} {t("widget.healthMonitoring.popover.used")}
</Progress.Label> </Progress.Label>
</Progress.Section> </Progress.Section>
@@ -283,8 +277,8 @@ export default function HealthMonitoringWidget({
value={100 - disk.percentage} value={100 - disk.percentage}
color="default" color="default"
> >
<Progress.Label className="health-monitoring-disk-available-value"> <Progress.Label className="health-monitoring-disk-available-value" fz="2.5cqmin">
{t("widget.healthMonitoring.popover.diskAvailable")} {t("widget.healthMonitoring.popover.available")}
</Progress.Label> </Progress.Label>
</Progress.Section> </Progress.Section>
</Tooltip> </Tooltip>
@@ -292,17 +286,20 @@ export default function HealthMonitoringWidget({
</Card> </Card>
); );
})} })}
</Box> </Stack>
); );
})} })}
</Box> </Stack>
); );
} }
export const formatUptime = (uptimeInSeconds: number, t: TranslationFunction) => { export const formatUptime = (uptimeInSeconds: number, t: TranslationFunction) => {
const days = Math.floor(uptimeInSeconds / (60 * 60 * 24)); const uptimeDuration = dayjs.duration(uptimeInSeconds, "seconds");
const remainingHours = Math.floor((uptimeInSeconds % (60 * 60 * 24)) / 3600); const days = uptimeDuration.days();
return t("widget.healthMonitoring.popover.uptime", { days, hours: remainingHours }); const hours = uptimeDuration.hours();
const minutes = uptimeDuration.minutes();
return t("widget.healthMonitoring.popover.uptime", { days, hours, minutes });
}; };
export const progressColor = (percentage: number) => { export const progressColor = (percentage: number) => {
@@ -341,6 +338,95 @@ export const matchFileSystemAndSmart = (fileSystems: FileSystem[], smartData: Sm
}); });
}; };
const CpuRing = ({ cpuUtilization }: { cpuUtilization: number }) => {
const { width, ref } = useElementSize();
return (
<Box ref={ref} w="100%" h="100%" className="health-monitoring-cpu">
<RingProgress
className="health-monitoring-cpu-utilization"
roundCaps
size={width * 0.95}
thickness={width / 10}
label={
<Center style={{ flexDirection: "column" }}>
<Text
className="health-monitoring-cpu-utilization-value"
size="3cqmin"
>{`${cpuUtilization.toFixed(2)}%`}</Text>
<IconCpu className="health-monitoring-cpu-utilization-icon" size="7cqmin" />
</Center>
}
sections={[
{
value: Number(cpuUtilization.toFixed(2)),
color: progressColor(Number(cpuUtilization.toFixed(2))),
},
]}
/>
</Box>
);
};
const CpuTempRing = ({ fahrenheit, cpuTemp }: { fahrenheit: boolean; cpuTemp: number }) => {
const { width, ref } = useElementSize();
return (
<Box ref={ref} w="100%" h="100%" className="health-monitoring-cpu-temperature">
<RingProgress
className="health-monitoring-cpu-temp"
roundCaps
size={width * 0.95}
thickness={width / 10}
label={
<Center style={{ flexDirection: "column" }}>
<Text className="health-monitoring-cpu-temp-value" size="3cqmin">
{fahrenheit ? `${(cpuTemp * 1.8 + 32).toFixed(1)}°F` : `${cpuTemp}°C`}
</Text>
<IconCpu className="health-monitoring-cpu-temp-icon" size="7cqmin" />
</Center>
}
sections={[
{
value: cpuTemp,
color: progressColor(cpuTemp),
},
]}
/>
</Box>
);
};
const MemoryRing = ({ available, used }: { available: string; used: string }) => {
const { width, ref } = useElementSize();
const memoryUsage = formatMemoryUsage(available, used);
return (
<Box ref={ref} w="100%" h="100%" className="health-monitoring-memory">
<RingProgress
className="health-monitoring-memory-use"
roundCaps
size={width * 0.95}
thickness={width / 10}
label={
<Center style={{ flexDirection: "column" }}>
<Text className="health-monitoring-memory-value" size="3cqmin">
{memoryUsage.memUsed.GB}GiB
</Text>
<IconBrain className="health-monitoring-memory-icon" size="7cqmin" />
</Center>
}
sections={[
{
value: Number(memoryUsage.memUsed.percent),
color: progressColor(Number(memoryUsage.memUsed.percent)),
tooltip: `${memoryUsage.memUsed.percent}%`,
},
]}
/>
</Box>
);
};
export const formatMemoryUsage = (memFree: string, memUsed: string) => { export const formatMemoryUsage = (memFree: string, memUsed: string) => {
const memFreeBytes = Number(memFree); const memFreeBytes = Number(memFree);
const memUsedBytes = Number(memUsed); const memUsedBytes = Number(memUsed);

View File

@@ -3,7 +3,7 @@ import { IconHeartRateMonitor, IconServerOff } from "@tabler/icons-react";
import { createWidgetDefinition } from "../definition"; import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options"; import { optionsBuilder } from "../options";
export const { definition, componentLoader, serverDataLoader } = createWidgetDefinition("healthMonitoring", { export const { definition, componentLoader } = createWidgetDefinition("healthMonitoring", {
icon: IconHeartRateMonitor, icon: IconHeartRateMonitor,
options: optionsBuilder.from((factory) => ({ options: optionsBuilder.from((factory) => ({
fahrenheit: factory.switch({ fahrenheit: factory.switch({
@@ -26,6 +26,4 @@ export const { definition, componentLoader, serverDataLoader } = createWidgetDef
message: (t) => t("widget.healthMonitoring.error.internalServerError"), message: (t) => t("widget.healthMonitoring.error.internalServerError"),
}, },
}, },
}) }).withDynamicImport(() => import("./component"));
.withServerData(() => import("./serverData"))
.withDynamicImport(() => import("./component"));

View File

@@ -1,27 +0,0 @@
"use server";
import { api } from "@homarr/api/server";
import type { WidgetProps } from "../definition";
export default async function getServerDataAsync({ integrationIds }: WidgetProps<"healthMonitoring">) {
if (integrationIds.length === 0) {
return {
initialData: [],
};
}
try {
const currentHealthInfo = await api.widget.healthMonitoring.getHealthStatus({
integrationIds,
});
return {
initialData: currentHealthInfo.filter((health) => health !== null),
};
} catch {
return {
initialData: [],
};
}
}

View File

@@ -30,8 +30,6 @@ export { reduceWidgetOptionsWithDefaultValues } from "./options";
export type { WidgetDefinition } from "./definition"; export type { WidgetDefinition } from "./definition";
export { WidgetEditModal } from "./modals/widget-edit-modal"; export { WidgetEditModal } from "./modals/widget-edit-modal";
export { useServerDataFor } from "./server/provider";
export { GlobalItemServerDataRunner } from "./server/runner";
export type { WidgetComponentProps }; export type { WidgetComponentProps };
export const widgetImports = { export const widgetImports = {

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