Compare commits
62
Commits
v1.0.0-beta.13
...
v1.0.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
81a8cf4b36 | ||
|
|
b5c008b4ad | ||
|
|
343438391b | ||
|
|
8400b9fb11 | ||
|
|
fe65e73fff | ||
|
|
e023edf9d6 | ||
|
|
927913cf4e | ||
|
|
07d8103051 | ||
|
|
fe08ccf589 | ||
|
|
c55207bd59 | ||
|
|
e35811c921 | ||
|
|
e481e116ff | ||
|
|
32be09a4a7 | ||
|
|
d0a20dbfc5 | ||
|
|
68329965f7 | ||
|
|
f9fea70d39 | ||
|
|
3ed46aecbd | ||
|
|
a31c6a97e0 | ||
|
|
d1be727eb3 | ||
|
|
a1084f91e5 | ||
|
|
7622f0a4d2 | ||
|
|
34cacb3153 | ||
|
|
48cf5d5304 | ||
|
|
8c36c3e36b | ||
|
|
b10b2013af | ||
|
|
ea8acc83ea | ||
|
|
cb96508634 | ||
|
|
5e83847772 | ||
|
|
1557537763 | ||
|
|
5f993d2630 | ||
|
|
44767c46c2 | ||
|
|
aaddf0d987 | ||
|
|
7f8e6e4ea1 | ||
|
|
34a76c98e8 | ||
|
|
0f82a6f63d | ||
|
|
fac522adb9 | ||
|
|
9945425eff | ||
|
|
8cd5833281 | ||
|
|
ef7174aa36 | ||
|
|
4ce76f8a73 | ||
|
|
3e65af9ebe | ||
|
|
c6e5bec348 | ||
|
|
3e74c065f5 | ||
|
|
14ae732d0c | ||
|
|
b97883f44d | ||
|
|
741753fa74 | ||
|
|
1f0f8912b6 | ||
|
|
49d6932218 | ||
|
|
f13fefa64d | ||
|
|
cef567efdf | ||
|
|
db2b79ee7f | ||
|
|
b46d30b04c | ||
|
|
de4283b5fa | ||
|
|
a08d3e387f | ||
|
|
bd1ff01109 | ||
|
|
b24b1cc8a9 | ||
|
|
e81edc898f | ||
|
|
4d864878e3 | ||
|
|
5776ead3e3 | ||
|
|
baf32c3fe6 | ||
|
|
ea408a0fdd | ||
|
|
17f93097f6 |
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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",
|
||||
|
||||
+3
-1
@@ -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,
|
||||
},
|
||||
],
|
||||
|
||||
+81
@@ -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");
|
||||
},
|
||||
});
|
||||
+54
@@ -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";
|
||||
};
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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/>
|
||||
|
||||

|
||||
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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();
|
||||
};
|
||||
});
|
||||
}),
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import baseConfig from "@homarr/eslint-config/base";
|
||||
|
||||
/** @type {import('typescript-eslint').Config} */
|
||||
export default [
|
||||
{
|
||||
ignores: [],
|
||||
},
|
||||
...baseConfig,
|
||||
];
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "@homarr/tsconfig/base.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
|
||||
},
|
||||
"include": ["*.ts", "src"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
@@ -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,3 +1,4 @@
|
||||
export * from "./security";
|
||||
export * from "./encryption";
|
||||
export * from "./user-agent";
|
||||
export * from "./fetch-agent";
|
||||
+1
-1
@@ -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 {
|
||||
@@ -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: () => ({}),
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -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
|
||||
|
||||
@@ -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";
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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,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}`) =>
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+17
-10
@@ -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[];
|
||||
}
|
||||
@@ -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,3 +1,4 @@
|
||||
import { Response } from "undici";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { IntegrationTestConnectionError } from "../src";
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
@@ -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"
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in new issue
Block a user