chore(release): automatic release v0.1.0

This commit is contained in:
homarr-releases[bot]
2024-10-04 19:12:57 +00:00
committed by GitHub
86 changed files with 5769 additions and 1104 deletions

1
.gitignore vendored
View File

@@ -56,6 +56,7 @@ yarn-error.log*
apps/tasks/tasks.cjs
apps/websocket/wssServer.cjs
apps/nextjs/.million/
packages/cli/cli.cjs
#personal backgrounds

2
.nvmrc
View File

@@ -1 +1 @@
20.17.0
20.18.0

View File

@@ -1,4 +1,4 @@
FROM node:20.17.0-alpine AS base
FROM node:20.18.0-alpine AS base
FROM base AS builder
RUN apk add --no-cache libc6-compat
@@ -46,6 +46,7 @@ COPY --from=builder /app/cli-out/full/ .
# Copy static data as it is not part of the build
COPY static-data ./static-data
ARG SKIP_ENV_VALIDATION='true'
ARG CI='true'
ARG DISABLE_REDIS_LOGS='true'
RUN corepack enable pnpm && pnpm build
@@ -58,6 +59,8 @@ RUN mkdir /appdata
RUN mkdir /appdata/db
RUN mkdir /appdata/redis
VOLUME /appdata
RUN mkdir /secrets
VOLUME /secrets
@@ -71,6 +74,7 @@ RUN chmod +x /usr/bin/homarr
# Don't run production as root
RUN chown -R nextjs:nodejs /appdata
RUN chown -R nextjs:nodejs /secrets
RUN mkdir -p /var/cache/nginx && chown -R nextjs:nodejs /var/cache/nginx && \
mkdir -p /var/log/nginx && chown -R nextjs:nodejs /var/log/nginx && \
mkdir -p /var/lib/nginx && chown -R nextjs:nodejs /var/lib/nginx && \
@@ -93,6 +97,7 @@ COPY --from=installer --chown=nextjs:nodejs /app/apps/nextjs/.next/standalone ./
COPY --from=installer --chown=nextjs:nodejs /app/apps/nextjs/.next/static ./apps/nextjs/.next/static
COPY --from=installer --chown=nextjs:nodejs /app/apps/nextjs/public ./apps/nextjs/public
COPY --chown=nextjs:nodejs scripts/run.sh ./run.sh
COPY --chown=nextjs:nodejs scripts/generateEncryptionKey.js ./generateEncryptionKey.js
COPY --chown=nextjs:nodejs packages/redis/redis.conf /app/redis.conf
COPY --chown=nextjs:nodejs nginx.conf /etc/nginx/templates/nginx.conf

View File

@@ -30,23 +30,24 @@
"@homarr/modals-collection": "workspace:^0.1.0",
"@homarr/notifications": "workspace:^0.1.0",
"@homarr/old-schema": "workspace:^0.1.0",
"@homarr/redis": "workspace:^0.1.0",
"@homarr/server-settings": "workspace:^0.1.0",
"@homarr/spotlight": "workspace:^0.1.0",
"@homarr/translation": "workspace:^0.1.0",
"@homarr/ui": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0",
"@homarr/widgets": "workspace:^0.1.0",
"@mantine/colors-generator": "^7.13.0",
"@mantine/core": "^7.13.0",
"@mantine/hooks": "^7.13.0",
"@mantine/modals": "^7.13.0",
"@mantine/tiptap": "^7.13.0",
"@million/lint": "1.0.0-rc.84",
"@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.8",
"@t3-oss/env-nextjs": "^0.11.1",
"@tabler/icons-react": "^3.18.0",
"@tanstack/react-query": "^5.56.2",
"@tanstack/react-query-devtools": "^5.58.0",
"@tanstack/react-query-next-experimental": "5.56.2",
"@tabler/icons-react": "^3.19.0",
"@tanstack/react-query": "^5.59.0",
"@tanstack/react-query-devtools": "^5.59.0",
"@tanstack/react-query-next-experimental": "5.59.0",
"@trpc/client": "next",
"@trpc/next": "next",
"@trpc/react-query": "next",
@@ -62,14 +63,14 @@
"glob": "^11.0.0",
"jotai": "^2.10.0",
"mantine-react-table": "2.0.0-beta.6",
"next": "^14.2.13",
"next": "^14.2.14",
"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-simple-code-editor": "^0.14.1",
"sass": "^1.79.3",
"sass": "^1.79.4",
"superjson": "2.2.1",
"swagger-ui-react": "^5.17.14",
"use-deep-compare-effect": "^1.8.1"
@@ -81,7 +82,7 @@
"@types/chroma-js": "2.4.4",
"@types/node": "^20.16.10",
"@types/prismjs": "^1.26.4",
"@types/react": "^18.3.10",
"@types/react": "^18.3.11",
"@types/react-dom": "^18.3.0",
"@types/swagger-ui-react": "^4.18.3",
"concurrently": "^9.0.1",

View File

@@ -105,7 +105,7 @@ const AppNoResults = async () => {
<Text fw={500} size="lg">
{t("app.page.list.noResults.title")}
</Text>
<Anchor href="/manage/apps/new">{t("app.page.list.noResults.description")}</Anchor>
<Anchor href="/manage/apps/new">{t("app.page.list.noResults.action")}</Anchor>
</Stack>
</Card>
);

View File

@@ -15,6 +15,7 @@ import {
IconPlug,
IconQuestionMark,
IconReport,
IconSearch,
IconSettings,
IconTool,
IconUser,
@@ -53,6 +54,11 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
href: "/manage/integrations",
label: t("items.integrations"),
},
{
icon: IconSearch,
href: "/manage/search-engines",
label: t("items.searchEngies"),
},
{
icon: IconUser,
label: t("items.users.label"),

View File

@@ -0,0 +1,73 @@
"use client";
import Link from "next/link";
import { Button, Grid, Group, Stack, Textarea, TextInput } from "@mantine/core";
import { useZodForm } from "@homarr/form";
import type { TranslationFunction } from "@homarr/translation";
import { useI18n } from "@homarr/translation/client";
import type { z } from "@homarr/validation";
import { validation } from "@homarr/validation";
import { IconPicker } from "~/components/icons/picker/icon-picker";
type FormType = z.infer<typeof validation.searchEngine.manage>;
interface SearchEngineFormProps {
submitButtonTranslation: (t: TranslationFunction) => string;
initialValues?: FormType;
handleSubmit: (values: FormType) => void;
isPending: boolean;
disableShort?: boolean;
}
export const SearchEngineForm = (props: SearchEngineFormProps) => {
const { submitButtonTranslation, handleSubmit, initialValues, isPending, disableShort } = props;
const t = useI18n();
const form = useZodForm(validation.searchEngine.manage, {
initialValues: initialValues ?? {
name: "",
short: "",
iconUrl: "",
urlTemplate: "",
description: "",
},
});
return (
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack>
<Grid>
<Grid.Col span={{ base: 12, md: 8, lg: 9, xl: 10 }}>
<TextInput {...form.getInputProps("name")} withAsterisk label={t("search.engine.field.name.label")} />
</Grid.Col>
<Grid.Col span={{ base: 12, md: 4, lg: 3, xl: 2 }}>
<TextInput
{...form.getInputProps("short")}
disabled={disableShort}
withAsterisk
label={t("search.engine.field.short.label")}
/>
</Grid.Col>
</Grid>
<IconPicker initialValue={initialValues?.iconUrl} {...form.getInputProps("iconUrl")} />
<TextInput
{...form.getInputProps("urlTemplate")}
withAsterisk
label={t("search.engine.field.urlTemplate.label")}
/>
<Textarea {...form.getInputProps("description")} label={t("search.engine.field.description.label")} />
<Group justify="end">
<Button variant="default" component={Link} href="/manage/search-engines">
{t("common.action.backToOverview")}
</Button>
<Button type="submit" loading={isPending}>
{submitButtonTranslation(t)}
</Button>
</Group>
</Stack>
</form>
);
};

View File

@@ -0,0 +1,55 @@
"use client";
import { useCallback } from "react";
import { ActionIcon } from "@mantine/core";
import { IconTrash } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useConfirmModal } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
interface SearchEngineDeleteButtonProps {
searchEngine: RouterOutputs["searchEngine"]["getPaginated"]["items"][number];
}
export const SearchEngineDeleteButton = ({ searchEngine }: SearchEngineDeleteButtonProps) => {
const t = useScopedI18n("search.engine.page.delete");
const { openConfirmModal } = useConfirmModal();
const { mutate, isPending } = clientApi.searchEngine.delete.useMutation();
const onClick = useCallback(() => {
openConfirmModal({
title: t("title"),
children: t("message", searchEngine),
onConfirm: () => {
mutate(
{ id: searchEngine.id },
{
onSuccess: () => {
showSuccessNotification({
title: t("notification.success.title"),
message: t("notification.success.message"),
});
void revalidatePathActionAsync("/manage/search-engines");
},
onError: () => {
showErrorNotification({
title: t("notification.error.title"),
message: t("notification.error.message"),
});
},
},
);
},
});
}, [searchEngine, mutate, t, openConfirmModal]);
return (
<ActionIcon loading={isPending} variant="subtle" color="red" onClick={onClick} aria-label={t("title")}>
<IconTrash color="red" size={16} stroke={1.5} />
</ActionIcon>
);
};

View File

@@ -0,0 +1,63 @@
"use client";
import { useCallback } from "react";
import { useRouter } from "next/navigation";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import type { TranslationFunction } from "@homarr/translation";
import { useScopedI18n } from "@homarr/translation/client";
import type { validation, z } from "@homarr/validation";
import { SearchEngineForm } from "../../_form";
interface SearchEngineEditFormProps {
searchEngine: RouterOutputs["searchEngine"]["byId"];
}
export const SearchEngineEditForm = ({ searchEngine }: SearchEngineEditFormProps) => {
const t = useScopedI18n("search.engine.page.edit.notification");
const router = useRouter();
const { mutate, isPending } = clientApi.searchEngine.update.useMutation({
onSuccess: () => {
showSuccessNotification({
title: t("success.title"),
message: t("success.message"),
});
void revalidatePathActionAsync("/manage/search-engines").then(() => {
router.push("/manage/search-engines");
});
},
onError: () => {
showErrorNotification({
title: t("error.title"),
message: t("error.message"),
});
},
});
const handleSubmit = useCallback(
(values: z.infer<typeof validation.searchEngine.manage>) => {
mutate({
id: searchEngine.id,
...values,
});
},
[mutate, searchEngine.id],
);
const submitButtonTranslation = useCallback((t: TranslationFunction) => t("common.action.save"), []);
return (
<SearchEngineForm
submitButtonTranslation={submitButtonTranslation}
initialValues={searchEngine}
handleSubmit={handleSubmit}
isPending={isPending}
disableShort
/>
);
};

View File

@@ -0,0 +1,27 @@
import { Stack, Title } from "@mantine/core";
import { api } from "@homarr/api/server";
import { getI18n } from "@homarr/translation/server";
import { ManageContainer } from "~/components/manage/manage-container";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { SearchEngineEditForm } from "./_search-engine-edit-form";
interface SearchEngineEditPageProps {
params: { id: string };
}
export default async function SearchEngineEditPage({ params }: SearchEngineEditPageProps) {
const searchEngine = await api.searchEngine.byId({ id: params.id });
const t = await getI18n();
return (
<ManageContainer>
<DynamicBreadcrumb dynamicMappings={new Map([[params.id, searchEngine.name]])} nonInteractable={["edit"]} />
<Stack>
<Title>{t("search.engine.page.edit.title")}</Title>
<SearchEngineEditForm searchEngine={searchEngine} />
</Stack>
</ManageContainer>
);
}

View File

