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_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
# @see https://next-auth.js.org/configuration/options#secret

View File

@@ -37,17 +37,17 @@
"@homarr/ui": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0",
"@homarr/widgets": "workspace:^0.1.0",
"@mantine/colors-generator": "^7.13.2",
"@mantine/core": "^7.13.2",
"@mantine/hooks": "^7.13.2",
"@mantine/modals": "^7.13.2",
"@mantine/tiptap": "^7.13.2",
"@million/lint": "1.0.9",
"@mantine/colors-generator": "^7.13.3",
"@mantine/core": "^7.13.3",
"@mantine/hooks": "^7.13.3",
"@mantine/modals": "^7.13.3",
"@mantine/tiptap": "^7.13.3",
"@million/lint": "1.0.11",
"@t3-oss/env-nextjs": "^0.11.1",
"@tabler/icons-react": "^3.19.0",
"@tanstack/react-query": "^5.59.9",
"@tanstack/react-query-devtools": "^5.59.9",
"@tanstack/react-query-next-experimental": "5.59.9",
"@tanstack/react-query": "^5.59.15",
"@tanstack/react-query-devtools": "^5.59.15",
"@tanstack/react-query-next-experimental": "5.59.15",
"@trpc/client": "next",
"@trpc/next": "next",
"@trpc/react-query": "next",
@@ -55,22 +55,22 @@
"@xterm/addon-canvas": "^0.7.0",
"@xterm/addon-fit": "0.10.0",
"@xterm/xterm": "^5.5.0",
"chroma-js": "^3.1.1",
"chroma-js": "^3.1.2",
"clsx": "^2.1.1",
"dayjs": "^1.11.13",
"dotenv": "^16.4.5",
"flag-icons": "^7.2.3",
"glob": "^11.0.0",
"jotai": "^2.10.0",
"mantine-react-table": "2.0.0-beta.6",
"jotai": "^2.10.1",
"mantine-react-table": "2.0.0-beta.7",
"next": "^14.2.15",
"postcss-preset-mantine": "^1.17.0",
"prismjs": "^1.29.0",
"react": "^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",
"sass": "^1.79.5",
"sass": "^1.80.2",
"superjson": "2.2.1",
"swagger-ui-react": "^5.17.14",
"use-deep-compare-effect": "^1.8.1"
@@ -80,7 +80,7 @@
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/chroma-js": "2.4.4",
"@types/node": "^20.16.11",
"@types/node": "^20.16.12",
"@types/prismjs": "^1.26.4",
"@types/react": "^18.3.11",
"@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 (
<DirectionProvider>
<MantineProvider
defaultColorScheme="auto"
defaultColorScheme="dark"
colorSchemeManager={manager}
theme={createTheme({
primaryColor: "red",
@@ -62,6 +62,7 @@ function useColorSchemeManager(): MantineColorSchemeManager {
},
set: (value) => {
if (value === "auto") return;
try {
if (session) {
mutateColorScheme({ colorScheme: value });

View File

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

View File

@@ -28,7 +28,6 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
layout: createBoardLayout({
headerActions: <BoardContentHeaderActions />,
getInitialBoardAsync: getInitialBoard,
isBoardContentPage: true,
}),
// eslint-disable-next-line no-restricted-syntax
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 })),
icons: {
icon: board.faviconImageUrl ? board.faviconImageUrl : undefined,
apple: board.faviconImageUrl ? board.faviconImageUrl : undefined,
},
appleWebApp: {
startupImage: { url: board.faviconImageUrl ? board.faviconImageUrl : "/logo/logo.png" },
},
};
} catch (error) {

View File

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

View File

@@ -28,8 +28,7 @@ const fontSans = Inter({
variable: "--font-sans",
});
export const metadata: Metadata = {
metadataBase: new URL("http://localhost:3000"),
export const generateMetadata = (): Metadata => ({
title: "Homarr",
description:
"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",
siteName: "Homarr Documentation",
},
twitter: {
card: "summary_large_image",
site: "@jullerino",
creator: "@jullerino",
icons: {
icon: "/logo/logo.png",
apple: "/logo/logo.png",
},
};
appleWebApp: {
title: "Homarr",
capable: true,
startupImage: { url: "/logo/logo.png" },
statusBarStyle: getColorScheme() === "dark" ? "black-translucent" : "default",
},
});
export const viewport: Viewport = {
themeColor: [
@@ -56,7 +60,7 @@ export const viewport: Viewport = {
export default async function Layout(props: { children: React.ReactNode; params: { locale: string } }) {
const session = await auth();
const colorScheme = cookies().get("homarr-color-scheme")?.value ?? "light";
const colorScheme = getColorScheme();
const tCommon = await getScopedI18n("common");
const direction = tCommon("direction");
@@ -73,7 +77,15 @@ export default async function Layout(props: { children: React.ReactNode; params:
return (
// 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>
<Analytics />
<SearchEngineOptimization />
@@ -87,3 +99,7 @@ export default async function Layout(props: { children: React.ReactNode; params:
</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 { revalidatePathActionAsync } from "@homarr/common/client";
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 { useZodForm } from "@homarr/form";
import { convertIntegrationTestConnectionError } from "@homarr/integrations/client";
@@ -32,7 +32,7 @@ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) =>
const router = useRouter();
const form = useZodForm(validation.integration.create.omit({ kind: true }), {
initialValues: {
name: searchParams.name ?? "",
name: searchParams.name ?? getIntegrationName(searchParams.kind),
url: searchParams.url ?? "",
secrets: secretKinds[0].map((kind) => ({
kind,
@@ -81,7 +81,7 @@ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) =>
return (
<form onSubmit={form.onSubmit((value) => void handleSubmitAsync(value))}>
<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")} />

View File

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

View File

@@ -47,7 +47,7 @@ export const ApiKeysManagement = ({ apiKeys }: ApiKeysManagementProps) => {
),
},
],
[],
[t],
);
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,
});
},
[user.id, mutate],
[isProviderCredentials, mutate, user.id],
);
return (

View File

@@ -14,6 +14,7 @@ import { canAccessUserEditPage } from "../access";
import { ChangeHomeBoardForm } from "./_components/_change-home-board";
import { DeleteUserButton } from "./_components/_delete-user-button";
import { FirstDayOfWeek } from "./_components/_first-day-of-week";
import { PingIconsEnabled } from "./_components/_ping-icons-enabled";
import { UserProfileAvatarForm } from "./_components/_profile-avatar-form";
import { UserProfileForm } from "./_components/_profile-form";
@@ -99,6 +100,11 @@ export default async function EditUserPage({ params }: Props) {
<FirstDayOfWeek user={user} />
</Stack>
<Stack mb="lg">
<Title order={2}>{tGeneral("item.accessibility")}</Title>
<PingIconsEnabled user={user} />
</Stack>
{isCredentialsUser && (
<DangerZoneRoot>
<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 { 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 type { Item } from "~/app/[locale]/boards/_types";
@@ -53,7 +53,6 @@ interface InnerContentProps {
const InnerContent = ({ item, ...dimensions }: InnerContentProps) => {
const board = useRequiredBoard();
const [isEditMode] = useEditMode();
const serverData = useServerDataFor(item.id);
const Comp = loadWidgetDynamic(item.kind);
const options = reduceWidgetOptionsWithDefaultValues(item.kind, item.options);
const newItem = { ...item, options };
@@ -61,8 +60,6 @@ const InnerContent = ({ item, ...dimensions }: InnerContentProps) => {
const updateOptions = ({ newOptions }: { newOptions: Record<string, unknown> }) =>
updateItemOptions({ itemId: item.id, newOptions });
if (!serverData?.isReady) return null;
return (
<QueryErrorResetBoundary>
{({ reset }) => (
@@ -79,8 +76,6 @@ const InnerContent = ({ item, ...dimensions }: InnerContentProps) => {
<Comp
options={options as never}
integrationIds={item.integrationIds}
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
serverData={serverData?.data as never}
isEditMode={isEditMode}
boardId={board.id}
itemId={item.id}

View File

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

View File

@@ -39,7 +39,7 @@ export const GridStackItem = ({
if (type !== "section") return;
innerRef.current.gridstackNode.minW = minWidth;
innerRef.current.gridstackNode.minH = minHeight;
}, [minWidth, minHeight, innerRef]);
}, [minWidth, minHeight, innerRef, type]);
return (
<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
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [itemIds.length, columnCount]);
/**

View File

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

View File

@@ -38,13 +38,13 @@
"dayjs": "^1.11.13",
"dotenv": "^16.4.5",
"superjson": "2.2.1",
"undici": "6.20.0"
"undici": "6.20.1"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "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",
"eslint": "^9.12.0",
"prettier": "^3.3.3",

View File

@@ -29,8 +29,8 @@
"@homarr/prettier-config": "workspace:^0.1.0",
"@turbo/gen": "^2.1.3",
"@vitejs/plugin-react": "^4.3.2",
"@vitest/coverage-v8": "^2.1.2",
"@vitest/ui": "^2.1.2",
"@vitest/coverage-v8": "^2.1.3",
"@vitest/ui": "^2.1.3",
"cross-env": "^7.0.3",
"jsdom": "^25.0.1",
"prettier": "^3.3.3",
@@ -38,9 +38,9 @@
"turbo": "^2.1.3",
"typescript": "^5.6.3",
"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": {
"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));
expect(user).toHaveLength(1);
expect(user[0]).toStrictEqual({
expect(user[0]).containSubset({
id: defaultOwnerId,
name: "ABC",
email: "abc@gmail.com",
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));
expect(user).toHaveLength(1);
expect(user[0]).toStrictEqual({
expect(user[0]).containSubset({
id: defaultOwnerId,
name: "ABC",
email: "myNewEmail@gmail.com",
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(),
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,
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(),
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);
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,
homeBoardId: true,
firstDayOfWeek: true,
pingIconsEnabled: true,
},
where: eq(users.id, input.userId),
});
@@ -376,6 +377,39 @@ export const userRouter = createTRPCRouter({
})
.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 }) => {
if (!ctx.session?.user) {
return 1 as const;
@@ -394,7 +428,7 @@ export const userRouter = createTRPCRouter({
changeFirstDayOfWeek: protectedProcedure
.input(validation.user.firstDayOfWeek.and(validation.common.byId))
.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) {
throw new TRPCError({
code: "NOT_FOUND",

View File

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

View File

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

View File

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

View File

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

View File

@@ -29,7 +29,7 @@
"dayjs": "^1.11.13",
"next": "^14.2.15",
"react": "^18.3.1",
"tldts": "^6.1.50"
"tldts": "^6.1.52"
},
"devDependencies": {
"@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,
"tag": "0010_melted_pestilence",
"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,
"tag": "0010_gorgeous_stingray",
"breakpoints": true
},
{
"idx": 11,
"version": "6",
"when": 1728490026154,
"tag": "0011_classy_angel",
"breakpoints": true
}
]
}

View File

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

View File

@@ -43,8 +43,9 @@ export const users = mysqlTable("user", {
homeBoardId: varchar("homeBoardId", { length: 64 }).references((): AnyMySqlColumn => boards.id, {
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
pingIconsEnabled: boolean("pingIconsEnabled").default(false).notNull(),
});
export const accounts = mysqlTable(

View File

@@ -44,8 +44,9 @@ export const users = sqliteTable("user", {
homeBoardId: text("homeBoardId").references((): AnySQLiteColumn => boards.id, {
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
pingIconsEnabled: int("pingIconsEnabled", { mode: "boolean" }).default(false).notNull(),
});
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];

View File

@@ -24,7 +24,7 @@
"dependencies": {
"@homarr/translation": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0",
"@mantine/form": "^7.13.2"
"@mantine/form": "^7.13.3"
},
"devDependencies": {
"@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 {
name: string;
subName: string;
date: Date;
dates?: { type: ReleaseType; date: Date }[];
description?: string;
thumbnail?: string;
mediaInformation?: {

View File

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

View File

@@ -30,7 +30,7 @@
"@homarr/translation": "workspace:^0.1.0",
"@homarr/ui": "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",
"dayjs": "^1.11.13",
"next": "^14.2.15",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,13 +3,13 @@ import type { ReactNode } from "react";
import type { inferSearchInteractionDefinition } from "./interaction";
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>[];
}
export interface ChildrenAction<TParentOptions extends Record<string, unknown>> {
key: string;
component: (option: TParentOptions) => JSX.Element;
Component: (option: TParentOptions) => JSX.Element;
useInteraction: (option: TParentOptions, query: string) => inferSearchInteractionDefinition<"link" | "javaScript">;
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
keyPath: keyof TOption;
title: stringOrTranslation;
component: (option: TOption) => JSX.Element;
Component: (option: TOption) => JSX.Element;
useInteraction: (option: TOption, query: string) => inferSearchInteractionDefinition<SearchInteraction>;
onKeyDown?: (
event: KeyboardEvent,

View File

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

View File

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

View File

@@ -23,7 +23,7 @@ const boardChildrenOptions = createChildrenOptions<Board>({
const actions: (ChildrenAction<Board> & { hidden?: boolean })[] = [
{
key: "open",
component: () => {
Component: () => {
const t = useI18n();
return (
@@ -37,7 +37,7 @@ const boardChildrenOptions = createChildrenOptions<Board>({
},
{
key: "homeBoard",
component: () => {
Component: () => {
const t = useI18n();
return (
@@ -61,7 +61,7 @@ const boardChildrenOptions = createChildrenOptions<Board>({
},
{
key: "settings",
component: () => {
Component: () => {
const t = useI18n();
return (
@@ -78,7 +78,7 @@ const boardChildrenOptions = createChildrenOptions<Board>({
return actions;
},
detailComponent: ({ options: board }) => {
DetailComponent: ({ options: board }) => {
const t = useI18n();
return (
@@ -102,7 +102,7 @@ const boardChildrenOptions = createChildrenOptions<Board>({
export const boardsSearchGroup = createGroup<Board>({
keyPath: "id",
title: "Boards",
component: (board) => (
Component: (board) => (
<Group px="md" py="sm">
{board.logoImageUrl ? (
<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 }>({
keyPath: "id",
title: (t) => t("search.mode.appIntegrationBoard.group.integration.title"),
component: (integration) => (
Component: (integration) => (
<Group px="md" py="sm">
<IntegrationAvatar size="sm" kind={integration.kind} />

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -26,7 +26,7 @@
"prettier": "@homarr/prettier-config",
"dependencies": {
"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"
},
"devDependencies": {

View File

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

View File

@@ -28,11 +28,11 @@
"@homarr/log": "workspace:^0.1.0",
"@homarr/translation": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0",
"@mantine/core": "^7.13.2",
"@mantine/dates": "^7.13.2",
"@mantine/hooks": "^7.13.2",
"@mantine/core": "^7.13.3",
"@mantine/dates": "^7.13.3",
"@mantine/hooks": "^7.13.3",
"@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",
"react": "^18.3.1"
},

View File

@@ -34,7 +34,7 @@ export const TablePagination = ({ total }: TablePaginationProps) => {
(control: ControlType) => {
return getItemProps(calculatePageFor(control, current, total));
},
[current],
[current, getItemProps, total],
);
const handleChange = useCallback(
@@ -43,7 +43,7 @@ export const TablePagination = ({ total }: TablePaginationProps) => {
params.set("page", page.toString());
replace(`${pathName}?${params.toString()}`);
},
[pathName, searchParams],
[pathName, replace, searchParams],
);
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),
});
const pingIconsEnabledSchema = z.object({
pingIconsEnabled: z.boolean(),
});
export const userSchemas = {
signIn: signInSchema,
registration: registrationSchema,
@@ -121,4 +125,5 @@ export const userSchemas = {
changePasswordApi: changePasswordApiSchema,
changeColorScheme: changeColorSchemeSchema,
firstDayOfWeek: firstDayOfWeekSchema,
pingIconsEnabled: pingIconsEnabledSchema,
};

View File

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

View File

@@ -1,19 +1,22 @@
"use client";
import { memo, useMemo } from "react";
import Link from "next/link";
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 type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { useI18n } from "@homarr/translation/client";
import type { CommonWidgetInputProps } from "./common";
import { useWidgetInputTranslation } from "./common";
import { useFormContext } from "./form";
export const WidgetAppInput = ({ property, kind, options }: CommonWidgetInputProps<"app">) => {
const t = useWidgetInputTranslation(kind, property);
export const WidgetAppInput = ({ property, kind }: CommonWidgetInputProps<"app">) => {
const t = useI18n();
const tInput = useWidgetInputTranslation(kind, property);
const form = useFormContext();
const { data: apps, isPending } = clientApi.app.selectable.useQuery();
@@ -24,10 +27,11 @@ export const WidgetAppInput = ({ property, kind, options }: CommonWidgetInputPro
return (
<Select
label={t("label")}
label={tInput("label")}
searchable
limit={10}
leftSection={<MemoizedLeftSection isPending={isPending} currentApp={currentApp} />}
nothingFoundMessage={t("widget.common.app.noData")}
renderOption={renderSelectOption}
data={
apps?.map((app) => ({
@@ -36,7 +40,18 @@ export const WidgetAppInput = ({ property, kind, options }: CommonWidgetInputPro
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}`)}
/>
);

View File

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

View File

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

View File

@@ -3,6 +3,7 @@
import type { PropsWithChildren } from "react";
import { Suspense } from "react";
import { Flex, Text, Tooltip, UnstyledButton } from "@mantine/core";
import { IconLoader } from "@tabler/icons-react";
import combineClasses from "clsx";
import { clientApi } from "@homarr/api/client";
@@ -59,7 +60,7 @@ export default function AppWidget({ options, isEditMode }: WidgetComponentProps<
</Flex>
</Tooltip.Floating>
{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} />
</Suspense>
) : null}

View File

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

View File

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

View File

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

View File

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

View File

@@ -6,12 +6,31 @@ import { Calendar } from "@mantine/dates";
import dayjs from "dayjs";
import { clientApi } from "@homarr/api/client";
import type { CalendarEvent } from "@homarr/integrations/types";
import type { WidgetComponentProps } from "../definition";
import { CalendarDay } from "./calender-day";
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 params = useParams();
const locale = params.locale as string;
@@ -67,9 +86,16 @@ export default function CalendarWidget({ isEditMode, serverData }: WidgetCompone
padding: 0,
},
}}
renderDay={(date) => {
const eventsForDate = (serverData?.initialData ?? []).filter((event) => dayjs(event.date).isSame(date, "day"));
return <CalendarDay date={date} events={eventsForDate} disabled={isEditMode} />;
renderDay={(tileDate) => {
const eventsForDate = events
.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 { getIntegrationKindsByCategory } from "@homarr/definitions";
import { radarrReleaseTypes } from "@homarr/integrations/types";
import { z } from "@homarr/validation";
import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options";
export const { definition, componentLoader, serverDataLoader } = createWidgetDefinition("calendar", {
export const { definition, componentLoader } = createWidgetDefinition("calendar", {
icon: IconCalendar,
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({
validate: z.number().min(2).max(9999),
defaultValue: 2,
@@ -19,6 +27,4 @@ export const { definition, componentLoader, serverDataLoader } = createWidgetDef
}),
})),
supportedIntegrations: getIntegrationKindsByCategory("calendar"),
})
.withServerData(() => import("./serverData"))
.withDynamicImport(() => import("./component"));
}).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 { inferOptionsFromDefinition, WidgetOptionsRecord } from "./options";
type ServerDataLoader<TKind extends WidgetKind> = () => Promise<{
default: (props: WidgetProps<TKind>) => Promise<Record<string, unknown>>;
}>;
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) =>
<TServerDataLoader extends ServerDataLoader<TKind>>(serverDataLoader: TServerDataLoader) => ({
(componentLoader: () => LoaderComponent<WidgetComponentProps<TKind>>) => ({
definition: {
...definition,
kind,
},
kind,
serverDataLoader,
withDynamicImport: createWithDynamicImport(kind, definition, serverDataLoader),
componentLoader,
});
export const createWidgetDefinition = <TKind extends WidgetKind, TDefinition extends WidgetDefinition>(
kind: TKind,
definition: TDefinition,
) => ({
withServerData: createWithServerData(kind, definition),
withDynamicImport: createWithDynamicImport(kind, definition, undefined),
withDynamicImport: createWithDynamicImport(kind, definition),
});
export interface WidgetDefinition {
@@ -83,15 +48,7 @@ export interface WidgetProps<TKind extends WidgetKind> {
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> & {
serverData?: inferServerDataForKind<TKind>;
} & {
boardId: string | undefined; // undefined when in preview mode
isEditMode: boolean;
setOptions: ({

View File

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

View File

@@ -7,7 +7,7 @@ import { optionsBuilder } from "../../options";
export const widgetKind = "dnsHoleControls";
export const { definition, componentLoader, serverDataLoader } = createWidgetDefinition(widgetKind, {
export const { definition, componentLoader } = createWidgetDefinition(widgetKind, {
icon: IconDeviceGamepad,
options: optionsBuilder.from((factory) => ({
showToggleAllButtons: factory.switch({
@@ -21,6 +21,4 @@ export const { definition, componentLoader, serverDataLoader } = createWidgetDef
message: (t) => t("widget.dnsHoleControls.error.internalServerError"),
},
},
})
.withServerData(() => import("./serverData"))
.withDynamicImport(() => import("./component"));
}).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";
import { useMemo, useState } from "react";
import { useMemo } from "react";
import type { BoxProps } from "@mantine/core";
import { Avatar, AvatarGroup, Box, Card, Flex, Stack, Text, Tooltip } from "@mantine/core";
import { useElementSize } from "@mantine/hooks";
@@ -20,12 +20,20 @@ import { widgetKind } from ".";
import type { WidgetComponentProps, WidgetProps } from "../../definition";
import { NoIntegrationSelectedError } from "../../errors";
export default function DnsHoleSummaryWidget({
options,
integrationIds,
serverData,
}: WidgetComponentProps<typeof widgetKind>) {
const [summaries, setSummaries] = useState(serverData?.initialData ?? []);
export default function DnsHoleSummaryWidget({ options, integrationIds }: WidgetComponentProps<typeof widgetKind>) {
const [summaries] = clientApi.widget.dnsHole.summary.useSuspenseQuery(
{
widgetKind,
integrationIds,
},
{
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
retry: false,
},
);
const utils = clientApi.useUtils();
const t = useI18n();
@@ -36,8 +44,21 @@ export default function DnsHoleSummaryWidget({
},
{
onData: (data) => {
setSummaries((prevSummaries) =>
prevSummaries.map((summary) => (summary.integration.id === data.integration.id ? data : summary)),
utils.widget.dnsHole.summary.setData(
{
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(
() =>
summaries
.filter(
(
pair,
): pair is {
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],
.filter((pair) => Math.abs(dayjs(pair.timestamp).diff()) < 30000)
.flatMap(({ summary }) => summary)
.filter((summary) => summary !== null),
[summaries],
);
if (integrationIds.length === 0) {

View File

@@ -7,7 +7,7 @@ import { optionsBuilder } from "../../options";
export const widgetKind = "dnsHoleSummary";
export const { definition, componentLoader, serverDataLoader } = createWidgetDefinition(widgetKind, {
export const { definition, componentLoader } = createWidgetDefinition(widgetKind, {
icon: IconAd,
options: optionsBuilder.from((factory) => ({
usePiHoleColors: factory.switch({
@@ -28,6 +28,4 @@ export const { definition, componentLoader, serverDataLoader } = createWidgetDef
message: (t) => t("widget.dnsHoleSummary.error.internalServerError"),
},
},
})
.withServerData(() => import("./serverData"))
.withDynamicImport(() => import("./component"));
}).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 { useMemo, useState } from "react";
import { useCallback, useMemo, useState } from "react";
import type { MantineStyleProp } from "@mantine/core";
import {
ActionIcon,
@@ -21,7 +21,7 @@ import {
Title,
Tooltip,
} from "@mantine/core";
import { useDisclosure, useListState, useTimeout } from "@mantine/hooks";
import { useDisclosure, useTimeout } from "@mantine/hooks";
import type { IconProps } from "@tabler/icons-react";
import {
IconAlertTriangle,
@@ -40,9 +40,6 @@ import { MantineReactTable, useMantineReactTable } from "mantine-react-table";
import { clientApi } from "@homarr/api/client";
import { useIntegrationsWithInteractAccess } from "@homarr/auth/client";
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 type {
DownloadClientJobsAndStatus,
@@ -91,30 +88,35 @@ export default function DownloadClientsWidget({
isEditMode,
integrationIds,
options,
serverData,
setOptions,
}: WidgetComponentProps<"downloads">) {
const integrationsWithInteractions = useIntegrationsWithInteractAccess().flatMap(({ id }) =>
integrationIds.includes(id) ? [id] : [],
);
const [currentItems, currentItemsHandlers] = useListState<{
integration: Modify<Integration, { kind: IntegrationKindByCategory<"downloadClient"> }>;
timestamp: Date;
data: DownloadClientJobsAndStatus | null;
}>(
//Automatically invalidate data older than 30 seconds
serverData?.initialData?.map((item) =>
dayjs().diff(item.timestamp) < invalidateTime ? item : { ...item, timestamp: new Date(0), data: null },
) ?? [],
const [currentItems] = clientApi.widget.downloads.getJobsAndStatuses.useSuspenseQuery(
{
integrationIds,
},
{
refetchOnMount: false,
refetchOnWindowFocus: false,
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
const invalidationTimer = useTimeout(
() => {
currentItemsHandlers.applyWhere(
() => true,
(item) => ({ ...item, timestamp: new Date(0), data: null }),
utils.widget.downloads.getJobsAndStatuses.setData({ integrationIds }, (prevData) =>
prevData?.map((item) => ({ ...item, timestamp: new Date(0), data: null })),
);
},
invalidateTime,
@@ -146,20 +148,24 @@ export default function DownloadClientsWidget({
//Don't update already invalid data (new Date (0))
.filter(({ timestamp }) => dayjs().diff(timestamp) > invalidateTime && timestamp > new Date(0))
.map(({ integration }) => integration.id);
currentItemsHandlers.applyWhere(
({ integration }) => invalidIndexes.includes(integration.id),
//Set date to now so it won't update that integration for at least 30 seconds
(item) => ({ ...item, timestamp: new Date(0), data: null }),
utils.widget.downloads.getJobsAndStatuses.setData({ integrationIds }, (prevData) =>
prevData?.map((item) =>
invalidIndexes.includes(item.integration.id) ? item : { ...item, timestamp: new Date(0), data: null },
),
);
//Find id to update
const updateIndex = currentItems.findIndex((pair) => pair.integration.id === data.integration.id);
if (updateIndex >= 0) {
//Update found index
currentItemsHandlers.setItem(updateIndex, data);
} else if (integrationIds.includes(data.integration.id)) {
//Append index not found (new integration)
currentItemsHandlers.append(data);
}
utils.widget.downloads.getJobsAndStatuses.setData({ integrationIds }, (prevData) => {
const updateIndex = currentItems.findIndex((pair) => pair.integration.id === data.integration.id);
if (updateIndex >= 0) {
//Update found index
return prevData?.map((pair, index) => (index === updateIndex ? data : pair));
} else if (integrationIds.includes(data.integration.id)) {
//Append index not found (new integration)
return [...(prevData ?? []), data];
}
return undefined;
});
//Reset no update timer
invalidationTimer.clear();
invalidationTimer.start();
@@ -227,7 +233,19 @@ export default function DownloadClientsWidget({
)
//flatMap already sorts by integration by nature, add sorting by integration type (usenet | torrent)
.sort(({ type: typeA }, { type: typeB }) => typeA.length - typeB.length),
[currentItems, integrationIds, 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.
@@ -272,7 +290,14 @@ export default function DownloadClientsWidget({
({ status: statusA }, { status: statusB }) =>
(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
@@ -327,37 +352,40 @@ export default function DownloadClientsWidget({
};
//Base element in common with all columns
const columnsDefBase = ({
key,
showHeader,
align,
}: {
key: keyof ExtendedDownloadClientItem;
showHeader: boolean;
align?: "center" | "left" | "right" | "justify" | "char";
}): MRT_ColumnDef<ExtendedDownloadClientItem> => {
const style: MantineStyleProp = {
minWidth: 0,
width: "var(--column-width)",
height: "var(--ratio-width)",
padding: "var(--space-size)",
transition: "unset",
"--key-width": columnsRatios[key],
"--column-width": "calc((var(--key-width)/var(--total-width) * 100cqw))",
};
return {
id: key,
accessorKey: key,
header: key,
size: columnsRatios[key],
mantineTableBodyCellProps: { style, align },
mantineTableHeadCellProps: {
style,
align: isEditMode ? "center" : align,
},
Header: () => (showHeader && !isEditMode ? <Text fw={700}>{t(`items.${key}.columnTitle`)}</Text> : ""),
};
};
const columnsDefBase = useCallback(
({
key,
showHeader,
align,
}: {
key: keyof ExtendedDownloadClientItem;
showHeader: boolean;
align?: "center" | "left" | "right" | "justify" | "char";
}): MRT_ColumnDef<ExtendedDownloadClientItem> => {
const style: MantineStyleProp = {
minWidth: 0,
width: "var(--column-width)",
height: "var(--ratio-width)",
padding: "var(--space-size)",
transition: "unset",
"--key-width": columnsRatios[key],
"--column-width": "calc((var(--key-width)/var(--total-width) * 100cqw))",
};
return {
id: key,
accessorKey: key,
header: key,
size: columnsRatios[key],
mantineTableBodyCellProps: { style, align },
mantineTableHeadCellProps: {
style,
align: isEditMode ? "center" : align,
},
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
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
@@ -698,10 +726,7 @@ interface ItemInfoModalProps {
}
const ItemInfoModal = ({ items, currentIndex, opened, onClose }: ItemInfoModalProps) => {
const item = useMemo<ExtendedDownloadClientItem | undefined>(
() => items[currentIndex],
[items, currentIndex, opened],
);
const item = useMemo<ExtendedDownloadClientItem | undefined>(() => items[currentIndex], [items, currentIndex]);
const t = useScopedI18n("widget.downloads.states");
//The use case for "No item found" should be impossible, hence no translation
return (

View File

@@ -31,7 +31,7 @@ const columnsSort = columnsList.filter((column) =>
sortingExclusion.some((exclusion) => exclusion !== column),
) as Exclude<typeof columnsList, (typeof sortingExclusion)[number]>;
export const { definition, componentLoader, serverDataLoader } = createWidgetDefinition("downloads", {
export const { definition, componentLoader } = createWidgetDefinition("downloads", {
icon: IconDownload,
options: optionsBuilder.from(
(factory) => ({
@@ -105,6 +105,4 @@ export const { definition, componentLoader, serverDataLoader } = createWidgetDef
},
),
supportedIntegrations: getIntegrationKindsByCategory("downloadClient"),
})
.withServerData(() => import("./serverData"))
.withDynamicImport(() => import("./component"));
}).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,
Tooltip,
} from "@mantine/core";
import { useDisclosure, useElementSize, useListState } from "@mantine/hooks";
import { useDisclosure, useElementSize } from "@mantine/hooks";
import {
IconBrain,
IconClock,
@@ -29,42 +29,91 @@ import {
IconTemperature,
IconVersions,
} 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 { useI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition";
import { NoIntegrationSelectedError } from "../errors";
export default function HealthMonitoringWidget({
options,
integrationIds,
serverData,
}: WidgetComponentProps<"healthMonitoring">) {
dayjs.extend(duration);
export default function HealthMonitoringWidget({ options, integrationIds }: WidgetComponentProps<"healthMonitoring">) {
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 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) {
throw new NoIntegrationSelectedError();
}
return (
<Box h="100%" className="health-monitoring">
{healthData.map(({ integrationId, integrationName, healthInfo }) => {
const memoryUsage = formatMemoryUsage(healthInfo.memAvailable, healthInfo.memUsed);
<Stack h="100%" gap="2.5cqmin" className="health-monitoring">
{healthData.map(({ integrationId, integrationName, healthInfo, timestamp }) => {
const disksData = matchFileSystemAndSmart(healthInfo.fileSystem, healthInfo.smart);
const { ref, width } = useElementSize();
const ringSize = width * 0.95;
const ringThickness = width / 10;
const progressSize = width * 0.2;
const memoryUsage = formatMemoryUsage(healthInfo.memAvailable, healthInfo.memUsed);
return (
<Box
<Stack
gap="2.5cqmin"
key={integrationId}
h="100%"
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
className="health-monitoring-information-card-elements"
h="100%"
@@ -102,21 +151,30 @@ export default function HealthMonitoringWidget({
className="health-monitoring-information-processor"
icon={<IconCpu2 size="1.5cqmin" />}
>
{t("widget.healthMonitoring.popover.processor")} {healthInfo.cpuModelName}
{t("widget.healthMonitoring.popover.processor", { cpuModelName: healthInfo.cpuModelName })}
</List.Item>
<List.Item
className="health-monitoring-information-memory"
icon={<IconBrain size="1.5cqmin" />}
>
{t("widget.healthMonitoring.popover.memory")} {memoryUsage.memTotal.GB}GiB -{" "}
{t("widget.healthMonitoring.popover.memAvailable")} {memoryUsage.memFree.GB}GiB (
{memoryUsage.memFree.percent}%)
{t("widget.healthMonitoring.popover.memory", { memory: memoryUsage.memTotal.GB })}
</List.Item>
<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
className="health-monitoring-information-version"
icon={<IconVersions size="1.5cqmin" />}
>
{t("widget.healthMonitoring.popover.version")} {healthInfo.version}
{t("widget.healthMonitoring.popover.version", {
version: healthInfo.version,
})}
</List.Item>
<List.Item
className="health-monitoring-information-uptime"
@@ -147,92 +205,28 @@ export default function HealthMonitoringWidget({
</Stack>
</Modal>
</Box>
{options.cpu && (
<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>
)}
{options.cpu && <CpuRing cpuUtilization={healthInfo.cpuUtilization} />}
{healthInfo.cpuTemp && options.cpu && (
<Box ref={ref} w="100%" h="100%" className="health-monitoring-cpu-temperature">
<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>
<CpuTempRing fahrenheit={options.fahrenheit} cpuTemp={healthInfo.cpuTemp} />
)}
{options.memory && <MemoryRing available={healthInfo.memAvailable} used={healthInfo.memUsed} />}
</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>
{options.fileSystem &&
disksData.map((disk) => {
return (
<Card
className="health-monitoring-disk-card"
className={`health-monitoring-disk-card health-monitoring-disk-card-${integrationName}`}
key={disk.deviceName}
m="2.5cqmin"
p="2.5cqmin"
withBorder
>
@@ -258,14 +252,14 @@ export default function HealthMonitoringWidget({
</Text>
</Group>
</Flex>
<Progress.Root className="health-monitoring-disk-use" size={progressSize}>
<Progress.Root className="health-monitoring-disk-use" h="6cqmin">
<Tooltip label={disk.used}>
<Progress.Section
value={disk.percentage}
color={progressColor(disk.percentage)}
className="health-monitoring-disk-use-percentage"
>
<Progress.Label className="health-monitoring-disk-use-value">
<Progress.Label className="health-monitoring-disk-use-value" fz="2.5cqmin">
{t("widget.healthMonitoring.popover.used")}
</Progress.Label>
</Progress.Section>
@@ -283,8 +277,8 @@ export default function HealthMonitoringWidget({
value={100 - disk.percentage}
color="default"
>
<Progress.Label className="health-monitoring-disk-available-value">
{t("widget.healthMonitoring.popover.diskAvailable")}
<Progress.Label className="health-monitoring-disk-available-value" fz="2.5cqmin">
{t("widget.healthMonitoring.popover.available")}
</Progress.Label>
</Progress.Section>
</Tooltip>
@@ -292,17 +286,20 @@ export default function HealthMonitoringWidget({
</Card>
);
})}
</Box>
</Stack>
);
})}
</Box>
</Stack>
);
}
export const formatUptime = (uptimeInSeconds: number, t: TranslationFunction) => {
const days = Math.floor(uptimeInSeconds / (60 * 60 * 24));
const remainingHours = Math.floor((uptimeInSeconds % (60 * 60 * 24)) / 3600);
return t("widget.healthMonitoring.popover.uptime", { days, hours: remainingHours });
const uptimeDuration = dayjs.duration(uptimeInSeconds, "seconds");
const days = uptimeDuration.days();
const hours = uptimeDuration.hours();
const minutes = uptimeDuration.minutes();
return t("widget.healthMonitoring.popover.uptime", { days, hours, minutes });
};
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) => {
const memFreeBytes = Number(memFree);
const memUsedBytes = Number(memUsed);

View File

@@ -3,7 +3,7 @@ import { IconHeartRateMonitor, IconServerOff } from "@tabler/icons-react";
import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options";
export const { definition, componentLoader, serverDataLoader } = createWidgetDefinition("healthMonitoring", {
export const { definition, componentLoader } = createWidgetDefinition("healthMonitoring", {
icon: IconHeartRateMonitor,
options: optionsBuilder.from((factory) => ({
fahrenheit: factory.switch({
@@ -26,6 +26,4 @@ export const { definition, componentLoader, serverDataLoader } = createWidgetDef
message: (t) => t("widget.healthMonitoring.error.internalServerError"),
},
},
})
.withServerData(() => import("./serverData"))
.withDynamicImport(() => import("./component"));
}).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 { WidgetEditModal } from "./modals/widget-edit-modal";
export { useServerDataFor } from "./server/provider";
export { GlobalItemServerDataRunner } from "./server/runner";
export type { WidgetComponentProps };
export const widgetImports = {

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