chore(release): automatic release v1.28.0

This commit is contained in:
homarr-releases[bot]
2025-07-11 19:15:20 +00:00
committed by GitHub
124 changed files with 3636 additions and 2804 deletions

View File

@@ -12,6 +12,10 @@ AUTH_SECRET="supersecret"
# or starting the project without any (which will show a randomly generated one).
SECRET_ENCRYPTION_KEY=0000000000000000000000000000000000000000000000000000000000000000
# Cron job API key is generated every time the container starts as it is required for communication between nextjs-api and tasks-api
# export CRON_JOB_API_KEY=$(openssl rand -base64 32)
# CRON_JOB_API_KEY="your-generated-api-key"
LOG_LEVEL='info'
# This is how you can use the sqlite driver:
@@ -30,7 +34,8 @@ DB_URL='FULL_PATH_TO_YOUR_SQLITE_DB_FILE'
# DB_PASSWORD='password'
# DB_NAME='name-of-database'
# The below path can be used to store trusted certificates during development, it is not required and can be left empty.
# The below path can be used to store trusted certificates, it is not required and can be left empty.
# If it is empty, it will default to `/appdata/trusted-certificates` in production.
# If it is used, please use the full path to the directory where the certificates are stored.
# LOCAL_CERTIFICATE_PATH='FULL_PATH_TO_CERTIFICATES'

View File

@@ -31,6 +31,7 @@ body:
label: Version
description: What version of Homarr are you running?
options:
- 1.27.0
- 1.26.0
- 1.25.0
- 1.24.0

View File

@@ -6,6 +6,10 @@
matchPackagePatterns: ["^@homarr/"],
enabled: false,
},
{
matchPackagePatterns: ["^zod$", "^drizzle-zod$", "^zod-form-data$"],
enabled: false,
},
{
matchUpdateTypes: ["minor", "patch", "pin", "digest"],
automerge: true,

View File

@@ -10,8 +10,6 @@ RUN apk add --no-cache libc6-compat curl bash
RUN apk update
COPY . .
# Install working version of corepack (See https://github.com/nodejs/corepack/issues/612)
RUN npm install -g corepack@0.31.0 && corepack --version
RUN corepack enable pnpm && pnpm install --recursive --frozen-lockfile
# Copy static data as it is not part of the build
@@ -19,8 +17,7 @@ COPY static-data ./static-data
ARG SKIP_ENV_VALIDATION='true'
ARG CI='true'
ARG DISABLE_REDIS_LOGS='true'
# Install working version of corepack (See https://github.com/nodejs/corepack/issues/612)
RUN npm install -g corepack@0.31.0 && corepack --version
RUN corepack enable pnpm && pnpm build
FROM base AS runner

View File

@@ -49,17 +49,17 @@
"@homarr/ui": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0",
"@homarr/widgets": "workspace:^0.1.0",
"@mantine/colors-generator": "^8.1.2",
"@mantine/core": "^8.1.2",
"@mantine/dropzone": "^8.1.2",
"@mantine/hooks": "^8.1.2",
"@mantine/modals": "^8.1.2",
"@mantine/tiptap": "^8.1.2",
"@mantine/colors-generator": "^8.1.3",
"@mantine/core": "^8.1.3",
"@mantine/dropzone": "^8.1.3",
"@mantine/hooks": "^8.1.3",
"@mantine/modals": "^8.1.3",
"@mantine/tiptap": "^8.1.3",
"@million/lint": "1.0.14",
"@tabler/icons-react": "^3.34.0",
"@tanstack/react-query": "^5.81.5",
"@tanstack/react-query-devtools": "^5.81.5",
"@tanstack/react-query-next-experimental": "^5.81.5",
"@tanstack/react-query": "^5.83.0",
"@tanstack/react-query-devtools": "^5.83.0",
"@tanstack/react-query-next-experimental": "^5.83.0",
"@trpc/client": "^11.4.3",
"@trpc/next": "^11.4.3",
"@trpc/react-query": "^11.4.3",
@@ -70,7 +70,7 @@
"chroma-js": "^3.1.2",
"clsx": "^2.1.1",
"dayjs": "^1.11.13",
"dotenv": "^17.0.1",
"dotenv": "^17.2.0",
"flag-icons": "^7.5.0",
"glob": "^11.0.3",
"jotai": "^2.12.5",
@@ -84,16 +84,16 @@
"react-simple-code-editor": "^0.14.1",
"sass": "^1.89.2",
"superjson": "2.2.2",
"swagger-ui-react": "^5.26.0",
"swagger-ui-react": "^5.26.2",
"use-deep-compare-effect": "^1.8.1",
"zod": "^3.25.74"
"zod": "^3.25.76"
},
"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.16.0",
"@types/node": "^22.16.3",
"@types/prismjs": "^1.26.5",
"@types/react": "19.1.8",
"@types/react-dom": "19.1.6",

View File

