mirror of
https://github.com/ajnart/homarr.git
synced 2026-02-01 04:09:12 +01:00
chore(release): automatic release v0.1.0
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -56,6 +56,7 @@ yarn-error.log*
|
||||
apps/tasks/tasks.cjs
|
||||
apps/websocket/wssServer.cjs
|
||||
apps/nextjs/.million/
|
||||
packages/cli/cli.cjs
|
||||
|
||||
|
||||
#personal backgrounds
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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"),
|
||||
|
||||
73
apps/nextjs/src/app/[locale]/manage/search-engines/_form.tsx
Normal file
73
apps/nextjs/src/app/[locale]/manage/search-engines/_form.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
139
apps/nextjs/src/app/[locale]/manage/search-engines/page.tsx
Normal file
139
apps/nextjs/src/app/[locale]/manage/search-engines/page.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
91
apps/nextjs/src/app/api/health/live/route.ts
Normal file
91
apps/nextjs/src/app/api/health/live/route.ts
Normal 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;
|
||||
}
|
||||
5
apps/nextjs/src/app/api/health/ready/route.ts
Normal file
5
apps/nextjs/src/app/api/health/ready/route.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export function GET() {
|
||||
return new Response(undefined, {
|
||||
status: 200,
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
18
package.json
18
package.json
@@ -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": {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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));
|
||||
}),
|
||||
});
|
||||
52
packages/api/src/router/widgets/health-monitoring.ts
Normal file
52
packages/api/src/router/widgets/health-monitoring.ts
Normal 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();
|
||||
});
|
||||
};
|
||||
});
|
||||
}),
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
9
packages/db/migrations/mysql/0008_far_lifeguard.sql
Normal file
9
packages/db/migrations/mysql/0008_far_lifeguard.sql
Normal 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`)
|
||||
);
|
||||
1429
packages/db/migrations/mysql/meta/0008_snapshot.json
Normal file
1429
packages/db/migrations/mysql/meta/0008_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -57,6 +57,13 @@
|
||||
"when": 1723749320706,
|
||||
"tag": "0007_boring_nocturne",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 8,
|
||||
"version": "5",
|
||||
"when": 1727532165317,
|
||||
"tag": "0008_far_lifeguard",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
8
packages/db/migrations/sqlite/0008_third_thor.sql
Normal file
8
packages/db/migrations/sqlite/0008_third_thor.sql
Normal 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
|
||||
);
|
||||
1367
packages/db/migrations/sqlite/meta/0008_snapshot.json
Normal file
1367
packages/db/migrations/sqlite/meta/0008_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -57,6 +57,13 @@
|
||||
"when": 1723746828385,
|
||||
"tag": "0007_known_ultragirl",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 8,
|
||||
"version": "6",
|
||||
"when": 1727526190343,
|
||||
"tag": "0008_third_thor",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -16,5 +16,6 @@ export const widgetKinds = [
|
||||
"mediaRequests-requestStats",
|
||||
"rssFeed",
|
||||
"indexerManager",
|
||||
"healthMonitoring",
|
||||
] as const;
|
||||
export type WidgetKind = (typeof widgetKinds)[number];
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>>;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
}[];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
}),
|
||||
});
|
||||
@@ -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";
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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 }>(
|
||||
|
||||
@@ -260,3 +260,7 @@ export const createQueueChannel = <TItem>(name: string) => {
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const handshakeAsync = async () => {
|
||||
await getSetClient.hello();
|
||||
};
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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>
|
||||
));
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -66,7 +66,7 @@ const helpMode = {
|
||||
</Group>
|
||||
),
|
||||
filter: () => true,
|
||||
useInteraction: interaction.link(({ href }) => ({ href })),
|
||||
useInteraction: interaction.link(({ href }) => ({ href, newTab: true })),
|
||||
}),
|
||||
],
|
||||
} satisfies SearchMode;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
22
packages/validation/src/common.ts
Normal file
22
packages/validation/src/common.ts
Normal 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,
|
||||
};
|
||||
@@ -48,7 +48,7 @@ const handleStringError = (issue: z.ZodInvalidStringIssue) => {
|
||||
}
|
||||
|
||||
return {
|
||||
key: "errors.invalid_string.includes",
|
||||
key: "errors.string.includes",
|
||||
params: {
|
||||
includes: issue.validation.includes,
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
20
packages/validation/src/search-engine.ts
Normal file
20
packages/validation/src/search-engine.ts
Normal 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,
|
||||
};
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
359
packages/widgets/src/health-monitoring/component.tsx
Normal file
359
packages/widgets/src/health-monitoring/component.tsx
Normal 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 },
|
||||
};
|
||||
};
|
||||
31
packages/widgets/src/health-monitoring/index.ts
Normal file
31
packages/widgets/src/health-monitoring/index.ts
Normal 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"));
|
||||
27
packages/widgets/src/health-monitoring/serverData.ts
Normal file
27
packages/widgets/src/health-monitoring/serverData.ts
Normal 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: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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
1692
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
7
scripts/generateEncryptionKey.js
Normal file
7
scripts/generateEncryptionKey.js
Normal 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"));
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user