Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0ca225d790 | ||
|
|
cd391c6b08 | ||
|
|
7281b4ae32 | ||
|
|
4cab12b80a | ||
|
|
172db0e3f9 | ||
|
|
6f0b5d7e04 | ||
|
|
6d5153e5dc | ||
|
|
ee8d49c7e4 | ||
|
|
c9637021f0 | ||
|
|
bd983701b7 | ||
|
|
e7598ee0e1 | ||
|
|
26a11e5d62 | ||
|
|
0df6e8fb20 | ||
|
|
c2453b6c96 | ||
|
|
fb4b8f7ca3 | ||
|
|
592412d240 | ||
|
|
eb717da792 | ||
|
|
ed5127de64 | ||
|
|
e1b4186aa2 | ||
|
|
63c5c9ca98 | ||
|
|
b0e44943a3 | ||
|
|
448272091b | ||
|
|
ceec6d2789 | ||
|
|
e216ae3a70 | ||
|
|
ce26cdf2b2 | ||
|
|
d66be5ff82 | ||
|
|
d6e463f6e4 | ||
|
|
f643a185f6 | ||
|
|
9aa2d5ccd8 | ||
|
|
f78eaa533e | ||
|
|
857b46bc33 | ||
|
|
3f87bbd42b | ||
|
|
9c1059afe6 | ||
|
|
4e1f7c5785 | ||
|
|
6e37416e76 | ||
|
|
27de0ce2a2 | ||
|
|
90784116e8 | ||
|
|
d8a2ab74d2 | ||
|
|
4dabc98bd4 | ||
|
|
84909b2535 | ||
|
|
f2540c7d8e | ||
|
|
0e63ffe611 | ||
|
|
0b120455fd | ||
|
|
fde68f8bcc | ||
|
|
38e368fdbc | ||
|
|
94533c667c | ||
|
|
cd25bdfc0e | ||
|
|
3a7c8d5e19 | ||
|
|
1afbdd0511 | ||
|
|
1fa60cbea6 | ||
|
|
3cbb664bc0 | ||
|
|
a80c5d40e0 | ||
|
|
cccec5e195 | ||
|
|
afca8007f1 | ||
|
|
5c86930220 | ||
|
|
35a8ac820f | ||
|
|
cd5d9c66ca | ||
|
|
60370a7e33 | ||
|
|
65ccfdfb13 | ||
|
|
f792b2a553 | ||
|
|
5d402f88d2 | ||
|
|
7a7b3a32f8 | ||
|
|
d80f070ba3 | ||
|
|
d7182b9cf5 | ||
|
|
6a199561b6 | ||
|
|
7e5f4e59a9 | ||
|
|
1b7e67dd97 | ||
|
|
0aede0dec3 | ||
|
|
e8877169f8 | ||
|
|
4ffbea3b52 | ||
|
|
fab57e6fbd | ||
|
|
c2b2b098a1 | ||
|
|
4f74395000 | ||
|
|
073ede3aad |
No files matched your search
@@ -33,6 +33,9 @@ body:
|
||||
options:
|
||||
# The below comment is used to insert a new version with on-release.yml
|
||||
#NEXT_VERSION#
|
||||
- 1.42.1
|
||||
- 1.42.0
|
||||
- 1.41.0
|
||||
- 1.40.0
|
||||
- 1.39.0
|
||||
- 1.38.0
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||
extends: ["config:recommended"],
|
||||
packageRules: [
|
||||
{
|
||||
matchPackagePatterns: ["^@homarr/"],
|
||||
matchPackageNames: ["^@homarr/"],
|
||||
enabled: false,
|
||||
},
|
||||
// Reenable once https://github.com/privatenumber/tsx/issues/737 is fixed
|
||||
{
|
||||
matchPackagePatterns: ["tsx"],
|
||||
matchPackageNames: ["tsx"],
|
||||
enabled: false,
|
||||
},
|
||||
{
|
||||
@@ -21,4 +21,6 @@
|
||||
automerge: false,
|
||||
baseBranches: ["dev"],
|
||||
dependencyDashboard: false,
|
||||
// prevent special characters in branch names (otherwise git errors occur on windows)
|
||||
branchNameStrict: true,
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
name: "[Renovate] Validate configuration"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: ["*"]
|
||||
paths: [".github/renovate.json5"]
|
||||
|
||||
jobs:
|
||||
renovate-validate:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- run: |
|
||||
npx --yes --package renovate -- \
|
||||
renovate-config-validator --strict .github/renovate.json5
|
||||
@@ -1,3 +1,56 @@
|
||||
## [1.43.0](https://github.com/homarr-labs/homarr/compare/v1.42.1...v1.43.0) (2025-10-24)
|
||||
|
||||
### Features
|
||||
|
||||
* **integrations:** add app linking ([#4338](https://github.com/homarr-labs/homarr/issues/4338)) ([172db0e](https://github.com/homarr-labs/homarr/commit/172db0e3f905839b01e934a121c5bc5e45f3314a))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **deps:** update dependency @tanstack/react-query to ^5.90.5 ([#4316](https://github.com/homarr-labs/homarr/issues/4316)) ([4cab12b](https://github.com/homarr-labs/homarr/commit/4cab12b80a4a37a20393ed8a3727997e157405d3))
|
||||
* **deps:** update dependency node-ical to ^0.22.0 ([#4344](https://github.com/homarr-labs/homarr/issues/4344)) ([7281b4a](https://github.com/homarr-labs/homarr/commit/7281b4ae32f6b86d3a1c6666db84419387b496a5))
|
||||
* **deps:** update dependency octokit to ^5.0.4 ([#4340](https://github.com/homarr-labs/homarr/issues/4340)) ([bd98370](https://github.com/homarr-labs/homarr/commit/bd983701b7d4eea15c81e7d6482f1390b98a35fb))
|
||||
* **deps:** update dependency swagger-ui-react to ^5.29.5 ([#4335](https://github.com/homarr-labs/homarr/issues/4335)) ([fb4b8f7](https://github.com/homarr-labs/homarr/commit/fb4b8f7ca3cc3c11392dc1665be2ad907d15772e))
|
||||
* **deps:** update mantine monorepo to ^8.3.5 ([#4291](https://github.com/homarr-labs/homarr/issues/4291)) ([63c5c9c](https://github.com/homarr-labs/homarr/commit/63c5c9ca9846d6c11cc44372366a68d3d46ee57c))
|
||||
* **deps:** update nextjs monorepo to v15.5.6 ([#4314](https://github.com/homarr-labs/homarr/issues/4314)) ([0df6e8f](https://github.com/homarr-labs/homarr/commit/0df6e8fb20e59457e7c12d123ba7f948619062e1))
|
||||
|
||||
## [1.42.1](https://github.com/homarr-labs/homarr/compare/v1.42.0...v1.42.1) (2025-10-18)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **deps:** update dependency @ctrl/qbittorrent to ^9.10.0 ([#4310](https://github.com/homarr-labs/homarr/issues/4310)) ([3f87bbd](https://github.com/homarr-labs/homarr/commit/3f87bbd42bbe122870ca388e8b52069265cc577f))
|
||||
* **deps:** update dependency typescript-eslint to ^8.46.1 ([#4313](https://github.com/homarr-labs/homarr/issues/4313)) ([f78eaa5](https://github.com/homarr-labs/homarr/commit/f78eaa533e9f75cfbdc223d5581e19f73fdb7303))
|
||||
* **deps:** update nextjs monorepo to v15.5.5 ([#4312](https://github.com/homarr-labs/homarr/issues/4312)) ([857b46b](https://github.com/homarr-labs/homarr/commit/857b46bc333b5f3d5b3336986bdbe399e28bdc99))
|
||||
* **docker:** stats not working ([#4311](https://github.com/homarr-labs/homarr/issues/4311)) ([9aa2d5c](https://github.com/homarr-labs/homarr/commit/9aa2d5ccd8cfa8facea962c5e21efeee7f695c97))
|
||||
|
||||
## [1.42.0](https://github.com/homarr-labs/homarr/compare/v1.41.0...v1.42.0) (2025-10-17)
|
||||
|
||||
### Features
|
||||
|
||||
* **nextcloud:** reuse color from event for indicator ([#4270](https://github.com/homarr-labs/homarr/issues/4270)) ([afca800](https://github.com/homarr-labs/homarr/commit/afca8007f1919e0bb8cb17eefb9c41e3f28291cc))
|
||||
* **users:** allow profile picture change for external providers ([#4275](https://github.com/homarr-labs/homarr/issues/4275)) ([5c86930](https://github.com/homarr-labs/homarr/commit/5c86930220e97888eeae1c5440572ba7ef2434be))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **calendar:** recurring events not working ([#4265](https://github.com/homarr-labs/homarr/issues/4265)) ([1b7e67d](https://github.com/homarr-labs/homarr/commit/1b7e67dd9760c159e525cd88face00e28a260d13))
|
||||
* **deps:** update dependency @auth/core to ^0.41.0 ([#4282](https://github.com/homarr-labs/homarr/issues/4282)) ([60370a7](https://github.com/homarr-labs/homarr/commit/60370a7e33dc1957aa433cbd6a11ab69661606cc))
|
||||
* **deps:** update dependency @auth/drizzle-adapter to ^1.11.0 ([#4283](https://github.com/homarr-labs/homarr/issues/4283)) ([cd5d9c6](https://github.com/homarr-labs/homarr/commit/cd5d9c66ca42687faa9aeeff3d4d74d21a472a92))
|
||||
* **deps:** update dependency @ctrl/qbittorrent to ^9.9.1 ([#4229](https://github.com/homarr-labs/homarr/issues/4229)) ([c2b2b09](https://github.com/homarr-labs/homarr/commit/c2b2b098a1ae7df027265dfcd184c01be1831ffc))
|
||||
* **deps:** update dependency ioredis to v5.8.1 ([#4259](https://github.com/homarr-labs/homarr/issues/4259)) ([4ffbea3](https://github.com/homarr-labs/homarr/commit/4ffbea3b521b45c3dd52595d4f6824d25a6b4a05))
|
||||
* **deps:** update dependency mysql2 to v3.15.2 ([#4280](https://github.com/homarr-labs/homarr/issues/4280)) ([65ccfdf](https://github.com/homarr-labs/homarr/commit/65ccfdfb13e83e78e65585aaf15a07b9d9755697))
|
||||
* **deps:** update dependency next-intl to v4.3.10 ([#4271](https://github.com/homarr-labs/homarr/issues/4271)) ([5d402f8](https://github.com/homarr-labs/homarr/commit/5d402f88d29892ddebfc7e9efc902be4c3bbeba1))
|
||||
* **deps:** update dependency next-intl to v4.3.11 ([#4273](https://github.com/homarr-labs/homarr/issues/4273)) ([f792b2a](https://github.com/homarr-labs/homarr/commit/f792b2a553f7a5851db36fc6a7ec495e2662dbc0))
|
||||
* **deps:** update dependency next-intl to v4.3.12 ([#4274](https://github.com/homarr-labs/homarr/issues/4274)) ([3a7c8d5](https://github.com/homarr-labs/homarr/commit/3a7c8d5e19c28a1e3b084e80eb52a3193a419d7f))
|
||||
* **deps:** update dependency swagger-ui-react to ^5.29.4 ([#4293](https://github.com/homarr-labs/homarr/issues/4293)) ([fde68f8](https://github.com/homarr-labs/homarr/commit/fde68f8bcc75aefb9cdb15c0b3b5d12c18a54d35))
|
||||
* **deps:** update dependency trpc-to-openapi to ^3.1.0 ([#4268](https://github.com/homarr-labs/homarr/issues/4268)) ([d80f070](https://github.com/homarr-labs/homarr/commit/d80f070ba3dca6bd8af10ee771f7cbd3d24dafa3))
|
||||
* **deps:** update dependency typescript-eslint to ^8.46.0 ([#4264](https://github.com/homarr-labs/homarr/issues/4264)) ([0aede0d](https://github.com/homarr-labs/homarr/commit/0aede0dec38016f5df08ce42f4f2e9237aaecaa6))
|
||||
* **deps:** update dependency zod to ^4.1.12 ([#4263](https://github.com/homarr-labs/homarr/issues/4263)) ([e887716](https://github.com/homarr-labs/homarr/commit/e8877169f88179fc9a23947c3f34478f851cb66a))
|
||||
* **deps:** update mantine monorepo to ^8.3.4 ([#4290](https://github.com/homarr-labs/homarr/issues/4290)) ([94533c6](https://github.com/homarr-labs/homarr/commit/94533c667cd234fdfe8f064b3d4892ad902e74c7))
|
||||
* **deps:** update tiptap monorepo to v2.26.3 ([#4292](https://github.com/homarr-labs/homarr/issues/4292)) ([38e368f](https://github.com/homarr-labs/homarr/commit/38e368fdbcf9aba54fbc960031742cb162a72b72))
|
||||
* **docker-widget:** memory usage wrong ([#4266](https://github.com/homarr-labs/homarr/issues/4266)) ([cccec5e](https://github.com/homarr-labs/homarr/commit/cccec5e19538aa8df154281f14fa939414bc8190))
|
||||
* **management:** improve homepage ([#4260](https://github.com/homarr-labs/homarr/issues/4260)) ([7e5f4e5](https://github.com/homarr-labs/homarr/commit/7e5f4e59a9edcb05dfa8a09919408e93e8d0313f))
|
||||
* **plex:** tv show links not working ([#4272](https://github.com/homarr-labs/homarr/issues/4272)) ([a80c5d4](https://github.com/homarr-labs/homarr/commit/a80c5d40e0e944d40dfdfe8e902ee10872e26bc0))
|
||||
* **users:** profile picture upload limit wrong ([#4276](https://github.com/homarr-labs/homarr/issues/4276)) ([35a8ac8](https://github.com/homarr-labs/homarr/commit/35a8ac820f084271bc7591834096226639aec0d4))
|
||||
|
||||
## [1.41.0](https://github.com/homarr-labs/homarr/compare/v1.40.0...v1.41.0) (2025-10-10)
|
||||
|
||||
### Features
|
||||
|
||||
+14
-14
@@ -50,15 +50,15 @@
|
||||
"@homarr/ui": "workspace:^0.1.0",
|
||||
"@homarr/validation": "workspace:^0.1.0",
|
||||
"@homarr/widgets": "workspace:^0.1.0",
|
||||
"@mantine/colors-generator": "^8.3.3",
|
||||
"@mantine/core": "^8.3.3",
|
||||
"@mantine/dropzone": "^8.3.3",
|
||||
"@mantine/hooks": "^8.3.3",
|
||||
"@mantine/modals": "^8.3.3",
|
||||
"@mantine/tiptap": "^8.3.3",
|
||||
"@mantine/colors-generator": "^8.3.5",
|
||||
"@mantine/core": "^8.3.5",
|
||||
"@mantine/dropzone": "^8.3.5",
|
||||
"@mantine/hooks": "^8.3.5",
|
||||
"@mantine/modals": "^8.3.5",
|
||||
"@mantine/tiptap": "^8.3.5",
|
||||
"@million/lint": "1.0.14",
|
||||
"@tabler/icons-react": "^3.35.0",
|
||||
"@tanstack/react-query": "^5.90.2",
|
||||
"@tanstack/react-query": "^5.90.5",
|
||||
"@tanstack/react-query-devtools": "^5.90.2",
|
||||
"@tanstack/react-query-next-experimental": "^5.90.2",
|
||||
"@trpc/client": "^11.6.0",
|
||||
@@ -76,7 +76,7 @@
|
||||
"glob": "^11.0.3",
|
||||
"jotai": "^2.15.0",
|
||||
"mantine-react-table": "2.0.0-beta.9",
|
||||
"next": "15.5.4",
|
||||
"next": "15.5.6",
|
||||
"postcss-preset-mantine": "^1.18.0",
|
||||
"prismjs": "^1.30.0",
|
||||
"react": "19.2.0",
|
||||
@@ -85,22 +85,22 @@
|
||||
"react-simple-code-editor": "^0.14.1",
|
||||
"sass": "^1.93.2",
|
||||
"superjson": "2.2.2",
|
||||
"swagger-ui-react": "^5.29.3",
|
||||
"swagger-ui-react": "^5.29.5",
|
||||
"use-deep-compare-effect": "^1.8.1",
|
||||
"zod": "^4.1.11"
|
||||
"zod": "^4.1.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"@types/chroma-js": "3.1.1",
|
||||
"@types/node": "^22.18.8",
|
||||
"@types/node": "^22.18.11",
|
||||
"@types/prismjs": "^1.26.5",
|
||||
"@types/react": "19.2.0",
|
||||
"@types/react-dom": "19.2.0",
|
||||
"@types/react": "19.2.2",
|
||||
"@types/react-dom": "19.2.2",
|
||||
"@types/swagger-ui-react": "^5.18.0",
|
||||
"concurrently": "^9.2.1",
|
||||
"eslint": "^9.37.0",
|
||||
"eslint": "^9.38.0",
|
||||
"node-loader": "^2.1.0",
|
||||
"prettier": "^3.6.2",
|
||||
"typescript": "^5.9.3"
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
@@ -62,6 +63,7 @@ export const BoardContentHeaderActions = () => {
|
||||
};
|
||||
|
||||
const AddMenu = () => {
|
||||
const { data: session } = useSession();
|
||||
const { openModal: openCategoryEditModal } = useModalAction(CategoryEditModal);
|
||||
const { openModal: openItemSelectModal } = useModalAction(ItemSelectModal);
|
||||
const { openModal: openAppSelectModal } = useModalAction(AppSelectModal);
|
||||
@@ -96,12 +98,13 @@ const AddMenu = () => {
|
||||
|
||||
const handleSelectApp = useCallback(() => {
|
||||
openAppSelectModal({
|
||||
onSelect: (appId) => {
|
||||
onSelect: (app) => {
|
||||
createItem({
|
||||
kind: "app",
|
||||
options: { appId },
|
||||
options: { appId: app.id },
|
||||
});
|
||||
},
|
||||
withCreate: session?.user.permissions.includes("app-create") ?? false,
|
||||
});
|
||||
}, [openAppSelectModal, createItem]);
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Box, Grid, GridCol, Group, Image, Stack, Title } from "@mantine/core";
|
||||
|
||||
import { splitToNChunks } from "@homarr/common";
|
||||
import { integrationDefs } from "@homarr/definitions";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
|
||||
import classes from "./hero-banner.module.css";
|
||||
|
||||
@@ -12,19 +13,20 @@ const icons = Object.values(integrationDefs)
|
||||
const countIconGroups = 3;
|
||||
const animationDurationInSeconds = icons.length;
|
||||
const arrayInChunks = splitToNChunks(icons, countIconGroups);
|
||||
const gridSpan = 12 / countIconGroups;
|
||||
|
||||
export const HeroBanner = () => {
|
||||
const gridSpan = 12 / countIconGroups;
|
||||
export const HeroBanner = async () => {
|
||||
const t = await getScopedI18n("management.page.home");
|
||||
|
||||
return (
|
||||
<Box className={classes.bannerContainer} p={{ base: "lg", md: "3rem" }} bg="dark.6" pos="relative">
|
||||
<Stack gap={0}>
|
||||
<Title fz={{ base: "h4", md: "h2" }} c="dimmed">
|
||||
Welcome back to your
|
||||
{t("heroBanner.title")}
|
||||
</Title>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Image src="/logo/logo.png" w={{ base: 32, md: 40 }} h={{ base: 32, md: 40 }} />
|
||||
<Title fz={{ base: "h3", md: "h1" }}>Homarr Board</Title>
|
||||
<Title fz={{ base: "h3", md: "h1" }}>{t("heroBanner.subtitle", { app: "Homarr" })}</Title>
|
||||
</Group>
|
||||
</Stack>
|
||||
<Box visibleFrom="md" className={classes.scrollContainer} w={"30%"} top={0} right={0} pos="absolute">
|
||||
|
||||
+103
-8
@@ -3,16 +3,18 @@
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Alert, Button, Fieldset, Group, Stack, Text, TextInput } from "@mantine/core";
|
||||
import { IconInfoCircle } from "@tabler/icons-react";
|
||||
import type { z } from "zod/v4";
|
||||
import { Alert, Anchor, Button, ButtonGroup, Fieldset, Group, Stack, Text, TextInput } from "@mantine/core";
|
||||
import { IconInfoCircle, IconPencil, IconPlus, IconUnlink } from "@tabler/icons-react";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { getAllSecretKindOptions, getDefaultSecretKinds } from "@homarr/definitions";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import { useConfirmModal } from "@homarr/modals";
|
||||
import { useConfirmModal, useModalAction } from "@homarr/modals";
|
||||
import { AppSelectModal } from "@homarr/modals-collection";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { integrationUpdateSchema } from "@homarr/validation/integration";
|
||||
@@ -27,6 +29,19 @@ interface EditIntegrationForm {
|
||||
integration: RouterOutputs["integration"]["byId"];
|
||||
}
|
||||
|
||||
const formSchema = integrationUpdateSchema.omit({ id: true, appId: true }).and(
|
||||
z.object({
|
||||
app: z
|
||||
.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
iconUrl: z.string(),
|
||||
href: z.string().nullable(),
|
||||
})
|
||||
.nullable(),
|
||||
}),
|
||||
);
|
||||
|
||||
export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
|
||||
const t = useI18n();
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
@@ -40,7 +55,7 @@ export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
|
||||
const hasUrlSecret = initialSecretsKinds.includes("url");
|
||||
|
||||
const router = useRouter();
|
||||
const form = useZodForm(integrationUpdateSchema.omit({ id: true }), {
|
||||
const form = useZodForm(formSchema, {
|
||||
initialValues: {
|
||||
name: integration.name,
|
||||
url: integration.url,
|
||||
@@ -48,6 +63,7 @@ export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
|
||||
kind,
|
||||
value: integration.secrets.find((secret) => secret.kind === kind)?.value ?? "",
|
||||
})),
|
||||
app: integration.app ?? null,
|
||||
},
|
||||
});
|
||||
const { mutateAsync, isPending } = clientApi.integration.update.useMutation();
|
||||
@@ -55,7 +71,7 @@ export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
|
||||
|
||||
const secretsMap = new Map(integration.secrets.map((secret) => [secret.kind, secret]));
|
||||
|
||||
const handleSubmitAsync = async (values: FormType) => {
|
||||
const handleSubmitAsync = async ({ app, ...values }: FormType) => {
|
||||
const url = hasUrlSecret
|
||||
? new URL(values.secrets.find((secret) => secret.kind === "url")?.value ?? values.url).origin
|
||||
: values.url;
|
||||
@@ -68,6 +84,7 @@ export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
|
||||
kind: secret.kind,
|
||||
value: secret.value === "" ? null : secret.value,
|
||||
})),
|
||||
appId: app?.id ?? null,
|
||||
},
|
||||
{
|
||||
onSuccess: (data) => {
|
||||
@@ -102,7 +119,7 @@ export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
|
||||
form.values.secrets.length === initialSecretsKinds.length;
|
||||
|
||||
return (
|
||||
<form onSubmit={form.onSubmit((values) => void handleSubmitAsync(values))}>
|
||||
<form onSubmit={form.onSubmit(async (values) => await handleSubmitAsync(values))}>
|
||||
<Stack>
|
||||
<TextInput withAsterisk label={t("integration.field.name.label")} {...form.getInputProps("name")} />
|
||||
|
||||
@@ -169,6 +186,8 @@ export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
|
||||
</Stack>
|
||||
</Fieldset>
|
||||
|
||||
<IntegrationLinkApp value={form.values.app} onChange={(app) => form.setFieldValue("app", app)} />
|
||||
|
||||
{error !== null && <IntegrationTestConnectionError error={error} url={form.values.url} />}
|
||||
|
||||
<Group justify="end" align="center">
|
||||
@@ -184,4 +203,80 @@ export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
|
||||
);
|
||||
};
|
||||
|
||||
type FormType = Omit<z.infer<typeof integrationUpdateSchema>, "id">;
|
||||
type FormType = z.infer<typeof formSchema>;
|
||||
|
||||
interface IntegrationAppSelectProps {
|
||||
value: FormType["app"];
|
||||
onChange: (app: FormType["app"]) => void;
|
||||
}
|
||||
|
||||
const IntegrationLinkApp = ({ value, onChange }: IntegrationAppSelectProps) => {
|
||||
const { openModal } = useModalAction(AppSelectModal);
|
||||
const t = useI18n();
|
||||
const { data: session } = useSession();
|
||||
const canCreateApps = session?.user.permissions.includes("app-create") ?? false;
|
||||
|
||||
const handleChange = () =>
|
||||
openModal(
|
||||
{
|
||||
onSelect: onChange,
|
||||
withCreate: canCreateApps,
|
||||
},
|
||||
{
|
||||
title: t("integration.page.edit.app.action.select"),
|
||||
},
|
||||
);
|
||||
|
||||
if (!value) {
|
||||
return (
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
leftSection={<IconPlus size={16} stroke={1.5} />}
|
||||
fullWidth
|
||||
onClick={handleChange}
|
||||
>
|
||||
{t("integration.page.edit.app.action.add")}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Fieldset legend={t("integration.field.app.sectionTitle")}>
|
||||
<Group justify="space-between">
|
||||
<Group gap="sm">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={value.iconUrl} alt={value.name} width={32} height={32} />
|
||||
<Stack gap={0}>
|
||||
<Text size="sm" fw="bold">
|
||||
{value.name}
|
||||
</Text>
|
||||
{value.href !== null && (
|
||||
<Anchor href={value.href} target="_blank" rel="noopener noreferrer" size="sm">
|
||||
{value.href}
|
||||
</Anchor>
|
||||
)}
|
||||
</Stack>
|
||||
</Group>
|
||||
<ButtonGroup>
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
leftSection={<IconUnlink size={16} stroke={1.5} />}
|
||||
onClick={() => onChange(null)}
|
||||
>
|
||||
{t("integration.page.edit.app.action.remove")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
leftSection={<IconPencil size={16} stroke={1.5} />}
|
||||
onClick={handleChange}
|
||||
>
|
||||
{t("common.action.change")}
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</Group>
|
||||
</Fieldset>
|
||||
);
|
||||
};
|
||||
@@ -1,14 +1,29 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { startTransition, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Alert, Button, Checkbox, Collapse, Fieldset, Group, Stack, Text, TextInput } from "@mantine/core";
|
||||
import { IconInfoCircle } from "@tabler/icons-react";
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Checkbox,
|
||||
Collapse,
|
||||
Fieldset,
|
||||
Group,
|
||||
Loader,
|
||||
SegmentedControl,
|
||||
Select,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
} from "@mantine/core";
|
||||
import { IconCheck, IconInfoCircle } from "@tabler/icons-react";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import type { Modify } from "@homarr/common/types";
|
||||
import type { IntegrationKind } from "@homarr/definitions";
|
||||
import {
|
||||
getAllSecretKindOptions,
|
||||
@@ -17,6 +32,7 @@ import {
|
||||
getIntegrationName,
|
||||
integrationDefs,
|
||||
} from "@homarr/definitions";
|
||||
import type { GetInputPropsReturnType, UseFormReturnType } from "@homarr/form";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
@@ -34,10 +50,11 @@ interface NewIntegrationFormProps {
|
||||
};
|
||||
}
|
||||
|
||||
const formSchema = integrationCreateSchema.omit({ kind: true }).and(
|
||||
const formSchema = integrationCreateSchema.omit({ kind: true, app: true }).and(
|
||||
z.object({
|
||||
createApp: z.boolean(),
|
||||
hasApp: z.boolean(),
|
||||
appHref: appHrefSchema,
|
||||
appId: z.string().nullable(),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -46,7 +63,8 @@ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) =>
|
||||
const secretKinds = getAllSecretKindOptions(searchParams.kind);
|
||||
const hasUrlSecret = secretKinds.some((kinds) => kinds.includes("url"));
|
||||
const router = useRouter();
|
||||
const [opened, setOpened] = useState(false);
|
||||
const { data: session } = useSession();
|
||||
const canCreateApps = session?.user.permissions.includes("app-create") ?? false;
|
||||
|
||||
let url = searchParams.url ?? getIntegrationDefaultUrl(searchParams.kind) ?? "";
|
||||
if (hasUrlSecret) {
|
||||
@@ -62,31 +80,40 @@ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) =>
|
||||
value: "",
|
||||
})),
|
||||
attemptSearchEngineCreation: true,
|
||||
createApp: false,
|
||||
appHref: "",
|
||||
},
|
||||
onValuesChange(values, previous) {
|
||||
if (values.createApp !== previous.createApp) {
|
||||
setOpened(values.createApp);
|
||||
}
|
||||
hasApp: false,
|
||||
appHref: url,
|
||||
appId: null,
|
||||
},
|
||||
});
|
||||
|
||||
const { mutateAsync: createIntegrationAsync, isPending: isPendingIntegration } =
|
||||
clientApi.integration.create.useMutation();
|
||||
const { mutateAsync: createAppAsync, isPending: isPendingApp } = clientApi.app.create.useMutation();
|
||||
const isPending = isPendingIntegration || isPendingApp;
|
||||
const { mutateAsync: createIntegrationAsync, isPending } = clientApi.integration.create.useMutation();
|
||||
const [error, setError] = useState<null | AnyMappedTestConnectionError>(null);
|
||||
|
||||
const handleSubmitAsync = async (values: FormType) => {
|
||||
const handleSubmitAsync = async ({ appId, appHref, hasApp, ...values }: FormType) => {
|
||||
const url = hasUrlSecret
|
||||
? new URL(values.secrets.find((secret) => secret.kind === "url")?.value ?? values.url).origin
|
||||
: values.url;
|
||||
|
||||
const hasCustomHref = appHref !== null && appHref.trim().length >= 1;
|
||||
|
||||
const app = hasApp
|
||||
? appId !== null
|
||||
? { id: appId }
|
||||
: {
|
||||
name: values.name,
|
||||
href: hasCustomHref ? appHref : url,
|
||||
iconUrl: getIconUrl(searchParams.kind),
|
||||
description: null,
|
||||
pingUrl: url,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
await createIntegrationAsync(
|
||||
{
|
||||
kind: searchParams.kind,
|
||||
...values,
|
||||
url,
|
||||
app,
|
||||
},
|
||||
{
|
||||
async onSuccess(data) {
|
||||
@@ -105,32 +132,7 @@ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) =>
|
||||
message: t("integration.page.create.notification.success.message"),
|
||||
});
|
||||
|
||||
if (!values.createApp) {
|
||||
await revalidatePathActionAsync("/manage/integrations").then(() => router.push("/manage/integrations"));
|
||||
return;
|
||||
}
|
||||
|
||||
const hasCustomHref = values.appHref !== null && values.appHref.trim().length >= 1;
|
||||
await createAppAsync(
|
||||
{
|
||||
name: values.name,
|
||||
href: hasCustomHref ? values.appHref : url,
|
||||
iconUrl: getIconUrl(searchParams.kind),
|
||||
description: null,
|
||||
pingUrl: url,
|
||||
},
|
||||
{
|
||||
async onSettled() {
|
||||
await revalidatePathActionAsync("/manage/integrations").then(() => router.push("/manage/integrations"));
|
||||
},
|
||||
onError() {
|
||||
showErrorNotification({
|
||||
title: t("app.page.create.notification.error.title"),
|
||||
message: t("app.page.create.notification.error.message"),
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
await revalidatePathActionAsync("/manage/integrations").then(() => router.push("/manage/integrations"));
|
||||
},
|
||||
onError: () => {
|
||||
showErrorNotification({
|
||||
@@ -184,15 +186,7 @@ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) =>
|
||||
/>
|
||||
)}
|
||||
|
||||
<Checkbox
|
||||
{...form.getInputProps("createApp", { type: "checkbox" })}
|
||||
label={t("integration.field.createApp.label")}
|
||||
description={t("integration.field.createApp.description")}
|
||||
/>
|
||||
|
||||
<Collapse in={opened}>
|
||||
<TextInput placeholder={t("integration.field.appHref.placeholder")} {...form.getInputProps("appHref")} />
|
||||
</Collapse>
|
||||
<AppForm form={form} canCreateApps={canCreateApps} />
|
||||
|
||||
<Group justify="end" align="center">
|
||||
<Button variant="default" component={Link} href="/manage/integrations">
|
||||
@@ -208,3 +202,114 @@ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) =>
|
||||
};
|
||||
|
||||
type FormType = z.infer<typeof formSchema>;
|
||||
|
||||
const AppForm = ({ form, canCreateApps }: { form: UseFormReturnType<FormType>; canCreateApps: boolean }) => {
|
||||
const t = useI18n();
|
||||
const checkboxInputProps = form.getInputProps("hasApp", { type: "checkbox" });
|
||||
|
||||
return (
|
||||
<>
|
||||
<Checkbox
|
||||
{...checkboxInputProps}
|
||||
onChange={(event) => {
|
||||
startTransition(() => {
|
||||
form.setFieldValue("appHref", event.currentTarget.checked ? form.values.url : null);
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
|
||||
checkboxInputProps.onChange(event);
|
||||
});
|
||||
}}
|
||||
label={t("integration.field.createApp.label")}
|
||||
description={t("integration.field.createApp.description")}
|
||||
/>
|
||||
|
||||
<Collapse in={form.values.hasApp}>
|
||||
<Fieldset legend={t("integration.field.app.sectionTitle")}>
|
||||
<Stack gap="sm">
|
||||
{canCreateApps && (
|
||||
<SegmentedControl
|
||||
data={(["new", "existing"] as const).map((value) => ({
|
||||
value,
|
||||
label: t(`integration.page.create.app.option.${value}.title`),
|
||||
}))}
|
||||
value={form.values.appHref === null ? "existing" : "new"}
|
||||
onChange={(value) => {
|
||||
if (value === "existing") {
|
||||
form.setFieldValue("appId", null);
|
||||
form.setFieldValue("appHref", null);
|
||||
} else {
|
||||
form.setFieldValue("appId", null);
|
||||
form.setFieldValue("appHref", form.values.url);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{typeof form.values.appHref === "string" && canCreateApps ? (
|
||||
<TextInput
|
||||
placeholder={t("integration.field.appHref.placeholder")}
|
||||
withAsterisk
|
||||
label={t("integration.page.create.app.option.new.url.label")}
|
||||
description={t("integration.page.create.app.option.new.url.description")}
|
||||
{...form.getInputProps("appHref")}
|
||||
/>
|
||||
) : (
|
||||
<IntegrationAppSelect {...form.getInputProps("appId")} />
|
||||
)}
|
||||
</Stack>
|
||||
</Fieldset>
|
||||
</Collapse>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
type IntegrationAppSelectProps = Modify<
|
||||
GetInputPropsReturnType,
|
||||
{
|
||||
value?: string | null;
|
||||
onChange: (value: string | null) => void;
|
||||
}
|
||||
>;
|
||||
|
||||
const IntegrationAppSelect = ({ value, ...props }: IntegrationAppSelectProps) => {
|
||||
const { data, isPending } = clientApi.app.selectable.useQuery();
|
||||
const t = useI18n();
|
||||
|
||||
const appMap = new Map(data?.map((app) => [app.id, app] as const));
|
||||
|
||||
return (
|
||||
<Select
|
||||
withAsterisk
|
||||
label={t("integration.page.create.app.option.existing.label")}
|
||||
searchable
|
||||
clearable
|
||||
leftSection={
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
value ? <img width={20} height={20} src={appMap.get(value)?.iconUrl} alt={appMap.get(value)?.name} /> : null
|
||||
}
|
||||
renderOption={({ option, checked }) => (
|
||||
<Group flex="1" gap="xs">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img width={20} height={20} src={appMap.get(option.value)?.iconUrl} alt={option.label} />
|
||||
<Stack gap={0}>
|
||||
<Text>{option.label}</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{appMap.get(option.value)?.href}
|
||||
</Text>
|
||||
</Stack>
|
||||
{checked && (
|
||||
<IconCheck
|
||||
style={{ marginInlineStart: "auto" }}
|
||||
stroke={1.5}
|
||||
color="currentColor"
|
||||
opacity={0.6}
|
||||
size={18}
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
)}
|
||||
{...props}
|
||||
data={data?.map((app) => ({ value: app.id, label: app.name }))}
|
||||
rightSection={isPending ? <Loader size="sm" /> : null}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1,25 +1,16 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { Card, Group, SimpleGrid, Space, Stack, Text } from "@mantine/core";
|
||||
import { IconArrowRight } from "@tabler/icons-react";
|
||||
|
||||
import { api } from "@homarr/api/server";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { isProviderEnabled } from "@homarr/auth/server";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
|
||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import { createMetaTitle } from "~/metadata";
|
||||
import { HeroBanner } from "./_components/hero-banner";
|
||||
|
||||
interface LinkProps {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
count: number;
|
||||
href: string;
|
||||
hidden?: boolean;
|
||||
}
|
||||
|
||||
export async function generateMetadata() {
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getScopedI18n("management");
|
||||
|
||||
return {
|
||||
@@ -29,78 +20,32 @@ export async function generateMetadata() {
|
||||
|
||||
export default async function ManagementPage() {
|
||||
const statistics = await api.home.getStats();
|
||||
const session = await auth();
|
||||
const t = await getScopedI18n("management.page.home");
|
||||
|
||||
const links: LinkProps[] = [
|
||||
{
|
||||
count: statistics.countBoards,
|
||||
href: "/manage/boards",
|
||||
subtitle: t("statisticLabel.boards"),
|
||||
title: t("statistic.board"),
|
||||
},
|
||||
{
|
||||
count: statistics.countUsers,
|
||||
href: "/manage/users",
|
||||
subtitle: t("statisticLabel.authentication"),
|
||||
title: t("statistic.user"),
|
||||
hidden: !session?.user.permissions.includes("admin"),
|
||||
},
|
||||
{
|
||||
count: statistics.countInvites,
|
||||
href: "/manage/users/invites",
|
||||
subtitle: t("statisticLabel.authentication"),
|
||||
title: t("statistic.invite"),
|
||||
hidden: !isProviderEnabled("credentials") || !session?.user.permissions.includes("admin"),
|
||||
},
|
||||
{
|
||||
count: statistics.countIntegrations,
|
||||
href: "/manage/integrations",
|
||||
subtitle: t("statisticLabel.resources"),
|
||||
title: t("statistic.integration"),
|
||||
},
|
||||
{
|
||||
count: statistics.countApps,
|
||||
href: "/manage/apps",
|
||||
subtitle: t("statisticLabel.resources"),
|
||||
title: t("statistic.app"),
|
||||
hidden: !session?.user,
|
||||
},
|
||||
{
|
||||
count: statistics.countGroups,
|
||||
href: "/manage/users/groups",
|
||||
subtitle: t("statisticLabel.authorization"),
|
||||
title: t("statistic.group"),
|
||||
hidden: !session?.user.permissions.includes("admin"),
|
||||
},
|
||||
];
|
||||
return (
|
||||
<>
|
||||
<DynamicBreadcrumb />
|
||||
<HeroBanner />
|
||||
<Space h="md" />
|
||||
<SimpleGrid cols={{ xs: 1, sm: 2, md: 3 }}>
|
||||
{links.map(
|
||||
(link) =>
|
||||
!link.hidden && (
|
||||
<Card component={Link} href={link.href} key={link.href} radius="lg">
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Group wrap="nowrap">
|
||||
<Text size="2.4rem" fw="bolder">
|
||||
{link.count}
|
||||
</Text>
|
||||
<Stack gap={0}>
|
||||
<Text c="red" size="xs">
|
||||
{link.subtitle}
|
||||
</Text>
|
||||
<Text fw="bold">{link.title}</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
<IconArrowRight />
|
||||
</Group>
|
||||
</Card>
|
||||
),
|
||||
)}
|
||||
{statistics.map((statistic) => (
|
||||
<Card component={Link} href={statistic.path} key={statistic.path} radius="lg">
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Group wrap="nowrap">
|
||||
<Text size="2.4rem" fw="bolder">
|
||||
{statistic.count}
|
||||
</Text>
|
||||
<Stack gap={0}>
|
||||
<Text c="red" size="xs">
|
||||
{t(`statisticLabel.${statistic.subtitleKey}`)}
|
||||
</Text>
|
||||
<Text fw="bold">{t(`statistic.${statistic.titleKey}`)}</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
<IconArrowRight />
|
||||
</Group>
|
||||
</Card>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</>
|
||||
);
|
||||
|
||||
+14
-28
@@ -92,38 +92,24 @@ export const UserProfileAvatarForm = ({ user }: UserProfileAvatarForm) => {
|
||||
});
|
||||
}, [mutate, user.id, openConfirmModal, tManageAvatar]);
|
||||
|
||||
const isCredentialsUser = user.provider === "credentials";
|
||||
|
||||
return (
|
||||
<Box pos="relative">
|
||||
<Menu
|
||||
opened={opened}
|
||||
keepMounted
|
||||
onChange={isCredentialsUser ? toggle : undefined}
|
||||
position="bottom-start"
|
||||
withArrow
|
||||
>
|
||||
<Menu opened={opened} keepMounted onChange={toggle} position="bottom-start" withArrow>
|
||||
<Menu.Target>
|
||||
<UnstyledButton
|
||||
component={isCredentialsUser ? undefined : "div"}
|
||||
style={{ cursor: !isCredentialsUser ? "default" : undefined }}
|
||||
onClick={isCredentialsUser ? toggle : undefined}
|
||||
>
|
||||
<UnstyledButton onClick={toggle}>
|
||||
<UserAvatar user={user} size={200} />
|
||||
{isCredentialsUser && (
|
||||
<Button
|
||||
component="div"
|
||||
pos="absolute"
|
||||
bottom={0}
|
||||
left={0}
|
||||
size="compact-md"
|
||||
fw="normal"
|
||||
variant="default"
|
||||
leftSection={<IconPencil size={18} stroke={1.5} />}
|
||||
>
|
||||
{t("common.action.edit")}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
component="div"
|
||||
pos="absolute"
|
||||
bottom={0}
|
||||
left={0}
|
||||
size="compact-md"
|
||||
fw="normal"
|
||||
variant="default"
|
||||
leftSection={<IconPencil size={18} stroke={1.5} />}
|
||||
>
|
||||
{t("common.action.edit")}
|
||||
</Button>
|
||||
</UnstyledButton>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
|
||||
@@ -47,10 +47,10 @@
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"@types/node": "^22.18.8",
|
||||
"@types/node": "^22.18.11",
|
||||
"dotenv-cli": "^10.0.0",
|
||||
"esbuild": "^0.25.10",
|
||||
"eslint": "^9.37.0",
|
||||
"esbuild": "^0.25.11",
|
||||
"eslint": "^9.38.0",
|
||||
"prettier": "^3.6.2",
|
||||
"tsx": "4.20.4",
|
||||
"typescript": "^5.9.3"
|
||||
|
||||
@@ -34,8 +34,8 @@
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"@types/ws": "^8.18.1",
|
||||
"esbuild": "^0.25.10",
|
||||
"eslint": "^9.37.0",
|
||||
"esbuild": "^0.25.11",
|
||||
"eslint": "^9.38.0",
|
||||
"prettier": "^3.6.2",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
|
||||
+7
-7
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "homarr",
|
||||
"version": "1.41.0",
|
||||
"version": "1.43.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "cross-env CI=true turbo build",
|
||||
@@ -49,7 +49,7 @@
|
||||
"@vitest/ui": "^3.2.4",
|
||||
"conventional-changelog-conventionalcommits": "^9.1.0",
|
||||
"cross-env": "^10.1.0",
|
||||
"jsdom": "^27.0.0",
|
||||
"jsdom": "^27.0.1",
|
||||
"prettier": "^3.6.2",
|
||||
"semantic-release": "^24.2.9",
|
||||
"testcontainers": "^11.7.1",
|
||||
@@ -58,7 +58,7 @@
|
||||
"vite-tsconfig-paths": "^5.1.4",
|
||||
"vitest": "^3.2.4"
|
||||
},
|
||||
"packageManager": "pnpm@10.18.0",
|
||||
"packageManager": "pnpm@10.18.3",
|
||||
"engines": {
|
||||
"node": ">=22.20.0"
|
||||
},
|
||||
@@ -80,20 +80,20 @@
|
||||
"axios@>=1.0.0 <1.8.2": ">=1.12.2",
|
||||
"brace-expansion@>=2.0.0 <=2.0.1": ">=4.0.1",
|
||||
"brace-expansion@>=1.0.0 <=1.1.11": ">=4.0.1",
|
||||
"esbuild@<=0.24.2": ">=0.25.10",
|
||||
"esbuild@<=0.24.2": ">=0.25.11",
|
||||
"form-data@>=4.0.0 <4.0.4": ">=4.0.4",
|
||||
"hono@<4.6.5": ">=4.9.10",
|
||||
"hono@<4.6.5": ">=4.10.2",
|
||||
"linkifyjs@<4.3.2": ">=4.3.2",
|
||||
"nanoid@>=4.0.0 <5.0.9": ">=5.1.6",
|
||||
"prismjs@<1.30.0": ">=1.30.0",
|
||||
"proxmox-api>undici": "7.16.0",
|
||||
"react-is": "^19.2.0",
|
||||
"rollup@>=4.0.0 <4.22.4": ">=4.52.4",
|
||||
"rollup@>=4.0.0 <4.22.4": ">=4.52.5",
|
||||
"sha.js@<=2.4.11": ">=2.4.12",
|
||||
"tar-fs@>=3.0.0 <3.0.9": ">=3.1.1",
|
||||
"tar-fs@>=2.0.0 <2.1.3": ">=3.1.1",
|
||||
"tmp@<=0.2.3": ">=0.2.5",
|
||||
"vite@>=5.0.0 <=5.4.18": ">=7.1.9"
|
||||
"vite@>=5.0.0 <=5.4.18": ">=7.1.11"
|
||||
},
|
||||
"patchedDependencies": {
|
||||
"@types/node-unifi": "patches/@types__node-unifi.patch",
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"eslint": "^9.37.0",
|
||||
"eslint": "^9.38.0",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
@@ -40,26 +40,27 @@
|
||||
"@homarr/redis": "workspace:^0.1.0",
|
||||
"@homarr/request-handler": "workspace:^0.1.0",
|
||||
"@homarr/server-settings": "workspace:^0.1.0",
|
||||
"@homarr/translation": "workspace:^0.1.0",
|
||||
"@homarr/validation": "workspace:^0.1.0",
|
||||
"@kubernetes/client-node": "^1.4.0",
|
||||
"@tanstack/react-query": "^5.90.2",
|
||||
"@tanstack/react-query": "^5.90.5",
|
||||
"@trpc/client": "^11.6.0",
|
||||
"@trpc/react-query": "^11.6.0",
|
||||
"@trpc/server": "^11.6.0",
|
||||
"@trpc/tanstack-react-query": "^11.6.0",
|
||||
"lodash.clonedeep": "^4.5.0",
|
||||
"next": "15.5.4",
|
||||
"next": "15.5.6",
|
||||
"react": "19.2.0",
|
||||
"react-dom": "19.2.0",
|
||||
"superjson": "2.2.2",
|
||||
"trpc-to-openapi": "^3.0.1",
|
||||
"zod": "^4.1.11"
|
||||
"trpc-to-openapi": "^3.1.0",
|
||||
"zod": "^4.1.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"eslint": "^9.37.0",
|
||||
"eslint": "^9.38.0",
|
||||
"prettier": "^3.6.2",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ export const createOneIntegrationMiddleware = <TKind extends IntegrationKind>(
|
||||
const integration = await ctx.db.query.integrations.findFirst({
|
||||
where: and(eq(integrations.id, input.integrationId), inArray(integrations.kind, kinds)),
|
||||
with: {
|
||||
app: true,
|
||||
secrets: true,
|
||||
groupPermissions: true,
|
||||
userPermissions: true,
|
||||
@@ -65,6 +66,7 @@ export const createOneIntegrationMiddleware = <TKind extends IntegrationKind>(
|
||||
ctx: {
|
||||
integration: {
|
||||
...rest,
|
||||
externalUrl: rest.app?.href ?? null,
|
||||
kind: kind as TKind,
|
||||
decryptedSecrets: secrets.map((secret) => ({
|
||||
...secret,
|
||||
@@ -96,6 +98,7 @@ export const createManyIntegrationMiddleware = <TKind extends IntegrationKind>(
|
||||
? await ctx.db.query.integrations.findMany({
|
||||
where: and(inArray(integrations.id, input.integrationIds), inArray(integrations.kind, kinds)),
|
||||
with: {
|
||||
app: true,
|
||||
secrets: true,
|
||||
items: {
|
||||
with: {
|
||||
@@ -125,6 +128,7 @@ export const createManyIntegrationMiddleware = <TKind extends IntegrationKind>(
|
||||
integrations: dbIntegrations.map(
|
||||
({ secrets, kind, items: _ignore1, groupPermissions: _ignore2, userPermissions: _ignore3, ...rest }) => ({
|
||||
...rest,
|
||||
externalUrl: rest.app?.href ?? null,
|
||||
kind: kind as TKind,
|
||||
decryptedSecrets: secrets.map((secret) => ({
|
||||
...secret,
|
||||
|
||||
@@ -118,20 +118,22 @@ export const appRouter = createTRPCRouter({
|
||||
create: permissionRequiredProcedure
|
||||
.requiresPermission("app-create")
|
||||
.input(appManageSchema)
|
||||
.output(z.object({ appId: z.string() }))
|
||||
.output(z.object({ appId: z.string() }).and(selectAppSchema))
|
||||
.meta({ openapi: { method: "POST", path: "/api/apps", tags: ["apps"], protect: true } })
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const id = createId();
|
||||
await ctx.db.insert(apps).values({
|
||||
const insertValues = {
|
||||
id,
|
||||
name: input.name,
|
||||
description: input.description,
|
||||
iconUrl: input.iconUrl,
|
||||
href: input.href,
|
||||
pingUrl: input.pingUrl === "" ? null : input.pingUrl,
|
||||
});
|
||||
};
|
||||
await ctx.db.insert(apps).values(insertValues);
|
||||
|
||||
return { appId: id };
|
||||
// TODO: breaking change necessary for removing appId property
|
||||
return { appId: id, ...insertValues };
|
||||
}),
|
||||
createMany: permissionRequiredProcedure
|
||||
.requiresPermission("app-create")
|
||||
|
||||
+136
-20
@@ -1,31 +1,147 @@
|
||||
import type { AnySQLiteTable } from "drizzle-orm/sqlite-core";
|
||||
|
||||
import { isProviderEnabled } from "@homarr/auth/server";
|
||||
import type { Database } from "@homarr/db";
|
||||
import { apps, boards, groups, integrations, invites, users } from "@homarr/db/schema";
|
||||
import { db, eq, inArray, or } from "@homarr/db";
|
||||
import {
|
||||
apps,
|
||||
boards,
|
||||
boardUserPermissions,
|
||||
groupMembers,
|
||||
groups,
|
||||
integrations,
|
||||
invites,
|
||||
medias,
|
||||
searchEngines,
|
||||
users,
|
||||
} from "@homarr/db/schema";
|
||||
import type { TranslationObject } from "@homarr/translation";
|
||||
|
||||
import { createTRPCRouter, publicProcedure } from "../trpc";
|
||||
|
||||
interface HomeStatistic {
|
||||
titleKey: keyof TranslationObject["management"]["page"]["home"]["statistic"];
|
||||
subtitleKey: keyof TranslationObject["management"]["page"]["home"]["statisticLabel"];
|
||||
count: number;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export const homeRouter = createTRPCRouter({
|
||||
getStats: publicProcedure.query(async ({ ctx }) => {
|
||||
const isAdmin = ctx.session?.user.permissions.includes("admin") ?? false;
|
||||
const isCredentialsEnabled = isProviderEnabled("credentials");
|
||||
|
||||
return {
|
||||
countBoards: await getCountForTableAsync(ctx.db, boards, true),
|
||||
countUsers: await getCountForTableAsync(ctx.db, users, isAdmin),
|
||||
countGroups: await getCountForTableAsync(ctx.db, groups, true),
|
||||
countInvites: await getCountForTableAsync(ctx.db, invites, isAdmin),
|
||||
countIntegrations: await getCountForTableAsync(ctx.db, integrations, isCredentialsEnabled && isAdmin),
|
||||
countApps: await getCountForTableAsync(ctx.db, apps, true),
|
||||
};
|
||||
const statistics: HomeStatistic[] = [];
|
||||
|
||||
const boardIds: string[] = [];
|
||||
if (ctx.session?.user && !ctx.session.user.permissions.includes("board-view-all")) {
|
||||
const permissionsOfCurrentUserWhenPresent = await ctx.db.query.boardUserPermissions.findMany({
|
||||
where: eq(boardUserPermissions.userId, ctx.session.user.id),
|
||||
});
|
||||
|
||||
const permissionsOfCurrentUserGroupsWhenPresent = await ctx.db.query.groupMembers.findMany({
|
||||
where: eq(groupMembers.userId, ctx.session.user.id),
|
||||
with: {
|
||||
group: {
|
||||
with: {
|
||||
boardPermissions: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
boardIds.push(
|
||||
...permissionsOfCurrentUserWhenPresent
|
||||
.map((permission) => permission.boardId)
|
||||
.concat(
|
||||
permissionsOfCurrentUserGroupsWhenPresent
|
||||
.map((groupMember) => groupMember.group.boardPermissions.map((permission) => permission.boardId))
|
||||
.flat(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
statistics.push({
|
||||
titleKey: "board",
|
||||
subtitleKey: "boards",
|
||||
count: await db.$count(
|
||||
boards,
|
||||
ctx.session?.user.permissions.includes("board-view-all")
|
||||
? undefined
|
||||
: or(
|
||||
eq(boards.isPublic, true),
|
||||
eq(boards.creatorId, ctx.session?.user.id ?? ""),
|
||||
boardIds.length > 0 ? inArray(boards.id, boardIds) : undefined,
|
||||
),
|
||||
),
|
||||
path: "/manage/boards",
|
||||
});
|
||||
|
||||
if (isAdmin) {
|
||||
statistics.push({
|
||||
titleKey: "user",
|
||||
subtitleKey: "authentication",
|
||||
count: await db.$count(users),
|
||||
path: "/manage/users",
|
||||
});
|
||||
}
|
||||
|
||||
if (isAdmin && isCredentialsEnabled) {
|
||||
statistics.push({
|
||||
titleKey: "invite",
|
||||
subtitleKey: "authentication",
|
||||
count: await db.$count(invites),
|
||||
path: "/manage/users/invites",
|
||||
});
|
||||
}
|
||||
|
||||
if (ctx.session?.user.permissions.includes("integration-create")) {
|
||||
statistics.push({
|
||||
titleKey: "integration",
|
||||
subtitleKey: "resources",
|
||||
count: await db.$count(integrations),
|
||||
path: "/manage/integrations",
|
||||
});
|
||||
}
|
||||
|
||||
if (ctx.session?.user) {
|
||||
statistics.push({
|
||||
titleKey: "app",
|
||||
subtitleKey: "resources",
|
||||
count: await db.$count(apps),
|
||||
path: "/manage/apps",
|
||||
});
|
||||
}
|
||||
|
||||
if (isAdmin) {
|
||||
statistics.push({
|
||||
titleKey: "group",
|
||||
subtitleKey: "authorization",
|
||||
count: await db.$count(groups),
|
||||
path: "/manage/users/groups",
|
||||
});
|
||||
}
|
||||
|
||||
if (ctx.session?.user.permissions.includes("search-engine-create")) {
|
||||
statistics.push({
|
||||
titleKey: "searchEngine",
|
||||
subtitleKey: "resources",
|
||||
count: await db.$count(searchEngines),
|
||||
path: "/manage/search-engines",
|
||||
});
|
||||
}
|
||||
|
||||
if (ctx.session?.user.permissions.includes("media-upload")) {
|
||||
statistics.push({
|
||||
titleKey: "media",
|
||||
subtitleKey: "resources",
|
||||
count: await db.$count(
|
||||
medias,
|
||||
ctx.session.user.permissions.includes("media-view-all")
|
||||
? undefined
|
||||
: eq(medias.creatorId, ctx.session.user.id),
|
||||
),
|
||||
path: "/manage/medias",
|
||||
});
|
||||
}
|
||||
|
||||
return statistics;
|
||||
}),
|
||||
});
|
||||
|
||||
const getCountForTableAsync = async (db: Database, table: AnySQLiteTable, canView: boolean) => {
|
||||
if (!canView) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return await db.$count(table);
|
||||
};
|
||||
@@ -6,6 +6,7 @@ import { decryptSecret, encryptSecret } from "@homarr/common/server";
|
||||
import type { Database } from "@homarr/db";
|
||||
import { and, asc, eq, handleTransactionsAsync, inArray, like, or } from "@homarr/db";
|
||||
import {
|
||||
apps,
|
||||
groupMembers,
|
||||
groupPermissions,
|
||||
integrationGroupPermissions,
|
||||
@@ -212,6 +213,14 @@ export const integrationRouter = createTRPCRouter({
|
||||
updatedAt: true,
|
||||
},
|
||||
},
|
||||
app: {
|
||||
columns: {
|
||||
id: true,
|
||||
name: true,
|
||||
iconUrl: true,
|
||||
href: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -233,6 +242,7 @@ export const integrationRouter = createTRPCRouter({
|
||||
value: integrationSecretKindObject[secret.kind].isPublic ? decryptSecret(secret.value) : null,
|
||||
updatedAt: secret.updatedAt,
|
||||
})),
|
||||
app: integration.app,
|
||||
};
|
||||
}),
|
||||
create: permissionRequiredProcedure
|
||||
@@ -245,6 +255,13 @@ export const integrationRouter = createTRPCRouter({
|
||||
url: input.url,
|
||||
});
|
||||
|
||||
if (input.app && "name" in input.app && !ctx.session.user.permissions.includes("app-create")) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "Permission denied",
|
||||
});
|
||||
}
|
||||
|
||||
const result = await testConnectionAsync({
|
||||
id: "new",
|
||||
name: input.name,
|
||||
@@ -267,12 +284,15 @@ export const integrationRouter = createTRPCRouter({
|
||||
};
|
||||
}
|
||||
|
||||
const appId = await createAppIfNecessaryAsync(ctx.db, input.app);
|
||||
|
||||
const integrationId = createId();
|
||||
await ctx.db.insert(integrations).values({
|
||||
id: integrationId,
|
||||
name: input.name,
|
||||
url: input.url,
|
||||
kind: input.kind,
|
||||
appId,
|
||||
});
|
||||
|
||||
if (input.secrets.length >= 1) {
|
||||
@@ -358,6 +378,7 @@ export const integrationRouter = createTRPCRouter({
|
||||
.set({
|
||||
name: input.name,
|
||||
url: input.url,
|
||||
appId: input.appId,
|
||||
})
|
||||
.where(eq(integrations.id, input.id));
|
||||
|
||||
@@ -652,3 +673,30 @@ const addSecretAsync = async (db: Database, input: AddSecretInput) => {
|
||||
integrationId: input.integrationId,
|
||||
});
|
||||
};
|
||||
|
||||
const createAppIfNecessaryAsync = async (db: Database, app: z.infer<typeof integrationCreateSchema>["app"]) => {
|
||||
if (!app) return null;
|
||||
if ("id" in app) return app.id;
|
||||
|
||||
logger.info("Creating app", {
|
||||
name: app.name,
|
||||
url: app.href,
|
||||
});
|
||||
const appId = createId();
|
||||
await db.insert(apps).values({
|
||||
id: appId,
|
||||
name: app.name,
|
||||
description: app.description,
|
||||
iconUrl: app.iconUrl,
|
||||
href: app.href,
|
||||
pingUrl: app.pingUrl,
|
||||
});
|
||||
|
||||
logger.info("Created app", {
|
||||
id: appId,
|
||||
name: app.name,
|
||||
url: app.href,
|
||||
});
|
||||
|
||||
return appId;
|
||||
};
|
||||
@@ -5,7 +5,7 @@ import { getAllSecretKindOptions } from "@homarr/definitions";
|
||||
import { createIntegrationAsync } from "@homarr/integrations";
|
||||
import { logger } from "@homarr/log";
|
||||
|
||||
type FormIntegration = Integration & {
|
||||
type FormIntegration = Omit<Integration, "appId"> & {
|
||||
secrets: {
|
||||
kind: IntegrationSecretKind;
|
||||
value: string | null;
|
||||
@@ -75,6 +75,7 @@ export const testConnectionAsync = async (
|
||||
const integrationInstance = await createIntegrationAsync({
|
||||
...baseIntegration,
|
||||
decryptedSecrets,
|
||||
externalUrl: null,
|
||||
});
|
||||
|
||||
const result = await integrationInstance.testConnectionAsync();
|
||||
|
||||
@@ -4,7 +4,7 @@ import { describe, expect, test, vi } from "vitest";
|
||||
import type { Session } from "@homarr/auth";
|
||||
import { createId } from "@homarr/common";
|
||||
import { encryptSecret } from "@homarr/common/server";
|
||||
import { integrations, integrationSecrets } from "@homarr/db/schema";
|
||||
import { apps, integrations, integrationSecrets } from "@homarr/db/schema";
|
||||
import { createDb } from "@homarr/db/test";
|
||||
import type { GroupPermissionKey } from "@homarr/definitions";
|
||||
|
||||
@@ -251,6 +251,102 @@ describe("create should create a new integration", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("with create integration access should create a new integration with new linked app", async () => {
|
||||
const db = createDb();
|
||||
const caller = integrationRouter.createCaller({
|
||||
db,
|
||||
deviceType: undefined,
|
||||
session: defaultSessionWithPermissions(["integration-create", "app-create"]),
|
||||
});
|
||||
const input = {
|
||||
name: "Jellyfin",
|
||||
kind: "jellyfin" as const,
|
||||
url: "http://jellyfin.local",
|
||||
secrets: [{ kind: "apiKey" as const, value: "1234567890" }],
|
||||
attemptSearchEngineCreation: false,
|
||||
app: {
|
||||
name: "Jellyfin",
|
||||
description: null,
|
||||
pingUrl: "http://jellyfin.local",
|
||||
href: "https://jellyfin.home",
|
||||
iconUrl: "logo.png",
|
||||
},
|
||||
};
|
||||
|
||||
const fakeNow = new Date("2023-07-01T00:00:00Z");
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(fakeNow);
|
||||
await caller.create(input);
|
||||
vi.useRealTimers();
|
||||
|
||||
const dbIntegration = await db.query.integrations.findFirst({
|
||||
with: {
|
||||
app: true,
|
||||
},
|
||||
});
|
||||
const dbSecret = await db.query.integrationSecrets.findFirst();
|
||||
expect(dbIntegration).toBeDefined();
|
||||
expect(dbIntegration!.name).toBe(input.name);
|
||||
expect(dbIntegration!.kind).toBe(input.kind);
|
||||
expect(dbIntegration!.url).toBe(input.url);
|
||||
expect(dbIntegration!.app!.name).toBe(input.app.name);
|
||||
expect(dbIntegration!.app!.pingUrl).toBe(input.app.pingUrl);
|
||||
expect(dbIntegration!.app!.href).toBe(input.app.href);
|
||||
expect(dbIntegration!.app!.iconUrl).toBe(input.app.iconUrl);
|
||||
|
||||
expect(dbSecret!.integrationId).toBe(dbIntegration!.id);
|
||||
expect(dbSecret).toBeDefined();
|
||||
expect(dbSecret!.kind).toBe(input.secrets[0]!.kind);
|
||||
expect(dbSecret!.value).toMatch(/^[a-f0-9]+.[a-f0-9]+$/);
|
||||
expect(dbSecret!.updatedAt).toEqual(fakeNow);
|
||||
});
|
||||
|
||||
test("with create integration access should create a new integration with existing linked app", async () => {
|
||||
const db = createDb();
|
||||
const appId = createId();
|
||||
await db.insert(apps).values({
|
||||
id: appId,
|
||||
name: "Existing Jellyfin",
|
||||
iconUrl: "logo.png",
|
||||
});
|
||||
|
||||
const caller = integrationRouter.createCaller({
|
||||
db,
|
||||
deviceType: undefined,
|
||||
session: defaultSessionWithPermissions(["integration-create"]),
|
||||
});
|
||||
const input = {
|
||||
name: "Jellyfin",
|
||||
kind: "jellyfin" as const,
|
||||
url: "http://jellyfin.local",
|
||||
secrets: [{ kind: "apiKey" as const, value: "1234567890" }],
|
||||
attemptSearchEngineCreation: false,
|
||||
app: {
|
||||
id: appId,
|
||||
},
|
||||
};
|
||||
|
||||
const fakeNow = new Date("2023-07-01T00:00:00Z");
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(fakeNow);
|
||||
await caller.create(input);
|
||||
vi.useRealTimers();
|
||||
|
||||
const dbIntegration = await db.query.integrations.findFirst();
|
||||
const dbSecret = await db.query.integrationSecrets.findFirst();
|
||||
expect(dbIntegration).toBeDefined();
|
||||
expect(dbIntegration!.name).toBe(input.name);
|
||||
expect(dbIntegration!.kind).toBe(input.kind);
|
||||
expect(dbIntegration!.url).toBe(input.url);
|
||||
expect(dbIntegration!.appId).toBe(appId);
|
||||
|
||||
expect(dbSecret!.integrationId).toBe(dbIntegration!.id);
|
||||
expect(dbSecret).toBeDefined();
|
||||
expect(dbSecret!.kind).toBe(input.secrets[0]!.kind);
|
||||
expect(dbSecret!.value).toMatch(/^[a-f0-9]+.[a-f0-9]+$/);
|
||||
expect(dbSecret!.updatedAt).toEqual(fakeNow);
|
||||
});
|
||||
|
||||
test("without create integration access should throw permission error", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
@@ -273,6 +369,36 @@ describe("create should create a new integration", () => {
|
||||
// Assert
|
||||
await expect(actAsync()).rejects.toThrow("Permission denied");
|
||||
});
|
||||
|
||||
test("without create app access should throw permission error with new linked app", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const caller = integrationRouter.createCaller({
|
||||
db,
|
||||
deviceType: undefined,
|
||||
session: defaultSessionWithPermissions(["integration-create"]),
|
||||
});
|
||||
const input = {
|
||||
name: "Jellyfin",
|
||||
kind: "jellyfin" as const,
|
||||
url: "http://jellyfin.local",
|
||||
secrets: [{ kind: "apiKey" as const, value: "1234567890" }],
|
||||
attemptSearchEngineCreation: false,
|
||||
app: {
|
||||
name: "Jellyfin",
|
||||
description: null,
|
||||
href: "https://jellyfin.home",
|
||||
iconUrl: "logo.png",
|
||||
pingUrl: null,
|
||||
},
|
||||
};
|
||||
|
||||
// Act
|
||||
const actAsync = async () => await caller.create(input);
|
||||
|
||||
// Assert
|
||||
await expect(actAsync()).rejects.toThrow("Permission denied");
|
||||
});
|
||||
});
|
||||
|
||||
describe("update should update an integration", () => {
|
||||
@@ -285,6 +411,7 @@ describe("update should update an integration", () => {
|
||||
});
|
||||
|
||||
const lastWeek = new Date("2023-06-24T00:00:00Z");
|
||||
const appId = createId();
|
||||
const integrationId = createId();
|
||||
const toInsert = {
|
||||
id: integrationId,
|
||||
@@ -293,6 +420,11 @@ describe("update should update an integration", () => {
|
||||
url: "http://hole.local",
|
||||
};
|
||||
|
||||
await db.insert(apps).values({
|
||||
id: appId,
|
||||
name: "Previous",
|
||||
iconUrl: "logo.png",
|
||||
});
|
||||
await db.insert(integrations).values(toInsert);
|
||||
|
||||
const usernameToInsert = {
|
||||
@@ -320,6 +452,7 @@ describe("update should update an integration", () => {
|
||||
{ kind: "password" as const, value: null },
|
||||
{ kind: "apiKey" as const, value: "1234567890" },
|
||||
],
|
||||
appId,
|
||||
};
|
||||
|
||||
const fakeNow = new Date("2023-07-01T00:00:00Z");
|
||||
@@ -335,6 +468,7 @@ describe("update should update an integration", () => {
|
||||
expect(dbIntegration!.name).toBe(input.name);
|
||||
expect(dbIntegration!.kind).toBe(input.kind);
|
||||
expect(dbIntegration!.url).toBe(input.url);
|
||||
expect(dbIntegration!.appId).toBe(appId);
|
||||
|
||||
expect(dbSecrets.length).toBe(3);
|
||||
const username = expectToBeDefined(dbSecrets.find((secret) => secret.kind === "username"));
|
||||
@@ -365,6 +499,7 @@ describe("update should update an integration", () => {
|
||||
name: "Pi Hole",
|
||||
url: "http://hole.local",
|
||||
secrets: [],
|
||||
appId: null,
|
||||
});
|
||||
await expect(actAsync()).rejects.toThrow("Integration not found");
|
||||
});
|
||||
@@ -385,6 +520,7 @@ describe("update should update an integration", () => {
|
||||
name: "Pi Hole",
|
||||
url: "http://hole.local",
|
||||
secrets: [],
|
||||
appId: null,
|
||||
});
|
||||
|
||||
// Assert
|
||||
|
||||
@@ -55,6 +55,7 @@ describe("testConnectionAsync should run test connection of integration", () =>
|
||||
value: "secret",
|
||||
}),
|
||||
],
|
||||
externalUrl: null,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -104,6 +105,7 @@ describe("testConnectionAsync should run test connection of integration", () =>
|
||||
value: "dbSecret",
|
||||
}),
|
||||
],
|
||||
externalUrl: null,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -153,6 +155,7 @@ describe("testConnectionAsync should run test connection of integration", () =>
|
||||
value: "secret",
|
||||
}),
|
||||
],
|
||||
externalUrl: null,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -206,6 +209,7 @@ describe("testConnectionAsync should run test connection of integration", () =>
|
||||
value: "secret",
|
||||
}),
|
||||
],
|
||||
externalUrl: null,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -263,6 +267,7 @@ describe("testConnectionAsync should run test connection of integration", () =>
|
||||
value: "dbPassword",
|
||||
}),
|
||||
],
|
||||
externalUrl: null,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -336,6 +341,7 @@ describe("testConnectionAsync should run test connection of integration", () =>
|
||||
value: "privateKey",
|
||||
}),
|
||||
],
|
||||
externalUrl: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -114,11 +114,10 @@ export const userRouter = createTRPCRouter({
|
||||
.input(
|
||||
z.object({
|
||||
userId: z.string(),
|
||||
// Max image size of 256KB, only png and jpeg are allowed
|
||||
image: z
|
||||
.string()
|
||||
.regex(/^data:image\/(png|jpeg|gif|webp);base64,[A-Za-z0-9/+]+=*$/g)
|
||||
.max(262144)
|
||||
.max(350000) // approximately 256KB in base64 (256 * 1024 * 4 / 3 + prefixes)
|
||||
.nullable(),
|
||||
}),
|
||||
)
|
||||
@@ -147,13 +146,6 @@ export const userRouter = createTRPCRouter({
|
||||
});
|
||||
}
|
||||
|
||||
if (user.provider !== "credentials") {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "Profile image can not be changed for users with external providers",
|
||||
});
|
||||
}
|
||||
|
||||
await ctx.db
|
||||
.update(users)
|
||||
.set({
|
||||
|
||||
@@ -20,6 +20,7 @@ export const createSignInEventHandler = (db: Database): Exclude<NextAuthConfig["
|
||||
where: eq(users.id, user.id),
|
||||
columns: {
|
||||
name: true,
|
||||
image: true,
|
||||
colorScheme: true,
|
||||
},
|
||||
});
|
||||
@@ -59,6 +60,15 @@ export const createSignInEventHandler = (db: Database): Exclude<NextAuthConfig["
|
||||
`Username for user of oidc provider has changed. user=${user.id} old='${dbUser.name}' new='${profileUsername}'`,
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
typeof profile.picture === "string" &&
|
||||
dbUser.image !== profile.picture &&
|
||||
!dbUser.image?.startsWith("data:")
|
||||
) {
|
||||
await db.update(users).set({ image: profile.picture }).where(eq(users.id, user.id));
|
||||
logger.info(`Profile picture for user of oidc provider has changed. user=${user.id}'`);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`User '${dbUser.name}' logged in at ${dayjs().format()}`);
|
||||
|
||||
@@ -23,8 +23,8 @@
|
||||
},
|
||||
"prettier": "@homarr/prettier-config",
|
||||
"dependencies": {
|
||||
"@auth/core": "^0.40.0",
|
||||
"@auth/drizzle-adapter": "^1.10.0",
|
||||
"@auth/core": "^0.41.0",
|
||||
"@auth/drizzle-adapter": "^1.11.0",
|
||||
"@homarr/certificates": "workspace:^0.1.0",
|
||||
"@homarr/common": "workspace:^0.1.0",
|
||||
"@homarr/core": "workspace:^0.1.0",
|
||||
@@ -35,11 +35,11 @@
|
||||
"bcrypt": "^6.0.0",
|
||||
"cookies": "^0.9.1",
|
||||
"ldapts": "8.0.9",
|
||||
"next": "15.5.4",
|
||||
"next": "15.5.6",
|
||||
"next-auth": "5.0.0-beta.29",
|
||||
"react": "19.2.0",
|
||||
"react-dom": "19.2.0",
|
||||
"zod": "^4.1.11"
|
||||
"zod": "^4.1.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
@@ -47,7 +47,7 @@
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"@types/bcrypt": "6.0.0",
|
||||
"@types/cookies": "0.9.1",
|
||||
"eslint": "^9.37.0",
|
||||
"eslint": "^9.38.0",
|
||||
"prettier": "^3.6.2",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
|
||||
@@ -61,6 +61,7 @@ export const OidcProvider = (headers: ReadonlyHeaders | null): OIDCConfig<Profil
|
||||
id: profile.sub,
|
||||
name,
|
||||
email: profile.email,
|
||||
image: typeof profile.picture === "string" ? profile.picture : null,
|
||||
provider: "oidc",
|
||||
};
|
||||
},
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"eslint": "^9.37.0",
|
||||
"eslint": "^9.38.0",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
@@ -30,7 +30,7 @@
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"eslint": "^9.37.0",
|
||||
"eslint": "^9.38.0",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
@@ -34,8 +34,8 @@
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"esbuild": "^0.25.10",
|
||||
"eslint": "^9.37.0",
|
||||
"esbuild": "^0.25.11",
|
||||
"eslint": "^9.38.0",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
@@ -32,19 +32,19 @@
|
||||
"@paralleldrive/cuid2": "^2.2.2",
|
||||
"dayjs": "^1.11.18",
|
||||
"dns-caching": "^0.2.7",
|
||||
"next": "15.5.4",
|
||||
"octokit": "^5.0.3",
|
||||
"next": "15.5.6",
|
||||
"octokit": "^5.0.4",
|
||||
"react": "19.2.0",
|
||||
"react-dom": "19.2.0",
|
||||
"undici": "7.16.0",
|
||||
"zod": "^4.1.11",
|
||||
"zod": "^4.1.12",
|
||||
"zod-validation-error": "^4.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"eslint": "^9.37.0",
|
||||
"eslint": "^9.38.0",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
@@ -25,14 +25,14 @@
|
||||
"prettier": "@homarr/prettier-config",
|
||||
"dependencies": {
|
||||
"@t3-oss/env-nextjs": "^0.13.8",
|
||||
"ioredis": "5.8.0",
|
||||
"zod": "^4.1.11"
|
||||
"ioredis": "5.8.1",
|
||||
"zod": "^4.1.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"eslint": "^9.37.0",
|
||||
"eslint": "^9.38.0",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
@@ -29,21 +29,21 @@
|
||||
"@homarr/core": "workspace:^0.1.0",
|
||||
"@homarr/cron-jobs": "workspace:^0.1.0",
|
||||
"@homarr/log": "workspace:^0.1.0",
|
||||
"@tanstack/react-query": "^5.90.2",
|
||||
"@tanstack/react-query": "^5.90.5",
|
||||
"@trpc/client": "^11.6.0",
|
||||
"@trpc/server": "^11.6.0",
|
||||
"@trpc/tanstack-react-query": "^11.6.0",
|
||||
"node-cron": "^4.2.1",
|
||||
"react": "19.2.0",
|
||||
"zod": "^4.1.11"
|
||||
"zod": "^4.1.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"@types/node-cron": "^3.0.11",
|
||||
"@types/react": "19.2.0",
|
||||
"eslint": "^9.37.0",
|
||||
"@types/react": "19.2.2",
|
||||
"eslint": "^9.38.0",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
@@ -29,7 +29,7 @@
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"eslint": "^9.37.0",
|
||||
"eslint": "^9.38.0",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
@@ -33,7 +33,7 @@
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"@types/node-cron": "^3.0.11",
|
||||
"eslint": "^9.37.0",
|
||||
"eslint": "^9.38.0",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
@@ -44,7 +44,7 @@
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"eslint": "^9.37.0",
|
||||
"eslint": "^9.38.0",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE `integration` ADD `app_id` varchar(128);--> statement-breakpoint
|
||||
ALTER TABLE `integration` ADD CONSTRAINT `integration_app_id_app_id_fk` FOREIGN KEY (`app_id`) REFERENCES `app`(`id`) ON DELETE set null ON UPDATE no action;
|
||||
File diff suppressed because it is too large.
Load diff
@@ -253,6 +253,13 @@
|
||||
"when": 1756701556908,
|
||||
"tag": "0035_increase-secret-kind-length",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 36,
|
||||
"version": "5",
|
||||
"when": 1760968518445,
|
||||
"tag": "0036_add_app_reference_to_integration",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE "integration" ADD COLUMN "app_id" varchar(128);--> statement-breakpoint
|
||||
ALTER TABLE "integration" ADD CONSTRAINT "integration_app_id_app_id_fk" FOREIGN KEY ("app_id") REFERENCES "public"."app"("id") ON DELETE set null ON UPDATE no action;
|
||||
File diff suppressed because it is too large.
Load diff
@@ -15,6 +15,13 @@
|
||||
"when": 1756701573101,
|
||||
"tag": "0001_increase-secret-kind-length",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "7",
|
||||
"when": 1760968530084,
|
||||
"tag": "0002_add_app_reference_to_integration",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -150,6 +150,7 @@ const seedDefaultIntegrationsAsync = async (db: Database) => {
|
||||
name: `${name} Default`,
|
||||
url: defaultUrl,
|
||||
kind,
|
||||
appId: null,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE `integration` ADD `app_id` text REFERENCES app(id);
|
||||
File diff suppressed because it is too large.
Load diff
@@ -239,6 +239,13 @@
|
||||
"when": 1750014001941,
|
||||
"tag": "0033_add_cron_job_configuration",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 34,
|
||||
"version": "6",
|
||||
"when": 1760968503571,
|
||||
"tag": "0034_add_app_reference_to_integration",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -43,13 +43,13 @@
|
||||
},
|
||||
"prettier": "@homarr/prettier-config",
|
||||
"dependencies": {
|
||||
"@auth/core": "^0.40.0",
|
||||
"@auth/core": "^0.41.0",
|
||||
"@homarr/common": "workspace:^0.1.0",
|
||||
"@homarr/core": "workspace:^0.1.0",
|
||||
"@homarr/definitions": "workspace:^0.1.0",
|
||||
"@homarr/log": "workspace:^0.1.0",
|
||||
"@homarr/server-settings": "workspace:^0.1.0",
|
||||
"@mantine/core": "^8.3.3",
|
||||
"@mantine/core": "^8.3.5",
|
||||
"@paralleldrive/cuid2": "^2.2.2",
|
||||
"@testcontainers/mysql": "^11.7.1",
|
||||
"@testcontainers/postgresql": "^11.7.1",
|
||||
@@ -58,7 +58,7 @@
|
||||
"drizzle-kit": "^0.31.5",
|
||||
"drizzle-orm": "^0.44.6",
|
||||
"drizzle-zod": "^0.8.3",
|
||||
"mysql2": "3.15.1",
|
||||
"mysql2": "3.15.2",
|
||||
"pg": "^8.16.3",
|
||||
"superjson": "2.2.2"
|
||||
},
|
||||
@@ -69,8 +69,8 @@
|
||||
"@types/better-sqlite3": "7.6.13",
|
||||
"@types/pg": "^8.15.5",
|
||||
"dotenv-cli": "^10.0.0",
|
||||
"esbuild": "^0.25.10",
|
||||
"eslint": "^9.37.0",
|
||||
"esbuild": "^0.25.11",
|
||||
"eslint": "^9.38.0",
|
||||
"prettier": "^3.6.2",
|
||||
"tsx": "4.20.4",
|
||||
"typescript": "^5.9.3"
|
||||
|
||||
@@ -16,6 +16,7 @@ export const getItemsWithIntegrationsAsync = async <TKind extends WidgetKind>(
|
||||
with: {
|
||||
integration: {
|
||||
with: {
|
||||
app: true,
|
||||
secrets: {
|
||||
columns: {
|
||||
kind: true,
|
||||
|
||||
@@ -199,6 +199,7 @@ export const integrations = mysqlTable(
|
||||
name: text().notNull(),
|
||||
url: text().notNull(),
|
||||
kind: varchar({ length: 128 }).$type<IntegrationKind>().notNull(),
|
||||
appId: varchar({ length: 128 }).references(() => apps.id, { onDelete: "set null" }),
|
||||
},
|
||||
(integrations) => ({
|
||||
kindIdx: index("integration__kind_idx").on(integrations.kind),
|
||||
@@ -627,11 +628,15 @@ export const boardGroupPermissionRelations = relations(boardGroupPermissions, ({
|
||||
}),
|
||||
}));
|
||||
|
||||
export const integrationRelations = relations(integrations, ({ many }) => ({
|
||||
export const integrationRelations = relations(integrations, ({ one, many }) => ({
|
||||
secrets: many(integrationSecrets),
|
||||
items: many(integrationItems),
|
||||
userPermissions: many(integrationUserPermissions),
|
||||
groupPermissions: many(integrationGroupPermissions),
|
||||
app: one(apps, {
|
||||
fields: [integrations.appId],
|
||||
references: [apps.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const integrationUserPermissionRelations = relations(integrationUserPermissions, ({ one }) => ({
|
||||
|
||||
@@ -198,6 +198,7 @@ export const integrations = pgTable(
|
||||
name: text().notNull(),
|
||||
url: text().notNull(),
|
||||
kind: varchar({ length: 128 }).$type<IntegrationKind>().notNull(),
|
||||
appId: varchar({ length: 128 }).references(() => apps.id, { onDelete: "set null" }),
|
||||
},
|
||||
(integrations) => ({
|
||||
kindIdx: index("integration__kind_idx").on(integrations.kind),
|
||||
@@ -626,11 +627,15 @@ export const boardGroupPermissionRelations = relations(boardGroupPermissions, ({
|
||||
}),
|
||||
}));
|
||||
|
||||
export const integrationRelations = relations(integrations, ({ many }) => ({
|
||||
export const integrationRelations = relations(integrations, ({ one, many }) => ({
|
||||
secrets: many(integrationSecrets),
|
||||
items: many(integrationItems),
|
||||
userPermissions: many(integrationUserPermissions),
|
||||
groupPermissions: many(integrationGroupPermissions),
|
||||
app: one(apps, {
|
||||
fields: [integrations.appId],
|
||||
references: [apps.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const integrationUserPermissionRelations = relations(integrationUserPermissions, ({ one }) => ({
|
||||
|
||||
@@ -185,6 +185,7 @@ export const integrations = sqliteTable(
|
||||
name: text().notNull(),
|
||||
url: text().notNull(),
|
||||
kind: text().$type<IntegrationKind>().notNull(),
|
||||
appId: text().references(() => apps.id, { onDelete: "set null" }),
|
||||
},
|
||||
(integrations) => ({
|
||||
kindIdx: index("integration__kind_idx").on(integrations.kind),
|
||||
@@ -612,11 +613,15 @@ export const boardGroupPermissionRelations = relations(boardGroupPermissions, ({
|
||||
}),
|
||||
}));
|
||||
|
||||
export const integrationRelations = relations(integrations, ({ many }) => ({
|
||||
export const integrationRelations = relations(integrations, ({ one, many }) => ({
|
||||
secrets: many(integrationSecrets),
|
||||
items: many(integrationItems),
|
||||
userPermissions: many(integrationUserPermissions),
|
||||
groupPermissions: many(integrationGroupPermissions),
|
||||
app: one(apps, {
|
||||
fields: [integrations.appId],
|
||||
references: [apps.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const integrationUserPermissionRelations = relations(integrationUserPermissions, ({ one }) => ({
|
||||
|
||||
@@ -25,13 +25,13 @@
|
||||
"dependencies": {
|
||||
"@homarr/common": "workspace:^0.1.0",
|
||||
"fast-xml-parser": "^5.3.0",
|
||||
"zod": "^4.1.11"
|
||||
"zod": "^4.1.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"eslint": "^9.37.0",
|
||||
"eslint": "^9.38.0",
|
||||
"tsx": "4.20.4",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"@types/dockerode": "^3.3.44",
|
||||
"eslint": "^9.37.0",
|
||||
"eslint": "^9.38.0",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
@@ -26,15 +26,15 @@
|
||||
"@homarr/common": "workspace:^0.1.0",
|
||||
"@homarr/translation": "workspace:^0.1.0",
|
||||
"@homarr/validation": "workspace:^0.1.0",
|
||||
"@mantine/form": "^8.3.3",
|
||||
"@mantine/form": "^8.3.5",
|
||||
"mantine-form-zod-resolver": "^1.3.0",
|
||||
"zod": "^4.1.11"
|
||||
"zod": "^4.1.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"eslint": "^9.37.0",
|
||||
"eslint": "^9.38.0",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
@@ -29,15 +29,15 @@
|
||||
"@homarr/notifications": "workspace:^0.1.0",
|
||||
"@homarr/translation": "workspace:^0.1.0",
|
||||
"@homarr/validation": "workspace:^0.1.0",
|
||||
"@mantine/core": "^8.3.3",
|
||||
"@mantine/core": "^8.3.5",
|
||||
"react": "19.2.0",
|
||||
"zod": "^4.1.11"
|
||||
"zod": "^4.1.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"eslint": "^9.37.0",
|
||||
"eslint": "^9.38.0",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
@@ -31,7 +31,7 @@
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"eslint": "^9.37.0",
|
||||
"eslint": "^9.38.0",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
@@ -33,7 +33,7 @@
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"@types/bcrypt": "6.0.0",
|
||||
"eslint": "^9.37.0",
|
||||
"eslint": "^9.38.0",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,7 @@
|
||||
"prettier": "@homarr/prettier-config",
|
||||
"dependencies": {
|
||||
"@ctrl/deluge": "^7.4.0",
|
||||
"@ctrl/qbittorrent": "^9.9.0",
|
||||
"@ctrl/qbittorrent": "^9.10.0",
|
||||
"@ctrl/transmission": "^7.4.0",
|
||||
"@gitbeaker/rest": "^43.5.0",
|
||||
"@homarr/certificates": "workspace:^0.1.0",
|
||||
@@ -43,13 +43,13 @@
|
||||
"@octokit/auth-app": "^8.1.1",
|
||||
"ical.js": "^2.2.1",
|
||||
"maria2": "^0.4.1",
|
||||
"node-ical": "^0.21.0",
|
||||
"octokit": "^5.0.3",
|
||||
"node-ical": "^0.22.0",
|
||||
"octokit": "^5.0.4",
|
||||
"proxmox-api": "1.1.1",
|
||||
"tsdav": "^2.1.5",
|
||||
"undici": "7.16.0",
|
||||
"xml2js": "^0.6.2",
|
||||
"zod": "^4.1.11"
|
||||
"zod": "^4.1.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
@@ -57,7 +57,7 @@
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"@types/node-unifi": "^2.5.1",
|
||||
"@types/xml2js": "^0.4.14",
|
||||
"eslint": "^9.37.0",
|
||||
"eslint": "^9.38.0",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,4 @@
|
||||
import { decryptSecret } from "@homarr/common/server";
|
||||
import type { Modify } from "@homarr/common/types";
|
||||
import type { Integration as DbIntegration } from "@homarr/db/schema";
|
||||
import type { IntegrationKind, IntegrationSecretKind } from "@homarr/definitions";
|
||||
import type { IntegrationKind } from "@homarr/definitions";
|
||||
|
||||
import { AdGuardHomeIntegration } from "../adguard-home/adguard-home-integration";
|
||||
import { CodebergIntegration } from "../codeberg/codeberg-integration";
|
||||
@@ -62,20 +59,6 @@ export const createIntegrationAsync = async <TKind extends keyof typeof integrat
|
||||
return new creator(integration) as IntegrationInstanceOfKind<TKind>;
|
||||
};
|
||||
|
||||
export const createIntegrationAsyncFromSecrets = <TKind extends keyof typeof integrationCreators>(
|
||||
integration: Modify<DbIntegration, { kind: TKind }> & {
|
||||
secrets: { kind: IntegrationSecretKind; value: `${string}.${string}` }[];
|
||||
},
|
||||
) => {
|
||||
return createIntegrationAsync({
|
||||
...integration,
|
||||
decryptedSecrets: integration.secrets.map((secret) => ({
|
||||
...secret,
|
||||
value: decryptSecret(secret.value),
|
||||
})),
|
||||
});
|
||||
};
|
||||
|
||||
type IntegrationInstance = new (integration: IntegrationInput) => Integration;
|
||||
|
||||
// factories are an array, to differentiate in js between class constructors and functions
|
||||
|
||||
@@ -17,6 +17,7 @@ export interface IntegrationInput {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
externalUrl: string | null;
|
||||
decryptedSecrets: IntegrationSecret[];
|
||||
}
|
||||
|
||||
@@ -54,8 +55,12 @@ export abstract class Integration {
|
||||
return this.integration.decryptedSecrets.some((secret) => secret.kind === kind);
|
||||
}
|
||||
|
||||
protected url(path: `/${string}`, queryParams?: Record<string, string | Date | number | boolean>) {
|
||||
const baseUrl = removeTrailingSlash(this.integration.url);
|
||||
private createUrl(
|
||||
inputUrl: string,
|
||||
path: `/${string}`,
|
||||
queryParams?: Record<string, string | Date | number | boolean>,
|
||||
) {
|
||||
const baseUrl = removeTrailingSlash(inputUrl);
|
||||
const url = new URL(`${baseUrl}${path}`);
|
||||
|
||||
if (queryParams) {
|
||||
@@ -66,6 +71,13 @@ export abstract class Integration {
|
||||
|
||||
return url;
|
||||
}
|
||||
protected url(path: `/${string}`, queryParams?: Record<string, string | Date | number | boolean>) {
|
||||
return this.createUrl(this.integration.url, path, queryParams);
|
||||
}
|
||||
|
||||
protected externalUrl(path: `/${string}`, queryParams?: Record<string, string | Date | number | boolean>) {
|
||||
return this.createUrl(this.integration.externalUrl ?? this.integration.url, path, queryParams);
|
||||
}
|
||||
|
||||
public async testConnectionAsync(): Promise<TestingResult> {
|
||||
try {
|
||||
|
||||
@@ -125,7 +125,7 @@ export class EmbyIntegration extends Integration implements IMediaServerIntegrat
|
||||
sessionId: `${sessionInfo.Id}`,
|
||||
sessionName: `${sessionInfo.Client} (${sessionInfo.DeviceName})`,
|
||||
user: {
|
||||
profilePictureUrl: super.url(`/Users/${sessionInfo.UserId}/Images/Primary`).toString(),
|
||||
profilePictureUrl: super.externalUrl(`/Users/${sessionInfo.UserId}/Images/Primary`).toString(),
|
||||
userId: sessionInfo.UserId ?? "",
|
||||
username: sessionInfo.UserName ?? "",
|
||||
},
|
||||
@@ -169,13 +169,13 @@ export class EmbyIntegration extends Integration implements IMediaServerIntegrat
|
||||
description: item.Overview,
|
||||
releaseDate: item.PremiereDate ?? item.DateCreated,
|
||||
imageUrls: {
|
||||
poster: super.url(`/Items/${item.Id}/Images/Primary?maxHeight=492&maxWidth=328&quality=90`).toString(),
|
||||
backdrop: super.url(`/Items/${item.Id}/Images/Backdrop/0?maxWidth=960&quality=70`).toString(),
|
||||
poster: super.externalUrl(`/Items/${item.Id}/Images/Primary?maxHeight=492&maxWidth=328&quality=90`).toString(),
|
||||
backdrop: super.externalUrl(`/Items/${item.Id}/Images/Backdrop/0?maxWidth=960&quality=70`).toString(),
|
||||
},
|
||||
producer: item.Studios.at(0)?.Name,
|
||||
rating: item.CommunityRating?.toFixed(1),
|
||||
tags: item.Genres,
|
||||
href: super.url(`/web/index.html#!/item?id=${item.Id}&serverId=${item.ServerId}`).toString(),
|
||||
href: super.externalUrl(`/web/index.html#!/item?id=${item.Id}&serverId=${item.ServerId}`).toString(),
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -54,4 +54,4 @@ export type { Notification } from "./interfaces/notifications/notification-types
|
||||
export { downloadClientItemSchema } from "./interfaces/downloads/download-client-items";
|
||||
|
||||
// Helpers
|
||||
export { createIntegrationAsync, createIntegrationAsyncFromSecrets } from "./base/creator";
|
||||
export { createIntegrationAsync } from "./base/creator";
|
||||
@@ -94,7 +94,7 @@ export class JellyfinIntegration extends Integration implements IMediaServerInte
|
||||
sessionId: `${sessionInfo.Id}`,
|
||||
sessionName: `${sessionInfo.Client} (${sessionInfo.DeviceName})`,
|
||||
user: {
|
||||
profilePictureUrl: this.url(`/Users/${sessionInfo.UserId}/Images/Primary`).toString(),
|
||||
profilePictureUrl: this.externalUrl(`/Users/${sessionInfo.UserId}/Images/Primary`).toString(),
|
||||
userId: sessionInfo.UserId ?? "",
|
||||
username: sessionInfo.UserName ?? "",
|
||||
},
|
||||
@@ -130,13 +130,13 @@ export class JellyfinIntegration extends Integration implements IMediaServerInte
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
releaseDate: new Date(item.PremiereDate ?? item.DateCreated!),
|
||||
imageUrls: {
|
||||
poster: super.url(`/Items/${item.Id}/Images/Primary?maxHeight=492&maxWidth=328&quality=90`).toString(),
|
||||
backdrop: super.url(`/Items/${item.Id}/Images/Backdrop/0?maxWidth=960&quality=70`).toString(),
|
||||
poster: super.externalUrl(`/Items/${item.Id}/Images/Primary?maxHeight=492&maxWidth=328&quality=90`).toString(),
|
||||
backdrop: super.externalUrl(`/Items/${item.Id}/Images/Backdrop/0?maxWidth=960&quality=70`).toString(),
|
||||
},
|
||||
producer: item.Studios?.at(0)?.Name ?? undefined,
|
||||
rating: item.CommunityRating?.toFixed(1),
|
||||
tags: item.Genres ?? [],
|
||||
href: super.url(`/web/index.html#!/details?id=${item.Id}&serverId=${item.ServerId}`).toString(),
|
||||
href: super.externalUrl(`/web/index.html#!/details?id=${item.Id}&serverId=${item.ServerId}`).toString(),
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -67,7 +67,7 @@ export class RadarrIntegration extends Integration implements ICalendarIntegrati
|
||||
private getLinksForRadarrCalendarEvent = (event: z.infer<typeof radarrCalendarEventSchema>) => {
|
||||
const links: CalendarLink[] = [
|
||||
{
|
||||
href: this.url(`/movie/${event.titleSlug}`).toString(),
|
||||
href: this.externalUrl(`/movie/${event.titleSlug}`).toString(),
|
||||
name: "Radarr",
|
||||
logo: "/images/apps/radarr.svg",
|
||||
color: undefined,
|
||||
|
||||
@@ -74,7 +74,7 @@ export class ReadarrIntegration extends Integration implements ICalendarIntegrat
|
||||
private getLinksForReadarrCalendarEvent = (event: z.infer<typeof readarrCalendarEventSchema>) => {
|
||||
return [
|
||||
{
|
||||
href: this.url(`/author/${event.author.foreignAuthorId}`).toString(),
|
||||
href: this.externalUrl(`/author/${event.author.foreignAuthorId}`).toString(),
|
||||
color: "#f5c518",
|
||||
isDark: false,
|
||||
logo: "/images/apps/readarr.svg",
|
||||
@@ -101,7 +101,7 @@ export class ReadarrIntegration extends Integration implements ICalendarIntegrat
|
||||
if (!bestImage) {
|
||||
return undefined;
|
||||
}
|
||||
return this.url(bestImage.url as `/${string}`).toString();
|
||||
return this.externalUrl(bestImage.url as `/${string}`).toString();
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -63,7 +63,7 @@ export class SonarrIntegration extends Integration implements ICalendarIntegrati
|
||||
private getLinksForSonarrCalendarEvent = (event: z.infer<typeof sonarrCalendarEventSchema>) => {
|
||||
const links: CalendarLink[] = [
|
||||
{
|
||||
href: this.url(`/series/${event.series.titleSlug}`).toString(),
|
||||
href: this.externalUrl(`/series/${event.series.titleSlug}`).toString(),
|
||||
name: "Sonarr",
|
||||
logo: "/images/apps/sonarr.svg",
|
||||
color: undefined,
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import type { Agent } from "https";
|
||||
import dayjs from "dayjs";
|
||||
import timezone from "dayjs/plugin/timezone";
|
||||
import utc from "dayjs/plugin/utc";
|
||||
import type { RequestInit as NodeFetchRequestInit } from "node-fetch";
|
||||
import * as ical from "node-ical";
|
||||
import { DAVClient } from "tsdav";
|
||||
@@ -14,6 +17,9 @@ import type { TestingResult } from "../base/test-connection/test-connection-serv
|
||||
import type { ICalendarIntegration } from "../interfaces/calendar/calendar-integration";
|
||||
import type { CalendarEvent } from "../interfaces/calendar/calendar-types";
|
||||
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(timezone);
|
||||
|
||||
@HandleIntegrationErrors([integrationTsdavHttpErrorHandler])
|
||||
export class NextcloudIntegration extends Integration implements ICalendarIntegration {
|
||||
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
|
||||
@@ -41,47 +47,59 @@ export class NextcloudIntegration extends Integration implements ICalendarIntegr
|
||||
)
|
||||
).flat();
|
||||
|
||||
return calendarEvents.map((event): CalendarEvent => {
|
||||
// @ts-expect-error the typescript definitions for this package are wrong
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-call,@typescript-eslint/no-unsafe-member-access
|
||||
const icalData = ical.default.parseICS(event.data) as ical.CalendarResponse;
|
||||
const veventObject = Object.values(icalData).find((data) => data.type === "VEVENT");
|
||||
return calendarEvents
|
||||
.map((event) => {
|
||||
// @ts-expect-error the typescript definitions for this package are wrong
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-call,@typescript-eslint/no-unsafe-member-access
|
||||
const icalData = ical.default.parseICS(event.data) as ical.CalendarResponse;
|
||||
const veventObject = Object.values(icalData).find((data) => data.type === "VEVENT");
|
||||
|
||||
if (!veventObject) {
|
||||
throw new Error(`Invalid event data object: ${JSON.stringify(event.data)}. Unable to process the calendar.`);
|
||||
}
|
||||
if (!veventObject) {
|
||||
throw new Error(`Invalid event data object: ${JSON.stringify(event.data)}. Unable to process the calendar.`);
|
||||
}
|
||||
|
||||
logger.debug(`Converting VEVENT event to ${event.etag} from Nextcloud: ${JSON.stringify(veventObject)}`);
|
||||
logger.debug(`Converting VEVENT event to ${event.etag} from Nextcloud: ${JSON.stringify(veventObject)}`);
|
||||
|
||||
const date = veventObject.start;
|
||||
const eventUrlWithoutHost = new URL(event.url).pathname;
|
||||
const eventSlug = Buffer.from(eventUrlWithoutHost).toString("base64url");
|
||||
|
||||
const eventUrlWithoutHost = new URL(event.url).pathname;
|
||||
const dateInMillis = veventObject.start.valueOf();
|
||||
const startDates = veventObject.rrule ? veventObject.rrule.between(start, end) : [veventObject.start];
|
||||
|
||||
const url = this.url(
|
||||
`/apps/calendar/timeGridWeek/now/edit/sidebar/${Buffer.from(eventUrlWithoutHost).toString("base64url")}/${dateInMillis / 1000}`,
|
||||
);
|
||||
const durationMs = veventObject.end.getTime() - veventObject.start.getTime();
|
||||
|
||||
return {
|
||||
title: veventObject.summary,
|
||||
subTitle: null,
|
||||
description: veventObject.description,
|
||||
startDate: date,
|
||||
endDate: veventObject.end,
|
||||
image: null,
|
||||
location: veventObject.location || null,
|
||||
indicatorColor: "#ff8600",
|
||||
links: [
|
||||
{
|
||||
href: url.toString(),
|
||||
name: "Nextcloud",
|
||||
logo: "/images/apps/nextcloud.svg",
|
||||
color: undefined,
|
||||
isDark: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
return startDates.map((startDate) => {
|
||||
const timezoneOffsetMinutes = veventObject.rrule?.origOptions.tzid
|
||||
? dayjs(startDate).tz(veventObject.rrule.origOptions.tzid).utcOffset()
|
||||
: 0;
|
||||
const utcStartDate = new Date(startDate.getTime() - timezoneOffsetMinutes * 60 * 1000);
|
||||
const endDate = new Date(utcStartDate.getTime() + durationMs);
|
||||
const dateInMillis = utcStartDate.valueOf();
|
||||
|
||||
return {
|
||||
title: veventObject.summary,
|
||||
subTitle: null,
|
||||
description: veventObject.description,
|
||||
startDate: utcStartDate,
|
||||
endDate,
|
||||
image: null,
|
||||
location: veventObject.location || null,
|
||||
indicatorColor:
|
||||
"color" in veventObject && typeof veventObject.color === "string" ? veventObject.color : "#ff8600",
|
||||
links: [
|
||||
{
|
||||
href: this.externalUrl(
|
||||
`/apps/calendar/timeGridWeek/now/edit/sidebar/${eventSlug}/${dateInMillis / 1000}`,
|
||||
).toString(),
|
||||
name: "Nextcloud",
|
||||
logo: "/images/apps/nextcloud.svg",
|
||||
color: undefined,
|
||||
isDark: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
})
|
||||
.flat();
|
||||
}
|
||||
|
||||
private async createCalendarClientAsync(agent?: Agent) {
|
||||
|
||||
@@ -54,7 +54,7 @@ export class NTFYIntegration extends Integration implements INotificationsIntegr
|
||||
return notifications
|
||||
.filter((notification) => notification !== null)
|
||||
.map((notification): Notification => {
|
||||
const topicURL = this.url(`/${notification.topic}`);
|
||||
const topicURL = this.externalUrl(`/${notification.topic}`);
|
||||
return {
|
||||
id: notification.id,
|
||||
time: new Date(notification.time * 1000),
|
||||
|
||||
@@ -43,7 +43,7 @@ export class OverseerrIntegration
|
||||
return schemaData.results.map((result) => ({
|
||||
id: result.id,
|
||||
name: "name" in result ? result.name : result.title,
|
||||
link: this.url(`/${result.mediaType}/${result.id}`).toString(),
|
||||
link: this.externalUrl(`/${result.mediaType}/${result.id}`).toString(),
|
||||
image: constructSearchResultImage(result),
|
||||
text: "overview" in result ? result.overview : undefined,
|
||||
type: result.mediaType,
|
||||
@@ -144,7 +144,7 @@ export class OverseerrIntegration
|
||||
availability: request.media.status,
|
||||
backdropImageUrl: `https://image.tmdb.org/t/p/original/${information.backdropPath}`,
|
||||
posterImagePath: `https://image.tmdb.org/t/p/w600_and_h900_bestv2/${information.posterPath}`,
|
||||
href: this.url(`/${request.type}/${request.media.tmdbId}`).toString(),
|
||||
href: this.externalUrl(`/${request.type}/${request.media.tmdbId}`).toString(),
|
||||
type: request.type,
|
||||
createdAt: request.createdAt,
|
||||
airDate: new Date(information.airDate),
|
||||
@@ -152,7 +152,7 @@ export class OverseerrIntegration
|
||||
? ({
|
||||
...request.requestedBy,
|
||||
displayName: request.requestedBy.displayName,
|
||||
link: this.url(`/users/${request.requestedBy.id}`).toString(),
|
||||
link: this.externalUrl(`/users/${request.requestedBy.id}`).toString(),
|
||||
avatar: this.constructAvatarUrl(request.requestedBy.avatar).toString(),
|
||||
} satisfies Omit<RequestUser, "requestCount">)
|
||||
: undefined,
|
||||
@@ -180,7 +180,7 @@ export class OverseerrIntegration
|
||||
return users.map((user): RequestUser => {
|
||||
return {
|
||||
...user,
|
||||
link: this.url(`/users/${user.id}`).toString(),
|
||||
link: this.externalUrl(`/users/${user.id}`).toString(),
|
||||
avatar: this.constructAvatarUrl(user.avatar).toString(),
|
||||
};
|
||||
});
|
||||
@@ -255,7 +255,7 @@ export class OverseerrIntegration
|
||||
return avatar;
|
||||
}
|
||||
|
||||
return this.url(`/${avatar}`);
|
||||
return this.externalUrl(`/${avatar}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -142,7 +142,10 @@ export class PlexIntegration extends Integration implements IMediaServerIntegrat
|
||||
rating: item.rating?.toFixed(1),
|
||||
tags: item.Genre?.map((genre) => genre.tag) ?? [],
|
||||
href: super
|
||||
.url(`/web/index.html#!/server/${machineIdentifier}/details?key=${encodeURIComponent(item.key)}`)
|
||||
.url(
|
||||
// Url with children on the end results in infinite loading screen, see https://github.com/homarr-labs/homarr/issues/4078
|
||||
`/web/index.html#!/server/${machineIdentifier}/details?key=${encodeURIComponent(item.key.replace("/children", ""))}`,
|
||||
)
|
||||
.toString(),
|
||||
length: item.duration ? Math.round(item.duration / 1000) : undefined,
|
||||
};
|
||||
|
||||
@@ -135,6 +135,7 @@ const createAria2Intergration = (container: StartedTestContainer, apikey: string
|
||||
],
|
||||
name: "Aria2",
|
||||
url: `http://${container.getHost()}:${container.getMappedPort(8080)}`,
|
||||
externalUrl: null,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -43,6 +43,7 @@ class FakeIntegration extends Integration {
|
||||
name: "Test",
|
||||
url: "https://example.com",
|
||||
decryptedSecrets: [],
|
||||
externalUrl: null,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -97,5 +97,6 @@ const createHomeAssistantIntegration = (container: StartedTestContainer, apiKeyO
|
||||
],
|
||||
name: "Home assistant",
|
||||
url: `http://${container.getHost()}:${container.getMappedPort(8123)}`,
|
||||
externalUrl: null,
|
||||
});
|
||||
};
|
||||
@@ -177,6 +177,7 @@ const createNzbGetIntegration = (container: StartedTestContainer, username: stri
|
||||
],
|
||||
name: "NzbGet",
|
||||
url: `http://${container.getHost()}:${container.getMappedPort(6789)}`,
|
||||
externalUrl: null,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -207,6 +207,7 @@ const createPiHoleIntegrationV5 = (container: StartedTestContainer, apiKey: stri
|
||||
],
|
||||
name: "Pi hole",
|
||||
url: `http://${container.getHost()}:${container.getMappedPort(80)}`,
|
||||
externalUrl: null,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -233,5 +234,6 @@ const createPiHoleIntegrationV6 = (container: StartedTestContainer, apiKey: stri
|
||||
],
|
||||
name: "Pi hole",
|
||||
url: `http://${container.getHost()}:${container.getMappedPort(80)}`,
|
||||
externalUrl: null,
|
||||
});
|
||||
};
|
||||
@@ -218,6 +218,7 @@ const createSabnzbdIntegration = (container: StartedTestContainer, apiKey: strin
|
||||
],
|
||||
name: "Sabnzbd",
|
||||
url: `http://${container.getHost()}:${container.getMappedPort(1212)}`,
|
||||
externalUrl: null,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -27,13 +27,13 @@
|
||||
"@homarr/core": "workspace:^0.1.0",
|
||||
"superjson": "2.2.2",
|
||||
"winston": "3.18.3",
|
||||
"zod": "^4.1.11"
|
||||
"zod": "^4.1.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"eslint": "^9.37.0",
|
||||
"eslint": "^9.38.0",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
@@ -33,19 +33,19 @@
|
||||
"@homarr/translation": "workspace:^0.1.0",
|
||||
"@homarr/ui": "workspace:^0.1.0",
|
||||
"@homarr/validation": "workspace:^0.1.0",
|
||||
"@mantine/core": "^8.3.3",
|
||||
"@mantine/core": "^8.3.5",
|
||||
"@tabler/icons-react": "^3.35.0",
|
||||
"dayjs": "^1.11.18",
|
||||
"next": "15.5.4",
|
||||
"next": "15.5.6",
|
||||
"react": "19.2.0",
|
||||
"react-dom": "19.2.0",
|
||||
"zod": "^4.1.11"
|
||||
"zod": "^4.1.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"eslint": "^9.37.0",
|
||||
"eslint": "^9.38.0",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { useMemo, useState } from "react";
|
||||
import { Button, Card, Center, Grid, Input, Stack, Text } from "@mantine/core";
|
||||
import { IconPlus, IconSearch } from "@tabler/icons-react";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { createModal, useModalAction } from "@homarr/modals";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
@@ -9,7 +10,8 @@ import { useI18n } from "@homarr/translation/client";
|
||||
import { QuickAddAppModal } from "./quick-add-app/quick-add-app-modal";
|
||||
|
||||
interface AppSelectModalProps {
|
||||
onSelect?: (appId: string) => void;
|
||||
onSelect?: (app: RouterOutputs["app"]["selectable"][number]) => void;
|
||||
withCreate: boolean;
|
||||
}
|
||||
|
||||
export const AppSelectModal = createModal<AppSelectModalProps>(({ actions, innerProps }) => {
|
||||
@@ -26,18 +28,18 @@ export const AppSelectModal = createModal<AppSelectModalProps>(({ actions, inner
|
||||
[apps, search],
|
||||
);
|
||||
|
||||
const handleSelect = (appId: string) => {
|
||||
const handleSelect = (app: RouterOutputs["app"]["selectable"][number]) => {
|
||||
if (innerProps.onSelect) {
|
||||
innerProps.onSelect(appId);
|
||||
innerProps.onSelect(app);
|
||||
}
|
||||
actions.closeModal();
|
||||
};
|
||||
|
||||
const handleAddNewApp = () => {
|
||||
openQuickAddAppModal({
|
||||
onClose(createdAppId) {
|
||||
onClose(app) {
|
||||
if (innerProps.onSelect) {
|
||||
innerProps.onSelect(createdAppId);
|
||||
innerProps.onSelect(app);
|
||||
}
|
||||
actions.closeModal();
|
||||
},
|
||||
@@ -54,32 +56,34 @@ export const AppSelectModal = createModal<AppSelectModalProps>(({ actions, inner
|
||||
data-autofocus
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" && filteredApps.length === 1 && filteredApps[0]) {
|
||||
handleSelect(filteredApps[0].id);
|
||||
handleSelect(filteredApps[0]);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<Grid>
|
||||
<Grid.Col span={{ xs: 12, sm: 4, md: 3 }}>
|
||||
<Card h="100%">
|
||||
<Stack justify="space-between" h="100%">
|
||||
<Stack gap="xs">
|
||||
<Center>
|
||||
<IconPlus size={24} />
|
||||
</Center>
|
||||
<Text lh={1.2} style={{ whiteSpace: "normal" }} ta="center">
|
||||
{t("app.action.create.title")}
|
||||
</Text>
|
||||
<Text lh={1.2} style={{ whiteSpace: "normal" }} size="xs" ta="center" c="dimmed">
|
||||
{t("app.action.create.description")}
|
||||
</Text>
|
||||
{innerProps.withCreate && (
|
||||
<Grid.Col span={{ xs: 12, sm: 4, md: 3 }}>
|
||||
<Card h="100%">
|
||||
<Stack justify="space-between" h="100%">
|
||||
<Stack gap="xs">
|
||||
<Center>
|
||||
<IconPlus size={24} />
|
||||
</Center>
|
||||
<Text lh={1.2} style={{ whiteSpace: "normal" }} ta="center">
|
||||
{t("app.action.create.title")}
|
||||
</Text>
|
||||
<Text lh={1.2} style={{ whiteSpace: "normal" }} size="xs" ta="center" c="dimmed">
|
||||
{t("app.action.create.description")}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Button onClick={handleAddNewApp} variant="light" size="xs" mt="auto" radius="md" fullWidth>
|
||||
{t("app.action.create.action")}
|
||||
</Button>
|
||||
</Stack>
|
||||
<Button onClick={handleAddNewApp} variant="light" size="xs" mt="auto" radius="md" fullWidth>
|
||||
{t("app.action.create.action")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Grid.Col>
|
||||
</Card>
|
||||
</Grid.Col>
|
||||
)}
|
||||
|
||||
{filteredApps.map((app) => (
|
||||
<Grid.Col key={app.id} span={{ xs: 12, sm: 4, md: 3 }}>
|
||||
@@ -96,7 +100,7 @@ export const AppSelectModal = createModal<AppSelectModalProps>(({ actions, inner
|
||||
{app.description ?? ""}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Button onClick={() => handleSelect(app.id)} variant="light" size="xs" mt="auto" radius="md" fullWidth>
|
||||
<Button onClick={() => handleSelect(app)} variant="light" size="xs" mt="auto" radius="md" fullWidth>
|
||||
{t("app.action.select.action", { app: app.name })}
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { z } from "zod/v4";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import type { MaybePromise } from "@homarr/common/types";
|
||||
import { AppForm } from "@homarr/forms-collection";
|
||||
@@ -9,7 +10,7 @@ import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
import type { appManageSchema } from "@homarr/validation/app";
|
||||
|
||||
interface QuickAddAppModalProps {
|
||||
onClose: (createdAppId: string) => MaybePromise<void>;
|
||||
onClose: (createdApp: Omit<RouterOutputs["app"]["create"], "appId">) => MaybePromise<void>;
|
||||
}
|
||||
|
||||
export const QuickAddAppModal = createModal<QuickAddAppModalProps>(({ actions, innerProps }) => {
|
||||
@@ -27,13 +28,13 @@ export const QuickAddAppModal = createModal<QuickAddAppModalProps>(({ actions, i
|
||||
|
||||
const handleSubmit = (values: z.infer<typeof appManageSchema>) => {
|
||||
mutate(values, {
|
||||
async onSuccess({ appId }) {
|
||||
async onSuccess(app) {
|
||||
showSuccessNotification({
|
||||
title: tScoped("success.title"),
|
||||
message: tScoped("success.message"),
|
||||
});
|
||||
|
||||
await innerProps.onClose(appId);
|
||||
await innerProps.onClose(app);
|
||||
actions.closeModal();
|
||||
},
|
||||
});
|
||||
|
||||
@@ -24,15 +24,15 @@
|
||||
"dependencies": {
|
||||
"@homarr/translation": "workspace:^0.1.0",
|
||||
"@homarr/ui": "workspace:^0.1.0",
|
||||
"@mantine/core": "^8.3.3",
|
||||
"@mantine/hooks": "^8.3.3",
|
||||
"@mantine/core": "^8.3.5",
|
||||
"@mantine/hooks": "^8.3.5",
|
||||
"react": "19.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"eslint": "^9.37.0",
|
||||
"eslint": "^9.38.0",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
@@ -24,14 +24,14 @@
|
||||
"prettier": "@homarr/prettier-config",
|
||||
"dependencies": {
|
||||
"@homarr/ui": "workspace:^0.1.0",
|
||||
"@mantine/notifications": "^8.3.3",
|
||||
"@mantine/notifications": "^8.3.5",
|
||||
"@tabler/icons-react": "^3.35.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"eslint": "^9.37.0",
|
||||
"eslint": "^9.38.0",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
@@ -37,14 +37,14 @@
|
||||
"@homarr/translation": "workspace:^0.1.0",
|
||||
"@homarr/ui": "workspace:^0.1.0",
|
||||
"@homarr/validation": "workspace:^0.1.0",
|
||||
"@mantine/core": "^8.3.3",
|
||||
"@mantine/hooks": "^8.3.3",
|
||||
"@mantine/core": "^8.3.5",
|
||||
"@mantine/hooks": "^8.3.5",
|
||||
"adm-zip": "0.5.16",
|
||||
"next": "15.5.4",
|
||||
"next": "15.5.6",
|
||||
"react": "19.2.0",
|
||||
"react-dom": "19.2.0",
|
||||
"superjson": "2.2.2",
|
||||
"zod": "^4.1.11",
|
||||
"zod": "^4.1.12",
|
||||
"zod-form-data": "^3.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -52,7 +52,7 @@
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"@types/adm-zip": "0.5.7",
|
||||
"eslint": "^9.37.0",
|
||||
"eslint": "^9.38.0",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
@@ -23,13 +23,13 @@
|
||||
"prettier": "@homarr/prettier-config",
|
||||
"dependencies": {
|
||||
"@homarr/common": "workspace:^0.1.0",
|
||||
"zod": "^4.1.11"
|
||||
"zod": "^4.1.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"eslint": "^9.37.0",
|
||||
"eslint": "^9.38.0",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
@@ -30,7 +30,7 @@
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"eslint": "^9.37.0",
|
||||
"eslint": "^9.38.0",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
@@ -27,14 +27,14 @@
|
||||
"@homarr/db": "workspace:^",
|
||||
"@homarr/definitions": "workspace:^",
|
||||
"@homarr/log": "workspace:^",
|
||||
"ioredis": "5.8.0",
|
||||
"ioredis": "5.8.1",
|
||||
"superjson": "2.2.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"eslint": "^9.37.0",
|
||||
"eslint": "^9.38.0",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
@@ -30,16 +30,16 @@
|
||||
"@homarr/log": "workspace:^0.1.0",
|
||||
"@homarr/redis": "workspace:^0.1.0",
|
||||
"dayjs": "^1.11.18",
|
||||
"octokit": "^5.0.3",
|
||||
"octokit": "^5.0.4",
|
||||
"superjson": "2.2.2",
|
||||
"undici": "7.16.0",
|
||||
"zod": "^4.1.11"
|
||||
"zod": "^4.1.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"eslint": "^9.37.0",
|
||||
"eslint": "^9.38.0",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
@@ -47,7 +47,7 @@ async function getContainersWithStatsAsync() {
|
||||
const instance = dockerInstances.find(({ host }) => host === container.instance)?.instance;
|
||||
if (!instance) return null;
|
||||
|
||||
const stats = await instance.getContainer(container.Id).stats({ stream: false });
|
||||
const stats = await instance.getContainer(container.Id).stats({ stream: false, "one-shot": true });
|
||||
|
||||
return {
|
||||
id: container.Id,
|
||||
@@ -60,7 +60,18 @@ async function getContainersWithStatsAsync() {
|
||||
return icon.name.toLowerCase().includes(extractedImage.toLowerCase());
|
||||
})?.url ?? null,
|
||||
cpuUsage: calculateCpuUsage(stats),
|
||||
memoryUsage: stats.memory_stats.usage,
|
||||
// memory usage by default includes cache, which should not be shown as it is also not shown with docker stats command
|
||||
// The below type is probably wrong, sometimes stats can be undefined
|
||||
// See https://docs.docker.com/reference/cli/docker/container/stats/ how it is / was calculated
|
||||
memoryUsage:
|
||||
stats.memory_stats.usage -
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
(stats.memory_stats.stats?.cache ??
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
stats.memory_stats.stats?.total_inactive_file ??
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
stats.memory_stats.stats?.inactive_file ??
|
||||
0),
|
||||
image: container.Image,
|
||||
ports: container.Ports,
|
||||
};
|
||||
@@ -70,11 +81,10 @@ async function getContainersWithStatsAsync() {
|
||||
}
|
||||
|
||||
function calculateCpuUsage(stats: ContainerStats): number {
|
||||
const cpuDelta = stats.cpu_stats.cpu_usage.total_usage - stats.precpu_stats.cpu_usage.total_usage;
|
||||
const systemDelta = stats.cpu_stats.system_cpu_usage - stats.precpu_stats.system_cpu_usage;
|
||||
const numberOfCpus = stats.cpu_stats.online_cpus || 1;
|
||||
|
||||
if (systemDelta === 0) return 0;
|
||||
const usage = stats.cpu_stats.system_cpu_usage;
|
||||
if (usage === 0) return 0;
|
||||
|
||||
return (cpuDelta / systemDelta) * numberOfCpus * 100;
|
||||
return (stats.cpu_stats.cpu_usage.total_usage / usage) * numberOfCpus * 100;
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { createCachedRequestHandler } from "./cached-request-handler";
|
||||
type IntegrationOfKind<TKind extends IntegrationKind> = Omit<Integration, "kind"> & {
|
||||
kind: TKind;
|
||||
decryptedSecrets: Modify<Pick<IntegrationSecret, "kind" | "value">, { value: string }>[];
|
||||
externalUrl: string | null;
|
||||
};
|
||||
|
||||
interface Options<TData, TKind extends IntegrationKind, TInput extends Record<string, unknown>> {
|
||||
|
||||
@@ -96,6 +96,7 @@ export const createRequestIntegrationJobHandler = <
|
||||
...integration,
|
||||
kind: integration.kind as TIntegrationKind,
|
||||
decryptedSecrets,
|
||||
externalUrl: integration.app?.href ?? null,
|
||||
},
|
||||
input,
|
||||
);
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"eslint": "^9.37.0",
|
||||
"eslint": "^9.38.0",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
@@ -26,8 +26,8 @@
|
||||
"@homarr/api": "workspace:^0.1.0",
|
||||
"@homarr/db": "workspace:^0.1.0",
|
||||
"@homarr/server-settings": "workspace:^0.1.0",
|
||||
"@mantine/dates": "^8.3.3",
|
||||
"next": "15.5.4",
|
||||
"@mantine/dates": "^8.3.5",
|
||||
"next": "15.5.6",
|
||||
"react": "19.2.0",
|
||||
"react-dom": "19.2.0"
|
||||
},
|
||||
@@ -35,7 +35,7 @@
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"eslint": "^9.37.0",
|
||||
"eslint": "^9.38.0",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
@@ -33,12 +33,12 @@
|
||||
"@homarr/settings": "workspace:^0.1.0",
|
||||
"@homarr/translation": "workspace:^0.1.0",
|
||||
"@homarr/ui": "workspace:^0.1.0",
|
||||
"@mantine/core": "^8.3.3",
|
||||
"@mantine/hooks": "^8.3.3",
|
||||
"@mantine/spotlight": "^8.3.3",
|
||||
"@mantine/core": "^8.3.5",
|
||||
"@mantine/hooks": "^8.3.5",
|
||||
"@mantine/spotlight": "^8.3.5",
|
||||
"@tabler/icons-react": "^3.35.0",
|
||||
"jotai": "^2.15.0",
|
||||
"next": "15.5.4",
|
||||
"next": "15.5.6",
|
||||
"react": "19.2.0",
|
||||
"react-dom": "19.2.0",
|
||||
"use-deep-compare-effect": "^1.8.1"
|
||||
@@ -47,7 +47,7 @@
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"eslint": "^9.37.0",
|
||||
"eslint": "^9.38.0",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
@@ -32,8 +32,8 @@
|
||||
"dayjs": "^1.11.18",
|
||||
"deepmerge": "4.3.1",
|
||||
"mantine-react-table": "2.0.0-beta.9",
|
||||
"next": "15.5.4",
|
||||
"next-intl": "4.3.9",
|
||||
"next": "15.5.6",
|
||||
"next-intl": "4.3.12",
|
||||
"react": "19.2.0",
|
||||
"react-dom": "19.2.0"
|
||||
},
|
||||
@@ -41,7 +41,7 @@
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"eslint": "^9.37.0",
|
||||
"eslint": "^9.38.0",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
@@ -1145,6 +1145,12 @@
|
||||
"groupNameTaken": ""
|
||||
}
|
||||
}
|
||||
},
|
||||
"unit": {
|
||||
"speed": {
|
||||
"kilometersPerHour": "",
|
||||
"milesPerHour": ""
|
||||
}
|
||||
}
|
||||
},
|
||||
"section": {
|
||||
@@ -1157,6 +1163,9 @@
|
||||
"title": {
|
||||
"label": ""
|
||||
},
|
||||
"customCssClasses": {
|
||||
"label": ""
|
||||
},
|
||||
"borderColor": {
|
||||
"label": ""
|
||||
}
|
||||
@@ -1744,6 +1753,9 @@
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"useImperialSpeed": {
|
||||
"label": ""
|
||||
},
|
||||
"location": {
|
||||
"label": ""
|
||||
},
|
||||
@@ -2974,13 +2986,19 @@
|
||||
"invite": "",
|
||||
"integration": "",
|
||||
"app": "",
|
||||
"group": ""
|
||||
"group": "",
|
||||
"searchEngine": "",
|
||||
"media": ""
|
||||
},
|
||||
"statisticLabel": {
|
||||
"boards": "",
|
||||
"resources": "",
|
||||
"authentication": "",
|
||||
"authorization": ""
|
||||
},
|
||||
"heroBanner": {
|
||||
"title": "",
|
||||
"subtitle": ""
|
||||
}
|
||||
},
|
||||
"board": {
|
||||
|
||||
@@ -947,27 +947,27 @@
|
||||
},
|
||||
"url": {
|
||||
"label": "网址",
|
||||
"newLabel": "新网址"
|
||||
"newLabel": "新建网址"
|
||||
},
|
||||
"opnsenseApiKey": {
|
||||
"label": "API 密钥 (Key)",
|
||||
"newLabel": "新 API 密钥 (Key)"
|
||||
"label": "API 密钥 (密钥)",
|
||||
"newLabel": "新建API密钥(密钥)"
|
||||
},
|
||||
"opnsenseApiSecret": {
|
||||
"label": "API 密钥 (保密)",
|
||||
"newLabel": "新 API 密钥 (保密)"
|
||||
"label": "API 密钥 (机密)",
|
||||
"newLabel": "新建API密钥(机密)"
|
||||
},
|
||||
"githubAppId": {
|
||||
"label": "应用ID",
|
||||
"newLabel": "新应用ID"
|
||||
"label": "应用 ID",
|
||||
"newLabel": "新应用 ID"
|
||||
},
|
||||
"githubInstallationId": {
|
||||
"label": "安装 ID",
|
||||
"newLabel": "新安装 ID"
|
||||
"label": "安装 Id",
|
||||
"newLabel": "新建安装ID"
|
||||
},
|
||||
"privateKey": {
|
||||
"label": "私人密钥",
|
||||
"newLabel": "新私人密钥"
|
||||
"label": "私钥",
|
||||
"newLabel": "新建私钥"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -1145,6 +1145,12 @@
|
||||
"groupNameTaken": "用户组名称已存在"
|
||||
}
|
||||
}
|
||||
},
|
||||
"unit": {
|
||||
"speed": {
|
||||
"kilometersPerHour": "千米/时",
|
||||
"milesPerHour": "英里/时"
|
||||
}
|
||||
}
|
||||
},
|
||||
"section": {
|
||||
@@ -1157,6 +1163,9 @@
|
||||
"title": {
|
||||
"label": "标题"
|
||||
},
|
||||
"customCssClasses": {
|
||||
"label": "自定义 CSS 类"
|
||||
},
|
||||
"borderColor": {
|
||||
"label": "边界颜色"
|
||||
}
|
||||
@@ -1286,20 +1295,20 @@
|
||||
"label": "启用状态检查"
|
||||
},
|
||||
"layout": {
|
||||
"label": "界面",
|
||||
"label": "布局",
|
||||
"option": {
|
||||
"row": "横向",
|
||||
"row-reverse": "横向 (反转)",
|
||||
"row": "水平",
|
||||
"row-reverse": "水平(反向)",
|
||||
"column": "垂直",
|
||||
"column-reverse": "垂直 (反转)"
|
||||
"column-reverse": "垂直(反向)"
|
||||
}
|
||||
},
|
||||
"descriptionDisplayMode": {
|
||||
"label": "描述信息显示模式",
|
||||
"label": "描述显示模式",
|
||||
"description": "选择应用描述的显示方式",
|
||||
"option": {
|
||||
"normal": "窗口组件内",
|
||||
"tooltip": "显示为悬停提示",
|
||||
"tooltip": "在工具提示中显示",
|
||||
"hidden": "隐藏"
|
||||
}
|
||||
}
|
||||
@@ -1549,8 +1558,8 @@
|
||||
},
|
||||
"placeholder": "开始写笔记",
|
||||
"dismiss": {
|
||||
"title": "是否放弃修改?",
|
||||
"message": "仍有未保存的更改,确定要丢弃吗?",
|
||||
"title": "放弃更改?",
|
||||
"message": "您的笔记中有未保存的更改。确定要放弃这些更改吗?",
|
||||
"action": {
|
||||
"discard": "放弃修改",
|
||||
"keepEditing": "继续编辑"
|
||||
@@ -1708,7 +1717,7 @@
|
||||
"name": "日历",
|
||||
"description": "在日历视图中显示某个相对时间段内的集成事件",
|
||||
"duration": {
|
||||
"allDay": ""
|
||||
"allDay": "全天"
|
||||
},
|
||||
"option": {
|
||||
"releaseType": {
|
||||
@@ -1744,6 +1753,9 @@
|
||||
"label": "显示当前风速",
|
||||
"description": "仅在当前天气时"
|
||||
},
|
||||
"useImperialSpeed": {
|
||||
"label": "风速使用英里/时"
|
||||
},
|
||||
"location": {
|
||||
"label": "天气位置"
|
||||
},
|
||||
@@ -1762,12 +1774,12 @@
|
||||
"description": "日期应该是什么样的"
|
||||
}
|
||||
},
|
||||
"currentWindSpeed": "{currentWindSpeed} km/h",
|
||||
"currentWindSpeed": "{currentWindSpeed} {unit}",
|
||||
"dailyForecast": {
|
||||
"sunrise": "日出",
|
||||
"sunset": "日落",
|
||||
"maxWindSpeed": "最大风速:{maxWindSpeed} km/h",
|
||||
"maxWindGusts": "最大阵风:{maxWindGusts} km/h"
|
||||
"maxWindSpeed": "最大风速:{maxWindSpeed} {unit}",
|
||||
"maxWindGusts": "最大阵风:{maxWindGusts} {unit}"
|
||||
},
|
||||
"kind": {
|
||||
"clear": "晴朗",
|
||||
@@ -1982,21 +1994,21 @@
|
||||
"name": "名称",
|
||||
"id": "ID",
|
||||
"metadata": {
|
||||
"title": "",
|
||||
"title": "技术统计",
|
||||
"video": {
|
||||
"title": "",
|
||||
"resolution": ""
|
||||
"title": "视频",
|
||||
"resolution": "分辨率"
|
||||
},
|
||||
"audio": {
|
||||
"title": "",
|
||||
"channelCount": "",
|
||||
"codec": ""
|
||||
"title": "音频",
|
||||
"channelCount": "音频声道",
|
||||
"codec": "音频编码"
|
||||
},
|
||||
"transcoding": {
|
||||
"title": "",
|
||||
"container": "",
|
||||
"resolution": "",
|
||||
"target": ""
|
||||
"title": "转码",
|
||||
"container": "容器",
|
||||
"resolution": "分辨率",
|
||||
"target": "目标编解码器"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2522,36 +2534,36 @@
|
||||
},
|
||||
"systemResources": {
|
||||
"name": "系统资源",
|
||||
"description": "",
|
||||
"description": "您系统的 CPU、内存、磁盘及其他硬件使用情况",
|
||||
"option": {
|
||||
"hasShadow": {
|
||||
"label": ""
|
||||
"label": "启用图表阴影"
|
||||
},
|
||||
"visibleCharts": {
|
||||
"label": "",
|
||||
"description": "",
|
||||
"label": "可见图表",
|
||||
"description": "选择您希望显示的图表。",
|
||||
"option": {
|
||||
"cpu": "",
|
||||
"memory": "",
|
||||
"network": ""
|
||||
"cpu": "CPU",
|
||||
"memory": "内存",
|
||||
"network": "网络"
|
||||
}
|
||||
},
|
||||
"labelDisplayMode": {
|
||||
"label": "",
|
||||
"label": "标签显示模式",
|
||||
"option": {
|
||||
"textWithIcon": "",
|
||||
"text": "",
|
||||
"icon": "",
|
||||
"hidden": ""
|
||||
"textWithIcon": "显示图标和文本",
|
||||
"text": "仅显示文本",
|
||||
"icon": "仅显示图标",
|
||||
"hidden": "隐藏标签"
|
||||
}
|
||||
}
|
||||
},
|
||||
"card": {
|
||||
"cpu": "CPU",
|
||||
"memory": "",
|
||||
"network": "",
|
||||
"up": "",
|
||||
"down": ""
|
||||
"memory": "内存",
|
||||
"network": "网络",
|
||||
"up": "上传",
|
||||
"down": "下载"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -2974,13 +2986,19 @@
|
||||
"invite": "邀请",
|
||||
"integration": "集成",
|
||||
"app": "应用",
|
||||
"group": "群组"
|
||||
"group": "群组",
|
||||
"searchEngine": "搜索引擎",
|
||||
"media": "媒体"
|
||||
},
|
||||
"statisticLabel": {
|
||||
"boards": "面板",
|
||||
"resources": "资源",
|
||||
"authentication": "认证",
|
||||
"authorization": "认证"
|
||||
},
|
||||
"heroBanner": {
|
||||
"title": "欢迎回到您的",
|
||||
"subtitle": "{app} 面板"
|
||||
}
|
||||
},
|
||||
"board": {
|
||||
@@ -3338,7 +3356,7 @@
|
||||
"label": "防火墙接口"
|
||||
},
|
||||
"weather": {
|
||||
"label": ""
|
||||
"label": "天气"
|
||||
}
|
||||
},
|
||||
"interval": {
|
||||
@@ -3349,10 +3367,10 @@
|
||||
"weeklyMonday": "每周一",
|
||||
"update": {
|
||||
"success": {
|
||||
"message": ""
|
||||
"message": "间隔已成功更新"
|
||||
},
|
||||
"error": {
|
||||
"message": ""
|
||||
"message": "更新间隔失败"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -3367,49 +3385,49 @@
|
||||
"label": "计划时间间隔"
|
||||
},
|
||||
"lastExecution": {
|
||||
"label": ""
|
||||
"label": "上次执行"
|
||||
},
|
||||
"actions": {
|
||||
"label": ""
|
||||
"label": "操作"
|
||||
}
|
||||
},
|
||||
"table": {
|
||||
"search": ""
|
||||
"search": "在 {count} 个任务中搜索……"
|
||||
},
|
||||
"action": {
|
||||
"refresh": {
|
||||
"label": ""
|
||||
"label": "刷新"
|
||||
}
|
||||
},
|
||||
"refresh": {
|
||||
"success": {
|
||||
"message": ""
|
||||
"message": "任务已成功刷新"
|
||||
},
|
||||
"error": {
|
||||
"message": ""
|
||||
"message": "刷新任务失败"
|
||||
}
|
||||
},
|
||||
"trigger": {
|
||||
"success": {
|
||||
"message": ""
|
||||
"message": "任务已成功触发"
|
||||
},
|
||||
"error": {
|
||||
"message": ""
|
||||
"message": "触发任务失败"
|
||||
}
|
||||
},
|
||||
"enable": {
|
||||
"success": {
|
||||
"message": ""
|
||||
"message": "任务已成功启用"
|
||||
}
|
||||
},
|
||||
"disable": {
|
||||
"success": {
|
||||
"message": ""
|
||||
"message": "任务已成功禁用"
|
||||
}
|
||||
},
|
||||
"toggle": {
|
||||
"error": {
|
||||
"message": ""
|
||||
"message": "切换任务状态失败"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -3466,19 +3484,19 @@
|
||||
"subtitle": "{count} 用于 Homarr 代码中"
|
||||
},
|
||||
"hotkeys": {
|
||||
"title": "",
|
||||
"subtitle": "",
|
||||
"title": "快捷键",
|
||||
"subtitle": "用于增强工作流的键盘快捷键",
|
||||
"field": {
|
||||
"shortcut": "",
|
||||
"action": ""
|
||||
"shortcut": "快捷键",
|
||||
"action": "操作"
|
||||
},
|
||||
"action": {
|
||||
"toggleBoardEdit": "",
|
||||
"toggleColorScheme": "",
|
||||
"saveNotebook": "",
|
||||
"openSpotlight": ""
|
||||
"toggleBoardEdit": "切换面板编辑模式",
|
||||
"toggleColorScheme": "切换 亮色/暗色 模式",
|
||||
"saveNotebook": "保存笔记(仅在笔记小工具内有效)",
|
||||
"openSpotlight": "打开搜索"
|
||||
},
|
||||
"note": ""
|
||||
"note": "提示:Mod 键在 macOS 上指 Ctrl 键和 ⌘ 键"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1145,6 +1145,12 @@
|
||||
"groupNameTaken": "crwdns2366:0crwdne2366:0"
|
||||
}
|
||||
}
|
||||
},
|
||||
"unit": {
|
||||
"speed": {
|
||||
"kilometersPerHour": "crwdns3764:0crwdne3764:0",
|
||||
"milesPerHour": "crwdns3766:0crwdne3766:0"
|
||||
}
|
||||
}
|
||||
},
|
||||
"section": {
|
||||
@@ -1157,6 +1163,9 @@
|
||||
"title": {
|
||||
"label": "crwdns3103:0crwdne3103:0"
|
||||
},
|
||||
"customCssClasses": {
|
||||
"label": "crwdns3776:0crwdne3776:0"
|
||||
},
|
||||
"borderColor": {
|
||||
"label": "crwdns2839:0crwdne2839:0"
|
||||
}
|
||||
@@ -1744,6 +1753,9 @@
|
||||
"label": "crwdns2706:0crwdne2706:0",
|
||||
"description": "crwdns2708:0crwdne2708:0"
|
||||
},
|
||||
"useImperialSpeed": {
|
||||
"label": "crwdns3768:0crwdne3768:0"
|
||||
},
|
||||
"location": {
|
||||
"label": "crwdns1018:0crwdne1018:0"
|
||||
},
|
||||
@@ -1762,12 +1774,12 @@
|
||||
"description": "crwdns1030:0crwdne1030:0"
|
||||
}
|
||||
},
|
||||
"currentWindSpeed": "crwdns2710:0{currentWindSpeed}crwdne2710:0",
|
||||
"currentWindSpeed": "crwdns3770:0{currentWindSpeed}crwdnd3770:0{unit}crwdne3770:0",
|
||||
"dailyForecast": {
|
||||
"sunrise": "crwdns2712:0crwdne2712:0",
|
||||
"sunset": "crwdns2714:0crwdne2714:0",
|
||||
"maxWindSpeed": "crwdns2716:0{maxWindSpeed}crwdne2716:0",
|
||||
"maxWindGusts": "crwdns2718:0{maxWindGusts}crwdne2718:0"
|
||||
"maxWindSpeed": "crwdns3772:0{maxWindSpeed}crwdnd3772:0{unit}crwdne3772:0",
|
||||
"maxWindGusts": "crwdns3774:0{maxWindGusts}crwdnd3774:0{unit}crwdne3774:0"
|
||||
},
|
||||
"kind": {
|
||||
"clear": "crwdns1032:0crwdne1032:0",
|
||||
@@ -2974,13 +2986,19 @@
|
||||
"invite": "crwdns1620:0crwdne1620:0",
|
||||
"integration": "crwdns1622:0crwdne1622:0",
|
||||
"app": "crwdns1624:0crwdne1624:0",
|
||||
"group": "crwdns1626:0crwdne1626:0"
|
||||
"group": "crwdns1626:0crwdne1626:0",
|
||||
"searchEngine": "crwdns3778:0crwdne3778:0",
|
||||
"media": "crwdns3780:0crwdne3780:0"
|
||||
},
|
||||
"statisticLabel": {
|
||||
"boards": "crwdns1628:0crwdne1628:0",
|
||||
"resources": "crwdns1630:0crwdne1630:0",
|
||||
"authentication": "crwdns1632:0crwdne1632:0",
|
||||
"authorization": "crwdns1634:0crwdne1634:0"
|
||||
},
|
||||
"heroBanner": {
|
||||
"title": "crwdns3782:0crwdne3782:0",
|
||||
"subtitle": "crwdns3784:0{app}crwdne3784:0"
|
||||
}
|
||||
},
|
||||
"board": {
|
||||
|
||||
@@ -1145,6 +1145,12 @@
|
||||
"groupNameTaken": "Název skupiny je již používán"
|
||||
}
|
||||
}
|
||||
},
|
||||
"unit": {
|
||||
"speed": {
|
||||
"kilometersPerHour": "",
|
||||
"milesPerHour": ""
|
||||
}
|
||||
}
|
||||
},
|
||||
"section": {
|
||||
@@ -1157,6 +1163,9 @@
|
||||
"title": {
|
||||
"label": ""
|
||||
},
|
||||
"customCssClasses": {
|
||||
"label": ""
|
||||
},
|
||||
"borderColor": {
|
||||
"label": ""
|
||||
}
|
||||
@@ -1744,6 +1753,9 @@
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"useImperialSpeed": {
|
||||
"label": ""
|
||||
},
|
||||
"location": {
|
||||
"label": "Lokalita pro počasí"
|
||||
},
|
||||
@@ -2974,13 +2986,19 @@
|
||||
"invite": "Pozvánky",
|
||||
"integration": "",
|
||||
"app": "Aplikace",
|
||||
"group": "Skupiny"
|
||||
"group": "Skupiny",
|
||||
"searchEngine": "",
|
||||
"media": ""
|
||||
},
|
||||
"statisticLabel": {
|
||||
"boards": "Plochy",
|
||||
"resources": "",
|
||||
"authentication": "",
|
||||
"authorization": ""
|
||||
},
|
||||
"heroBanner": {
|
||||
"title": "",
|
||||
"subtitle": ""
|
||||
}
|
||||
},
|
||||
"board": {
|
||||
|
||||
@@ -1145,6 +1145,12 @@
|
||||
"groupNameTaken": "Gruppenavn allerede taget"
|
||||
}
|
||||
}
|
||||
},
|
||||
"unit": {
|
||||
"speed": {
|
||||
"kilometersPerHour": "km/t",
|
||||
"milesPerHour": "mi/t"
|
||||
}
|
||||
}
|
||||
},
|
||||
"section": {
|
||||
@@ -1157,6 +1163,9 @@
|
||||
"title": {
|
||||
"label": "Titel"
|
||||
},
|
||||
"customCssClasses": {
|
||||
"label": "Brugerdefinerede css-klasser"
|
||||
},
|
||||
"borderColor": {
|
||||
"label": "Kantfarve"
|
||||
}
|
||||
@@ -1708,7 +1717,7 @@
|
||||
"name": "Kalender",
|
||||
"description": "Vis begivenheder fra dine integrationer i en kalendervisning inden for en vis relativ tidsperiode",
|
||||
"duration": {
|
||||
"allDay": ""
|
||||
"allDay": "Hele dagen"
|
||||
},
|
||||
"option": {
|
||||
"releaseType": {
|
||||
@@ -1744,6 +1753,9 @@
|
||||
"label": "Vis aktuel vindhastighed",
|
||||
"description": "Kun på nuværende vejr"
|
||||
},
|
||||
"useImperialSpeed": {
|
||||
"label": "Brug mi/t til vindhastighed"
|
||||
},
|
||||
"location": {
|
||||
"label": "Vejr lokation"
|
||||
},
|
||||
@@ -1762,12 +1774,12 @@
|
||||
"description": "Hvordan datoen skal se ud"
|
||||
}
|
||||
},
|
||||
"currentWindSpeed": "{currentWindSpeed} km/t",
|
||||
"currentWindSpeed": "{currentWindSpeed} {unit}",
|
||||
"dailyForecast": {
|
||||
"sunrise": "Solopgang",
|
||||
"sunset": "Solnedgang",
|
||||
"maxWindSpeed": "Maks. vindhastighed: {maxWindSpeed} km/t",
|
||||
"maxWindGusts": "Maks. vindstød {maxWindGusts} km/t"
|
||||
"maxWindSpeed": "Maks. vindhastighed: {maxWindSpeed} {unit}",
|
||||
"maxWindGusts": "Maks. vindstød {maxWindGusts} {unit}"
|
||||
},
|
||||
"kind": {
|
||||
"clear": "Skyfrit",
|
||||
@@ -1982,21 +1994,21 @@
|
||||
"name": "Navn",
|
||||
"id": "Id",
|
||||
"metadata": {
|
||||
"title": "",
|
||||
"title": "Statistik for nørder",
|
||||
"video": {
|
||||
"title": "",
|
||||
"resolution": ""
|
||||
"title": "Video",
|
||||
"resolution": "Opløsning"
|
||||
},
|
||||
"audio": {
|
||||
"title": "",
|
||||
"channelCount": "",
|
||||
"codec": ""
|
||||
"title": "Lyd",
|
||||
"channelCount": "Lyd kanaler",
|
||||
"codec": "Lyd codec"
|
||||
},
|
||||
"transcoding": {
|
||||
"title": "",
|
||||
"container": "",
|
||||
"resolution": "",
|
||||
"target": ""
|
||||
"title": "Transkoder",
|
||||
"container": "Container",
|
||||
"resolution": "Opløsning",
|
||||
"target": "Mål codec"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2974,13 +2986,19 @@
|
||||
"invite": "Invitationer",
|
||||
"integration": "Integrationer",
|
||||
"app": "Apps",
|
||||
"group": "Grupper"
|
||||
"group": "Grupper",
|
||||
"searchEngine": "Søgemaskiner",
|
||||
"media": "Medier"
|
||||
},
|
||||
"statisticLabel": {
|
||||
"boards": "Tavler",
|
||||
"resources": "Ressourcer",
|
||||
"authentication": "Autorisering",
|
||||
"authorization": "Autorisation"
|
||||
},
|
||||
"heroBanner": {
|
||||
"title": "Velkommen tilbage til din",
|
||||
"subtitle": "{app} Tavle"
|
||||
}
|
||||
},
|
||||
"board": {
|
||||
@@ -3338,7 +3356,7 @@
|
||||
"label": "Firewall Grænseflader"
|
||||
},
|
||||
"weather": {
|
||||
"label": ""
|
||||
"label": "Vejr"
|
||||
}
|
||||
},
|
||||
"interval": {
|
||||
@@ -3466,19 +3484,19 @@
|
||||
"subtitle": "{count} brugt i koden for Homarr"
|
||||
},
|
||||
"hotkeys": {
|
||||
"title": "",
|
||||
"subtitle": "",
|
||||
"title": "Genvejstaster",
|
||||
"subtitle": "Tastaturgenveje til at forbedre din arbejdsgang",
|
||||
"field": {
|
||||
"shortcut": "",
|
||||
"action": ""
|
||||
"shortcut": "Genvej",
|
||||
"action": "Handling"
|
||||
},
|
||||
"action": {
|
||||
"toggleBoardEdit": "",
|
||||
"toggleColorScheme": "",
|
||||
"saveNotebook": "",
|
||||
"openSpotlight": ""
|
||||
"toggleBoardEdit": "Skift tavleredigeringstilstand",
|
||||
"toggleColorScheme": "Slå lys/mørk tilstand til/fra",
|
||||
"saveNotebook": "Gem notesbog (kun inde i notesbog widget)",
|
||||
"openSpotlight": "Åbn søgefunktionen"
|
||||
},
|
||||
"note": ""
|
||||
"note": "Tip: Mod refererer til både Ctrl-tasten og -tasten på macOS"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1145,6 +1145,12 @@
|
||||
"groupNameTaken": "Gruppenname bereits vergeben"
|
||||
}
|
||||
}
|
||||
},
|
||||
"unit": {
|
||||
"speed": {
|
||||
"kilometersPerHour": "",
|
||||
"milesPerHour": ""
|
||||
}
|
||||
}
|
||||
},
|
||||
"section": {
|
||||
@@ -1157,6 +1163,9 @@
|
||||
"title": {
|
||||
"label": ""
|
||||
},
|
||||
"customCssClasses": {
|
||||
"label": ""
|
||||
},
|
||||
"borderColor": {
|
||||
"label": "Rahmenfarbe"
|
||||
}
|
||||
@@ -1744,6 +1753,9 @@
|
||||
"label": "Aktuelle Windgeschwindigkeit anzeigen",
|
||||
"description": "Nur bei aktuellem Wetter"
|
||||
},
|
||||
"useImperialSpeed": {
|
||||
"label": ""
|
||||
},
|
||||
"location": {
|
||||
"label": "Wetterstandort"
|
||||
},
|
||||
@@ -2974,13 +2986,19 @@
|
||||
"invite": "Einladungen",
|
||||
"integration": "Integrationen",
|
||||
"app": "",
|
||||
"group": "Gruppen"
|
||||
"group": "Gruppen",
|
||||
"searchEngine": "",
|
||||
"media": ""
|
||||
},
|
||||
"statisticLabel": {
|
||||
"boards": "",
|
||||
"resources": "Ressourcen",
|
||||
"authentication": "Authentifizierung",
|
||||
"authorization": "Autorisierung"
|
||||
},
|
||||
"heroBanner": {
|
||||
"title": "",
|
||||
"subtitle": ""
|
||||
}
|
||||
},
|
||||
"board": {
|
||||
|
||||
Loaded 100 of 137 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user