@@ -17,6 +17,7 @@ import {
useMantineTheme,
} from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { IconX } from "@tabler/icons-react";
import { useZodForm } from "@homarr/form";
import { useI18n } from "@homarr/translation/client";
@@ -102,12 +103,25 @@ export const ColorSettingsContent = ({ board }: Props) => {
</InputWrapper>
</Grid.Col>
<Grid.Col span={{ sm: 12, md: 6 }}>
<ColorInput
label={t("board.field.iconColor.label")}
format="hex"
swatches={Object.values(theme.colors).map((color) => color[6])}
{...form.getInputProps("iconColor")}
/>
<Group align="end">
<ColorInput
label={t("board.field.iconColor.label")}
format="hex"
swatches={Object.values(theme.colors).map((color) => color[6])}
flex={1}
{...form.getInputProps("iconColor")}
/>
<Button
variant="subtle"
leftSection={<IconX />}
onClick={() => form.setFieldValue("iconColor", "")}
disabled={!form.values.iconColor}
>
{t("board.field.clearColor.label")}
</Button>
</Group>
</Grid.Col>
<Grid.Col span={{ sm: 12, md: 6 }}>
<Select
label={t("board.field.itemRadius.label")}
description={t("board.field.itemRadius.description")}

View File

@@ -1,4 +1,12 @@
import { IconGrid3x3, IconKey, IconMessage, IconPassword, IconServer, IconUser } from "@tabler/icons-react";
import {
IconGrid3x3,
IconKey,
IconMessage,
IconPassword,
IconPasswordUser,
IconServer,
IconUser,
} from "@tabler/icons-react";
import type { IntegrationSecretKind } from "@homarr/definitions";
import type { TablerIcon } from "@homarr/ui";
@@ -9,5 +17,6 @@ export const integrationSecretIcons = {
password: IconPassword,
realm: IconServer,
tokenId: IconGrid3x3,
personalAccessToken: IconPasswordUser,
topic: IconMessage,
} satisfies Record<IntegrationSecretKind, TablerIcon>;

View File

@@ -21,7 +21,13 @@ import { z } from "zod";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import type { IntegrationKind, IntegrationSecretKind } from "@homarr/definitions";
import { getAllSecretKindOptions, getIconUrl, getIntegrationName, integrationDefs } from "@homarr/definitions";
import {
getAllSecretKindOptions,
getIconUrl,
getIntegrationDefaultUrl,
getIntegrationName,
integrationDefs,
} from "@homarr/definitions";
import type { UseFormReturnType } from "@homarr/form";
import { useZodForm } from "@homarr/form";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
@@ -54,7 +60,7 @@ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) =>
const form = useZodForm(formSchema, {
initialValues: {
name: searchParams.name ?? getIntegrationName(searchParams.kind),
url: searchParams.url ?? "",
url: searchParams.url ?? getIntegrationDefaultUrl(searchParams.kind) ?? "",
secrets: secretKinds[0].map((kind) => ({
kind,
value: "",

View File

@@ -30,13 +30,12 @@
"@homarr/icons": "workspace:^0.1.0",
"@homarr/integrations": "workspace:^0.1.0",
"@homarr/log": "workspace:^",
"@homarr/ping": "workspace:^0.1.0",
"@homarr/redis": "workspace:^0.1.0",
"@homarr/server-settings": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0",
"@homarr/widgets": "workspace:^0.1.0",
"dayjs": "^1.11.13",
"dotenv": "^17.0.1",
"dotenv": "^17.2.0",
"fastify": "^5.4.0",
"superjson": "2.2.2",
"undici": "7.11.0"
@@ -45,9 +44,9 @@
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/node": "^22.16.0",
"@types/node": "^22.16.3",
"dotenv-cli": "^8.0.0",
"esbuild": "^0.25.5",
"esbuild": "^0.25.6",
"eslint": "^9.30.1",
"prettier": "^3.6.2",
"tsx": "4.20.3",

View File

@@ -25,7 +25,7 @@
"@homarr/log": "workspace:^",
"@homarr/redis": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0",
"dotenv": "^17.0.1",
"dotenv": "^17.2.0",
"tsx": "4.20.3",
"ws": "^8.18.3"
},
@@ -34,7 +34,7 @@
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/ws": "^8.18.1",
"esbuild": "^0.25.5",
"esbuild": "^0.25.6",
"eslint": "^9.30.1",
"prettier": "^3.6.2",
"typescript": "^5.8.3"

View File

@@ -42,18 +42,18 @@
"@vitejs/plugin-react": "^4.6.0",
"@vitest/coverage-v8": "^3.2.4",
"@vitest/ui": "^3.2.4",
"conventional-changelog-conventionalcommits": "^9.0.0",
"conventional-changelog-conventionalcommits": "^9.1.0",
"cross-env": "^7.0.3",
"jsdom": "^26.1.0",
"prettier": "^3.6.2",
"semantic-release": "^24.2.6",
"testcontainers": "^11.0.3",
"testcontainers": "^11.2.1",
"turbo": "^2.5.4",
"typescript": "^5.8.3",
"vite-tsconfig-paths": "^5.1.4",
"vitest": "^3.2.4"
},
"packageManager": "pnpm@10.12.4",
"packageManager": "pnpm@10.13.1",
"engines": {
"node": ">=22.17.0"
},

View File

@@ -35,13 +35,12 @@
"@homarr/log": "workspace:^",
"@homarr/old-import": "workspace:^0.1.0",
"@homarr/old-schema": "workspace:^0.1.0",
"@homarr/ping": "workspace:^0.1.0",
"@homarr/redis": "workspace:^0.1.0",
"@homarr/request-handler": "workspace:^0.1.0",
"@homarr/server-settings": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0",
"@kubernetes/client-node": "^1.3.0",
"@tanstack/react-query": "^5.81.5",
"@tanstack/react-query": "^5.83.0",
"@trpc/client": "^11.4.3",
"@trpc/react-query": "^11.4.3",
"@trpc/server": "^11.4.3",
@@ -52,7 +51,7 @@
"react-dom": "19.1.0",
"superjson": "2.2.2",
"trpc-to-openapi": "^2.3.2",
"zod": "^3.25.74"
"zod": "^3.25.76"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",

View File

@@ -19,6 +19,7 @@ import {
getIconUrl,
getIntegrationKindsByCategory,
getPermissionsWithParents,
integrationCategories,
integrationDefs,
integrationKinds,
integrationSecretKindObject,
@@ -129,6 +130,57 @@ export const integrationRouter = createTRPCRouter({
integrationKinds.indexOf(integrationA.kind) - integrationKinds.indexOf(integrationB.kind),
);
}),
allOfGivenCategory: publicProcedure
.input(
z.object({
category: z.enum(integrationCategories),
}),
)
.query(async ({ ctx, input }) => {
const groupsOfCurrentUser = await ctx.db.query.groupMembers.findMany({
where: eq(groupMembers.userId, ctx.session?.user.id ?? ""),
});
const intergrationKinds = getIntegrationKindsByCategory(input.category);
const integrationsFromDb = await ctx.db.query.integrations.findMany({
with: {
userPermissions: {
where: eq(integrationUserPermissions.userId, ctx.session?.user.id ?? ""),
},
groupPermissions: {
where: inArray(
integrationGroupPermissions.groupId,
groupsOfCurrentUser.map((group) => group.groupId),
),
},
},
where: inArray(integrations.kind, intergrationKinds),
});
return integrationsFromDb
.map((integration) => {
const permissions = integration.userPermissions
.map(({ permission }) => permission)
.concat(integration.groupPermissions.map(({ permission }) => permission));
return {
id: integration.id,
name: integration.name,
kind: integration.kind,
url: integration.url,
permissions: {
hasUseAccess:
permissions.includes("use") || permissions.includes("interact") || permissions.includes("full"),
hasInteractAccess: permissions.includes("interact") || permissions.includes("full"),
hasFullAccess: permissions.includes("full"),
},
};
})
.sort(
(integrationA, integrationB) =>
integrationKinds.indexOf(integrationA.kind) - integrationKinds.indexOf(integrationB.kind),
);
}),
search: protectedProcedure
.input(z.object({ query: z.string(), limit: z.number().min(1).max(100).default(10) }))
.query(async ({ ctx, input }) => {

View File

@@ -1,53 +0,0 @@
import { describe, expect, test, vi } from "vitest";
import type { Session } from "@homarr/auth";
import { createDb } from "@homarr/db/test";
import * as ping from "@homarr/ping";
import { appRouter } from "../../widgets/app";
// Mock the auth module to return an empty session
vi.mock("@homarr/auth", () => ({ auth: () => ({}) as Session }));
vi.mock("@homarr/ping", () => ({ sendPingRequestAsync: async () => await Promise.resolve(null) }));
describe("ping should call sendPingRequestAsync with url and return result", () => {
test("ping with error response should return error and url", async () => {
// Arrange
const spy = vi.spyOn(ping, "sendPingRequestAsync");
const url = "http://localhost";
const db = createDb();
const caller = appRouter.createCaller({
db,
deviceType: undefined,
session: null,
});
spy.mockImplementation(() => Promise.resolve({ error: "error" }));
// Act
const result = await caller.ping({ url });
// Assert
expect(result.url).toBe(url);
expect("error" in result).toBe(true);
});
test("ping with success response should return statusCode and url", async () => {
// Arrange
const spy = vi.spyOn(ping, "sendPingRequestAsync");
const url = "http://localhost";
const db = createDb();
const caller = appRouter.createCaller({
db,
deviceType: undefined,
session: null,
});
spy.mockImplementation(() => Promise.resolve({ statusCode: 200 }));
// Act
const result = await caller.ping({ url });
// Assert
expect(result.url).toBe(url);
expect("statusCode" in result).toBe(true);
});
});

View File

@@ -1,20 +1,12 @@
import { observable } from "@trpc/server/observable";
import { z } from "zod";
import { sendPingRequestAsync } from "@homarr/ping";
import { pingChannel, pingUrlChannel } from "@homarr/redis";
import { pingUrlChannel } from "@homarr/redis";
import { pingRequestHandler } from "@homarr/request-handler/ping";
import { createTRPCRouter, publicProcedure } from "../../trpc";
export const appRouter = createTRPCRouter({
ping: publicProcedure.input(z.object({ url: z.string() })).query(async ({ input }) => {
const pingResult = await sendPingRequestAsync(input.url);
return {
url: input.url,
...pingResult,
};
}),
updatedPing: publicProcedure
.input(
z.object({
@@ -23,21 +15,27 @@ export const appRouter = createTRPCRouter({
)
.subscription(async ({ input }) => {
await pingUrlChannel.addAsync(input.url);
const innerHandler = pingRequestHandler.handler({ url: input.url });
const pingResult = await sendPingRequestAsync(input.url);
return observable<{ url: string; statusCode: number; durationMs: number } | { url: string; error: string }>(
(emit) => {
// Run ping request in background
void innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false }).then(({ data }) => {
emit.next({ url: input.url, ...data });
});
return observable<{ url: string; statusCode: number } | { url: string; error: string }>((emit) => {
emit.next({ url: input.url, ...pingResult });
const unsubscribe = pingChannel.subscribe((message) => {
// Only emit if same url
if (message.url !== input.url) return;
emit.next(message);
});
const unsubscribe = innerHandler.subscribe((pingResponse) => {
emit.next({
url: input.url,
...pingResponse,
});
});
return () => {
unsubscribe();
void pingUrlChannel.removeAsync(input.url);
};
});
return () => {
unsubscribe();
void pingUrlChannel.removeAsync(input.url);
};
},
);
}),
});

View File

@@ -1,8 +1,10 @@
import { escapeForRegEx } from "@tiptap/react";
import { z } from "zod";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { releasesRequestHandler } from "@homarr/request-handler/releases";
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc";
const formatVersionFilterRegex = (versionFilter: z.infer<typeof releaseVersionFilterSchema> | undefined) => {
@@ -23,31 +25,31 @@ const releaseVersionFilterSchema = z.object({
export const releasesRouter = createTRPCRouter({
getLatest: publicProcedure
.concat(createOneIntegrationMiddleware("query", ...getIntegrationKindsByCategory("releasesProvider")))
.input(
z.object({
repositories: z.array(
z.object({
providerKey: z.string(),
id: z.string(),
identifier: z.string(),
versionFilter: releaseVersionFilterSchema.optional(),
}),
),
}),
)
.query(async ({ input }) => {
const result = await Promise.all(
.query(async ({ ctx, input }) => {
return await Promise.all(
input.repositories.map(async (repository) => {
const innerHandler = releasesRequestHandler.handler({
providerKey: repository.providerKey,
const innerHandler = releasesRequestHandler.handler(ctx.integration, {
id: repository.id,
identifier: repository.identifier,
versionRegex: formatVersionFilterRegex(repository.versionFilter),
});
return await innerHandler.getCachedOrUpdatedDataAsync({
forceUpdate: false,
});
}),
);
return result;
}),
});

View File

@@ -34,12 +34,12 @@
"@homarr/validation": "workspace:^0.1.0",
"bcrypt": "^6.0.0",
"cookies": "^0.9.1",
"ldapts": "8.0.4",
"ldapts": "8.0.5",
"next": "15.3.5",
"next-auth": "5.0.0-beta.29",
"react": "19.1.0",
"react-dom": "19.1.0",
"zod": "^3.25.74"
"zod": "^3.25.76"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",

View File

@@ -3,12 +3,14 @@
import type { PropsWithChildren } from "react";
import { createContext, useContext } from "react";
import type { IntegrationKind } from "@homarr/definitions";
interface IntegrationContextProps {
integrations: {
id: string;
name: string;
url: string;
kind: string;
kind: IntegrationKind;
permissions: {
hasFullAccess: boolean;
hasInteractAccess: boolean;

View File

@@ -15,9 +15,8 @@ import { db } from "@homarr/db";
import type { trustedCertificateHostnames } from "@homarr/db/schema";
const getCertificateFolder = () => {
return env.NODE_ENV === "production"
? path.join("/appdata", "trusted-certificates")
: process.env.LOCAL_CERTIFICATE_PATH;
if (env.NODE_ENV !== "production") return process.env.LOCAL_CERTIFICATE_PATH;
return process.env.LOCAL_CERTIFICATE_PATH ?? path.join("/appdata", "trusted-certificates");
};
export const loadCustomRootCertificatesAsync = async () => {

View File

@@ -28,13 +28,13 @@
"@homarr/common": "workspace:^0.1.0",
"@homarr/db": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0",
"dotenv": "^17.0.1"
"dotenv": "^17.2.0"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"esbuild": "^0.25.5",
"esbuild": "^0.25.6",
"eslint": "^9.30.1",
"typescript": "^5.8.3"
}

View File

@@ -29,13 +29,14 @@
"dependencies": {
"@homarr/env": "workspace:^0.1.0",
"@homarr/log": "workspace:^0.1.0",
"@paralleldrive/cuid2": "^2.2.2",
"dayjs": "^1.11.13",
"next": "15.3.5",
"react": "19.1.0",
"react-dom": "19.1.0",
"undici": "7.11.0",
"zod": "^3.25.74",
"zod-validation-error": "^3.5.2"
"zod": "^3.25.76",
"zod-validation-error": "^3.5.3"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",

View File

@@ -0,0 +1 @@
export { createId } from "@paralleldrive/cuid2";

View File

@@ -5,6 +5,7 @@ export * from "./array";
export * from "./date";
export * from "./stopwatch";
export * from "./hooks";
export * from "./id";
export * from "./url";
export * from "./number";
export * from "./error";

View File

@@ -29,13 +29,13 @@
"@homarr/cron-jobs": "workspace:^0.1.0",
"@homarr/env": "workspace:^0.1.0",
"@homarr/log": "workspace:^0.1.0",
"@tanstack/react-query": "^5.81.5",
"@tanstack/react-query": "^5.83.0",
"@trpc/client": "^11.4.3",
"@trpc/server": "^11.4.3",
"@trpc/tanstack-react-query": "^11.4.3",
"node-cron": "^4.2.0",
"node-cron": "^4.2.1",
"react": "19.1.0",
"zod": "^3.25.74"
"zod": "^3.25.76"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",

View File

@@ -26,7 +26,7 @@
"dependencies": {
"@homarr/common": "workspace:^0.1.0",
"@homarr/db": "workspace:^0.1.0",
"node-cron": "^4.2.0"
"node-cron": "^4.2.1"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",

View File

@@ -32,7 +32,6 @@
"@homarr/icons": "workspace:^0.1.0",
"@homarr/integrations": "workspace:^0.1.0",
"@homarr/log": "workspace:^0.1.0",
"@homarr/ping": "workspace:^0.1.0",
"@homarr/redis": "workspace:^0.1.0",
"@homarr/request-handler": "workspace:^0.1.0",
"@homarr/server-settings": "workspace:^0.1.0",

View File

@@ -2,8 +2,8 @@ import { EVERY_MINUTE } from "@homarr/cron-jobs-core/expressions";
import { db } from "@homarr/db";
import { getServerSettingByKeyAsync } from "@homarr/db/queries";
import { logger } from "@homarr/log";
import { sendPingRequestAsync } from "@homarr/ping";
import { pingChannel, pingUrlChannel } from "@homarr/redis";
import { pingUrlChannel } from "@homarr/redis";
import { pingRequestHandler } from "@homarr/request-handler/ping";
import { createCronJob } from "../lib";
@@ -28,16 +28,6 @@ export const pingJob = createCronJob("ping", EVERY_MINUTE, {
});
const pingAsync = async (url: string) => {
const pingResult = await sendPingRequestAsync(url);
if ("statusCode" in pingResult) {
logger.debug(`executed ping for url ${url} with status code ${pingResult.statusCode}`);
} else {
logger.error(`Executing ping for url ${url} failed with error: ${pingResult.error}`);
}
await pingChannel.publishAsync({
url,
...pingResult,
});
const handler = pingRequestHandler.handler({ url });
await handler.getCachedOrUpdatedDataAsync({ forceUpdate: true });
};

View File

@@ -0,0 +1,72 @@
import SuperJSON from "superjson";
import { createId } from "@homarr/common";
import type { IntegrationKind } from "@homarr/definitions";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { eq } from "../..";
import type { Database } from "../..";
import { items } from "../../schema";
export async function migrateReleaseWidgetProviderToOptionsAsync(db: Database) {
const existingItems = await db.query.items.findMany({
where: (items, { eq }) => eq(items.kind, "releases"),
});
const integrationKinds = getIntegrationKindsByCategory("releasesProvider");
const providerIntegrations = await db.query.integrations.findMany({
where: (integrations, { inArray }) => inArray(integrations.kind, integrationKinds),
columns: {
id: true,
kind: true,
},
});
const providerIntegrationMap = new Map<IntegrationKind, string>(
providerIntegrations.map((integration) => [integration.kind, integration.id]),
);
const updates: {
id: string;
options: object;
}[] = [];
for (const item of existingItems) {
const options = SuperJSON.parse<object>(item.options);
if (!("repositories" in options)) continue;
if (!Array.isArray(options.repositories)) continue;
if (options.repositories.length === 0) continue;
if (!options.repositories.some((repository) => "providerKey" in repository)) continue;
const updatedRepositories = options.repositories.map(
({ providerKey, ...otherFields }: { providerKey: string; [key: string]: unknown }) => {
// Ensure providerKey is camelCase
const provider = providerKey.charAt(0).toLowerCase() + providerKey.slice(1);
return {
id: createId(),
providerIntegrationId: providerIntegrationMap.get(provider as IntegrationKind) ?? null,
...otherFields,
};
},
);
updates.push({
id: item.id,
options: {
...options,
repositories: updatedRepositories,
},
});
}
for (const update of updates) {
await db
.update(items)
.set({
options: SuperJSON.stringify(update.options),
})
.where(eq(items.id, update.id));
}
console.log(`Migrated release widget providers to integrations count="${updates.length}"`);
}

View File

@@ -0,0 +1,6 @@
import type { Database } from "../..";
import { migrateReleaseWidgetProviderToOptionsAsync } from "./0000_release_widget_provider_to_options";
export const applyCustomMigrationsAsync = async (db: Database) => {
await migrateReleaseWidgetProviderToOptionsAsync(db);
};

View File

@@ -0,0 +1,12 @@
import { applyCustomMigrationsAsync } from ".";
import { database } from "../../driver";
applyCustomMigrationsAsync(database)
.then(() => {
console.log("Custom migrations applied successfully");
process.exit(0);
})
.catch((err) => {
console.log("Failed to apply custom migrations\n\t", err);
process.exit(1);
});

View File

@@ -5,6 +5,7 @@ import mysql from "mysql2";
import type { Database } from "../..";
import { env } from "../../env";
import * as mysqlSchema from "../../schema/mysql";
import { applyCustomMigrationsAsync } from "../custom";
import { seedDataAsync } from "../seed";
const migrationsFolder = process.argv[2] ?? ".";
@@ -30,6 +31,7 @@ const migrateAsync = async () => {
await migrate(db, { migrationsFolder });
await seedDataAsync(db as unknown as Database);
await applyCustomMigrationsAsync(db as unknown as Database);
};
migrateAsync()

View File

@@ -1,5 +1,11 @@
import { objectKeys } from "@homarr/common";
import { createDocumentationLink, everyoneGroup } from "@homarr/definitions";
import {
createDocumentationLink,
everyoneGroup,
getIntegrationDefaultUrl,
getIntegrationName,
integrationKinds,
} from "@homarr/definitions";
import { defaultServerSettings, defaultServerSettingsKeys } from "@homarr/server-settings";
import type { Database } from "..";
@@ -9,7 +15,8 @@ import {
insertServerSettingByKeyAsync,
updateServerSettingByKeyAsync,
} from "../queries/server-setting";
import { onboarding, searchEngines } from "../schema";
import { integrations, onboarding, searchEngines } from "../schema";
import type { Integration } from "../schema";
import { groups } from "../schema/mysql";
export const seedDataAsync = async (db: Database) => {
@@ -17,6 +24,7 @@ export const seedDataAsync = async (db: Database) => {
await seedOnboardingAsync(db);
await seedServerSettingsAsync(db);
await seedDefaultSearchEnginesAsync(db);
await seedDefaultIntegrationsAsync(db);
};
const seedEveryoneGroupAsync = async (db: Database) => {
@@ -131,3 +139,53 @@ const seedServerSettingsAsync = async (db: Database) => {
console.log(`Updated serverSetting through seed key=${settingsKey}`);
}
};
const seedDefaultIntegrationsAsync = async (db: Database) => {
const defaultIntegrations = integrationKinds.reduce<Integration[]>((acc, kind) => {
const name = getIntegrationName(kind);
const defaultUrl = getIntegrationDefaultUrl(kind);
if (defaultUrl !== undefined) {
acc.push({
id: "new",
name: `${name} Default`,
url: defaultUrl,
kind,
});
}
return acc;
}, []);
if (defaultIntegrations.length === 0) {
console.warn("No default integrations found to seed");
return;
}
let createdCount = 0;
await Promise.all(
defaultIntegrations.map(async (integration) => {
const existingKind = await db.$count(integrations, eq(integrations.kind, integration.kind));
if (existingKind > 0) {
console.log(`Skipping seeding of default ${integration.kind} integration as one already exists`);
return;
}
const newIntegration = {
...integration,
id: createId(),
};
await db.insert(integrations).values(newIntegration);
createdCount++;
}),
);
if (createdCount === 0) {
console.log("No default integrations were created as they already exist");
return;
}
console.log(`Created ${createdCount} default integrations through seeding process`);
};

View File

@@ -4,6 +4,7 @@ import { migrate } from "drizzle-orm/better-sqlite3/migrator";
import { env } from "../../env";
import * as sqliteSchema from "../../schema/sqlite";
import { applyCustomMigrationsAsync } from "../custom";
import { seedDataAsync } from "../seed";
const migrationsFolder = process.argv[2] ?? ".";
@@ -16,6 +17,7 @@ const migrateAsync = async () => {
migrate(db, { migrationsFolder });
await seedDataAsync(db);
await applyCustomMigrationsAsync(db);
};
migrateAsync()

View File

@@ -23,12 +23,13 @@
"clean": "rm -rf .turbo node_modules",
"format": "prettier --check . --ignore-path ../../.gitignore",
"lint": "eslint",
"migration:custom": "pnpm with-env tsx ./migrations/custom/run-custom.ts",
"migration:mysql:drop": "pnpm with-env drizzle-kit drop --config ./configs/mysql.config.ts",
"migration:mysql:generate": "pnpm with-env drizzle-kit generate --config ./configs/mysql.config.ts",
"migration:mysql:run": "pnpm with-env drizzle-kit migrate --config ./configs/mysql.config.ts && pnpm run seed",
"migration:mysql:run": "pnpm with-env drizzle-kit migrate --config ./configs/mysql.config.ts && pnpm run seed && pnpm run migration:custom",
"migration:sqlite:drop": "pnpm with-env drizzle-kit drop --config ./configs/sqlite.config.ts",
"migration:sqlite:generate": "pnpm with-env drizzle-kit generate --config ./configs/sqlite.config.ts",
"migration:sqlite:run": "pnpm with-env drizzle-kit migrate --config ./configs/sqlite.config.ts && pnpm run seed",
"migration:sqlite:run": "pnpm with-env drizzle-kit migrate --config ./configs/sqlite.config.ts && pnpm run seed && pnpm run migration:custom",
"push:mysql": "pnpm with-env drizzle-kit push --config ./configs/mysql.config.ts",
"push:sqlite": "pnpm with-env drizzle-kit push --config ./configs/sqlite.config.ts",
"seed": "pnpm with-env tsx ./migrations/run-seed.ts",
@@ -44,15 +45,16 @@
"@homarr/env": "workspace:^0.1.0",
"@homarr/log": "workspace:^0.1.0",
"@homarr/server-settings": "workspace:^0.1.0",
"@mantine/core": "^8.1.2",
"@mantine/core": "^8.1.3",
"@paralleldrive/cuid2": "^2.2.2",
"@testcontainers/mysql": "^11.0.3",
"@testcontainers/mysql": "^11.2.1",
"better-sqlite3": "^12.2.0",
"dotenv": "^17.0.1",
"dotenv": "^17.2.0",
"drizzle-kit": "^0.31.4",
"drizzle-orm": "^0.44.2",
"drizzle-zod": "^0.7.1",
"mysql2": "3.14.1"
"mysql2": "3.14.2",
"superjson": "2.2.2"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",
@@ -60,7 +62,7 @@
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/better-sqlite3": "7.6.13",
"dotenv-cli": "^8.0.0",
"esbuild": "^0.25.5",
"esbuild": "^0.25.6",
"eslint": "^9.30.1",
"prettier": "^3.6.2",
"tsx": "4.20.3",

View File

@@ -25,7 +25,7 @@
"dependencies": {
"@homarr/common": "workspace:^0.1.0",
"fast-xml-parser": "^5.2.5",
"zod": "^3.25.74"
"zod": "^3.25.76"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",

View File

@@ -46,6 +46,7 @@ export type HomarrDocumentationPath =
| "/docs/tags/analytics"
| "/docs/tags/api"
| "/docs/tags/apps"
| "/docs/tags/background"
| "/docs/tags/banner"
| "/docs/tags/blocking"
| "/docs/tags/boards"
@@ -89,6 +90,7 @@ export type HomarrDocumentationPath =
| "/docs/tags/integrations"
| "/docs/tags/interface"
| "/docs/tags/jellyserr"
| "/docs/tags/jobs"
| "/docs/tags/layout"
| "/docs/tags/ldap"
| "/docs/tags/links"
@@ -98,6 +100,7 @@ export type HomarrDocumentationPath =
| "/docs/tags/media"
| "/docs/tags/minecraft"
| "/docs/tags/monitoring"
| "/docs/tags/network"
| "/docs/tags/news"
| "/docs/tags/notebook"
| "/docs/tags/notes"
@@ -130,11 +133,13 @@ export type HomarrDocumentationPath =
| "/docs/tags/stocks"
| "/docs/tags/system"
| "/docs/tags/table"
| "/docs/tags/tasks"
| "/docs/tags/technical-documentation"
| "/docs/tags/text"
| "/docs/tags/torrent"
| "/docs/tags/traefik"
| "/docs/tags/translations"
| "/docs/tags/unifi-controller"
| "/docs/tags/unraid"
| "/docs/tags/uploads"
| "/docs/tags/usenet"
@@ -190,6 +195,7 @@ export type HomarrDocumentationPath =
| "/docs/integrations/kubernetes"
| "/docs/integrations/media-requester"
| "/docs/integrations/media-server"
| "/docs/integrations/network"
| "/docs/integrations/servarr"
| "/docs/integrations/torrent"
| "/docs/integrations/usenet"
@@ -201,6 +207,7 @@ export type HomarrDocumentationPath =
| "/docs/management/media"
| "/docs/management/search-engines"
| "/docs/management/settings"
| "/docs/management/tasks"
| "/docs/management/users"
| "/docs/widgets/bookmarks"
| "/docs/widgets/calendar"
@@ -214,6 +221,7 @@ export type HomarrDocumentationPath =
| "/docs/widgets/media-requests"
| "/docs/widgets/media-server"
| "/docs/widgets/minecraft-server-status"
| "/docs/widgets/network-controller"
| "/docs/widgets/notebook"
| "/docs/widgets/releases"
| "/docs/widgets/rss"

View File

@@ -7,6 +7,7 @@ export const integrationSecretKindObject = {
password: { isPublic: false },
tokenId: { isPublic: true },
realm: { isPublic: true },
personalAccessToken: { isPublic: false },
topic: { isPublic: true },
} satisfies Record<string, { isPublic: boolean }>;
@@ -17,6 +18,7 @@ interface integrationDefinition {
iconUrl: string;
secretKinds: AtLeastOneOf<IntegrationSecretKind[]>; // at least one secret kind set is required
category: AtLeastOneOf<IntegrationCategory>;
defaultUrl?: string; // optional default URL for the integration
}
export const integrationDefs = {
@@ -170,6 +172,41 @@ export const integrationDefs = {
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/png/unifi.png",
category: ["networkController"],
},
github: {
name: "Github",
secretKinds: [[], ["personalAccessToken"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/github.svg",
category: ["releasesProvider"],
defaultUrl: "https://api.github.com",
},
dockerHub: {
name: "Docker Hub",
secretKinds: [[], ["username", "personalAccessToken"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/docker.svg",
category: ["releasesProvider"],
defaultUrl: "https://hub.docker.com",
},
gitlab: {
name: "Gitlab",
secretKinds: [[], ["personalAccessToken"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/gitlab.svg",
category: ["releasesProvider"],
defaultUrl: "https://gitlab.com",
},
npm: {
name: "NPM",
secretKinds: [[]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/npm.svg",
category: ["releasesProvider"],
defaultUrl: "https://registry.npmjs.org",
},
codeberg: {
name: "Codeberg",
secretKinds: [[], ["personalAccessToken"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/codeberg.svg",
category: ["releasesProvider"],
defaultUrl: "https://codeberg.org",
},
ntfy: {
name: "ntfy",
secretKinds: [["topic"], ["topic", "apiKey"]],
@@ -209,6 +246,11 @@ export const getDefaultSecretKinds = (integration: IntegrationKind): Integration
export const getAllSecretKindOptions = (integration: IntegrationKind): AtLeastOneOf<IntegrationSecretKind[]> =>
integrationDefs[integration].secretKinds;
export const getIntegrationDefaultUrl = (integration: IntegrationKind) => {
const definition = integrationDefs[integration];
return "defaultUrl" in definition ? definition.defaultUrl : undefined;
};
/**
* Get all integration kinds that share a category, typed only by the kinds belonging to the category
* @param category Category to filter by, belonging to IntegrationCategory
@@ -234,20 +276,25 @@ export type IntegrationKindByCategory<TCategory extends IntegrationCategory> = {
export type IntegrationSecretKind = keyof typeof integrationSecretKindObject;
export type IntegrationKind = keyof typeof integrationDefs;
export type IntegrationCategory =
| "dnsHole"
| "mediaService"
| "calendar"
| "mediaSearch"
| "mediaRequest"
| "downloadClient"
| "usenet"
| "torrent"
| "miscellaneous"
| "smartHomeServer"
| "indexerManager"
| "healthMonitoring"
| "search"
| "mediaTranscoding"
| "networkController"
| "notifications";
export const integrationCategories = [
"dnsHole",
"mediaService",
"calendar",
"mediaSearch",
"mediaRequest",
"downloadClient",
"usenet",
"torrent",
"miscellaneous",
"smartHomeServer",
"indexerManager",
"healthMonitoring",
"search",
"mediaTranscoding",
"networkController",
"releasesProvider",
"notifications",
] as const;
export type IntegrationCategory = (typeof integrationCategories)[number];

View File

@@ -24,7 +24,7 @@
"prettier": "@homarr/prettier-config",
"dependencies": {
"@t3-oss/env-nextjs": "^0.13.8",
"zod": "^3.25.74"
"zod": "^3.25.76"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",

View File

@@ -26,8 +26,8 @@
"@homarr/common": "workspace:^0.1.0",
"@homarr/translation": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0",
"@mantine/form": "^8.1.2",
"zod": "^3.25.74"
"@mantine/form": "^8.1.3",
"zod": "^3.25.76"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",

View File

@@ -29,9 +29,9 @@
"@homarr/notifications": "workspace:^0.1.0",
"@homarr/translation": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0",
"@mantine/core": "^8.1.2",
"@mantine/core": "^8.1.3",
"react": "19.1.0",
"zod": "^3.25.74"
"zod": "^3.25.76"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",

View File

@@ -28,6 +28,7 @@
"@ctrl/deluge": "^7.1.0",
"@ctrl/qbittorrent": "^9.6.0",
"@ctrl/transmission": "^7.2.0",
"@gitbeaker/rest": "^42.5.0",
"@homarr/certificates": "workspace:^0.1.0",
"@homarr/common": "workspace:^0.1.0",
"@homarr/db": "workspace:^0.1.0",
@@ -40,11 +41,12 @@
"@jellyfin/sdk": "^0.11.0",
"maria2": "^0.4.1",
"node-ical": "^0.20.1",
"octokit": "^5.0.3",
"proxmox-api": "1.1.1",
"tsdav": "^2.1.5",
"undici": "7.11.0",
"xml2js": "^0.6.2",
"zod": "^3.25.74"
"zod": "^3.25.76"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",

View File

@@ -4,7 +4,9 @@ import type { Integration as DbIntegration } from "@homarr/db/schema";
import type { IntegrationKind, IntegrationSecretKind } from "@homarr/definitions";
import { AdGuardHomeIntegration } from "../adguard-home/adguard-home-integration";
import { CodebergIntegration } from "../codeberg/codeberg-integration";
import { DashDotIntegration } from "../dashdot/dashdot-integration";
import { DockerHubIntegration } from "../docker-hub/docker-hub-integration";
import { Aria2Integration } from "../download-client/aria2/aria2-integration";
import { DelugeIntegration } from "../download-client/deluge/deluge-integration";
import { NzbGetIntegration } from "../download-client/nzbget/nzbget-integration";
@@ -12,6 +14,8 @@ import { QBitTorrentIntegration } from "../download-client/qbittorrent/qbittorre
import { SabnzbdIntegration } from "../download-client/sabnzbd/sabnzbd-integration";
import { TransmissionIntegration } from "../download-client/transmission/transmission-integration";
import { EmbyIntegration } from "../emby/emby-integration";
import { GithubIntegration } from "../github/github-integration";
import { GitlabIntegration } from "../gitlab/gitlab-integration";
import { HomeAssistantIntegration } from "../homeassistant/homeassistant-integration";
import { JellyfinIntegration } from "../jellyfin/jellyfin-integration";
import { JellyseerrIntegration } from "../jellyseerr/jellyseerr-integration";
@@ -22,6 +26,7 @@ import { SonarrIntegration } from "../media-organizer/sonarr/sonarr-integration"
import { TdarrIntegration } from "../media-transcoding/tdarr-integration";
import { MockIntegration } from "../mock/mock-integration";
import { NextcloudIntegration } from "../nextcloud/nextcloud.integration";
import { NPMIntegration } from "../npm/npm-integration";
import { NTFYIntegration } from "../ntfy/ntfy-integration";
import { OpenMediaVaultIntegration } from "../openmediavault/openmediavault-integration";
import { OverseerrIntegration } from "../overseerr/overseerr-integration";
@@ -94,6 +99,11 @@ export const integrationCreators = {
emby: EmbyIntegration,
nextcloud: NextcloudIntegration,
unifiController: UnifiControllerIntegration,
github: GithubIntegration,
dockerHub: DockerHubIntegration,
gitlab: GitlabIntegration,
npm: NPMIntegration,
codeberg: CodebergIntegration,
ntfy: NTFYIntegration,
mock: MockIntegration,
} satisfies Record<IntegrationKind, IntegrationInstance | [(input: IntegrationInput) => Promise<Integration>]>;

View File

@@ -0,0 +1,143 @@
import type { RequestInit, Response } from "undici";
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
import { logger } from "@homarr/log";
import type { IntegrationTestingInput } from "../base/integration";
import { Integration } from "../base/integration";
import { TestConnectionError } from "../base/test-connection/test-connection-error";
import type { TestingResult } from "../base/test-connection/test-connection-service";
import type { ReleasesProviderIntegration } from "../interfaces/releases-providers/releases-providers-integration";
import { getLatestRelease } from "../interfaces/releases-providers/releases-providers-integration";
import type {
DetailsProviderResponse,
ReleasesRepository,
ReleasesResponse,
} from "../interfaces/releases-providers/releases-providers-types";
import { detailsResponseSchema, releasesResponseSchema } from "./codeberg-schemas";
const localLogger = logger.child({ module: "CodebergIntegration" });
export class CodebergIntegration extends Integration implements ReleasesProviderIntegration {
private async withHeadersAsync(callback: (headers: RequestInit["headers"]) => Promise<Response>): Promise<Response> {
if (!this.hasSecretValue("personalAccessToken")) return await callback({});
return await callback({
Authorization: `token ${this.getSecretValue("personalAccessToken")}`,
});
}
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const response = await this.withHeadersAsync(async (headers) => {
return await input.fetchAsync(this.url("/version"), {
headers,
});
});
if (!response.ok) {
return TestConnectionError.StatusResult(response);
}
return {
success: true,
};
}
public async getLatestMatchingReleaseAsync(repository: ReleasesRepository): Promise<ReleasesResponse> {
const [owner, name] = repository.identifier.split("/");
if (!owner || !name) {
localLogger.warn(
`Invalid identifier format. Expected 'owner/name', for ${repository.identifier} with Codeberg integration`,
{
identifier: repository.identifier,
},
);
return {
id: repository.id,
error: { code: "invalidIdentifier" },
};
}
const details = await this.getDetailsAsync(owner, name);
const releasesResponse = await this.withHeadersAsync(async (headers) => {
return fetchWithTrustedCertificatesAsync(
this.url(`/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/releases`),
{ headers },
);
});
if (!releasesResponse.ok) {
return {
id: repository.id,
error: { message: releasesResponse.statusText },
};
}
const releasesResponseJson: unknown = await releasesResponse.json();
const { data, success, error } = releasesResponseSchema.safeParse(releasesResponseJson);
if (!success) {
return {
id: repository.id,
error: {
message: releasesResponseJson ? JSON.stringify(releasesResponseJson, null, 2) : error.message,
},
};
} else {
const formattedReleases = data.map((tag) => ({
latestRelease: tag.tag_name,
latestReleaseAt: tag.published_at,
releaseUrl: tag.url,
releaseDescription: tag.body,
isPreRelease: tag.prerelease,
}));
return getLatestRelease(formattedReleases, repository, details);
}
}
protected async getDetailsAsync(owner: string, name: string): Promise<DetailsProviderResponse | undefined> {
const response = await this.withHeadersAsync(async (headers) => {
return await fetchWithTrustedCertificatesAsync(
this.url(`/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}`),
{
headers,
},
);
});
if (!response.ok) {
localLogger.warn(`Failed to get details response for ${owner}/${name} with Codeberg integration`, {
owner,
name,
error: response.statusText,
});
return undefined;
}
const responseJson = await response.json();
const { data, success, error } = detailsResponseSchema.safeParse(responseJson);
if (!success) {
localLogger.warn(`Failed to parse details response for ${owner}/${name} with Codeberg integration`, {
owner,
name,
error,
});
return undefined;
}
return {
projectUrl: data.html_url,
projectDescription: data.description,
isFork: data.fork,
isArchived: data.archived,
createdAt: data.created_at,
starsCount: data.stars_count,
openIssues: data.open_issues_count,
forksCount: data.forks_count,
};
}
}

View File

@@ -0,0 +1,22 @@
import { z } from "zod";
export const releasesResponseSchema = z.array(
z.object({
tag_name: z.string(),
published_at: z.string().transform((value) => new Date(value)),
url: z.string(),
body: z.string(),
prerelease: z.boolean(),
}),
);
export const detailsResponseSchema = z.object({
html_url: z.string(),
description: z.string(),
fork: z.boolean(),
archived: z.boolean(),
created_at: z.string().transform((value) => new Date(value)),
stars_count: z.number(),
open_issues_count: z.number(),
forks_count: z.number(),
});

View File

@@ -0,0 +1,190 @@
import type { fetch, RequestInit, Response } from "undici";
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
import { ResponseError } from "@homarr/common/server";
import { logger } from "@homarr/log";
import type { IntegrationInput, IntegrationTestingInput } from "../base/integration";
import { Integration } from "../base/integration";
import type { SessionStore } from "../base/session-store";
import { createSessionStore } from "../base/session-store";
import { TestConnectionError } from "../base/test-connection/test-connection-error";
import type { TestingResult } from "../base/test-connection/test-connection-service";
import type { ReleasesProviderIntegration } from "../interfaces/releases-providers/releases-providers-integration";
import { getLatestRelease } from "../interfaces/releases-providers/releases-providers-integration";
import type {
DetailsProviderResponse,
ReleasesRepository,
ReleasesResponse,
} from "../interfaces/releases-providers/releases-providers-types";
import { accessTokenResponseSchema, detailsResponseSchema, releasesResponseSchema } from "./docker-hub-schemas";
const localLogger = logger.child({ module: "DockerHubIntegration" });
export class DockerHubIntegration extends Integration implements ReleasesProviderIntegration {
private readonly sessionStore: SessionStore<string>;
constructor(integration: IntegrationInput) {
super(integration);
this.sessionStore = createSessionStore(integration);
}
private async withHeadersAsync(callback: (headers: RequestInit["headers"]) => Promise<Response>): Promise<Response> {
if (!this.hasSecretValue("username") || !this.hasSecretValue("personalAccessToken")) return await callback({});
const storedSession = await this.sessionStore.getAsync();
if (storedSession) {
localLogger.debug("Using stored session for request", { integrationId: this.integration.id });
const response = await callback({
Authorization: `Bearer ${storedSession}`,
});
if (response.status !== 401) {
return response;
}
localLogger.debug("Session expired, getting new session", { integrationId: this.integration.id });
}
const accessToken = await this.getSessionAsync();
await this.sessionStore.setAsync(accessToken);
return await callback({
Authorization: `Bearer ${accessToken}`,
});
}
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const hasAuth = this.hasSecretValue("username") && this.hasSecretValue("personalAccessToken");
if (hasAuth) {
localLogger.debug("Testing DockerHub connection with authentication", { integrationId: this.integration.id });
await this.getSessionAsync(input.fetchAsync);
} else {
localLogger.debug("Testing DockerHub connection without authentication", { integrationId: this.integration.id });
const response = await input.fetchAsync(this.url("/v2/repositories/library"));
if (!response.ok) {
return TestConnectionError.StatusResult(response);
}
}
return {
success: true,
};
}
public async getLatestMatchingReleaseAsync(repository: ReleasesRepository): Promise<ReleasesResponse> {
const relativeUrl = this.getRelativeUrl(repository.identifier);
if (relativeUrl === "/") {
localLogger.warn(
`Invalid identifier format. Expected 'owner/name' or 'name', for ${repository.identifier} on DockerHub`,
{
identifier: repository.identifier,
},
);
return {
id: repository.id,
error: { code: "invalidIdentifier" },
};
}
const details = await this.getDetailsAsync(relativeUrl);
const releasesResponse = await this.withHeadersAsync(async (headers) => {
return await fetchWithTrustedCertificatesAsync(this.url(`${relativeUrl}/tags?page_size=100`), {
headers,
});
});
if (!releasesResponse.ok) {
return {
id: repository.id,
error: { message: releasesResponse.statusText },
};
}
const releasesResponseJson: unknown = await releasesResponse.json();
const releasesResult = releasesResponseSchema.safeParse(releasesResponseJson);
if (!releasesResult.success) {
return {
id: repository.id,
error: {
message: releasesResponseJson ? JSON.stringify(releasesResponseJson, null, 2) : releasesResult.error.message,
},
};
} else {
return getLatestRelease(releasesResult.data.results, repository, details);
}
}
private async getDetailsAsync(relativeUrl: `/${string}`): Promise<DetailsProviderResponse | undefined> {
const response = await this.withHeadersAsync(async (headers) => {
return await fetchWithTrustedCertificatesAsync(this.url(`${relativeUrl}/`), {
headers,
});
});
if (!response.ok) {
localLogger.warn(`Failed to get details response for ${relativeUrl} with DockerHub integration`, {
relativeUrl,
error: response.statusText,
});
return undefined;
}
const responseJson = await response.json();
const { data, success, error } = detailsResponseSchema.safeParse(responseJson);
if (!success) {
localLogger.warn(`Failed to parse details response for ${relativeUrl} with DockerHub integration`, {
relativeUrl,
error,
});
return undefined;
}
return {
projectUrl: `https://hub.docker.com/r/${data.namespace === "library" ? "_" : data.namespace}/${data.name}`,
projectDescription: data.description,
createdAt: data.date_registered,
starsCount: data.star_count,
};
}
private getRelativeUrl(identifier: string): `/${string}` {
if (identifier.indexOf("/") > 0) {
const [owner, name] = identifier.split("/");
if (!owner || !name) {
return "/";
}
return `/v2/namespaces/${encodeURIComponent(owner)}/repositories/${encodeURIComponent(name)}`;
} else {
return `/v2/repositories/library/${encodeURIComponent(identifier)}`;
}
}
private async getSessionAsync(fetchAsync: typeof fetch = fetchWithTrustedCertificatesAsync): Promise<string> {
const response = await fetchAsync(this.url("/v2/auth/token"), {
method: "POST",
body: JSON.stringify({
identifier: this.getSecretValue("username"),
secret: this.getSecretValue("personalAccessToken"),
}),
});
if (!response.ok) throw new ResponseError(response);
const data = await response.json();
const result = await accessTokenResponseSchema.parseAsync(data);
if (!result.access_token) {
throw new ResponseError({ status: 401, url: response.url });
}
localLogger.info("Received session successfully", { integrationId: this.integration.id });
return result.access_token;
}
}

View File

@@ -0,0 +1,22 @@
import { z } from "zod";
export const accessTokenResponseSchema = z.object({
access_token: z.string(),
});
export const releasesResponseSchema = z.object({
results: z.array(
z.object({ name: z.string(), last_updated: z.string().transform((value) => new Date(value)) }).transform((tag) => ({
latestRelease: tag.name,
latestReleaseAt: tag.last_updated,
})),
),
});
export const detailsResponseSchema = z.object({
name: z.string(),
namespace: z.string(),
description: z.string(),
star_count: z.number(),
date_registered: z.string().transform((value) => new Date(value)),
});

View File

@@ -0,0 +1,152 @@
import { Octokit, RequestError } from "octokit";
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
import { logger } from "@homarr/log";
import type { IntegrationTestingInput } from "../base/integration";
import { Integration } from "../base/integration";
import { TestConnectionError } from "../base/test-connection/test-connection-error";
import type { TestingResult } from "../base/test-connection/test-connection-service";
import type { ReleasesProviderIntegration } from "../interfaces/releases-providers/releases-providers-integration";
import { getLatestRelease } from "../interfaces/releases-providers/releases-providers-integration";
import type {
DetailsProviderResponse,
ReleaseProviderResponse,
ReleasesRepository,
ReleasesResponse,
} from "../interfaces/releases-providers/releases-providers-types";
const localLogger = logger.child({ module: "GithubIntegration" });
export class GithubIntegration extends Integration implements ReleasesProviderIntegration {
private static readonly userAgent = "Homarr-Lab/Homarr:GithubIntegration";
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const headers: RequestInit["headers"] = {
"User-Agent": GithubIntegration.userAgent,
};
if (this.hasSecretValue("personalAccessToken"))
headers.Authorization = `Bearer ${this.getSecretValue("personalAccessToken")}`;
const response = await input.fetchAsync(this.url("/octocat"), {
headers,
});
if (!response.ok) {
return TestConnectionError.StatusResult(response);
}
return {
success: true,
};
}
public async getLatestMatchingReleaseAsync(repository: ReleasesRepository): Promise<ReleasesResponse> {
const [owner, name] = repository.identifier.split("/");
if (!owner || !name) {
localLogger.warn(
`Invalid identifier format. Expected 'owner/name', for ${repository.identifier} with Github integration`,
{
identifier: repository.identifier,
},
);
return {
id: repository.id,
error: { code: "invalidIdentifier" },
};
}
const api = this.getApi();
const details = await this.getDetailsAsync(api, owner, name);
try {
const releasesResponse = await api.rest.repos.listReleases({
owner,
repo: name,
});
if (releasesResponse.data.length === 0) {
localLogger.warn(`No releases found, for ${repository.identifier} with Github integration`, {
identifier: repository.identifier,
});
return {
id: repository.id,
error: { code: "noReleasesFound" },
};
}
const releasesProviderResponse = releasesResponse.data.reduce<ReleaseProviderResponse[]>((acc, release) => {
if (!release.published_at) return acc;
acc.push({
latestRelease: release.tag_name,
latestReleaseAt: new Date(release.published_at),
releaseUrl: release.html_url,
releaseDescription: release.body ?? undefined,
isPreRelease: release.prerelease,
});
return acc;
}, []);
return getLatestRelease(releasesProviderResponse, repository, details);
} catch (error) {
const errorMessage = error instanceof RequestError ? error.message : String(error);
localLogger.warn(`Failed to get releases for ${owner}\\${name} with Github integration`, {
owner,
name,
error: errorMessage,
});
return {
id: repository.id,
error: { message: errorMessage },
};
}
}
protected async getDetailsAsync(
api: Octokit,
owner: string,
name: string,
): Promise<DetailsProviderResponse | undefined> {
try {
const response = await api.rest.repos.get({
owner,
repo: name,
});
return {
projectUrl: response.data.html_url,
projectDescription: response.data.description ?? undefined,
isFork: response.data.fork,
isArchived: response.data.archived,
createdAt: new Date(response.data.created_at),
starsCount: response.data.stargazers_count,
openIssues: response.data.open_issues_count,
forksCount: response.data.forks_count,
};
} catch (error) {
localLogger.warn(`Failed to get details for ${owner}\\${name} with Github integration`, {
owner,
name,
error: error instanceof RequestError ? error.message : String(error),
});
return undefined;
}
}
private getApi() {
return new Octokit({
baseUrl: this.url("/").origin,
request: {
fetch: fetchWithTrustedCertificatesAsync,
},
userAgent: GithubIntegration.userAgent,
throttle: { enabled: false }, // Disable throttling for this integration, Octokit will retry by default after a set time, thus delaying the repsonse to the user in case of errors. Errors will be shown to the user, no need to retry the request.
...(this.hasSecretValue("personalAccessToken") ? { auth: this.getSecretValue("personalAccessToken") } : {}),
});
}
}

View File

@@ -0,0 +1,159 @@
import type { Gitlab as CoreGitlab } from "@gitbeaker/core";
import { createRequesterFn, defaultOptionsHandler } from "@gitbeaker/requester-utils";
import type { FormattedResponse, RequestOptions, ResourceOptions } from "@gitbeaker/requester-utils";
import { Gitlab } from "@gitbeaker/rest";
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
import { logger } from "@homarr/log";
import type { IntegrationTestingInput } from "../base/integration";
import { Integration } from "../base/integration";
import { TestConnectionError } from "../base/test-connection/test-connection-error";
import type { TestingResult } from "../base/test-connection/test-connection-service";
import type { ReleasesProviderIntegration } from "../interfaces/releases-providers/releases-providers-integration";
import { getLatestRelease } from "../interfaces/releases-providers/releases-providers-integration";
import type {
DetailsProviderResponse,
ReleaseProviderResponse,
ReleasesRepository,
ReleasesResponse,
} from "../interfaces/releases-providers/releases-providers-types";
const localLogger = logger.child({ module: "GitlabIntegration" });
export class GitlabIntegration extends Integration implements ReleasesProviderIntegration {
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const response = await input.fetchAsync(this.url("/api/v4/projects"), {
headers: {
...(this.hasSecretValue("personalAccessToken")
? { Authorization: `Bearer ${this.getSecretValue("personalAccessToken")}` }
: {}),
},
});
if (!response.ok) {
return TestConnectionError.StatusResult(response);
}
return {
success: true,
};
}
public async getLatestMatchingReleaseAsync(repository: ReleasesRepository): Promise<ReleasesResponse> {
const api = this.getApi();
const details = await this.getDetailsAsync(api, repository.identifier);
try {
const releasesResponse = await api.ProjectReleases.all(repository.identifier, {
perPage: 100,
});
if (releasesResponse instanceof Error) {
localLogger.warn(`Failed to get releases for ${repository.identifier} with Gitlab integration`, {
identifier: repository.identifier,
error: releasesResponse.message,
});
return {
id: repository.id,
error: { code: "noReleasesFound" },
};
}
const releasesProviderResponse = releasesResponse.reduce<ReleaseProviderResponse[]>((acc, release) => {
if (!release.released_at) return acc;
const releaseDate = new Date(release.released_at);
acc.push({
latestRelease: release.name ?? release.tag_name,
latestReleaseAt: releaseDate,
releaseUrl: release._links.self,
releaseDescription: release.description ?? undefined,
isPreRelease: releaseDate > new Date(), // For upcoming releases the `released_at` will be set to the future (https://docs.gitlab.com/api/releases/#upcoming-releases). Gitbreaker doesn't currently support the `upcoming_release` field (https://github.com/jdalrymple/gitbeaker/issues/3730)
});
return acc;
}, []);
return getLatestRelease(releasesProviderResponse, repository, details);
} catch (error) {
localLogger.warn(`Failed to get releases for ${repository.identifier} with Gitlab integration`, {
identifier: repository.identifier,
error: error instanceof Error ? error.message : String(error),
});
return {
id: repository.id,
error: { code: "noReleasesFound" },
};
}
}
protected async getDetailsAsync(api: CoreGitlab, identifier: string): Promise<DetailsProviderResponse | undefined> {
try {
const response = await api.Projects.show(identifier);
if (response instanceof Error) {
localLogger.warn(`Failed to get details for ${identifier} with Gitlab integration`, {
identifier,
error: response.message,
});
return undefined;
}
if (!response.web_url) {
localLogger.warn(`No web URL found for ${identifier} with Gitlab integration`, {
identifier,
});
return undefined;
}
return {
projectUrl: response.web_url,
projectDescription: response.description,
isFork: response.forked_from_project !== null,
isArchived: response.archived,
createdAt: new Date(response.created_at),
starsCount: response.star_count,
openIssues: response.open_issues_count,
forksCount: response.forks_count,
};
} catch (error) {
localLogger.warn(`Failed to get details for ${identifier} with Gitlab integration`, {
identifier,
error: error instanceof Error ? error.message : String(error),
});
return undefined;
}
}
private getApi() {
return new Gitlab({
host: this.url("/").origin,
requesterFn: createRequesterFn(
async (serviceOptions: ResourceOptions, _: RequestOptions) => await defaultOptionsHandler(serviceOptions),
async (endpoint: string, options?: Record<string, unknown>): Promise<FormattedResponse> => {
if (options === undefined) {
throw new Error("Gitlab library is not configured correctly. Options must be provided.");
}
const response = await fetchWithTrustedCertificatesAsync(
`${options.prefixUrl as string}${endpoint}`,
options,
);
const headers = Object.fromEntries(response.headers.entries());
return {
status: response.status,
headers,
body: await response.json(),
} as FormattedResponse;
},
),
...(this.hasSecretValue("personalAccessToken") ? { token: this.getSecretValue("personalAccessToken") } : {}),
});
}
}

View File

@@ -27,6 +27,7 @@ export type { IntegrationInput } from "./base/integration";
export type { DownloadClientJobsAndStatus } from "./interfaces/downloads/download-client-data";
export type { ExtendedDownloadClientItem } from "./interfaces/downloads/download-client-items";
export type { ExtendedClientStatus } from "./interfaces/downloads/download-client-status";
export type { SystemHealthMonitoring } from "./interfaces/health-monitoring/health-monitoring-types";
export { MediaRequestStatus } from "./interfaces/media-requests/media-request-types";
export type { MediaRequestList, MediaRequestStats } from "./interfaces/media-requests/media-request-types";
@@ -37,6 +38,7 @@ export type {
TdarrStatistics,
TdarrWorker,
} from "./interfaces/media-transcoding/media-transcoding-types";
export type { ReleasesResponse } from "./interfaces/releases-providers/releases-providers-types";
export type { Notification } from "./interfaces/notifications/notification-types";
// Schemas

View File

@@ -0,0 +1,47 @@
import type {
DetailsProviderResponse,
ReleaseProviderResponse,
ReleasesRepository,
ReleasesResponse,
} from "./releases-providers-types";
export const getLatestRelease = (
releases: ReleaseProviderResponse[],
repository: ReleasesRepository,
details?: DetailsProviderResponse,
): ReleasesResponse => {
const validReleases = releases.filter((result) => {
if (result.latestRelease) {
return repository.versionRegex ? new RegExp(repository.versionRegex).test(result.latestRelease) : true;
}
return true;
});
const latest =
validReleases.length === 0
? ({
id: repository.id,
error: { code: "noMatchingVersion" },
} as ReleasesResponse)
: validReleases.reduce(
(latest, result) => {
return {
...details,
...(result.latestReleaseAt > latest.latestReleaseAt ? result : latest),
id: repository.id,
};
},
{
id: "",
latestRelease: "",
latestReleaseAt: new Date(0),
},
);
return latest;
};
export interface ReleasesProviderIntegration {
getLatestMatchingReleaseAsync(repository: ReleasesRepository): Promise<ReleasesResponse>;
}

View File

@@ -0,0 +1,53 @@
import type { TranslationObject } from "@homarr/translation";
export interface DetailsProviderResponse {
projectUrl?: string;
projectDescription?: string;
isFork?: boolean;
isArchived?: boolean;
createdAt?: Date;
starsCount?: number;
openIssues?: number;
forksCount?: number;
}
export interface ReleaseProviderResponse {
latestRelease: string;
latestReleaseAt: Date;
releaseUrl?: string;
releaseDescription?: string;
isPreRelease?: boolean;
}
export interface ReleasesRepository {
id: string;
identifier: string;
versionRegex?: string;
}
type ReleasesErrorKeys = keyof TranslationObject["widget"]["releases"]["error"]["messages"];
export interface ReleasesResponse {
id: string;
latestRelease?: string;
latestReleaseAt?: Date;
releaseUrl?: string;
releaseDescription?: string;
isPreRelease?: boolean;
projectUrl?: string;
projectDescription?: string;
isFork?: boolean;
isArchived?: boolean;
createdAt?: Date;
starsCount?: number;
openIssues?: number;
forksCount?: number;
error?:
| {
code: ReleasesErrorKeys;
}
| {
message: string;
};
}

View File

@@ -0,0 +1,56 @@
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
import type { IntegrationTestingInput } from "../base/integration";
import { Integration } from "../base/integration";
import { TestConnectionError } from "../base/test-connection/test-connection-error";
import type { TestingResult } from "../base/test-connection/test-connection-service";
import type { ReleasesProviderIntegration } from "../interfaces/releases-providers/releases-providers-integration";
import { getLatestRelease } from "../interfaces/releases-providers/releases-providers-integration";
import type { ReleasesRepository, ReleasesResponse } from "../interfaces/releases-providers/releases-providers-types";
import { releasesResponseSchema } from "./npm-schemas";
export class NPMIntegration extends Integration implements ReleasesProviderIntegration {
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const response = await input.fetchAsync(this.url("/"));
if (!response.ok) {
return TestConnectionError.StatusResult(response);
}
return {
success: true,
};
}
public async getLatestMatchingReleaseAsync(repository: ReleasesRepository): Promise<ReleasesResponse> {
const releasesResponse = await fetchWithTrustedCertificatesAsync(
this.url(`/${encodeURIComponent(repository.identifier)}`),
);
if (!releasesResponse.ok) {
return {
id: repository.id,
error: { message: releasesResponse.statusText },
};
}
const releasesResponseJson: unknown = await releasesResponse.json();
const { data, success, error } = releasesResponseSchema.safeParse(releasesResponseJson);
if (!success) {
return {
id: repository.id,
error: {
message: releasesResponseJson ? JSON.stringify(releasesResponseJson, null, 2) : error.message,
},
};
} else {
const formattedReleases = data.time.map((tag) => ({
...tag,
releaseUrl: `https://www.npmjs.com/package/${encodeURIComponent(data.name)}/v/${encodeURIComponent(tag.latestRelease)}`,
releaseDescription: data.versions[tag.latestRelease]?.description ?? "",
}));
return getLatestRelease(formattedReleases, repository);
}
}
}

View File

@@ -0,0 +1,12 @@
import { z } from "zod";
export const releasesResponseSchema = z.object({
time: z.record(z.string().transform((value) => new Date(value))).transform((version) =>
Object.entries(version).map(([key, value]) => ({
latestRelease: key,
latestReleaseAt: value,
})),
),
versions: z.record(z.object({ description: z.string() })),
name: z.string(),
});

View File

@@ -155,7 +155,7 @@ export class OpenMediaVaultIntegration extends Integration implements ISystemHea
return response;
}
localLogger.info("Session expired, getting new session", { integrationId: this.integration.id });
localLogger.debug("Session expired, getting new session", { integrationId: this.integration.id });
}
const session = await this.getSessionAsync();

View File

@@ -128,7 +128,7 @@ export class PiHoleIntegrationV6 extends Integration implements DnsHoleSummaryIn
return response;
}
localLogger.info("Session expired, getting new session", { integrationId: this.integration.id });
localLogger.debug("Session expired, getting new session", { integrationId: this.integration.id });
}
const sessionId = await this.getSessionAsync();

View File

@@ -27,7 +27,7 @@
"ioredis": "5.6.1",
"superjson": "2.2.2",
"winston": "3.17.0",
"zod": "^3.25.74"
"zod": "^3.25.76"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",

View File

@@ -33,13 +33,13 @@
"@homarr/translation": "workspace:^0.1.0",
"@homarr/ui": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0",
"@mantine/core": "^8.1.2",
"@mantine/core": "^8.1.3",
"@tabler/icons-react": "^3.34.0",
"dayjs": "^1.11.13",
"next": "15.3.5",
"react": "19.1.0",
"react-dom": "19.1.0",
"zod": "^3.25.74"
"zod": "^3.25.76"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",

View File

@@ -24,8 +24,8 @@
"dependencies": {
"@homarr/translation": "workspace:^0.1.0",
"@homarr/ui": "workspace:^0.1.0",
"@mantine/core": "^8.1.2",
"@mantine/hooks": "^8.1.2",
"@mantine/core": "^8.1.3",
"@mantine/hooks": "^8.1.3",
"react": "19.1.0"
},
"devDependencies": {

View File

@@ -24,7 +24,7 @@
"prettier": "@homarr/prettier-config",
"dependencies": {
"@homarr/ui": "workspace:^0.1.0",
"@mantine/notifications": "^8.1.2",
"@mantine/notifications": "^8.1.3",
"@tabler/icons-react": "^3.34.0"
},
"devDependencies": {

View File

@@ -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.1.2",
"@mantine/hooks": "^8.1.2",
"@mantine/core": "^8.1.3",
"@mantine/hooks": "^8.1.3",
"adm-zip": "0.5.16",
"next": "15.3.5",
"react": "19.1.0",
"react-dom": "19.1.0",
"superjson": "2.2.2",
"zod": "^3.25.74",
"zod": "^3.25.76",
"zod-form-data": "^2.0.7"
},
"devDependencies": {

View File

@@ -23,7 +23,7 @@
"prettier": "@homarr/prettier-config",
"dependencies": {
"@homarr/common": "workspace:^0.1.0",
"zod": "^3.25.74"
"zod": "^3.25.76"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",

View File

@@ -1,4 +0,0 @@
import baseConfig from "@homarr/eslint-config/base";
/** @type {import('typescript-eslint').Config} */
export default [...baseConfig];

View File

@@ -1 +0,0 @@
export * from "./src";

View File

@@ -1,36 +0,0 @@
{
"name": "@homarr/ping",
"version": "0.1.0",
"private": true,
"license": "Apache-2.0",
"type": "module",
"exports": {
".": "./index.ts"
},
"typesVersions": {
"*": {
"*": [
"src/*"
]
}
},
"scripts": {
"clean": "rm -rf .turbo node_modules",
"format": "prettier --check . --ignore-path ../../.gitignore",
"lint": "eslint",
"typecheck": "tsc --noEmit"
},
"prettier": "@homarr/prettier-config",
"dependencies": {
"@homarr/certificates": "workspace:^0.1.0",
"@homarr/common": "workspace:^0.1.0",
"@homarr/log": "workspace:^0.1.0"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.30.1",
"typescript": "^5.8.3"
}
}

View File

@@ -1,34 +0,0 @@
import type { fetch } from "undici";
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
import { extractErrorMessage } from "@homarr/common";
import { logger } from "@homarr/log";
export const sendPingRequestAsync = async (url: string) => {
try {
return await fetchWithTimeoutAndCertificates(url).then((response) => ({ statusCode: response.status }));
} catch (error) {
logger.error(new Error(`Failed to send ping request to "${url}"`, { cause: error }));
return {
error: extractErrorMessage(error),
};
}
};
/**
* Same as fetch, but with a timeout of 10 seconds.
* Also respects certificates.
* https://stackoverflow.com/questions/46946380/fetch-api-request-timeout
* @param param0 fetch arguments
* @returns fetch response
*/
export const fetchWithTimeoutAndCertificates = (...[url, requestInit]: Parameters<typeof fetch>) => {
const controller = new AbortController();
// 10 seconds timeout:
const timeoutId = setTimeout(() => controller.abort(), 10000);
return fetchWithTrustedCertificatesAsync(url, { signal: controller.signal, ...requestInit }).finally(() => {
clearTimeout(timeoutId);
});
};

View File

@@ -1,9 +0,0 @@
{
"extends": "@homarr/tsconfig/base.json",
"compilerOptions": {
"types": ["node"],
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
},
"include": ["*.ts", "src"],
"exclude": ["node_modules"]
}

View File

@@ -12,10 +12,6 @@ export {
createGetSetChannel,
} from "./lib/channel";
export const exampleChannel = createSubPubChannel<{ message: string }>("example");
export const pingChannel = createSubPubChannel<{ url: string; statusCode: number } | { url: string; error: string }>(
"ping",
);
export const pingUrlChannel = createListChannel<string>("ping-url");
export const homeAssistantEntityState = createSubPubChannel<{

View File

@@ -31,7 +31,8 @@
"@homarr/redis": "workspace:^0.1.0",
"dayjs": "^1.11.13",
"octokit": "^5.0.3",
"superjson": "2.2.2"
"superjson": "2.2.2",
"undici": "7.11.0"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",

View File

@@ -0,0 +1,50 @@
import dayjs from "dayjs";
import duration from "dayjs/plugin/duration";
import { fetch } from "undici";
import { extractErrorMessage } from "@homarr/common";
import { LoggingAgent } from "@homarr/common/server";
import { logger } from "@homarr/log";
import { createCachedWidgetRequestHandler } from "./lib/cached-widget-request-handler";
dayjs.extend(duration);
type PingResponse =
| {
statusCode: number;
durationMs: number;
}
| {
error: string;
};
export const pingRequestHandler = createCachedWidgetRequestHandler<PingResponse, "app", { url: string }>({
queryKey: "pingResult",
widgetKind: "app",
async requestAsync(input) {
return await sendPingRequestAsync(input.url);
},
cacheDuration: dayjs.duration(1, "minute"),
});
const sendPingRequestAsync = async (url: string) => {
try {
const start = performance.now();
return await fetch(url, {
dispatcher: new LoggingAgent({
connect: {
rejectUnauthorized: false,
},
}),
}).then((response) => {
const end = performance.now();
logger.debug(`Ping request succeeded url="${url}" status="${response.status}" duration="${end - start}ms"`);
return { statusCode: response.status, durationMs: end - start };
});
} catch (error) {
logger.error(new Error(`Failed to send ping request to url="${url}"`, { cause: error }));
return {
error: extractErrorMessage(error),
};
}
};

View File

@@ -1,304 +0,0 @@
import { z } from "zod";
export interface ReleasesProvider {
getDetailsUrl: (identifier: string) => string | undefined;
parseDetailsResponse: (response: unknown) => z.SafeParseReturnType<unknown, DetailsResponse> | undefined;
getReleasesUrl: (identifier: string) => string;
parseReleasesResponse: (response: unknown) => z.SafeParseReturnType<unknown, ReleasesResponse[]>;
}
interface ProvidersProps {
[key: string]: ReleasesProvider;
DockerHub: ReleasesProvider;
Github: ReleasesProvider;
Gitlab: ReleasesProvider;
Npm: ReleasesProvider;
Codeberg: ReleasesProvider;
}
export const Providers: ProvidersProps = {
DockerHub: {
getDetailsUrl(identifier) {
if (identifier.indexOf("/") > 0) {
const [owner, name] = identifier.split("/");
if (!owner || !name) {
return "";
}
return `https://hub.docker.com/v2/namespaces/${encodeURIComponent(owner)}/repositories/${encodeURIComponent(name)}`;
} else {
return `https://hub.docker.com/v2/repositories/library/${encodeURIComponent(identifier)}`;
}
},
parseDetailsResponse(response) {
return z
.object({
name: z.string(),
namespace: z.string(),
description: z.string(),
star_count: z.number(),
date_registered: z.string().transform((value) => new Date(value)),
})
.transform((resp) => ({
projectUrl: `https://hub.docker.com/r/${resp.namespace === "library" ? "_" : resp.namespace}/${resp.name}`,
projectDescription: resp.description,
createdAt: resp.date_registered,
starsCount: resp.star_count,
}))
.safeParse(response);
},
getReleasesUrl(identifier) {
return `${this.getDetailsUrl(identifier)}/tags?page_size=200`;
},
parseReleasesResponse(response) {
return z
.object({
results: z.array(
z
.object({ name: z.string(), last_updated: z.string().transform((value) => new Date(value)) })
.transform((tag) => ({
identifier: "",
latestRelease: tag.name,
latestReleaseAt: tag.last_updated,
})),
),
})
.transform((resp) => {
return resp.results;
})
.safeParse(response);
},
},
Github: {
getDetailsUrl(identifier) {
const [owner, name] = identifier.split("/");
if (!owner || !name) {
return "";
}
return `https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}`;
},
parseDetailsResponse(response) {
return z
.object({
html_url: z.string(),
description: z.string().nullable(),
fork: z.boolean(),
archived: z.boolean(),
created_at: z.string().transform((value) => new Date(value)),
stargazers_count: z.number(),
open_issues_count: z.number(),
forks_count: z.number(),
})
.transform((resp) => ({
projectUrl: resp.html_url,
projectDescription: resp.description ?? undefined,
isFork: resp.fork,
isArchived: resp.archived,
createdAt: resp.created_at,
starsCount: resp.stargazers_count,
openIssues: resp.open_issues_count,
forksCount: resp.forks_count,
}))
.safeParse(response);
},
getReleasesUrl(identifier) {
return `${this.getDetailsUrl(identifier)}/releases`;
},
parseReleasesResponse(response) {
return z
.array(
z
.object({
tag_name: z.string(),
published_at: z.string().transform((value) => new Date(value)),
html_url: z.string(),
body: z.string().nullable(),
prerelease: z.boolean(),
})
.transform((tag) => ({
identifier: "",
latestRelease: tag.tag_name,
latestReleaseAt: tag.published_at,
releaseUrl: tag.html_url,
releaseDescription: tag.body ?? undefined,
isPreRelease: tag.prerelease,
})),
)
.safeParse(response);
},
},
Gitlab: {
getDetailsUrl(identifier) {
return `https://gitlab.com/api/v4/projects/${encodeURIComponent(identifier)}`;
},
parseDetailsResponse(response) {
return z
.object({
web_url: z.string(),
description: z.string(),
forked_from_project: z.object({ id: z.number() }).optional(),
archived: z.boolean().optional(),
created_at: z.string().transform((value) => new Date(value)),
star_count: z.number(),
open_issues_count: z.number().optional(),
forks_count: z.number(),
})
.transform((resp) => ({
projectUrl: resp.web_url,
projectDescription: resp.description,
isFork: resp.forked_from_project !== undefined,
isArchived: resp.archived,
createdAt: resp.created_at,
starsCount: resp.star_count,
openIssues: resp.open_issues_count,
forksCount: resp.forks_count,
}))
.safeParse(response);
},
getReleasesUrl(identifier) {
return `${this.getDetailsUrl(identifier)}/releases`;
},
parseReleasesResponse(response) {
return z
.array(
z
.object({
name: z.string(),
released_at: z.string().transform((value) => new Date(value)),
description: z.string(),
_links: z.object({ self: z.string() }),
upcoming_release: z.boolean(),
})
.transform((tag) => ({
identifier: "",
latestRelease: tag.name,
latestReleaseAt: tag.released_at,
releaseUrl: tag._links.self,
releaseDescription: tag.description,
isPreRelease: tag.upcoming_release,
})),
)
.safeParse(response);
},
},
Npm: {
getDetailsUrl(_) {
return undefined;
},
parseDetailsResponse(_) {
return undefined;
},
getReleasesUrl(identifier) {
return `https://registry.npmjs.org/${encodeURIComponent(identifier)}`;
},
parseReleasesResponse(response) {
return z
.object({
time: z.record(z.string().transform((value) => new Date(value))).transform((version) =>
Object.entries(version).map(([key, value]) => ({
identifier: "",
latestRelease: key,
latestReleaseAt: value,
})),
),
versions: z.record(z.object({ description: z.string() })),
name: z.string(),
})
.transform((resp) => {
return resp.time.map((release) => ({
...release,
releaseUrl: `https://www.npmjs.com/package/${resp.name}/v/${release.latestRelease}`,
releaseDescription: resp.versions[release.latestRelease]?.description ?? "",
}));
})
.safeParse(response);
},
},
Codeberg: {
getDetailsUrl(identifier) {
const [owner, name] = identifier.split("/");
if (!owner || !name) {
return "";
}
return `https://codeberg.org/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}`;
},
parseDetailsResponse(response) {
return z
.object({
html_url: z.string(),
description: z.string(),
fork: z.boolean(),
archived: z.boolean(),
created_at: z.string().transform((value) => new Date(value)),
stars_count: z.number(),
open_issues_count: z.number(),
forks_count: z.number(),
})
.transform((resp) => ({
projectUrl: resp.html_url,
projectDescription: resp.description,
isFork: resp.fork,
isArchived: resp.archived,
createdAt: resp.created_at,
starsCount: resp.stars_count,
openIssues: resp.open_issues_count,
forksCount: resp.forks_count,
}))
.safeParse(response);
},
getReleasesUrl(identifier) {
return `${this.getDetailsUrl(identifier)}/releases`;
},
parseReleasesResponse(response) {
return z
.array(
z
.object({
tag_name: z.string(),
published_at: z.string().transform((value) => new Date(value)),
url: z.string(),
body: z.string(),
prerelease: z.boolean(),
})
.transform((tag) => ({
latestRelease: tag.tag_name,
latestReleaseAt: tag.published_at,
releaseUrl: tag.url,
releaseDescription: tag.body,
isPreRelease: tag.prerelease,
})),
)
.safeParse(response);
},
},
};
const _detailsSchema = z
.object({
projectUrl: z.string().optional(),
projectDescription: z.string().optional(),
isFork: z.boolean().optional(),
isArchived: z.boolean().optional(),
createdAt: z.date().optional(),
starsCount: z.number().optional(),
openIssues: z.number().optional(),
forksCount: z.number().optional(),
})
.optional();
const _releasesSchema = z.object({
latestRelease: z.string(),
latestReleaseAt: z.date(),
releaseUrl: z.string().optional(),
releaseDescription: z.string().optional(),
isPreRelease: z.boolean().optional(),
error: z
.object({
code: z.string().optional(),
message: z.string().optional(),
})
.optional(),
});
export type DetailsResponse = z.infer<typeof _detailsSchema>;
export type ReleasesResponse = z.infer<typeof _releasesSchema>;

View File

@@ -1,122 +1,37 @@
import dayjs from "dayjs";
import { z } from "zod";
import { fetchWithTimeout } from "@homarr/common";
import { logger } from "@homarr/log";
import type { IntegrationKindByCategory } from "@homarr/definitions";
import { getIconUrl } from "@homarr/definitions";
import { createIntegrationAsync } from "@homarr/integrations";
import type { ReleasesResponse } from "@homarr/integrations";
import { createCachedWidgetRequestHandler } from "./lib/cached-widget-request-handler";
import { Providers } from "./releases-providers";
import type { DetailsResponse } from "./releases-providers";
import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
const errorSchema = z.object({
code: z.string().optional(),
message: z.string().optional(),
});
export const releasesRequestHandler = createCachedIntegrationRequestHandler<
ReleasesResponse,
IntegrationKindByCategory<"releasesProvider">,
{
id: string;
identifier: string;
versionRegex?: string;
}
>({
async requestAsync(integration, input) {
const integrationInstance = await createIntegrationAsync(integration);
const response = await integrationInstance.getLatestMatchingReleaseAsync({
id: input.id,
identifier: input.identifier,
versionRegex: input.versionRegex,
});
type ReleasesError = z.infer<typeof errorSchema>;
const _reponseSchema = z.object({
identifier: z.string(),
providerKey: z.string(),
latestRelease: z.string().optional(),
latestReleaseAt: z.date().optional(),
releaseUrl: z.string().optional(),
releaseDescription: z.string().optional(),
isPreRelease: z.boolean().optional(),
projectUrl: z.string().optional(),
projectDescription: z.string().optional(),
isFork: z.boolean().optional(),
isArchived: z.boolean().optional(),
createdAt: z.date().optional(),
starsCount: z.number().optional(),
openIssues: z.number().optional(),
forksCount: z.number().optional(),
error: errorSchema.optional(),
});
const formatErrorRelease = (identifier: string, providerKey: string, error: ReleasesError) => ({
identifier,
providerKey,
latestRelease: undefined,
latestReleaseAt: undefined,
releaseUrl: undefined,
releaseDescription: undefined,
isPreRelease: undefined,
projectUrl: undefined,
projectDescription: undefined,
isFork: undefined,
isArchived: undefined,
createdAt: undefined,
starsCount: undefined,
openIssues: undefined,
forksCount: undefined,
error,
});
export const releasesRequestHandler = createCachedWidgetRequestHandler({
queryKey: "releasesApiResult",
widgetKind: "releases",
async requestAsync(input: { providerKey: string; identifier: string; versionRegex: string | undefined }) {
const provider = Providers[input.providerKey];
if (!provider) return undefined;
let detailsResult: DetailsResponse;
const detailsUrl = provider.getDetailsUrl(input.identifier);
if (detailsUrl !== undefined) {
const detailsResponse = await fetchWithTimeout(detailsUrl);
const parsedDetails = provider.parseDetailsResponse(await detailsResponse.json());
if (parsedDetails?.success) {
detailsResult = parsedDetails.data;
} else {
detailsResult = undefined;
logger.warn(`Failed to parse details response for ${input.identifier} on ${input.providerKey}`, {
provider: input.providerKey,
identifier: input.identifier,
detailsUrl,
error: parsedDetails?.error,
});
}
}
const releasesResponse = await fetchWithTimeout(provider.getReleasesUrl(input.identifier));
const releasesResponseJson: unknown = await releasesResponse.json();
const releasesResult = provider.parseReleasesResponse(releasesResponseJson);
if (!releasesResult.success) {
return formatErrorRelease(input.identifier, input.providerKey, {
message: releasesResponseJson ? JSON.stringify(releasesResponseJson, null, 2) : releasesResult.error.message,
});
} else {
const releases = releasesResult.data.filter((result) =>
input.versionRegex && result.latestRelease ? new RegExp(input.versionRegex).test(result.latestRelease) : true,
);
const latest =
releases.length === 0
? formatErrorRelease(input.identifier, input.providerKey, { code: "noMatchingVersion" })
: releases.reduce(
(latest, result) => {
return {
...detailsResult,
...(result.latestReleaseAt > latest.latestReleaseAt ? result : latest),
identifier: input.identifier,
providerKey: input.providerKey,
};
},
{
identifier: "",
providerKey: "",
latestRelease: "",
latestReleaseAt: new Date(0),
},
);
return latest;
}
return {
...response,
integration: {
name: integration.name,
iconUrl: getIconUrl(integration.kind),
},
};
},
cacheDuration: dayjs.duration(5, "minutes"),
queryKey: "repositoriesReleases",
});
export type ReleaseResponse = z.infer<typeof _reponseSchema>;

View File

@@ -26,7 +26,7 @@
"@homarr/api": "workspace:^0.1.0",
"@homarr/db": "workspace:^0.1.0",
"@homarr/server-settings": "workspace:^0.1.0",
"@mantine/dates": "^8.1.2",
"@mantine/dates": "^8.1.3",
"next": "15.3.5",
"react": "19.1.0",
"react-dom": "19.1.0"

View File

@@ -33,9 +33,9 @@
"@homarr/settings": "workspace:^0.1.0",
"@homarr/translation": "workspace:^0.1.0",
"@homarr/ui": "workspace:^0.1.0",
"@mantine/core": "^8.1.2",
"@mantine/hooks": "^8.1.2",
"@mantine/spotlight": "^8.1.2",
"@mantine/core": "^8.1.3",
"@mantine/hooks": "^8.1.3",
"@mantine/spotlight": "^8.1.3",
"@tabler/icons-react": "^3.34.0",
"jotai": "^2.12.5",
"next": "15.3.5",

View File

@@ -1502,7 +1502,8 @@
"rows": "",
"width": "",
"height": ""
}
},
"placeholder": ""
},
"iframe": {
"name": "",
@@ -2303,7 +2304,7 @@
"created": "",
"error": {
"label": "",
"options": {
"messages": {
"noMatchingVersion": ""
}
}

View File

@@ -1502,7 +1502,8 @@
"rows": "行数",
"width": "宽度",
"height": "高度"
}
},
"placeholder": ""
},
"iframe": {
"name": "iFrame",
@@ -2303,7 +2304,7 @@
"created": "已创建",
"error": {
"label": "错误",
"options": {
"messages": {
"noMatchingVersion": "没有找到匹配的版本"
}
}

View File

@@ -1502,7 +1502,8 @@
"rows": "Řádky",
"width": "Šířka",
"height": "Výška"
}
},
"placeholder": ""
},
"iframe": {
"name": "iFrame",
@@ -2303,7 +2304,7 @@
"created": "",
"error": {
"label": "",
"options": {
"messages": {
"noMatchingVersion": ""
}
}

View File

@@ -1502,7 +1502,8 @@
"rows": "Rækker",
"width": "Bredde",
"height": "Højde"
}
},
"placeholder": ""
},
"iframe": {
"name": "indlejret dokument (iframe)",
@@ -2303,7 +2304,7 @@
"created": "Oprettet",
"error": {
"label": "Fejl",
"options": {
"messages": {
"noMatchingVersion": "Ingen matchende version fundet"
}
}

View File

@@ -1502,7 +1502,8 @@
"rows": "Zeilen",
"width": "Breite",
"height": "Höhe"
}
},
"placeholder": ""
},
"iframe": {
"name": "",
@@ -2303,7 +2304,7 @@
"created": "",
"error": {
"label": "",
"options": {
"messages": {
"noMatchingVersion": ""
}
}

View File

@@ -1502,7 +1502,8 @@
"rows": "Zeilen",
"width": "Breite",
"height": "Höhe"
}
},
"placeholder": ""
},
"iframe": {
"name": "iFrame",
@@ -2303,7 +2304,7 @@
"created": "Erstellt",
"error": {
"label": "Fehler",
"options": {
"messages": {
"noMatchingVersion": "Keine passende Version gefunden"
}
}

View File

@@ -1502,7 +1502,8 @@
"rows": "Γραμμές",
"width": "Πλάτος",
"height": "Ύψος"
}
},
"placeholder": ""
},
"iframe": {
"name": "iframe",
@@ -2303,7 +2304,7 @@
"created": "",
"error": {
"label": "",
"options": {
"messages": {
"noMatchingVersion": ""
}
}

View File

@@ -1502,7 +1502,8 @@
"rows": "",
"width": "",
"height": ""
}
},
"placeholder": ""
},
"iframe": {
"name": "",
@@ -2303,7 +2304,7 @@
"created": "",
"error": {
"label": "",
"options": {
"messages": {
"noMatchingVersion": ""
}
}

View File

@@ -937,6 +937,10 @@
"label": "Realm",
"newLabel": "New realm"
},
"personalAccessToken": {
"label": "Personal Access Token",
"newLabel": "New Personal Access Token"
},
"topic": {
"label": "Topic",
"newLabel": "New topic"
@@ -1502,7 +1506,8 @@
"rows": "Rows",
"width": "Width",
"height": "Height"
}
},
"placeholder": "Start writing your notes"
},
"iframe": {
"name": "iFrame",
@@ -2287,7 +2292,11 @@
"example": {
"label": "Example"
},
"invalid": "Invalid repository definition, please check the values"
"invalid": "Invalid repository definition, please check the values",
"noProvider": {
"label": "No Provider",
"tooltip": "The provider could not be parsed, please manually set it after importing the images"
}
}
},
"not-found": "Not Found",
@@ -2303,8 +2312,12 @@
"created": "Created",
"error": {
"label": "Error",
"options": {
"noMatchingVersion": "No matching version found"
"messages": {
"invalidIdentifier": "Invalid identifier",
"noMatchingVersion": "No matching version found",
"noReleasesFound": "No releases found",
"noProviderSeleceted": "No provider selected",
"noProviderResponse": "No response from provider"
}
}
},
@@ -2555,6 +2568,9 @@
"iconColor": {
"label": "Icon color"
},
"clearColor": {
"label": "Clear color"
},
"customCss": {
"label": "Custom css for this board",
"description": "Further, customize your dashboard using CSS, only recommended for experienced users",

View File

@@ -305,7 +305,7 @@
"search": "",
"field": {
"name": "Nombre",
"members": "",
"members": "Miembros",
"homeBoard": {
"label": "",
"description": ""
@@ -1502,7 +1502,8 @@
"rows": "Filas",
"width": "Ancho",
"height": "Alto"
}
},
"placeholder": ""
},
"iframe": {
"name": "",
@@ -2303,7 +2304,7 @@
"created": "",
"error": {
"label": "",
"options": {
"messages": {
"noMatchingVersion": ""
}
}

View File

@@ -1502,7 +1502,8 @@
"rows": "",
"width": "",
"height": ""
}
},
"placeholder": ""
},
"iframe": {
"name": "",
@@ -2303,7 +2304,7 @@
"created": "",
"error": {
"label": "",
"options": {
"messages": {
"noMatchingVersion": ""
}
}

View File

@@ -1502,7 +1502,8 @@
"rows": "Lignes",
"width": "Largeur",
"height": "Hauteur"
}
},
"placeholder": ""
},
"iframe": {
"name": "iFrame",
@@ -2303,7 +2304,7 @@
"created": "",
"error": {
"label": "",
"options": {
"messages": {
"noMatchingVersion": ""
}
}

View File

@@ -1502,7 +1502,8 @@
"rows": "שורות",
"width": "רוחב",
"height": "גובה"
}
},
"placeholder": ""
},
"iframe": {
"name": "iFrame",
@@ -2303,7 +2304,7 @@
"created": "נוצר",
"error": {
"label": "שגיאה",
"options": {
"messages": {
"noMatchingVersion": "לא נמצאה גרסה תואמת"
}
}

View File

@@ -1502,7 +1502,8 @@
"rows": "",
"width": "Širina",
"height": "Visina"
}
},
"placeholder": ""
},
"iframe": {
"name": "",
@@ -2303,7 +2304,7 @@
"created": "",
"error": {
"label": "",
"options": {
"messages": {
"noMatchingVersion": ""
}
}

View File

@@ -1502,7 +1502,8 @@
"rows": "Sorok",
"width": "Szélesség",
"height": "Magasság"
}
},
"placeholder": ""
},
"iframe": {
"name": "Beágyazott keret (iFrame)",
@@ -2303,7 +2304,7 @@
"created": "",
"error": {
"label": "",
"options": {
"messages": {
"noMatchingVersion": ""
}
}

View File

@@ -1502,7 +1502,8 @@
"rows": "Righe",
"width": "Larghezza",
"height": "Altezza"
}
},
"placeholder": ""
},
"iframe": {
"name": "",
@@ -2303,7 +2304,7 @@
"created": "",
"error": {
"label": "",
"options": {
"messages": {
"noMatchingVersion": ""
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1502,7 +1502,8 @@
"rows": "",
"width": "너비",
"height": "높이"
}
},
"placeholder": ""
},
"iframe": {
"name": "",
@@ -2303,7 +2304,7 @@
"created": "",
"error": {
"label": "",
"options": {
"messages": {
"noMatchingVersion": ""
}
}

View File

@@ -1502,7 +1502,8 @@
"rows": "Eilutės",
"width": "Plotis",
"height": "Aukštis"
}
},
"placeholder": ""
},
"iframe": {
"name": "",
@@ -2303,7 +2304,7 @@
"created": "",
"error": {
"label": "",
"options": {
"messages": {
"noMatchingVersion": ""
}
}

View File

@@ -1502,7 +1502,8 @@
"rows": "Rindas",
"width": "Platums",
"height": "Augstums"
}
},
"placeholder": ""
},
"iframe": {
"name": "",
@@ -2303,7 +2304,7 @@
"created": "",
"error": {
"label": "",
"options": {
"messages": {
"noMatchingVersion": ""
}
}

View File

@@ -613,14 +613,14 @@
"label": "App selecteren",
"notFound": "Geen app gevonden",
"search": "Zoek naar een app",
"noResults": "Geen resultaten gevonden voor deze zoekopdracht",
"action": "Selecteer {app}",
"noResults": "Geen resultaten",
"action": "{app} selecteren",
"title": "Selecteer een app om aan dit bord toe te voegen"
},
"create": {
"title": "Nieuwe app aanmaken",
"description": "Een nieuwe app aanmaken ",
"action": "Open app aanmaken"
"action": "App aanmaken openen"
},
"add": "Een app toevoegen"
}
@@ -717,7 +717,7 @@
"description": ""
},
"authorization": {
"title": "Fout tijdens het aanmelden",
"title": "Autorisatie fout",
"description": ""
},
"statusCode": {
@@ -1502,7 +1502,8 @@
"rows": "Rijen",
"width": "Breedte",
"height": "Hoogte"
}
},
"placeholder": ""
},
"iframe": {
"name": "iFrame",
@@ -2303,7 +2304,7 @@
"created": "",
"error": {
"label": "",
"options": {
"messages": {
"noMatchingVersion": ""
}
}
@@ -3218,7 +3219,7 @@
"title": "Containers",
"table": {
"updated": "{when} bijgewerkt",
"search": "Zoek {count} containers",
"search": "{count} containers zoeken",
"selected": "{selectCount} van {totalCount} containers geselecteerd",
"footer": ""
},

View File

@@ -1502,7 +1502,8 @@
"rows": "Rader",
"width": "Bredde",
"height": "Høyde"
}
},
"placeholder": ""
},
"iframe": {
"name": "iFrame",
@@ -2303,7 +2304,7 @@
"created": "",
"error": {
"label": "",
"options": {
"messages": {
"noMatchingVersion": ""
}
}

View File

@@ -1502,7 +1502,8 @@
"rows": "Wiersze",
"width": "Szerokość",
"height": "Wysokość"
}
},
"placeholder": ""
},
"iframe": {
"name": "iFrame",
@@ -2303,7 +2304,7 @@
"created": "",
"error": {
"label": "",
"options": {
"messages": {
"noMatchingVersion": ""
}
}

View File

@@ -1502,7 +1502,8 @@
"rows": "Linhas",
"width": "Largura",
"height": "Altura"
}
},
"placeholder": ""
},
"iframe": {
"name": "",
@@ -2303,7 +2304,7 @@
"created": "",
"error": {
"label": "",
"options": {
"messages": {
"noMatchingVersion": ""
}
}

View File

@@ -1502,7 +1502,8 @@
"rows": "Rânduri",
"width": "Lățime",
"height": "Înălțime"
}
},
"placeholder": ""
},
"iframe": {
"name": "",
@@ -2303,7 +2304,7 @@
"created": "",
"error": {
"label": "",
"options": {
"messages": {
"noMatchingVersion": ""
}
}

View File

@@ -1502,7 +1502,8 @@
"rows": "Строки",
"width": "Ширина",
"height": "Высота"
}
},
"placeholder": ""
},
"iframe": {
"name": "iFrame",
@@ -2303,7 +2304,7 @@
"created": "",
"error": {
"label": "",
"options": {
"messages": {
"noMatchingVersion": ""
}
}

View File

@@ -1502,7 +1502,8 @@
"rows": "Riadky",
"width": "Šírka",
"height": "Výška"
}
},
"placeholder": ""
},
"iframe": {
"name": "iFrame",
@@ -2303,7 +2304,7 @@
"created": "Vytvorené",
"error": {
"label": "Chyba",
"options": {
"messages": {
"noMatchingVersion": "Nenašla sa žiadna zodpovedajúca verzia"
}
}

Some files were not shown because too many files have changed in this diff Show More