@@ -0,0 +1,52 @@
"use client";
import { useCallback } from "react";
import { useRouter } from "next/navigation";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import type { TranslationFunction } from "@homarr/translation";
import { useScopedI18n } from "@homarr/translation/client";
import type { validation, z } from "@homarr/validation";
import { SearchEngineForm } from "../_form";
export const SearchEngineNewForm = () => {
const t = useScopedI18n("search.engine.page.create.notification");
const router = useRouter();
const { mutate, isPending } = clientApi.searchEngine.create.useMutation({
onSuccess: async () => {
showSuccessNotification({
title: t("success.title"),
message: t("success.message"),
});
await revalidatePathActionAsync("/manage/search-engines");
router.push("/manage/search-engines");
},
onError: () => {
showErrorNotification({
title: t("error.title"),
message: t("error.message"),
});
},
});
const handleSubmit = useCallback(
(values: z.infer<typeof validation.searchEngine.manage>) => {
mutate(values);
},
[mutate],
);
const submitButtonTranslation = useCallback((t: TranslationFunction) => t("common.action.create"), []);
return (
<SearchEngineForm
submitButtonTranslation={submitButtonTranslation}
handleSubmit={handleSubmit}
isPending={isPending}
/>
);
};

View File

@@ -0,0 +1,21 @@
import { Stack, Title } from "@mantine/core";
import { getI18n } from "@homarr/translation/server";
import { ManageContainer } from "~/components/manage/manage-container";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { SearchEngineNewForm } from "./_search-engine-new-form";
export default async function SearchEngineNewPage() {
const t = await getI18n();
return (
<ManageContainer>
<DynamicBreadcrumb />
<Stack>
<Title>{t("search.engine.page.create.title")}</Title>
<SearchEngineNewForm />
</Stack>
</ManageContainer>
);
}

View File

@@ -0,0 +1,139 @@
import Link from "next/link";
import { ActionIcon, ActionIconGroup, Anchor, Avatar, Card, Group, Stack, Text, Title } from "@mantine/core";
import { IconPencil, IconSearch } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { api } from "@homarr/api/server";
import { getI18n, getScopedI18n } from "@homarr/translation/server";
import { SearchInput, TablePagination } from "@homarr/ui";
import { z } from "@homarr/validation";
import { ManageContainer } from "~/components/manage/manage-container";
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { SearchEngineDeleteButton } from "./_search-engine-delete-button";
const searchParamsSchema = z.object({
search: z.string().optional(),
pageSize: z.string().regex(/\d+/).transform(Number).catch(10),
page: z.string().regex(/\d+/).transform(Number).catch(1),
});
type SearchParamsSchemaInputFromSchema<TSchema extends Record<string, unknown>> = Partial<{
[K in keyof TSchema]: Exclude<TSchema[K], undefined> extends unknown[] ? string[] : string;
}>;
interface SearchEnginesPageProps {
searchParams: SearchParamsSchemaInputFromSchema<z.infer<typeof searchParamsSchema>>;
}
export default async function SearchEnginesPage(props: SearchEnginesPageProps) {
const searchParams = searchParamsSchema.parse(props.searchParams);
const { items: searchEngines, totalCount } = await api.searchEngine.getPaginated(searchParams);
const t = await getI18n();
const tEngine = await getScopedI18n("search.engine");
return (
<ManageContainer>
<DynamicBreadcrumb />
<Stack>
<Title>{tEngine("page.list.title")}</Title>
<Group justify="space-between" align="center">
<SearchInput
placeholder={t("common.rtl", {
value: tEngine("search"),
symbol: "...",
})}
defaultValue={searchParams.search}
/>
<MobileAffixButton component={Link} href="/manage/search-engines/new">
{tEngine("page.create.title")}
</MobileAffixButton>
</Group>
{searchEngines.length === 0 && <SearchEngineNoResults />}
{searchEngines.length > 0 && (
<Stack gap="sm">
{searchEngines.map((searchEngine) => (
<SearchEngineCard key={searchEngine.id} searchEngine={searchEngine} />
))}
</Stack>
)}
<Group justify="end">
<TablePagination total={Math.ceil(totalCount / searchParams.pageSize)} />
</Group>
</Stack>
</ManageContainer>
);
}
interface SearchEngineCardProps {
searchEngine: RouterOutputs["searchEngine"]["getPaginated"]["items"][number];
}
const SearchEngineCard = async ({ searchEngine }: SearchEngineCardProps) => {
const t = await getScopedI18n("search.engine");
return (
<Card>
<Group justify="space-between" wrap="nowrap">
<Group align="top" justify="start" wrap="nowrap" style={{ flex: 1 }}>
<Avatar
size="sm"
src={searchEngine.iconUrl}
radius={0}
styles={{
image: {
objectFit: "contain",
},
}}
/>
<Stack gap={0}>
<Text fw={500} lineClamp={1}>
{searchEngine.name}
</Text>
{searchEngine.description && (
<Text size="sm" c="gray.6" lineClamp={4}>
{searchEngine.description}
</Text>
)}
<Anchor href={searchEngine.urlTemplate.replace("%s", "test")} lineClamp={1} size="sm">
{searchEngine.urlTemplate}
</Anchor>
</Stack>
</Group>
<Group>
<ActionIconGroup>
<ActionIcon
component={Link}
href={`/manage/search-engines/edit/${searchEngine.id}`}
variant="subtle"
color="gray"
aria-label={t("page.edit.title")}
>
<IconPencil size={16} stroke={1.5} />
</ActionIcon>
<SearchEngineDeleteButton searchEngine={searchEngine} />
</ActionIconGroup>
</Group>
</Group>
</Card>
);
};
const SearchEngineNoResults = async () => {
const t = await getI18n();
return (
<Card withBorder bg="transparent">
<Stack align="center" gap="sm">
<IconSearch size="2rem" />
<Text fw={500} size="lg">
{t("search.engine.page.list.noResults.title")}
</Text>
<Anchor href="/manage/search-engines/new">{t("search.engine.page.list.noResults.action")}</Anchor>
</Stack>
</Card>
);
};

View File

