diff --git a/packages/api/src/router/widgets/coolify.ts b/packages/api/src/router/widgets/coolify.ts new file mode 100644 index 000000000..8bdaa6f3e --- /dev/null +++ b/packages/api/src/router/widgets/coolify.ts @@ -0,0 +1,50 @@ +import { observable } from "@trpc/server/observable"; + +import type { CoolifyInstanceInfo } from "@homarr/integrations/types"; +import { coolifyRequestHandler } from "@homarr/request-handler/coolify"; + +import { createManyIntegrationMiddleware } from "../../middlewares/integration"; +import { createTRPCRouter, publicProcedure } from "../../trpc"; + +export const coolifyRouter = createTRPCRouter({ + getInstancesInfo: publicProcedure + .concat(createManyIntegrationMiddleware("query", "coolify")) + .query(async ({ ctx }) => { + const results = await Promise.all( + ctx.integrations.map(async (integration) => { + const innerHandler = coolifyRequestHandler.handler(integration, {}); + const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false }); + + return { + integrationId: integration.id, + integrationName: integration.name, + integrationUrl: integration.url, + instanceInfo: data, + updatedAt: timestamp, + }; + }), + ); + + return results; + }), + subscribeInstancesInfo: publicProcedure + .concat(createManyIntegrationMiddleware("query", "coolify")) + .subscription(({ ctx }) => { + return observable<{ integrationId: string; instanceInfo: CoolifyInstanceInfo; timestamp: Date }>((emit) => { + const unsubscribes = ctx.integrations.map((integration) => { + const innerHandler = coolifyRequestHandler.handler(integration, {}); + return innerHandler.subscribe((instanceInfo) => { + emit.next({ + integrationId: integration.id, + instanceInfo, + timestamp: new Date(), + }); + }); + }); + + return () => { + unsubscribes.forEach((unsubscribe) => unsubscribe()); + }; + }); + }), +}); diff --git a/packages/api/src/router/widgets/index.ts b/packages/api/src/router/widgets/index.ts index cac890f97..8f2d775db 100644 --- a/packages/api/src/router/widgets/index.ts +++ b/packages/api/src/router/widgets/index.ts @@ -3,6 +3,7 @@ import { lazy } from "@trpc/server"; import { createTRPCRouter } from "../../trpc"; export const widgetRouter = createTRPCRouter({ + coolify: lazy(() => import("./coolify").then((mod) => mod.coolifyRouter)), notebook: lazy(() => import("./notebook").then((mod) => mod.notebookRouter)), weather: lazy(() => import("./weather").then((mod) => mod.weatherRouter)), app: lazy(() => import("./app").then((mod) => mod.appRouter)), diff --git a/packages/cron-jobs/src/jobs/integrations/health-monitoring.ts b/packages/cron-jobs/src/jobs/integrations/health-monitoring.ts index e52db0d4e..197a61c8e 100644 --- a/packages/cron-jobs/src/jobs/integrations/health-monitoring.ts +++ b/packages/cron-jobs/src/jobs/integrations/health-monitoring.ts @@ -1,4 +1,5 @@ import { EVERY_5_SECONDS } from "@homarr/cron-jobs-core/expressions"; +import { coolifyRequestHandler } from "@homarr/request-handler/coolify"; import { clusterInfoRequestHandler, systemInfoRequestHandler } from "@homarr/request-handler/health-monitoring"; import { createRequestIntegrationJobHandler } from "@homarr/request-handler/lib/cached-request-integration-job-handler"; @@ -9,16 +10,20 @@ export const healthMonitoringJob = createCronJob("healthMonitoring", EVERY_5_SEC (integration, itemOptions: Record) => { const { kind } = integration; - if (kind !== "proxmox" && kind !== "mock") { + if (kind !== "proxmox" && kind !== "mock" && kind !== "coolify") { return systemInfoRequestHandler.handler({ ...integration, kind }, itemOptions); } + if (kind === "coolify") { + return coolifyRequestHandler.handler({ ...integration, kind }, itemOptions); + } return clusterInfoRequestHandler.handler({ ...integration, kind }, itemOptions); }, { - widgetKinds: ["healthMonitoring", "systemResources"], + widgetKinds: ["healthMonitoring", "systemResources", "coolify"], getInput: { healthMonitoring: () => ({}), systemResources: () => ({}), + coolify: () => ({}), }, }, ), diff --git a/packages/definitions/src/integration.ts b/packages/definitions/src/integration.ts index e8518b016..d97b0c31b 100644 --- a/packages/definitions/src/integration.ts +++ b/packages/definitions/src/integration.ts @@ -305,6 +305,14 @@ export const integrationDefs = { category: ["healthMonitoring"], documentationUrl: createDocumentationLink("/docs/integrations/unraid"), }, + coolify: { + name: "Coolify", + secretKinds: [["apiKey"]], + iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/coolify.svg", + category: ["healthMonitoring"], + // @ts-expect-error - docs page will be created when integration is merged + documentationUrl: createDocumentationLink("/docs/integrations/coolify"), + }, // This integration only returns mock data, it is used during development (but can also be used in production by directly going to the create page) mock: { name: "Mock", diff --git a/packages/definitions/src/widget.ts b/packages/definitions/src/widget.ts index 55719026c..8d5b657d2 100644 --- a/packages/definitions/src/widget.ts +++ b/packages/definitions/src/widget.ts @@ -29,6 +29,7 @@ export const widgetKinds = [ "firewall", "notifications", "systemResources", + "coolify", "systemDisks", ] as const; export type WidgetKind = (typeof widgetKinds)[number]; diff --git a/packages/integrations/src/base/creator.ts b/packages/integrations/src/base/creator.ts index adca7fcb1..d1ad60ad0 100644 --- a/packages/integrations/src/base/creator.ts +++ b/packages/integrations/src/base/creator.ts @@ -2,6 +2,7 @@ import type { IntegrationKind } from "@homarr/definitions"; import { AdGuardHomeIntegration } from "../adguard-home/adguard-home-integration"; import { CodebergIntegration } from "../codeberg/codeberg-integration"; +import { CoolifyIntegration } from "../coolify/coolify-integration"; import { DashDotIntegration } from "../dashdot/dashdot-integration"; import { DockerHubIntegration } from "../docker-hub/docker-hub-integration"; import { Aria2Integration } from "../download-client/aria2/aria2-integration"; @@ -103,6 +104,7 @@ export const integrationCreators = { mock: MockIntegration, truenas: TrueNasIntegration, unraid: UnraidIntegration, + coolify: CoolifyIntegration, } satisfies Record Promise]>; type IntegrationInstanceOfKind = { diff --git a/packages/integrations/src/coolify/coolify-integration.ts b/packages/integrations/src/coolify/coolify-integration.ts new file mode 100644 index 000000000..fa7e50667 --- /dev/null +++ b/packages/integrations/src/coolify/coolify-integration.ts @@ -0,0 +1,351 @@ +import { ResponseError } from "@homarr/common/server"; +import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; + +import type { IntegrationTestingInput } from "../base/integration"; +import { Integration } from "../base/integration"; +import type { TestingResult } from "../base/test-connection/test-connection-service"; +import type { + CoolifyApplication, + CoolifyApplicationWithContext, + CoolifyEnvironment, + CoolifyEnvironmentVariable, + CoolifyHealthcheck, + CoolifyInstanceInfo, + CoolifyProject, + CoolifyProjectWithEnvironments, + CoolifyResource, + CoolifyServer, + CoolifyService, + CoolifyServiceWithContext, +} from "./coolify-types"; +import { + coolifyApplicationLogSchema, + coolifyApplicationSchema, + coolifyEnvironmentSchema, + coolifyEnvironmentVariableSchema, + coolifyHealthcheckSchema, + coolifyProjectSchema, + coolifyProjectWithEnvironmentsSchema, + coolifyResourceSchema, + coolifyServerSchema, + coolifyServiceSchema, +} from "./coolify-types"; + +export class CoolifyIntegration extends Integration { + protected async testingAsync(input: IntegrationTestingInput): Promise { + const healthUrl = this.url("/api/health"); + const healthResponse = await input.fetchAsync(healthUrl, {}); + + if (!healthResponse.ok) { + throw new ResponseError(healthResponse); + } + + // Test API key authentication + const versionUrl = this.url("/api/v1/version"); + const versionResponse = await input.fetchAsync(versionUrl, { + headers: this.getAuthHeaders(), + }); + + if (!versionResponse.ok) { + throw new ResponseError(versionResponse); + } + + return { success: true }; + } + + /** + * Get Coolify version information + * https://coolify.io/docs/api-reference/api/operations/version + */ + public async getVersionAsync(): Promise { + const url = this.url("/api/v1/version"); + const response = await fetchWithTrustedCertificatesAsync(url, { + headers: this.getAuthHeaders(), + }); + + if (!response.ok) { + throw new ResponseError(response); + } + + const version = await response.text(); + return version.trim(); + } + + /** + * Get Coolify healthcheck status + * https://coolify.io/docs/api-reference/api/operations/healthcheck + */ + public async getHealthcheckAsync(): Promise { + const url = this.url("/api/v1/healthcheck"); + const response = await fetchWithTrustedCertificatesAsync(url, { + headers: this.getAuthHeaders(), + }); + + if (!response.ok) { + throw new ResponseError(response); + } + + return coolifyHealthcheckSchema.parse(await response.json()); + } + + /** + * List all applications + * https://coolify.io/docs/api-reference/api/operations/list-applications + */ + public async listApplicationsAsync(): Promise { + const url = this.url("/api/v1/applications"); + const response = await fetchWithTrustedCertificatesAsync(url, { + headers: this.getAuthHeaders(), + }); + + if (!response.ok) { + throw new ResponseError(response); + } + + return coolifyApplicationSchema.array().parse(await response.json()); + } + + /** + * Get application by UUID + * https://coolify.io/docs/api-reference/api/operations/get-application-by-uuid + */ + public async getApplicationByUuidAsync(uuid: string): Promise { + const url = this.url(`/api/v1/applications/${uuid}`); + const response = await fetchWithTrustedCertificatesAsync(url, { + headers: this.getAuthHeaders(), + }); + + if (!response.ok) { + throw new ResponseError(response); + } + + return coolifyApplicationSchema.parse(await response.json()); + } + + /** + * Get application logs by UUID + * https://coolify.io/docs/api-reference/api/operations/get-application-logs-by-uuid + */ + public async getApplicationLogsAsync(uuid: string): Promise { + const url = this.url(`/api/v1/applications/${uuid}/logs`); + const response = await fetchWithTrustedCertificatesAsync(url, { + headers: this.getAuthHeaders(), + }); + + if (!response.ok) { + throw new ResponseError(response); + } + + const result = coolifyApplicationLogSchema.parse(await response.json()); + return result.logs; + } + + /** + * List environment variables by application UUID + * https://coolify.io/docs/api-reference/api/operations/list-envs-by-application-uuid + */ + public async listEnvsByApplicationUuidAsync(uuid: string): Promise { + const url = this.url(`/api/v1/applications/${uuid}/envs`); + const response = await fetchWithTrustedCertificatesAsync(url, { + headers: this.getAuthHeaders(), + }); + + if (!response.ok) { + throw new ResponseError(response); + } + + return coolifyEnvironmentVariableSchema.array().parse(await response.json()); + } + + /** + * List projects + * https://coolify.io/docs/api-reference/api/operations/list-projects + */ + public async listProjectsAsync(): Promise { + const url = this.url("/api/v1/projects"); + const response = await fetchWithTrustedCertificatesAsync(url, { + headers: this.getAuthHeaders(), + }); + + if (!response.ok) { + throw new ResponseError(response); + } + + return coolifyProjectSchema.array().parse(await response.json()); + } + + /** + * Get project by UUID (includes environments) + * https://coolify.io/docs/api-reference/api/operations/get-project-by-uuid + */ + public async getProjectByUuidAsync(uuid: string): Promise { + const url = this.url(`/api/v1/projects/${uuid}`); + const response = await fetchWithTrustedCertificatesAsync(url, { + headers: this.getAuthHeaders(), + }); + + if (!response.ok) { + throw new ResponseError(response); + } + + return coolifyProjectWithEnvironmentsSchema.parse(await response.json()); + } + + /** + * Get environments + * https://coolify.io/docs/api-reference/api/operations/get-environments + */ + public async getEnvironmentsAsync(): Promise { + const url = this.url("/api/v1/environments"); + const response = await fetchWithTrustedCertificatesAsync(url, { + headers: this.getAuthHeaders(), + }); + + if (!response.ok) { + throw new ResponseError(response); + } + + return coolifyEnvironmentSchema.array().parse(await response.json()); + } + + /** + * Get environment by name or UUID + * https://coolify.io/docs/api-reference/api/operations/get-environment-by-name-or-uuid + */ + public async getEnvironmentByNameOrUuidAsync(nameOrUuid: string): Promise { + const url = this.url(`/api/v1/environments/${nameOrUuid}`); + const response = await fetchWithTrustedCertificatesAsync(url, { + headers: this.getAuthHeaders(), + }); + + if (!response.ok) { + throw new ResponseError(response); + } + + return coolifyEnvironmentSchema.parse(await response.json()); + } + + /** + * List resources + * https://coolify.io/docs/api-reference/api/operations/list-resources + */ + public async listResourcesAsync(): Promise { + const url = this.url("/api/v1/resources"); + const response = await fetchWithTrustedCertificatesAsync(url, { + headers: this.getAuthHeaders(), + }); + + if (!response.ok) { + throw new ResponseError(response); + } + + return coolifyResourceSchema.array().parse(await response.json()); + } + + /** + * List servers + * https://coolify.io/docs/api-reference/api/operations/list-servers + */ + public async listServersAsync(): Promise { + const url = this.url("/api/v1/servers"); + const response = await fetchWithTrustedCertificatesAsync(url, { + headers: this.getAuthHeaders(), + }); + + if (!response.ok) { + throw new ResponseError(response); + } + + return coolifyServerSchema.array().parse(await response.json()); + } + + /** + * List services + * https://coolify.io/docs/api-reference/api/operations/list-services + */ + public async listServicesAsync(): Promise { + const url = this.url("/api/v1/services"); + const response = await fetchWithTrustedCertificatesAsync(url, { + headers: this.getAuthHeaders(), + }); + + if (!response.ok) { + throw new ResponseError(response); + } + + return coolifyServiceSchema.array().parse(await response.json()); + } + + public async getInstanceInfoAsync(): Promise { + const [version, applications, services, projectsWithEnvs, servers] = await Promise.all([ + this.getVersionAsync(), + this.listApplicationsAsync(), + this.listServicesAsync(), + this.listProjectsWithEnvironmentsAsync(), + this.listServersAsync(), + ]); + + const envToProjectMap = new Map< + number, + { projectName: string; projectUuid: string; environmentName: string; environmentUuid?: string } + >(); + for (const project of projectsWithEnvs) { + for (const env of project.environments ?? []) { + if (env.id != null) { + envToProjectMap.set(env.id, { + projectName: project.name, + projectUuid: project.uuid, + environmentName: env.name, + environmentUuid: env.uuid ?? undefined, + }); + } + } + } + + const applicationsWithContext: CoolifyApplicationWithContext[] = applications.map((app) => { + const context = app.environment_id ? envToProjectMap.get(app.environment_id) : undefined; + return { + ...app, + projectName: context?.projectName, + projectUuid: context?.projectUuid, + environmentName: context?.environmentName, + environmentUuid: context?.environmentUuid, + }; + }); + + const servicesWithContext: CoolifyServiceWithContext[] = services.map((service) => { + const context = service.environment_id ? envToProjectMap.get(service.environment_id) : undefined; + const fqdn = service.fqdn ?? service.applications?.find((app) => app.fqdn)?.fqdn ?? undefined; + return { + ...service, + fqdn, + projectName: context?.projectName, + projectUuid: context?.projectUuid, + environmentName: context?.environmentName, + environmentUuid: context?.environmentUuid, + }; + }); + + return { + version, + applications: applicationsWithContext, + services: servicesWithContext, + servers, + }; + } + + /** + * List all projects with their environments + */ + private async listProjectsWithEnvironmentsAsync(): Promise { + const projects = await this.listProjectsAsync(); + const projectsWithEnvs = await Promise.all(projects.map((project) => this.getProjectByUuidAsync(project.uuid))); + return projectsWithEnvs; + } + + private getAuthHeaders(): Record { + return { + Authorization: `Bearer ${this.getSecretValue("apiKey")}`, + }; + } +} diff --git a/packages/integrations/src/coolify/coolify-types.ts b/packages/integrations/src/coolify/coolify-types.ts new file mode 100644 index 000000000..a2995d23b --- /dev/null +++ b/packages/integrations/src/coolify/coolify-types.ts @@ -0,0 +1,154 @@ +// Coolify API Types and Schemas based on https://coolify.io/docs/api-reference/api +import { z } from "zod/v4"; + +// Health check schema +export const coolifyHealthcheckSchema = z.object({ + status: z.string(), +}); + +export type CoolifyHealthcheck = z.infer; + +// Application log schema +export const coolifyApplicationLogSchema = z.object({ + logs: z.string(), +}); + +export type CoolifyApplicationLog = z.infer; + +// Environment variable schema +export const coolifyEnvironmentVariableSchema = z.object({ + id: z.number().nullish(), + key: z.string(), + value: z.string(), + is_build_time: z.boolean().nullish(), + is_preview: z.boolean().nullish(), + application_id: z.number().nullish(), +}); + +export type CoolifyEnvironmentVariable = z.infer; + +// Application schema (only fields used in the widget) +export const coolifyApplicationSchema = z.object({ + id: z.number().nullish(), + uuid: z.string(), + name: z.string(), + status: z.string().nullish(), + fqdn: z.string().nullish(), + environment_id: z.number().nullish(), + server_id: z.number().nullish(), + destination_id: z.number().nullish(), + updated_at: z.string().nullish(), + last_online_at: z.string().nullish(), +}); + +export type CoolifyApplication = z.infer; + +// Project schema +export const coolifyProjectSchema = z.object({ + id: z.number().nullish(), + uuid: z.string(), + name: z.string(), +}); + +export type CoolifyProject = z.infer; + +// Environment schema +export const coolifyEnvironmentSchema = z.object({ + id: z.number().nullish(), + uuid: z.string().nullish(), + name: z.string(), + project_id: z.number().nullish(), +}); + +export type CoolifyEnvironment = z.infer; + +// Project with environments schema +export const coolifyProjectWithEnvironmentsSchema = coolifyProjectSchema.extend({ + environments: z.array(coolifyEnvironmentSchema).nullish(), +}); + +export type CoolifyProjectWithEnvironments = z.infer; + +// Resource schema +export const coolifyResourceSchema = z.object({ + id: z.number().nullish(), + uuid: z.string(), + name: z.string(), + type: z.string(), + status: z.string().nullish(), +}); + +export type CoolifyResource = z.infer; + +// Server settings schema +export const coolifyServerSettingsSchema = z.object({ + server_id: z.number().nullish(), + is_build_server: z.boolean().nullish(), + is_reachable: z.boolean().nullish(), + is_usable: z.boolean().nullish(), +}); + +export type CoolifyServerSettings = z.infer; + +// Server schema +export const coolifyServerSchema = z.object({ + id: z.number().nullish(), + uuid: z.string(), + name: z.string(), + ip: z.string().nullish(), + is_reachable: z.boolean().nullish(), + is_usable: z.boolean().nullish(), + settings: coolifyServerSettingsSchema.nullish(), +}); + +export type CoolifyServer = z.infer; + +// Service application schema (nested in services) +export const coolifyServiceApplicationSchema = z.object({ + id: z.number().nullish(), + uuid: z.string(), + name: z.string(), + fqdn: z.string().nullish(), + status: z.string().nullish(), +}); + +export type CoolifyServiceApplication = z.infer; + +// Service schema +export const coolifyServiceSchema = z.object({ + id: z.number().nullish(), + uuid: z.string(), + name: z.string(), + status: z.string().nullish(), + fqdn: z.string().nullish(), + environment_id: z.number().nullish(), + server_id: z.number().nullish(), + destination_id: z.number().nullish(), + updated_at: z.string().nullish(), + applications: z.array(coolifyServiceApplicationSchema).nullish(), +}); + +export type CoolifyService = z.infer; + +// Extended types with context (not from API, added by integration) +export interface CoolifyApplicationWithContext extends CoolifyApplication { + projectName?: string; + projectUuid?: string; + environmentName?: string; + environmentUuid?: string; +} + +export interface CoolifyServiceWithContext extends CoolifyService { + projectName?: string; + projectUuid?: string; + environmentName?: string; + environmentUuid?: string; +} + +// Aggregated information for the Coolify instance +export interface CoolifyInstanceInfo { + version: string; + applications: CoolifyApplicationWithContext[]; + services: CoolifyServiceWithContext[]; + servers: CoolifyServer[]; +} diff --git a/packages/integrations/src/coolify/test/coolify-integration.spec.ts b/packages/integrations/src/coolify/test/coolify-integration.spec.ts new file mode 100644 index 000000000..c551fd3dc --- /dev/null +++ b/packages/integrations/src/coolify/test/coolify-integration.spec.ts @@ -0,0 +1,313 @@ +import { Request, Response } from "undici"; +import { beforeEach, describe, expect, test, vi } from "vitest"; + +import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; + +import { CoolifyIntegration } from "../coolify-integration"; +import type { + CoolifyApplication, + CoolifyProject, + CoolifyProjectWithEnvironments, + CoolifyServer, + CoolifyService, +} from "../coolify-types"; + +vi.mock("@homarr/core/infrastructure/http", () => ({ + fetchWithTrustedCertificatesAsync: vi.fn(), +})); + +const TEST_API_KEY = "test-api-key-12345"; +const TEST_URL = "https://coolify.example.com"; + +type MockResponseData = string | Record | unknown[]; + +const mockFetchWithTrustedCertificates = vi.mocked(fetchWithTrustedCertificatesAsync); + +const setupMockFetch = (responses: Record) => { + mockFetchWithTrustedCertificates.mockImplementation((url) => { + const urlString = typeof url === "string" ? url : url instanceof Request ? url.url : url.toString(); + const urlObj = new URL(urlString); + const path = urlObj.pathname; + + if (path in responses) { + const data = responses[path]; + const body = typeof data === "string" ? data : JSON.stringify(data); + return Promise.resolve( + new Response(body, { + status: 200, + headers: { "content-type": "application/json" }, + }) as unknown as Awaited>, + ); + } + + return Promise.resolve( + new Response(JSON.stringify({ error: "Not Found" }), { + status: 404, + headers: { "content-type": "application/json" }, + }) as unknown as Awaited>, + ); + }); +}; + +const createCoolifyIntegration = () => { + return new CoolifyIntegration({ + id: "test-coolify", + name: "Test Coolify", + url: TEST_URL, + externalUrl: null, + decryptedSecrets: [{ kind: "apiKey", value: TEST_API_KEY }], + }); +}; + +describe("CoolifyIntegration", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("getVersionAsync", () => { + test("should return version string from Coolify API", async () => { + setupMockFetch({ + "/api/v1/version": "4.0.0-beta.460", + }); + + const integration = createCoolifyIntegration(); + const result = await integration.getVersionAsync(); + + expect(result).toBe("4.0.0-beta.460"); + expect(mockFetchWithTrustedCertificates).toHaveBeenCalled(); + }); + + test("should throw error when API returns error", async () => { + mockFetchWithTrustedCertificates.mockResolvedValue( + new Response("Unauthorized", { + status: 401, + headers: { "content-type": "text/plain" }, + }) as unknown as Awaited>, + ); + + const integration = createCoolifyIntegration(); + await expect(integration.getVersionAsync()).rejects.toThrow(); + }); + }); + + describe("listServersAsync", () => { + test("should return list of servers", async () => { + const servers: CoolifyServer[] = [ + { + id: 1, + uuid: "server-uuid-1", + name: "Production Server", + ip: "192.168.1.100", + is_reachable: true, + is_usable: true, + settings: { is_build_server: false }, + }, + { + id: 2, + uuid: "server-uuid-2", + name: "Build Server", + ip: "192.168.1.101", + is_reachable: true, + is_usable: true, + settings: { is_build_server: true }, + }, + ]; + + setupMockFetch({ + "/api/v1/servers": servers, + }); + + const integration = createCoolifyIntegration(); + const result = await integration.listServersAsync(); + + expect(result).toHaveLength(2); + expect(result[0]?.name).toBe("Production Server"); + expect(result[1]?.settings?.is_build_server).toBe(true); + }); + }); + + describe("listApplicationsAsync", () => { + test("should return list of applications", async () => { + const applications: CoolifyApplication[] = [ + { + id: 1, + uuid: "app-uuid-1", + name: "my-app", + fqdn: "https://myapp.example.com", + status: "running", + updated_at: "2024-01-01T00:00:00Z", + }, + { + id: 2, + uuid: "app-uuid-2", + name: "another-app", + fqdn: "https://another.example.com", + status: "stopped", + updated_at: "2024-01-01T00:00:00Z", + }, + ]; + + setupMockFetch({ + "/api/v1/applications": applications, + }); + + const integration = createCoolifyIntegration(); + const result = await integration.listApplicationsAsync(); + + expect(result).toHaveLength(2); + expect(result[0]?.status).toBe("running"); + expect(result[1]?.status).toBe("stopped"); + }); + }); + + describe("listServicesAsync", () => { + test("should return list of services", async () => { + const services: CoolifyService[] = [ + { + id: 1, + uuid: "service-uuid-1", + name: "postgresql", + updated_at: "2024-01-01T00:00:00Z", + }, + ]; + + setupMockFetch({ + "/api/v1/services": services, + }); + + const integration = createCoolifyIntegration(); + const result = await integration.listServicesAsync(); + + expect(result).toHaveLength(1); + expect(result[0]?.name).toBe("postgresql"); + }); + }); + + describe("listProjectsAsync", () => { + test("should return list of projects", async () => { + const projects: CoolifyProject[] = [ + { + id: 1, + uuid: "proj-uuid-1", + name: "Production", + }, + ]; + + setupMockFetch({ + "/api/v1/projects": projects, + }); + + const integration = createCoolifyIntegration(); + const result = await integration.listProjectsAsync(); + + expect(result).toHaveLength(1); + expect(result[0]?.name).toBe("Production"); + }); + }); + + describe("getInstanceInfoAsync", () => { + test("should aggregate all instance data with project context", async () => { + const projectWithEnvs: CoolifyProjectWithEnvironments = { + id: 1, + uuid: "proj-uuid-1", + name: "Production", + environments: [ + { + id: 1, + uuid: "env-uuid-1", + name: "production", + project_id: 1, + }, + ], + }; + + setupMockFetch({ + "/api/v1/version": "4.0.0-beta.460", + "/api/v1/applications": [ + { + id: 1, + uuid: "app-uuid-1", + name: "my-app", + status: "running", + environment_id: 1, + updated_at: "2024-01-01T00:00:00Z", + }, + ], + "/api/v1/services": [ + { + id: 1, + uuid: "service-uuid-1", + name: "postgres", + environment_id: 1, + updated_at: "2024-01-01T00:00:00Z", + }, + ], + "/api/v1/projects": [ + { + id: 1, + uuid: "proj-uuid-1", + name: "Production", + }, + ], + "/api/v1/servers": [ + { + id: 1, + uuid: "server-uuid-1", + name: "Server 1", + ip: "192.168.1.1", + is_reachable: true, + is_usable: true, + settings: { is_build_server: false }, + }, + ], + "/api/v1/projects/proj-uuid-1": projectWithEnvs, + }); + + const integration = createCoolifyIntegration(); + const result = await integration.getInstanceInfoAsync(); + + expect(result.version).toBe("4.0.0-beta.460"); + expect(result.applications).toHaveLength(1); + expect(result.services).toHaveLength(1); + expect(result.servers).toHaveLength(1); + expect(result.applications[0]?.projectName).toBe("Production"); + expect(result.applications[0]?.environmentName).toBe("production"); + }); + }); + + describe("getHealthcheckAsync", () => { + test("should return healthcheck status", async () => { + setupMockFetch({ + "/api/v1/healthcheck": { status: "healthy" }, + }); + + const integration = createCoolifyIntegration(); + const result = await integration.getHealthcheckAsync(); + + expect(result.status).toBe("healthy"); + }); + }); + + describe("getApplicationByUuidAsync", () => { + test("should return application details by UUID", async () => { + const application: CoolifyApplication = { + id: 1, + uuid: "app-uuid-1", + name: "my-app", + fqdn: "https://app.example.com", + status: "running", + updated_at: "2024-01-01T00:00:00Z", + }; + + setupMockFetch({ + "/api/v1/applications/app-uuid-1": application, + }); + + const integration = createCoolifyIntegration(); + const result = await integration.getApplicationByUuidAsync("app-uuid-1"); + + expect(result.uuid).toBe("app-uuid-1"); + expect(result.name).toBe("my-app"); + }); + }); +}); diff --git a/packages/integrations/src/index.ts b/packages/integrations/src/index.ts index 45a37e476..a2f1287df 100644 --- a/packages/integrations/src/index.ts +++ b/packages/integrations/src/index.ts @@ -25,6 +25,7 @@ export { TrueNasIntegration } from "./truenas/truenas-integration"; export { UnraidIntegration } from "./unraid/unraid-integration"; export { OPNsenseIntegration } from "./opnsense/opnsense-integration"; export { ICalIntegration } from "./ical/ical-integration"; +export { CoolifyIntegration } from "./coolify/coolify-integration"; // Types export type { IntegrationInput } from "./base/integration"; diff --git a/packages/integrations/src/types.ts b/packages/integrations/src/types.ts index e38b3e710..d4be3a9c2 100644 --- a/packages/integrations/src/types.ts +++ b/packages/integrations/src/types.ts @@ -11,3 +11,4 @@ export * from "./proxmox/proxmox-types"; export * from "./unifi-controller/unifi-controller-types"; export * from "./opnsense/opnsense-types"; export * from "./interfaces/media-releases"; +export * from "./coolify/coolify-types"; diff --git a/packages/request-handler/src/coolify.ts b/packages/request-handler/src/coolify.ts new file mode 100644 index 000000000..d307a780f --- /dev/null +++ b/packages/request-handler/src/coolify.ts @@ -0,0 +1,19 @@ +import dayjs from "dayjs"; + +import { createIntegrationAsync } from "@homarr/integrations"; +import type { CoolifyInstanceInfo } from "@homarr/integrations/types"; + +import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler"; + +export const coolifyRequestHandler = createCachedIntegrationRequestHandler< + CoolifyInstanceInfo, + "coolify", + Record +>({ + async requestAsync(integration, _input) { + const integrationInstance = await createIntegrationAsync(integration); + return await integrationInstance.getInstanceInfoAsync(); + }, + cacheDuration: dayjs.duration(30, "seconds"), + queryKey: "coolifyInfo", +}); diff --git a/packages/request-handler/src/health-monitoring.ts b/packages/request-handler/src/health-monitoring.ts index 3a8959286..98d0cce3d 100644 --- a/packages/request-handler/src/health-monitoring.ts +++ b/packages/request-handler/src/health-monitoring.ts @@ -8,7 +8,7 @@ import { createCachedIntegrationRequestHandler } from "./lib/cached-integration- export const systemInfoRequestHandler = createCachedIntegrationRequestHandler< SystemHealthMonitoring, - Exclude, "proxmox">, + Exclude, "proxmox" | "coolify">, Record >({ async requestAsync(integration, _input) { diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index 92f8ebc70..6fbbfa187 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -1138,6 +1138,9 @@ "dangerZone": "Danger zone", "noResults": "No results found", "unsavedChanges": "You have unsaved changes!", + "servers": "Servers", + "applications": "Apps", + "services": "Services", "preview": { "show": "Show preview", "hide": "Hide preview" @@ -1960,6 +1963,39 @@ "internalServerError": "Failed to fetch containers stats" } }, + "coolify": { + "name": "Coolify", + "description": "Overview of your Coolify instance with servers and applications", + "option": { + "showServers": { + "label": "Show servers" + }, + "showApplications": { + "label": "Show applications" + }, + "showServices": { + "label": "Show services" + } + }, + "empty": { + "servers": "No servers found", + "applications": "No applications found", + "services": "No services found" + }, + "footer": { + "updated": "Updated {when}" + }, + "error": { + "noIntegration": "No Coolify integration configured", + "internalServerError": "Failed to fetch Coolify data" + }, + "server": { + "buildServer": "Build" + }, + "instances": { + "title": "Instances" + } + }, "common": { "location": { "query": "City / Postal code", diff --git a/packages/widgets/src/coolify/applications-section.tsx b/packages/widgets/src/coolify/applications-section.tsx new file mode 100644 index 000000000..bbc52c662 --- /dev/null +++ b/packages/widgets/src/coolify/applications-section.tsx @@ -0,0 +1,48 @@ +"use client"; + +import { Accordion, Badge, Group, Stack, Text } from "@mantine/core"; +import { IconCloud } from "@tabler/icons-react"; + +import type { CoolifyApplicationWithContext } from "@homarr/integrations/types"; +import { useScopedI18n } from "@homarr/translation/client"; + +import { getBadgeColor, parseStatus } from "./coolify-utils"; +import { ResourceRow } from "./resource-row"; + +interface ApplicationsSectionProps { + applications: CoolifyApplicationWithContext[]; + baseUrl: string; + isTiny: boolean; +} + +export function ApplicationsSection({ applications, baseUrl, isTiny }: ApplicationsSectionProps) { + const t = useScopedI18n("widget.coolify"); + const tCommon = useScopedI18n("common"); + const runningApps = applications.filter((app) => parseStatus(app.status ?? "") === "running").length; + + return ( + + }> + + {tCommon("applications")} + + {runningApps} / {applications.length} + + + + + {applications.length > 0 ? ( + + {applications.map((app) => ( + + ))} + + ) : ( + + {t("empty.applications")} + + )} + + + ); +} diff --git a/packages/widgets/src/coolify/component.tsx b/packages/widgets/src/coolify/component.tsx new file mode 100644 index 000000000..a62b4f3cd --- /dev/null +++ b/packages/widgets/src/coolify/component.tsx @@ -0,0 +1,76 @@ +"use client"; + +import { ScrollArea, Stack, Text } from "@mantine/core"; + +import { clientApi } from "@homarr/api/client"; +import { useScopedI18n } from "@homarr/translation/client"; + +import type { WidgetComponentProps } from "../definition"; +import { createWidgetKey } from "./coolify-utils"; +import { InstanceCard } from "./instance-card"; +import { SingleInstanceLayout } from "./single-instance-layout"; + +export default function CoolifyWidget({ options, integrationIds, width }: WidgetComponentProps<"coolify">) { + const t = useScopedI18n("widget.coolify"); + + if (integrationIds.length === 0) { + return ( + + {t("error.noIntegration")} + + ); + } + + return ; +} + +interface CoolifyContentProps { + integrationIds: string[]; + options: WidgetComponentProps<"coolify">["options"]; + width: number; +} + +function CoolifyContent({ integrationIds, options, width }: CoolifyContentProps) { + const [instancesData] = clientApi.widget.coolify.getInstancesInfo.useSuspenseQuery({ integrationIds }); + + const utils = clientApi.useUtils(); + clientApi.widget.coolify.subscribeInstancesInfo.useSubscription( + { integrationIds }, + { + onData(newData) { + utils.widget.coolify.getInstancesInfo.setData({ integrationIds }, (prevData) => { + if (!prevData) return prevData; + return prevData.map((instance) => + instance.integrationId === newData.integrationId + ? { ...instance, instanceInfo: newData.instanceInfo, updatedAt: newData.timestamp } + : instance, + ); + }); + }, + }, + ); + + const isTiny = width < 256; + const [firstInstance] = instancesData; + const widgetKey = createWidgetKey(integrationIds); + + if (instancesData.length === 1 && firstInstance) { + return ; + } + + return ( + + + {instancesData.map((instance) => ( + + ))} + + + ); +} diff --git a/packages/widgets/src/coolify/coolify-utils.ts b/packages/widgets/src/coolify/coolify-utils.ts new file mode 100644 index 000000000..7bf4f50cb --- /dev/null +++ b/packages/widgets/src/coolify/coolify-utils.ts @@ -0,0 +1,125 @@ +import type { + CoolifyApplicationWithContext, + CoolifyServer, + CoolifyServiceWithContext, +} from "@homarr/integrations/types"; + +export function parseStatus(status: string): string { + const firstPart = status.split(":")[0]?.toLowerCase(); + if (!firstPart) return "unknown"; + return firstPart; +} + +export function cleanFqdn(fqdn: string | undefined | null): string | undefined { + if (!fqdn) return undefined; + const firstUrl = fqdn.split(",")[0]?.trim(); + if (!firstUrl) return undefined; + try { + const url = new URL(firstUrl); + return `${url.protocol}//${url.host}${url.pathname}`.replace(/\/$/, ""); + } catch { + return firstUrl; + } +} + +export function getStatusColor(status: string): string { + const colors: Record = { + running: "green", + stopped: "red", + exited: "red", + starting: "yellow", + restarting: "yellow", + }; + return colors[status] ?? "gray"; +} + +export function getBadgeColor(running: number, total: number): string { + if (total === 0) return "gray"; + if (running === total) return "green"; + if (running > 0) return "yellow"; + return "red"; +} + +export function formatRelativeTime(dateString: string | null | undefined): string | undefined { + if (!dateString) return undefined; + const date = new Date(dateString); + if (isNaN(date.getTime())) return undefined; + + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffSec = Math.floor(diffMs / 1000); + const diffMin = Math.floor(diffSec / 60); + const diffHour = Math.floor(diffMin / 60); + const diffDay = Math.floor(diffHour / 24); + + if (diffDay > 0) { + const remainingHours = diffHour % 24; + return remainingHours > 0 ? `${diffDay}d ${remainingHours}h ago` : `${diffDay}d ago`; + } + if (diffHour > 0) { + const remainingMin = diffMin % 60; + return remainingMin > 0 ? `${diffHour}h ${remainingMin}m ago` : `${diffHour}h ago`; + } + if (diffMin > 0) return `${diffMin}m ago`; + return "just now"; +} + +export function getResourceTimestamp( + item: { updated_at?: string | null; last_online_at?: string | null; status?: string | null }, + resourceType: "application" | "service", +): string | undefined { + const status = parseStatus(item.status ?? ""); + const isRunning = status === "running"; + + if (isRunning) return undefined; + + const timestamp = resourceType === "application" ? (item.last_online_at ?? item.updated_at) : item.updated_at; + return formatRelativeTime(timestamp); +} + +export function buildServerResourceCounts( + servers: CoolifyServer[], + applications: CoolifyApplicationWithContext[], + services: CoolifyServiceWithContext[], +): Map { + const serverResourceCounts = new Map(); + + for (const server of servers) { + const serverId = server.settings?.server_id ?? server.id ?? 0; + serverResourceCounts.set(serverId, { apps: 0, services: 0 }); + } + + const destinationToServer = new Map(); + for (const service of services) { + if (service.destination_id != null && service.server_id != null) { + destinationToServer.set(service.destination_id, service.server_id); + } + } + + for (const app of applications) { + const serverId = app.server_id ?? destinationToServer.get(app.destination_id ?? 0) ?? app.destination_id ?? 0; + const counts = serverResourceCounts.get(serverId); + if (counts) { + counts.apps++; + } + } + + for (const service of services) { + const serverId = service.server_id ?? service.destination_id ?? 0; + const counts = serverResourceCounts.get(serverId); + if (counts) { + counts.services++; + } + } + + return serverResourceCounts; +} + +export function createWidgetKey(integrationIds: string[]): string { + return integrationIds.slice().sort().join("-"); +} + +export function createStorageKey(widgetKey: string, integrationId: string, type: "sections" | "show-ip"): string { + const cardKey = widgetKey.includes("-") ? `${widgetKey}-${integrationId}` : widgetKey; + return `coolify-${type}-${cardKey}`; +} diff --git a/packages/widgets/src/coolify/index.ts b/packages/widgets/src/coolify/index.ts new file mode 100644 index 000000000..cebf9de05 --- /dev/null +++ b/packages/widgets/src/coolify/index.ts @@ -0,0 +1,28 @@ +import { IconCloud, IconServerOff } from "@tabler/icons-react"; + +import { createWidgetDefinition } from "../definition"; +import { optionsBuilder } from "../options"; + +export const { definition, componentLoader } = createWidgetDefinition("coolify", { + icon: IconCloud, + createOptions() { + return optionsBuilder.from((factory) => ({ + showServers: factory.switch({ + defaultValue: true, + }), + showApplications: factory.switch({ + defaultValue: true, + }), + showServices: factory.switch({ + defaultValue: true, + }), + })); + }, + supportedIntegrations: ["coolify"], + errors: { + INTERNAL_SERVER_ERROR: { + icon: IconServerOff, + message: (t) => t("widget.coolify.error.internalServerError"), + }, + }, +}).withDynamicImport(() => import("./component")); diff --git a/packages/widgets/src/coolify/instance-card.tsx b/packages/widgets/src/coolify/instance-card.tsx new file mode 100644 index 000000000..f6eec9c52 --- /dev/null +++ b/packages/widgets/src/coolify/instance-card.tsx @@ -0,0 +1,122 @@ +"use client"; + +import { Accordion, Anchor, Badge, Card, Group, Image, Text } from "@mantine/core"; +import { useLocalStorage } from "@mantine/hooks"; + +import { useTimeAgo } from "@homarr/common"; +import { useScopedI18n } from "@homarr/translation/client"; + +import { ApplicationsSection } from "./applications-section"; +import { buildServerResourceCounts, getBadgeColor, parseStatus } from "./coolify-utils"; +import { ServersSection } from "./servers-section"; +import { ServicesSection } from "./services-section"; +import type { CoolifyOptions, InstanceData } from "./types"; +import { COOLIFY_ICON_URL } from "./types"; + +interface InstanceCardProps { + instance: InstanceData; + options: CoolifyOptions; + isTiny: boolean; + widgetKey: string; +} + +export function InstanceCard({ instance, options, isTiny, widgetKey }: InstanceCardProps) { + const t = useScopedI18n("widget.coolify"); + const cardKey = `${widgetKey}-${instance.integrationId}`; + const [showIp, setShowIp] = useLocalStorage({ + key: `coolify-show-ip-${cardKey}`, + defaultValue: false, + }); + const [openSections, setOpenSections] = useLocalStorage({ + key: `coolify-sections-${cardKey}`, + defaultValue: ["applications"], + }); + + const serverResourceCounts = buildServerResourceCounts( + instance.instanceInfo.servers, + instance.instanceInfo.applications, + instance.instanceInfo.services, + ); + + const baseUrl = instance.integrationUrl.replace(/\/+$/, ""); + const relativeTime = useTimeAgo(instance.updatedAt); + + const onlineServers = instance.instanceInfo.servers.filter((s) => s.is_reachable !== false).length; + const runningApps = instance.instanceInfo.applications.filter( + (a) => parseStatus(a.status ?? "") === "running", + ).length; + const runningServices = instance.instanceInfo.services.filter( + (s) => parseStatus(s.status ?? "") === "running", + ).length; + + return ( + + + + Coolify + + {instance.integrationName} + + + + {options.showServers && ( + + {onlineServers}/{instance.instanceInfo.servers.length} + + )} + {options.showApplications && ( + + {runningApps}/{instance.instanceInfo.applications.length} + + )} + {options.showServices && ( + + {runningServices}/{instance.instanceInfo.services.length} + + )} + + + + + {options.showServers && ( + setShowIp((prev) => !prev)} + /> + )} + {options.showApplications && ( + + )} + {options.showServices && ( + + )} + + + + + v{instance.instanceInfo.version} + + + {t("footer.updated", { when: relativeTime })} + + + + ); +} diff --git a/packages/widgets/src/coolify/resource-row.tsx b/packages/widgets/src/coolify/resource-row.tsx new file mode 100644 index 000000000..9f6cfe1ae --- /dev/null +++ b/packages/widgets/src/coolify/resource-row.tsx @@ -0,0 +1,78 @@ +"use client"; + +import { ActionIcon, Anchor, Group, Indicator, Stack, Text } from "@mantine/core"; +import { IconExternalLink, IconFileText, IconLink } from "@tabler/icons-react"; + +import { cleanFqdn, getResourceTimestamp, getStatusColor, parseStatus } from "./coolify-utils"; + +interface ResourceRowProps { + item: { + uuid: string; + name: string; + status?: string | null; + fqdn?: string | null; + updated_at?: string | null; + last_online_at?: string | null; + projectName?: string; + projectUuid?: string; + environmentName?: string; + environmentUuid?: string; + }; + baseUrl: string; + isTiny: boolean; + resourceType: "application" | "service"; +} + +export function ResourceRow({ item, baseUrl, isTiny, resourceType }: ResourceRowProps) { + const status = parseStatus(item.status ?? ""); + const statusColor = getStatusColor(status); + + const resourceUrl = + item.projectUuid && item.environmentUuid + ? `${baseUrl}/project/${item.projectUuid}/environment/${item.environmentUuid}/${resourceType}/${item.uuid}` + : undefined; + + const logsUrl = resourceUrl ? `${resourceUrl}/logs` : undefined; + + return ( + + + + {resourceUrl ? ( + + {item.name} + + ) : ( + + {item.name} + + )} + + + {cleanFqdn(item.fqdn) && ( + + + + )} + {resourceUrl && ( + + + + )} + {logsUrl && ( + + + + )} + + {item.projectName ?? "-"} / {item.environmentName ?? "-"} + + {getResourceTimestamp(item, resourceType) && ( + + {getResourceTimestamp(item, resourceType)} + + )} + + + ); +} diff --git a/packages/widgets/src/coolify/servers-section.tsx b/packages/widgets/src/coolify/servers-section.tsx new file mode 100644 index 000000000..543df1c67 --- /dev/null +++ b/packages/widgets/src/coolify/servers-section.tsx @@ -0,0 +1,122 @@ +"use client"; + +import { Accordion, ActionIcon, Anchor, Badge, Group, Indicator, Stack, Text } from "@mantine/core"; +import { IconExternalLink, IconEye, IconEyeOff, IconServer } from "@tabler/icons-react"; + +import type { CoolifyServer } from "@homarr/integrations/types"; +import { useScopedI18n } from "@homarr/translation/client"; + +import { getBadgeColor } from "./coolify-utils"; + +interface ServersSectionProps { + servers: CoolifyServer[]; + serverResourceCounts: Map; + baseUrl: string; + isTiny: boolean; + showIp: boolean; + onToggleIp: () => void; +} + +export function ServersSection({ + servers, + serverResourceCounts, + baseUrl, + isTiny, + showIp, + onToggleIp, +}: ServersSectionProps) { + const t = useScopedI18n("widget.coolify"); + const tCommon = useScopedI18n("common"); + const onlineServers = servers.filter((server) => server.is_reachable !== false).length; + + return ( + + }> + + + {tCommon("servers")} + + {onlineServers} / {servers.length} + + + { + event.stopPropagation(); + onToggleIp(); + }} + > + {showIp ? : } + + + + + {servers.length > 0 ? ( + + {servers.map((server) => ( + + ))} + + ) : ( + + {t("empty.servers")} + + )} + + + ); +} + +interface ServerRowProps { + server: CoolifyServer; + counts: { apps: number; services: number }; + baseUrl: string; + isTiny: boolean; + showIp: boolean; +} + +function ServerRow({ server, counts, baseUrl, isTiny, showIp }: ServerRowProps) { + const t = useScopedI18n("widget.coolify"); + const isBuildServer = server.settings?.is_build_server === true; + const isOnline = server.is_reachable !== false; + const serverUrl = `${baseUrl}/server/${server.uuid}`; + + return ( + + + + + {server.name} + + {isBuildServer ? ( + + {t("server.buildServer")} + + ) : ( + + ({counts.apps} apps / {counts.services} svcs) + + )} + + + + + + + {showIp ? server.ip : "***.***.***.***"} + + + + ); +} diff --git a/packages/widgets/src/coolify/services-section.tsx b/packages/widgets/src/coolify/services-section.tsx new file mode 100644 index 000000000..c225db27d --- /dev/null +++ b/packages/widgets/src/coolify/services-section.tsx @@ -0,0 +1,48 @@ +"use client"; + +import { Accordion, Badge, Group, Stack, Text } from "@mantine/core"; +import { IconStack2 } from "@tabler/icons-react"; + +import type { CoolifyServiceWithContext } from "@homarr/integrations/types"; +import { useScopedI18n } from "@homarr/translation/client"; + +import { getBadgeColor, parseStatus } from "./coolify-utils"; +import { ResourceRow } from "./resource-row"; + +interface ServicesSectionProps { + services: CoolifyServiceWithContext[]; + baseUrl: string; + isTiny: boolean; +} + +export function ServicesSection({ services, baseUrl, isTiny }: ServicesSectionProps) { + const t = useScopedI18n("widget.coolify"); + const tCommon = useScopedI18n("common"); + const runningServices = services.filter((svc) => parseStatus(svc.status ?? "") === "running").length; + + return ( + + }> + + {tCommon("services")} + + {runningServices} / {services.length} + + + + + {services.length > 0 ? ( + + {services.map((service) => ( + + ))} + + ) : ( + + {t("empty.services")} + + )} + + + ); +} diff --git a/packages/widgets/src/coolify/single-instance-layout.tsx b/packages/widgets/src/coolify/single-instance-layout.tsx new file mode 100644 index 000000000..af9948b13 --- /dev/null +++ b/packages/widgets/src/coolify/single-instance-layout.tsx @@ -0,0 +1,92 @@ +"use client"; + +import { Accordion, Anchor, Group, Image, ScrollArea, Stack, Text } from "@mantine/core"; +import { useLocalStorage } from "@mantine/hooks"; + +import { useTimeAgo } from "@homarr/common"; +import { useScopedI18n } from "@homarr/translation/client"; + +import { ApplicationsSection } from "./applications-section"; +import { buildServerResourceCounts } from "./coolify-utils"; +import { ServersSection } from "./servers-section"; +import { ServicesSection } from "./services-section"; +import type { CoolifyOptions, InstanceData } from "./types"; +import { COOLIFY_ICON_URL } from "./types"; + +interface SingleInstanceLayoutProps { + instance: InstanceData; + options: CoolifyOptions; + isTiny: boolean; + widgetKey: string; +} + +export function SingleInstanceLayout({ instance, options, isTiny, widgetKey }: SingleInstanceLayoutProps) { + const t = useScopedI18n("widget.coolify"); + const [showIp, setShowIp] = useLocalStorage({ + key: `coolify-show-ip-${widgetKey}`, + defaultValue: false, + }); + const [openSections, setOpenSections] = useLocalStorage({ + key: `coolify-sections-${widgetKey}`, + defaultValue: ["applications"], + }); + + const serverResourceCounts = buildServerResourceCounts( + instance.instanceInfo.servers, + instance.instanceInfo.applications, + instance.instanceInfo.services, + ); + + const baseUrl = instance.integrationUrl.replace(/\/+$/, ""); + const displayUrl = baseUrl.replace(/^https?:\/\//, ""); + const relativeTime = useTimeAgo(instance.updatedAt); + + return ( + + + + + Coolify + + oolify + + + + {displayUrl} + + + + + {options.showServers && ( + setShowIp((prev) => !prev)} + /> + )} + {options.showApplications && ( + + )} + {options.showServices && ( + + )} + + + + + Coolify + + v{instance.instanceInfo.version} + + + + {t("footer.updated", { when: relativeTime })} + + + + + ); +} diff --git a/packages/widgets/src/coolify/types.ts b/packages/widgets/src/coolify/types.ts new file mode 100644 index 000000000..a5982b695 --- /dev/null +++ b/packages/widgets/src/coolify/types.ts @@ -0,0 +1,15 @@ +import type { CoolifyInstanceInfo } from "@homarr/integrations/types"; + +import type { WidgetComponentProps } from "../definition"; + +export const COOLIFY_ICON_URL = "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/coolify.svg"; + +export interface InstanceData { + integrationId: string; + integrationName: string; + integrationUrl: string; + instanceInfo: CoolifyInstanceInfo; + updatedAt: Date; +} + +export type CoolifyOptions = WidgetComponentProps<"coolify">["options"]; diff --git a/packages/widgets/src/index.tsx b/packages/widgets/src/index.tsx index d13888c6a..ec075d467 100644 --- a/packages/widgets/src/index.tsx +++ b/packages/widgets/src/index.tsx @@ -11,6 +11,7 @@ import * as app from "./app"; import * as bookmarks from "./bookmarks"; import * as calendar from "./calendar"; import * as clock from "./clock"; +import * as coolify from "./coolify"; import type { WidgetComponentProps } from "./definition"; import * as dnsHoleControls from "./dns-hole/controls"; import * as dnsHoleSummary from "./dns-hole/summary"; @@ -76,6 +77,7 @@ export const widgetImports = { notifications, mediaReleases, systemResources, + coolify, systemDisks, } satisfies WidgetImportRecord;