feat(releases-widget): define providers as integrations (#3253)
Co-authored-by: Meier Lukas <meierschlumpf@gmail.com>
This commit is contained in:
@@ -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>;
|
||||
|
||||
@@ -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: "",
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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;
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
"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",
|
||||
|
||||
1
packages/common/src/id.ts
Normal file
1
packages/common/src/id.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { createId } from "@paralleldrive/cuid2";
|
||||
@@ -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";
|
||||
|
||||
@@ -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}"`);
|
||||
}
|
||||
6
packages/db/migrations/custom/index.ts
Normal file
6
packages/db/migrations/custom/index.ts
Normal 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);
|
||||
};
|
||||
12
packages/db/migrations/custom/run-custom.ts
Normal file
12
packages/db/migrations/custom/run-custom.ts
Normal 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);
|
||||
});
|
||||
@@ -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()
|
||||
|
||||
@@ -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`);
|
||||
};
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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",
|
||||
@@ -52,7 +53,8 @@
|
||||
"drizzle-kit": "^0.31.4",
|
||||
"drizzle-orm": "^0.44.2",
|
||||
"drizzle-zod": "^0.7.1",
|
||||
"mysql2": "3.14.2"
|
||||
"mysql2": "3.14.2",
|
||||
"superjson": "2.2.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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,6 +41,7 @@
|
||||
"@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",
|
||||
|
||||
@@ -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>]>;
|
||||
|
||||
143
packages/integrations/src/codeberg/codeberg-integration.ts
Normal file
143
packages/integrations/src/codeberg/codeberg-integration.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
22
packages/integrations/src/codeberg/codeberg-schemas.ts
Normal file
22
packages/integrations/src/codeberg/codeberg-schemas.ts
Normal 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(),
|
||||
});
|
||||
190
packages/integrations/src/docker-hub/docker-hub-integration.ts
Normal file
190
packages/integrations/src/docker-hub/docker-hub-integration.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
22
packages/integrations/src/docker-hub/docker-hub-schemas.ts
Normal file
22
packages/integrations/src/docker-hub/docker-hub-schemas.ts
Normal 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)),
|
||||
});
|
||||
152
packages/integrations/src/github/github-integration.ts
Normal file
152
packages/integrations/src/github/github-integration.ts
Normal 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") } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
159
packages/integrations/src/gitlab/gitlab-integration.ts
Normal file
159
packages/integrations/src/gitlab/gitlab-integration.ts
Normal 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") } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
56
packages/integrations/src/npm/npm-integration.ts
Normal file
56
packages/integrations/src/npm/npm-integration.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
12
packages/integrations/src/npm/npm-schemas.ts
Normal file
12
packages/integrations/src/npm/npm-schemas.ts
Normal 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(),
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>;
|
||||
@@ -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>;
|
||||
|
||||
@@ -2304,7 +2304,7 @@
|
||||
"created": "",
|
||||
"error": {
|
||||
"label": "",
|
||||
"options": {
|
||||
"messages": {
|
||||
"noMatchingVersion": ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2304,7 +2304,7 @@
|
||||
"created": "已创建",
|
||||
"error": {
|
||||
"label": "错误",
|
||||
"options": {
|
||||
"messages": {
|
||||
"noMatchingVersion": "没有找到匹配的版本"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2304,7 +2304,7 @@
|
||||
"created": "",
|
||||
"error": {
|
||||
"label": "",
|
||||
"options": {
|
||||
"messages": {
|
||||
"noMatchingVersion": ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2304,7 +2304,7 @@
|
||||
"created": "Oprettet",
|
||||
"error": {
|
||||
"label": "Fejl",
|
||||
"options": {
|
||||
"messages": {
|
||||
"noMatchingVersion": "Ingen matchende version fundet"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2304,7 +2304,7 @@
|
||||
"created": "",
|
||||
"error": {
|
||||
"label": "",
|
||||
"options": {
|
||||
"messages": {
|
||||
"noMatchingVersion": ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2304,7 +2304,7 @@
|
||||
"created": "Erstellt",
|
||||
"error": {
|
||||
"label": "Fehler",
|
||||
"options": {
|
||||
"messages": {
|
||||
"noMatchingVersion": "Keine passende Version gefunden"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2304,7 +2304,7 @@
|
||||
"created": "",
|
||||
"error": {
|
||||
"label": "",
|
||||
"options": {
|
||||
"messages": {
|
||||
"noMatchingVersion": ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2304,7 +2304,7 @@
|
||||
"created": "",
|
||||
"error": {
|
||||
"label": "",
|
||||
"options": {
|
||||
"messages": {
|
||||
"noMatchingVersion": ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
@@ -2288,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",
|
||||
@@ -2304,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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -2304,7 +2304,7 @@
|
||||
"created": "",
|
||||
"error": {
|
||||
"label": "",
|
||||
"options": {
|
||||
"messages": {
|
||||
"noMatchingVersion": ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2304,7 +2304,7 @@
|
||||
"created": "",
|
||||
"error": {
|
||||
"label": "",
|
||||
"options": {
|
||||
"messages": {
|
||||
"noMatchingVersion": ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2304,7 +2304,7 @@
|
||||
"created": "",
|
||||
"error": {
|
||||
"label": "",
|
||||
"options": {
|
||||
"messages": {
|
||||
"noMatchingVersion": ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2304,7 +2304,7 @@
|
||||
"created": "נוצר",
|
||||
"error": {
|
||||
"label": "שגיאה",
|
||||
"options": {
|
||||
"messages": {
|
||||
"noMatchingVersion": "לא נמצאה גרסה תואמת"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2304,7 +2304,7 @@
|
||||
"created": "",
|
||||
"error": {
|
||||
"label": "",
|
||||
"options": {
|
||||
"messages": {
|
||||
"noMatchingVersion": ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2304,7 +2304,7 @@
|
||||
"created": "",
|
||||
"error": {
|
||||
"label": "",
|
||||
"options": {
|
||||
"messages": {
|
||||
"noMatchingVersion": ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2304,7 +2304,7 @@
|
||||
"created": "",
|
||||
"error": {
|
||||
"label": "",
|
||||
"options": {
|
||||
"messages": {
|
||||
"noMatchingVersion": ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2304,7 +2304,7 @@
|
||||
"created": "作成日",
|
||||
"error": {
|
||||
"label": "エラー",
|
||||
"options": {
|
||||
"messages": {
|
||||
"noMatchingVersion": "一致するバージョンが見つかりません"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2304,7 +2304,7 @@
|
||||
"created": "",
|
||||
"error": {
|
||||
"label": "",
|
||||
"options": {
|
||||
"messages": {
|
||||
"noMatchingVersion": ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2304,7 +2304,7 @@
|
||||
"created": "",
|
||||
"error": {
|
||||
"label": "",
|
||||
"options": {
|
||||
"messages": {
|
||||
"noMatchingVersion": ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2304,7 +2304,7 @@
|
||||
"created": "",
|
||||
"error": {
|
||||
"label": "",
|
||||
"options": {
|
||||
"messages": {
|
||||
"noMatchingVersion": ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2304,7 +2304,7 @@
|
||||
"created": "",
|
||||
"error": {
|
||||
"label": "",
|
||||
"options": {
|
||||
"messages": {
|
||||
"noMatchingVersion": ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2304,7 +2304,7 @@
|
||||
"created": "",
|
||||
"error": {
|
||||
"label": "",
|
||||
"options": {
|
||||
"messages": {
|
||||
"noMatchingVersion": ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2304,7 +2304,7 @@
|
||||
"created": "",
|
||||
"error": {
|
||||
"label": "",
|
||||
"options": {
|
||||
"messages": {
|
||||
"noMatchingVersion": ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2304,7 +2304,7 @@
|
||||
"created": "",
|
||||
"error": {
|
||||
"label": "",
|
||||
"options": {
|
||||
"messages": {
|
||||
"noMatchingVersion": ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2304,7 +2304,7 @@
|
||||
"created": "",
|
||||
"error": {
|
||||
"label": "",
|
||||
"options": {
|
||||
"messages": {
|
||||
"noMatchingVersion": ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2304,7 +2304,7 @@
|
||||
"created": "",
|
||||
"error": {
|
||||
"label": "",
|
||||
"options": {
|
||||
"messages": {
|
||||
"noMatchingVersion": ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2304,7 +2304,7 @@
|
||||
"created": "Vytvorené",
|
||||
"error": {
|
||||
"label": "Chyba",
|
||||
"options": {
|
||||
"messages": {
|
||||
"noMatchingVersion": "Nenašla sa žiadna zodpovedajúca verzia"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2304,7 +2304,7 @@
|
||||
"created": "",
|
||||
"error": {
|
||||
"label": "",
|
||||
"options": {
|
||||
"messages": {
|
||||
"noMatchingVersion": ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2304,7 +2304,7 @@
|
||||
"created": "",
|
||||
"error": {
|
||||
"label": "",
|
||||
"options": {
|
||||
"messages": {
|
||||
"noMatchingVersion": ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2304,7 +2304,7 @@
|
||||
"created": "Oluşturuldu",
|
||||
"error": {
|
||||
"label": "Hata",
|
||||
"options": {
|
||||
"messages": {
|
||||
"noMatchingVersion": "Eşleşen sürüm bulunamadı"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2304,7 +2304,7 @@
|
||||
"created": "",
|
||||
"error": {
|
||||
"label": "",
|
||||
"options": {
|
||||
"messages": {
|
||||
"noMatchingVersion": ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2304,7 +2304,7 @@
|
||||
"created": "",
|
||||
"error": {
|
||||
"label": "",
|
||||
"options": {
|
||||
"messages": {
|
||||
"noMatchingVersion": ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2304,7 +2304,7 @@
|
||||
"created": "已創建",
|
||||
"error": {
|
||||
"label": "錯誤",
|
||||
"options": {
|
||||
"messages": {
|
||||
"noMatchingVersion": "找不到匹配的版本"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ import type { CheckboxProps } from "@mantine/core";
|
||||
import type { FormErrors } from "@mantine/form";
|
||||
import { useDebouncedValue } from "@mantine/hooks";
|
||||
import {
|
||||
IconAlertTriangleFilled,
|
||||
IconBrandDocker,
|
||||
IconEdit,
|
||||
IconPlus,
|
||||
@@ -35,13 +36,17 @@ import { escapeForRegEx } from "@tiptap/react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
import { createId } from "@homarr/common";
|
||||
import { getIconUrl } from "@homarr/definitions";
|
||||
import type { IntegrationKind } from "@homarr/definitions";
|
||||
import { findBestIconMatch, IconPicker } from "@homarr/forms-collection";
|
||||
import { createModal, useModalAction } from "@homarr/modals";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { MaskedImage } from "@homarr/ui";
|
||||
|
||||
import { isProviderKey, Providers } from "../releases/releases-providers";
|
||||
import type { ReleasesRepository, ReleasesVersionFilter } from "../releases/releases-repository";
|
||||
import { WidgetIntegrationSelect } from "../widget-integration-select";
|
||||
import type { IntegrationSelectOption } from "../widget-integration-select";
|
||||
import type { CommonWidgetInputProps } from "./common";
|
||||
import { useWidgetInputTranslation } from "./common";
|
||||
import { useFormContext } from "./form";
|
||||
@@ -51,6 +56,10 @@ interface FormValidation {
|
||||
errors: FormErrors;
|
||||
}
|
||||
|
||||
interface Integration extends IntegrationSelectOption {
|
||||
iconUrl: string;
|
||||
}
|
||||
|
||||
export const WidgetMultiReleasesRepositoriesInput = ({
|
||||
property,
|
||||
kind,
|
||||
@@ -68,9 +77,34 @@ export const WidgetMultiReleasesRepositoriesInput = ({
|
||||
const { data: session } = useSession();
|
||||
const isAdmin = session?.user.permissions.includes("admin") ?? false;
|
||||
|
||||
const integrationsApi = clientApi.integration.allOfGivenCategory.useQuery(
|
||||
{
|
||||
category: "releasesProvider",
|
||||
},
|
||||
{
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
},
|
||||
);
|
||||
const integrations = useMemo(
|
||||
() =>
|
||||
integrationsApi.data?.reduce<Record<string, Integration>>((acc, integration) => {
|
||||
acc[integration.id] = {
|
||||
id: integration.id,
|
||||
name: integration.name,
|
||||
url: integration.url,
|
||||
kind: integration.kind,
|
||||
iconUrl: getIconUrl(integration.kind),
|
||||
};
|
||||
return acc;
|
||||
}, {}) ?? {},
|
||||
[integrationsApi],
|
||||
);
|
||||
|
||||
const onRepositorySave = useCallback(
|
||||
(repository: ReleasesRepository, index: number): FormValidation => {
|
||||
form.setFieldValue(`options.${property}.${index}.providerKey`, repository.providerKey);
|
||||
form.setFieldValue(`options.${property}.${index}.providerIntegrationId`, repository.providerIntegrationId);
|
||||
form.setFieldValue(`options.${property}.${index}.identifier`, repository.identifier);
|
||||
form.setFieldValue(`options.${property}.${index}.name`, repository.name);
|
||||
form.setFieldValue(`options.${property}.${index}.versionFilter`, repository.versionFilter);
|
||||
@@ -94,7 +128,7 @@ export const WidgetMultiReleasesRepositoriesInput = ({
|
||||
|
||||
const addNewRepository = () => {
|
||||
const repository: ReleasesRepository = {
|
||||
providerKey: "DockerHub",
|
||||
id: createId(),
|
||||
identifier: "",
|
||||
};
|
||||
|
||||
@@ -117,6 +151,7 @@ export const WidgetMultiReleasesRepositoriesInput = ({
|
||||
onRepositorySave: (saved) => onRepositorySave(saved, index),
|
||||
onRepositoryCancel: () => onRepositoryRemove(index),
|
||||
versionFilterPrecisionOptions,
|
||||
integrations,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -147,6 +182,7 @@ export const WidgetMultiReleasesRepositoriesInput = ({
|
||||
onClick={() =>
|
||||
openImportModal({
|
||||
repositories,
|
||||
integrations,
|
||||
versionFilterPrecisionOptions,
|
||||
onConfirm: (selectedRepositories) => {
|
||||
if (!selectedRepositories.length) return;
|
||||
@@ -173,11 +209,14 @@ export const WidgetMultiReleasesRepositoriesInput = ({
|
||||
<Divider my="sm" />
|
||||
|
||||
{repositories.map((repository, index) => {
|
||||
const integration = repository.providerIntegrationId
|
||||
? integrations[repository.providerIntegrationId]
|
||||
: undefined;
|
||||
return (
|
||||
<Stack key={`${repository.providerKey}.${repository.identifier}`} gap={5}>
|
||||
<Stack key={repository.id} gap={5}>
|
||||
<Group align="center" gap="xs">
|
||||
<Image
|
||||
src={repository.iconUrl ?? Providers[repository.providerKey].iconUrl}
|
||||
src={repository.iconUrl ?? integration?.iconUrl ?? null}
|
||||
style={{
|
||||
height: "1.2em",
|
||||
width: "1.2em",
|
||||
@@ -185,7 +224,7 @@ export const WidgetMultiReleasesRepositoriesInput = ({
|
||||
/>
|
||||
|
||||
<Text c="dimmed" fw={100} size="xs">
|
||||
{Providers[repository.providerKey].name}
|
||||
{integration?.name ?? ""}
|
||||
</Text>
|
||||
|
||||
<Group justify="space-between" align="center" style={{ flex: 1 }} gap={5}>
|
||||
@@ -202,6 +241,7 @@ export const WidgetMultiReleasesRepositoriesInput = ({
|
||||
repository,
|
||||
onRepositorySave: (saved) => onRepositorySave(saved, index),
|
||||
versionFilterPrecisionOptions,
|
||||
integrations,
|
||||
})
|
||||
}
|
||||
variant="light"
|
||||
@@ -253,6 +293,7 @@ interface RepositoryEditProps {
|
||||
onRepositorySave: (repository: ReleasesRepository) => FormValidation;
|
||||
onRepositoryCancel?: () => void;
|
||||
versionFilterPrecisionOptions: string[];
|
||||
integrations: Record<string, Integration>;
|
||||
}
|
||||
|
||||
const RepositoryEditModal = createModal<RepositoryEditProps>(({ innerProps, actions }) => {
|
||||
@@ -260,6 +301,10 @@ const RepositoryEditModal = createModal<RepositoryEditProps>(({ innerProps, acti
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [tempRepository, setTempRepository] = useState(() => ({ ...innerProps.repository }));
|
||||
const [formErrors, setFormErrors] = useState<FormErrors>({});
|
||||
const integrationSelectOptions: IntegrationSelectOption[] = useMemo(
|
||||
() => Object.values(innerProps.integrations),
|
||||
[innerProps.integrations],
|
||||
);
|
||||
|
||||
// Allows user to not select an icon by removing the url from the input,
|
||||
// will only try and get an icon if the name or identifier changes
|
||||
@@ -313,23 +358,20 @@ const RepositoryEditModal = createModal<RepositoryEditProps>(({ innerProps, acti
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<Group align="center" wrap="nowrap">
|
||||
<Select
|
||||
withAsterisk
|
||||
label={tRepository("provider.label")}
|
||||
data={Object.entries(Providers).map(([key, provider]) => ({
|
||||
value: key,
|
||||
label: provider.name,
|
||||
}))}
|
||||
value={tempRepository.providerKey}
|
||||
error={formErrors[`${innerProps.fieldPath}.providerKey`]}
|
||||
onChange={(value) => {
|
||||
if (value && isProviderKey(value)) {
|
||||
handleChange({ providerKey: value });
|
||||
}
|
||||
}}
|
||||
style={{ flex: 1, flexBasis: "40%" }}
|
||||
/>
|
||||
<Group align="start" wrap="nowrap" grow preventGrowOverflow={false}>
|
||||
<div style={{ flex: 0.3 }}>
|
||||
<WidgetIntegrationSelect
|
||||
canSelectMultiple={false}
|
||||
withAsterisk
|
||||
label={tRepository("provider.label")}
|
||||
data={integrationSelectOptions}
|
||||
value={tempRepository.providerIntegrationId ? [tempRepository.providerIntegrationId] : []}
|
||||
error={formErrors[`${innerProps.fieldPath}.providerIntegrationId`] as string}
|
||||
onChange={(value) => {
|
||||
handleChange({ providerIntegrationId: value.length > 0 ? value[0] : undefined });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<TextInput
|
||||
withAsterisk
|
||||
@@ -350,11 +392,11 @@ const RepositoryEditModal = createModal<RepositoryEditProps>(({ innerProps, acti
|
||||
if (event.currentTarget.value) setAutoSetIcon(true);
|
||||
}}
|
||||
error={formErrors[`${innerProps.fieldPath}.identifier`]}
|
||||
w="100%"
|
||||
style={{ flex: 0.7 }}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<Group align="center" wrap="nowrap">
|
||||
<Group align="center" wrap="nowrap" grow preventGrowOverflow={false}>
|
||||
<TextInput
|
||||
label={tRepository("name.label")}
|
||||
value={tempRepository.name ?? ""}
|
||||
@@ -364,22 +406,24 @@ const RepositoryEditModal = createModal<RepositoryEditProps>(({ innerProps, acti
|
||||
if (event.currentTarget.value) setAutoSetIcon(true);
|
||||
}}
|
||||
error={formErrors[`${innerProps.fieldPath}.name`]}
|
||||
style={{ flex: 1, flexBasis: "40%" }}
|
||||
style={{ flex: 0.3 }}
|
||||
/>
|
||||
|
||||
<IconPicker
|
||||
withAsterisk={false}
|
||||
value={tempRepository.iconUrl ?? ""}
|
||||
onChange={(url) => {
|
||||
if (url === "") {
|
||||
setAutoSetIcon(false);
|
||||
handleChange({ iconUrl: undefined });
|
||||
} else {
|
||||
handleChange({ iconUrl: url });
|
||||
}
|
||||
}}
|
||||
error={formErrors[`${innerProps.fieldPath}.iconUrl`] as string}
|
||||
/>
|
||||
<div style={{ flex: 0.7 }}>
|
||||
<IconPicker
|
||||
withAsterisk={false}
|
||||
value={tempRepository.iconUrl ?? ""}
|
||||
onChange={(url) => {
|
||||
if (url === "") {
|
||||
setAutoSetIcon(false);
|
||||
handleChange({ iconUrl: undefined });
|
||||
} else {
|
||||
handleChange({ iconUrl: url });
|
||||
}
|
||||
}}
|
||||
error={formErrors[`${innerProps.fieldPath}.iconUrl`] as string}
|
||||
/>
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
<Fieldset legend={tRepository("versionFilter.label")}>
|
||||
@@ -467,12 +511,14 @@ interface ReleasesRepositoryImport extends ReleasesRepository {
|
||||
|
||||
interface ContainerImageSelectorProps {
|
||||
containerImage: ReleasesRepositoryImport;
|
||||
integration?: Integration;
|
||||
versionFilterPrecisionOptions: string[];
|
||||
onImageSelectionChanged?: (isSelected: boolean) => void;
|
||||
}
|
||||
|
||||
const ContainerImageSelector = ({
|
||||
containerImage,
|
||||
integration,
|
||||
versionFilterPrecisionOptions,
|
||||
onImageSelectionChanged,
|
||||
}: ContainerImageSelectorProps) => {
|
||||
@@ -487,11 +533,7 @@ const ContainerImageSelector = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<Group
|
||||
key={`${Providers[containerImage.providerKey].name}/${containerImage.identifier}`}
|
||||
gap="xl"
|
||||
justify="space-between"
|
||||
>
|
||||
<Group gap="xl" justify="space-between">
|
||||
<Group gap="md">
|
||||
<Checkbox
|
||||
label={
|
||||
@@ -524,25 +566,33 @@ const ContainerImageSelector = ({
|
||||
)}
|
||||
</Group>
|
||||
|
||||
<Group>
|
||||
<MaskedImage
|
||||
color="dimmed"
|
||||
imageUrl={Providers[containerImage.providerKey].iconUrl}
|
||||
style={{
|
||||
height: "1em",
|
||||
width: "1em",
|
||||
}}
|
||||
/>
|
||||
<Text ff="monospace" c="dimmed" size="sm">
|
||||
{Providers[containerImage.providerKey].name}
|
||||
</Text>
|
||||
</Group>
|
||||
<Tooltip label={tRepository("noProvider.tooltip")} disabled={!integration} withArrow>
|
||||
<Group>
|
||||
{integration ? (
|
||||
<MaskedImage
|
||||
color="dimmed"
|
||||
imageUrl={integration.iconUrl}
|
||||
style={{
|
||||
height: "1em",
|
||||
width: "1em",
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<IconAlertTriangleFilled />
|
||||
)}
|
||||
|
||||
<Text ff="monospace" c="dimmed" size="sm">
|
||||
{integration?.name ?? tRepository("noProvider.label")}
|
||||
</Text>
|
||||
</Group>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
|
||||
interface RepositoryImportProps {
|
||||
repositories: ReleasesRepository[];
|
||||
integrations: Record<string, Integration>;
|
||||
versionFilterPrecisionOptions: string[];
|
||||
onConfirm: (selectedRepositories: ReleasesRepositoryImport[]) => void;
|
||||
isAdmin: boolean;
|
||||
@@ -563,26 +613,38 @@ const RepositoryImportModal = createModal<RepositoryImportProps>(({ innerProps,
|
||||
const containersImages: ReleasesRepositoryImport[] = useMemo(
|
||||
() =>
|
||||
docker.data?.containers.reduce<ReleasesRepositoryImport[]>((acc, containerImage) => {
|
||||
const providerKey = containerImage.image.startsWith("ghcr.io/") ? "Github" : "DockerHub";
|
||||
const [identifier, version] = containerImage.image.replace(/^(ghcr\.io\/|docker\.io\/)/, "").split(":");
|
||||
const imageParts = containerImage.image.split("/");
|
||||
const source = imageParts.length > 1 ? imageParts[0] : "docker.io";
|
||||
const identifierImage = imageParts.length > 1 ? imageParts[1] : imageParts[0];
|
||||
|
||||
if (!identifier) return acc;
|
||||
if (!source || !identifierImage) return acc;
|
||||
|
||||
if (acc.some((item) => item.providerKey === providerKey && item.identifier === identifier)) return acc;
|
||||
const providerKey = source in containerImageToProviderKind ? containerImageToProviderKind[source] : "dockerHub";
|
||||
const integrationId = Object.values(innerProps.integrations).find(
|
||||
(integration) => integration.kind === providerKey,
|
||||
)?.id;
|
||||
|
||||
const [identifier, version] = identifierImage.split(":");
|
||||
|
||||
if (!identifier || !integrationId) return acc;
|
||||
|
||||
if (acc.some((item) => item.providerIntegrationId === integrationId && item.identifier === identifier))
|
||||
return acc;
|
||||
|
||||
acc.push({
|
||||
providerKey,
|
||||
id: createId(),
|
||||
providerIntegrationId: integrationId,
|
||||
identifier,
|
||||
iconUrl: containerImage.iconUrl ?? undefined,
|
||||
name: formatIdentifierName(identifier),
|
||||
versionFilter: version ? parseImageVersionToVersionFilter(version) : undefined,
|
||||
alreadyImported: innerProps.repositories.some(
|
||||
(item) => item.providerKey === providerKey && item.identifier === identifier,
|
||||
(item) => item.providerIntegrationId === integrationId && item.identifier === identifier,
|
||||
),
|
||||
});
|
||||
return acc;
|
||||
}, []) ?? [],
|
||||
[docker.data, innerProps.repositories],
|
||||
[docker.data, innerProps.repositories, innerProps.integrations],
|
||||
);
|
||||
|
||||
const handleConfirm = useCallback(() => {
|
||||
@@ -635,10 +697,15 @@ const RepositoryImportModal = createModal<RepositoryImportProps>(({ innerProps,
|
||||
containersImages
|
||||
.filter((containerImage) => !containerImage.alreadyImported)
|
||||
.map((containerImage) => {
|
||||
const integration = containerImage.providerIntegrationId
|
||||
? innerProps.integrations[containerImage.providerIntegrationId]
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<ContainerImageSelector
|
||||
key={`${containerImage.providerKey}/${containerImage.identifier}`}
|
||||
key={containerImage.id}
|
||||
containerImage={containerImage}
|
||||
integration={integration}
|
||||
versionFilterPrecisionOptions={innerProps.versionFilterPrecisionOptions}
|
||||
onImageSelectionChanged={(isSelected) =>
|
||||
isSelected
|
||||
@@ -659,10 +726,15 @@ const RepositoryImportModal = createModal<RepositoryImportProps>(({ innerProps,
|
||||
containersImages
|
||||
.filter((containerImage) => containerImage.alreadyImported)
|
||||
.map((containerImage) => {
|
||||
const integration = containerImage.providerIntegrationId
|
||||
? innerProps.integrations[containerImage.providerIntegrationId]
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<ContainerImageSelector
|
||||
key={`${containerImage.providerKey}/${containerImage.identifier}`}
|
||||
key={containerImage.id}
|
||||
containerImage={containerImage}
|
||||
integration={integration}
|
||||
versionFilterPrecisionOptions={innerProps.versionFilterPrecisionOptions}
|
||||
/>
|
||||
);
|
||||
@@ -691,6 +763,11 @@ const RepositoryImportModal = createModal<RepositoryImportProps>(({ innerProps,
|
||||
size: "xl",
|
||||
});
|
||||
|
||||
const containerImageToProviderKind: Record<string, IntegrationKind> = {
|
||||
"ghcr.io": "github",
|
||||
"docker.io": "dockerHub",
|
||||
};
|
||||
|
||||
const parseImageVersionToVersionFilter = (imageVersion: string): ReleasesVersionFilter | undefined => {
|
||||
const version = /(?<=\D|^)\d+(?:\.\d+)*(?![\d.])/.exec(imageVersion)?.[0];
|
||||
|
||||
|
||||
@@ -24,8 +24,7 @@ import { MaskedOrNormalImage } from "@homarr/ui";
|
||||
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import classes from "./component.module.scss";
|
||||
import { Providers } from "./releases-providers";
|
||||
import type { ReleasesRepositoryResponse } from "./releases-repository";
|
||||
import type { ReleasesRepository, ReleasesRepositoryResponse } from "./releases-repository";
|
||||
|
||||
const formatRelativeDate = (value: string): string => {
|
||||
const isMonths = /\d+m/g.test(value);
|
||||
@@ -38,7 +37,7 @@ export default function ReleasesWidget({ options }: WidgetComponentProps<"releas
|
||||
const now = useNow();
|
||||
const formatter = useFormatter();
|
||||
const board = useRequiredBoard();
|
||||
const [expandedRepository, setExpandedRepository] = useState({ providerKey: "", identifier: "" });
|
||||
const [expandedRepositoryId, setExpandedRepositoryId] = useState<string | null>(null);
|
||||
const hasIconColor = useMemo(() => board.iconColor !== null, [board.iconColor]);
|
||||
const relativeDateOptions = useMemo(
|
||||
() => ({
|
||||
@@ -48,12 +47,38 @@ export default function ReleasesWidget({ options }: WidgetComponentProps<"releas
|
||||
[options.newReleaseWithin, options.staleReleaseWithin],
|
||||
);
|
||||
|
||||
const batchedRepositories = useMemo(() => splitToChunksWithNItems(options.repositories, 5), [options.repositories]);
|
||||
// Group repositories by integration
|
||||
const groupedRepositories = useMemo(() => {
|
||||
return options.repositories.reduce(
|
||||
(acc, repo) => {
|
||||
const key = repo.providerIntegrationId;
|
||||
if (!key) return acc;
|
||||
|
||||
acc[key] ??= [];
|
||||
acc[key].push(repo);
|
||||
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, ReleasesRepository[]>,
|
||||
);
|
||||
}, [options.repositories]);
|
||||
|
||||
// For each group, split into chunks of 5
|
||||
const batchedRepositories = useMemo(() => {
|
||||
return Object.entries(groupedRepositories).flatMap(([integrationId, group]) =>
|
||||
splitToChunksWithNItems(group, 5).map((chunk) => ({
|
||||
integrationId,
|
||||
repositories: chunk,
|
||||
})),
|
||||
);
|
||||
}, [groupedRepositories]);
|
||||
|
||||
const [results] = clientApi.useSuspenseQueries((t) =>
|
||||
batchedRepositories.flatMap((chunk) =>
|
||||
batchedRepositories.flatMap(({ integrationId, repositories }) =>
|
||||
t.widget.releases.getLatest({
|
||||
repositories: chunk.map((repository) => ({
|
||||
providerKey: repository.providerKey,
|
||||
integrationId,
|
||||
repositories: repositories.map((repository) => ({
|
||||
id: repository.id,
|
||||
identifier: repository.identifier,
|
||||
versionFilter: repository.versionFilter,
|
||||
})),
|
||||
@@ -62,41 +87,56 @@ export default function ReleasesWidget({ options }: WidgetComponentProps<"releas
|
||||
);
|
||||
|
||||
const repositories = useMemo(() => {
|
||||
const formattedResults = results
|
||||
.flat()
|
||||
.map(({ data }) => {
|
||||
if (data === undefined) return undefined;
|
||||
const formattedResults = options.repositories
|
||||
.map((repository) => {
|
||||
if (repository.providerIntegrationId === undefined) {
|
||||
return {
|
||||
...repository,
|
||||
isNewRelease: false,
|
||||
isStaleRelease: false,
|
||||
latestReleaseAt: undefined,
|
||||
error: {
|
||||
code: "noProviderSeleceted",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const repository = options.repositories.find(
|
||||
(repository) => repository.providerKey === data.providerKey && repository.identifier === data.identifier,
|
||||
);
|
||||
const response = results.flat().find(({ data }) => data.id === repository.id)?.data;
|
||||
|
||||
if (repository === undefined) return undefined;
|
||||
if (response === undefined)
|
||||
return {
|
||||
...repository,
|
||||
isNewRelease: false,
|
||||
isStaleRelease: false,
|
||||
latestReleaseAt: undefined,
|
||||
error: {
|
||||
code: "noProviderResponse",
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
...repository,
|
||||
...data,
|
||||
...response,
|
||||
isNewRelease:
|
||||
relativeDateOptions.newReleaseWithin !== "" && data.latestReleaseAt
|
||||
? isDateWithin(data.latestReleaseAt, relativeDateOptions.newReleaseWithin)
|
||||
relativeDateOptions.newReleaseWithin !== "" && response.latestReleaseAt
|
||||
? isDateWithin(response.latestReleaseAt, relativeDateOptions.newReleaseWithin)
|
||||
: false,
|
||||
isStaleRelease:
|
||||
relativeDateOptions.staleReleaseWithin !== "" && data.latestReleaseAt
|
||||
? !isDateWithin(data.latestReleaseAt, relativeDateOptions.staleReleaseWithin)
|
||||
relativeDateOptions.staleReleaseWithin !== "" && response.latestReleaseAt
|
||||
? !isDateWithin(response.latestReleaseAt, relativeDateOptions.staleReleaseWithin)
|
||||
: false,
|
||||
};
|
||||
})
|
||||
.filter(
|
||||
(repository) =>
|
||||
repository !== undefined &&
|
||||
(repository.error !== undefined ||
|
||||
!options.showOnlyHighlighted ||
|
||||
repository.isNewRelease ||
|
||||
repository.isStaleRelease),
|
||||
repository.error !== undefined ||
|
||||
!options.showOnlyHighlighted ||
|
||||
repository.isNewRelease ||
|
||||
repository.isStaleRelease,
|
||||
)
|
||||
.sort((repoA, repoB) => {
|
||||
if (repoA?.latestReleaseAt === undefined) return 1;
|
||||
if (repoB?.latestReleaseAt === undefined) return -1;
|
||||
if (repoA.latestReleaseAt === undefined) return -1;
|
||||
if (repoB.latestReleaseAt === undefined) return 1;
|
||||
return repoA.latestReleaseAt > repoB.latestReleaseAt ? -1 : 1;
|
||||
}) as ReleasesRepositoryResponse[];
|
||||
|
||||
@@ -115,34 +155,24 @@ export default function ReleasesWidget({ options }: WidgetComponentProps<"releas
|
||||
]);
|
||||
|
||||
const toggleExpandedRepository = useCallback(
|
||||
(repository: ReleasesRepositoryResponse) => {
|
||||
if (
|
||||
expandedRepository.providerKey === repository.providerKey &&
|
||||
expandedRepository.identifier === repository.identifier
|
||||
) {
|
||||
setExpandedRepository({ providerKey: "", identifier: "" });
|
||||
} else {
|
||||
setExpandedRepository({ providerKey: repository.providerKey, identifier: repository.identifier });
|
||||
}
|
||||
},
|
||||
[expandedRepository],
|
||||
(repository: ReleasesRepositoryResponse) =>
|
||||
setExpandedRepositoryId(expandedRepositoryId === repository.id ? "" : repository.id),
|
||||
[expandedRepositoryId],
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack gap={0} className="releases">
|
||||
{repositories.map((repository: ReleasesRepositoryResponse) => {
|
||||
const isActive =
|
||||
expandedRepository.providerKey === repository.providerKey &&
|
||||
expandedRepository.identifier === repository.identifier;
|
||||
const isActive = expandedRepositoryId === repository.id;
|
||||
const hasError = repository.error !== undefined;
|
||||
|
||||
return (
|
||||
<Stack
|
||||
key={`${repository.providerKey}.${repository.identifier}`}
|
||||
key={repository.id}
|
||||
className={combineClasses(
|
||||
"releases-repository",
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
|
||||
`releases-repository-${repository.providerKey}-${repository.name || repository.identifier.replace(/[^a-zA-Z0-9]/g, "_")}`,
|
||||
`releases-repository-${repository.integration?.name ?? "error"}-${repository.name || repository.identifier.replace(/[^a-zA-Z0-9]/g, "_")}`,
|
||||
classes.releasesRepository,
|
||||
)}
|
||||
gap={0}
|
||||
@@ -156,7 +186,7 @@ export default function ReleasesWidget({ options }: WidgetComponentProps<"releas
|
||||
>
|
||||
<MaskedOrNormalImage
|
||||
className="releases-repository-header-icon"
|
||||
imageUrl={repository.iconUrl ?? Providers[repository.providerKey].iconUrl}
|
||||
imageUrl={repository.iconUrl ?? repository.integration?.iconUrl}
|
||||
hasColor={hasIconColor}
|
||||
style={{
|
||||
width: "1em",
|
||||
@@ -471,20 +501,27 @@ const ExpandedDisplay = ({ repository, hasIconColor }: ExtendedDisplayProps) =>
|
||||
{repository.identifier}
|
||||
</Text>
|
||||
|
||||
<Group className="releases-repository-expanded-header-provider-wrapper" gap={5} align="center">
|
||||
<MaskedOrNormalImage
|
||||
className="releases-repository-expanded-header-provider-icon"
|
||||
imageUrl={Providers[repository.providerKey].iconUrl}
|
||||
hasColor={hasIconColor}
|
||||
style={{
|
||||
width: "1em",
|
||||
aspectRatio: "1/1",
|
||||
}}
|
||||
/>
|
||||
<Text className="releases-repository-expanded-header-provider-name" size="xs" c="iconColor" ff="monospace">
|
||||
{Providers[repository.providerKey].name}
|
||||
</Text>
|
||||
</Group>
|
||||
{repository.integration && (
|
||||
<Group className="releases-repository-expanded-header-provider-wrapper" gap={5} align="center">
|
||||
<MaskedOrNormalImage
|
||||
className="releases-repository-expanded-header-provider-icon"
|
||||
imageUrl={repository.integration.iconUrl}
|
||||
hasColor={hasIconColor}
|
||||
style={{
|
||||
width: "1em",
|
||||
aspectRatio: "1/1",
|
||||
}}
|
||||
/>
|
||||
<Text
|
||||
className="releases-repository-expanded-header-provider-name"
|
||||
size="xs"
|
||||
c="iconColor"
|
||||
ff="monospace"
|
||||
>
|
||||
{repository.integration.name}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
{repository.createdAt && (
|
||||
@@ -531,7 +568,7 @@ const ExpandedDisplay = ({ repository, hasIconColor }: ExtendedDisplayProps) =>
|
||||
c="red"
|
||||
style={{ whiteSpace: "pre-wrap" }}
|
||||
>
|
||||
{repository.error.code ? t(`error.options.${repository.error.code}` as never) : repository.error.message}
|
||||
{repository.error.code ? t(`error.messages.${repository.error.code}` as never) : repository.error.message}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -39,7 +39,7 @@ export const { definition, componentLoader } = createWidgetDefinition("releases"
|
||||
defaultValue: [],
|
||||
validate: z.array(
|
||||
z.object({
|
||||
providerKey: z.string().min(1),
|
||||
providerIntegrationId: z.string().optional(),
|
||||
identifier: z.string().min(1),
|
||||
name: z.string().optional(),
|
||||
versionFilter: z
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
export interface ReleasesProvider {
|
||||
name: string;
|
||||
iconUrl: string;
|
||||
}
|
||||
|
||||
export const Providers = {
|
||||
DockerHub: {
|
||||
name: "Docker Hub",
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/selfhst/icons/svg/docker.svg",
|
||||
},
|
||||
Github: {
|
||||
name: "Github",
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/selfhst/icons/svg/github-dark.svg",
|
||||
},
|
||||
Gitlab: {
|
||||
name: "Gitlab",
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/selfhst/icons/svg/gitlab.svg",
|
||||
},
|
||||
Npm: {
|
||||
name: "Npm",
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/loganmarchione/homelab-svg-assets//assets/npm.svg",
|
||||
},
|
||||
Codeberg: {
|
||||
name: "Codeberg",
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/selfhst/icons/svg/codeberg.svg",
|
||||
},
|
||||
} as const satisfies Record<string, ReleasesProvider>;
|
||||
|
||||
export type ProviderKey = keyof typeof Providers;
|
||||
|
||||
export const isProviderKey = (key: string): key is ProviderKey => {
|
||||
return key in Providers;
|
||||
};
|
||||
@@ -1,5 +1,3 @@
|
||||
import type { ProviderKey } from "./releases-providers";
|
||||
|
||||
export interface ReleasesVersionFilter {
|
||||
prefix?: string;
|
||||
precision: number;
|
||||
@@ -7,7 +5,8 @@ export interface ReleasesVersionFilter {
|
||||
}
|
||||
|
||||
export interface ReleasesRepository {
|
||||
providerKey: ProviderKey;
|
||||
id: string;
|
||||
providerIntegrationId?: string;
|
||||
identifier: string;
|
||||
name?: string;
|
||||
versionFilter?: ReleasesVersionFilter;
|
||||
@@ -33,5 +32,10 @@ export interface ReleasesRepositoryResponse extends ReleasesRepository {
|
||||
forksCount?: number;
|
||||
openIssues?: number;
|
||||
|
||||
integration?: {
|
||||
name: string;
|
||||
iconUrl?: string;
|
||||
};
|
||||
|
||||
error?: { code?: string; message?: string };
|
||||
}
|
||||
|
||||
82
pnpm-lock.yaml
generated
82
pnpm-lock.yaml
generated
@@ -826,6 +826,9 @@ importers:
|
||||
'@homarr/log':
|
||||
specifier: workspace:^0.1.0
|
||||
version: link:../log
|
||||
'@paralleldrive/cuid2':
|
||||
specifier: ^2.2.2
|
||||
version: 2.2.2
|
||||
dayjs:
|
||||
specifier: ^1.11.13
|
||||
version: 1.11.13
|
||||
@@ -1089,6 +1092,9 @@ importers:
|
||||
mysql2:
|
||||
specifier: 3.14.2
|
||||
version: 3.14.2
|
||||
superjson:
|
||||
specifier: 2.2.2
|
||||
version: 2.2.2
|
||||
devDependencies:
|
||||
'@homarr/eslint-config':
|
||||
specifier: workspace:^0.2.0
|
||||
@@ -1330,6 +1336,9 @@ importers:
|
||||
'@ctrl/transmission':
|
||||
specifier: ^7.2.0
|
||||
version: 7.2.0
|
||||
'@gitbeaker/rest':
|
||||
specifier: ^42.5.0
|
||||
version: 42.5.0
|
||||
'@homarr/certificates':
|
||||
specifier: workspace:^0.1.0
|
||||
version: link:../certificates
|
||||
@@ -1366,6 +1375,9 @@ importers:
|
||||
node-ical:
|
||||
specifier: ^0.20.1
|
||||
version: 0.20.1
|
||||
octokit:
|
||||
specifier: ^5.0.3
|
||||
version: 5.0.3
|
||||
proxmox-api:
|
||||
specifier: 1.1.1
|
||||
version: 1.1.1
|
||||
@@ -3275,6 +3287,18 @@ packages:
|
||||
'@formatjs/intl-localematcher@0.5.5':
|
||||
resolution: {integrity: sha512-t5tOGMgZ/i5+ALl2/offNqAQq/lfUnKLEw0mXQI4N4bqpedhrSE+fyKLpwnd22sK0dif6AV+ufQcTsKShB9J1g==}
|
||||
|
||||
'@gitbeaker/core@42.5.0':
|
||||
resolution: {integrity: sha512-rMWpOPaZi1iLiifnOIoVO57p2EmQQdfIwP4txqNyMvG4WjYP5Ez0U7jRD9Nra41x6K5kTPBZkuQcAdxVWRJcEQ==}
|
||||
engines: {node: '>=18.20.0'}
|
||||
|
||||
'@gitbeaker/requester-utils@42.5.0':
|
||||
resolution: {integrity: sha512-HLdLS9LPBMVQumvroQg/4qkphLDtwDB+ygEsrD2u4oYCMUtXV4V1xaVqU4yTXjbTJ5sItOtdB43vYRkBcgueBw==}
|
||||
engines: {node: '>=18.20.0'}
|
||||
|
||||
'@gitbeaker/rest@42.5.0':
|
||||
resolution: {integrity: sha512-oC5cM6jS7aFOp0luTw5mWSRuMgdxwHRLZQ/aWkI+ETMfsprR/HyxsXfljlMY/XJ/fRxTbRJiodR5Axf66WjO3w==}
|
||||
engines: {node: '>=18.20.0'}
|
||||
|
||||
'@grpc/grpc-js@1.12.5':
|
||||
resolution: {integrity: sha512-d3iiHxdpg5+ZcJ6jnDSOT8Z0O0VMVGy34jAnYLUX8yd36b1qn8f1TwOA/Lc7TsOh03IkPJ38eGI5qD2EjNkoEA==}
|
||||
engines: {node: '>=12.10.0'}
|
||||
@@ -8696,6 +8720,10 @@ packages:
|
||||
picocolors@1.1.1:
|
||||
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
||||
|
||||
picomatch-browser@2.2.6:
|
||||
resolution: {integrity: sha512-0ypsOQt9D4e3hziV8O4elD9uN0z/jtUEfxVRtNaAAtXIyUx9m/SzlO020i8YNL2aL/E6blOvvHQcin6HZlFy/w==}
|
||||
engines: {node: '>=8.6'}
|
||||
|
||||
picomatch@2.3.1:
|
||||
resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==}
|
||||
engines: {node: '>=8.6'}
|
||||
@@ -8994,6 +9022,9 @@ packages:
|
||||
randombytes@2.1.0:
|
||||
resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==}
|
||||
|
||||
rate-limiter-flexible@4.0.1:
|
||||
resolution: {integrity: sha512-2/dGHpDFpeA0+755oUkW+EKyklqLS9lu0go9pDsbhqQjZcxfRyJ6LA4JI0+HAdZ2bemD/oOjUeZQB2lCZqXQfQ==}
|
||||
|
||||
raw-body@2.5.2:
|
||||
resolution: {integrity: sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==}
|
||||
engines: {node: '>= 0.8'}
|
||||
@@ -10762,6 +10793,9 @@ packages:
|
||||
utf-8-validate:
|
||||
optional: true
|
||||
|
||||
xcase@2.0.1:
|
||||
resolution: {integrity: sha512-UmFXIPU+9Eg3E9m/728Bii0lAIuoc+6nbrNUKaRPJOFp91ih44qqGlWtxMB6kXFrRD6po+86ksHM5XHCfk6iPw==}
|
||||
|
||||
xdg-basedir@4.0.0:
|
||||
resolution: {integrity: sha512-PSNhEJDejZYV7h50BohL09Er9VaIefr2LMAf3OEmpCkjOi34eYyQYAXUTjEQtZJTKcF0E2UKTh+osDLsgNim9Q==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -11698,6 +11732,24 @@ snapshots:
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
'@gitbeaker/core@42.5.0':
|
||||
dependencies:
|
||||
'@gitbeaker/requester-utils': 42.5.0
|
||||
qs: 6.13.1
|
||||
xcase: 2.0.1
|
||||
|
||||
'@gitbeaker/requester-utils@42.5.0':
|
||||
dependencies:
|
||||
picomatch-browser: 2.2.6
|
||||
qs: 6.13.1
|
||||
rate-limiter-flexible: 4.0.1
|
||||
xcase: 2.0.1
|
||||
|
||||
'@gitbeaker/rest@42.5.0':
|
||||
dependencies:
|
||||
'@gitbeaker/core': 42.5.0
|
||||
'@gitbeaker/requester-utils': 42.5.0
|
||||
|
||||
'@grpc/grpc-js@1.12.5':
|
||||
dependencies:
|
||||
'@grpc/proto-loader': 0.7.13
|
||||
@@ -12131,7 +12183,7 @@ snapshots:
|
||||
'@octokit/core': 7.0.2
|
||||
'@octokit/oauth-app': 8.0.1
|
||||
'@octokit/plugin-paginate-rest': 13.0.0(@octokit/core@7.0.2)
|
||||
'@octokit/types': 14.0.0
|
||||
'@octokit/types': 14.1.0
|
||||
'@octokit/webhooks': 14.0.0
|
||||
|
||||
'@octokit/auth-app@8.0.1':
|
||||
@@ -12140,7 +12192,7 @@ snapshots:
|
||||
'@octokit/auth-oauth-user': 6.0.0
|
||||
'@octokit/request': 10.0.2
|
||||
'@octokit/request-error': 7.0.0
|
||||
'@octokit/types': 14.0.0
|
||||
'@octokit/types': 14.1.0
|
||||
toad-cache: 3.7.0
|
||||
universal-github-app-jwt: 2.2.0
|
||||
universal-user-agent: 7.0.2
|
||||
@@ -12150,14 +12202,14 @@ snapshots:
|
||||
'@octokit/auth-oauth-device': 8.0.1
|
||||
'@octokit/auth-oauth-user': 6.0.0
|
||||
'@octokit/request': 10.0.2
|
||||
'@octokit/types': 14.0.0
|
||||
'@octokit/types': 14.1.0
|
||||
universal-user-agent: 7.0.2
|
||||
|
||||
'@octokit/auth-oauth-device@8.0.1':
|
||||
dependencies:
|
||||
'@octokit/oauth-methods': 6.0.0
|
||||
'@octokit/request': 10.0.2
|
||||
'@octokit/types': 14.0.0
|
||||
'@octokit/types': 14.1.0
|
||||
universal-user-agent: 7.0.2
|
||||
|
||||
'@octokit/auth-oauth-user@6.0.0':
|
||||
@@ -12165,7 +12217,7 @@ snapshots:
|
||||
'@octokit/auth-oauth-device': 8.0.1
|
||||
'@octokit/oauth-methods': 6.0.0
|
||||
'@octokit/request': 10.0.2
|
||||
'@octokit/types': 14.0.0
|
||||
'@octokit/types': 14.1.0
|
||||
universal-user-agent: 7.0.2
|
||||
|
||||
'@octokit/auth-token@6.0.0': {}
|
||||
@@ -12173,7 +12225,7 @@ snapshots:
|
||||
'@octokit/auth-unauthenticated@7.0.1':
|
||||
dependencies:
|
||||
'@octokit/request-error': 7.0.0
|
||||
'@octokit/types': 14.0.0
|
||||
'@octokit/types': 14.1.0
|
||||
|
||||
'@octokit/core@7.0.2':
|
||||
dependencies:
|
||||
@@ -12187,13 +12239,13 @@ snapshots:
|
||||
|
||||
'@octokit/endpoint@11.0.0':
|
||||
dependencies:
|
||||
'@octokit/types': 14.0.0
|
||||
'@octokit/types': 14.1.0
|
||||
universal-user-agent: 7.0.2
|
||||
|
||||
'@octokit/graphql@9.0.1':
|
||||
dependencies:
|
||||
'@octokit/request': 10.0.2
|
||||
'@octokit/types': 14.0.0
|
||||
'@octokit/types': 14.1.0
|
||||
universal-user-agent: 7.0.2
|
||||
|
||||
'@octokit/oauth-app@8.0.1':
|
||||
@@ -12214,7 +12266,7 @@ snapshots:
|
||||
'@octokit/oauth-authorization-url': 8.0.0
|
||||
'@octokit/request': 10.0.2
|
||||
'@octokit/request-error': 7.0.0
|
||||
'@octokit/types': 14.0.0
|
||||
'@octokit/types': 14.1.0
|
||||
|
||||
'@octokit/openapi-types@25.0.0': {}
|
||||
|
||||
@@ -12251,13 +12303,13 @@ snapshots:
|
||||
|
||||
'@octokit/request-error@7.0.0':
|
||||
dependencies:
|
||||
'@octokit/types': 14.0.0
|
||||
'@octokit/types': 14.1.0
|
||||
|
||||
'@octokit/request@10.0.2':
|
||||
dependencies:
|
||||
'@octokit/endpoint': 11.0.0
|
||||
'@octokit/request-error': 7.0.0
|
||||
'@octokit/types': 14.0.0
|
||||
'@octokit/types': 14.1.0
|
||||
fast-content-type-parse: 3.0.0
|
||||
universal-user-agent: 7.0.2
|
||||
|
||||
@@ -17757,7 +17809,7 @@ snapshots:
|
||||
'@octokit/plugin-retry': 8.0.1(@octokit/core@7.0.2)
|
||||
'@octokit/plugin-throttling': 11.0.1(@octokit/core@7.0.2)
|
||||
'@octokit/request-error': 7.0.0
|
||||
'@octokit/types': 14.0.0
|
||||
'@octokit/types': 14.1.0
|
||||
'@octokit/webhooks': 14.0.0
|
||||
|
||||
ofetch@1.4.1:
|
||||
@@ -18009,6 +18061,8 @@ snapshots:
|
||||
|
||||
picocolors@1.1.1: {}
|
||||
|
||||
picomatch-browser@2.2.6: {}
|
||||
|
||||
picomatch@2.3.1: {}
|
||||
|
||||
picomatch@4.0.2: {}
|
||||
@@ -18368,6 +18422,8 @@ snapshots:
|
||||
dependencies:
|
||||
safe-buffer: 5.2.1
|
||||
|
||||
rate-limiter-flexible@4.0.1: {}
|
||||
|
||||
raw-body@2.5.2:
|
||||
dependencies:
|
||||
bytes: 3.1.2
|
||||
@@ -20529,6 +20585,8 @@ snapshots:
|
||||
|
||||
ws@8.18.3: {}
|
||||
|
||||
xcase@2.0.1: {}
|
||||
|
||||
xdg-basedir@4.0.0: {}
|
||||
|
||||
xml-but-prettier@1.0.1:
|
||||
|
||||
Reference in New Issue
Block a user