diff --git a/package-lock.json b/package-lock.json index c6da9176..3c10c813 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,6 +24,7 @@ "@radix-ui/react-popover": "1.1.4", "@radix-ui/react-progress": "^1.1.4", "@radix-ui/react-radio-group": "1.2.2", + "@radix-ui/react-scroll-area": "^1.2.9", "@radix-ui/react-select": "2.1.4", "@radix-ui/react-separator": "1.1.1", "@radix-ui/react-slot": "1.1.1", @@ -2709,6 +2710,189 @@ } } }, + "node_modules/@radix-ui/react-scroll-area": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.9.tgz", + "integrity": "sha512-YSjEfBXnhUELsO2VzjdtYYD4CfQjvao+lhhrX5XsHD7/cyUNzljF1FHEbgTPN7LH2MClfwRMIsYlqTYpKTTe2A==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-presence": "1.1.4", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/number": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", + "integrity": "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/primitive": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.2.tgz", + "integrity": "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-direction": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz", + "integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-presence": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.4.tgz", + "integrity": "sha512-ueDqRbdc4/bkaQT3GIpLQssRlFgWaL/U2z/S31qRwwLWoxHLgry3SIfCwhxeQNbirEUXFa+lq3RL3oBYXtcmIA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", + "integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", + "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-select": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.1.4.tgz", diff --git a/package.json b/package.json index f2ce2cd4..4a85b570 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "@radix-ui/react-popover": "1.1.4", "@radix-ui/react-progress": "^1.1.4", "@radix-ui/react-radio-group": "1.2.2", + "@radix-ui/react-scroll-area": "^1.2.9", "@radix-ui/react-select": "2.1.4", "@radix-ui/react-separator": "1.1.1", "@radix-ui/react-slot": "1.1.1", diff --git a/server/db/schemas/schema.ts b/server/db/schemas/schema.ts index ebbc0ce3..5e4a6be3 100644 --- a/server/db/schemas/schema.ts +++ b/server/db/schemas/schema.ts @@ -41,7 +41,10 @@ export const sites = sqliteTable("sites", { megabytesOut: integer("bytesOut"), lastBandwidthUpdate: text("lastBandwidthUpdate"), type: text("type").notNull(), // "newt" or "wireguard" - online: integer("online", { mode: "boolean" }).notNull().default(false) + online: integer("online", { mode: "boolean" }).notNull().default(false), + dockerSocketEnabled: integer("dockerSocketEnabled", { mode: "boolean" }) + .notNull() + .default(false) }); export const resources = sqliteTable("resources", { diff --git a/server/routers/external.ts b/server/routers/external.ts index 41979651..3bb3ebda 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -29,7 +29,7 @@ import { getUserOrgs, verifyUserIsServerAdmin, verifyIsLoggedInUser, - verifyApiKeyAccess, + verifyApiKeyAccess } from "@server/middlewares"; import { verifyUserHasAction } from "../middlewares/verifyUserHasAction"; import { ActionsEnum } from "@server/auth/actions"; @@ -124,6 +124,37 @@ authenticated.delete( site.deleteSite ); +authenticated.get( + "/site/:siteId/docker/status", + verifySiteAccess, + verifyUserHasAction(ActionsEnum.getSite), + site.dockerStatus +); +authenticated.get( + "/site/:siteId/docker/online", + verifySiteAccess, + verifyUserHasAction(ActionsEnum.getSite), + site.dockerOnline +); +authenticated.post( + "/site/:siteId/docker/check", + verifySiteAccess, + verifyUserHasAction(ActionsEnum.getSite), + site.checkDockerSocket +); +authenticated.post( + "/site/:siteId/docker/trigger", + verifySiteAccess, + verifyUserHasAction(ActionsEnum.getSite), + site.triggerFetchContainers +); +authenticated.get( + "/site/:siteId/docker/containers", + verifySiteAccess, + verifyUserHasAction(ActionsEnum.getSite), + site.listContainers +); + authenticated.put( "/org/:orgId/site/:siteId/resource", verifyOrgAccess, diff --git a/server/routers/messageHandlers.ts b/server/routers/messageHandlers.ts index 9dd7756f..e79f8606 100644 --- a/server/routers/messageHandlers.ts +++ b/server/routers/messageHandlers.ts @@ -1,6 +1,12 @@ -import { handleRegisterMessage } from "./newt"; +import { + handleRegisterMessage, + handleDockerStatusMessage, + handleDockerContainersMessage +} from "./newt"; import { MessageHandler } from "./ws"; export const messageHandlers: Record = { "newt/wg/register": handleRegisterMessage, -}; \ No newline at end of file + "newt/socket/status": handleDockerStatusMessage, + "newt/socket/containers": handleDockerContainersMessage +}; diff --git a/server/routers/newt/dockerSocket.ts b/server/routers/newt/dockerSocket.ts new file mode 100644 index 00000000..0c59d354 --- /dev/null +++ b/server/routers/newt/dockerSocket.ts @@ -0,0 +1,22 @@ +import NodeCache from "node-cache"; +import { sendToClient } from "../ws"; + +export const dockerSocketCache = new NodeCache({ + stdTTL: 3600 // seconds +}); + +export function fetchContainers(newtId: string) { + const payload = { + type: `newt/socket/fetch`, + data: {} + }; + sendToClient(newtId, payload); +} + +export function dockerSocket(newtId: string) { + const payload = { + type: `newt/socket/check`, + data: {} + }; + sendToClient(newtId, payload); +} diff --git a/server/routers/newt/handleSocketMessages.ts b/server/routers/newt/handleSocketMessages.ts new file mode 100644 index 00000000..0a217c52 --- /dev/null +++ b/server/routers/newt/handleSocketMessages.ts @@ -0,0 +1,57 @@ +import { MessageHandler } from "../ws"; +import logger from "@server/logger"; +import { dockerSocketCache } from "./dockerSocket"; + +export const handleDockerStatusMessage: MessageHandler = async (context) => { + const { message, newt } = context; + + logger.info("Handling Docker socket check response"); + + if (!newt) { + logger.warn("Newt not found"); + return; + } + + logger.info(`Newt ID: ${newt.newtId}, Site ID: ${newt.siteId}`); + const { available, socketPath } = message.data; + + logger.info( + `Docker socket availability for Newt ${newt.newtId}: available=${available}, socketPath=${socketPath}` + ); + + if (available) { + logger.info(`Newt ${newt.newtId} has Docker socket access`); + dockerSocketCache.set(`${newt.newtId}:socketPath`, socketPath, 0); + dockerSocketCache.set(`${newt.newtId}:isAvailable`, available, 0); + } else { + logger.warn(`Newt ${newt.newtId} does not have Docker socket access`); + } + + return; +}; + +export const handleDockerContainersMessage: MessageHandler = async ( + context +) => { + const { message, newt } = context; + + logger.info("Handling Docker containers response"); + + if (!newt) { + logger.warn("Newt not found"); + return; + } + + logger.info(`Newt ID: ${newt.newtId}, Site ID: ${newt.siteId}`); + const { containers } = message.data; + + logger.info( + `Docker containers for Newt ${newt.newtId}: ${containers ? containers.length : 0}` + ); + + if (containers && containers.length > 0) { + dockerSocketCache.set(`${newt.newtId}:dockerContainers`, containers, 0); + } else { + logger.warn(`Newt ${newt.newtId} does not have Docker containers`); + } +}; diff --git a/server/routers/newt/index.ts b/server/routers/newt/index.ts index dcc49749..ad6d531c 100644 --- a/server/routers/newt/index.ts +++ b/server/routers/newt/index.ts @@ -1,3 +1,4 @@ export * from "./createNewt"; export * from "./getToken"; -export * from "./handleRegisterMessage"; \ No newline at end of file +export * from "./handleRegisterMessage"; +export * from "./handleSocketMessages"; \ No newline at end of file diff --git a/server/routers/site/index.ts b/server/routers/site/index.ts index 63505991..3edf67c1 100644 --- a/server/routers/site/index.ts +++ b/server/routers/site/index.ts @@ -3,5 +3,6 @@ export * from "./createSite"; export * from "./deleteSite"; export * from "./updateSite"; export * from "./listSites"; -export * from "./listSiteRoles" -export * from "./pickSiteDefaults"; \ No newline at end of file +export * from "./listSiteRoles"; +export * from "./pickSiteDefaults"; +export * from "./socketIntegration"; diff --git a/server/routers/site/socketIntegration.ts b/server/routers/site/socketIntegration.ts new file mode 100644 index 00000000..cc2aac6c --- /dev/null +++ b/server/routers/site/socketIntegration.ts @@ -0,0 +1,278 @@ +import { db } from "@server/db"; +import { newts, sites } from "@server/db/schemas"; +import logger from "@server/logger"; +import HttpCode from "@server/types/HttpCode"; +import response from "@server/lib/response"; +import { eq } from "drizzle-orm"; +import { NextFunction, Request, Response } from "express"; +import createHttpError from "http-errors"; +import { z } from "zod"; +import { fromError } from "zod-validation-error"; +import stoi from "@server/lib/stoi"; +import { sendToClient } from "../ws"; +import { + fetchContainers, + dockerSocketCache, + dockerSocket +} from "../newt/dockerSocket"; + +export interface ContainerNetwork { + networkId: string; + endpointId: string; + gateway?: string; + ipAddress?: string; + ipPrefixLen?: number; + macAddress?: string; + aliases?: string[]; + dnsNames?: string[]; +} + +export interface ContainerPort { + privatePort: number; + publicPort?: number; + type: "tcp" | "udp"; + ip?: string; +} + +export interface Container { + id: string; + name: string; + image: string; + state: "running" | "exited" | "paused" | "created"; + status: string; + ports?: ContainerPort[]; + labels: Record; + created: number; + networks: Record; +} + +const siteIdParamsSchema = z + .object({ + siteId: z.string().transform(stoi).pipe(z.number().int().positive()) + }) + .strict(); + +const DockerStatusSchema = z + .object({ + isAvailable: z.boolean(), + socketPath: z.string().optional() + }) + .strict(); + +function validateSiteIdParams(params: any) { + const parsedParams = siteIdParamsSchema.safeParse(params); + if (!parsedParams.success) { + throw createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error) + ); + } + return parsedParams.data; +} + +async function getSiteAndValidateNewt(siteId: number) { + const [site] = await db + .select() + .from(sites) + .where(eq(sites.siteId, siteId)) + .limit(1); + + if (!site) { + throw createHttpError(HttpCode.NOT_FOUND, "Site not found"); + } + + if (site.type !== "newt") { + throw createHttpError( + HttpCode.BAD_REQUEST, + "This endpoint is only for Newt sites" + ); + } + + return site; +} + +async function getNewtBySiteId(siteId: number) { + const [newt] = await db + .select() + .from(newts) + .where(eq(newts.siteId, siteId)) + .limit(1); + + if (!newt) { + throw createHttpError(HttpCode.NOT_FOUND, "Newt not found for site"); + } + + return newt; +} + +async function getSiteAndNewt(siteId: number) { + const site = await getSiteAndValidateNewt(siteId); + const newt = await getNewtBySiteId(siteId); + return { site, newt }; +} + +function asyncHandler( + operation: (siteId: number) => Promise, + successMessage: string +) { + return async ( + req: Request, + res: Response, + next: NextFunction + ): Promise => { + try { + const { siteId } = validateSiteIdParams(req.params); + const result = await operation(siteId); + + return response(res, { + data: result, + success: true, + error: false, + message: successMessage, + status: HttpCode.OK + }); + } catch (error) { + if (createHttpError.isHttpError(error)) { + return next(error); + } + logger.error(error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "An error occurred" + ) + ); + } + }; +} + +// Core business logic functions +async function triggerFetch(siteId: number) { + const { newt } = await getSiteAndNewt(siteId); + + logger.info( + `Triggering fetch containers for site ${siteId} with Newt ${newt.newtId}` + ); + fetchContainers(newt.newtId); + return { siteId, newtId: newt.newtId }; +} + +async function queryContainers(siteId: number) { + const { newt } = await getSiteAndNewt(siteId); + + const result = dockerSocketCache.get( + `${newt.newtId}:dockerContainers` + ) as Container[]; + if (!result) { + throw createHttpError( + HttpCode.TOO_EARLY, + "Nothing found yet. Perhaps the fetch is still in progress? Wait a bit and try again." + ); + } + + return result; +} + +async function isDockerAvailable(siteId: number): Promise { + const { newt } = await getSiteAndNewt(siteId); + + const key = `${newt.newtId}:isAvailable`; + const isAvailable = dockerSocketCache.get(key); + + return !!isAvailable; +} + +async function getDockerStatus( + siteId: number +): Promise> { + const { newt } = await getSiteAndNewt(siteId); + + const keys = ["isAvailable", "socketPath"]; + const mappedKeys = keys.map((x) => `${newt.newtId}:${x}`); + + const result = { + isAvailable: dockerSocketCache.get(mappedKeys[0]) as boolean, + socketPath: dockerSocketCache.get(mappedKeys[1]) as string | undefined + }; + + return result; +} + +async function checkSocket( + siteId: number +): Promise<{ siteId: number; newtId: string }> { + const { newt } = await getSiteAndNewt(siteId); + + logger.info( + `Checking Docker socket for site ${siteId} with Newt ${newt.newtId}` + ); + + // Trigger the Docker socket check + dockerSocket(newt.newtId); + return { siteId, newtId: newt.newtId }; +} + +// Export types +export type GetDockerStatusResponse = NonNullable< + Awaited> +>; + +export type ListContainersResponse = Awaited< + ReturnType +>; + +export type TriggerFetchResponse = Awaited>; + +// Route handlers +export const triggerFetchContainers = asyncHandler( + triggerFetch, + "Fetch containers triggered successfully" +); + +export const listContainers = asyncHandler( + queryContainers, + "Containers retrieved successfully" +); + +export const dockerOnline = asyncHandler(async (siteId: number) => { + const isAvailable = await isDockerAvailable(siteId); + return { isAvailable }; +}, "Docker availability checked successfully"); + +export const dockerStatus = asyncHandler( + getDockerStatus, + "Docker status retrieved successfully" +); + +export async function checkDockerSocket( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const { siteId } = validateSiteIdParams(req.params); + const result = await checkSocket(siteId); + + // Notify the Newt client about the Docker socket check + sendToClient(result.newtId, { + type: "newt/socket/check", + data: {} + }); + + return response(res, { + data: result, + success: true, + error: false, + message: "Docker socket checked successfully", + status: HttpCode.OK + }); + } catch (error) { + if (createHttpError.isHttpError(error)) { + return next(error); + } + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/site/updateSite.ts b/server/routers/site/updateSite.ts index 43cd848a..89106974 100644 --- a/server/routers/site/updateSite.ts +++ b/server/routers/site/updateSite.ts @@ -19,6 +19,7 @@ const updateSiteParamsSchema = z const updateSiteBodySchema = z .object({ name: z.string().min(1).max(255).optional(), + dockerSocketEnabled: z.boolean().optional(), // subdomain: z // .string() // .min(1) diff --git a/src/app/[orgId]/settings/resources/[resourceId]/ResourceInfoBox.tsx b/src/app/[orgId]/settings/resources/[resourceId]/ResourceInfoBox.tsx index 86916755..b59fe93e 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/ResourceInfoBox.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/ResourceInfoBox.tsx @@ -1,9 +1,8 @@ "use client"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; -import { ArrowRight, InfoIcon, ShieldCheck, ShieldOff } from "lucide-react"; +import { InfoIcon, ShieldCheck, ShieldOff } from "lucide-react"; import { useResourceContext } from "@app/hooks/useResourceContext"; -import { Separator } from "@app/components/ui/separator"; import CopyToClipboard from "@app/components/CopyToClipboard"; import { InfoSection, @@ -11,13 +10,17 @@ import { InfoSections, InfoSectionTitle } from "@app/components/InfoSection"; -import Link from "next/link"; -import { Switch } from "@app/components/ui/switch"; +import { createApiClient } from "@app/lib/api"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { useDockerSocket } from "@app/hooks/useDockerSocket"; type ResourceInfoBoxType = {}; export default function ResourceInfoBox({}: ResourceInfoBoxType) { - const { resource, authInfo } = useResourceContext(); + const { resource, authInfo, site } = useResourceContext(); + const api = createApiClient(useEnvContext()); + + const { isEnabled, isAvailable } = useDockerSocket(resource.siteId); let fullUrl = `${resource.ssl ? "https" : "http"}://${resource.fullDomain}`; @@ -28,7 +31,7 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) { Resource Information - + {resource.http ? ( <> @@ -67,6 +70,24 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) { {resource.siteName} + {isEnabled && ( + + Socket + + {isAvailable ? ( + +
+ Online +
+ ) : ( + +
+ Offline +
+ )} +
+
+ )} ) : ( <> @@ -92,7 +113,9 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) { Visibility - {resource.enabled ? "Enabled" : "Disabled"} + + {resource.enabled ? "Enabled" : "Disabled"} +
diff --git a/src/app/[orgId]/settings/resources/[resourceId]/layout.tsx b/src/app/[orgId]/settings/resources/[resourceId]/layout.tsx index edb21303..1021889e 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/layout.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/layout.tsx @@ -13,15 +13,7 @@ import { GetOrgResponse } from "@server/routers/org"; import OrgProvider from "@app/providers/OrgProvider"; import { cache } from "react"; import ResourceInfoBox from "./ResourceInfoBox"; -import { - Breadcrumb, - BreadcrumbItem, - BreadcrumbLink, - BreadcrumbList, - BreadcrumbPage, - BreadcrumbSeparator -} from "@app/components/ui/breadcrumb"; -import Link from "next/link"; +import { GetSiteResponse } from "@server/routers/site"; interface ResourceLayoutProps { children: React.ReactNode; @@ -35,6 +27,7 @@ export default async function ResourceLayout(props: ResourceLayoutProps) { let authInfo = null; let resource = null; + let site = null; try { const res = await internal.get>( `/resource/${params.resourceId}`, @@ -49,6 +42,19 @@ export default async function ResourceLayout(props: ResourceLayoutProps) { redirect(`/${params.orgId}/settings/resources`); } + // Fetch site info + if (resource.siteId) { + try { + const res = await internal.get>( + `/site/${resource.siteId}`, + await authCookieHeader() + ); + site = res.data.data; + } catch { + redirect(`/${params.orgId}/settings/resources`); + } + } + try { const res = await internal.get< AxiosResponse @@ -110,7 +116,11 @@ export default async function ResourceLayout(props: ResourceLayoutProps) { /> - +
diff --git a/src/app/[orgId]/settings/resources/[resourceId]/proxy/page.tsx b/src/app/[orgId]/settings/resources/[resourceId]/proxy/page.tsx index ddf255e0..714e61bb 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/proxy/page.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/proxy/page.tsx @@ -41,7 +41,6 @@ import { TableBody, TableCaption, TableCell, - TableContainer, TableHead, TableHeader, TableRow @@ -73,6 +72,7 @@ import { CollapsibleContent, CollapsibleTrigger } from "@app/components/ui/collapsible"; +import { ContainersSelector } from "@app/components/ContainersSelector"; const addTargetSchema = z.object({ ip: z.string().refine(isTargetValid), @@ -767,12 +767,32 @@ export default function ReverseProxyTargets(props: { control={addTargetForm.control} name="ip" render={({ field }) => ( - + IP / Hostname + {site && ( + { + addTargetForm.setValue( + "ip", + hostname + ); + if (port) { + addTargetForm.setValue( + "port", + port + ); + } + }} + /> + )} )} /> diff --git a/src/app/[orgId]/settings/sites/[niceId]/general/page.tsx b/src/app/[orgId]/settings/sites/[niceId]/general/page.tsx index f107d960..de44b7cd 100644 --- a/src/app/[orgId]/settings/sites/[niceId]/general/page.tsx +++ b/src/app/[orgId]/settings/sites/[niceId]/general/page.tsx @@ -31,9 +31,11 @@ import { formatAxiosError } from "@app/lib/api"; import { createApiClient } from "@app/lib/api"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { useState } from "react"; +import { SwitchInput } from "@app/components/SwitchInput"; const GeneralFormSchema = z.object({ - name: z.string().nonempty("Name is required") + name: z.string().nonempty("Name is required"), + dockerSocketEnabled: z.boolean().optional() }); type GeneralFormValues = z.infer; @@ -50,7 +52,8 @@ export default function GeneralPage() { const form = useForm({ resolver: zodResolver(GeneralFormSchema), defaultValues: { - name: site?.name + name: site?.name, + dockerSocketEnabled: site?.dockerSocketEnabled ?? false }, mode: "onChange" }); @@ -60,7 +63,8 @@ export default function GeneralPage() { await api .post(`/site/${site?.siteId}`, { - name: data.name + name: data.name, + dockerSocketEnabled: data.dockerSocketEnabled }) .catch((e) => { toast({ @@ -73,7 +77,10 @@ export default function GeneralPage() { }); }); - updateSite({ name: data.name }); + updateSite({ + name: data.name, + dockerSocketEnabled: data.dockerSocketEnabled + }); toast({ title: "Site updated", @@ -102,7 +109,7 @@ export default function GeneralPage() {
)} /> + ( + + + + + + + Enable Docker Socket discovery + for populating container + information, useful in resource + targets. + + + )} + /> diff --git a/src/components/ContainersSelector.tsx b/src/components/ContainersSelector.tsx new file mode 100644 index 00000000..6cd9f4d5 --- /dev/null +++ b/src/components/ContainersSelector.tsx @@ -0,0 +1,723 @@ +import { useEffect, useState, FC, useCallback, useMemo } from "react"; +import { + ColumnDef, + getCoreRowModel, + useReactTable, + flexRender, + getFilteredRowModel, + VisibilityState +} from "@tanstack/react-table"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger +} from "@/components/ui/dialog"; +import { + Drawer, + DrawerClose, + DrawerContent, + DrawerDescription, + DrawerFooter, + DrawerHeader, + DrawerTitle, + DrawerTrigger +} from "@/components/ui/drawer"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow +} from "@/components/ui/table"; +import { Badge } from "@/components/ui/badge"; +import { Input } from "@/components/ui/input"; +import { + Popover, + PopoverContent, + PopoverTrigger +} from "@/components/ui/popover"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuTrigger, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuCheckboxItem +} from "@/components/ui/dropdown-menu"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Search, RefreshCw, Filter, Columns } from "lucide-react"; +import { GetSiteResponse, Container } from "@server/routers/site"; +import { useDockerSocket } from "@app/hooks/useDockerSocket"; +import { useMediaQuery } from "@app/hooks/useMediaQuery"; + +// Type definitions based on the JSON structure + +interface ContainerSelectorProps { + site: GetSiteResponse; + onContainerSelect?: (hostname: string, port?: number) => void; +} + +export const ContainersSelector: FC = ({ + site, + onContainerSelect +}) => { + const [open, setOpen] = useState(false); + const isDesktop = useMediaQuery("(min-width: 768px)"); + const { isAvailable, containers, fetchContainers } = useDockerSocket( + site.siteId + ); + + useEffect(() => { + if (isAvailable) { + fetchContainers(); + } + }, [isAvailable]); + + useEffect(() => { + if (isAvailable && containers.length === 0) { + fetchContainers(); + } + }, [isAvailable, containers.length]); + + if (!site || !isAvailable) { + return null; + } + + const handleContainerSelect = (container: Container, port?: number) => { + // Extract hostname - prefer IP address from networks, fallback to container name + const hostname = getContainerHostname(container); + onContainerSelect?.(hostname, port); + setOpen(false); + }; + + if (isDesktop) { + return ( + + + + + + + + Containers in {site.name} + + + Select any container (w/ port) to use as target for + your resource + + +
+ fetchContainers()} + /> +
+
+
+ ); + } + + return ( + + + + + + + + Containers in {site.name} + + + Select any container to use as target for your resource + + +
+ +
+ + + + + +
+
+ ); +}; + +const DockerContainersTable: FC<{ + containers: Container[]; + onContainerSelect: (container: Container, port?: number) => void; + onRefresh: () => void; +}> = ({ containers, onContainerSelect, onRefresh }) => { + const [searchInput, setSearchInput] = useState(""); + const [globalFilter, setGlobalFilter] = useState(""); + const [hideContainersWithoutPorts, setHideContainersWithoutPorts] = + useState(true); + const [hideStoppedContainers, setHideStoppedContainers] = useState(false); + const [columnVisibility, setColumnVisibility] = useState({ + labels: false + }); + + useEffect(() => { + const timer = setTimeout(() => { + setGlobalFilter(searchInput); + }, 100); + + return () => clearTimeout(timer); + }, [searchInput]); + + const getExposedPorts = useCallback((container: Container): number[] => { + const ports: number[] = []; + + container.ports?.forEach((port) => { + if (port.privatePort) { + ports.push(port.privatePort); + } + }); + + return [...new Set(ports)]; // Remove duplicates + }, []); + + const globalFilterFunction = useCallback( + (row: any, columnId: string, value: string) => { + const container = row.original as Container; + const searchValue = value.toLowerCase(); + + // Search across all relevant fields + const searchableFields = [ + container.name, + container.image, + container.state, + container.status, + getContainerHostname(container), + ...Object.keys(container.networks), + ...Object.values(container.networks) + .map((n) => n.ipAddress) + .filter(Boolean), + ...getExposedPorts(container).map((p) => p.toString()), + ...Object.entries(container.labels).flat() + ]; + + return searchableFields.some((field) => + field?.toString().toLowerCase().includes(searchValue) + ); + }, + [getExposedPorts] + ); + + const columns: ColumnDef[] = [ + { + accessorKey: "name", + header: "Name", + cell: ({ row }) => ( +
{row.original.name}
+ ) + }, + { + accessorKey: "image", + header: "Image", + cell: ({ row }) => ( +
+ {row.original.image} +
+ ) + }, + { + accessorKey: "state", + header: "State", + cell: ({ row }) => ( + + {row.original.state} + + ) + }, + { + accessorKey: "networks", + header: "Networks", + cell: ({ row }) => { + const networks = Object.keys(row.original.networks); + return ( +
+ {networks.length > 0 + ? networks.map((n) => ( + + {n} + + )) + : "-"} +
+ ); + } + }, + { + accessorKey: "hostname", + header: "Hostname/IP", + enableHiding: false, + cell: ({ row }) => ( +
+ {getContainerHostname(row.original)} +
+ ) + }, + { + accessorKey: "labels", + header: "Labels", + cell: ({ row }) => { + const labels = row.original.labels || {}; + const labelEntries = Object.entries(labels); + + if (labelEntries.length === 0) { + return -; + } + + return ( + + + + + + +
+

+ Container Labels +

+
+ {labelEntries.map(([key, value]) => ( +
+
+ {key} +
+
+ {value || ""} +
+
+ ))} +
+
+
+
+
+ ); + } + }, + { + accessorKey: "ports", + header: "Ports", + enableHiding: false, + cell: ({ row }) => { + const ports = getExposedPorts(row.original); + return ( +
+ {ports.slice(0, 2).map((port) => ( + + ))} + {ports.length > 2 && ( + + + + + + {ports.slice(2).map((port) => ( + + ))} + + + )} +
+ ); + } + }, + { + id: "actions", + header: "Actions", + cell: ({ row }) => ( + + ) + } + ]; + + const initialFilters = useMemo(() => { + let filtered = containers; + + // Filter by port visibility + if (hideContainersWithoutPorts) { + filtered = filtered.filter((container) => { + const ports = getExposedPorts(container); + return ports.length > 0; // Show only containers WITH ports + }); + } + + // Filter by container state + if (hideStoppedContainers) { + filtered = filtered.filter((container) => { + return container.state === "running"; + }); + } + + return filtered; + }, [ + containers, + hideContainersWithoutPorts, + hideStoppedContainers, + getExposedPorts + ]); + + const table = useReactTable({ + data: initialFilters, + columns, + getCoreRowModel: getCoreRowModel(), + getFilteredRowModel: getFilteredRowModel(), + globalFilterFn: globalFilterFunction, + state: { + globalFilter, + columnVisibility + }, + onGlobalFilterChange: setGlobalFilter, + onColumnVisibilityChange: setColumnVisibility + }); + + if (initialFilters.length === 0) { + return ( +
+
+
+ {(hideContainersWithoutPorts || + hideStoppedContainers) && + containers.length > 0 ? ( + <> +

+ No containers found matching the current + filters. +

+
+ {hideContainersWithoutPorts && ( + + )} + {hideStoppedContainers && ( + + )} +
+ + ) : ( +

+ No containers found. Make sure Docker containers + are running. +

+ )} +
+
+
+ ); + } + + return ( +
+
+
+
+ + + setSearchInput(event.target.value) + } + className="pl-8" + /> + {searchInput && + table.getFilteredRowModel().rows.length > 0 && ( +
+ {table.getFilteredRowModel().rows.length}{" "} + result + {table.getFilteredRowModel().rows.length !== + 1 + ? "s" + : ""} +
+ )} +
+
+ + + + + + + Filter Options + + + + Ports + + + Stopped + + {(hideContainersWithoutPorts || + hideStoppedContainers) && ( + <> + +
+ +
+ + )} +
+
+ + + + + + + + Toggle Columns + + + {table + .getAllColumns() + .filter((column) => column.getCanHide()) + .map((column) => { + return ( + + column.toggleVisibility( + !!value + ) + } + > + {column.id === "hostname" + ? "Hostname/IP" + : column.id} + + ); + })} + + +
+ +
+
+
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef + .header, + header.getContext() + )} + + ))} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} + + ))} + + )) + ) : ( + + + {searchInput && !globalFilter ? ( +
+
+ Searching... +
+ ) : ( + `No containers found matching "${globalFilter}".` + )} + + + )} + +
+
+
+ ); +}; + +function getContainerHostname(container: Container): string { + // First, try to get IP from networks + const networks = Object.values(container.networks); + for (const network of networks) { + if (network.ipAddress) { + return network.ipAddress; + } + } + + // Fallback to container name (works in Docker networks) + return container.name; +} diff --git a/src/components/ui/scroll-area.tsx b/src/components/ui/scroll-area.tsx new file mode 100644 index 00000000..704be637 --- /dev/null +++ b/src/components/ui/scroll-area.tsx @@ -0,0 +1,58 @@ +"use client" + +import * as React from "react" +import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area" +import { cn } from "@app/lib/cn" + + +function ScrollArea({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + {children} + + + + + ) +} + +function ScrollBar({ + className, + orientation = "vertical", + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +export { ScrollArea, ScrollBar } diff --git a/src/components/ui/table.tsx b/src/components/ui/table.tsx index 7bfec308..069e68b8 100644 --- a/src/components/ui/table.tsx +++ b/src/components/ui/table.tsx @@ -1,121 +1,138 @@ -import * as React from "react" +import * as React from "react"; -import { cn } from "@app/lib/cn" +import { cn } from "@app/lib/cn"; export function TableContainer({ children }: { children: React.ReactNode }) { - return
{children}
+ return
{children}
; } const Table = React.forwardRef< - HTMLTableElement, - React.HTMLAttributes ->(({ className, ...props }, ref) => ( -
- - -)) -Table.displayName = "Table" + HTMLTableElement, + React.HTMLAttributes & { sticky?: boolean } +>(({ className, sticky, ...props }, ref) => ( +
+
+ +)); +Table.displayName = "Table"; const TableHeader = React.forwardRef< - HTMLTableSectionElement, - React.HTMLAttributes ->(({ className, ...props }, ref) => ( - -)) -TableHeader.displayName = "TableHeader" + HTMLTableSectionElement, + React.HTMLAttributes & { sticky?: boolean } +>(({ className, sticky, ...props }, ref) => ( + +)); +TableHeader.displayName = "TableHeader"; const TableBody = React.forwardRef< - HTMLTableSectionElement, - React.HTMLAttributes + HTMLTableSectionElement, + React.HTMLAttributes >(({ className, ...props }, ref) => ( - -)) -TableBody.displayName = "TableBody" + +)); +TableBody.displayName = "TableBody"; const TableFooter = React.forwardRef< - HTMLTableSectionElement, - React.HTMLAttributes + HTMLTableSectionElement, + React.HTMLAttributes >(({ className, ...props }, ref) => ( - tr]:last:border-b-0", - className - )} - {...props} - /> -)) -TableFooter.displayName = "TableFooter" + tr]:last:border-b-0", + className + )} + {...props} + /> +)); +TableFooter.displayName = "TableFooter"; const TableRow = React.forwardRef< - HTMLTableRowElement, - React.HTMLAttributes + HTMLTableRowElement, + React.HTMLAttributes >(({ className, ...props }, ref) => ( - -)) -TableRow.displayName = "TableRow" + +)); +TableRow.displayName = "TableRow"; const TableHead = React.forwardRef< - HTMLTableCellElement, - React.ThHTMLAttributes + HTMLTableCellElement, + React.ThHTMLAttributes >(({ className, ...props }, ref) => ( -
-)) -TableHead.displayName = "TableHead" + +)); +TableHead.displayName = "TableHead"; const TableCell = React.forwardRef< - HTMLTableCellElement, - React.TdHTMLAttributes + HTMLTableCellElement, + React.TdHTMLAttributes >(({ className, ...props }, ref) => ( - -)) -TableCell.displayName = "TableCell" + +)); +TableCell.displayName = "TableCell"; const TableCaption = React.forwardRef< - HTMLTableCaptionElement, - React.HTMLAttributes + HTMLTableCaptionElement, + React.HTMLAttributes >(({ className, ...props }, ref) => ( -
-)) -TableCaption.displayName = "TableCaption" + +)); +TableCaption.displayName = "TableCaption"; export { - Table, - TableHeader, - TableBody, - TableFooter, - TableHead, - TableRow, - TableCell, - TableCaption, -} + Table, + TableHeader, + TableBody, + TableFooter, + TableHead, + TableRow, + TableCell, + TableCaption +}; diff --git a/src/contexts/resourceContext.ts b/src/contexts/resourceContext.ts index bb5501a6..d24a948b 100644 --- a/src/contexts/resourceContext.ts +++ b/src/contexts/resourceContext.ts @@ -1,9 +1,11 @@ import { GetResourceAuthInfoResponse } from "@server/routers/resource"; import { GetResourceResponse } from "@server/routers/resource/getResource"; +import { GetSiteResponse } from "@server/routers/site"; import { createContext } from "react"; interface ResourceContextType { resource: GetResourceResponse; + site: GetSiteResponse | null; authInfo: GetResourceAuthInfoResponse; updateResource: (updatedResource: Partial) => void; updateAuthInfo: ( diff --git a/src/hooks/useDockerSocket.ts b/src/hooks/useDockerSocket.ts new file mode 100644 index 00000000..598ff88b --- /dev/null +++ b/src/hooks/useDockerSocket.ts @@ -0,0 +1,207 @@ +import { createApiClient, formatAxiosError } from "@app/lib/api"; +import { useCallback, useEffect, useState } from "react"; +import { useEnvContext } from "./useEnvContext"; +import { + Container, + GetDockerStatusResponse, + GetSiteResponse, + ListContainersResponse, + TriggerFetchResponse +} from "@server/routers/site"; +import { AxiosResponse } from "axios"; +import { toast } from "./useToast"; + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +export function useDockerSocket(siteId: number) { + if (!siteId) { + throw new Error("Site ID is required to use Docker Socket"); + } + + const [site, setSite] = useState(); + const [dockerSocket, setDockerSocket] = useState(); + const [containers, setContainers] = useState([]); + + const api = createApiClient(useEnvContext()); + + const { dockerSocketEnabled: isEnabled = false } = site || {}; + const { isAvailable = false, socketPath } = dockerSocket || {}; + + const fetchSite = useCallback(async () => { + try { + const res = await api.get>( + `/site/${siteId}` + ); + + if (res.status === 200) { + setSite(res.data.data); + } + } catch (err) { + console.error(err); + toast({ + variant: "destructive", + title: "Failed to fetch resource", + description: formatAxiosError( + err, + "An error occurred while fetching resource" + ) + }); + } + }, [api, siteId]); + + const checkDockerSocket = useCallback(async () => { + if (!isEnabled) { + console.warn("Docker socket is not enabled for this site."); + return; + } + try { + const res = await api.post(`/site/${siteId}/docker/check`); + console.log("Docker socket check response:", res); + } catch (error) { + console.error("Failed to check Docker socket:", error); + } + }, [api, siteId, isEnabled]); + + const getDockerSocketStatus = useCallback(async () => { + if (!isEnabled) { + console.warn("Docker socket is not enabled for this site."); + return; + } + + try { + const res = await api.get>( + `/site/${siteId}/docker/status` + ); + + if (res.status === 200) { + setDockerSocket(res.data.data); + } else { + console.error("Failed to get Docker status:", res); + toast({ + variant: "destructive", + title: "Failed to get Docker status", + description: + "An error occurred while fetching Docker status." + }); + } + } catch (error) { + console.error("Failed to get Docker status:", error); + toast({ + variant: "destructive", + title: "Failed to get Docker status", + description: "An error occurred while fetching Docker status." + }); + } + }, [api, siteId, isEnabled]); + + const getContainers = useCallback( + async (maxRetries: number = 3) => { + if (!isEnabled || !isAvailable) { + console.warn("Docker socket is not enabled or available."); + return; + } + + const fetchContainerList = async () => { + if (!isEnabled || !isAvailable) { + return; + } + + let attempt = 0; + while (attempt < maxRetries) { + try { + const res = await api.get< + AxiosResponse + >(`/site/${siteId}/docker/containers`); + setContainers(res.data.data); + return; + } catch (error: any) { + attempt++; + + // Check if the error is a 425 (Too Early) status + if (error?.response?.status === 425) { + if (attempt < maxRetries) { + // Ask the newt server to check containers + await getContainers(); + // Exponential backoff: 2s, 4s, 8s... + const retryDelay = Math.min( + 2000 * Math.pow(2, attempt - 1), + 10000 + ); + console.log( + `Containers not ready yet (attempt ${attempt}/${maxRetries}). Retrying in ${retryDelay}ms...` + ); + await sleep(retryDelay); + continue; + } else { + console.warn( + "Max retry attempts reached. Containers may still be loading." + ); + toast({ + variant: "destructive", + title: "Containers not ready", + description: + "Containers are still loading. Please try again in a moment." + }); + } + } else { + console.error( + "Failed to fetch Docker containers:", + error + ); + toast({ + variant: "destructive", + title: "Failed to fetch containers", + description: formatAxiosError( + error, + "An error occurred while fetching containers" + ) + }); + } + break; + } + } + }; + + try { + const res = await api.post>( + `/site/${siteId}/docker/trigger` + ); + await sleep(1000); // Wait a second before fetching containers + // TODO: identify a way to poll the server for latest container list periodically? + await fetchContainerList(); + return res.data.data; + } catch (error) { + console.error("Failed to trigger Docker containers:", error); + } + }, + [api, siteId, isEnabled, isAvailable] + ); + + useEffect(() => { + fetchSite(); + }, [fetchSite]); + + // 2. Docker socket status monitoring + useEffect(() => { + if (!isEnabled || isAvailable) { + return; + } + + checkDockerSocket(); + const timeout = setTimeout(() => { + getDockerSocketStatus(); + }, 3000); + + return () => clearTimeout(timeout); + }, [isEnabled, isAvailable, checkDockerSocket, getDockerSocketStatus]); + + return { + isEnabled, + isAvailable: isEnabled && isAvailable, + socketPath, + containers, + check: checkDockerSocket, + status: getDockerSocketStatus, + fetchContainers: getContainers + }; +} diff --git a/src/providers/ResourceProvider.tsx b/src/providers/ResourceProvider.tsx index cd6229a4..37e30580 100644 --- a/src/providers/ResourceProvider.tsx +++ b/src/providers/ResourceProvider.tsx @@ -3,18 +3,21 @@ import ResourceContext from "@app/contexts/resourceContext"; import { GetResourceAuthInfoResponse } from "@server/routers/resource"; import { GetResourceResponse } from "@server/routers/resource/getResource"; +import { GetSiteResponse } from "@server/routers/site"; import { useState } from "react"; interface ResourceProviderProps { children: React.ReactNode; resource: GetResourceResponse; + site: GetSiteResponse | null; authInfo: GetResourceAuthInfoResponse; } export function ResourceProvider({ children, + site, resource: serverResource, - authInfo: serverAuthInfo, + authInfo: serverAuthInfo }: ResourceProviderProps) { const [resource, setResource] = useState(serverResource); @@ -34,7 +37,7 @@ export function ResourceProvider({ return { ...prev, - ...updatedResource, + ...updatedResource }; }); }; @@ -53,14 +56,14 @@ export function ResourceProvider({ return { ...prev, - ...updatedAuthInfo, + ...updatedAuthInfo }; }); }; return ( {children}