@@ -2,7 +2,7 @@
import { useMemo } from "react";
import Link from "next/link";
import { Anchor, Button, Group, Text, ThemeIcon, Title } from "@mantine/core";
import { Anchor, Button, Group, Text, Title, Tooltip } from "@mantine/core";
import { IconCheck } from "@tabler/icons-react";
import type { MRT_ColumnDef } from "mantine-react-table";
import { MantineReactTable } from "mantine-react-table";
@@ -15,9 +15,10 @@ import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
interface UserListComponentProps {
initialUserList: RouterOutputs["user"]["getAll"];
credentialsProviderEnabled: boolean;
}
export const UserListComponent = ({ initialUserList }: UserListComponentProps) => {
export const UserListComponent = ({ initialUserList, credentialsProviderEnabled }: UserListComponentProps) => {
const tUserList = useScopedI18n("management.page.user.list");
const t = useI18n();
const { data, isLoading } = clientApi.user.getAll.useQuery(undefined, {
@@ -29,7 +30,7 @@ export const UserListComponent = ({ initialUserList }: UserListComponentProps) =
{
accessorKey: "name",
header: t("user.field.username.label"),
grow: 100,
grow: 1,
Cell: ({ renderedCellValue, row }) => (
<Group>
<UserAvatar size="sm" user={row.original} />
@@ -42,13 +43,14 @@ export const UserListComponent = ({ initialUserList }: UserListComponentProps) =
{
accessorKey: "email",
header: t("user.field.email.label"),
size: 300,
Cell: ({ renderedCellValue, row }) => (
<Group>
<Group wrap="nowrap" gap="sm">
{row.original.email ? renderedCellValue : <Text>-</Text>}
{row.original.emailVerified && (
<ThemeIcon radius="xl" size="sm">
<IconCheck size="1rem" />
</ThemeIcon>
<Tooltip label={t("user.field.email.verified")} position="top">
<IconCheck color="var(--mantine-color-green-4)" size="1rem" />
</Tooltip>
)}
</Group>
),
@@ -68,11 +70,12 @@ export const UserListComponent = ({ initialUserList }: UserListComponentProps) =
enableFullScreenToggle: false,
layoutMode: "grid-no-grow",
getRowId: (row) => row.id,
renderTopToolbarCustomActions: () => (
<Button component={Link} href="/manage/users/create">
Create New User
</Button>
),
renderTopToolbarCustomActions: () =>
credentialsProviderEnabled ? (
<Button component={Link} href="/manage/users/create">
{t("management.page.user.create.title")}
</Button>
) : null,
state: {
isLoading,
},

View File

@@ -1,3 +1,6 @@
import { notFound } from "next/navigation";
import { isProviderEnabled } from "@homarr/auth/server";
import { getScopedI18n } from "@homarr/translation/server";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
@@ -5,6 +8,8 @@ import { createMetaTitle } from "~/metadata";
import { UserCreateStepperComponent } from "./_components/create-user-stepper";
export async function generateMetadata() {
if (!isProviderEnabled("credentials")) return {};
const t = await getScopedI18n("management.page.user.create");
return {
@@ -13,6 +18,10 @@ export async function generateMetadata() {
}
export default function CreateUserPage() {
if (!isProviderEnabled("credentials")) {
notFound();
}
return (
<>
<DynamicBreadcrumb />

View File

@@ -1,4 +1,5 @@
import { api } from "@homarr/api/server";
import { isProviderEnabled } from "@homarr/auth/server";
import { getScopedI18n } from "@homarr/translation/server";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
@@ -15,10 +16,12 @@ export async function generateMetadata() {
export default async function UsersPage() {
const userList = await api.user.getAll();
const credentialsProviderEnabled = isProviderEnabled("credentials");
return (
<>
<DynamicBreadcrumb />
<UserListComponent initialUserList={userList} />
<UserListComponent initialUserList={userList} credentialsProviderEnabled={credentialsProviderEnabled} />
</>
);
}

View File

@@ -0,0 +1,91 @@
import { performance } from "perf_hooks";
import { db } from "@homarr/db";
import { logger } from "@homarr/log";
import { handshakeAsync } from "@homarr/redis";
export async function GET() {
const timeBeforeHealthCheck = performance.now();
const response = await executeAndAggregateAllHealthChecksAsync();
logger.info(`Completed healthcheck after ${performance.now() - timeBeforeHealthCheck}ms`);
if (response.status === "healthy") {
return new Response(JSON.stringify(response), {
status: 200,
});
}
return new Response(JSON.stringify(response), {
status: 500,
});
}
const executeAndAggregateAllHealthChecksAsync = async (): Promise<{
healthChecks: Record<string, object>;
status: "healthy" | "unhealthy";
}> => {
const healthChecks = [
executeHealthCheckSafelyAsync("database", async () => {
// sqlite driver does not support raw query execution. this is for a heartbeat check only - it doesn't matter if data is returned or not
await db.query.serverSettings.findFirst();
return {};
}),
executeHealthCheckSafelyAsync("redis", async () => {
await handshakeAsync();
return {};
}),
];
const healthCheckResults = await Promise.all(healthChecks);
const anyUnhealthy = healthCheckResults.some((healthCheck) => healthCheck.status === "unhealthy");
const healthCheckValues = healthCheckResults.reduce(
(acc, healthCheck) => {
acc[healthCheck.name] = {
status: healthCheck.status,
...healthCheck.values,
};
return acc;
},
{} as Record<string, object>,
);
return {
status: anyUnhealthy ? "unhealthy" : "healthy",
healthChecks: healthCheckValues,
};
};
const executeHealthCheckSafelyAsync = async (
name: string,
callback: () => Promise<object>,
): Promise<HealthCheckResult> => {
try {
const currentTimeBeforeCallback = performance.now();
const values = await callback();
return {
name,
status: "healthy",
values: {
...values,
latency: performance.now() - currentTimeBeforeCallback,
},
};
} catch (error) {
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
logger.error(`Healthcheck '${name}' has failed: ${error}`);
return {
status: "unhealthy",
values: {
error,
},
name,
};
}
};
interface HealthCheckResult {
status: "healthy" | "unhealthy";
name: string;
values: object;
}

View File

@@ -0,0 +1,5 @@
export function GET() {
return new Response(undefined, {
status: 200,
});
}

View File

@@ -195,6 +195,32 @@ export const useGridstack = (section: Omit<Section, "items">, itemIds: string[])
[moveItemToSection, moveInnerSectionToSection, section.id],
);
// initialize the gridstack
useEffect(() => {
const isReady = initializeGridstack({
section,
itemIds,
refs: {
items: itemRefs,
wrapper: wrapperRef,
gridstack: gridRef,
},
sectionColumnCount: columnCount,
});
// If the section is ready mark it as ready
// When all sections are ready the board is ready and will get visible
if (isReady) {
markAsReady(section.id);
}
// Only run this effect when the section items change
}, [itemIds.length, columnCount]);
/**
* IMPORTANT: This effect has to be placed after the effect to initialize the gridstack
* because we need the gridstack object to add the listeners
*/
useEffect(() => {
if (!isEditMode) return;
const currentGrid = gridRef.current;
@@ -231,28 +257,6 @@ export const useGridstack = (section: Omit<Section, "items">, itemIds: string[])
};
}, [isEditMode, onAdd, onChange]);
// initialize the gridstack
useEffect(() => {
const isReady = initializeGridstack({
section,
itemIds,
refs: {
items: itemRefs,
wrapper: wrapperRef,
gridstack: gridRef,
},
sectionColumnCount: columnCount,
});
// If the section is ready mark it as ready
// When all sections are ready the board is ready and will get visible
if (isReady) {
markAsReady(section.id);
}
// Only run this effect when the section items change
}, [itemIds.length, columnCount]);
const sectionHeight = section.kind === "dynamic" && "height" in section ? (section.height as number) : null;
// We want the amount of rows in a dynamic section to be the height of the section in the outer gridstack

View File

@@ -9,8 +9,21 @@ class LoggingAgent extends Agent {
}
dispatch(options: Dispatcher.DispatchOptions, handler: Dispatcher.DispatchHandlers): boolean {
const url = new URL(`${options.origin as string}${options.path}`);
// The below code should prevent sensitive data from being logged as
// some integrations use query parameters for auth
url.searchParams.forEach((value, key) => {
if (value === "") return; // Skip empty values
if (/^\d{1,12}$/.test(value)) return; // Skip small numbers
if (value === "true" || value === "false") return; // Skip boolean values
if (/^[a-zA-Z]{1,12}$/.test(value)) return; // Skip short strings
url.searchParams.set(key, "REDACTED");
});
logger.info(
`Dispatching request ${options.method} ${options.origin as string}${options.path} (${Object.keys(options.headers as object).length} headers)`,
`Dispatching request ${url.toString().replaceAll("=&", "&")} (${Object.keys(options.headers as object).length} headers)`,
);
return super.dispatch(options, handler);
}

View File

@@ -27,22 +27,22 @@
"prettier": "@homarr/prettier-config",
"devDependencies": {
"@homarr/prettier-config": "workspace:^0.1.0",
"@turbo/gen": "^2.1.2",
"@vitejs/plugin-react": "^4.3.1",
"@vitest/coverage-v8": "^2.1.1",
"@vitest/ui": "^2.1.1",
"@turbo/gen": "^2.1.3",
"@vitejs/plugin-react": "^4.3.2",
"@vitest/coverage-v8": "^2.1.2",
"@vitest/ui": "^2.1.2",
"cross-env": "^7.0.3",
"jsdom": "^25.0.1",
"prettier": "^3.3.3",
"testcontainers": "^10.13.1",
"turbo": "^2.1.2",
"testcontainers": "^10.13.2",
"turbo": "^2.1.3",
"typescript": "^5.6.2",
"vite-tsconfig-paths": "^5.0.1",
"vitest": "^2.1.1"
"vitest": "^2.1.2"
},
"packageManager": "pnpm@9.11.0",
"packageManager": "pnpm@9.12.0",
"engines": {
"node": ">=20.17.0"
"node": ">=20.18.0"
},
"pnpm": {
"patchedDependencies": {

View File

@@ -39,7 +39,7 @@
"@trpc/react-query": "next",
"@trpc/server": "next",
"dockerode": "^4.0.2",
"next": "^14.2.13",
"next": "^14.2.14",
"react": "^18.3.1",
"superjson": "2.2.1",
"trpc-swagger": "^1.2.6"

View File

@@ -9,6 +9,7 @@ import { integrationRouter } from "./router/integration/integration-router";
import { inviteRouter } from "./router/invite";
import { locationRouter } from "./router/location";
import { logRouter } from "./router/log";
import { searchEngineRouter } from "./router/search-engine/search-engine-router";
import { serverSettingsRouter } from "./router/serverSettings";
import { userRouter } from "./router/user";
import { widgetRouter } from "./router/widgets";
@@ -21,6 +22,7 @@ export const appRouter = createTRPCRouter({
integration: integrationRouter,
board: boardRouter,
app: innerAppRouter,
searchEngine: searchEngineRouter,
widget: widgetRouter,
location: locationRouter,
log: logRouter,

View File

@@ -31,7 +31,7 @@ export const appRouter = createTRPCRouter({
limit: input.limit,
});
}),
byId: publicProcedure.input(validation.app.byId).query(async ({ ctx, input }) => {
byId: publicProcedure.input(validation.common.byId).query(async ({ ctx, input }) => {
const app = await ctx.db.query.apps.findFirst({
where: eq(apps.id, input.id),
});
@@ -76,7 +76,7 @@ export const appRouter = createTRPCRouter({
})
.where(eq(apps.id, input.id));
}),
delete: publicProcedure.input(validation.app.byId).mutation(async ({ ctx, input }) => {
delete: publicProcedure.input(validation.common.byId).mutation(async ({ ctx, input }) => {
await ctx.db.delete(apps).where(eq(apps.id, input.id));
}),
});

View File

@@ -8,7 +8,7 @@ import { validation, z } from "@homarr/validation";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
export const groupRouter = createTRPCRouter({
getPaginated: protectedProcedure.input(validation.group.paginated).query(async ({ input, ctx }) => {
getPaginated: protectedProcedure.input(validation.common.paginated).query(async ({ input, ctx }) => {
const whereQuery = input.search ? like(groups.name, `%${input.search.trim()}%`) : undefined;
const groupCount = await ctx.db
.select({
@@ -45,7 +45,7 @@ export const groupRouter = createTRPCRouter({
totalCount: groupCount[0]?.count ?? 0,
};
}),
getById: protectedProcedure.input(validation.group.byId).query(async ({ input, ctx }) => {
getById: protectedProcedure.input(validation.common.byId).query(async ({ input, ctx }) => {
const group = await ctx.db.query.groups.findFirst({
where: eq(groups.id, input.id),
with: {
@@ -156,7 +156,7 @@ export const groupRouter = createTRPCRouter({
})
.where(eq(groups.id, input.groupId));
}),
deleteGroup: protectedProcedure.input(validation.group.byId).mutation(async ({ input, ctx }) => {
deleteGroup: protectedProcedure.input(validation.common.byId).mutation(async ({ input, ctx }) => {
await throwIfGroupNotFoundAsync(ctx.db, input.id);
await ctx.db.delete(groups).where(eq(groups.id, input.id));

View File

@@ -0,0 +1,85 @@
import { TRPCError } from "@trpc/server";
import { createId, eq, like, sql } from "@homarr/db";
import { searchEngines } from "@homarr/db/schema/sqlite";
import { validation } from "@homarr/validation";
import { createTRPCRouter, protectedProcedure } from "../../trpc";
export const searchEngineRouter = createTRPCRouter({
getPaginated: protectedProcedure.input(validation.common.paginated).query(async ({ input, ctx }) => {
const whereQuery = input.search ? like(searchEngines.name, `%${input.search.trim()}%`) : undefined;
const searchEngineCount = await ctx.db
.select({
count: sql<number>`count(*)`,
})
.from(searchEngines)
.where(whereQuery);
const dbSearachEngines = await ctx.db.query.searchEngines.findMany({
limit: input.pageSize,
offset: (input.page - 1) * input.pageSize,
where: whereQuery,
});
return {
items: dbSearachEngines,
totalCount: searchEngineCount[0]?.count ?? 0,
};
}),
byId: protectedProcedure.input(validation.common.byId).query(async ({ ctx, input }) => {
const searchEngine = await ctx.db.query.searchEngines.findFirst({
where: eq(searchEngines.id, input.id),
});
if (!searchEngine) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Search engine not found",
});
}
return searchEngine;
}),
search: protectedProcedure.input(validation.common.search).query(async ({ ctx, input }) => {
return await ctx.db.query.searchEngines.findMany({
where: like(searchEngines.short, `${input.query.toLowerCase().trim()}%`),
limit: input.limit,
});
}),
create: protectedProcedure.input(validation.searchEngine.manage).mutation(async ({ ctx, input }) => {
await ctx.db.insert(searchEngines).values({
id: createId(),
name: input.name,
short: input.short.toLowerCase(),
iconUrl: input.iconUrl,
urlTemplate: input.urlTemplate,
description: input.description,
});
}),
update: protectedProcedure.input(validation.searchEngine.edit).mutation(async ({ ctx, input }) => {
const searchEngine = await ctx.db.query.searchEngines.findFirst({
where: eq(searchEngines.id, input.id),
});
if (!searchEngine) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Search engine not found",
});
}
await ctx.db
.update(searchEngines)
.set({
name: input.name,
iconUrl: input.iconUrl,
urlTemplate: input.urlTemplate,
description: input.description,
})
.where(eq(searchEngines.id, input.id));
}),
delete: protectedProcedure.input(validation.common.byId).mutation(async ({ ctx, input }) => {
await ctx.db.delete(searchEngines).where(eq(searchEngines.id, input.id));
}),
});

View File

@@ -0,0 +1,52 @@
import { observable } from "@trpc/server/observable";
import type { HealthMonitoring } from "@homarr/integrations";
import { createItemAndIntegrationChannel } from "@homarr/redis";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc";
export const healthMonitoringRouter = createTRPCRouter({
getHealthStatus: publicProcedure
.unstable_concat(createManyIntegrationMiddleware("query", "openmediavault"))
.query(async ({ ctx }) => {
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;
}
return {
integrationId: integration.id,
integrationName: integration.name,
healthInfo: data.data,
};
}),
);
}),
subscribeHealthStatus: publicProcedure
.unstable_concat(createManyIntegrationMiddleware("query", "openmediavault"))
.subscription(({ ctx }) => {
return observable<{ integrationId: string; healthInfo: HealthMonitoring }>((emit) => {
const unsubscribes: (() => void)[] = [];
for (const integration of ctx.integrations) {
const channel = createItemAndIntegrationChannel<HealthMonitoring>("healthMonitoring", integration.id);
const unsubscribe = channel.subscribe((healthInfo) => {
emit.next({
integrationId: integration.id,
healthInfo,
});
});
unsubscribes.push(unsubscribe);
}
return () => {
unsubscribes.forEach((unsubscribe) => {
unsubscribe();
});
};
});
}),
});

View File

@@ -3,6 +3,7 @@ import { appRouter } from "./app";
import { calendarRouter } from "./calendar";
import { dnsHoleRouter } from "./dns-hole";
import { downloadsRouter } from "./downloads";
import { healthMonitoringRouter } from "./health-monitoring";
import { indexerManagerRouter } from "./indexer-manager";
import { mediaRequestsRouter } from "./media-requests";
import { mediaServerRouter } from "./media-server";
@@ -23,4 +24,5 @@ export const widgetRouter = createTRPCRouter({
mediaRequests: mediaRequestsRouter,
rssFeed: rssFeedRouter,
indexerManager: indexerManagerRouter,
healthMonitoring: healthMonitoringRouter,
});

View File

@@ -23,8 +23,8 @@
},
"prettier": "@homarr/prettier-config",
"dependencies": {
"@auth/core": "^0.35.2",
"@auth/drizzle-adapter": "^1.5.2",
"@auth/core": "^0.35.3",
"@auth/drizzle-adapter": "^1.5.3",
"@homarr/common": "workspace:^0.1.0",
"@homarr/db": "workspace:^0.1.0",
"@homarr/definitions": "workspace:^0.1.0",
@@ -33,9 +33,9 @@
"@t3-oss/env-nextjs": "^0.11.1",
"bcrypt": "^5.1.1",
"cookies": "^0.9.1",
"ldapts": "7.2.0",
"next": "^14.2.13",
"next-auth": "5.0.0-beta.21",
"ldapts": "7.2.1",
"next": "^14.2.14",
"next-auth": "5.0.0-beta.22",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},

View File

@@ -1,8 +1,8 @@
import { CredentialsSignin } from "@auth/core/errors";
import type { Database } from "@homarr/db";
import { and, createId, eq } from "@homarr/db";
import { users } from "@homarr/db/schema/sqlite";
import type { Database, InferInsertModel } from "@homarr/db";
import { and, createId, eq, inArray } from "@homarr/db";
import { groupMembers, groups, users } from "@homarr/db/schema/sqlite";
import { logger } from "@homarr/log";
import type { validation } from "@homarr/validation";
import { z } from "@homarr/validation";
@@ -99,21 +99,39 @@ export const authorizeWithLdapCredentialsAsync = async (
emailVerified: true,
provider: true,
},
with: {
groups: {
with: {
group: {
columns: {
id: true,
name: true,
},
},
},
},
},
where: and(eq(users.email, mailResult.data), eq(users.provider, "ldap")),
});
if (!user) {
logger.info(`User ${credentials.name} not found in the database. Creating...`);
user = {
const insertUser = {
id: createId(),
name: credentials.name,
email: mailResult.data,
emailVerified: new Date(), // assume email is verified
image: null,
provider: "ldap",
} satisfies InferInsertModel<typeof users>;
await db.insert(users).values(insertUser);
user = {
...insertUser,
groups: [],
};
await db.insert(users).values(user);
logger.info(`User ${credentials.name} created successfully.`);
}
@@ -128,6 +146,58 @@ export const authorizeWithLdapCredentialsAsync = async (
logger.info(`User ${credentials.name} updated successfully.`);
}
const ldapGroupsUserIsNotIn = userGroups.filter(
(group) => !user.groups.some((userGroup) => userGroup.group.name === group),
);
if (ldapGroupsUserIsNotIn.length > 0) {
logger.debug(
`Homarr does not have the user in certain groups. user=${user.name} count=${ldapGroupsUserIsNotIn.length}`,
);
const groupIds = await db.query.groups.findMany({
columns: {
id: true,
},
where: inArray(groups.name, ldapGroupsUserIsNotIn),
});
logger.debug(`Homarr has found groups in the database user is not in. user=${user.name} count=${groupIds.length}`);
if (groupIds.length > 0) {
await db.insert(groupMembers).values(
groupIds.map((group) => ({
userId: user.id,
groupId: group.id,
})),
);
logger.info(`Added user to groups successfully. user=${user.name} count=${groupIds.length}`);
} else {
logger.debug(`User is already in all groups of Homarr. user=${user.name}`);
}
}
const homarrGroupsUserIsNotIn = user.groups.filter((userGroup) => !userGroups.includes(userGroup.group.name));
if (homarrGroupsUserIsNotIn.length > 0) {
logger.debug(
`Homarr has the user in certain groups that LDAP does not have. user=${user.name} count=${homarrGroupsUserIsNotIn.length}`,
);
await db.delete(groupMembers).where(
and(
eq(groupMembers.userId, user.id),
inArray(
groupMembers.groupId,
homarrGroupsUserIsNotIn.map(({ groupId }) => groupId),
),
),
);
logger.info(`Removed user from groups successfully. user=${user.name} count=${homarrGroupsUserIsNotIn.length}`);
}
return {
id: user.id,
name: user.name,

View File

@@ -3,10 +3,9 @@ import { describe, expect, test, vi } from "vitest";
import type { Database } from "@homarr/db";
import { and, createId, eq } from "@homarr/db";
import { users } from "@homarr/db/schema/sqlite";
import { groupMembers, groups, users } from "@homarr/db/schema/sqlite";
import { createDb } from "@homarr/db/test";
import { createSaltAsync, hashPasswordAsync } from "../../security";
import { authorizeWithLdapCredentialsAsync } from "../credentials/authorization/ldap-authorization";
import * as ldapClient from "../credentials/ldap-client";
@@ -15,6 +14,7 @@ vi.mock("../../env.mjs", () => ({
AUTH_LDAP_BIND_DN: "bind_dn",
AUTH_LDAP_BIND_PASSWORD: "bind_password",
AUTH_LDAP_USER_MAIL_ATTRIBUTE: "mail",
AUTH_LDAP_GROUP_CLASS: "group",
},
}));
@@ -171,7 +171,6 @@ describe("authorizeWithLdapCredentials", () => {
// Arrange
const db = createDb();
const spy = vi.spyOn(ldapClient, "LdapClient");
const salt = await createSaltAsync();
spy.mockImplementation(
() =>
({
@@ -190,8 +189,6 @@ describe("authorizeWithLdapCredentials", () => {
await db.insert(users).values({
id: createId(),
name: "test",
salt,
password: await hashPasswordAsync("test", salt),
email: "test@gmail.com",
provider: "credentials",
});
@@ -224,14 +221,28 @@ describe("authorizeWithLdapCredentials", () => {
test("should authorize user with correct credentials and update name", async () => {
// Arrange
const spy = vi.spyOn(ldapClient, "LdapClient");
spy.mockImplementation(
() =>
({
bindAsync: vi.fn(() => Promise.resolve()),
searchAsync: vi.fn(() =>
Promise.resolve([
{
dn: "test55",
mail: "test@gmail.com",
},
]),
),
disconnectAsync: vi.fn(),
}) as unknown as ldapClient.LdapClient,
);
const userId = createId();
const db = createDb();
const salt = await createSaltAsync();
await db.insert(users).values({
id: userId,
name: "test-old",
salt,
password: await hashPasswordAsync("test", salt),
email: "test@gmail.com",
provider: "ldap",
});
@@ -256,4 +267,127 @@ describe("authorizeWithLdapCredentials", () => {
expect(dbUser?.email).toBe("test@gmail.com");
expect(dbUser?.provider).toBe("ldap");
});
test("should authorize user with correct credentials and add him to the groups that he is in LDAP but not in Homar", async () => {
// Arrange
const spy = vi.spyOn(ldapClient, "LdapClient");
spy.mockImplementation(
() =>
({
bindAsync: vi.fn(() => Promise.resolve()),
searchAsync: vi.fn((argument: { options: { filter: string } }) =>
argument.options.filter.includes("group")
? Promise.resolve([
{
cn: "homarr_example",
},
])
: Promise.resolve([
{
dn: "test55",
mail: "test@gmail.com",
},
]),
),
disconnectAsync: vi.fn(),
}) as unknown as ldapClient.LdapClient,
);
const db = createDb();
const userId = createId();
await db.insert(users).values({
id: userId,
name: "test",
email: "test@gmail.com",
provider: "ldap",
});
const groupId = createId();
await db.insert(groups).values({
id: groupId,
name: "homarr_example",
});
// Act
const result = await authorizeWithLdapCredentialsAsync(db, {
name: "test",
password: "test",
credentialType: "ldap",
});
// Assert
expect(result).toEqual({ id: userId, name: "test" });
const dbGroupMembers = await db.query.groupMembers.findMany();
expect(dbGroupMembers).toHaveLength(1);
});
test("should authorize user with correct credentials and remove him from groups he is in Homarr but not in LDAP", async () => {
// Arrange
const spy = vi.spyOn(ldapClient, "LdapClient");
spy.mockImplementation(
() =>
({
bindAsync: vi.fn(() => Promise.resolve()),
searchAsync: vi.fn((argument: { options: { filter: string } }) =>
argument.options.filter.includes("group")
? Promise.resolve([
{
cn: "homarr_example",
},
])
: Promise.resolve([
{
dn: "test55",
mail: "test@gmail.com",
},
]),
),
disconnectAsync: vi.fn(),
}) as unknown as ldapClient.LdapClient,
);
const db = createDb();
const userId = createId();
await db.insert(users).values({
id: userId,
name: "test",
email: "test@gmail.com",
provider: "ldap",
});
const groupIds = [createId(), createId()] as const;
await db.insert(groups).values([
{
id: groupIds[0],
name: "homarr_example",
},
{
id: groupIds[1],
name: "homarr_no_longer_member",
},
]);
await db.insert(groupMembers).values([
{
userId,
groupId: groupIds[0],
},
{
userId,
groupId: groupIds[1],
},
]);
// Act
const result = await authorizeWithLdapCredentialsAsync(db, {
name: "test",
password: "test",
credentialType: "ldap",
});
// Assert
expect(result).toEqual({ id: userId, name: "test" });
const dbGroupMembers = await db.query.groupMembers.findMany();
expect(dbGroupMembers).toHaveLength(1);
expect(dbGroupMembers[0]?.groupId).toBe(groupIds[0]);
});
});

View File

@@ -25,10 +25,11 @@
},
"prettier": "@homarr/prettier-config",
"dependencies": {
"@homarr/log": "workspace:^0.1.0",
"dayjs": "^1.11.13",
"next": "^14.2.13",
"next": "^14.2.14",
"react": "^18.3.1",
"tldts": "^6.1.48"
"tldts": "^6.1.50"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",

View File

@@ -1,7 +1,20 @@
import crypto from "crypto";
import { logger } from "@homarr/log";
const algorithm = "aes-256-cbc"; //Using AES encryption
const key = Buffer.from("1d71cceced68159ba59a277d056a66173613052cbeeccbfbd15ab1c909455a4d", "hex"); // TODO: generate with const data = crypto.randomBytes(32).toString('hex')
const fallbackKey = "0000000000000000000000000000000000000000000000000000000000000000";
const encryptionKey = process.env.ENCRYPTION_KEY ?? fallbackKey; // Fallback to a default key for local development
if (encryptionKey === fallbackKey) {
logger.warn("Using a fallback encryption key, stored secrets are not secure");
// We never want to use the fallback key in production
if (process.env.NODE_ENV === "production" && process.env.CI !== "true") {
throw new Error("Encryption key is not set");
}
}
const key = Buffer.from(encryptionKey, "hex");
export function encryptSecret(text: string): `${string}.${string}` {
const initializationVector = crypto.randomBytes(16);

View File

@@ -2,6 +2,7 @@ import { analyticsJob } from "./jobs/analytics";
import { iconsUpdaterJob } from "./jobs/icons-updater";
import { dnsHoleJob } from "./jobs/integrations/dns-hole";
import { downloadsJob } from "./jobs/integrations/downloads";
import { healthMonitoringJob } from "./jobs/integrations/health-monitoring";
import { smartHomeEntityStateJob } from "./jobs/integrations/home-assistant";
import { indexerManagerJob } from "./jobs/integrations/indexer-manager";
import { mediaOrganizerJob } from "./jobs/integrations/media-organizer";
@@ -24,6 +25,7 @@ export const jobGroup = createCronJobGroup({
mediaRequests: mediaRequestsJob,
rssFeeds: rssFeedsJob,
indexerManager: indexerManagerJob,
healthMonitoring: healthMonitoringJob,
});
export type JobGroupKeys = ReturnType<(typeof jobGroup)["getKeys"]>[number];

View File

@@ -0,0 +1,22 @@
import { EVERY_5_SECONDS } from "@homarr/cron-jobs-core/expressions";
import { db } from "@homarr/db";
import { getItemsWithIntegrationsAsync } from "@homarr/db/queries";
import { integrationCreatorFromSecrets } from "@homarr/integrations";
import { createItemAndIntegrationChannel } from "@homarr/redis";
import { createCronJob } from "../../lib";
export const healthMonitoringJob = createCronJob("healthMonitoring", EVERY_5_SECONDS).withCallback(async () => {
const itemsForIntegration = await getItemsWithIntegrationsAsync(db, {
kinds: ["healthMonitoring"],
});
for (const itemForIntegration of itemsForIntegration) {
for (const integration of itemForIntegration.integrations) {
const openmediavault = integrationCreatorFromSecrets(integration.integration);
const healthInfo = await openmediavault.getSystemInfoAsync();
const channel = createItemAndIntegrationChannel("healthMonitoring", integration.integrationId);
await channel.publishAndUpdateLastStateAsync(healthInfo);
}
}
});

View File

@@ -0,0 +1,9 @@
CREATE TABLE `search_engine` (
`id` varchar(64) NOT NULL,
`icon_url` text NOT NULL,
`name` varchar(64) NOT NULL,
`short` varchar(8) NOT NULL,
`description` text,
`url_template` text NOT NULL,
CONSTRAINT `search_engine_id` PRIMARY KEY(`id`)
);

File diff suppressed because it is too large Load Diff

View File

@@ -57,6 +57,13 @@
"when": 1723749320706,
"tag": "0007_boring_nocturne",
"breakpoints": true
},
{
"idx": 8,
"version": "5",
"when": 1727532165317,
"tag": "0008_far_lifeguard",
"breakpoints": true
}
]
}

View File

@@ -0,0 +1,8 @@
CREATE TABLE `search_engine` (
`id` text PRIMARY KEY NOT NULL,
`icon_url` text NOT NULL,
`name` text NOT NULL,
`short` text NOT NULL,
`description` text,
`url_template` text NOT NULL
);

File diff suppressed because it is too large Load Diff

View File

@@ -57,6 +57,13 @@
"when": 1723746828385,
"tag": "0007_known_ultragirl",
"breakpoints": true
},
{
"idx": 8,
"version": "6",
"when": 1727526190343,
"tag": "0008_third_thor",
"breakpoints": true
}
]
}

View File

@@ -31,12 +31,12 @@
},
"prettier": "@homarr/prettier-config",
"dependencies": {
"@auth/core": "^0.35.2",
"@auth/core": "^0.35.3",
"@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.1",
"@testcontainers/mysql": "^10.13.2",
"better-sqlite3": "^11.3.0",
"dotenv": "^16.4.5",
"drizzle-kit": "^0.24.2",

View File

@@ -341,6 +341,15 @@ export const serverSettings = mysqlTable("serverSetting", {
value: text("value").default('{"json": {}}').notNull(), // empty superjson object
});
export const searchEngines = mysqlTable("search_engine", {
id: varchar("id", { length: 64 }).notNull().primaryKey(),
iconUrl: text("icon_url").notNull(),
name: varchar("name", { length: 64 }).notNull(),
short: varchar("short", { length: 8 }).notNull(),
description: text("description"),
urlTemplate: text("url_template").notNull(),
});
export const accountRelations = relations(accounts, ({ one }) => ({
user: one(users, {
fields: [accounts.userId],

View File

@@ -343,6 +343,15 @@ export const serverSettings = sqliteTable("serverSetting", {
value: text("value").default('{"json": {}}').notNull(), // empty superjson object
});
export const searchEngines = sqliteTable("search_engine", {
id: text("id").notNull().primaryKey(),
iconUrl: text("icon_url").notNull(),
name: text("name").notNull(),
short: text("short").notNull(),
description: text("description"),
urlTemplate: text("url_template").notNull(),
});
export const accountRelations = relations(accounts, ({ one }) => ({
user: one(users, {
fields: [accounts.userId],

View File

@@ -119,6 +119,12 @@ export const integrationDefs = {
iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/home-assistant.png",
category: ["smartHomeServer"],
},
openmediavault: {
name: "OpenMediaVault",
secretKinds: [["username", "password"]],
iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/openmediavault.png",
category: ["healthMonitoring"],
},
} as const satisfies Record<string, integrationDefinition>;
export const integrationKinds = objectKeys(integrationDefs) as AtLeastOneOf<IntegrationKind>;
@@ -168,4 +174,5 @@ export type IntegrationCategory =
| "usenet"
| "torrent"
| "smartHomeServer"
| "indexerManager";
| "indexerManager"
| "healthMonitoring";

View File

@@ -16,5 +16,6 @@ export const widgetKinds = [
"mediaRequests-requestStats",
"rssFeed",
"indexerManager",
"healthMonitoring",
] as const;
export type WidgetKind = (typeof widgetKinds)[number];

View File

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

View File

@@ -14,6 +14,7 @@ import { JellyfinIntegration } from "../jellyfin/jellyfin-integration";
import { JellyseerrIntegration } from "../jellyseerr/jellyseerr-integration";
import { RadarrIntegration } from "../media-organizer/radarr/radarr-integration";
import { SonarrIntegration } from "../media-organizer/sonarr/sonarr-integration";
import { OpenMediaVaultIntegration } from "../openmediavault/openmediavault-integration";
import { OverseerrIntegration } from "../overseerr/overseerr-integration";
import { PiHoleIntegration } from "../pi-hole/pi-hole-integration";
import { ProwlarrIntegration } from "../prowlarr/prowlarr-integration";
@@ -60,4 +61,5 @@ export const integrationCreators = {
jellyseerr: JellyseerrIntegration,
overseerr: OverseerrIntegration,
prowlarr: ProwlarrIntegration,
openmediavault: OpenMediaVaultIntegration,
} satisfies Partial<Record<IntegrationKind, new (integration: IntegrationInput) => Integration>>;

View File

@@ -6,23 +6,25 @@ export { DownloadClientIntegration } from "./interfaces/downloads/download-clien
export { JellyseerrIntegration } from "./jellyseerr/jellyseerr-integration";
export { RadarrIntegration } from "./media-organizer/radarr/radarr-integration";
export { SonarrIntegration } from "./media-organizer/sonarr/sonarr-integration";
export { OpenMediaVaultIntegration } from "./openmediavault/openmediavault-integration";
export { OverseerrIntegration } from "./overseerr/overseerr-integration";
export { PiHoleIntegration } from "./pi-hole/pi-hole-integration";
export { ProwlarrIntegration } from "./prowlarr/prowlarr-integration";
export { SabnzbdIntegration } from "./download-client/sabnzbd/sabnzbd-integration";
export { NzbGetIntegration } from "./download-client/nzbget/nzbget-integration";
export { QBitTorrentIntegration } from "./download-client/qbittorrent/qbittorrent-integration";
export { DelugeIntegration } from "./download-client/deluge/deluge-integration";
export { TransmissionIntegration } from "./download-client/transmission/transmission-integration";
export { OverseerrIntegration } from "./overseerr/overseerr-integration";
export { PiHoleIntegration } from "./pi-hole/pi-hole-integration";
export { ProwlarrIntegration } from "./prowlarr/prowlarr-integration";
// Types
export type { IntegrationInput } from "./base/integration";
export type { DownloadClientJobsAndStatus } from "./interfaces/downloads/download-client-data";
export type { ExtendedDownloadClientItem } from "./interfaces/downloads/download-client-items";
export type { ExtendedClientStatus } from "./interfaces/downloads/download-client-status";
export type { HealthMonitoring } from "./interfaces/health-monitoring/healt-monitoring";
export { MediaRequestStatus } from "./interfaces/media-requests/media-request";
export type { MediaRequestList, MediaRequestStats } from "./interfaces/media-requests/media-request";
export type { StreamSession } from "./interfaces/media-server/session";
export type { ExtendedClientStatus } from "./interfaces/downloads/download-client-status";
export type { ExtendedDownloadClientItem } from "./interfaces/downloads/download-client-items";
export type { DownloadClientJobsAndStatus } from "./interfaces/downloads/download-client-data";
export type { IntegrationInput } from "./base/integration";
// Schemas
export { downloadClientItemSchema } from "./interfaces/downloads/download-client-items";

View File

@@ -0,0 +1,27 @@
export interface HealthMonitoring {
version: string;
cpuModelName: string;
cpuUtilization: number;
memUsed: string;
memAvailable: string;
uptime: number;
loadAverage: {
"1min": number;
"5min": number;
"15min": number;
};
rebootRequired: boolean;
availablePkgUpdates: number;
cpuTemp: number;
fileSystem: {
deviceName: string;
used: string;
available: string;
percentage: number;
}[];
smart: {
deviceName: string;
temperature: number;
overallStatus: string;
}[];
}

View File

@@ -0,0 +1,155 @@
import { Integration } from "../base/integration";
import { IntegrationTestConnectionError } from "../base/test-connection-error";
import type { HealthMonitoring } from "../types";
import { cpuTempSchema, fileSystemSchema, smartSchema, systemInformationSchema } from "./openmediavault-types";
export class OpenMediaVaultIntegration extends Integration {
static extractSessionIdFromCookies(headers: Headers): string {
const cookies = headers.get("set-cookie") ?? "";
const sessionId = cookies
.split(";")
.find((cookie) => cookie.includes("X-OPENMEDIAVAULT-SESSIONID") || cookie.includes("OPENMEDIAVAULT-SESSIONID"));
if (sessionId) {
return sessionId;
} else {
throw new Error("Session ID not found in cookies");
}
}
static extractLoginTokenFromCookies(headers: Headers): string {
const cookies = headers.get("set-cookie") ?? "";
const loginToken = cookies
.split(";")
.find((cookie) => cookie.includes("X-OPENMEDIAVAULT-LOGIN") || cookie.includes("OPENMEDIAVAULT-LOGIN"));
if (loginToken) {
return loginToken;
} else {
throw new Error("Login token not found in cookies");
}
}
public async getSystemInfoAsync(): Promise<HealthMonitoring> {
if (!this.headers) {
await this.authenticateAndConstructSessionInHeaderAsync();
}
const systemResponses = await this.makeOpenMediaVaultRPCCallAsync("system", "getInformation", {}, this.headers);
const fileSystemResponse = await this.makeOpenMediaVaultRPCCallAsync(
"filesystemmgmt",
"enumerateMountedFilesystems",
{ includeroot: true },
this.headers,
);
const smartResponse = await this.makeOpenMediaVaultRPCCallAsync("smart", "enumerateDevices", {}, this.headers);
const cpuTempResponse = await this.makeOpenMediaVaultRPCCallAsync("cputemp", "get", {}, this.headers);
const systemResult = systemInformationSchema.safeParse(await systemResponses.json());
const fileSystemResult = fileSystemSchema.safeParse(await fileSystemResponse.json());
const smartResult = smartSchema.safeParse(await smartResponse.json());
const cpuTempResult = cpuTempSchema.safeParse(await cpuTempResponse.json());
if (!systemResult.success) {
throw new Error("Invalid system information response");
}
if (!fileSystemResult.success) {
throw new Error("Invalid file system response");
}
if (!smartResult.success) {
throw new Error("Invalid SMART information response");
}
if (!cpuTempResult.success) {
throw new Error("Invalid CPU temperature response");
}
const fileSystem = fileSystemResult.data.response.map((fileSystem) => ({
deviceName: fileSystem.devicename,
used: fileSystem.used,
available: fileSystem.available,
percentage: fileSystem.percentage,
}));
const smart = smartResult.data.response.map((smart) => ({
deviceName: smart.devicename,
temperature: smart.temperature,
overallStatus: smart.overallstatus,
}));
return {
version: systemResult.data.response.version,
cpuModelName: systemResult.data.response.cpuModelName,
cpuUtilization: systemResult.data.response.cpuUtilization,
memUsed: systemResult.data.response.memUsed,
memAvailable: systemResult.data.response.memAvailable,
uptime: systemResult.data.response.uptime,
loadAverage: {
"1min": systemResult.data.response.loadAverage["1min"],
"5min": systemResult.data.response.loadAverage["5min"],
"15min": systemResult.data.response.loadAverage["15min"],
},
rebootRequired: systemResult.data.response.rebootRequired,
availablePkgUpdates: systemResult.data.response.availablePkgUpdates,
cpuTemp: cpuTempResult.data.response.cputemp,
fileSystem,
smart,
};
}
public async testConnectionAsync(): Promise<void> {
const response = await this.makeOpenMediaVaultRPCCallAsync("session", "login", {
username: this.getSecretValue("username"),
password: this.getSecretValue("password"),
});
if (!response.ok) {
throw new IntegrationTestConnectionError("invalidCredentials");
}
const result = (await response.json()) as unknown;
if (typeof result !== "object" || result === null || !("response" in result)) {
throw new IntegrationTestConnectionError("invalidJson");
}
}
private async makeOpenMediaVaultRPCCallAsync(
serviceName: string,
method: string,
params: Record<string, unknown>,
headers: Record<string, string> = {},
): Promise<Response> {
return await fetch(`${this.integration.url}/rpc.php`, {
method: "POST",
headers: {
"Content-Type": "application/json",
...headers,
},
body: JSON.stringify({
service: serviceName,
method,
params,
}),
});
}
private headers: Record<string, string> | undefined = undefined;
private async authenticateAndConstructSessionInHeaderAsync() {
const authResponse = await this.makeOpenMediaVaultRPCCallAsync("session", "login", {
username: this.getSecretValue("username"),
password: this.getSecretValue("password"),
});
const authResult = (await authResponse.json()) as Response;
const response = (authResult as { response?: { sessionid?: string } }).response;
let sessionId;
const headers: Record<string, string> = {};
if (response?.sessionid) {
sessionId = response.sessionid;
headers["X-OPENMEDIAVAULT-SESSIONID"] = sessionId;
} else {
sessionId = OpenMediaVaultIntegration.extractSessionIdFromCookies(authResponse.headers);
const loginToken = OpenMediaVaultIntegration.extractLoginTokenFromCookies(authResponse.headers);
headers.Cookie = `${loginToken};${sessionId}`;
}
this.headers = headers;
}
}

View File

@@ -0,0 +1,57 @@
import { z } from "zod";
// Schema for system information
export const systemInformationSchema = z.object({
response: z.object({
version: z.string(),
cpuModelName: z.string(),
cpuUtilization: z.number(),
memUsed: z.string(),
memAvailable: z.string(),
uptime: z.number(),
loadAverage: z.object({
"1min": z.number(),
"5min": z.number(),
"15min": z.number(),
}),
rebootRequired: z.boolean(),
availablePkgUpdates: z.number(),
}),
});
// Schema for file systems
export const fileSystemSchema = z.object({
response: z.array(
z.object({
devicename: z.string(),
used: z.string(),
available: z.string(),
percentage: z.number(),
}),
),
});
// Schema for SMART information
export const smartSchema = z.object({
response: z.array(
z.object({
devicename: z.string(),
temperature: z.union([z.string(), z.number()]).transform((val) => {
// Convert string to number if necessary
const temp = typeof val === "string" ? parseFloat(val) : val;
if (isNaN(temp)) {
throw new Error("Invalid temperature value");
}
return temp;
}),
overallstatus: z.string(),
}),
),
});
// Schema for CPU temperature
export const cpuTempSchema = z.object({
response: z.object({
cputemp: z.number(),
}),
});

View File

@@ -1,5 +1,6 @@
export * from "./calendar-types";
export * from "./interfaces/dns-hole-summary/dns-hole-summary-types";
export * from "./interfaces/health-monitoring/healt-monitoring";
export * from "./interfaces/indexer-manager/indexer";
export * from "./interfaces/media-requests/media-request";
export * from "./pi-hole/pi-hole-types";

View File

@@ -30,10 +30,10 @@
"@homarr/translation": "workspace:^0.1.0",
"@homarr/ui": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0",
"@mantine/core": "^7.13.0",
"@tabler/icons-react": "^3.18.0",
"@mantine/core": "^7.13.2",
"@tabler/icons-react": "^3.19.0",
"dayjs": "^1.11.13",
"next": "^14.2.13",
"next": "^14.2.14",
"react": "^18.3.1"
},
"devDependencies": {

View File

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

View File

@@ -85,7 +85,7 @@ export const ConfirmModal = createModal<Omit<ConfirmModalProps, "title">>(({ act
{cancelProps?.children ?? translateIfNecessary(t, cancelLabel)}
</Button>
<Button {...confirmProps} onClick={handleConfirm} color="red.9" loading={loading}>
<Button data-autofocus {...confirmProps} onClick={handleConfirm} color="red.9" loading={loading}>
{confirmProps?.children ?? translateIfNecessary(t, confirmLabel)}
</Button>
</Group>

View File

@@ -24,8 +24,8 @@
"prettier": "@homarr/prettier-config",
"dependencies": {
"@homarr/ui": "workspace:^0.1.0",
"@mantine/notifications": "^7.13.0",
"@tabler/icons-react": "^3.18.0"
"@mantine/notifications": "^7.13.2",
"@tabler/icons-react": "^3.19.0"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",

View File

@@ -66,6 +66,7 @@ export const widgetKindMapping = {
"mediaRequests-requestList": "media-requests-list",
"mediaRequests-requestStats": "media-requests-stats",
indexerManager: "indexer-manager",
healthMonitoring: "health-monitoring",
} satisfies Record<WidgetKind, OldmarrWidgetDefinitions["id"] | null>;
// Use null for widgets that did not exist in oldmarr
// TODO: revert assignment so that only old widgets are needed in the object,

View File

@@ -103,6 +103,12 @@ const optionMapping: OptionMapping = {
indexerManager: {
openIndexerSiteInNewTab: (oldOptions) => oldOptions.openIndexerSiteInNewTab,
},
healthMonitoring: {
cpu: (oldOptions) => oldOptions.cpu,
memory: (oldOptions) => oldOptions.memory,
fahrenheit: (oldOptions) => oldOptions.fahrenheit,
fileSystem: (oldOptions) => oldOptions.fileSystem,
},
app: null,
};

View File

@@ -1,6 +1,6 @@
import { createListChannel, createQueueChannel, createSubPubChannel } from "./lib/channel";
export { createCacheChannel, createItemAndIntegrationChannel, createItemChannel } from "./lib/channel";
export { createCacheChannel, createItemAndIntegrationChannel, createItemChannel, handshakeAsync } from "./lib/channel";
export const exampleChannel = createSubPubChannel<{ message: string }>("example");
export const pingChannel = createSubPubChannel<{ url: string; statusCode: number } | { url: string; error: string }>(

View File

@@ -260,3 +260,7 @@ export const createQueueChannel = <TItem>(name: string) => {
},
};
};
export const handshakeAsync = async () => {
await getSetClient.hello();
};

View File

@@ -30,12 +30,12 @@
"@homarr/modals-collection": "workspace:^0.1.0",
"@homarr/translation": "workspace:^0.1.0",
"@homarr/ui": "workspace:^0.1.0",
"@mantine/core": "^7.13.0",
"@mantine/hooks": "^7.13.0",
"@mantine/spotlight": "^7.13.0",
"@tabler/icons-react": "^3.18.0",
"@mantine/core": "^7.13.2",
"@mantine/hooks": "^7.13.2",
"@mantine/spotlight": "^7.13.2",
"@tabler/icons-react": "^3.19.0",
"jotai": "^2.10.0",
"next": "^14.2.13",
"next": "^14.2.14",
"react": "^18.3.1",
"use-deep-compare-effect": "^1.8.1"
},

View File

@@ -1,4 +1,5 @@
import { Center, Loader } from "@mantine/core";
import { useWindowEvent } from "@mantine/hooks";
import type { TranslationObject } from "@homarr/translation";
import { useI18n } from "@homarr/translation/client";
@@ -27,6 +28,11 @@ export const SpotlightGroupActions = <TOption extends Record<string, unknown>>({
const options = useOptions(query);
const t = useI18n();
useWindowEvent("keydown", (event) => {
const optionsArray = Array.isArray(options) ? options : (options.data ?? []);
group.onKeyDown?.(event, optionsArray, query, { setChildrenOptions });
});
if (Array.isArray(options)) {
const filteredOptions = options
.filter((option) => ("filter" in group ? group.filter(query, option) : false))

View File

@@ -15,18 +15,13 @@ interface SpotlightActionGroupsProps {
setChildrenOptions: (options: inferSearchInteractionOptions<"children">) => void;
}
export const SpotlightActionGroups = ({ groups, query, setMode, setChildrenOptions }: SpotlightActionGroupsProps) => {
export const SpotlightActionGroups = ({ groups, ...others }: SpotlightActionGroupsProps) => {
const t = useI18n();
return groups.map((group) => (
<Spotlight.ActionsGroup key={translateIfNecessary(t, group.title)} label={translateIfNecessary(t, group.title)}>
{/*eslint-disable-next-line @typescript-eslint/no-explicit-any */}
<SpotlightGroupActions<any>
group={group}
query={query}
setMode={setMode}
setChildrenOptions={setChildrenOptions}
/>
<SpotlightGroupActions<any> group={group} {...others} />
</Spotlight.ActionsGroup>
));
};

View File

@@ -28,6 +28,7 @@ export const Spotlight = () => {
return (
<MantineSpotlight.Root
yOffset={8}
onSpotlightClose={() => {
setMode("help");
setChildrenOptions(null);
@@ -64,6 +65,11 @@ export const Spotlight = () => {
{activeMode.modeKey !== "help" ? <Kbd size="sm">{activeMode.character}</Kbd> : null}
</Group>
}
styles={{
section: {
pointerEvents: "all",
},
}}
rightSection={
mode === "help" ? undefined : (
<ActionIcon
@@ -105,8 +111,11 @@ export const Spotlight = () => {
}}
setChildrenOptions={(options) => {
setChildrenOptions(options);
setQuery("");
setTimeout(() => selectAction(0, spotlightStore));
setTimeout(() => {
setQuery("");
selectAction(0, spotlightStore);
});
}}
query={query}
groups={activeMode.groups}

View File

@@ -2,7 +2,7 @@ import type { UseTRPCQueryResult } from "@trpc/react-query/shared";
import type { stringOrTranslation } from "@homarr/translation";
import type { inferSearchInteractionDefinition, SearchInteraction } from "./interaction";
import type { inferSearchInteractionDefinition, inferSearchInteractionOptions, SearchInteraction } from "./interaction";
type CommonSearchGroup<TOption extends Record<string, unknown>, TOptionProps extends Record<string, unknown>> = {
// key path is used to define the path to a unique key in the option object
@@ -10,6 +10,14 @@ type CommonSearchGroup<TOption extends Record<string, unknown>, TOptionProps ext
title: stringOrTranslation;
component: (option: TOption) => JSX.Element;
useInteraction: (option: TOption, query: string) => inferSearchInteractionDefinition<SearchInteraction>;
onKeyDown?: (
event: KeyboardEvent,
options: TOption[],
query: string,
actions: {
setChildrenOptions: (options: inferSearchInteractionOptions<"children">) => void;
},
) => void;
} & TOptionProps;
// eslint-disable-next-line @typescript-eslint/no-explicit-any

View File

@@ -1,82 +1,85 @@
import { Group, Stack, Text } from "@mantine/core";
import type { TablerIcon } from "@tabler/icons-react";
import { IconDownload } from "@tabler/icons-react";
import { Group, Kbd, Stack, Text } from "@mantine/core";
import { IconSearch } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { useScopedI18n } from "@homarr/translation/client";
import { createChildrenOptions } from "../../lib/children";
import { createGroup } from "../../lib/group";
import { interaction } from "../../lib/interaction";
// This has to be type so it can be interpreted as Record<string, unknown>.
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
type SearchEngine = {
short: string;
image: string | TablerIcon;
name: string;
description: string;
urlTemplate: string;
};
type SearchEngine = RouterOutputs["searchEngine"]["search"][number];
export const searchEnginesChildrenOptions = createChildrenOptions<SearchEngine>({
useActions: () => [
{
key: "search",
component: ({ name }) => {
const tChildren = useScopedI18n("search.mode.external.group.searchEngine.children");
return (
<Group mx="md" my="sm">
<IconSearch stroke={1.5} />
<Text>{tChildren("action.search.label", { name })}</Text>
</Group>
);
},
useInteraction: interaction.link(({ urlTemplate }, query) => ({
href: urlTemplate.replace("%s", query),
})),
},
],
detailComponent({ options }) {
const tChildren = useScopedI18n("search.mode.external.group.searchEngine.children");
return (
<Stack mx="md" my="sm">
<Text>{tChildren("detail.title")}</Text>
<Group>
<img height={24} width={24} src={options.iconUrl} alt={options.name} />
<Text>{options.name}</Text>
</Group>
</Stack>
);
},
});
export const searchEnginesSearchGroups = createGroup<SearchEngine>({
keyPath: "short",
title: (t) => t("search.mode.external.group.searchEngine.title"),
component: ({ image: Image, name, description }) => (
<Group w="100%" wrap="nowrap" justify="space-between" align="center" px="md" py="xs">
<Group wrap="nowrap">
{typeof Image === "string" ? <img height={24} width={24} src={Image} alt={name} /> : <Image size={24} />}
<Stack gap={0} justify="center">
<Text size="sm">{name}</Text>
<Text size="xs" c="gray.6">
{description}
</Text>
</Stack>
component: ({ iconUrl, name, short, description }) => {
return (
<Group w="100%" wrap="nowrap" justify="space-between" align="center" px="md" py="xs">
<Group wrap="nowrap">
<img height={24} width={24} src={iconUrl} alt={name} />
<Stack gap={0} justify="center">
<Text size="sm">{name}</Text>
<Text size="xs" c="gray.6">
{description}
</Text>
</Stack>
</Group>
<Kbd size="sm">{short}</Kbd>
</Group>
</Group>
),
filter: () => true,
);
},
onKeyDown(event, options, query, { setChildrenOptions }) {
if (event.code !== "Space") return;
const engine = options.find((option) => option.short === query);
if (!engine) return;
setChildrenOptions(searchEnginesChildrenOptions(engine));
},
useInteraction: interaction.link(({ urlTemplate }, query) => ({
href: urlTemplate.replace("%s", query),
newTab: true,
})),
useOptions() {
const tOption = useScopedI18n("search.mode.external.group.searchEngine.option");
return [
{
short: "g",
name: tOption("google.name"),
image: "https://www.google.com/favicon.ico",
description: tOption("google.description"),
urlTemplate: "https://www.google.com/search?q=%s",
},
{
short: "b",
name: tOption("bing.name"),
image: "https://www.bing.com/favicon.ico",
description: tOption("bing.description"),
urlTemplate: "https://www.bing.com/search?q=%s",
},
{
short: "d",
name: tOption("duckduckgo.name"),
image: "https://duckduckgo.com/favicon.ico",
description: tOption("duckduckgo.description"),
urlTemplate: "https://duckduckgo.com/?q=%s",
},
{
short: "t",
name: tOption("torrent.name"),
image: IconDownload,
description: tOption("torrent.description"),
urlTemplate: "https://www.torrentdownloads.pro/search/?search=%s",
},
{
short: "y",
name: tOption("youTube.name"),
image: "https://www.youtube.com/favicon.ico",
description: tOption("youTube.description"),
urlTemplate: "https://www.youtube.com/results?search_query=%s",
},
];
useQueryOptions(query) {
return clientApi.searchEngine.search.useQuery({
query: query.trim(),
limit: 5,
});
},
});

View File

@@ -66,7 +66,7 @@ const helpMode = {
</Group>
),
filter: () => true,
useInteraction: interaction.link(({ href }) => ({ href })),
useInteraction: interaction.link(({ href }) => ({ href, newTab: true })),
}),
],
} satisfies SearchMode;

View File

@@ -9,6 +9,7 @@ import {
IconMailForward,
IconPlug,
IconReport,
IconSearch,
IconSettings,
IconUsers,
IconUsersGroup,
@@ -82,6 +83,12 @@ export const pagesSearchGroup = createGroup<{
name: t("manageIntegration.label"),
hidden: !session,
},
{
icon: IconSearch,
path: "/manage/search-engines",
name: t("manageSearchEngine.label"),
hidden: !session,
},
{
icon: IconUsers,
path: "/manage/users",

View File

@@ -24,6 +24,7 @@ export default {
field: {
email: {
label: "E-Mail",
verified: "Verified",
},
username: {
label: "Username",
@@ -303,8 +304,8 @@ export default {
list: {
title: "Apps",
noResults: {
title: "There aren't any apps.",
description: "Create your first app",
title: "There aren't any apps",
action: "Create your first app",
},
},
create: {
@@ -1069,6 +1070,41 @@ export default {
internalServerError: "Failed to fetch indexers status",
},
},
healthMonitoring: {
name: "System Health Monitoring",
description: "Displays information showing the health and status of your system(s).",
option: {
fahrenheit: {
label: "CPU Temp in Fahrenheit",
},
cpu: {
label: "Show CPU Info",
},
memory: {
label: "Show Memory Info",
},
fileSystem: {
label: "Show Filesystem Info",
},
},
popover: {
information: "Information",
processor: "Processor:",
memory: "Memory:",
version: "Version:",
uptime: "Uptime: {days} days, {hours} hours",
loadAverage: "Load average:",
minute: "1 minute:",
minutes: "{count} minutes:",
used: "Used",
diskAvailable: "Available",
memAvailable: "Available:",
},
memory: {},
error: {
internalServerError: "Failed to fetch health status",
},
},
common: {
location: {
query: "City / Postal code",
@@ -1559,6 +1595,7 @@ export default {
boards: "Boards",
apps: "Apps",
integrations: "Integrations",
searchEngies: "Search engines",
users: {
label: "Users",
items: {
@@ -1842,6 +1879,9 @@ export default {
indexerManager: {
label: "Indexer Manager",
},
healthMonitoring: {
label: "Health Monitoring",
},
dnsHole: {
label: "DNS Hole Data",
},
@@ -2010,13 +2050,22 @@ export default {
label: "New",
},
},
"search-engines": {
label: "Search engines",
new: {
label: "New",
},
edit: {
label: "Edit",
},
},
apps: {
label: "Apps",
new: {
label: "New App",
label: "New",
},
edit: {
label: "Edit App",
label: "Edit",
},
},
users: {
@@ -2154,6 +2203,16 @@ export default {
group: {
searchEngine: {
title: "Search engines",
children: {
action: {
search: {
label: "Search with {name}",
},
},
detail: {
title: "Select an action for the search engine",
},
},
option: {
google: {
name: "Google",
@@ -2218,6 +2277,9 @@ export default {
manageIntegration: {
label: "Manage integrations",
},
manageSearchEngine: {
label: "Manage search engines",
},
manageUser: {
label: "Manage users",
},
@@ -2293,5 +2355,71 @@ export default {
},
},
},
engine: {
search: "Find a search engine",
field: {
name: {
label: "Name",
},
short: {
label: "Short",
},
urlTemplate: {
label: "URL search template",
},
description: {
label: "Description",
},
},
page: {
list: {
title: "Search engines",
noResults: {
title: "There aren't any search engines",
action: "Create your first search engine",
},
},
create: {
title: "New search engine",
notification: {
success: {
title: "Search engine created",
message: "The search engine was created successfully",
},
error: {
title: "Search engine not created",
message: "The search engine could not be created",
},
},
},
edit: {
title: "Edit search engine",
notification: {
success: {
title: "Changes applied successfully",
message: "The search engine was saved successfully",
},
error: {
title: "Unable to apply changes",
message: "The search engine could not be saved",
},
},
},
delete: {
title: "Delete search engine",
message: "Are you sure you want to delete the search engine '{name}'?",
notification: {
success: {
title: "Search engine deleted",
message: "The search engine was deleted successfully",
},
error: {
title: "Search engine not deleted",
message: "The search engine could not be deleted",
},
},
},
},
},
},
} as const;

View File

@@ -28,12 +28,12 @@
"@homarr/log": "workspace:^0.1.0",
"@homarr/translation": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0",
"@mantine/core": "^7.13.0",
"@mantine/dates": "^7.13.0",
"@mantine/hooks": "^7.13.0",
"@tabler/icons-react": "^3.18.0",
"@mantine/core": "^7.13.2",
"@mantine/dates": "^7.13.2",
"@mantine/hooks": "^7.13.2",
"@tabler/icons-react": "^3.19.0",
"mantine-react-table": "2.0.0-beta.6",
"next": "^14.2.13",
"next": "^14.2.14",
"react": "^18.3.1"
},
"devDependencies": {

View File

@@ -9,10 +9,7 @@ const manageAppSchema = z.object({
const editAppSchema = manageAppSchema.and(z.object({ id: z.string() }));
const byIdSchema = z.object({ id: z.string() });
export const appSchemas = {
manage: manageAppSchema,
edit: editAppSchema,
byId: byIdSchema,
};

View File

@@ -0,0 +1,22 @@
import { z } from "zod";
const paginatedSchema = z.object({
search: z.string().optional(),
pageSize: z.number().int().positive().default(10),
page: z.number().int().positive().default(1),
});
export const byIdSchema = z.object({
id: z.string(),
});
const searchSchema = z.object({
query: z.string(),
limit: z.number().int().positive().default(10),
});
export const commonSchemas = {
paginated: paginatedSchema,
byId: byIdSchema,
search: searchSchema,
};

View File

@@ -48,7 +48,7 @@ const handleStringError = (issue: z.ZodInvalidStringIssue) => {
}
return {
key: "errors.invalid_string.includes",
key: "errors.string.includes",
params: {
includes: issue.validation.includes,
},

View File

@@ -2,18 +2,9 @@ import { z } from "zod";
import { groupPermissionKeys } from "@homarr/definitions";
import { byIdSchema } from "./common";
import { zodEnumFromArray } from "./enums";
const paginatedSchema = z.object({
search: z.string().optional(),
pageSize: z.number().int().positive().default(10),
page: z.number().int().positive().default(1),
});
const byIdSchema = z.object({
id: z.string(),
});
const createSchema = z.object({
name: z.string().max(64),
});
@@ -28,8 +19,6 @@ const savePermissionsSchema = z.object({
const groupUserSchema = z.object({ groupId: z.string(), userId: z.string() });
export const groupSchemas = {
paginated: paginatedSchema,
byId: byIdSchema,
create: createSchema,
update: updateSchema,
savePermissions: savePermissionsSchema,

View File

@@ -1,9 +1,11 @@
import { appSchemas } from "./app";
import { boardSchemas } from "./board";
import { commonSchemas } from "./common";
import { groupSchemas } from "./group";
import { iconsSchemas } from "./icons";
import { integrationSchemas } from "./integration";
import { locationSchemas } from "./location";
import { searchEngineSchemas } from "./search-engine";
import { userSchemas } from "./user";
import { widgetSchemas } from "./widgets";
@@ -16,15 +18,17 @@ export const validation = {
widget: widgetSchemas,
location: locationSchemas,
icons: iconsSchemas,
searchEngine: searchEngineSchemas,
common: commonSchemas,
};
export {
createSectionSchema,
sharedItemSchema,
itemAdvancedOptionsSchema,
type BoardItemIntegration,
type BoardItemAdvancedOptions,
} from "./shared";
export { passwordRequirements } from "./user";
export { oldmarrImportConfigurationSchema, superRefineJsonImportFile } from "./board";
export type { OldmarrImportConfiguration } from "./board";
export {
createSectionSchema,
itemAdvancedOptionsSchema,
sharedItemSchema,
type BoardItemAdvancedOptions,
type BoardItemIntegration,
} from "./shared";
export { passwordRequirements } from "./user";

View File

@@ -0,0 +1,20 @@
import { z } from "zod";
const manageSearchEngineSchema = z.object({
name: z.string().min(1).max(64),
short: z.string().min(1).max(8),
iconUrl: z.string().min(1),
urlTemplate: z.string().min(1).startsWith("http").includes("%s"),
description: z.string().max(512).nullable(),
});
const editSearchEngineSchema = manageSearchEngineSchema
.extend({
id: z.string(),
})
.omit({ short: true });
export const searchEngineSchemas = {
manage: manageSearchEngineSchema,
edit: editSearchEngineSchema,
};

View File

@@ -38,28 +38,28 @@
"@homarr/translation": "workspace:^0.1.0",
"@homarr/ui": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0",
"@mantine/core": "^7.13.0",
"@mantine/hooks": "^7.13.0",
"@tabler/icons-react": "^3.18.0",
"@tiptap/extension-color": "2.7.4",
"@tiptap/extension-highlight": "2.7.4",
"@tiptap/extension-image": "2.7.4",
"@tiptap/extension-link": "^2.7.4",
"@tiptap/extension-table": "2.7.4",
"@tiptap/extension-table-cell": "2.7.4",
"@tiptap/extension-table-header": "2.7.4",
"@tiptap/extension-table-row": "2.7.4",
"@tiptap/extension-task-item": "2.7.4",
"@tiptap/extension-task-list": "2.7.4",
"@tiptap/extension-text-align": "2.7.4",
"@tiptap/extension-text-style": "2.7.4",
"@tiptap/extension-underline": "2.7.4",
"@tiptap/react": "^2.7.4",
"@tiptap/starter-kit": "^2.7.4",
"@mantine/core": "^7.13.2",
"@mantine/hooks": "^7.13.2",
"@tabler/icons-react": "^3.19.0",
"@tiptap/extension-color": "2.8.0",
"@tiptap/extension-highlight": "2.8.0",
"@tiptap/extension-image": "2.8.0",
"@tiptap/extension-link": "^2.8.0",
"@tiptap/extension-table": "2.8.0",
"@tiptap/extension-table-cell": "2.8.0",
"@tiptap/extension-table-header": "2.8.0",
"@tiptap/extension-table-row": "2.8.0",
"@tiptap/extension-task-item": "2.8.0",
"@tiptap/extension-task-list": "2.8.0",
"@tiptap/extension-text-align": "2.8.0",
"@tiptap/extension-text-style": "2.8.0",
"@tiptap/extension-underline": "2.8.0",
"@tiptap/react": "^2.8.0",
"@tiptap/starter-kit": "^2.8.0",
"clsx": "^2.1.1",
"dayjs": "^1.11.13",
"mantine-react-table": "2.0.0-beta.6",
"next": "^14.2.13",
"next": "^14.2.14",
"react": "^18.3.1",
"video.js": "^8.17.4"
},

View File

@@ -0,0 +1,359 @@
"use client";
import {
Avatar,
Box,
Card,
Center,
Divider,
Flex,
Group,
Indicator,
List,
Modal,
Progress,
RingProgress,
Stack,
Text,
Tooltip,
} from "@mantine/core";
import { useDisclosure, useElementSize, useListState } from "@mantine/hooks";
import {
IconBrain,
IconClock,
IconCpu,
IconCpu2,
IconFileReport,
IconInfoCircle,
IconServer,
IconTemperature,
IconVersions,
} from "@tabler/icons-react";
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">) {
const t = useI18n();
const [healthData] = useListState(serverData?.initialData ?? []);
const [opened, { open, close }] = useDisclosure(false);
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);
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;
return (
<Box
key={integrationId}
h="100%"
className={`health-monitoring-information health-monitoring-${integrationName}`}
>
<Card className="health-monitoring-information-card" m="2.5cqmin" p="2.5cqmin" withBorder>
<Flex
className="health-monitoring-information-card-elements"
h="100%"
w="100%"
justify="space-between"
align="center"
key={integrationId}
>
<Box className="health-monitoring-information-card-section">
<Indicator
className="health-monitoring-updates-reboot-indicator"
inline
processing
color={healthInfo.rebootRequired ? "red" : healthInfo.availablePkgUpdates > 0 ? "blue" : "gray"}
position="top-end"
size="4cqmin"
label={healthInfo.availablePkgUpdates > 0 ? healthInfo.availablePkgUpdates : undefined}
disabled={!healthInfo.rebootRequired && healthInfo.availablePkgUpdates === 0}
>
<Avatar className="health-monitoring-information-icon-avatar" size="10cqmin" radius="sm">
<IconInfoCircle className="health-monitoring-information-icon" size="8cqmin" onClick={open} />
</Avatar>
</Indicator>
<Modal
opened={opened}
onClose={close}
size="auto"
title={t("widget.healthMonitoring.popover.information")}
centered
>
<Stack gap="10px" className="health-monitoring-modal-stack">
<Divider />
<List className="health-monitoring-information-list" center spacing="0.5cqmin">
<List.Item
className="health-monitoring-information-processor"
icon={<IconCpu2 size="1.5cqmin" />}
>
{t("widget.healthMonitoring.popover.processor")} {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}%)
</List.Item>
<List.Item
className="health-monitoring-information-version"
icon={<IconVersions size="1.5cqmin" />}
>
{t("widget.healthMonitoring.popover.version")} {healthInfo.version}
</List.Item>
<List.Item
className="health-monitoring-information-uptime"
icon={<IconClock size="1.5cqmin" />}
>
{formatUptime(healthInfo.uptime, t)}
</List.Item>
<List.Item
className="health-monitoring-information-load-average"
icon={<IconCpu size="1.5cqmin" />}
>
{t("widget.healthMonitoring.popover.loadAverage")}
</List.Item>
<List m="0.5cqmin" withPadding center spacing="0.5cqmin" icon={<IconCpu size="1cqmin" />}>
<List.Item className="health-monitoring-information-load-average-1min">
{t("widget.healthMonitoring.popover.minute")} {healthInfo.loadAverage["1min"]}
</List.Item>
<List.Item className="health-monitoring-information-load-average-5min">
{t("widget.healthMonitoring.popover.minutes", { count: 5 })}{" "}
{healthInfo.loadAverage["5min"]}
</List.Item>
<List.Item className="health-monitoring-information-load-average-15min">
{t("widget.healthMonitoring.popover.minutes", { count: 15 })}{" "}
{healthInfo.loadAverage["15min"]}
</List.Item>
</List>
</List>
</Stack>
</Modal>
</Box>
{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>
)}
{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>
)}
</Flex>
</Card>
{options.fileSystem &&
disksData.map((disk) => {
return (
<Card
className="health-monitoring-disk-card"
key={disk.deviceName}
m="2.5cqmin"
p="2.5cqmin"
withBorder
>
<Flex className="health-monitoring-disk-status" justify="space-between" align="center" m="1.5cqmin">
<Group gap="1cqmin">
<IconServer className="health-monitoring-disk-icon" size="5cqmin" />
<Text className="dihealth-monitoring-disk-name" size="4cqmin">
{disk.deviceName}
</Text>
</Group>
<Group gap="1cqmin">
<IconTemperature className="health-monitoring-disk-temperature-icon" size="5cqmin" />
<Text className="health-monitoring-disk-temperature-value" size="4cqmin">
{options.fahrenheit
? `${(disk.temperature * 1.8 + 32).toFixed(1)}°F`
: `${disk.temperature}°C`}
</Text>
</Group>
<Group gap="1cqmin">
<IconFileReport className="health-monitoring-disk-status-icon" size="5cqmin" />
<Text className="health-monitoring-disk-status-value" size="4cqmin">
{disk.overallStatus}
</Text>
</Group>
</Flex>
<Progress.Root className="health-monitoring-disk-use" size={progressSize}>
<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">
{t("widget.healthMonitoring.popover.used")}
</Progress.Label>
</Progress.Section>
</Tooltip>
<Tooltip
label={
Number(disk.available) / 1024 ** 4 >= 1
? `${(Number(disk.available) / 1024 ** 4).toFixed(2)} TiB`
: `${(Number(disk.available) / 1024 ** 3).toFixed(2)} GiB`
}
>
<Progress.Section
className="health-monitoring-disk-available-percentage"
value={100 - disk.percentage}
color="default"
>
<Progress.Label className="health-monitoring-disk-available-value">
{t("widget.healthMonitoring.popover.diskAvailable")}
</Progress.Label>
</Progress.Section>
</Tooltip>
</Progress.Root>
</Card>
);
})}
</Box>
);
})}
</Box>
);
}
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 });
};
export const progressColor = (percentage: number) => {
if (percentage < 40) return "green";
else if (percentage < 60) return "yellow";
else if (percentage < 90) return "orange";
else return "red";
};
interface FileSystem {
deviceName: string;
used: string;
available: string;
percentage: number;
}
interface SmartData {
deviceName: string;
temperature: number;
overallStatus: string;
}
export const matchFileSystemAndSmart = (fileSystems: FileSystem[], smartData: SmartData[]) => {
return fileSystems.map((fileSystem) => {
const baseDeviceName = fileSystem.deviceName.replace(/[0-9]+$/, "");
const smartDisk = smartData.find((smart) => smart.deviceName === baseDeviceName);
return {
deviceName: smartDisk?.deviceName ?? fileSystem.deviceName,
used: fileSystem.used,
available: fileSystem.available,
percentage: fileSystem.percentage,
temperature: smartDisk?.temperature ?? 0,
overallStatus: smartDisk?.overallStatus ?? "",
};
});
};
export const formatMemoryUsage = (memFree: string, memUsed: string) => {
const memFreeBytes = Number(memFree);
const memUsedBytes = Number(memUsed);
const totalMemory = memFreeBytes + memUsedBytes;
const memFreeGB = (memFreeBytes / 1024 ** 3).toFixed(2);
const memUsedGB = (memUsedBytes / 1024 ** 3).toFixed(2);
const memFreePercent = Math.round((memFreeBytes / totalMemory) * 100);
const memUsedPercent = Math.round((memUsedBytes / totalMemory) * 100);
const memTotalGB = (totalMemory / 1024 ** 3).toFixed(2);
return {
memFree: { percent: memFreePercent, GB: memFreeGB },
memUsed: { percent: memUsedPercent, GB: memUsedGB },
memTotal: { GB: memTotalGB },
};
};

View File

@@ -0,0 +1,31 @@
import { IconHeartRateMonitor, IconServerOff } from "@tabler/icons-react";
import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options";
export const { definition, componentLoader, serverDataLoader } = createWidgetDefinition("healthMonitoring", {
icon: IconHeartRateMonitor,
options: optionsBuilder.from((factory) => ({
fahrenheit: factory.switch({
defaultValue: false,
}),
cpu: factory.switch({
defaultValue: true,
}),
memory: factory.switch({
defaultValue: true,
}),
fileSystem: factory.switch({
defaultValue: true,
}),
})),
supportedIntegrations: ["openmediavault"],
errors: {
INTERNAL_SERVER_ERROR: {
icon: IconServerOff,
message: (t) => t("widget.healthMonitoring.error.internalServerError"),
},
},
})
.withServerData(() => import("./serverData"))
.withDynamicImport(() => import("./component"));

View File

@@ -0,0 +1,27 @@
"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

@@ -12,6 +12,7 @@ import type { WidgetComponentProps } from "./definition";
import * as dnsHoleControls from "./dns-hole/controls";
import * as dnsHoleSummary from "./dns-hole/summary";
import * as downloads from "./downloads";
import * as healthMonitoring from "./health-monitoring";
import * as iframe from "./iframe";
import type { WidgetImportRecord } from "./import";
import * as indexerManager from "./indexer-manager";
@@ -51,6 +52,7 @@ export const widgetImports = {
"mediaRequests-requestStats": mediaRequestsStats,
rssFeed,
indexerManager,
healthMonitoring,
} satisfies WidgetImportRecord;
export type WidgetImports = typeof widgetImports;

1692
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,7 @@
// This script generates a random encryption key
// This key is used to encrypt and decrypt the integration secrets
// In production it is generated in run.sh and stored in the environment variable ENCRYPTION_KEY
// during runtime, it's also stored in a file.
const crypto = require("crypto");
console.log(crypto.randomBytes(32).toString("hex"));

View File

@@ -6,6 +6,19 @@ else
node ./db/migrations/$DB_DIALECT/migrate.cjs ./db/migrations/$DB_DIALECT
fi
# Generates an encryption key if it doesn't exist and saves it to /secrets/encryptionKey
# Also sets the ENCRYPTION_KEY environment variable
encryptionKey=""
if [ -r /secrets/encryptionKey ]; then
echo "Encryption key already exists"
encryptionKey=$(cat /secrets/encryptionKey)
else
echo "Generating encryption key"
encryptionKey=$(node ./generateEncryptionKey.js)
echo $encryptionKey > /secrets/encryptionKey
fi
export ENCRYPTION_KEY=$encryptionKey
# Start nginx proxy
# 1. Replace the HOSTNAME in the nginx template file
# 2. Create the nginx configuration file from the template

View File

@@ -16,14 +16,14 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@next/eslint-plugin-next": "^14.2.13",
"@next/eslint-plugin-next": "^14.2.14",
"eslint-config-prettier": "^9.1.0",
"eslint-config-turbo": "^2.1.2",
"eslint-plugin-import": "^2.30.0",
"eslint-config-turbo": "^2.1.3",
"eslint-plugin-import": "^2.31.0",
"eslint-plugin-jsx-a11y": "^6.10.0",
"eslint-plugin-react": "^7.37.0",
"eslint-plugin-react": "^7.37.1",
"eslint-plugin-react-hooks": "^4.6.2",
"typescript-eslint": "^8.7.0"
"typescript-eslint": "^8.8.0"
},
"devDependencies": {
"@homarr/prettier-config": "workspace:^0.1.0",