Compare commits

...
62 Commits
Author SHA1 Message Date
Releases Homarr 81a8cf4b36 chore(release): 1.0.1 [skip ci] 2025-01-17 19:14:12 +00:00
homarr-releases[bot] b5c008b4ad chore(release): automatic release v1.0.1 2025-01-17 19:13:04 +00:00
Toine Rademacher (toineenzo)andMeier Lukas 343438391b chore: Fixed broken links in README.md (#1985)
Co-authored-by: Meier Lukas <meierschlumpf@gmail.com>
2025-01-17 17:39:40 +00:00
Toine Rademacher (toineenzo) 8400b9fb11 chore: Change icon and issue link to new repo in layout.tsx and index.tsx (#1986) 2025-01-17 17:35:20 +00:00
Meier Lukas fe65e73fff chore: add version 1.0.0 to bug issue template (#1983) 2025-01-17 18:23:58 +01:00
Meier Lukas e023edf9d6 fix: add missing old-import proxmox integration kind mapping (#1979) 2025-01-17 16:12:02 +01:00
Meier Lukas 927913cf4e fix: add error from ldap bind operation to log statement (#1980) 2025-01-17 16:11:50 +01:00
Meier Lukas 07d8103051 chore: update docs sitemap (#1982) 2025-01-17 16:11:29 +01:00
homarr-renovate[bot] fe08ccf589 chore(deps): update vitest monorepo to v3 (major) (#1964)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-01-17 16:10:59 +01:00
homarr-renovate[bot] c55207bd59 fix(deps): update dependency flag-icons to ^7.3.2 (#1981)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-01-17 15:09:46 +00:00
homarr-renovate[bot] e35811c921 fix(deps): update nextjs monorepo to ^15.1.5 (#1978)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-01-17 15:09:21 +00:00
Meier Lukas e481e116ff Merge branch 'main' into dev 2025-01-17 13:52:59 +01:00
Meier Lukas 32be09a4a7 docs: remove beta banner from readme 2025-01-17 13:51:34 +01:00
Releases Homarr d0a20dbfc5 chore(release): 1.0.0 [skip ci] 2025-01-17 12:47:30 +00:00
homarr-releases[bot] 68329965f7 chore(release): automatic release v1.0.0 2025-01-17 12:46:22 +00:00
Meier Lukas f9fea70d39 chore: change documentation base url to homarr.dev (#1826) 2025-01-17 13:45:18 +01:00
Meier Lukas 3ed46aecbd feat(widget): add proxmox integration (#1969)
* feat(widget): add proxmox integration

* fix: broken lock file

* fix: ci issues

* fix: ci issues

* fix: ci issues

* chore: debug temporary

* fix: name is not used correctly for nodes and storage in proxmox

* fix: remove temporary debu logs

* fix: job runs for both cluster and system health and throws error

* fix: ts-expect-error is unnecessary

* fix: remove unused import
2025-01-17 13:01:04 +01:00
Meier Lukas a31c6a97e0 ci: change weekly release base to main branch (#1976) 2025-01-17 12:59:02 +01:00
homarr-crowdin[bot]andMeier Lukas d1be727eb3 chore(lang): updated translations from crowdin
Co-authored-by: Crowdin Homarr <190541745+homarr-crowdin[bot]@users.noreply.github.com>
Co-authored-by: Meier Lukas <meierschlumpf@gmail.com>
2025-01-17 11:54:32 +00:00
Meier Lukas a1084f91e5 fix: mysql transactions do not work with run property of sqlite (#1974)
* fix: mysql transactions do not work with run property of sqlite

* fix: ci issues
2025-01-17 12:53:01 +01:00
Meier Lukas 7622f0a4d2 fix: websockets not working extend not defined (#1975)
* fix: websockets not working extend not defined

* fix: revert dockerode to 4.0.2
2025-01-17 12:52:42 +01:00
homarr-renovate[bot] 34cacb3153 fix(deps): update dependency flag-icons to ^7.3.1 (#1970)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-01-17 02:57:17 +00:00
homarr-renovate[bot] 48cf5d5304 chore(deps): update dependency prettier-plugin-packagejson to ^2.5.8 (#1973)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-01-17 02:14:40 +00:00
Meier Lukas 8c36c3e36b feat(certificates): handle self signed certificates (#1951)
* wip: add page and loading of certificates in folder

* wip: add certificate addition and removal

* feat: add removal ui for certificates

* feat: migrate integrations to fetch or agent with trusted certificates

* fix: lock file issues

* fix: typecheck issue

* fix: inconsistent package versions

* chore: address pull request feedback

* fix: add missing navigation item and restrict access to page

* chore: address pull request feedback

* fix: inconsistent undici dependency version

* fix: inconsistent undici dependency version
2025-01-17 00:08:40 +01:00
homarr-renovate[bot] b10b2013af fix(deps): update dependency drizzle-orm to ^0.38.4 (#1967)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-01-16 15:33:39 +00:00
homarr-renovate[bot] ea8acc83ea fix(deps): update dependency flag-icons to ^7.3.0 (#1966)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-01-16 14:44:14 +00:00
homarr-renovate[bot] cb96508634 fix(deps): update dependency drizzle-kit to ^0.30.2 (#1965)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-01-16 14:34:48 +00:00
homarr-renovate[bot] 5e83847772 fix(deps): update dependency undici to v7.2.3 (#1963)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-01-16 09:32:31 +00:00
homarr-renovate[bot] 1557537763 chore(deps): update dependency @types/node to ^22.10.7 (#1962)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-01-16 00:54:29 +00:00
homarr-crowdin[bot] 5f993d2630 chore(lang): updated translations from crowdin
Co-authored-by: Crowdin Homarr <190541745+homarr-crowdin[bot]@users.noreply.github.com>
2025-01-16 00:43:40 +00:00
homarr-renovate[bot] 44767c46c2 fix(deps): update dependency flag-icons to ^7.2.4 (#1960)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-01-15 22:53:09 +00:00
homarr-renovate[bot] aaddf0d987 fix(deps): update dependency undici to v7.2.2 (#1957)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-01-15 17:41:33 +00:00
homarr-crowdin[bot] 7f8e6e4ea1 chore(lang): updated translations from crowdin
Co-authored-by: Crowdin Homarr <190541745+homarr-crowdin[bot]@users.noreply.github.com>
2025-01-15 16:13:54 +00:00
homarr-renovate[bot] 34a76c98e8 fix(deps): update dependency better-sqlite3 to ^11.8.0 (#1956)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-01-15 03:17:53 +00:00
homarr-renovate[bot] 0f82a6f63d chore(deps): update dependency prettier-plugin-packagejson to ^2.5.7 (#1954)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2025-01-14 20:42:28 +00:00
homarr-releases[bot] fac522adb9 chore(release): automatic release v0.1.0 2024-12-13 19:13:52 +00:00
homarr-releases[bot] 9945425eff chore(release): automatic release v0.1.0 2024-12-06 19:14:14 +00:00
homarr-releases[bot] 8cd5833281 chore(release): automatic release v0.1.0 2024-11-29 19:15:06 +00:00
homarr-releases[bot] ef7174aa36 chore(release): automatic release v0.1.0 2024-11-22 19:14:01 +00:00
homarr-releases[bot] 4ce76f8a73 chore(release): automatic release v0.1.0 2024-11-15 19:13:45 +00:00
homarr-releases[bot] 3e65af9ebe chore(release): automatic release v0.1.0 2024-11-08 19:12:25 +00:00
homarr-releases[bot] c6e5bec348 chore(release): automatic release v0.1.0 2024-11-04 19:34:22 +00:00
homarr-releases[bot] 3e74c065f5 chore(release): automatic release v0.1.0 2024-11-04 18:21:54 +00:00
homarr-releases[bot] 14ae732d0c chore(release): automatic release v0.1.0 2024-11-01 19:13:45 +00:00
homarr-releases[bot] b97883f44d chore(release): automatic release v0.1.0 2024-10-25 19:12:51 +00:00
homarr-releases[bot] 741753fa74 chore(release): automatic release v0.1.0 2024-10-18 19:13:17 +00:00
homarr-releases[bot] 1f0f8912b6 chore(release): automatic release v0.1.0 2024-10-11 21:37:04 +00:00
homarr-releases[bot] 49d6932218 chore(release): automatic release v0.1.0 2024-10-11 19:12:56 +00:00
homarr-releases[bot] f13fefa64d chore(release): automatic release v0.1.0 2024-10-04 19:33:01 +00:00
homarr-releases[bot] cef567efdf chore(release): automatic release v0.1.0 2024-10-04 19:12:57 +00:00
homarr-releases[bot] db2b79ee7f chore(release): automatic release v0.1.0 2024-09-27 19:13:34 +00:00
Meier Lukas b46d30b04c chore(release): release v0.1.0 2024-09-21 11:42:43 +02:00
homarr-releases[bot] de4283b5fa chore(release): automatic release v0.1.0 2024-09-20 19:13:13 +00:00
homarr-releases[bot] a08d3e387f chore(release): automatic release v0.1.0 2024-09-13 19:12:49 +00:00
homarr-releases[bot] bd1ff01109 chore(release): automatic release v0.1.0 2024-09-06 19:13:34 +00:00
homarr-releases[bot] b24b1cc8a9 chore(release): automatic release v0.1.0 2024-08-30 19:11:57 +00:00
homarr-releases[bot] e81edc898f chore(release): automatic release v0.1.0 2024-08-23 19:11:47 +00:00
homarr-releases[bot] 4d864878e3 chore(release): automatic release v0.1.0 2024-08-16 19:13:19 +00:00
homarr-releases[bot] 5776ead3e3 chore(release): automatic release v0.1.0 2024-08-09 19:12:44 +00:00
homarr-releases[bot] baf32c3fe6 chore(release): automatic release v0.1.0 2024-08-02 19:52:15 +00:00
Meier Lukas ea408a0fdd Merge pull request #570 from homarr-labs/dev
chore: merge to main
2024-05-28 21:50:27 +02:00
Manuel 17f93097f6 Merge pull request #547 from homarr-labs/dev
chore: merge to main
2024-05-24 22:16:13 +02:00
124 changed files with 6268 additions and 1415 deletions

No files matched your search

+4
View File
@@ -28,6 +28,10 @@ DB_URL='FULL_PATH_TO_YOUR_SQLITE_DB_FILE'
# DB_PASSWORD='password'
# DB_NAME='name-of-database'
# The below path can be used to store trusted certificates during development, it is not required and can be left empty.
# If it is used, please use the full path to the directory where the certificates are stored.
# LOCAL_CERTIFICATE_PATH='FULL_PATH_TO_CERTIFICATES'
TURBO_TELEMETRY_DISABLED=1
# Configure logging to use winston logger
+2 -1
View File
@@ -32,6 +32,7 @@ body:
description: What version of Homarr are you running?
options:
- 1.0.0-beta
- 1.0.0
- Other (describe in "additional information")
default: 0
validations:
@@ -75,4 +76,4 @@ body:
- Other (describe in "additional information")
default: 0
validations:
required: false
required: false
+5
View File
@@ -6,6 +6,11 @@
matchPackagePatterns: ["^@homarr/"],
enabled: false,
},
// Disable Dockerode updates see https://github.com/apocas/dockerode/issues/787
{
matchPackagePatterns: ["^dockerode$"],
enabled: false,
},
{
matchUpdateTypes: ["minor", "patch", "pin", "digest"],
automerge: true,
@@ -37,7 +37,7 @@ jobs:
token: ${{ github.token }}
branch: dev
- name: Create pull request
run: 'gh pr create --title "chore(release): automatic release ${{ steps.semver.outputs.next }}" --body "**This is an automatic release**.<br/>Manual action may be required for major bumps.<br/>Detected change to be ``${{ steps.semver.outputs.bump }}``<br/>Bump version from ``${{ steps.semver.outputs.current }}`` to ``${{ steps.semver.outputs.next }}``" --base beta --head dev --label automerge'
run: 'gh pr create --title "chore(release): automatic release ${{ steps.semver.outputs.next }}" --body "**This is an automatic release**.<br/>Manual action may be required for major bumps.<br/>Detected change to be ``${{ steps.semver.outputs.bump }}``<br/>Bump version from ``${{ steps.semver.outputs.current }}`` to ``${{ steps.semver.outputs.next }}``" --base main --head dev --label automerge'
env:
GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
- name: Discord notification
+1 -1
View File
@@ -64,4 +64,4 @@ packages/cli/cli.cjs
e2e/shared/tmp
#personal backgrounds
apps/nextjs/public/images/background.png
apps/nextjs/public/images/background.png
+1197
View File
File diff suppressed because it is too large. Load diff
+4 -3
View File
@@ -18,6 +18,7 @@
"@homarr/analytics": "workspace:^0.1.0",
"@homarr/api": "workspace:^0.1.0",
"@homarr/auth": "workspace:^0.1.0",
"@homarr/certificates": "workspace:^0.1.0",
"@homarr/common": "workspace:^0.1.0",
"@homarr/cron-job-status": "workspace:^0.1.0",
"@homarr/db": "workspace:^0.1.0",
@@ -61,11 +62,11 @@
"clsx": "^2.1.1",
"dayjs": "^1.11.13",
"dotenv": "^16.4.7",
"flag-icons": "^7.2.3",
"flag-icons": "^7.3.2",
"glob": "^11.0.1",
"jotai": "^2.11.0",
"mantine-react-table": "2.0.0-beta.8",
"next": "15.1.4",
"next": "15.1.5",
"postcss-preset-mantine": "^1.17.0",
"prismjs": "^1.29.0",
"react": "19.0.0",
@@ -82,7 +83,7 @@
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/chroma-js": "3.1.0",
"@types/node": "^22.10.6",
"@types/node": "^22.10.7",
"@types/prismjs": "^1.26.5",
"@types/react": "19.0.7",
"@types/react-dom": "19.0.3",
@@ -1,4 +1,4 @@
import { IconKey, IconPassword, IconUser } from "@tabler/icons-react";
import { IconGrid3x3, IconKey, IconPassword, IconServer, IconUser } from "@tabler/icons-react";
import type { IntegrationSecretKind } from "@homarr/definitions";
import type { TablerIcon } from "@homarr/ui";
@@ -7,4 +7,6 @@ export const integrationSecretIcons = {
username: IconUser,
apiKey: IconKey,
password: IconPassword,
realm: IconServer,
tokenId: IconGrid3x3,
} satisfies Record<IntegrationSecretKind, TablerIcon>;
@@ -6,6 +6,7 @@ import {
IconBrandDiscord,
IconBrandDocker,
IconBrandGithub,
IconCertificate,
IconGitFork,
IconHome,
IconInfoSmall,
@@ -119,6 +120,12 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
href: "/manage/tools/logs",
hidden: !session?.user.permissions.includes("other-view-logs"),
},
{
label: t("items.tools.items.certificates"),
icon: IconCertificate,
href: "/manage/tools/certificates",
hidden: !session?.user.permissions.includes("admin"),
},
{
label: t("items.tools.items.tasks"),
icon: IconReport,
@@ -146,7 +153,7 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
{
label: t("items.help.items.submitIssue"),
icon: IconBrandGithub,
href: "https://github.com/ajnart/homarr/issues/new/choose",
href: "https://github.com/homarr-labs/homarr/issues/new/choose",
external: true,
},
{
@@ -158,7 +165,7 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
{
label: t("items.help.items.sourceCode"),
icon: IconGitFork,
href: "https://github.com/ajnart/homarr",
href: "https://github.com/homarr-labs/homarr",
external: true,
},
],
@@ -0,0 +1,81 @@
"use client";
import { Button, FileInput, Group, Stack } from "@mantine/core";
import { IconCertificate } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useZodForm } from "@homarr/form";
import { createModal, useModalAction } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { superRefineCertificateFile, z } from "@homarr/validation";
export const AddCertificateButton = () => {
const { openModal } = useModalAction(AddCertificateModal);
const t = useI18n();
const handleClick = () => {
openModal({});
};
return <Button onClick={handleClick}>{t("certificate.action.create.label")}</Button>;
};
const AddCertificateModal = createModal(({ actions }) => {
const t = useI18n();
const form = useZodForm(
z.object({
file: z.instanceof(File).nullable().superRefine(superRefineCertificateFile),
}),
{
initialValues: {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
file: null!,
},
},
);
const { mutateAsync } = clientApi.certificates.addCertificate.useMutation();
return (
<form
onSubmit={form.onSubmit(async (values) => {
const formData = new FormData();
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
formData.set("file", values.file!);
await mutateAsync(formData, {
async onSuccess() {
showSuccessNotification({
title: t("certificate.action.create.notification.success.title"),
message: t("certificate.action.create.notification.success.message"),
});
await revalidatePathActionAsync("/manage/tools/certificates");
actions.closeModal();
},
onError() {
showErrorNotification({
title: t("certificate.action.create.notification.error.title"),
message: t("certificate.action.create.notification.error.message"),
});
},
});
})}
>
<Stack>
<FileInput leftSection={<IconCertificate size={16} />} {...form.getInputProps("file")} />
<Group justify="end">
<Button onClick={actions.closeModal} variant="subtle" color="gray">
{t("common.action.cancel")}
</Button>
<Button type="submit" loading={form.submitting}>
{t("common.action.add")}
</Button>
</Group>
</Stack>
</form>
);
}).withOptions({
defaultTitle(t) {
return t("certificate.action.create.label");
},
});
@@ -0,0 +1,54 @@
"use client";
import { ActionIcon } from "@mantine/core";
import { IconTrash } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useConfirmModal } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
interface RemoveCertificateProps {
fileName: string;
}
export const RemoveCertificate = ({ fileName }: RemoveCertificateProps) => {
const { openConfirmModal } = useConfirmModal();
const { mutateAsync } = clientApi.certificates.removeCertificate.useMutation();
const t = useI18n();
const handleClick = () => {
openConfirmModal({
title: t("certificate.action.remove.label"),
children: t("certificate.action.remove.confirm"),
// eslint-disable-next-line no-restricted-syntax
async onConfirm() {
await mutateAsync(
{ fileName },
{
async onSuccess() {
showSuccessNotification({
title: t("certificate.action.remove.notification.success.title"),
message: t("certificate.action.remove.notification.success.message"),
});
await revalidatePathActionAsync("/manage/tools/certificates");
},
onError() {
showErrorNotification({
title: t("certificate.action.remove.notification.error.title"),
message: t("certificate.action.remove.notification.error.message"),
});
},
},
);
},
});
};
return (
<ActionIcon onClick={handleClick} color="red" variant="subtle">
<IconTrash color="red" size={16} />
</ActionIcon>
);
};
@@ -0,0 +1,97 @@
import { X509Certificate } from "node:crypto";
import { notFound } from "next/navigation";
import { Card, Group, SimpleGrid, Stack, Text, Title } from "@mantine/core";
import { IconCertificate, IconCertificateOff } from "@tabler/icons-react";
import dayjs from "dayjs";
import { auth } from "@homarr/auth/next";
import { loadCustomRootCertificatesAsync } from "@homarr/certificates/server";
import { getMantineColor } from "@homarr/common";
import type { SupportedLanguage } from "@homarr/translation";
import { getI18n } from "@homarr/translation/server";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { NoResults } from "~/components/no-results";
import { AddCertificateButton } from "./_components/add-certificate";
import { RemoveCertificate } from "./_components/remove-certificate";
interface CertificatesPageProps {
params: Promise<{
locale: SupportedLanguage;
}>;
}
export default async function CertificatesPage({ params }: CertificatesPageProps) {
const session = await auth();
if (!session?.user.permissions.includes("admin")) {
notFound();
}
const { locale } = await params;
const t = await getI18n();
const certificates = await loadCustomRootCertificatesAsync();
const x509Certificates = certificates
.map((cert) => ({
...cert,
x509: new X509Certificate(cert.content),
}))
.sort((certA, certB) => certA.x509.validToDate.getTime() - certB.x509.validToDate.getTime());
return (
<>
<DynamicBreadcrumb />
<Stack>
<Group justify="space-between">
<Stack gap={4}>
<Title>{t("certificate.page.list.title")}</Title>
<Text>{t("certificate.page.list.description")}</Text>
</Stack>
<AddCertificateButton />
</Group>
{x509Certificates.length === 0 && (
<NoResults icon={IconCertificateOff} title={t("certificate.page.list.noResults.title")} />
)}
<SimpleGrid cols={{ sm: 1, lg: 2, xl: 3 }} spacing="lg">
{x509Certificates.map((cert) => (
<Card key={cert.x509.fingerprint} withBorder>
<Group wrap="nowrap">
<IconCertificate color={getMantineColor(iconColor(cert.x509.validToDate), 6)} size={32} stroke={1.5} />
<Stack flex={1} gap="xs">
<Group justify="space-between">
<Text fw={500}>{cert.x509.subject}</Text>
<Text c="gray.6" ta="end" size="sm">
{cert.fileName}
</Text>
</Group>
<Group justify="space-between">
<Text size="sm" c="gray.6" title={cert.x509.validToDate.toISOString()}>
{t("certificate.page.list.expires", {
when: new Intl.RelativeTimeFormat(locale).format(
dayjs(cert.x509.validToDate).diff(dayjs(), "days"),
"days",
),
})}
</Text>
<RemoveCertificate fileName={cert.fileName} />
</Group>
</Stack>
</Group>
</Card>
))}
</SimpleGrid>
</Stack>
</>
);
}
const iconColor = (validTo: Date) => {
const daysUntilInvalid = dayjs(validTo).diff(new Date(), "days");
if (daysUntilInvalid < 1) return "red";
if (daysUntilInvalid < 7) return "orange";
if (daysUntilInvalid < 30) return "yellow";
return "green";
};
+2 -2
View File
@@ -38,13 +38,13 @@
"dayjs": "^1.11.13",
"dotenv": "^16.4.7",
"superjson": "2.2.2",
"undici": "7.2.1"
"undici": "7.2.3"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/node": "^22.10.6",
"@types/node": "^22.10.7",
"dotenv-cli": "^8.0.0",
"eslint": "^9.18.0",
"prettier": "^3.4.2",
+2 -31
View File
@@ -1,35 +1,6 @@
import type { Dispatcher } from "undici";
import { Agent, setGlobalDispatcher } from "undici";
import { setGlobalDispatcher } from "undici";
import { logger } from "@homarr/log";
export class LoggingAgent extends Agent {
constructor(...props: ConstructorParameters<typeof Agent>) {
super(...props);
}
dispatch(options: Dispatcher.DispatchOptions, handler: Dispatcher.DispatchHandler): 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
if (/^\d{4}-\d{2}-\d{2}$/.test(value)) return; // Skip dates
if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(value)) return; // Skip date times
url.searchParams.set(key, "REDACTED");
});
logger.info(
`Dispatching request ${url.toString().replaceAll("=&", "&")} (${Object.keys(options.headers ?? {}).length} headers)`,
);
return super.dispatch(options, handler);
}
}
import { LoggingAgent } from "@homarr/common/server";
const agent = new LoggingAgent();
setGlobalDispatcher(agent);
+3 -10
View File
@@ -24,10 +24,10 @@
<strong>Demo ✨</strong>
</a>
•
<a href="https://homarr.dev/docs/getting-started/installation/">
<a href="https://homarr.dev/docs/category/installation-1/">
<strong>Install 💻</strong>
</a> •
<a href="https://translate.homarr.dev/">
<a href="https://crowdin.com/project/homarr_labs">
<strong>Translations 🈺</strong>
</a> •
<a href="https://discord.com/invite/aCsmEV5RgA">
@@ -35,13 +35,6 @@
</a>
</p>
> [!WARNING]
> This is a beta version of Homarr. You can download the old but stable version at https://github.com/ajnart/homarr/. Running this version is at your own risk.
Simplify the management of your server with Homarr - a sleek, modern dashboard that puts all of your apps and services at your fingertips. With Homarr, you can access and control everything in one location. Homarr seamlessly integrates with the apps you've added, providing you with valuable information and giving you complete control. Installation is a breeze, and Homarr supports a wide range of deployment methods.
<br/>
<br/>
![Features Section](./section-features.png)
@@ -100,7 +93,7 @@ Homarr has a [built-in collection of widgets and integrations](https://homarr.de
Since we are updating Homarr very frequently, we recommend reading our official installation guides:
<h2>
<a href="https://homarr.dev/docs/getting-started/installation/">
<a href="https://homarr.dev/docs/category/installation-1/">
Please click here for official installation instructions
</a>
</h2>
+7 -4
View File
@@ -1,6 +1,6 @@
{
"name": "homarr",
"version": "1.0.0-beta.12",
"version": "1.0.1",
"private": true,
"scripts": {
"build": "cross-env CI=true turbo build",
@@ -40,8 +40,8 @@
"@semantic-release/release-notes-generator": "^14.0.3",
"@turbo/gen": "^2.3.3",
"@vitejs/plugin-react": "^4.3.4",
"@vitest/coverage-v8": "^2.1.8",
"@vitest/ui": "^2.1.8",
"@vitest/coverage-v8": "^3.0.2",
"@vitest/ui": "^3.0.2",
"conventional-changelog-conventionalcommits": "^8.0.0",
"cross-env": "^7.0.3",
"jsdom": "^26.0.0",
@@ -51,7 +51,7 @@
"turbo": "^2.3.3",
"typescript": "^5.7.3",
"vite-tsconfig-paths": "^5.1.4",
"vitest": "^2.1.8"
"vitest": "^3.0.2"
},
"packageManager": "pnpm@9.15.4",
"engines": {
@@ -59,6 +59,9 @@
},
"pnpm": {
"allowNonAppliedPatches": true,
"overrides": {
"proxmox-api>undici": "7.2.3"
},
"patchedDependencies": {
"pretty-print-error": "patches/pretty-print-error.patch"
}
+3 -2
View File
@@ -22,6 +22,7 @@
"prettier": "@homarr/prettier-config",
"dependencies": {
"@homarr/auth": "workspace:^0.1.0",
"@homarr/certificates": "workspace:^0.1.0",
"@homarr/common": "workspace:^0.1.0",
"@homarr/cron-job-runner": "workspace:^0.1.0",
"@homarr/cron-job-status": "workspace:^0.1.0",
@@ -41,9 +42,9 @@
"@trpc/client": "next",
"@trpc/react-query": "next",
"@trpc/server": "next",
"dockerode": "^4.0.3",
"dockerode": "4.0.2",
"lodash.clonedeep": "^4.5.0",
"next": "15.1.4",
"next": "15.1.5",
"react": "19.0.0",
"react-dom": "19.0.0",
"superjson": "2.2.2",
+2
View File
@@ -1,6 +1,7 @@
import { apiKeysRouter } from "./router/apiKeys";
import { appRouter as innerAppRouter } from "./router/app";
import { boardRouter } from "./router/board";
import { certificateRouter } from "./router/certificates/certificate-router";
import { cronJobsRouter } from "./router/cron-jobs";
import { dockerRouter } from "./router/docker/docker-router";
import { groupRouter } from "./router/group";
@@ -41,6 +42,7 @@ export const appRouter = createTRPCRouter({
apiKeys: apiKeysRouter,
media: mediaRouter,
updateChecker: updateCheckerRouter,
certificates: certificateRouter,
});
// export type definition of API
+460 -193
View File
@@ -4,7 +4,7 @@ import superjson from "superjson";
import { constructBoardPermissions } from "@homarr/auth/shared";
import type { DeviceType } from "@homarr/common/server";
import type { Database, InferInsertModel, InferSelectModel, SQL } from "@homarr/db";
import { and, createId, eq, inArray, like, or } from "@homarr/db";
import { and, createId, eq, handleTransactionsAsync, inArray, like, or } from "@homarr/db";
import { getServerSettingByKeyAsync } from "@homarr/db/queries";
import {
boardGroupPermissions,
@@ -204,21 +204,49 @@ export const boardRouter = createTRPCRouter({
.input(validation.board.create)
.mutation(async ({ ctx, input }) => {
const boardId = createId();
await ctx.db.transaction(async (transaction) => {
await transaction.insert(boards).values({
id: boardId,
name: input.name,
isPublic: input.isPublic,
columnCount: input.columnCount,
creatorId: ctx.session.user.id,
});
await transaction.insert(sections).values({
id: createId(),
kind: "empty",
xOffset: 0,
yOffset: 0,
boardId,
});
await handleTransactionsAsync(ctx.db, {
async handleAsync(db, schema) {
await db.transaction(async (transaction) => {
await transaction.insert(schema.boards).values({
id: boardId,
name: input.name,
isPublic: input.isPublic,
columnCount: input.columnCount,
creatorId: ctx.session.user.id,
});
await transaction.insert(schema.sections).values({
id: createId(),
kind: "empty",
xOffset: 0,
yOffset: 0,
boardId,
});
});
},
handleSync(db) {
db.transaction((transaction) => {
transaction
.insert(boards)
.values({
id: boardId,
name: input.name,
isPublic: input.isPublic,
columnCount: input.columnCount,
creatorId: ctx.session.user.id,
})
.run();
transaction
.insert(sections)
.values({
id: createId(),
kind: "empty",
xOffset: 0,
yOffset: 0,
boardId,
})
.run();
});
},
});
}),
duplicateBoard: permissionRequiredProcedure
@@ -302,28 +330,54 @@ export const boardRouter = createTRPCRouter({
})),
);
ctx.db.transaction((transaction) => {
transaction
.insert(boards)
.values({
...boardProps,
id: newBoardId,
name: input.name,
creatorId: ctx.session.user.id,
})
.run();
await handleTransactionsAsync(ctx.db, {
async handleAsync(db, schema) {
await db.transaction(async (transaction) => {
transaction.insert(schema.boards).values({
...boardProps,
id: newBoardId,
name: input.name,
creatorId: ctx.session.user.id,
});
if (sectionsToInsert.length > 0) {
transaction.insert(sections).values(sectionsToInsert).run();
}
if (sectionsToInsert.length > 0) {
await transaction.insert(schema.sections).values(sectionsToInsert);
}
if (itemsToInsert.length > 0) {
transaction.insert(items).values(itemsToInsert).run();
}
if (itemsToInsert.length > 0) {
await transaction.insert(schema.items).values(itemsToInsert);
}
if (itemIntegrationsToInsert.length > 0) {
transaction.insert(integrationItems).values(itemIntegrationsToInsert).run();
}
if (itemIntegrationsToInsert.length > 0) {
await transaction.insert(schema.integrationItems).values(itemIntegrationsToInsert);
}
});
},
handleSync(db) {
db.transaction((transaction) => {
transaction
.insert(boards)
.values({
...boardProps,
id: newBoardId,
name: input.name,
creatorId: ctx.session.user.id,
})
.run();
if (sectionsToInsert.length > 0) {
transaction.insert(sections).values(sectionsToInsert).run();
}
if (itemsToInsert.length > 0) {
transaction.insert(items).values(itemsToInsert).run();
}
if (itemIntegrationsToInsert.length > 0) {
transaction.insert(integrationItems).values(itemIntegrationsToInsert).run();
}
});
},
});
}),
renameBoard: protectedProcedure.input(validation.board.rename).mutation(async ({ ctx, input }) => {
@@ -434,148 +488,313 @@ export const boardRouter = createTRPCRouter({
saveBoard: protectedProcedure.input(validation.board.save).mutation(async ({ input, ctx }) => {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "modify");
await ctx.db.transaction(async (transaction) => {
const dbBoard = await getFullBoardWithWhereAsync(transaction, eq(boards.id, input.id), ctx.session.user.id);
const dbBoard = await getFullBoardWithWhereAsync(ctx.db, eq(boards.id, input.id), ctx.session.user.id);
const addedSections = filterAddedItems(input.sections, dbBoard.sections);
await handleTransactionsAsync(ctx.db, {
async handleAsync(db, schema) {
await db.transaction(async (transaction) => {
const addedSections = filterAddedItems(input.sections, dbBoard.sections);
if (addedSections.length > 0) {
await transaction.insert(sections).values(
addedSections.map((section) => ({
id: section.id,
kind: section.kind,
yOffset: section.yOffset,
xOffset: section.kind === "dynamic" ? section.xOffset : 0,
height: "height" in section ? section.height : null,
width: "width" in section ? section.width : null,
parentSectionId: "parentSectionId" in section ? section.parentSectionId : null,
name: "name" in section ? section.name : null,
boardId: dbBoard.id,
})),
);
}
if (addedSections.length > 0) {
await transaction.insert(schema.sections).values(
addedSections.map((section) => ({
id: section.id,
kind: section.kind,
yOffset: section.yOffset,
xOffset: section.kind === "dynamic" ? section.xOffset : 0,
height: "height" in section ? section.height : null,
width: "width" in section ? section.width : null,
parentSectionId: "parentSectionId" in section ? section.parentSectionId : null,
name: "name" in section ? section.name : null,
boardId: dbBoard.id,
})),
);
}
const inputItems = input.sections.flatMap((section) =>
section.items.map((item) => ({ ...item, sectionId: section.id })),
);
const dbItems = dbBoard.sections.flatMap((section) =>
section.items.map((item) => ({ ...item, sectionId: section.id })),
);
const addedItems = filterAddedItems(inputItems, dbItems);
if (addedItems.length > 0) {
await transaction.insert(items).values(
addedItems.map((item) => ({
id: item.id,
kind: item.kind,
height: item.height,
width: item.width,
xOffset: item.xOffset,
yOffset: item.yOffset,
options: superjson.stringify(item.options),
advancedOptions: superjson.stringify(item.advancedOptions),
sectionId: item.sectionId,
})),
);
}
const inputIntegrationRelations = inputItems.flatMap(({ integrationIds, id: itemId }) =>
integrationIds.map((integrationId) => ({
integrationId,
itemId,
})),
);
const dbIntegrationRelations = dbItems.flatMap(({ integrationIds, id: itemId }) =>
integrationIds.map((integrationId) => ({
integrationId,
itemId,
})),
);
const addedIntegrationRelations = inputIntegrationRelations.filter(
(inputRelation) =>
!dbIntegrationRelations.some(
(dbRelation) =>
dbRelation.itemId === inputRelation.itemId && dbRelation.integrationId === inputRelation.integrationId,
),
);
if (addedIntegrationRelations.length > 0) {
await transaction.insert(integrationItems).values(
addedIntegrationRelations.map((relation) => ({
itemId: relation.itemId,
integrationId: relation.integrationId,
})),
);
}
const updatedItems = filterUpdatedItems(inputItems, dbItems);
for (const item of updatedItems) {
await transaction
.update(items)
.set({
kind: item.kind,
height: item.height,
width: item.width,
xOffset: item.xOffset,
yOffset: item.yOffset,
options: superjson.stringify(item.options),
advancedOptions: superjson.stringify(item.advancedOptions),
sectionId: item.sectionId,
})
.where(eq(items.id, item.id));
}
const updatedSections = filterUpdatedItems(input.sections, dbBoard.sections);
for (const section of updatedSections) {
const prev = dbBoard.sections.find((dbSection) => dbSection.id === section.id);
await transaction
.update(sections)
.set({
yOffset: section.yOffset,
xOffset: section.xOffset,
height: prev?.kind === "dynamic" && "height" in section ? section.height : null,
width: prev?.kind === "dynamic" && "width" in section ? section.width : null,
parentSectionId: prev?.kind === "dynamic" && "parentSectionId" in section ? section.parentSectionId : null,
name: prev?.kind === "category" && "name" in section ? section.name : null,
})
.where(eq(sections.id, section.id));
}
const removedIntegrationRelations = dbIntegrationRelations.filter(
(dbRelation) =>
!inputIntegrationRelations.some(
(inputRelation) =>
dbRelation.itemId === inputRelation.itemId && dbRelation.integrationId === inputRelation.integrationId,
),
);
for (const relation of removedIntegrationRelations) {
await transaction
.delete(integrationItems)
.where(
and(
eq(integrationItems.itemId, relation.itemId),
eq(integrationItems.integrationId, relation.integrationId),
),
const inputItems = input.sections.flatMap((section) =>
section.items.map((item) => ({ ...item, sectionId: section.id })),
);
const dbItems = dbBoard.sections.flatMap((section) =>
section.items.map((item) => ({ ...item, sectionId: section.id })),
);
}
const removedItems = filterRemovedItems(inputItems, dbItems);
const addedItems = filterAddedItems(inputItems, dbItems);
const itemIds = removedItems.map((item) => item.id);
if (itemIds.length > 0) {
await transaction.delete(items).where(inArray(items.id, itemIds));
}
if (addedItems.length > 0) {
await transaction.insert(schema.items).values(
addedItems.map((item) => ({
id: item.id,
kind: item.kind,
height: item.height,
width: item.width,
xOffset: item.xOffset,
yOffset: item.yOffset,
options: superjson.stringify(item.options),
advancedOptions: superjson.stringify(item.advancedOptions),
sectionId: item.sectionId,
})),
);
}
const removedSections = filterRemovedItems(input.sections, dbBoard.sections);
const sectionIds = removedSections.map((section) => section.id);
const inputIntegrationRelations = inputItems.flatMap(({ integrationIds, id: itemId }) =>
integrationIds.map((integrationId) => ({
integrationId,
itemId,
})),
);
const dbIntegrationRelations = dbItems.flatMap(({ integrationIds, id: itemId }) =>
integrationIds.map((integrationId) => ({
integrationId,
itemId,
})),
);
const addedIntegrationRelations = inputIntegrationRelations.filter(
(inputRelation) =>
!dbIntegrationRelations.some(
(dbRelation) =>
dbRelation.itemId === inputRelation.itemId &&
dbRelation.integrationId === inputRelation.integrationId,
),
);
if (sectionIds.length > 0) {
await transaction.delete(sections).where(inArray(sections.id, sectionIds));
}
if (addedIntegrationRelations.length > 0) {
await transaction.insert(schema.integrationItems).values(
addedIntegrationRelations.map((relation) => ({
itemId: relation.itemId,
integrationId: relation.integrationId,
})),
);
}
const updatedItems = filterUpdatedItems(inputItems, dbItems);
for (const item of updatedItems) {
await transaction
.update(schema.items)
.set({
kind: item.kind,
height: item.height,
width: item.width,
xOffset: item.xOffset,
yOffset: item.yOffset,
options: superjson.stringify(item.options),
advancedOptions: superjson.stringify(item.advancedOptions),
sectionId: item.sectionId,
})
.where(eq(items.id, item.id));
}
const updatedSections = filterUpdatedItems(input.sections, dbBoard.sections);
for (const section of updatedSections) {
const prev = dbBoard.sections.find((dbSection) => dbSection.id === section.id);
await transaction
.update(schema.sections)
.set({
yOffset: section.yOffset,
xOffset: section.xOffset,
height: prev?.kind === "dynamic" && "height" in section ? section.height : null,
width: prev?.kind === "dynamic" && "width" in section ? section.width : null,
parentSectionId:
prev?.kind === "dynamic" && "parentSectionId" in section ? section.parentSectionId : null,
name: prev?.kind === "category" && "name" in section ? section.name : null,
})
.where(eq(sections.id, section.id));
}
const removedIntegrationRelations = dbIntegrationRelations.filter(
(dbRelation) =>
!inputIntegrationRelations.some(
(inputRelation) =>
dbRelation.itemId === inputRelation.itemId &&
dbRelation.integrationId === inputRelation.integrationId,
),
);
for (const relation of removedIntegrationRelations) {
await transaction
.delete(schema.integrationItems)
.where(
and(
eq(integrationItems.itemId, relation.itemId),
eq(integrationItems.integrationId, relation.integrationId),
),
);
}
const removedItems = filterRemovedItems(inputItems, dbItems);
const itemIds = removedItems.map((item) => item.id);
if (itemIds.length > 0) {
await transaction.delete(schema.items).where(inArray(items.id, itemIds));
}
const removedSections = filterRemovedItems(input.sections, dbBoard.sections);
const sectionIds = removedSections.map((section) => section.id);
if (sectionIds.length > 0) {
await transaction.delete(schema.sections).where(inArray(sections.id, sectionIds));
}
});
},
handleSync(db) {
db.transaction((transaction) => {
const addedSections = filterAddedItems(input.sections, dbBoard.sections);
if (addedSections.length > 0) {
transaction
.insert(sections)
.values(
addedSections.map((section) => ({
id: section.id,
kind: section.kind,
yOffset: section.yOffset,
xOffset: section.kind === "dynamic" ? section.xOffset : 0,
height: "height" in section ? section.height : null,
width: "width" in section ? section.width : null,
parentSectionId: "parentSectionId" in section ? section.parentSectionId : null,
name: "name" in section ? section.name : null,
boardId: dbBoard.id,
})),
)
.run();
}
const inputItems = input.sections.flatMap((section) =>
section.items.map((item) => ({ ...item, sectionId: section.id })),
);
const dbItems = dbBoard.sections.flatMap((section) =>
section.items.map((item) => ({ ...item, sectionId: section.id })),
);
const addedItems = filterAddedItems(inputItems, dbItems);
if (addedItems.length > 0) {
transaction
.insert(items)
.values(
addedItems.map((item) => ({
id: item.id,
kind: item.kind,
height: item.height,
width: item.width,
xOffset: item.xOffset,
yOffset: item.yOffset,
options: superjson.stringify(item.options),
advancedOptions: superjson.stringify(item.advancedOptions),
sectionId: item.sectionId,
})),
)
.run();
}
const inputIntegrationRelations = inputItems.flatMap(({ integrationIds, id: itemId }) =>
integrationIds.map((integrationId) => ({
integrationId,
itemId,
})),
);
const dbIntegrationRelations = dbItems.flatMap(({ integrationIds, id: itemId }) =>
integrationIds.map((integrationId) => ({
integrationId,
itemId,
})),
);
const addedIntegrationRelations = inputIntegrationRelations.filter(
(inputRelation) =>
!dbIntegrationRelations.some(
(dbRelation) =>
dbRelation.itemId === inputRelation.itemId &&
dbRelation.integrationId === inputRelation.integrationId,
),
);
if (addedIntegrationRelations.length > 0) {
transaction
.insert(integrationItems)
.values(
addedIntegrationRelations.map((relation) => ({
itemId: relation.itemId,
integrationId: relation.integrationId,
})),
)
.run();
}
const updatedItems = filterUpdatedItems(inputItems, dbItems);
for (const item of updatedItems) {
transaction
.update(items)
.set({
kind: item.kind,
height: item.height,
width: item.width,
xOffset: item.xOffset,
yOffset: item.yOffset,
options: superjson.stringify(item.options),
advancedOptions: superjson.stringify(item.advancedOptions),
sectionId: item.sectionId,
})
.where(eq(items.id, item.id))
.run();
}
const updatedSections = filterUpdatedItems(input.sections, dbBoard.sections);
for (const section of updatedSections) {
const prev = dbBoard.sections.find((dbSection) => dbSection.id === section.id);
transaction
.update(sections)
.set({
yOffset: section.yOffset,
xOffset: section.xOffset,
height: prev?.kind === "dynamic" && "height" in section ? section.height : null,
width: prev?.kind === "dynamic" && "width" in section ? section.width : null,
parentSectionId:
prev?.kind === "dynamic" && "parentSectionId" in section ? section.parentSectionId : null,
name: prev?.kind === "category" && "name" in section ? section.name : null,
})
.where(eq(sections.id, section.id))
.run();
}
const removedIntegrationRelations = dbIntegrationRelations.filter(
(dbRelation) =>
!inputIntegrationRelations.some(
(inputRelation) =>
dbRelation.itemId === inputRelation.itemId &&
dbRelation.integrationId === inputRelation.integrationId,
),
);
for (const relation of removedIntegrationRelations) {
transaction
.delete(integrationItems)
.where(
and(
eq(integrationItems.itemId, relation.itemId),
eq(integrationItems.integrationId, relation.integrationId),
),
)
.run();
}
const removedItems = filterRemovedItems(inputItems, dbItems);
const itemIds = removedItems.map((item) => item.id);
if (itemIds.length > 0) {
transaction.delete(items).where(inArray(items.id, itemIds)).run();
}
const removedSections = filterRemovedItems(input.sections, dbBoard.sections);
const sectionIds = removedSections.map((section) => section.id);
if (sectionIds.length > 0) {
transaction.delete(sections).where(inArray(sections.id, sectionIds)).run();
}
});
},
});
}),
@@ -655,18 +874,42 @@ export const boardRouter = createTRPCRouter({
.mutation(async ({ input, ctx }) => {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.entityId), "full");
await ctx.db.transaction(async (transaction) => {
await transaction.delete(boardUserPermissions).where(eq(boardUserPermissions.boardId, input.entityId));
if (input.permissions.length === 0) {
return;
}
await transaction.insert(boardUserPermissions).values(
input.permissions.map((permission) => ({
userId: permission.principalId,
permission: permission.permission,
boardId: input.entityId,
})),
);
await handleTransactionsAsync(ctx.db, {
async handleAsync(db, schema) {
await db.transaction(async (transaction) => {
await transaction
.delete(schema.boardUserPermissions)
.where(eq(boardUserPermissions.boardId, input.entityId));
if (input.permissions.length === 0) {
return;
}
await transaction.insert(schema.boardUserPermissions).values(
input.permissions.map((permission) => ({
userId: permission.principalId,
permission: permission.permission,
boardId: input.entityId,
})),
);
});
},
handleSync(db) {
db.transaction((transaction) => {
transaction.delete(boardUserPermissions).where(eq(boardUserPermissions.boardId, input.entityId)).run();
if (input.permissions.length === 0) {
return;
}
transaction
.insert(boardUserPermissions)
.values(
input.permissions.map((permission) => ({
userId: permission.principalId,
permission: permission.permission,
boardId: input.entityId,
})),
)
.run();
});
},
});
}),
saveGroupBoardPermissions: protectedProcedure
@@ -674,18 +917,42 @@ export const boardRouter = createTRPCRouter({
.mutation(async ({ input, ctx }) => {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.entityId), "full");
await ctx.db.transaction(async (transaction) => {
await transaction.delete(boardGroupPermissions).where(eq(boardGroupPermissions.boardId, input.entityId));
if (input.permissions.length === 0) {
return;
}
await transaction.insert(boardGroupPermissions).values(
input.permissions.map((permission) => ({
groupId: permission.principalId,
permission: permission.permission,
boardId: input.entityId,
})),
);
await handleTransactionsAsync(ctx.db, {
async handleAsync(db, schema) {
await db.transaction(async (transaction) => {
await transaction
.delete(schema.boardGroupPermissions)
.where(eq(boardGroupPermissions.boardId, input.entityId));
if (input.permissions.length === 0) {
return;
}
await transaction.insert(schema.boardGroupPermissions).values(
input.permissions.map((permission) => ({
groupId: permission.principalId,
permission: permission.permission,
boardId: input.entityId,
})),
);
});
},
handleSync(db) {
db.transaction((transaction) => {
transaction.delete(boardGroupPermissions).where(eq(boardGroupPermissions.boardId, input.entityId)).run();
if (input.permissions.length === 0) {
return;
}
transaction
.insert(boardGroupPermissions)
.values(
input.permissions.map((permission) => ({
groupId: permission.principalId,
permission: permission.permission,
boardId: input.entityId,
})),
)
.run();
});
},
});
}),
importOldmarrConfig: permissionRequiredProcedure
@@ -0,0 +1,27 @@
import { z } from "zod";
import { zfd } from "zod-form-data";
import { addCustomRootCertificateAsync, removeCustomRootCertificateAsync } from "@homarr/certificates/server";
import { superRefineCertificateFile, validation } from "@homarr/validation";
import { createTRPCRouter, permissionRequiredProcedure } from "../../trpc";
export const certificateRouter = createTRPCRouter({
addCertificate: permissionRequiredProcedure
.requiresPermission("admin")
.input(
zfd.formData({
file: zfd.file().superRefine(superRefineCertificateFile),
}),
)
.mutation(async ({ input }) => {
const content = await input.file.text();
await addCustomRootCertificateAsync(input.file.name, content);
}),
removeCertificate: permissionRequiredProcedure
.requiresPermission("admin")
.input(z.object({ fileName: validation.certificates.validFileNameSchema }))
.mutation(async ({ input }) => {
await removeCustomRootCertificateAsync(input.fileName);
}),
});
@@ -3,7 +3,7 @@ import { TRPCError } from "@trpc/server";
import { objectEntries } from "@homarr/common";
import { decryptSecret, encryptSecret } from "@homarr/common/server";
import type { Database } from "@homarr/db";
import { and, asc, createId, eq, inArray, like } from "@homarr/db";
import { and, asc, createId, eq, handleTransactionsAsync, inArray, like } from "@homarr/db";
import {
groupMembers,
groupPermissions,
@@ -130,6 +130,16 @@ export const integrationRouter = createTRPCRouter({
limit: input.limit,
});
}),
// This is used to get the integrations by their ids it's public because it's needed to get integrations data in the boards
byIds: publicProcedure.input(z.array(z.string())).query(async ({ ctx, input }) => {
return await ctx.db.query.integrations.findMany({
where: inArray(integrations.id, input),
columns: {
id: true,
kind: true,
},
});
}),
byId: protectedProcedure.input(validation.integration.byId).query(async ({ ctx, input }) => {
await throwIfActionForbiddenAsync(ctx, eq(integrations.id, input.id), "full");
const integration = await ctx.db.query.integrations.findFirst({
@@ -360,20 +370,45 @@ export const integrationRouter = createTRPCRouter({
.mutation(async ({ input, ctx }) => {
await throwIfActionForbiddenAsync(ctx, eq(integrations.id, input.entityId), "full");
await ctx.db.transaction(async (transaction) => {
await transaction
.delete(integrationUserPermissions)
.where(eq(integrationUserPermissions.integrationId, input.entityId));
if (input.permissions.length === 0) {
return;
}
await transaction.insert(integrationUserPermissions).values(
input.permissions.map((permission) => ({
userId: permission.principalId,
permission: permission.permission,
integrationId: input.entityId,
})),
);
await handleTransactionsAsync(ctx.db, {
async handleAsync(db, schema) {
await ctx.db.transaction(async (transaction) => {
await transaction
.delete(schema.integrationUserPermissions)
.where(eq(schema.integrationUserPermissions.integrationId, input.entityId));
if (input.permissions.length === 0) {
return;
}
await transaction.insert(schema.integrationUserPermissions).values(
input.permissions.map((permission) => ({
userId: permission.principalId,
permission: permission.permission,
integrationId: input.entityId,
})),
);
});
},
handleSync(db) {
db.transaction((transaction) => {
transaction
.delete(integrationUserPermissions)
.where(eq(integrationUserPermissions.integrationId, input.entityId))
.run();
if (input.permissions.length === 0) {
return;
}
transaction
.insert(integrationUserPermissions)
.values(
input.permissions.map((permission) => ({
userId: permission.principalId,
permission: permission.permission,
integrationId: input.entityId,
})),
)
.run();
});
},
});
}),
saveGroupIntegrationPermissions: protectedProcedure
@@ -381,20 +416,45 @@ export const integrationRouter = createTRPCRouter({
.mutation(async ({ input, ctx }) => {
await throwIfActionForbiddenAsync(ctx, eq(integrations.id, input.entityId), "full");
await ctx.db.transaction(async (transaction) => {
await transaction
.delete(integrationGroupPermissions)
.where(eq(integrationGroupPermissions.integrationId, input.entityId));
if (input.permissions.length === 0) {
return;
}
await transaction.insert(integrationGroupPermissions).values(
input.permissions.map((permission) => ({
groupId: permission.principalId,
permission: permission.permission,
integrationId: input.entityId,
})),
);
await handleTransactionsAsync(ctx.db, {
async handleAsync(db, schema) {
await db.transaction(async (transaction) => {
await transaction
.delete(schema.integrationGroupPermissions)
.where(eq(schema.integrationGroupPermissions.integrationId, input.entityId));
if (input.permissions.length === 0) {
return;
}
await transaction.insert(schema.integrationGroupPermissions).values(
input.permissions.map((permission) => ({
groupId: permission.principalId,
permission: permission.permission,
integrationId: input.entityId,
})),
);
});
},
handleSync(db) {
db.transaction((transaction) => {
transaction
.delete(integrationGroupPermissions)
.where(eq(integrationGroupPermissions.integrationId, input.entityId))
.run();
if (input.permissions.length === 0) {
return;
}
transaction
.insert(integrationGroupPermissions)
.values(
input.permissions.map((permission) => ({
groupId: permission.principalId,
permission: permission.permission,
integrationId: input.entityId,
})),
)
.run();
});
},
});
}),
searchInIntegration: protectedProcedure
@@ -1,15 +1,15 @@
import { observable } from "@trpc/server/observable";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import type { HealthMonitoring } from "@homarr/integrations";
import { systemInfoRequestHandler } from "@homarr/request-handler/health-monitoring";
import type { ProxmoxClusterInfo } from "@homarr/integrations/types";
import { clusterInfoRequestHandler, systemInfoRequestHandler } from "@homarr/request-handler/health-monitoring";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc";
export const healthMonitoringRouter = createTRPCRouter({
getHealthStatus: publicProcedure
.unstable_concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("healthMonitoring")))
getSystemHealthStatus: publicProcedure
.unstable_concat(createManyIntegrationMiddleware("query", "openmediavault", "dashDot"))
.query(async ({ ctx }) => {
return await Promise.all(
ctx.integrations.map(async (integration) => {
@@ -25,9 +25,8 @@ export const healthMonitoringRouter = createTRPCRouter({
}),
);
}),
subscribeHealthStatus: publicProcedure
.unstable_concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("healthMonitoring")))
subscribeSystemHealthStatus: publicProcedure
.unstable_concat(createManyIntegrationMiddleware("query", "openmediavault", "dashDot"))
.subscription(({ ctx }) => {
return observable<{ integrationId: string; healthInfo: HealthMonitoring; timestamp: Date }>((emit) => {
const unsubscribes: (() => void)[] = [];
@@ -49,4 +48,26 @@ export const healthMonitoringRouter = createTRPCRouter({
};
});
}),
getClusterHealthStatus: publicProcedure
.unstable_concat(createOneIntegrationMiddleware("query", "proxmox"))
.query(async ({ ctx }) => {
const innerHandler = clusterInfoRequestHandler.handler(ctx.integration, {});
const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
return data;
}),
subscribeClusterHealthStatus: publicProcedure
.unstable_concat(createOneIntegrationMiddleware("query", "proxmox"))
.subscription(({ ctx }) => {
return observable<ProxmoxClusterInfo>((emit) => {
const unsubscribes: (() => void)[] = [];
const innerHandler = clusterInfoRequestHandler.handler(ctx.integration, {});
const unsubscribe = innerHandler.subscribe((healthInfo) => {
emit.next(healthInfo);
});
unsubscribes.push(unsubscribe);
return () => {
unsubscribe();
};
});
}),
});
+1 -1
View File
@@ -34,7 +34,7 @@
"bcrypt": "^5.1.1",
"cookies": "^0.9.1",
"ldapts": "7.3.1",
"next": "15.1.4",
"next": "15.1.5",
"next-auth": "5.0.0-beta.25",
"react": "19.0.0",
"react-dom": "19.0.0"
@@ -1,5 +1,6 @@
import { CredentialsSignin } from "@auth/core/errors";
import { extractErrorMessage } from "@homarr/common";
import type { Database, InferInsertModel } from "@homarr/db";
import { and, createId, eq } from "@homarr/db";
import { users } from "@homarr/db/schema";
@@ -21,8 +22,8 @@ export const authorizeWithLdapCredentialsAsync = async (
distinguishedName: env.AUTH_LDAP_BIND_DN,
password: env.AUTH_LDAP_BIND_PASSWORD,
})
.catch(() => {
logger.error("Failed to connect to LDAP server");
.catch((error) => {
logger.error(`Failed to connect to LDAP server ${extractErrorMessage(error)}`);
throw new CredentialsSignin();
});
+9
View File
@@ -0,0 +1,9 @@
import baseConfig from "@homarr/eslint-config/base";
/** @type {import('typescript-eslint').Config} */
export default [
{
ignores: [],
},
...baseConfig,
];
+35
View File
@@ -0,0 +1,35 @@
{
"name": "@homarr/certificates",
"version": "0.1.0",
"private": true,
"license": "MIT",
"type": "module",
"exports": {
"./server": "./src/server.ts"
},
"typesVersions": {
"*": {
"*": [
"src/*"
]
}
},
"scripts": {
"clean": "rm -rf .turbo node_modules",
"format": "prettier --check . --ignore-path ../../.gitignore",
"lint": "eslint",
"typecheck": "tsc --noEmit"
},
"prettier": "@homarr/prettier-config",
"dependencies": {
"@homarr/common": "workspace:^0.1.0",
"undici": "7.2.3"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.18.0",
"typescript": "^5.7.3"
}
}
+87
View File
@@ -0,0 +1,87 @@
import fsSync from "node:fs";
import fs from "node:fs/promises";
import { Agent } from "node:https";
import path from "node:path";
import { rootCertificates } from "node:tls";
import axios from "axios";
import { fetch } from "undici";
import { LoggingAgent } from "@homarr/common/server";
const getCertificateFolder = () => {
return process.env.NODE_ENV === "production"
? path.join("/appdata", "trusted-certificates")
: process.env.LOCAL_CERTIFICATE_PATH;
};
export const loadCustomRootCertificatesAsync = async () => {
const folder = getCertificateFolder();
if (!folder) {
return [];
}
if (!fsSync.existsSync(folder)) {
await fs.mkdir(folder, { recursive: true });
}
const dirContent = await fs.readdir(folder);
return await Promise.all(
dirContent
.filter((file) => file.endsWith(".crt"))
.map(async (file) => ({
content: await fs.readFile(path.join(folder, file), "utf8"),
fileName: file,
})),
);
};
export const removeCustomRootCertificateAsync = async (fileName: string) => {
const folder = getCertificateFolder();
if (!folder) {
return;
}
await fs.rm(path.join(folder, fileName));
};
export const addCustomRootCertificateAsync = async (fileName: string, content: string) => {
const folder = getCertificateFolder();
if (!folder) {
throw new Error(
"When you want to use custom certificates locally you need to set LOCAL_CERTIFICATE_PATH to an absolute path",
);
}
if (fileName.includes("/")) {
throw new Error("Invalid file name");
}
await fs.writeFile(path.join(folder, fileName), content);
};
export const createCertificateAgentAsync = async () => {
const customCertificates = await loadCustomRootCertificatesAsync();
return new LoggingAgent({
connect: {
ca: rootCertificates.concat(customCertificates.map((cert) => cert.content)),
},
});
};
export const createAxiosCertificateInstanceAsync = async () => {
const customCertificates = await loadCustomRootCertificatesAsync();
return axios.create({
httpsAgent: new Agent({
ca: rootCertificates.concat(customCertificates.map((cert) => cert.content)),
}),
});
};
export const fetchWithTrustedCertificatesAsync: typeof fetch = async (url, options) => {
const agent = await createCertificateAgentAsync();
return fetch(url, {
...options,
dispatcher: agent,
});
};
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "@homarr/tsconfig/base.json",
"compilerOptions": {
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
},
"include": ["*.ts", "src"],
"exclude": ["node_modules"]
}
+2 -1
View File
@@ -29,9 +29,10 @@
"dependencies": {
"@homarr/log": "workspace:^0.1.0",
"dayjs": "^1.11.13",
"next": "15.1.4",
"next": "15.1.5",
"react": "19.0.0",
"react-dom": "19.0.0",
"undici": "7.2.3",
"zod": "^3.24.1"
},
"devDependencies": {
+32
View File
@@ -0,0 +1,32 @@
import type { Dispatcher } from "undici";
import { Agent } from "undici";
import { logger } from "@homarr/log";
export class LoggingAgent extends Agent {
constructor(...props: ConstructorParameters<typeof Agent>) {
super(...props);
}
dispatch(options: Dispatcher.DispatchOptions, handler: Dispatcher.DispatchHandler): 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
if (/^\d{4}-\d{2}-\d{2}$/.test(value)) return; // Skip dates
if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(value)) return; // Skip date times
url.searchParams.set(key, "REDACTED");
});
logger.info(
`Dispatching request ${url.toString().replaceAll("=&", "&")} (${Object.keys(options.headers ?? {}).length} headers)`,
);
return super.dispatch(options, handler);
}
}
+1
View File
@@ -1,3 +1,4 @@
export * from "./security";
export * from "./encryption";
export * from "./user-agent";
export * from "./fetch-agent";
@@ -3,7 +3,7 @@ import { describe, expect, test, vi } from "vitest";
import { logger } from "@homarr/log";
import { LoggingAgent } from "~/undici-log-agent-override";
import { LoggingAgent } from "../fetch-agent";
vi.mock("undici", () => {
return {
+73 -35
View File
@@ -1,7 +1,7 @@
import { splitToNChunks, Stopwatch } from "@homarr/common";
import { EVERY_WEEK } from "@homarr/cron-jobs-core/expressions";
import type { InferInsertModel } from "@homarr/db";
import { db, inArray, sql } from "@homarr/db";
import { db, handleTransactionsAsync, inArray, sql } from "@homarr/db";
import { createId } from "@homarr/db/client";
import { iconRepositories, icons } from "@homarr/db/schema";
import { fetchIconsAsync } from "@homarr/icons";
@@ -83,43 +83,81 @@ export const iconsUpdaterJob = createCronJob("iconsUpdater", EVERY_WEEK, {
(iconRepository) => !repositoryIconGroups.some((group) => group.slug === iconRepository.slug),
);
db.transaction((transaction) => {
if (newIconRepositories.length >= 1) {
transaction.insert(iconRepositories).values(newIconRepositories).run();
}
await handleTransactionsAsync(db, {
async handleAsync(db, schema) {
await db.transaction(async (transaction) => {
if (newIconRepositories.length >= 1) {
await transaction.insert(schema.iconRepositories).values(newIconRepositories);
}
if (newIcons.length >= 1) {
// We only insert 5000 icons at a time to avoid SQLite limitations
for (const chunck of splitToNChunks(newIcons, Math.ceil(newIcons.length / 5000))) {
transaction.insert(icons).values(chunck).run();
}
}
if (deadIcons.length >= 1) {
transaction
.delete(icons)
.where(
inArray(
// Combine iconRepositoryId and checksum to allow same icons on different repositories
sql`concat(${icons.iconRepositoryId}, '.', ${icons.checksum})`,
deadIcons.map((icon) => `${icon.iconRepositoryId}.${icon.checksum}`),
),
)
.run();
}
if (newIcons.length >= 1) {
// We only insert 5000 icons at a time to avoid SQLite limitations
for (const chunck of splitToNChunks(newIcons, Math.ceil(newIcons.length / 5000))) {
await transaction.insert(schema.icons).values(chunck);
}
}
if (deadIcons.length >= 1) {
await transaction.delete(schema.icons).where(
inArray(
// Combine iconRepositoryId and checksum to allow same icons on different repositories
sql`concat(${icons.iconRepositoryId}, '.', ${icons.checksum})`,
deadIcons.map((icon) => `${icon.iconRepositoryId}.${icon.checksum}`),
),
);
}
if (deadIconRepositories.length >= 1) {
transaction
.delete(iconRepositories)
.where(
inArray(
iconRepositories.id,
deadIconRepositories.map((iconRepository) => iconRepository.id),
),
)
.run();
}
if (deadIconRepositories.length >= 1) {
await transaction.delete(schema.iconRepositories).where(
inArray(
iconRepositories.id,
deadIconRepositories.map((iconRepository) => iconRepository.id),
),
);
}
countDeleted += deadIcons.length;
countDeleted += deadIcons.length;
});
},
handleSync() {
db.transaction((transaction) => {
if (newIconRepositories.length >= 1) {
transaction.insert(iconRepositories).values(newIconRepositories).run();
}
if (newIcons.length >= 1) {
// We only insert 5000 icons at a time to avoid SQLite limitations
for (const chunck of splitToNChunks(newIcons, Math.ceil(newIcons.length / 5000))) {
transaction.insert(icons).values(chunck).run();
}
}
if (deadIcons.length >= 1) {
transaction
.delete(icons)
.where(
inArray(
// Combine iconRepositoryId and checksum to allow same icons on different repositories
sql`concat(${icons.iconRepositoryId}, '.', ${icons.checksum})`,
deadIcons.map((icon) => `${icon.iconRepositoryId}.${icon.checksum}`),
),
)
.run();
}
if (deadIconRepositories.length >= 1) {
transaction
.delete(iconRepositories)
.where(
inArray(
iconRepositories.id,
deadIconRepositories.map((iconRepository) => iconRepository.id),
),
)
.run();
}
countDeleted += deadIcons.length;
});
},
});
logger.info(`Updated database within ${stopWatch.getElapsedInHumanWords()} (-${countDeleted}, +${countInserted})`);
@@ -1,14 +1,23 @@
import { EVERY_5_SECONDS } from "@homarr/cron-jobs-core/expressions";
import { systemInfoRequestHandler } from "@homarr/request-handler/health-monitoring";
import { clusterInfoRequestHandler, systemInfoRequestHandler } from "@homarr/request-handler/health-monitoring";
import { createRequestIntegrationJobHandler } from "@homarr/request-handler/lib/cached-request-integration-job-handler";
import { createCronJob } from "../../lib";
export const healthMonitoringJob = createCronJob("healthMonitoring", EVERY_5_SECONDS).withCallback(
createRequestIntegrationJobHandler(systemInfoRequestHandler.handler, {
widgetKinds: ["healthMonitoring"],
getInput: {
healthMonitoring: () => ({}),
createRequestIntegrationJobHandler(
(integration, itemOptions: Record<string, never>) => {
const { kind } = integration;
if (kind !== "proxmox") {
return systemInfoRequestHandler.handler({ ...integration, kind }, itemOptions);
}
return clusterInfoRequestHandler.handler({ ...integration, kind }, itemOptions);
},
}),
{
widgetKinds: ["healthMonitoring"],
getInput: {
healthMonitoring: () => ({}),
},
},
),
);
+3 -1
View File
@@ -2,6 +2,7 @@ import Database from "better-sqlite3";
import type { Logger } from "drizzle-orm";
import type { BetterSQLite3Database } from "drizzle-orm/better-sqlite3";
import { drizzle as drizzleSqlite } from "drizzle-orm/better-sqlite3";
import type { MySql2Database } from "drizzle-orm/mysql2";
import { drizzle as drizzleMysql } from "drizzle-orm/mysql2";
import mysql from "mysql2";
@@ -11,7 +12,8 @@ import { env } from "./env";
import * as mysqlSchema from "./schema/mysql";
import * as sqliteSchema from "./schema/sqlite";
type HomarrDatabase = BetterSQLite3Database<typeof sqliteSchema>;
export type HomarrDatabase = BetterSQLite3Database<typeof sqliteSchema>;
export type HomarrDatabaseMysql = MySql2Database<typeof mysqlSchema>;
const init = () => {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
+2
View File
@@ -7,5 +7,7 @@ export * from "drizzle-orm";
export const db = database;
export type Database = typeof db;
export type { HomarrDatabaseMysql } from "./driver";
export { createId } from "@paralleldrive/cuid2";
export { handleDiffrentDbDriverOperationsAsync as handleTransactionsAsync } from "./transactions";
+3 -3
View File
@@ -45,10 +45,10 @@
"@paralleldrive/cuid2": "^2.2.2",
"@t3-oss/env-nextjs": "^0.11.1",
"@testcontainers/mysql": "^10.16.0",
"better-sqlite3": "^11.7.2",
"better-sqlite3": "^11.8.0",
"dotenv": "^16.4.7",
"drizzle-kit": "^0.30.1",
"drizzle-orm": "^0.38.3",
"drizzle-kit": "^0.30.2",
"drizzle-orm": "^0.38.4",
"drizzle-zod": "^0.6.1",
"mysql2": "3.12.0"
},
+24
View File
@@ -0,0 +1,24 @@
import type { HomarrDatabase, HomarrDatabaseMysql } from "./driver";
import { env } from "./env";
import * as mysqlSchema from "./schema/mysql";
type MysqlSchema = typeof mysqlSchema;
interface HandleTransactionInput {
handleAsync: (db: HomarrDatabaseMysql, schema: MysqlSchema) => Promise<void>;
handleSync: (db: HomarrDatabase) => void;
}
/**
* The below method is mostly used to handle transactions in different database drivers.
* As better-sqlite3 transactions have to be synchronous, we have to implement them in a different way.
* But it can also generally be used when dealing with different database drivers.
*/
export const handleDiffrentDbDriverOperationsAsync = async (db: HomarrDatabase, input: HandleTransactionInput) => {
if (env.DB_DRIVER !== "mysql2") {
input.handleSync(db);
return;
}
await input.handleAsync(db as unknown as HomarrDatabaseMysql, mysqlSchema);
};
@@ -51,6 +51,7 @@ export type HomarrDocumentationPath =
| "/docs/tags/bookmark"
| "/docs/tags/bookmarks"
| "/docs/tags/caddy"
| "/docs/tags/certificates"
| "/docs/tags/checklist"
| "/docs/tags/code"
| "/docs/tags/community"
@@ -113,6 +114,7 @@ export type HomarrDocumentationPath =
| "/docs/tags/search"
| "/docs/tags/search-engines"
| "/docs/tags/security"
| "/docs/tags/self-signed"
| "/docs/tags/seo"
| "/docs/tags/server"
| "/docs/tags/settings"
@@ -166,7 +168,6 @@ export type HomarrDocumentationPath =
| "/docs/getting-started/installation/saltbox"
| "/docs/getting-started/installation/source"
| "/docs/getting-started/installation/synology"
| "/docs/getting-started/installation/truenas"
| "/docs/getting-started/installation/unraid"
| "/docs/integrations/containers"
| "/docs/integrations/dns"
@@ -179,6 +180,7 @@ export type HomarrDocumentationPath =
| "/docs/management/api"
| "/docs/management/apps"
| "/docs/management/boards"
| "/docs/management/certificates"
| "/docs/management/integrations"
| "/docs/management/media"
| "/docs/management/search-engines"
+1 -1
View File
@@ -1,6 +1,6 @@
import type { HomarrDocumentationPath } from "./homarr-docs-sitemap";
const documentationBaseUrl = "https://deploy-preview-113--homarr-docs.netlify.app";
const documentationBaseUrl = "https://homarr.dev";
// Please use the method so the path can be checked!
export const createDocumentationLink = (path: HomarrDocumentationPath, hashTag?: `#${string}`) =>
+8
View File
@@ -5,6 +5,8 @@ export const integrationSecretKindObject = {
apiKey: { isPublic: false },
username: { isPublic: true },
password: { isPublic: false },
tokenId: { isPublic: true },
realm: { isPublic: true },
} satisfies Record<string, { isPublic: boolean }>;
export const integrationSecretKinds = objectKeys(integrationSecretKindObject);
@@ -137,6 +139,12 @@ export const integrationDefs = {
category: ["mediaTranscoding"],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/png/tdarr.png",
},
proxmox: {
name: "Proxmox",
secretKinds: [["username", "tokenId", "apiKey", "realm"]],
category: ["healthMonitoring"],
iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/proxmox.png",
},
} as const satisfies Record<string, integrationDefinition>;
export const integrationKinds = objectKeys(integrationDefs) as AtLeastOneOf<IntegrationKind>;
+3
View File
@@ -27,6 +27,7 @@
"@ctrl/deluge": "^7.1.0",
"@ctrl/qbittorrent": "^9.2.0",
"@ctrl/transmission": "^7.2.0",
"@homarr/certificates": "workspace:^0.1.0",
"@homarr/common": "workspace:^0.1.0",
"@homarr/db": "workspace:^0.1.0",
"@homarr/definitions": "workspace:^0.1.0",
@@ -35,6 +36,8 @@
"@homarr/translation": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0",
"@jellyfin/sdk": "^0.11.0",
"proxmox-api": "1.1.1",
"undici": "7.2.3",
"xml2js": "^0.6.2"
},
"devDependencies": {
@@ -1,3 +1,5 @@
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
import { Integration } from "../base/integration";
import { IntegrationTestConnectionError } from "../base/test-connection-error";
import type { DnsHoleSummaryIntegration } from "../interfaces/dns-hole-summary/dns-hole-summary-integration";
@@ -6,7 +8,7 @@ import { filteringStatusSchema, statsResponseSchema, statusResponseSchema } from
export class AdGuardHomeIntegration extends Integration implements DnsHoleSummaryIntegration {
public async getSummaryAsync(): Promise<DnsHoleSummary> {
const statsResponse = await fetch(this.url("/control/stats"), {
const statsResponse = await fetchWithTrustedCertificatesAsync(this.url("/control/stats"), {
headers: {
Authorization: `Basic ${this.getAuthorizationHeaderValue()}`,
},
@@ -18,7 +20,7 @@ export class AdGuardHomeIntegration extends Integration implements DnsHoleSummar
);
}
const statusResponse = await fetch(this.url("/control/status"), {
const statusResponse = await fetchWithTrustedCertificatesAsync(this.url("/control/status"), {
headers: {
Authorization: `Basic ${this.getAuthorizationHeaderValue()}`,
},
@@ -30,7 +32,7 @@ export class AdGuardHomeIntegration extends Integration implements DnsHoleSummar
);
}
const filteringStatusResponse = await fetch(this.url("/control/filtering/status"), {
const filteringStatusResponse = await fetchWithTrustedCertificatesAsync(this.url("/control/filtering/status"), {
headers: {
Authorization: `Basic ${this.getAuthorizationHeaderValue()}`,
},
@@ -86,7 +88,7 @@ export class AdGuardHomeIntegration extends Integration implements DnsHoleSummar
public async testConnectionAsync(): Promise<void> {
await super.handleTestConnectionResponseAsync({
queryFunctionAsync: async () => {
return await fetch(this.url("/control/status"), {
return await fetchWithTrustedCertificatesAsync(this.url("/control/status"), {
headers: {
Authorization: `Basic ${this.getAuthorizationHeaderValue()}`,
},
@@ -94,7 +96,7 @@ export class AdGuardHomeIntegration extends Integration implements DnsHoleSummar
},
handleResponseAsync: async (response) => {
try {
const result = (await response.json()) as unknown;
const result = await response.json();
if (typeof result === "object" && result !== null) return;
} catch {
throw new IntegrationTestConnectionError("invalidJson");
@@ -106,7 +108,7 @@ export class AdGuardHomeIntegration extends Integration implements DnsHoleSummar
}
public async enableAsync(): Promise<void> {
const response = await fetch(this.url("/control/protection"), {
const response = await fetchWithTrustedCertificatesAsync(this.url("/control/protection"), {
method: "POST",
headers: {
"Content-Type": "application/json",
@@ -124,7 +126,7 @@ export class AdGuardHomeIntegration extends Integration implements DnsHoleSummar
}
public async disableAsync(duration = 0): Promise<void> {
const response = await fetch(this.url("/control/protection"), {
const response = await fetchWithTrustedCertificatesAsync(this.url("/control/protection"), {
method: "POST",
headers: {
"Content-Type": "application/json",
@@ -23,6 +23,7 @@ import { OverseerrIntegration } from "../overseerr/overseerr-integration";
import { PiHoleIntegration } from "../pi-hole/pi-hole-integration";
import { PlexIntegration } from "../plex/plex-integration";
import { ProwlarrIntegration } from "../prowlarr/prowlarr-integration";
import { ProxmoxIntegration } from "../proxmox/proxmox-integration";
import type { Integration, IntegrationInput } from "./integration";
export const integrationCreator = <TKind extends keyof typeof integrationCreators>(
@@ -72,4 +73,5 @@ export const integrationCreators = {
readarr: ReadarrIntegration,
dashDot: DashDotIntegration,
tdarr: TdarrIntegration,
proxmox: ProxmoxIntegration,
} satisfies Record<IntegrationKind, new (integration: IntegrationInput) => Integration>;
@@ -1,3 +1,5 @@
import type { Response } from "undici";
import { extractErrorMessage, removeTrailingSlash } from "@homarr/common";
import type { IntegrationSecretKind } from "@homarr/definitions";
import { logger } from "@homarr/log";
@@ -4,6 +4,7 @@ import "@homarr/redis";
import dayjs from "dayjs";
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
import { z } from "@homarr/validation";
import { createChannelEventHistory } from "../../../redis/src/lib/channel";
@@ -12,7 +13,7 @@ import type { HealthMonitoring } from "../types";
export class DashDotIntegration extends Integration {
public async testConnectionAsync(): Promise<void> {
const response = await fetch(this.url("/info"));
const response = await fetchWithTrustedCertificatesAsync(this.url("/info"));
await response.json();
}
@@ -52,7 +53,7 @@ export class DashDotIntegration extends Integration {
}
private async getInfoAsync() {
const infoResponse = await fetch(this.url("/info"));
const infoResponse = await fetchWithTrustedCertificatesAsync(this.url("/info"));
const serverInfo = await internalServerInfoApi.parseAsync(await infoResponse.json());
return {
maxAvailableMemoryBytes: serverInfo.ram.size,
@@ -66,7 +67,7 @@ export class DashDotIntegration extends Integration {
private async getCurrentCpuLoadAsync() {
const channel = this.getChannel();
const cpu = await fetch(this.url("/load/cpu"));
const cpu = await fetchWithTrustedCertificatesAsync(this.url("/load/cpu"));
const data = await cpuLoadPerCoreApiList.parseAsync(await cpu.json());
await channel.pushAsync(data);
return {
@@ -88,12 +89,12 @@ export class DashDotIntegration extends Integration {
}
private async getCurrentStorageLoadAsync() {
const storageLoad = await fetch(this.url("/load/storage"));
const storageLoad = await fetchWithTrustedCertificatesAsync(this.url("/load/storage"));
return (await storageLoad.json()) as number[];
}
private async getCurrentMemoryLoadAsync() {
const memoryLoad = await fetch(this.url("/load/ram"));
const memoryLoad = await fetchWithTrustedCertificatesAsync(this.url("/load/ram"));
const data = await memoryLoadApi.parseAsync(await memoryLoad.json());
return {
loadInBytes: data.load,
@@ -1,6 +1,8 @@
import { Deluge } from "@ctrl/deluge";
import dayjs from "dayjs";
import { createCertificateAgentAsync } from "@homarr/certificates/server";
import type { DownloadClientJobsAndStatus } from "../../interfaces/downloads/download-client-data";
import { DownloadClientIntegration } from "../../interfaces/downloads/download-client-integration";
import type { DownloadClientItem } from "../../interfaces/downloads/download-client-items";
@@ -8,13 +10,13 @@ import type { DownloadClientStatus } from "../../interfaces/downloads/download-c
export class DelugeIntegration extends DownloadClientIntegration {
public async testConnectionAsync(): Promise<void> {
const client = this.getClient();
const client = await this.getClientAsync();
await client.login();
}
public async getClientJobsAndStatusAsync(): Promise<DownloadClientJobsAndStatus> {
const type = "torrent";
const client = this.getClient();
const client = await this.getClientAsync();
const {
stats: { download_rate, upload_rate },
torrents: rawTorrents,
@@ -57,7 +59,7 @@ export class DelugeIntegration extends DownloadClientIntegration {
}
public async pauseQueueAsync() {
const client = this.getClient();
const client = await this.getClientAsync();
const store = (await client.listTorrents()).result.torrents;
await Promise.all(
Object.entries(store).map(async ([id]) => {
@@ -67,11 +69,12 @@ export class DelugeIntegration extends DownloadClientIntegration {
}
public async pauseItemAsync({ id }: DownloadClientItem): Promise<void> {
await this.getClient().pauseTorrent(id);
const client = await this.getClientAsync();
await client.pauseTorrent(id);
}
public async resumeQueueAsync() {
const client = this.getClient();
const client = await this.getClientAsync();
const store = (await client.listTorrents()).result.torrents;
await Promise.all(
Object.entries(store).map(async ([id]) => {
@@ -81,17 +84,20 @@ export class DelugeIntegration extends DownloadClientIntegration {
}
public async resumeItemAsync({ id }: DownloadClientItem): Promise<void> {
await this.getClient().resumeTorrent(id);
const client = await this.getClientAsync();
await client.resumeTorrent(id);
}
public async deleteItemAsync({ id }: DownloadClientItem, fromDisk: boolean): Promise<void> {
await this.getClient().removeTorrent(id, fromDisk);
const client = await this.getClientAsync();
await client.removeTorrent(id, fromDisk);
}
private getClient() {
private async getClientAsync() {
return new Deluge({
baseUrl: this.url("/").toString(),
password: this.getSecretValue("password"),
dispatcher: await createCertificateAgentAsync(),
});
}
@@ -1,5 +1,7 @@
import dayjs from "dayjs";
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
import type { DownloadClientJobsAndStatus } from "../../interfaces/downloads/download-client-data";
import { DownloadClientIntegration } from "../../interfaces/downloads/download-client-integration";
import type { DownloadClientItem } from "../../interfaces/downloads/download-client-items";
@@ -96,7 +98,7 @@ export class NzbGetIntegration extends DownloadClientIntegration {
const password = this.getSecretValue("password");
const url = this.url(`/${username}:${password}/jsonrpc`);
const body = JSON.stringify({ method, params });
return await fetch(url, { method: "POST", body })
return await fetchWithTrustedCertificatesAsync(url, { method: "POST", body })
.then(async (response) => {
if (!response.ok) {
throw new Error(response.statusText);
@@ -1,6 +1,8 @@
import { QBittorrent } from "@ctrl/qbittorrent";
import dayjs from "dayjs";
import { createCertificateAgentAsync } from "@homarr/certificates/server";
import type { DownloadClientJobsAndStatus } from "../../interfaces/downloads/download-client-data";
import { DownloadClientIntegration } from "../../interfaces/downloads/download-client-integration";
import type { DownloadClientItem } from "../../interfaces/downloads/download-client-items";
@@ -8,13 +10,13 @@ import type { DownloadClientStatus } from "../../interfaces/downloads/download-c
export class QBitTorrentIntegration extends DownloadClientIntegration {
public async testConnectionAsync(): Promise<void> {
const client = this.getClient();
const client = await this.getClientAsync();
await client.login();
}
public async getClientJobsAndStatusAsync(): Promise<DownloadClientJobsAndStatus> {
const type = "torrent";
const client = this.getClient();
const client = await this.getClientAsync();
const torrents = await client.listTorrents();
const rates = torrents.reduce(
({ down, up }, { dlspeed, upspeed }) => ({ down: down + dlspeed, up: up + upspeed }),
@@ -50,30 +52,36 @@ export class QBitTorrentIntegration extends DownloadClientIntegration {
}
public async pauseQueueAsync() {
await this.getClient().pauseTorrent("all");
const client = await this.getClientAsync();
await client.pauseTorrent("all");
}
public async pauseItemAsync({ id }: DownloadClientItem): Promise<void> {
await this.getClient().pauseTorrent(id);
const client = await this.getClientAsync();
await client.pauseTorrent(id);
}
public async resumeQueueAsync() {
await this.getClient().resumeTorrent("all");
const client = await this.getClientAsync();
await client.resumeTorrent("all");
}
public async resumeItemAsync({ id }: DownloadClientItem): Promise<void> {
await this.getClient().resumeTorrent(id);
const client = await this.getClientAsync();
await client.resumeTorrent(id);
}
public async deleteItemAsync({ id }: DownloadClientItem, fromDisk: boolean): Promise<void> {
await this.getClient().removeTorrent(id, fromDisk);
const client = await this.getClientAsync();
await client.removeTorrent(id, fromDisk);
}
private getClient() {
private async getClientAsync() {
return new QBittorrent({
baseUrl: this.url("/").toString(),
username: this.getSecretValue("username"),
password: this.getSecretValue("password"),
dispatcher: await createCertificateAgentAsync(),
});
}
@@ -1,6 +1,8 @@
import dayjs from "dayjs";
import duration from "dayjs/plugin/duration";
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
import type { DownloadClientJobsAndStatus } from "../../interfaces/downloads/download-client-data";
import { DownloadClientIntegration } from "../../interfaces/downloads/download-client-integration";
import type { DownloadClientItem } from "../../interfaces/downloads/download-client-items";
@@ -106,12 +108,12 @@ export class SabnzbdIntegration extends DownloadClientIntegration {
apikey: this.getSecretValue("apiKey"),
});
return await fetch(url)
return await fetchWithTrustedCertificatesAsync(url)
.then((response) => {
if (!response.ok) {
throw new Error(response.statusText);
}
return response.json() as Promise<unknown>;
return response.json();
})
.catch((error) => {
if (error instanceof Error) {
@@ -1,6 +1,8 @@
import { Transmission } from "@ctrl/transmission";
import dayjs from "dayjs";
import { createCertificateAgentAsync } from "@homarr/certificates/server";
import type { DownloadClientJobsAndStatus } from "../../interfaces/downloads/download-client-data";
import { DownloadClientIntegration } from "../../interfaces/downloads/download-client-integration";
import type { DownloadClientItem } from "../../interfaces/downloads/download-client-items";
@@ -8,12 +10,13 @@ import type { DownloadClientStatus } from "../../interfaces/downloads/download-c
export class TransmissionIntegration extends DownloadClientIntegration {
public async testConnectionAsync(): Promise<void> {
await this.getClient().getSession();
const client = await this.getClientAsync();
await client.getSession();
}
public async getClientJobsAndStatusAsync(): Promise<DownloadClientJobsAndStatus> {
const type = "torrent";
const client = this.getClient();
const client = await this.getClientAsync();
const { torrents } = (await client.listTorrents()).arguments;
const rates = torrents.reduce(
({ down, up }, { rateDownload, rateUpload }) => ({ down: down + rateDownload, up: up + rateUpload }),
@@ -47,34 +50,38 @@ export class TransmissionIntegration extends DownloadClientIntegration {
}
public async pauseQueueAsync() {
const client = this.getClient();
const client = await this.getClientAsync();
const ids = (await client.listTorrents()).arguments.torrents.map(({ hashString }) => hashString);
await this.getClient().pauseTorrent(ids);
await client.pauseTorrent(ids);
}
public async pauseItemAsync({ id }: DownloadClientItem): Promise<void> {
await this.getClient().pauseTorrent(id);
const client = await this.getClientAsync();
await client.pauseTorrent(id);
}
public async resumeQueueAsync() {
const client = this.getClient();
const client = await this.getClientAsync();
const ids = (await client.listTorrents()).arguments.torrents.map(({ hashString }) => hashString);
await this.getClient().resumeTorrent(ids);
await client.resumeTorrent(ids);
}
public async resumeItemAsync({ id }: DownloadClientItem): Promise<void> {
await this.getClient().resumeTorrent(id);
const client = await this.getClientAsync();
await client.resumeTorrent(id);
}
public async deleteItemAsync({ id }: DownloadClientItem, fromDisk: boolean): Promise<void> {
await this.getClient().removeTorrent(id, fromDisk);
const client = await this.getClientAsync();
await client.removeTorrent(id, fromDisk);
}
private getClient() {
private async getClientAsync() {
return new Transmission({
baseUrl: this.url("/").toString(),
username: this.getSecretValue("username"),
password: this.getSecretValue("password"),
dispatcher: await createCertificateAgentAsync(),
});
}
@@ -1,3 +1,4 @@
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
import { logger } from "@homarr/log";
import { Integration } from "../base/integration";
@@ -7,7 +8,7 @@ export class HomeAssistantIntegration extends Integration {
public async getEntityStateAsync(entityId: string) {
try {
const response = await this.getAsync(`/api/states/${entityId}`);
const body = (await response.json()) as unknown;
const body = await response.json();
if (!response.ok) {
logger.warn(`Response did not indicate success`);
return {
@@ -71,7 +72,7 @@ export class HomeAssistantIntegration extends Integration {
* @returns the response from the API
*/
private async getAsync(path: `/api/${string}`) {
return await fetch(this.url(path), {
return await fetchWithTrustedCertificatesAsync(this.url(path), {
headers: this.getAuthHeaders(),
});
}
@@ -84,7 +85,7 @@ export class HomeAssistantIntegration extends Integration {
* @returns the response from the API
*/
private async postAsync(path: `/api/${string}`, body: Record<string, string>) {
return await fetch(this.url(path), {
return await fetchWithTrustedCertificatesAsync(this.url(path), {
headers: this.getAuthHeaders(),
body: JSON.stringify(body),
method: "POST",
@@ -2,6 +2,8 @@ import { Jellyfin } from "@jellyfin/sdk";
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api/session-api";
import { getSystemApi } from "@jellyfin/sdk/lib/utils/api/system-api";
import { createAxiosCertificateInstanceAsync } from "@homarr/certificates/server";
import { Integration } from "../base/integration";
import type { StreamSession } from "../interfaces/media-server/session";
@@ -65,12 +67,13 @@ export class JellyfinIntegration extends Integration {
* @returns An instance of Api that has been authenticated
*/
private async getApiAsync() {
const httpsAgent = await createAxiosCertificateInstanceAsync();
if (this.hasSecretValue("apiKey")) {
const apiKey = this.getSecretValue("apiKey");
return this.jellyfin.createApi(this.url("/").toString(), apiKey);
return this.jellyfin.createApi(this.url("/").toString(), apiKey, httpsAgent);
}
const apiClient = this.jellyfin.createApi(this.url("/").toString());
const apiClient = this.jellyfin.createApi(this.url("/").toString(), undefined, httpsAgent);
// Authentication state is stored internally in the Api class, so now
// requests that require authentication can be made normally.
// see https://typescript-sdk.jellyfin.org/#usage
@@ -1,3 +1,4 @@
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
import { logger } from "@homarr/log";
import { z } from "@homarr/validation";
@@ -8,7 +9,7 @@ export class LidarrIntegration extends MediaOrganizerIntegration {
public async testConnectionAsync(): Promise<void> {
await super.handleTestConnectionResponseAsync({
queryFunctionAsync: async () => {
return await fetch(this.url("/api"), {
return await fetchWithTrustedCertificatesAsync(this.url("/api"), {
headers: { "X-Api-Key": super.getSecretValue("apiKey") },
});
},
@@ -28,7 +29,7 @@ export class LidarrIntegration extends MediaOrganizerIntegration {
unmonitored: includeUnmonitored,
});
const response = await fetch(url, {
const response = await fetchWithTrustedCertificatesAsync(url, {
headers: {
"X-Api-Key": super.getSecretValue("apiKey"),
},
@@ -1,3 +1,4 @@
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
import type { AtLeastOneOf } from "@homarr/common/types";
import { logger } from "@homarr/log";
import { z } from "@homarr/validation";
@@ -20,7 +21,7 @@ export class RadarrIntegration extends MediaOrganizerIntegration {
unmonitored: includeUnmonitored,
});
const response = await fetch(url, {
const response = await fetchWithTrustedCertificatesAsync(url, {
headers: {
"X-Api-Key": super.getSecretValue("apiKey"),
},
@@ -94,7 +95,7 @@ export class RadarrIntegration extends MediaOrganizerIntegration {
public async testConnectionAsync(): Promise<void> {
await super.handleTestConnectionResponseAsync({
queryFunctionAsync: async () => {
return await fetch(this.url("/api"), {
return await fetchWithTrustedCertificatesAsync(this.url("/api"), {
headers: { "X-Api-Key": super.getSecretValue("apiKey") },
});
},
@@ -1,3 +1,4 @@
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
import { logger } from "@homarr/log";
import { z } from "@homarr/validation";
@@ -8,7 +9,7 @@ export class ReadarrIntegration extends MediaOrganizerIntegration {
public async testConnectionAsync(): Promise<void> {
await super.handleTestConnectionResponseAsync({
queryFunctionAsync: async () => {
return await fetch(this.url("/api"), {
return await fetchWithTrustedCertificatesAsync(this.url("/api"), {
headers: { "X-Api-Key": super.getSecretValue("apiKey") },
});
},
@@ -34,7 +35,7 @@ export class ReadarrIntegration extends MediaOrganizerIntegration {
includeAuthor,
});
const response = await fetch(url, {
const response = await fetchWithTrustedCertificatesAsync(url, {
headers: {
"X-Api-Key": super.getSecretValue("apiKey"),
},
@@ -1,3 +1,4 @@
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
import { logger } from "@homarr/log";
import { z } from "@homarr/validation";
@@ -21,7 +22,7 @@ export class SonarrIntegration extends MediaOrganizerIntegration {
includeEpisodeImages: true,
});
const response = await fetch(url, {
const response = await fetchWithTrustedCertificatesAsync(url, {
headers: {
"X-Api-Key": super.getSecretValue("apiKey"),
},
@@ -93,7 +94,7 @@ export class SonarrIntegration extends MediaOrganizerIntegration {
public async testConnectionAsync(): Promise<void> {
await super.handleTestConnectionResponseAsync({
queryFunctionAsync: async () => {
return await fetch(this.url("/api"), {
return await fetchWithTrustedCertificatesAsync(this.url("/api"), {
headers: { "X-Api-Key": super.getSecretValue("apiKey") },
});
},
@@ -1,3 +1,4 @@
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
import { z } from "@homarr/validation";
import { Integration } from "../base/integration";
@@ -9,7 +10,7 @@ import { getNodesResponseSchema, getStatisticsSchema, getStatusTableSchema } fro
export class TdarrIntegration extends Integration {
public async testConnectionAsync(): Promise<void> {
const url = this.url("/api/v2/status");
const response = await fetch(url);
const response = await fetchWithTrustedCertificatesAsync(url);
if (response.status !== 200) {
throw new Error(`Unexpected status code: ${response.status}`);
}
@@ -19,7 +20,7 @@ export class TdarrIntegration extends Integration {
public async getStatisticsAsync(): Promise<TdarrStatistics> {
const url = this.url("/api/v2/cruddb");
const response = await fetch(url, {
const response = await fetchWithTrustedCertificatesAsync(url, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
@@ -61,7 +62,7 @@ export class TdarrIntegration extends Integration {
public async getWorkersAsync(): Promise<TdarrWorker[]> {
const url = this.url("/api/v2/get-nodes");
const response = await fetch(url, {
const response = await fetchWithTrustedCertificatesAsync(url, {
method: "GET",
headers: { "content-type": "application/json" },
});
@@ -101,7 +102,7 @@ export class TdarrIntegration extends Integration {
private async getTranscodingQueueAsync(firstItemIndex: number, pageSize: number) {
const url = this.url("/api/v2/client/status-tables");
const response = await fetch(url, {
const response = await fetchWithTrustedCertificatesAsync(url, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
@@ -136,7 +137,7 @@ export class TdarrIntegration extends Integration {
private async getHealthCheckDataAsync(firstItemIndex: number, pageSize: number, totalQueueCount: number) {
const url = this.url("/api/v2/client/status-tables");
const response = await fetch(url, {
const response = await fetchWithTrustedCertificatesAsync(url, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
@@ -1,3 +1,7 @@
import type { Response } from "undici";
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
import { Integration } from "../base/integration";
import { IntegrationTestConnectionError } from "../base/test-connection-error";
import type { HealthMonitoring } from "../types";
@@ -105,7 +109,7 @@ export class OpenMediaVaultIntegration extends Integration {
if (!response.ok) {
throw new IntegrationTestConnectionError("invalidCredentials");
}
const result = (await response.json()) as unknown;
const result = await response.json();
if (typeof result !== "object" || result === null || !("response" in result)) {
throw new IntegrationTestConnectionError("invalidJson");
}
@@ -117,7 +121,7 @@ export class OpenMediaVaultIntegration extends Integration {
params: Record<string, unknown>,
headers: Record<string, string> = {},
): Promise<Response> {
return await fetch(this.url("/rpc.php"), {
return await fetchWithTrustedCertificatesAsync(this.url("/rpc.php"), {
method: "POST",
headers: {
"Content-Type": "application/json",
@@ -1,3 +1,4 @@
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
import { logger } from "@homarr/log";
import { z } from "@homarr/validation";
@@ -20,7 +21,7 @@ interface OverseerrSearchResult {
*/
export class OverseerrIntegration extends Integration implements ISearchableIntegration<OverseerrSearchResult> {
public async searchAsync(query: string) {
const response = await fetch(this.url("/api/v1/search", { query }), {
const response = await fetchWithTrustedCertificatesAsync(this.url("/api/v1/search", { query }), {
headers: {
"X-Api-Key": this.getSecretValue("apiKey"),
},
@@ -44,7 +45,7 @@ export class OverseerrIntegration extends Integration implements ISearchableInte
public async getSeriesInformationAsync(mediaType: "movie" | "tv", id: number) {
const url = mediaType === "tv" ? this.url(`/api/v1/tv/${id}`) : this.url(`/api/v1/movie/${id}`);
const response = await fetch(url, {
const response = await fetchWithTrustedCertificatesAsync(url, {
headers: {
"X-Api-Key": this.getSecretValue("apiKey"),
},
@@ -60,7 +61,7 @@ export class OverseerrIntegration extends Integration implements ISearchableInte
*/
public async requestMediaAsync(mediaType: "movie" | "tv", id: number, seasons?: number[]): Promise<void> {
const url = this.url("/api/v1/request");
const response = await fetch(url, {
const response = await fetchWithTrustedCertificatesAsync(url, {
method: "POST",
body: JSON.stringify({
mediaType,
@@ -80,13 +81,12 @@ export class OverseerrIntegration extends Integration implements ISearchableInte
}
public async testConnectionAsync(): Promise<void> {
const response = await fetch(this.url("/api/v1/auth/me"), {
const response = await fetchWithTrustedCertificatesAsync(this.url("/api/v1/auth/me"), {
headers: {
"X-Api-Key": this.getSecretValue("apiKey"),
},
});
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const json: object = await response.json();
const json = (await response.json()) as object;
if (Object.keys(json).includes("id")) {
return;
}
@@ -96,14 +96,17 @@ export class OverseerrIntegration extends Integration implements ISearchableInte
public async getRequestsAsync(): Promise<MediaRequest[]> {
//Ensure to get all pending request first
const pendingRequests = await fetch(this.url("/api/v1/request", { take: -1, filter: "pending" }), {
headers: {
"X-Api-Key": this.getSecretValue("apiKey"),
const pendingRequests = await fetchWithTrustedCertificatesAsync(
this.url("/api/v1/request", { take: -1, filter: "pending" }),
{
headers: {
"X-Api-Key": this.getSecretValue("apiKey"),
},
},
});
);
//Change 20 to integration setting (set to -1 for all)
const allRequests = await fetch(this.url("/api/v1/request", { take: 20 }), {
const allRequests = await fetchWithTrustedCertificatesAsync(this.url("/api/v1/request", { take: 20 }), {
headers: {
"X-Api-Key": this.getSecretValue("apiKey"),
},
@@ -151,7 +154,7 @@ export class OverseerrIntegration extends Integration implements ISearchableInte
}
public async getStatsAsync(): Promise<RequestStats> {
const response = await fetch(this.url("/api/v1/request/count"), {
const response = await fetchWithTrustedCertificatesAsync(this.url("/api/v1/request/count"), {
headers: {
"X-Api-Key": this.getSecretValue("apiKey"),
},
@@ -160,7 +163,7 @@ export class OverseerrIntegration extends Integration implements ISearchableInte
}
public async getUsersAsync(): Promise<RequestUser[]> {
const response = await fetch(this.url("/api/v1/user", { take: -1 }), {
const response = await fetchWithTrustedCertificatesAsync(this.url("/api/v1/user", { take: -1 }), {
headers: {
"X-Api-Key": this.getSecretValue("apiKey"),
},
@@ -177,7 +180,7 @@ export class OverseerrIntegration extends Integration implements ISearchableInte
public async approveRequestAsync(requestId: number): Promise<void> {
logger.info(`Approving media request id='${requestId}' integration='${this.integration.name}'`);
await fetch(this.url(`/api/v1/request/${requestId}/approve`), {
await fetchWithTrustedCertificatesAsync(this.url(`/api/v1/request/${requestId}/approve`), {
method: "POST",
headers: {
"X-Api-Key": this.getSecretValue("apiKey"),
@@ -195,7 +198,7 @@ export class OverseerrIntegration extends Integration implements ISearchableInte
public async declineRequestAsync(requestId: number): Promise<void> {
logger.info(`Declining media request id='${requestId}' integration='${this.integration.name}'`);
await fetch(this.url(`/api/v1/request/${requestId}/decline`), {
await fetchWithTrustedCertificatesAsync(this.url(`/api/v1/request/${requestId}/decline`), {
method: "POST",
headers: {
"X-Api-Key": this.getSecretValue("apiKey"),
@@ -212,7 +215,7 @@ export class OverseerrIntegration extends Integration implements ISearchableInte
}
private async getItemInformationAsync(id: number, type: MediaRequest["type"]): Promise<MediaInformation> {
const response = await fetch(this.url(`/api/v1/${type}/${id}`), {
const response = await fetchWithTrustedCertificatesAsync(this.url(`/api/v1/${type}/${id}`), {
headers: {
"X-Api-Key": this.getSecretValue("apiKey"),
},
@@ -1,3 +1,5 @@
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
import { Integration } from "../base/integration";
import { IntegrationTestConnectionError } from "../base/test-connection-error";
import type { DnsHoleSummaryIntegration } from "../interfaces/dns-hole-summary/dns-hole-summary-integration";
@@ -7,7 +9,7 @@ import { summaryResponseSchema } from "./pi-hole-types";
export class PiHoleIntegration extends Integration implements DnsHoleSummaryIntegration {
public async getSummaryAsync(): Promise<DnsHoleSummary> {
const apiKey = super.getSecretValue("apiKey");
const response = await fetch(this.url("/admin/api.php?summaryRaw", { auth: apiKey }));
const response = await fetchWithTrustedCertificatesAsync(this.url("/admin/api.php?summaryRaw", { auth: apiKey }));
if (!response.ok) {
throw new Error(
`Failed to fetch summary for ${this.integration.name} (${this.integration.id}): ${response.statusText}`,
@@ -36,11 +38,11 @@ export class PiHoleIntegration extends Integration implements DnsHoleSummaryInte
await super.handleTestConnectionResponseAsync({
queryFunctionAsync: async () => {
return await fetch(this.url("/admin/api.php?status", { auth: apiKey }));
return await fetchWithTrustedCertificatesAsync(this.url("/admin/api.php?status", { auth: apiKey }));
},
handleResponseAsync: async (response) => {
try {
const result = (await response.json()) as unknown;
const result = await response.json();
if (typeof result === "object" && result !== null && "status" in result) return;
} catch {
throw new IntegrationTestConnectionError("invalidJson");
@@ -53,7 +55,7 @@ export class PiHoleIntegration extends Integration implements DnsHoleSummaryInte
public async enableAsync(): Promise<void> {
const apiKey = super.getSecretValue("apiKey");
const response = await fetch(this.url("/admin/api.php?enable", { auth: apiKey }));
const response = await fetchWithTrustedCertificatesAsync(this.url("/admin/api.php?enable", { auth: apiKey }));
if (!response.ok) {
throw new Error(
`Failed to enable PiHole for ${this.integration.name} (${this.integration.id}): ${response.statusText}`,
@@ -64,7 +66,7 @@ export class PiHoleIntegration extends Integration implements DnsHoleSummaryInte
public async disableAsync(duration?: number): Promise<void> {
const apiKey = super.getSecretValue("apiKey");
const url = this.url(`/admin/api.php?disable${duration ? `=${duration}` : ""}`, { auth: apiKey });
const response = await fetch(url);
const response = await fetchWithTrustedCertificatesAsync(url);
if (!response.ok) {
throw new Error(
`Failed to disable PiHole for ${this.integration.name} (${this.integration.id}): ${response.statusText}`,
@@ -1,5 +1,6 @@
import { parseStringPromise } from "xml2js";
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
import { logger } from "@homarr/log";
import { Integration } from "../base/integration";
@@ -11,7 +12,7 @@ export class PlexIntegration extends Integration {
public async getCurrentSessionsAsync(): Promise<StreamSession[]> {
const token = super.getSecretValue("apiKey");
const response = await fetch(this.url("/status/sessions"), {
const response = await fetchWithTrustedCertificatesAsync(this.url("/status/sessions"), {
headers: {
"X-Plex-Token": token,
},
@@ -66,7 +67,7 @@ export class PlexIntegration extends Integration {
await super.handleTestConnectionResponseAsync({
queryFunctionAsync: async () => {
return await fetch(this.url("/"), {
return await fetchWithTrustedCertificatesAsync(this.url("/"), {
headers: {
"X-Plex-Token": token,
},
@@ -1,3 +1,5 @@
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
import { Integration } from "../base/integration";
import { IntegrationTestConnectionError } from "../base/test-connection-error";
import type { Indexer } from "../interfaces/indexer-manager/indexer";
@@ -7,7 +9,7 @@ export class ProwlarrIntegration extends Integration {
public async getIndexersAsync(): Promise<Indexer[]> {
const apiKey = super.getSecretValue("apiKey");
const indexerResponse = await fetch(this.url("/api/v1/indexer"), {
const indexerResponse = await fetchWithTrustedCertificatesAsync(this.url("/api/v1/indexer"), {
headers: {
"X-Api-Key": apiKey,
},
@@ -18,7 +20,7 @@ export class ProwlarrIntegration extends Integration {
);
}
const statusResponse = await fetch(this.url("/api/v1/indexerstatus"), {
const statusResponse = await fetchWithTrustedCertificatesAsync(this.url("/api/v1/indexerstatus"), {
headers: {
"X-Api-Key": apiKey,
},
@@ -60,7 +62,7 @@ export class ProwlarrIntegration extends Integration {
public async testAllAsync(): Promise<void> {
const apiKey = super.getSecretValue("apiKey");
const response = await fetch(this.url("/api/v1/indexer/testall"), {
const response = await fetchWithTrustedCertificatesAsync(this.url("/api/v1/indexer/testall"), {
headers: {
"X-Api-Key": apiKey,
},
@@ -78,7 +80,7 @@ export class ProwlarrIntegration extends Integration {
await super.handleTestConnectionResponseAsync({
queryFunctionAsync: async () => {
return await fetch(this.url("/api"), {
return await fetchWithTrustedCertificatesAsync(this.url("/api"), {
headers: {
"X-Api-Key": apiKey,
},
@@ -86,7 +88,7 @@ export class ProwlarrIntegration extends Integration {
},
handleResponseAsync: async (response) => {
try {
const result = (await response.json()) as unknown;
const result = await response.json();
if (typeof result === "object" && result !== null) return;
} catch {
throw new IntegrationTestConnectionError("invalidJson");
@@ -0,0 +1,125 @@
import type { Proxmox } from "proxmox-api";
import proxmoxApi from "proxmox-api";
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
import { extractErrorMessage } from "@homarr/common";
import { logger } from "@homarr/log";
import { Integration } from "../base/integration";
import { IntegrationTestConnectionError } from "../base/test-connection-error";
import type {
ComputeResourceBase,
LxcResource,
NodeResource,
QemuResource,
Resource,
StorageResource,
} from "./proxmox-types";
export class ProxmoxIntegration extends Integration {
public async testConnectionAsync(): Promise<void> {
const proxmox = this.getPromoxApi();
await proxmox.nodes.$get().catch((error) => {
throw new IntegrationTestConnectionError("internalServerError", extractErrorMessage(error));
});
}
public async getClusterInfoAsync() {
const proxmox = this.getPromoxApi();
const resources = await proxmox.cluster.resources.$get();
logger.info(
`Found ${resources.length} resources in Proxmox cluster node=${resources.filter((resource) => resource.type === "node").length} lxc=${resources.filter((resource) => resource.type === "lxc").length} qemu=${resources.filter((resource) => resource.type === "qemu").length} storage=${resources.filter((resource) => resource.type === "storage").length}`,
);
const mappedResources = resources.map(mapResource).filter((resource) => resource !== null);
return {
nodes: mappedResources.filter((resource): resource is NodeResource => resource.type === "node"),
lxcs: mappedResources.filter((resource): resource is LxcResource => resource.type === "lxc"),
vms: mappedResources.filter((resource): resource is QemuResource => resource.type === "qemu"),
storages: mappedResources.filter((resource): resource is StorageResource => resource.type === "storage"),
};
}
private getPromoxApi() {
return proxmoxApi({
host: this.url("/").host,
tokenID: `${this.getSecretValue("username")}@${this.getSecretValue("realm")}!${this.getSecretValue("tokenId")}`,
tokenSecret: this.getSecretValue("apiKey"),
fetch: fetchWithTrustedCertificatesAsync,
});
}
}
const mapResource = (resource: Proxmox.clusterResourcesResources): Resource | null => {
switch (resource.type) {
case "node":
return mapNodeResource(resource);
case "lxc":
case "qemu":
return mapVmResource(resource);
case "storage":
return mapStorageResource(resource);
}
return null;
};
const mapComputeResource = (resource: Proxmox.clusterResourcesResources): Omit<ComputeResourceBase<string>, "type"> => {
return {
cpu: {
utilization: resource.cpu ?? 0,
cores: resource.maxcpu ?? 0,
},
memory: {
used: resource.mem ?? 0,
total: resource.maxmem ?? 0,
},
storage: {
used: resource.disk ?? 0,
total: resource.maxdisk ?? 0,
read: (resource.diskread as number | null) ?? null,
write: (resource.diskwrite as number | null) ?? null,
},
network: {
in: (resource.netin as number | null) ?? null,
out: (resource.netout as number | null) ?? null,
},
haState: resource.hastate ?? null,
isRunning: resource.status === "running" || resource.status === "online",
name: resource.name ?? "",
node: resource.node ?? "",
status: resource.status ?? (resource.type === "node" ? "offline" : "stopped"),
uptime: resource.uptime ?? 0,
};
};
const mapNodeResource = (resource: Proxmox.clusterResourcesResources): NodeResource => {
return {
type: "node",
...mapComputeResource(resource),
name: resource.node ?? "",
};
};
const mapVmResource = (resource: Proxmox.clusterResourcesResources): LxcResource | QemuResource => {
return {
type: resource.type as "lxc" | "qemu",
vmId: resource.vmid ?? 0,
...mapComputeResource(resource),
};
};
const mapStorageResource = (resource: Proxmox.clusterResourcesResources): StorageResource => {
return {
type: "storage",
name: resource.storage ?? "",
node: resource.node ?? "",
isRunning: resource.status === "available",
status: resource.status ?? "offline",
storagePlugin: resource.storage ?? "",
total: resource.maxdisk ?? 0,
used: resource.disk ?? 0,
isShared: resource.shared === 1,
};
};
@@ -0,0 +1,57 @@
interface ResourceBase<TType extends string> {
type: TType;
name: string;
node: string;
isRunning: boolean;
status: string;
}
export interface ComputeResourceBase<TType extends string> extends ResourceBase<TType> {
cpu: {
utilization: number; // previously cpu (0-1)
cores: number; // previously cpuCores
};
memory: {
used: number; // previously mem
total: number; // previously maxMem
};
storage: {
used: number; // previously disk
total: number; // previously maxDisk
read: number | null; // previously diskRead
write: number | null; // previously diskWrite
};
network: {
in: number | null; // previously netIn
out: number | null; // previously netOut
};
uptime: number; // expressed in seconds
haState: string | null; // HA service status (for HA managed VMs).
}
export type NodeResource = ComputeResourceBase<"node">;
export interface LxcResource extends ComputeResourceBase<"lxc"> {
vmId: number;
}
export interface QemuResource extends ComputeResourceBase<"qemu"> {
vmId: number;
}
export interface StorageResource extends ResourceBase<"storage"> {
storagePlugin: string;
used: number; // previously disk
total: number; // previously maxDisk
isShared: boolean; // previously storageShared
}
export type ComputeResource = NodeResource | LxcResource | QemuResource;
export type Resource = ComputeResource | StorageResource;
export interface ProxmoxClusterInfo {
nodes: NodeResource[];
lxcs: LxcResource[];
vms: QemuResource[];
storages: StorageResource[];
}
+1
View File
@@ -6,3 +6,4 @@ export * from "./interfaces/media-requests/media-request";
export * from "./pi-hole/pi-hole-types";
export * from "./base/searchable-integration";
export * from "./homeassistant/homeassistant-types";
export * from "./proxmox/proxmox-types";
+1
View File
@@ -1,3 +1,4 @@
import { Response } from "undici";
import { describe, expect, test } from "vitest";
import { IntegrationTestConnectionError } from "../src";
+1 -1
View File
@@ -35,7 +35,7 @@
"@mantine/core": "^7.16.0",
"@tabler/icons-react": "^3.28.1",
"dayjs": "^1.11.13",
"next": "15.1.4",
"next": "15.1.5",
"react": "19.0.0",
"react-dom": "19.0.0"
},
+1 -1
View File
@@ -40,7 +40,7 @@
"@mantine/core": "^7.16.0",
"@mantine/hooks": "^7.16.0",
"adm-zip": "0.5.16",
"next": "15.1.4",
"next": "15.1.5",
"react": "19.0.0",
"react-dom": "19.0.0",
"superjson": "2.2.2",
@@ -1,5 +1,5 @@
import { objectEntries } from "@homarr/common";
import type { Database, InferInsertModel } from "@homarr/db";
import type { Database, HomarrDatabaseMysql, InferInsertModel } from "@homarr/db";
import * as schema from "@homarr/db/schema";
type TableKey = {
@@ -29,5 +29,15 @@ export const createDbInsertCollection = <TTableKey extends TableKey>(tablesInIns
}
});
},
insertAllAsync: async (db: HomarrDatabaseMysql) => {
await db.transaction(async (transaction) => {
for (const [key, values] of objectEntries(context)) {
if (values.length >= 1) {
// Below is actually the mysqlSchema when the driver is mysql
await transaction.insert(schema[key] as never).values(values as never);
}
}
});
},
};
};
@@ -1,6 +1,7 @@
import type { z } from "zod";
import { Stopwatch } from "@homarr/common";
import { handleTransactionsAsync } from "@homarr/db";
import type { Database } from "@homarr/db";
import { logger } from "@homarr/log";
@@ -34,11 +35,21 @@ export const importInitialOldmarrAsync = async (
logger.info("Inserting import data to database");
// Due to a limitation with better-sqlite it's only possible to use it synchronously
db.transaction((transaction) => {
boardInsertCollection.insertAll(transaction);
userInsertCollection.insertAll(transaction);
integrationInsertCollection.insertAll(transaction);
await handleTransactionsAsync(db, {
async handleAsync(db) {
await db.transaction(async (transaction) => {
await boardInsertCollection.insertAllAsync(transaction);
await userInsertCollection.insertAllAsync(transaction);
await integrationInsertCollection.insertAllAsync(transaction);
});
},
handleSync(db) {
db.transaction((transaction) => {
boardInsertCollection.insertAll(transaction);
userInsertCollection.insertAll(transaction);
integrationInsertCollection.insertAll(transaction);
});
},
});
logger.info(`Import successful (in ${stopwatch.getElapsedInHumanWords()})`);
@@ -1,4 +1,4 @@
import { inArray } from "@homarr/db";
import { handleTransactionsAsync, inArray } from "@homarr/db";
import type { Database } from "@homarr/db";
import { apps } from "@homarr/db/schema";
import type { OldmarrConfig } from "@homarr/old-schema";
@@ -31,6 +31,12 @@ export const importSingleOldmarrConfigAsync = async (
const boardInsertCollection = createBoardInsertCollection({ preparedApps, preparedBoards }, settings);
// Due to a limitation with better-sqlite it's only possible to use it synchronously
boardInsertCollection.insertAll(db);
await handleTransactionsAsync(db, {
async handleAsync(db) {
await boardInsertCollection.insertAllAsync(db);
},
handleSync(db) {
boardInsertCollection.insertAll(db);
},
});
};
@@ -25,7 +25,7 @@ const mapping: Record<OldmarrIntegrationType, IntegrationKind | null> = {
overseerr: "overseerr",
pihole: "piHole",
prowlarr: "prowlarr",
proxmox: null,
proxmox: "proxmox",
qBittorrent: "qBittorrent",
radarr: "radarr",
readarr: "readarr",
@@ -141,6 +141,9 @@ const optionMapping: OptionMapping = {
"fileSystem" in oldOptions
? oldOptions.fileSystem
: oldOptions.graphsOrder.some((graph) => graph.key === "storage" && graph.subValues.enabled),
defaultTab: (oldOptions) => ("defaultTabState" in oldOptions ? oldOptions.defaultTabState : undefined),
sectionIndicatorRequirement: (oldOptions) =>
"sectionIndicatorColor" in oldOptions ? oldOptions.sectionIndicatorColor : undefined,
},
mediaTranscoding: {
defaultView: (oldOptions) => oldOptions.defaultView,
@@ -2,13 +2,13 @@ import dayjs from "dayjs";
import type { IntegrationKindByCategory } from "@homarr/definitions";
import { integrationCreator } from "@homarr/integrations";
import type { HealthMonitoring } from "@homarr/integrations/types";
import type { HealthMonitoring, ProxmoxClusterInfo } from "@homarr/integrations/types";
import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
export const systemInfoRequestHandler = createCachedIntegrationRequestHandler<
HealthMonitoring,
IntegrationKindByCategory<"healthMonitoring">,
Exclude<IntegrationKindByCategory<"healthMonitoring">, "proxmox">,
Record<string, never>
>({
async requestAsync(integration, _input) {
@@ -18,3 +18,16 @@ export const systemInfoRequestHandler = createCachedIntegrationRequestHandler<
cacheDuration: dayjs.duration(5, "seconds"),
queryKey: "systemInfo",
});
export const clusterInfoRequestHandler = createCachedIntegrationRequestHandler<
ProxmoxClusterInfo,
"proxmox",
Record<string, never>
>({
async requestAsync(integration, _input) {
const integrationInstance = integrationCreator(integration);
return await integrationInstance.getClusterInfoAsync();
},
cacheDuration: dayjs.duration(5, "seconds"),
queryKey: "clusterInfo",
});
+1 -1
View File
@@ -37,7 +37,7 @@
"@mantine/spotlight": "^7.16.0",
"@tabler/icons-react": "^3.28.1",
"jotai": "^2.11.0",
"next": "15.1.4",
"next": "15.1.5",
"react": "19.0.0",
"react-dom": "19.0.0",
"use-deep-compare-effect": "^1.8.1"
+1 -1
View File
@@ -61,7 +61,7 @@ const helpMode = {
{
label: t("submitIssue.label"),
icon: IconBrandGithub,
href: "https://github.com/ajnart/homarr/issues/new/choose",
href: "https://github.com/homarr-labs/homarr/issues/new/choose",
},
{
label: t("discord.label"),
+1 -1
View File
@@ -32,7 +32,7 @@
"dayjs": "^1.11.13",
"deepmerge": "4.3.1",
"mantine-react-table": "2.0.0-beta.8",
"next": "15.1.4",
"next": "15.1.5",
"next-intl": "3.26.3",
"react": "19.0.0",
"react-dom": "19.0.0"
+64 -1
View File
@@ -897,6 +897,7 @@
"passwordRequirements": "密码不符合要求",
"boardAlreadyExists": "具有此名称的面板已存在",
"invalidFileType": "无效的文件类型,例如 {expected}",
"invalidFileName": "",
"fileTooLarge": "文件太大,最大大小为 {maxSize}",
"invalidConfiguration": "无效配置",
"groupNameTaken": "用户组名称已存在"
@@ -2101,6 +2102,7 @@
"docker": "Docker",
"logs": "日志",
"api": "API",
"certificates": "",
"tasks": "任务"
}
},
@@ -2153,6 +2155,13 @@
"tooltip": "此面板将显示为您的主面板"
}
},
"setMobileHomeBoard": {
"label": "设置为您的手机面板",
"badge": {
"label": "手机",
"tooltip": "此面板将显示为您的手机面板"
}
},
"duplicate": {
"label": "复制面板"
},
@@ -2189,7 +2198,13 @@
"title": "通用",
"item": {
"language": "语言与地区",
"board": "主面板",
"board": {
"title": "主面板",
"type": {
"general": "通用",
"mobile": "手机"
}
},
"defaultSearchEngine": "默认搜索引擎",
"firstDayOfWeek": "一周的第一天",
"accessibility": "无障碍服务"
@@ -2349,6 +2364,7 @@
"title": "面板",
"homeBoard": {
"label": "全局主面板",
"mobileLabel": "全局手机面板",
"description": "只有公共面板可供选择"
}
},
@@ -2692,6 +2708,9 @@
},
"logs": {
"label": "日志"
},
"certificates": {
"label": ""
}
},
"settings": {
@@ -2738,6 +2757,9 @@
"homeBoard": {
"label": "设置为主面板"
},
"mobileBoard": {
"label": "设置为手机面板"
},
"settings": {
"label": "打开设置"
}
@@ -3084,5 +3106,46 @@
}
}
}
},
"certificate": {
"page": {
"list": {
"title": "",
"description": "",
"noResults": {
"title": ""
},
"expires": ""
}
},
"action": {
"create": {
"label": "",
"notification": {
"success": {
"title": "",
"message": ""
},
"error": {
"title": "",
"message": ""
}
}
},
"remove": {
"label": "",
"confirm": "",
"notification": {
"success": {
"title": "",
"message": ""
},
"error": {
"title": "",
"message": ""
}
}
}
}
}
}
+64 -1
View File
@@ -897,6 +897,7 @@
"passwordRequirements": "Heslo nesplňuje požadavky",
"boardAlreadyExists": "Plocha s tímto názvem již existuje",
"invalidFileType": "Neplatný typ souboru, očekáván {expected}",
"invalidFileName": "",
"fileTooLarge": "Soubor je příliš velký, maximální velikost je {maxSize}",
"invalidConfiguration": "Neplatná konfigurace",
"groupNameTaken": "Název skupiny je již používán"
@@ -2101,6 +2102,7 @@
"docker": "",
"logs": "",
"api": "",
"certificates": "",
"tasks": ""
}
},
@@ -2153,6 +2155,13 @@
"tooltip": ""
}
},
"setMobileHomeBoard": {
"label": "",
"badge": {
"label": "",
"tooltip": ""
}
},
"duplicate": {
"label": ""
},
@@ -2189,7 +2198,13 @@
"title": "Obecné",
"item": {
"language": "Jazyk & oblast",
"board": "Domovská plocha",
"board": {
"title": "",
"type": {
"general": "",
"mobile": ""
}
},
"defaultSearchEngine": "Výchozí vyhledávač",
"firstDayOfWeek": "První den v týdnu",
"accessibility": "Přístupnost"
@@ -2349,6 +2364,7 @@
"title": "Plochy",
"homeBoard": {
"label": "",
"mobileLabel": "",
"description": ""
}
},
@@ -2692,6 +2708,9 @@
},
"logs": {
"label": ""
},
"certificates": {
"label": ""
}
},
"settings": {
@@ -2738,6 +2757,9 @@
"homeBoard": {
"label": ""
},
"mobileBoard": {
"label": ""
},
"settings": {
"label": ""
}
@@ -3084,5 +3106,46 @@
}
}
}
},
"certificate": {
"page": {
"list": {
"title": "",
"description": "",
"noResults": {
"title": ""
},
"expires": ""
}
},
"action": {
"create": {
"label": "",
"notification": {
"success": {
"title": "",
"message": ""
},
"error": {
"title": "",
"message": ""
}
}
},
"remove": {
"label": "",
"confirm": "",
"notification": {
"success": {
"title": "",
"message": ""
},
"error": {
"title": "",
"message": ""
}
}
}
}
}
}
+64 -1
View File
@@ -897,6 +897,7 @@
"passwordRequirements": "",
"boardAlreadyExists": "",
"invalidFileType": "",
"invalidFileName": "",
"fileTooLarge": "",
"invalidConfiguration": "",
"groupNameTaken": ""
@@ -2101,6 +2102,7 @@
"docker": "",
"logs": "",
"api": "",
"certificates": "",
"tasks": ""
}
},
@@ -2153,6 +2155,13 @@
"tooltip": ""
}
},
"setMobileHomeBoard": {
"label": "",
"badge": {
"label": "",
"tooltip": ""
}
},
"duplicate": {
"label": ""
},
@@ -2189,7 +2198,13 @@
"title": "Generelt",
"item": {
"language": "",
"board": "",
"board": {
"title": "",
"type": {
"general": "",
"mobile": ""
}
},
"defaultSearchEngine": "",
"firstDayOfWeek": "Første ugedag",
"accessibility": "Hjælpefunktioner"
@@ -2349,6 +2364,7 @@
"title": "",
"homeBoard": {
"label": "",
"mobileLabel": "",
"description": ""
}
},
@@ -2692,6 +2708,9 @@
},
"logs": {
"label": ""
},
"certificates": {
"label": ""
}
},
"settings": {
@@ -2738,6 +2757,9 @@
"homeBoard": {
"label": ""
},
"mobileBoard": {
"label": ""
},
"settings": {
"label": ""
}
@@ -3084,5 +3106,46 @@
}
}
}
},
"certificate": {
"page": {
"list": {
"title": "",
"description": "",
"noResults": {
"title": ""
},
"expires": ""
}
},
"action": {
"create": {
"label": "",
"notification": {
"success": {
"title": "",
"message": ""
},
"error": {
"title": "",
"message": ""
}
}
},
"remove": {
"label": "",
"confirm": "",
"notification": {
"success": {
"title": "",
"message": ""
},
"error": {
"title": "",
"message": ""
}
}
}
}
}
}
+205 -142
View File
@@ -213,10 +213,10 @@
"changeDefaultSearchEngine": {
"notification": {
"success": {
"message": ""
"message": "Standard-Suchmaschine erfolgreich geändert"
},
"error": {
"message": ""
"message": "Standard Suchmaschine kann nicht geändert werden"
}
}
},
@@ -499,7 +499,7 @@
}
},
"app": {
"search": "",
"search": "App suchen",
"page": {
"list": {
"title": "Apps",
@@ -625,8 +625,8 @@
"label": "URL"
},
"attemptSearchEngineCreation": {
"label": "",
"description": ""
"label": "Suchmaschinen anlegen",
"description": "Integration \"{kind}\" kann mit den Suchmaschinen verwendet werden. Wählen Sie dies, um die Suchmaschine automatisch zu konfigurieren."
}
},
"action": {
@@ -897,6 +897,7 @@
"passwordRequirements": "Passwort erfüllt nicht alle Anforderungen",
"boardAlreadyExists": "Ein Board mit diesem Namen existiert bereits",
"invalidFileType": "Ungültiger Dateityp, erwartete {expected}",
"invalidFileName": "",
"fileTooLarge": "Datei ist zu groß, maximale Größe ist {maxSize}",
"invalidConfiguration": "Ungültige Konfiguration",
"groupNameTaken": "Gruppenname bereits vergeben"
@@ -918,7 +919,7 @@
"category": {
"field": {
"name": {
"label": ""
"label": "Name"
}
},
"action": {
@@ -966,7 +967,7 @@
},
"create": {
"title": "Wählen Sie das hinzuzufügende Element aus",
"search": "",
"search": "Gegenstände filtern",
"addToBoard": "Zum Board hinzufügen"
},
"moveResize": {
@@ -1008,7 +1009,7 @@
},
"widget": {
"app": {
"name": "",
"name": "App",
"description": "Bettet eine App in das Board ein.",
"option": {
"appId": {
@@ -1045,24 +1046,24 @@
"label": "Ansicht",
"option": {
"row": {
"label": ""
"label": "Horizontal"
},
"column": {
"label": "Vertikal"
},
"grid": {
"label": ""
"label": "Raster"
}
}
},
"items": {
"label": "Lesezeichen",
"add": ""
"add": "Lesezeichen hinzufügen"
}
}
},
"dnsHoleSummary": {
"name": "",
"name": "DNS Hole Zusammenfassung",
"description": "Zeigt die Zusammenfassung deines DNS Holes an",
"option": {
"layout": {
@@ -1093,7 +1094,7 @@
"dnsQueriesToday": "Abfragen heute",
"domainsBeingBlocked": "Domänen auf der Sperrliste"
},
"domainsTooltip": ""
"domainsTooltip": "Aufgrund mehrerer Integrationen kann Homarr die genaue Anzahl der blockierten Domains nicht berechnen"
},
"dnsHoleControls": {
"name": "DNS Hole Steuerung",
@@ -1169,22 +1170,22 @@
}
},
"minecraftServerStatus": {
"name": "",
"description": "",
"name": "Status des Minecraft Servers",
"description": "Status des Minecraft Servers anzeigen",
"option": {
"title": {
"label": ""
"label": "Titel"
},
"domain": {
"label": ""
"label": "Server Adresse"
},
"isBedrockServer": {
"label": ""
"label": "Bedrock Server"
}
},
"status": {
"online": "",
"offline": ""
"online": "Online",
"offline": "Offline"
}
},
"notebook": {
@@ -1282,7 +1283,7 @@
},
"error": {
"noUrl": "Keine iFrame URL angegeben",
"unsupportedProtocol": "",
"unsupportedProtocol": "Die angegebene URL verwendet ein nicht unterstütztes Protokoll. Bitte verwenden Sie eines von ({supportedProtocols})",
"noBrowerSupport": "Ihr Browser unterstützt keine iframes. Bitte aktualisieren Sie Ihren Browser."
}
},
@@ -1417,37 +1418,37 @@
"processor": "Prozessor: {cpuModelName}",
"memory": "Speicher: {memory}GiB",
"memoryAvailable": "Verfügbar: {memoryAvailable} GiB ({percent}%)",
"version": "",
"version": "Version: {version}",
"uptime": "Laufzeit: {months} Monate, {days} Tage, {hours} Stunden, {minutes} Minuten",
"loadAverage": "Durchschnittliche Last:",
"minute": "1 Minute",
"minutes": "{count} Minuten",
"used": "Belegt",
"available": "Verfügbar",
"lastSeen": ""
"lastSeen": "Letzte Statusaktualisierung: {lastSeen}"
},
"memory": {},
"error": {
"internalServerError": ""
"internalServerError": "Fehler beim Abrufen des Gesundheitsstatus"
}
},
"common": {
"location": {
"query": "",
"latitude": "",
"longitude": "",
"disabledTooltip": "",
"unknownLocation": "",
"query": "Stadt / Postleitzahl",
"latitude": "Breitengrad",
"longitude": "Längengrad",
"disabledTooltip": "Gib eine Stadt oder Postleitzahl an",
"unknownLocation": "Unbekannter Ort",
"search": "Suche",
"table": {
"header": {
"city": "",
"country": "",
"coordinates": "",
"population": ""
"city": "Stadt",
"country": "Land",
"coordinates": "Koordinaten",
"population": "Bevölkerung"
},
"action": {
"select": ""
"select": "Wähle {city}, {countryCode}"
},
"population": {
"fallback": "Unbekannt"
@@ -1455,16 +1456,16 @@
}
},
"integration": {
"noData": "",
"description": ""
"noData": "Keine Integration gefunden",
"description": "Klicken Sie <here></here> um eine neue Integration zu erstellen"
},
"app": {
"noData": "Keine App gefunden",
"description": ""
"description": "Klicken Sie <here></here> um eine neue App zu erstellen"
},
"error": {
"noIntegration": "",
"noData": ""
"noIntegration": "Keine Integration ausgewählt",
"noData": "Keine Integrationsdaten verfügbar"
},
"option": {}
},
@@ -1477,32 +1478,32 @@
},
"hasAutoPlay": {
"label": "Automatische Wiedergabe",
"description": ""
"description": "Autoplay funktioniert nur bei Stummschaltung aufgrund von Browser Einschränkungen"
},
"isMuted": {
"label": ""
"label": "Stumm geschaltet"
},
"hasControls": {
"label": ""
"label": "Steuerelemente anzeigen"
}
},
"error": {
"noUrl": "",
"forYoutubeUseIframe": ""
"noUrl": "Keine Video URL angegeben",
"forYoutubeUseIframe": "Für YouTube Videos verwenden Sie die iframe Option"
}
},
"mediaServer": {
"name": "",
"description": "",
"name": "Aktuelle Media Server Streams",
"description": "Zeige die aktuellen Streams auf deinen Medienservern an",
"option": {},
"items": {
"user": "",
"name": "",
"id": ""
"user": "Benutzer",
"name": "Name",
"id": "Id"
}
},
"downloads": {
"name": "",
"name": "Download Client",
"description": "",
"option": {
"columns": {
@@ -1634,15 +1635,15 @@
"delete": {
"title": "",
"modalTitle": "",
"entry": "",
"entryAndFiles": ""
"entry": "Eintrag löschen",
"entryAndFiles": "Eintrag und Datei(en) löschen"
}
}
},
"globalRatio": ""
"globalRatio": "Globales Verhältnis"
},
"mediaRequests-requestList": {
"name": "",
"name": "Liste der Medienanfragen",
"description": "Sehen Sie eine Liste aller Medienanfragen von Ihrer Overseerr- oder Jellyseerr-Instanz",
"option": {
"linksTargetNewTab": {
@@ -1650,21 +1651,21 @@
}
},
"pending": {
"approve": "",
"approving": "",
"decline": ""
"approve": "Anfrage annehmen",
"approving": "Anfrage wird bestätigt...",
"decline": "Antrag ablehnen"
},
"availability": {
"unknown": "Unbekannt",
"pending": "",
"processing": "",
"pending": "Ausstehend",
"processing": "In Bearbeitung",
"partiallyAvailable": "Teilweise",
"available": "Verfügbar"
},
"toBeDetermined": ""
"toBeDetermined": "Noch Festzulegen"
},
"mediaRequests-requestStats": {
"name": "",
"name": "Statistik der Medienanfragen",
"description": "Statistiken über Ihre Medienanfragen",
"option": {},
"titles": {
@@ -1672,38 +1673,38 @@
"main": "Medien Statistiken",
"approved": "Bereits genehmigt",
"pending": "Ausstehende Freigaben",
"processing": "",
"declined": "",
"available": "",
"processing": "Wird verarbeitet",
"declined": "Bereits abgelehnt",
"available": "Bereits verfügbar",
"tv": "TV Anfragen",
"movie": "Film Anfragen",
"total": "Gesamt"
},
"users": {
"main": "Top Nutzer",
"requests": ""
"requests": "Anfragen"
}
}
},
"mediaTranscoding": {
"name": "",
"description": "",
"name": "Medien Transkodierung",
"description": "Statistiken, aktuelle Warteschlange und Status Ihrer Medien Transkordierung",
"option": {
"defaultView": {
"label": "Standardansicht"
},
"queuePageSize": {
"label": ""
"label": "Größe der Warteschlange"
}
},
"tab": {
"workers": "",
"workers": "Arbeiter",
"queue": "Warteschlange",
"statistics": "Statistiken"
},
"currentIndex": "{start}-{end} von {total}",
"healthCheck": {
"title": "",
"title": "Gesundheitscheck",
"queued": "In der Warteschlange",
"status": {
"healthy": "Gesund",
@@ -1714,12 +1715,12 @@
"statistics": {
"empty": "Leer",
"transcodes": "Transkodieren",
"transcodesCount": "",
"healthChecksCount": "",
"filesCount": "",
"savedSpace": "",
"healthChecks": "",
"videoCodecs": "",
"transcodesCount": "Transkodierung: {value}",
"healthChecksCount": "Gesundheitscheck: {value}",
"filesCount": "Dateien: {value}",
"savedSpace": "Gespeicherter Speicherplatz: {value}",
"healthChecks": "Gesundheitscheck",
"videoCodecs": "Codecs",
"videoContainers": "Container",
"videoResolutions": "Auflösungen"
},
@@ -1730,7 +1731,7 @@
"eta": "Voraussichtlicher Abschluss",
"progress": "Fortschritt",
"transcode": "Transkodieren",
"healthCheck": ""
"healthCheck": "Gesundheitscheck"
}
},
"queue": {
@@ -1739,52 +1740,52 @@
"file": "Datei",
"size": "Größe",
"transcode": "Transkodieren",
"healthCheck": ""
"healthCheck": "Gesundheitscheck"
}
}
}
},
"rssFeed": {
"name": "",
"description": "",
"name": "RSS Feeds",
"description": "Einen oder mehrere generische RSS, ATOM oder JSON-Feeds überwachen und anzeigen",
"option": {
"feedUrls": {
"label": ""
"label": "Feed URLs"
},
"enableRtl": {
"label": ""
"label": "RTL aktivieren"
},
"textLinesClamp": {
"label": ""
"label": "Beschreibung line clamp"
},
"maximumAmountPosts": {
"label": ""
"label": "Limit für Beiträge"
}
}
}
},
"widgetPreview": {
"toggle": {
"enabled": "",
"disabled": ""
"enabled": "Bearbeitungsmodus aktiviert",
"disabled": "Bearbeitungsmodus deaktiviert"
},
"dimensions": {
"title": ""
"title": "Dimensionen ändern"
}
},
"board": {
"action": {
"duplicate": {
"title": "",
"message": "",
"title": "Board duplizieren",
"message": "Dies wird das Board {name} mit all seinen Inhalten duplizieren. Wenn Widgets Integrationen, hinterlegt sind die Sie nicht verwenden dürfen, werden diese entfernt.",
"notification": {
"success": {
"title": "",
"message": ""
"title": "Board dupliziert",
"message": "Das Board wurde erfolgreich dupliziert"
},
"error": {
"title": "",
"message": ""
"title": "Board konnte nicht dupliziert werden",
"message": "Das Board konnte nicht dupliziert werden"
}
}
},
@@ -1792,24 +1793,24 @@
"notification": {
"success": {
"title": "Änderungen erfolgreich angewendet",
"message": ""
"message": "Das Board wurde erfolgreich dupliziert"
},
"error": {
"title": "Änderungen konnten nicht angewendet werden",
"message": ""
"message": "Das Board konnte nicht gespeichert werden"
}
},
"confirmLeave": {
"title": "",
"message": ""
"title": "Nicht gespeicherte Änderungen",
"message": "Sie haben ungespeicherte Änderungen, sind sie sicher dass sie abbrechen möchten?"
}
},
"oldImport": {
"label": "",
"label": "Import von Homarr vor Version 1.0.0",
"notification": {
"success": {
"title": "",
"message": ""
"title": "Import erfolgreich",
"message": "Das Board wurde erfolgreich importiert"
},
"error": {
"title": "",
@@ -1969,23 +1970,23 @@
"title": "Hintergrund"
},
"color": {
"title": ""
"title": "Farben"
},
"customCss": {
"title": ""
"title": "Benutzerdefiniertes CSS"
},
"access": {
"title": "",
"title": "Zugriffskontrolle",
"permission": {
"item": {
"view": {
"label": "Board anzeigen"
},
"modify": {
"label": ""
"label": "Board bearbeiten"
},
"full": {
"label": ""
"label": "Voller Zugriff"
}
}
}
@@ -1994,41 +1995,41 @@
"title": "Gefahrenbereich",
"action": {
"rename": {
"label": "",
"description": "",
"button": "",
"label": "Board umbenennen",
"description": "Das Ändern des Namens wird alle Links zu diesem Board aufheben.",
"button": "Namen ändern",
"modal": {
"title": ""
"title": "Board umbenennen"
}
},
"visibility": {
"label": "",
"label": "Sichtbarkeit des Boards ändern",
"description": {
"public": "",
"private": ""
"public": "Dieses Board ist derzeit öffentlich.",
"private": "Dieses Board ist derzeit privat."
},
"button": {
"public": "",
"private": ""
"public": "Als privat festlegen",
"private": "Als öffentlich festlegen"
},
"confirm": {
"public": {
"title": "",
"description": ""
"title": "Board auf privat schalten",
"description": "Sind Sie sicher, dass Sie dieses Board privat machen möchten? Dies wird das Board vor der Öffentlichkeit ausblenden. Links für Gastbenutzer werden unbrauchbar."
},
"private": {
"title": "",
"description": ""
"title": "Board auf öffentlich schalten",
"description": "Sind Sie sicher, dass Sie dieses Board öffentlich machen möchten? Dies wird das Board für alle zugänglich machen."
}
}
},
"delete": {
"label": "",
"description": "",
"button": "",
"label": "Board löschen",
"description": "Sobald Sie ein Board löschen, gibt es kein zurück mehr. Bitte seien sie sich dessen bewusst.",
"button": "Board löschen",
"confirm": {
"title": "Board löschen",
"description": ""
"description": "Sind Sie sicher, dass Sie dieses Board löschen möchten? Dies wird das Board und seinen gesamten Inhalt permanent löschen."
}
}
}
@@ -2038,32 +2039,32 @@
"error": {
"noBoard": {
"title": "Willkommen bei Homarr",
"description": "",
"description": "Ein schlankens, modernes Dashboard, das alle Ihre Apps und Dienste bereit stellt.",
"link": "Erstellen Sie Ihr erstes Board",
"notice": ""
"notice": "Um diese Seite verschwinden zu lassen, erstellen Sie ein Board und setzen es als Home Board"
},
"notFound": {
"title": "",
"description": "",
"title": "Board nicht gefunden",
"description": "Das angegebene Board wurde entweder nicht gefunden oder Sie haben keinen Zugriff darauf.",
"link": "Alle Boards anzeigen",
"notice": ""
"notice": "Überprüfen Sie den Link oder wenden Sie sich an einen Administrator, wenn Sie glauben, dass er zugänglich sein sollte"
},
"homeBoard": {
"title": "",
"title": "Kein Home Board",
"admin": {
"description": "",
"link": "",
"notice": ""
"description": "Es wurde noch kein Home Board für den Server festgelegt.",
"link": "Serverweites Home Board konfigurieren",
"notice": "Um diese Seite für alle Benutzer verschwinden zu lassen, legen sie ein Home-Board für den Server fest"
},
"user": {
"description": "",
"link": "",
"notice": ""
"description": "Es wurde noch kein Home Board festgelegt.",
"link": "Home Board konfigurieren",
"notice": "Um diese Seite verschwinden zu lassen, geben Sie das Home Board in Ihren Einstellungen ein"
},
"anonymous": {
"description": "",
"link": "",
"notice": ""
"description": "Der Server-Administrator hat noch kein Home Board eingerichtet.",
"link": "Öffentliche Boards anzeigen",
"notice": "Um diese Seite verschwinden zu lassen, bitten sie den Server Administrator, ein Home Board für den Server festzulegen"
}
}
}
@@ -2071,12 +2072,12 @@
"management": {
"metaTitle": "Verwaltung",
"title": {
"morning": "",
"afternoon": "",
"evening": ""
"morning": "Guten Morgen, {username}",
"afternoon": "Guten Nachmittag, {username}",
"evening": "Guten Abend, {username}"
},
"notFound": {
"title": "",
"title": "Nicht gefunden",
"text": ""
},
"navbar": {
@@ -2101,6 +2102,7 @@
"docker": "",
"logs": "",
"api": "",
"certificates": "",
"tasks": ""
}
},
@@ -2153,6 +2155,13 @@
"tooltip": ""
}
},
"setMobileHomeBoard": {
"label": "",
"badge": {
"label": "",
"tooltip": ""
}
},
"duplicate": {
"label": ""
},
@@ -2189,7 +2198,13 @@
"title": "Allgemein",
"item": {
"language": "",
"board": "",
"board": {
"title": "",
"type": {
"general": "",
"mobile": ""
}
},
"defaultSearchEngine": "",
"firstDayOfWeek": "Erster Tag der Woche",
"accessibility": "Barrierefreiheit"
@@ -2349,6 +2364,7 @@
"title": "",
"homeBoard": {
"label": "",
"mobileLabel": "",
"description": ""
}
},
@@ -2692,6 +2708,9 @@
},
"logs": {
"label": ""
},
"certificates": {
"label": ""
}
},
"settings": {
@@ -2738,6 +2757,9 @@
"homeBoard": {
"label": ""
},
"mobileBoard": {
"label": ""
},
"settings": {
"label": ""
}
@@ -3084,5 +3106,46 @@
}
}
}
},
"certificate": {
"page": {
"list": {
"title": "",
"description": "",
"noResults": {
"title": ""
},
"expires": ""
}
},
"action": {
"create": {
"label": "",
"notification": {
"success": {
"title": "",
"message": ""
},
"error": {
"title": "",
"message": ""
}
}
},
"remove": {
"label": "",
"confirm": "",
"notification": {
"success": {
"title": "",
"message": ""
},
"error": {
"title": "",
"message": ""
}
}
}
}
}
}
+64 -1
View File
@@ -897,6 +897,7 @@
"passwordRequirements": "",
"boardAlreadyExists": "",
"invalidFileType": "",
"invalidFileName": "",
"fileTooLarge": "",
"invalidConfiguration": "",
"groupNameTaken": ""
@@ -2101,6 +2102,7 @@
"docker": "",
"logs": "",
"api": "",
"certificates": "",
"tasks": ""
}
},
@@ -2153,6 +2155,13 @@
"tooltip": ""
}
},
"setMobileHomeBoard": {
"label": "",
"badge": {
"label": "",
"tooltip": ""
}
},
"duplicate": {
"label": ""
},
@@ -2189,7 +2198,13 @@
"title": "Γενικά",
"item": {
"language": "",
"board": "",
"board": {
"title": "",
"type": {
"general": "",
"mobile": ""
}
},
"defaultSearchEngine": "",
"firstDayOfWeek": "Πρώτη ημέρα της εβδομάδας",
"accessibility": "Προσβασιμότητα"
@@ -2349,6 +2364,7 @@
"title": "Πίνακες",
"homeBoard": {
"label": "",
"mobileLabel": "",
"description": ""
}
},
@@ -2692,6 +2708,9 @@
},
"logs": {
"label": ""
},
"certificates": {
"label": ""
}
},
"settings": {
@@ -2738,6 +2757,9 @@
"homeBoard": {
"label": ""
},
"mobileBoard": {
"label": ""
},
"settings": {
"label": ""
}
@@ -3084,5 +3106,46 @@
}
}
}
},
"certificate": {
"page": {
"list": {
"title": "",
"description": "",
"noResults": {
"title": ""
},
"expires": ""
}
},
"action": {
"create": {
"label": "",
"notification": {
"success": {
"title": "",
"message": ""
},
"error": {
"title": "",
"message": ""
}
}
},
"remove": {
"label": "",
"confirm": "",
"notification": {
"success": {
"title": "",
"message": ""
},
"error": {
"title": "",
"message": ""
}
}
}
}
}
}
+106
View File
@@ -733,6 +733,14 @@
"password": {
"label": "Password",
"newLabel": "New password"
},
"tokenId": {
"label": "Token ID",
"newLabel": "New token ID"
},
"realm": {
"label": "Realm",
"newLabel": "New realm"
}
}
},
@@ -897,6 +905,7 @@
"passwordRequirements": "Password does not meet the requirements",
"boardAlreadyExists": "A board with this name already exists",
"invalidFileType": "Invalid file type, expected {expected}",
"invalidFileName": "Invalid file name",
"fileTooLarge": "File is too large, maximum size is {maxSize}",
"invalidConfiguration": "Invalid configuration",
"groupNameTaken": "Group name already taken"
@@ -1410,6 +1419,12 @@
},
"fileSystem": {
"label": "Show Filesystem Info"
},
"defaultTab": {
"label": "Default tab"
},
"sectionIndicatorRequirement": {
"label": "Section indicator requirement"
}
},
"popover": {
@@ -1429,6 +1444,52 @@
"memory": {},
"error": {
"internalServerError": "Failed to fetch health status"
},
"cluster": {
"summary": {
"cpu": "CPU",
"memory": "RAM"
},
"resource": {
"node": {
"name": "Nodes"
},
"qemu": {
"name": "VMs"
},
"lxc": {
"name": "LXCs"
},
"storage": {
"name": "Storage"
}
},
"popover": {
"rightSection": {
"node": "Node",
"vmId": "VM ID",
"plugin": "Plugin"
},
"detail": {
"cpu": "Cores",
"memory": "Memory",
"storage": "Storage",
"uptime": "Uptime",
"haState": "HA State",
"storageType": {
"local": "Local storage",
"shared": "Shared storage"
}
}
},
"table": {
"header": {
"name": "Name",
"cpu": "CPU",
"memory": "RAM",
"node": "Node"
}
}
}
},
"common": {
@@ -2101,6 +2162,7 @@
"docker": "Docker",
"logs": "Logs",
"api": "API",
"certificates": "Certificates",
"tasks": "Tasks"
}
},
@@ -2706,6 +2768,9 @@
},
"logs": {
"label": "Logs"
},
"certificates": {
"label": "Certificates"
}
},
"settings": {
@@ -3101,5 +3166,46 @@
}
}
}
},
"certificate": {
"page": {
"list": {
"title": "Trusted certificates",
"description": "Used by Homarr to request data from integrations.",
"noResults": {
"title": "There are no certificates yet"
},
"expires": "Expires {when}"
}
},
"action": {
"create": {
"label": "Add certificate",
"notification": {
"success": {
"title": "Certificate added",
"message": "The certificate was added successfully"
},
"error": {
"title": "Failed to add certificate",
"message": "The certificate could not be added"
}
}
},
"remove": {
"label": "Remove certificate",
"confirm": "Are you sure you want to remove the certificate?",
"notification": {
"success": {
"title": "Certificate removed",
"message": "The certificate was removed successfully"
},
"error": {
"title": "Certificate not removed",
"message": "The certificate could not be removed"
}
}
}
}
}
}
+64 -1
View File
@@ -897,6 +897,7 @@
"passwordRequirements": "",
"boardAlreadyExists": "",
"invalidFileType": "",
"invalidFileName": "",
"fileTooLarge": "",
"invalidConfiguration": "",
"groupNameTaken": ""
@@ -2101,6 +2102,7 @@
"docker": "",
"logs": "",
"api": "",
"certificates": "",
"tasks": ""
}
},
@@ -2153,6 +2155,13 @@
"tooltip": ""
}
},
"setMobileHomeBoard": {
"label": "",
"badge": {
"label": "",
"tooltip": ""
}
},
"duplicate": {
"label": ""
},
@@ -2189,7 +2198,13 @@
"title": "",
"item": {
"language": "",
"board": "",
"board": {
"title": "",
"type": {
"general": "",
"mobile": ""
}
},
"defaultSearchEngine": "",
"firstDayOfWeek": "Primer día de la semana",
"accessibility": "Accesibilidad"
@@ -2349,6 +2364,7 @@
"title": "Tableros",
"homeBoard": {
"label": "",
"mobileLabel": "",
"description": ""
}
},
@@ -2692,6 +2708,9 @@
},
"logs": {
"label": ""
},
"certificates": {
"label": ""
}
},
"settings": {
@@ -2738,6 +2757,9 @@
"homeBoard": {
"label": ""
},
"mobileBoard": {
"label": ""
},
"settings": {
"label": ""
}
@@ -3084,5 +3106,46 @@
}
}
}
},
"certificate": {
"page": {
"list": {
"title": "",
"description": "",
"noResults": {
"title": ""
},
"expires": ""
}
},
"action": {
"create": {
"label": "",
"notification": {
"success": {
"title": "",
"message": ""
},
"error": {
"title": "",
"message": ""
}
}
},
"remove": {
"label": "",
"confirm": "",
"notification": {
"success": {
"title": "",
"message": ""
},
"error": {
"title": "",
"message": ""
}
}
}
}
}
}
+64 -1
View File
@@ -897,6 +897,7 @@
"passwordRequirements": "",
"boardAlreadyExists": "",
"invalidFileType": "",
"invalidFileName": "",
"fileTooLarge": "",
"invalidConfiguration": "",
"groupNameTaken": ""
@@ -2101,6 +2102,7 @@
"docker": "",
"logs": "",
"api": "",
"certificates": "",
"tasks": ""
}
},
@@ -2153,6 +2155,13 @@
"tooltip": ""
}
},
"setMobileHomeBoard": {
"label": "",
"badge": {
"label": "",
"tooltip": ""
}
},
"duplicate": {
"label": ""
},
@@ -2189,7 +2198,13 @@
"title": "",
"item": {
"language": "",
"board": "",
"board": {
"title": "",
"type": {
"general": "",
"mobile": ""
}
},
"defaultSearchEngine": "",
"firstDayOfWeek": "",
"accessibility": ""
@@ -2349,6 +2364,7 @@
"title": "",
"homeBoard": {
"label": "",
"mobileLabel": "",
"description": ""
}
},
@@ -2692,6 +2708,9 @@
},
"logs": {
"label": ""
},
"certificates": {
"label": ""
}
},
"settings": {
@@ -2738,6 +2757,9 @@
"homeBoard": {
"label": ""
},
"mobileBoard": {
"label": ""
},
"settings": {
"label": ""
}
@@ -3084,5 +3106,46 @@
}
}
}
},
"certificate": {
"page": {
"list": {
"title": "",
"description": "",
"noResults": {
"title": ""
},
"expires": ""
}
},
"action": {
"create": {
"label": "",
"notification": {
"success": {
"title": "",
"message": ""
},
"error": {
"title": "",
"message": ""
}
}
},
"remove": {
"label": "",
"confirm": "",
"notification": {
"success": {
"title": "",
"message": ""
},
"error": {
"title": "",
"message": ""
}
}
}
}
}
}
+64 -1
View File
@@ -897,6 +897,7 @@
"passwordRequirements": "",
"boardAlreadyExists": "",
"invalidFileType": "",
"invalidFileName": "",
"fileTooLarge": "",
"invalidConfiguration": "",
"groupNameTaken": ""
@@ -2101,6 +2102,7 @@
"docker": "",
"logs": "",
"api": "",
"certificates": "",
"tasks": ""
}
},
@@ -2153,6 +2155,13 @@
"tooltip": ""
}
},
"setMobileHomeBoard": {
"label": "",
"badge": {
"label": "",
"tooltip": ""
}
},
"duplicate": {
"label": ""
},
@@ -2189,7 +2198,13 @@
"title": "Général",
"item": {
"language": "",
"board": "",
"board": {
"title": "",
"type": {
"general": "",
"mobile": ""
}
},
"defaultSearchEngine": "",
"firstDayOfWeek": "Premier jour de la semaine",
"accessibility": "Accessibilité"
@@ -2349,6 +2364,7 @@
"title": "Tableaux de bord",
"homeBoard": {
"label": "",
"mobileLabel": "",
"description": ""
}
},
@@ -2692,6 +2708,9 @@
},
"logs": {
"label": ""
},
"certificates": {
"label": ""
}
},
"settings": {
@@ -2738,6 +2757,9 @@
"homeBoard": {
"label": ""
},
"mobileBoard": {
"label": ""
},
"settings": {
"label": ""
}
@@ -3084,5 +3106,46 @@
}
}
}
},
"certificate": {
"page": {
"list": {
"title": "",
"description": "",
"noResults": {
"title": ""
},
"expires": ""
}
},
"action": {
"create": {
"label": "",
"notification": {
"success": {
"title": "",
"message": ""
},
"error": {
"title": "",
"message": ""
}
}
},
"remove": {
"label": "",
"confirm": "",
"notification": {
"success": {
"title": "",
"message": ""
},
"error": {
"title": "",
"message": ""
}
}
}
}
}
}
+64 -1
View File
@@ -897,6 +897,7 @@
"passwordRequirements": "הסיסמה אינה עומדת בדרישות",
"boardAlreadyExists": "לוח בשם זה כבר קיים",
"invalidFileType": "סוג קובץ לא חוקי, צפוי {expected}",
"invalidFileName": "",
"fileTooLarge": "הקובץ גדול מדי, הגודל המרבי הוא {maxSize}",
"invalidConfiguration": "תצורה לא חוקית",
"groupNameTaken": "שם הקבוצה כבר תפוס"
@@ -2101,6 +2102,7 @@
"docker": "דוקר",
"logs": "קובצי לוג",
"api": "ממשק API",
"certificates": "",
"tasks": "משימות"
}
},
@@ -2153,6 +2155,13 @@
"tooltip": "הלוח הזה יוצג כלוח הבית שלך"
}
},
"setMobileHomeBoard": {
"label": "",
"badge": {
"label": "",
"tooltip": ""
}
},
"duplicate": {
"label": "שכפול לוח"
},
@@ -2189,7 +2198,13 @@
"title": "כללי",
"item": {
"language": "שפה ואיזור",
"board": "לוח בית",
"board": {
"title": "",
"type": {
"general": "",
"mobile": ""
}
},
"defaultSearchEngine": "מנוע חיפוש ברירת מחדל",
"firstDayOfWeek": "היום הראשון בשבוע",
"accessibility": "נגישות"
@@ -2349,6 +2364,7 @@
"title": "לוחות",
"homeBoard": {
"label": "לוח בית גלובלי",
"mobileLabel": "",
"description": "רק לוחות ציבוריים זמינים לבחירה"
}
},
@@ -2692,6 +2708,9 @@
},
"logs": {
"label": "קבצי לוג"
},
"certificates": {
"label": ""
}
},
"settings": {
@@ -2738,6 +2757,9 @@
"homeBoard": {
"label": "קביעה כלוח הבית"
},
"mobileBoard": {
"label": ""
},
"settings": {
"label": "פתיחת הגדרות"
}
@@ -3084,5 +3106,46 @@
}
}
}
},
"certificate": {
"page": {
"list": {
"title": "",
"description": "",
"noResults": {
"title": ""
},
"expires": ""
}
},
"action": {
"create": {
"label": "",
"notification": {
"success": {
"title": "",
"message": ""
},
"error": {
"title": "",
"message": ""
}
}
},
"remove": {
"label": "",
"confirm": "",
"notification": {
"success": {
"title": "",
"message": ""
},
"error": {
"title": "",
"message": ""
}
}
}
}
}
}
+64 -1
View File
@@ -897,6 +897,7 @@
"passwordRequirements": "",
"boardAlreadyExists": "",
"invalidFileType": "",
"invalidFileName": "",
"fileTooLarge": "",
"invalidConfiguration": "",
"groupNameTaken": ""
@@ -2101,6 +2102,7 @@
"docker": "",
"logs": "",
"api": "",
"certificates": "",
"tasks": ""
}
},
@@ -2153,6 +2155,13 @@
"tooltip": ""
}
},
"setMobileHomeBoard": {
"label": "",
"badge": {
"label": "",
"tooltip": ""
}
},
"duplicate": {
"label": ""
},
@@ -2189,7 +2198,13 @@
"title": "Općenito",
"item": {
"language": "",
"board": "",
"board": {
"title": "",
"type": {
"general": "",
"mobile": ""
}
},
"defaultSearchEngine": "",
"firstDayOfWeek": "Prvi dan u tjednu",
"accessibility": "Pristupačnost"
@@ -2349,6 +2364,7 @@
"title": "Daske",
"homeBoard": {
"label": "",
"mobileLabel": "",
"description": ""
}
},
@@ -2692,6 +2708,9 @@
},
"logs": {
"label": ""
},
"certificates": {
"label": ""
}
},
"settings": {
@@ -2738,6 +2757,9 @@
"homeBoard": {
"label": ""
},
"mobileBoard": {
"label": ""
},
"settings": {
"label": ""
}
@@ -3084,5 +3106,46 @@
}
}
}
},
"certificate": {
"page": {
"list": {
"title": "",
"description": "",
"noResults": {
"title": ""
},
"expires": ""
}
},
"action": {
"create": {
"label": "",
"notification": {
"success": {
"title": "",
"message": ""
},
"error": {
"title": "",
"message": ""
}
}
},
"remove": {
"label": "",
"confirm": "",
"notification": {
"success": {
"title": "",
"message": ""
},
"error": {
"title": "",
"message": ""
}
}
}
}
}
}
+64 -1
View File
@@ -897,6 +897,7 @@
"passwordRequirements": "",
"boardAlreadyExists": "",
"invalidFileType": "",
"invalidFileName": "",
"fileTooLarge": "",
"invalidConfiguration": "",
"groupNameTaken": ""
@@ -2101,6 +2102,7 @@
"docker": "",
"logs": "",
"api": "",
"certificates": "",
"tasks": ""
}
},
@@ -2153,6 +2155,13 @@
"tooltip": ""
}
},
"setMobileHomeBoard": {
"label": "",
"badge": {
"label": "",
"tooltip": ""
}
},
"duplicate": {
"label": ""
},
@@ -2189,7 +2198,13 @@
"title": "Általános",
"item": {
"language": "",
"board": "Főoldal",
"board": {
"title": "",
"type": {
"general": "",
"mobile": ""
}
},
"defaultSearchEngine": "",
"firstDayOfWeek": "A hét első napja",
"accessibility": "Kisegítő lehetőségek"
@@ -2349,6 +2364,7 @@
"title": "Táblák",
"homeBoard": {
"label": "",
"mobileLabel": "",
"description": ""
}
},
@@ -2692,6 +2708,9 @@
},
"logs": {
"label": ""
},
"certificates": {
"label": ""
}
},
"settings": {
@@ -2738,6 +2757,9 @@
"homeBoard": {
"label": ""
},
"mobileBoard": {
"label": ""
},
"settings": {
"label": ""
}
@@ -3084,5 +3106,46 @@
}
}
}
},
"certificate": {
"page": {
"list": {
"title": "",
"description": "",
"noResults": {
"title": ""
},
"expires": ""
}
},
"action": {
"create": {
"label": "",
"notification": {
"success": {
"title": "",
"message": ""
},
"error": {
"title": "",
"message": ""
}
}
},
"remove": {
"label": "",
"confirm": "",
"notification": {
"success": {
"title": "",
"message": ""
},
"error": {
"title": "",
"message": ""
}
}
}
}
}
}
+64 -1
View File
@@ -897,6 +897,7 @@
"passwordRequirements": "",
"boardAlreadyExists": "",
"invalidFileType": "",
"invalidFileName": "",
"fileTooLarge": "",
"invalidConfiguration": "",
"groupNameTaken": ""
@@ -2101,6 +2102,7 @@
"docker": "",
"logs": "",
"api": "",
"certificates": "",
"tasks": ""
}
},
@@ -2153,6 +2155,13 @@
"tooltip": ""
}
},
"setMobileHomeBoard": {
"label": "",
"badge": {
"label": "",
"tooltip": ""
}
},
"duplicate": {
"label": ""
},
@@ -2189,7 +2198,13 @@
"title": "Generale",
"item": {
"language": "",
"board": "",
"board": {
"title": "",
"type": {
"general": "",
"mobile": ""
}
},
"defaultSearchEngine": "",
"firstDayOfWeek": "Primo giorno della settimana",
"accessibility": "Accessibilità"
@@ -2349,6 +2364,7 @@
"title": "",
"homeBoard": {
"label": "",
"mobileLabel": "",
"description": ""
}
},
@@ -2692,6 +2708,9 @@
},
"logs": {
"label": ""
},
"certificates": {
"label": ""
}
},
"settings": {
@@ -2738,6 +2757,9 @@
"homeBoard": {
"label": ""
},
"mobileBoard": {
"label": ""
},
"settings": {
"label": ""
}
@@ -3084,5 +3106,46 @@
}
}
}
},
"certificate": {
"page": {
"list": {
"title": "",
"description": "",
"noResults": {
"title": ""
},
"expires": ""
}
},
"action": {
"create": {
"label": "",
"notification": {
"success": {
"title": "",
"message": ""
},
"error": {
"title": "",
"message": ""
}
}
},
"remove": {
"label": "",
"confirm": "",
"notification": {
"success": {
"title": "",
"message": ""
},
"error": {
"title": "",
"message": ""
}
}
}
}
}
}
+64 -1
View File
@@ -897,6 +897,7 @@
"passwordRequirements": "",
"boardAlreadyExists": "",
"invalidFileType": "",
"invalidFileName": "",
"fileTooLarge": "",
"invalidConfiguration": "",
"groupNameTaken": ""
@@ -2101,6 +2102,7 @@
"docker": "",
"logs": "",
"api": "",
"certificates": "",
"tasks": ""
}
},
@@ -2153,6 +2155,13 @@
"tooltip": ""
}
},
"setMobileHomeBoard": {
"label": "",
"badge": {
"label": "",
"tooltip": ""
}
},
"duplicate": {
"label": ""
},
@@ -2189,7 +2198,13 @@
"title": "一般",
"item": {
"language": "",
"board": "",
"board": {
"title": "",
"type": {
"general": "",
"mobile": ""
}
},
"defaultSearchEngine": "",
"firstDayOfWeek": "週の初日",
"accessibility": "アクセシビリティ"
@@ -2349,6 +2364,7 @@
"title": "ボード",
"homeBoard": {
"label": "",
"mobileLabel": "",
"description": ""
}
},
@@ -2692,6 +2708,9 @@
},
"logs": {
"label": ""
},
"certificates": {
"label": ""
}
},
"settings": {
@@ -2738,6 +2757,9 @@
"homeBoard": {
"label": ""
},
"mobileBoard": {
"label": ""
},
"settings": {
"label": ""
}
@@ -3084,5 +3106,46 @@
}
}
}
},
"certificate": {
"page": {
"list": {
"title": "",
"description": "",
"noResults": {
"title": ""
},
"expires": ""
}
},
"action": {
"create": {
"label": "",
"notification": {
"success": {
"title": "",
"message": ""
},
"error": {
"title": "",
"message": ""
}
}
},
"remove": {
"label": "",
"confirm": "",
"notification": {
"success": {
"title": "",
"message": ""
},
"error": {
"title": "",
"message": ""
}
}
}
}
}
}
+64 -1
View File
@@ -897,6 +897,7 @@
"passwordRequirements": "",
"boardAlreadyExists": "",
"invalidFileType": "",
"invalidFileName": "",
"fileTooLarge": "",
"invalidConfiguration": "",
"groupNameTaken": ""
@@ -2101,6 +2102,7 @@
"docker": "",
"logs": "",
"api": "",
"certificates": "",
"tasks": ""
}
},
@@ -2153,6 +2155,13 @@
"tooltip": ""
}
},
"setMobileHomeBoard": {
"label": "",
"badge": {
"label": "",
"tooltip": ""
}
},
"duplicate": {
"label": ""
},
@@ -2189,7 +2198,13 @@
"title": "일반",
"item": {
"language": "",
"board": "",
"board": {
"title": "",
"type": {
"general": "",
"mobile": ""
}
},
"defaultSearchEngine": "",
"firstDayOfWeek": "요일별 요일",
"accessibility": "접근성"
@@ -2349,6 +2364,7 @@
"title": "보드",
"homeBoard": {
"label": "",
"mobileLabel": "",
"description": ""
}
},
@@ -2692,6 +2708,9 @@
},
"logs": {
"label": ""
},
"certificates": {
"label": ""
}
},
"settings": {
@@ -2738,6 +2757,9 @@
"homeBoard": {
"label": ""
},
"mobileBoard": {
"label": ""
},
"settings": {
"label": ""
}
@@ -3084,5 +3106,46 @@
}
}
}
},
"certificate": {
"page": {
"list": {
"title": "",
"description": "",
"noResults": {
"title": ""
},
"expires": ""
}
},
"action": {
"create": {
"label": "",
"notification": {
"success": {
"title": "",
"message": ""
},
"error": {
"title": "",
"message": ""
}
}
},
"remove": {
"label": "",
"confirm": "",
"notification": {
"success": {
"title": "",
"message": ""
},
"error": {
"title": "",
"message": ""
}
}
}
}
}
}
+64 -1
View File
@@ -897,6 +897,7 @@
"passwordRequirements": "",
"boardAlreadyExists": "",
"invalidFileType": "",
"invalidFileName": "",
"fileTooLarge": "",
"invalidConfiguration": "",
"groupNameTaken": ""
@@ -2101,6 +2102,7 @@
"docker": "",
"logs": "",
"api": "",
"certificates": "",
"tasks": ""
}
},
@@ -2153,6 +2155,13 @@
"tooltip": ""
}
},
"setMobileHomeBoard": {
"label": "",
"badge": {
"label": "",
"tooltip": ""
}
},
"duplicate": {
"label": ""
},
@@ -2189,7 +2198,13 @@
"title": "Bendras",
"item": {
"language": "",
"board": "",
"board": {
"title": "",
"type": {
"general": "",
"mobile": ""
}
},
"defaultSearchEngine": "",
"firstDayOfWeek": "",
"accessibility": ""
@@ -2349,6 +2364,7 @@
"title": "Lentos",
"homeBoard": {
"label": "",
"mobileLabel": "",
"description": ""
}
},
@@ -2692,6 +2708,9 @@
},
"logs": {
"label": ""
},
"certificates": {
"label": ""
}
},
"settings": {
@@ -2738,6 +2757,9 @@
"homeBoard": {
"label": ""
},
"mobileBoard": {
"label": ""
},
"settings": {
"label": ""
}
@@ -3084,5 +3106,46 @@
}
}
}
},
"certificate": {
"page": {
"list": {
"title": "",
"description": "",
"noResults": {
"title": ""
},
"expires": ""
}
},
"action": {
"create": {
"label": "",
"notification": {
"success": {
"title": "",
"message": ""
},
"error": {
"title": "",
"message": ""
}
}
},
"remove": {
"label": "",
"confirm": "",
"notification": {
"success": {
"title": "",
"message": ""
},
"error": {
"title": "",
"message": ""
}
}
}
}
}
}
+64 -1
View File
@@ -897,6 +897,7 @@
"passwordRequirements": "",
"boardAlreadyExists": "",
"invalidFileType": "",
"invalidFileName": "",
"fileTooLarge": "",
"invalidConfiguration": "",
"groupNameTaken": ""
@@ -2101,6 +2102,7 @@
"docker": "",
"logs": "",
"api": "",
"certificates": "",
"tasks": ""
}
},
@@ -2153,6 +2155,13 @@
"tooltip": ""
}
},
"setMobileHomeBoard": {
"label": "",
"badge": {
"label": "",
"tooltip": ""
}
},
"duplicate": {
"label": ""
},
@@ -2189,7 +2198,13 @@
"title": "Vispārīgi",
"item": {
"language": "",
"board": "",
"board": {
"title": "",
"type": {
"general": "",
"mobile": ""
}
},
"defaultSearchEngine": "",
"firstDayOfWeek": "Nedēļas pirmā diena",
"accessibility": "Piekļūstamība"
@@ -2349,6 +2364,7 @@
"title": "Dēļi",
"homeBoard": {
"label": "",
"mobileLabel": "",
"description": ""
}
},
@@ -2692,6 +2708,9 @@
},
"logs": {
"label": ""
},
"certificates": {
"label": ""
}
},
"settings": {
@@ -2738,6 +2757,9 @@
"homeBoard": {
"label": ""
},
"mobileBoard": {
"label": ""
},
"settings": {
"label": ""
}
@@ -3084,5 +3106,46 @@
}
}
}
},
"certificate": {
"page": {
"list": {
"title": "",
"description": "",
"noResults": {
"title": ""
},
"expires": ""
}
},
"action": {
"create": {
"label": "",
"notification": {
"success": {
"title": "",
"message": ""
},
"error": {
"title": "",
"message": ""
}
}
},
"remove": {
"label": "",
"confirm": "",
"notification": {
"success": {
"title": "",
"message": ""
},
"error": {
"title": "",
"message": ""
}
}
}
}
}
}
+64 -1
View File
@@ -897,6 +897,7 @@
"passwordRequirements": "Wachtwoord voldoet niet aan de eisen",
"boardAlreadyExists": "Er bestaat al een bord met deze naam",
"invalidFileType": "Ongeldig bestandstype, verwachtte {expected}",
"invalidFileName": "",
"fileTooLarge": "Bestand is te groot, maximale grootte is {maxSize}",
"invalidConfiguration": "Ongeldige configuratie",
"groupNameTaken": "Groepsnaam al in gebruik"
@@ -2101,6 +2102,7 @@
"docker": "Docker",
"logs": "Logs",
"api": "API",
"certificates": "",
"tasks": "Taken"
}
},
@@ -2153,6 +2155,13 @@
"tooltip": "Dit bord wordt weergegeven als je home-bord"
}
},
"setMobileHomeBoard": {
"label": "",
"badge": {
"label": "",
"tooltip": ""
}
},
"duplicate": {
"label": ""
},
@@ -2189,7 +2198,13 @@
"title": "Algemeen",
"item": {
"language": "Taal & regio",
"board": "Home-bord",
"board": {
"title": "",
"type": {
"general": "",
"mobile": ""
}
},
"defaultSearchEngine": "",
"firstDayOfWeek": "Eerste dag van de week",
"accessibility": "Toegankelijkheid"
@@ -2349,6 +2364,7 @@
"title": "Borden",
"homeBoard": {
"label": "Globaal home-bord",
"mobileLabel": "",
"description": "Alleen openbare borden zijn beschikbaar voor selectie"
}
},
@@ -2692,6 +2708,9 @@
},
"logs": {
"label": "Logs"
},
"certificates": {
"label": ""
}
},
"settings": {
@@ -2738,6 +2757,9 @@
"homeBoard": {
"label": "Instellen als home-bord"
},
"mobileBoard": {
"label": ""
},
"settings": {
"label": "Instellingen openen"
}
@@ -3084,5 +3106,46 @@
}
}
}
},
"certificate": {
"page": {
"list": {
"title": "",
"description": "",
"noResults": {
"title": ""
},
"expires": ""
}
},
"action": {
"create": {
"label": "",
"notification": {
"success": {
"title": "",
"message": ""
},
"error": {
"title": "",
"message": ""
}
}
},
"remove": {
"label": "",
"confirm": "",
"notification": {
"success": {
"title": "",
"message": ""
},
"error": {
"title": "",
"message": ""
}
}
}
}
}
}
+64 -1
View File
@@ -897,6 +897,7 @@
"passwordRequirements": "",
"boardAlreadyExists": "",
"invalidFileType": "",
"invalidFileName": "",
"fileTooLarge": "",
"invalidConfiguration": "",
"groupNameTaken": ""
@@ -2101,6 +2102,7 @@
"docker": "",
"logs": "",
"api": "",
"certificates": "",
"tasks": ""
}
},
@@ -2153,6 +2155,13 @@
"tooltip": ""
}
},
"setMobileHomeBoard": {
"label": "",
"badge": {
"label": "",
"tooltip": ""
}
},
"duplicate": {
"label": ""
},
@@ -2189,7 +2198,13 @@
"title": "Generelt",
"item": {
"language": "",
"board": "",
"board": {
"title": "",
"type": {
"general": "",
"mobile": ""
}
},
"defaultSearchEngine": "",
"firstDayOfWeek": "Første dag i uken",
"accessibility": "Hjelpemidler"
@@ -2349,6 +2364,7 @@
"title": "Tavler",
"homeBoard": {
"label": "",
"mobileLabel": "",
"description": ""
}
},
@@ -2692,6 +2708,9 @@
},
"logs": {
"label": ""
},
"certificates": {
"label": ""
}
},
"settings": {
@@ -2738,6 +2757,9 @@
"homeBoard": {
"label": ""
},
"mobileBoard": {
"label": ""
},
"settings": {
"label": ""
}
@@ -3084,5 +3106,46 @@
}
}
}
},
"certificate": {
"page": {
"list": {
"title": "",
"description": "",
"noResults": {
"title": ""
},
"expires": ""
}
},
"action": {
"create": {
"label": "",
"notification": {
"success": {
"title": "",
"message": ""
},
"error": {
"title": "",
"message": ""
}
}
},
"remove": {
"label": "",
"confirm": "",
"notification": {
"success": {
"title": "",
"message": ""
},
"error": {
"title": "",
"message": ""
}
}
}
}
}
}
+64 -1
View File
@@ -897,6 +897,7 @@
"passwordRequirements": "Twoje hasło nie spełnia wymagań.",
"boardAlreadyExists": "Tablica o tej nazwie już istnieje",
"invalidFileType": "Nieprawidłowy typ pliku, oczekiwany {expected}",
"invalidFileName": "",
"fileTooLarge": "Plik jest zbyt duży, maksymalny rozmiar to {maxSize}",
"invalidConfiguration": "Nieprawidłowa konfiguracja",
"groupNameTaken": "Nazwa użytkownika jest już zajęta"
@@ -2101,6 +2102,7 @@
"docker": "",
"logs": "",
"api": "",
"certificates": "",
"tasks": ""
}
},
@@ -2153,6 +2155,13 @@
"tooltip": ""
}
},
"setMobileHomeBoard": {
"label": "",
"badge": {
"label": "",
"tooltip": ""
}
},
"duplicate": {
"label": ""
},
@@ -2189,7 +2198,13 @@
"title": "Ogólne",
"item": {
"language": "",
"board": "",
"board": {
"title": "",
"type": {
"general": "",
"mobile": ""
}
},
"defaultSearchEngine": "",
"firstDayOfWeek": "Pierwszy dzień tygodnia",
"accessibility": "Ułatwienia dostępu"
@@ -2349,6 +2364,7 @@
"title": "Tablice",
"homeBoard": {
"label": "",
"mobileLabel": "",
"description": ""
}
},
@@ -2692,6 +2708,9 @@
},
"logs": {
"label": ""
},
"certificates": {
"label": ""
}
},
"settings": {
@@ -2738,6 +2757,9 @@
"homeBoard": {
"label": ""
},
"mobileBoard": {
"label": ""
},
"settings": {
"label": ""
}
@@ -3084,5 +3106,46 @@
}
}
}
},
"certificate": {
"page": {
"list": {
"title": "",
"description": "",
"noResults": {
"title": ""
},
"expires": ""
}
},
"action": {
"create": {
"label": "",
"notification": {
"success": {
"title": "",
"message": ""
},
"error": {
"title": "",
"message": ""
}
}
},
"remove": {
"label": "",
"confirm": "",
"notification": {
"success": {
"title": "",
"message": ""
},
"error": {
"title": "",
"message": ""
}
}
}
}
}
}
+64 -1
View File
@@ -897,6 +897,7 @@
"passwordRequirements": "",
"boardAlreadyExists": "",
"invalidFileType": "",
"invalidFileName": "",
"fileTooLarge": "",
"invalidConfiguration": "",
"groupNameTaken": ""
@@ -2101,6 +2102,7 @@
"docker": "",
"logs": "",
"api": "",
"certificates": "",
"tasks": ""
}
},
@@ -2153,6 +2155,13 @@
"tooltip": ""
}
},
"setMobileHomeBoard": {
"label": "",
"badge": {
"label": "",
"tooltip": ""
}
},
"duplicate": {
"label": ""
},
@@ -2189,7 +2198,13 @@
"title": "Geral",
"item": {
"language": "",
"board": "",
"board": {
"title": "",
"type": {
"general": "",
"mobile": ""
}
},
"defaultSearchEngine": "",
"firstDayOfWeek": "Primeiro dia da semana",
"accessibility": "Acessibilidade"
@@ -2349,6 +2364,7 @@
"title": "Placas",
"homeBoard": {
"label": "",
"mobileLabel": "",
"description": ""
}
},
@@ -2692,6 +2708,9 @@
},
"logs": {
"label": ""
},
"certificates": {
"label": ""
}
},
"settings": {
@@ -2738,6 +2757,9 @@
"homeBoard": {
"label": ""
},
"mobileBoard": {
"label": ""
},
"settings": {
"label": ""
}
@@ -3084,5 +3106,46 @@
}
}
}
},
"certificate": {
"page": {
"list": {
"title": "",
"description": "",
"noResults": {
"title": ""
},
"expires": ""
}
},
"action": {
"create": {
"label": "",
"notification": {
"success": {
"title": "",
"message": ""
},
"error": {
"title": "",
"message": ""
}
}
},
"remove": {
"label": "",
"confirm": "",
"notification": {
"success": {
"title": "",
"message": ""
},
"error": {
"title": "",
"message": ""
}
}
}
}
}
}
+64 -1
View File
@@ -897,6 +897,7 @@
"passwordRequirements": "",
"boardAlreadyExists": "",
"invalidFileType": "",
"invalidFileName": "",
"fileTooLarge": "",
"invalidConfiguration": "",
"groupNameTaken": ""
@@ -2101,6 +2102,7 @@
"docker": "",
"logs": "",
"api": "",
"certificates": "",
"tasks": ""
}
},
@@ -2153,6 +2155,13 @@
"tooltip": ""
}
},
"setMobileHomeBoard": {
"label": "",
"badge": {
"label": "",
"tooltip": ""
}
},
"duplicate": {
"label": ""
},
@@ -2189,7 +2198,13 @@
"title": "",
"item": {
"language": "",
"board": "",
"board": {
"title": "",
"type": {
"general": "",
"mobile": ""
}
},
"defaultSearchEngine": "",
"firstDayOfWeek": "Prima zi a săptămâni",
"accessibility": "Accesibilitate"
@@ -2349,6 +2364,7 @@
"title": "Planșe",
"homeBoard": {
"label": "",
"mobileLabel": "",
"description": ""
}
},
@@ -2692,6 +2708,9 @@
},
"logs": {
"label": ""
},
"certificates": {
"label": ""
}
},
"settings": {
@@ -2738,6 +2757,9 @@
"homeBoard": {
"label": ""
},
"mobileBoard": {
"label": ""
},
"settings": {
"label": ""
}
@@ -3084,5 +3106,46 @@
}
}
}
},
"certificate": {
"page": {
"list": {
"title": "",
"description": "",
"noResults": {
"title": ""
},
"expires": ""
}
},
"action": {
"create": {
"label": "",
"notification": {
"success": {
"title": "",
"message": ""
},
"error": {
"title": "",
"message": ""
}
}
},
"remove": {
"label": "",
"confirm": "",
"notification": {
"success": {
"title": "",
"message": ""
},
"error": {
"title": "",
"message": ""
}
}
}
}
}
}
Loaded 100 of 124 files, more files were not shown because too many files have changed in this diff. Show more