docker socket

This commit is contained in:
Rajesh V 2025-05-29 22:34:05 +05:30
parent 23b5dcfbed
commit 948eb7f6d0
21 changed files with 1808 additions and 128 deletions

184
package-lock.json generated
View file

@ -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",

View file

@ -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",

View file

@ -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", {

View file

@ -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,

View file

@ -1,6 +1,12 @@
import { handleRegisterMessage } from "./newt";
import {
handleRegisterMessage,
handleDockerStatusMessage,
handleDockerContainersMessage
} from "./newt";
import { MessageHandler } from "./ws";
export const messageHandlers: Record<string, MessageHandler> = {
"newt/wg/register": handleRegisterMessage,
"newt/socket/status": handleDockerStatusMessage,
"newt/socket/containers": handleDockerContainersMessage
};

View file

@ -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);
}

View file

@ -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`);
}
};

View file

@ -1,3 +1,4 @@
export * from "./createNewt";
export * from "./getToken";
export * from "./handleRegisterMessage";
export * from "./handleSocketMessages";

View file

@ -3,5 +3,6 @@ export * from "./createSite";
export * from "./deleteSite";
export * from "./updateSite";
export * from "./listSites";
export * from "./listSiteRoles"
export * from "./listSiteRoles";
export * from "./pickSiteDefaults";
export * from "./socketIntegration";

View file

@ -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<string, string>;
created: number;
networks: Record<string, ContainerNetwork>;
}
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<any>,
successMessage: string
) {
return async (
req: Request,
res: Response,
next: NextFunction
): Promise<any> => {
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<boolean> {
const { newt } = await getSiteAndNewt(siteId);
const key = `${newt.newtId}:isAvailable`;
const isAvailable = dockerSocketCache.get(key);
return !!isAvailable;
}
async function getDockerStatus(
siteId: number
): Promise<z.infer<typeof DockerStatusSchema>> {
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<ReturnType<typeof getDockerStatus>>
>;
export type ListContainersResponse = Awaited<
ReturnType<typeof queryContainers>
>;
export type TriggerFetchResponse = Awaited<ReturnType<typeof triggerFetch>>;
// 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<any> {
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")
);
}
}

View file

@ -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)

View file

@ -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
</AlertTitle>
<AlertDescription className="mt-4">
<InfoSections cols={4}>
<InfoSections cols={isEnabled ? 5 : 4}>
{resource.http ? (
<>
<InfoSection>
@ -67,6 +70,24 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
{resource.siteName}
</InfoSectionContent>
</InfoSection>
{isEnabled && (
<InfoSection>
<InfoSectionTitle>Socket</InfoSectionTitle>
<InfoSectionContent>
{isAvailable ? (
<span className="text-green-500 flex items-center space-x-2">
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
<span>Online</span>
</span>
) : (
<span className="text-neutral-500 flex items-center space-x-2">
<div className="w-2 h-2 bg-gray-500 rounded-full"></div>
<span>Offline</span>
</span>
)}
</InfoSectionContent>
</InfoSection>
)}
</>
) : (
<>
@ -92,7 +113,9 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
<InfoSection>
<InfoSectionTitle>Visibility</InfoSectionTitle>
<InfoSectionContent>
<span>{resource.enabled ? "Enabled" : "Disabled"}</span>
<span>
{resource.enabled ? "Enabled" : "Disabled"}
</span>
</InfoSectionContent>
</InfoSection>
</InfoSections>

View file

@ -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<AxiosResponse<GetResourceResponse>>(
`/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<AxiosResponse<GetSiteResponse>>(
`/site/${resource.siteId}`,
await authCookieHeader()
);
site = res.data.data;
} catch {
redirect(`/${params.orgId}/settings/resources`);
}
}
try {
const res = await internal.get<
AxiosResponse<GetResourceAuthInfoResponse>
@ -110,7 +116,11 @@ export default async function ResourceLayout(props: ResourceLayoutProps) {
/>
<OrgProvider org={org}>
<ResourceProvider resource={resource} authInfo={authInfo}>
<ResourceProvider
site={site}
resource={resource}
authInfo={authInfo}
>
<div className="space-y-6">
<ResourceInfoBox />
<HorizontalTabs items={navItems}>

View file

@ -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 }) => (
<FormItem>
<FormItem className="relative">
<FormLabel>IP / Hostname</FormLabel>
<FormControl>
<Input id="ip" {...field} />
</FormControl>
<FormMessage />
{site && (
<ContainersSelector
site={site}
onContainerSelect={(
hostname,
port
) => {
addTargetForm.setValue(
"ip",
hostname
);
if (port) {
addTargetForm.setValue(
"port",
port
);
}
}}
/>
)}
</FormItem>
)}
/>

View file

@ -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<typeof GeneralFormSchema>;
@ -50,7 +52,8 @@ export default function GeneralPage() {
const form = useForm<GeneralFormValues>({
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() {
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4"
className="space-y-6"
id="general-settings-form"
>
<FormField
@ -122,6 +129,31 @@ export default function GeneralPage() {
</FormItem>
)}
/>
<FormField
control={form.control}
name="dockerSocketEnabled"
render={({ field }) => (
<FormItem>
<FormControl>
<SwitchInput
id="docker-socket-enabled"
label="Enable Docker Socket"
defaultChecked={field.value}
onCheckedChange={
field.onChange
}
/>
</FormControl>
<FormMessage />
<FormDescription>
Enable Docker Socket discovery
for populating container
information, useful in resource
targets.
</FormDescription>
</FormItem>
)}
/>
</form>
</Form>
</SettingsSectionForm>

View file

@ -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<ContainerSelectorProps> = ({
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 (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button
type="button"
variant="squareOutline"
size="icon"
className="absolute top-[35%] right-0"
>
<span className="scale-125">🐋</span>
</Button>
</DialogTrigger>
<DialogContent className="max-w-[75vw] max-h-[75vh] flex flex-col">
<DialogHeader>
<DialogTitle>
Containers in <b>{site.name}</b>
</DialogTitle>
<DialogDescription>
Select any container (w/ port) to use as target for
your resource
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-hidden min-h-0">
<DockerContainersTable
containers={containers}
onContainerSelect={handleContainerSelect}
onRefresh={() => fetchContainers()}
/>
</div>
</DialogContent>
</Dialog>
);
}
return (
<Drawer open={open} onOpenChange={setOpen}>
<DrawerTrigger asChild>
<Button
type="button"
variant="squareOutline"
size="icon"
className="absolute top-[35%] right-0"
>
<span className="scale-125">🐋</span>
</Button>
</DrawerTrigger>
<DrawerContent>
<DrawerHeader className="text-left">
<DrawerTitle>
Containers in <b>{site.name}</b>
</DrawerTitle>
<DrawerDescription>
Select any container to use as target for your resource
</DrawerDescription>
</DrawerHeader>
<div className="px-4">
<DockerContainersTable
containers={containers}
onContainerSelect={handleContainerSelect}
onRefresh={fetchContainers}
/>
</div>
<DrawerFooter className="pt-2">
<DrawerClose asChild>
<Button variant="outline">Cancel</Button>
</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>
);
};
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<VisibilityState>({
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<Container>[] = [
{
accessorKey: "name",
header: "Name",
cell: ({ row }) => (
<div className="font-medium">{row.original.name}</div>
)
},
{
accessorKey: "image",
header: "Image",
cell: ({ row }) => (
<div className="text-sm text-muted-foreground">
{row.original.image}
</div>
)
},
{
accessorKey: "state",
header: "State",
cell: ({ row }) => (
<Badge
variant={
row.original.state === "running"
? "default"
: "secondary"
}
>
{row.original.state}
</Badge>
)
},
{
accessorKey: "networks",
header: "Networks",
cell: ({ row }) => {
const networks = Object.keys(row.original.networks);
return (
<div className="text-sm text-muted-foreground">
{networks.length > 0
? networks.map((n) => (
<Badge key={n} variant="outlinePrimary">
{n}
</Badge>
))
: "-"}
</div>
);
}
},
{
accessorKey: "hostname",
header: "Hostname/IP",
enableHiding: false,
cell: ({ row }) => (
<div className="text-sm font-mono">
{getContainerHostname(row.original)}
</div>
)
},
{
accessorKey: "labels",
header: "Labels",
cell: ({ row }) => {
const labels = row.original.labels || {};
const labelEntries = Object.entries(labels);
if (labelEntries.length === 0) {
return <span className="text-muted-foreground">-</span>;
}
return (
<Popover modal>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs hover:bg-muted"
>
{labelEntries.length} label
{labelEntries.length !== 1 ? "s" : ""}
</Button>
</PopoverTrigger>
<PopoverContent side="top" align="start">
<ScrollArea className="w-64 h-64">
<div className="space-y-2">
<h4 className="font-medium text-sm">
Container Labels
</h4>
<div className="space-y-1">
{labelEntries.map(([key, value]) => (
<div key={key} className="text-xs">
<div className="font-mono font-medium text-foreground">
{key}
</div>
<div className="font-mono text-muted-foreground pl-2 break-all">
{value || "<empty>"}
</div>
</div>
))}
</div>
</div>
</ScrollArea>
</PopoverContent>
</Popover>
);
}
},
{
accessorKey: "ports",
header: "Ports",
enableHiding: false,
cell: ({ row }) => {
const ports = getExposedPorts(row.original);
return (
<div className="flex flex-wrap items-center gap-1">
{ports.slice(0, 2).map((port) => (
<Button
key={port}
variant="outline"
size="sm"
className="h-6 px-2 text-xs"
onClick={() =>
onContainerSelect(row.original, port)
}
>
{port}
</Button>
))}
{ports.length > 2 && (
<Popover>
<PopoverTrigger asChild>
<Button variant="link" size="sm">
+{ports.length - 2} more
</Button>
</PopoverTrigger>
<PopoverContent
side="top"
className="w-auto"
align="end"
>
{ports.slice(2).map((port) => (
<Button
key={port}
variant="outline"
size="sm"
className="h-6 px-2 text-xs"
onClick={() =>
onContainerSelect(
row.original,
port
)
}
>
{port}
</Button>
))}
</PopoverContent>
</Popover>
)}
</div>
);
}
},
{
id: "actions",
header: "Actions",
cell: ({ row }) => (
<Button
variant="default"
size="sm"
onClick={() => onContainerSelect(row.original)}
disabled={row.original.state !== "running"}
>
Select
</Button>
)
}
];
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 (
<div className="border rounded-md max-h-[500px] overflow-hidden flex flex-col">
<div className="flex-1 flex items-center justify-center py-8">
<div className="text-center text-muted-foreground space-y-3">
{(hideContainersWithoutPorts ||
hideStoppedContainers) &&
containers.length > 0 ? (
<>
<p>
No containers found matching the current
filters.
</p>
<div className="space-x-2">
{hideContainersWithoutPorts && (
<Button
variant="outline"
size="sm"
onClick={() =>
setHideContainersWithoutPorts(
false
)
}
>
Show containers without ports
</Button>
)}
{hideStoppedContainers && (
<Button
variant="outline"
size="sm"
onClick={() =>
setHideStoppedContainers(false)
}
>
Show stopped containers
</Button>
)}
</div>
</>
) : (
<p>
No containers found. Make sure Docker containers
are running.
</p>
)}
</div>
</div>
</div>
);
}
return (
<div className="border rounded-md max-h-[500px] overflow-hidden flex flex-col">
<div className="p-3 border-b bg-background space-y-3">
<div className="flex items-center gap-2">
<div className="relative flex-1">
<Search className="absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder={`Search across ${initialFilters.length} containers...`}
value={searchInput}
onChange={(event) =>
setSearchInput(event.target.value)
}
className="pl-8"
/>
{searchInput &&
table.getFilteredRowModel().rows.length > 0 && (
<div className="absolute right-2 top-1/2 -translate-y-1/2 text-sm text-muted-foreground">
{table.getFilteredRowModel().rows.length}{" "}
result
{table.getFilteredRowModel().rows.length !==
1
? "s"
: ""}
</div>
)}
</div>
<div className="flex items-center gap-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className="gap-2"
>
<Filter className="h-4 w-4" />
Filters
{(hideContainersWithoutPorts ||
hideStoppedContainers) && (
<span className="bg-primary text-primary-foreground rounded-full w-5 h-5 text-xs flex items-center justify-center">
{Number(
hideContainersWithoutPorts
) + Number(hideStoppedContainers)}
</span>
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-64">
<DropdownMenuLabel>
Filter Options
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuCheckboxItem
checked={hideContainersWithoutPorts}
onCheckedChange={
setHideContainersWithoutPorts
}
>
Ports
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
checked={hideStoppedContainers}
onCheckedChange={setHideStoppedContainers}
>
Stopped
</DropdownMenuCheckboxItem>
{(hideContainersWithoutPorts ||
hideStoppedContainers) && (
<>
<DropdownMenuSeparator />
<div className="p-2">
<Button
variant="ghost"
size="sm"
onClick={() => {
setHideContainersWithoutPorts(
false
);
setHideStoppedContainers(
false
);
}}
className="w-full text-xs"
>
Clear all filters
</Button>
</div>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className="gap-2"
>
<Columns className="h-4 w-4" />
Columns
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-48">
<DropdownMenuLabel>
Toggle Columns
</DropdownMenuLabel>
<DropdownMenuSeparator />
{table
.getAllColumns()
.filter((column) => column.getCanHide())
.map((column) => {
return (
<DropdownMenuCheckboxItem
key={column.id}
className="capitalize"
checked={column.getIsVisible()}
onCheckedChange={(value) =>
column.toggleVisibility(
!!value
)
}
>
{column.id === "hostname"
? "Hostname/IP"
: column.id}
</DropdownMenuCheckboxItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
</div>
<Button
variant="outline"
size="icon"
onClick={onRefresh}
title="Refresh containers list"
>
<RefreshCw className="h-4 w-4" />
</Button>
</div>
</div>
<div className="overflow-auto relative flex-1">
<Table sticky>
<TableHeader sticky className="bg-background border-b">
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead
key={header.id}
className="bg-background"
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef
.header,
header.getContext()
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
className={
row.original.state !== "running"
? "opacity-50"
: ""
}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center"
>
{searchInput && !globalFilter ? (
<div className="flex items-center justify-center gap-2 text-muted-foreground">
<div className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
Searching...
</div>
) : (
`No containers found matching "${globalFilter}".`
)}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</div>
);
};
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;
}

View file

@ -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<typeof ScrollAreaPrimitive.Root>) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn("relative", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
)
}
function ScrollBar({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
return (
<ScrollAreaPrimitive.ScrollAreaScrollbar
data-slot="scroll-area-scrollbar"
orientation={orientation}
className={cn(
"flex touch-none p-px transition-colors select-none",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb
data-slot="scroll-area-thumb"
className="bg-border relative flex-1 rounded-full"
/>
</ScrollAreaPrimitive.ScrollAreaScrollbar>
)
}
export { ScrollArea, ScrollBar }

View file

@ -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 <div className="border rounded-lg bg-card">{children}</div>
return <div className="border rounded-lg bg-card">{children}</div>;
}
const Table = React.forwardRef<
HTMLTableElement,
React.HTMLAttributes<HTMLTableElement>
>(({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table
ref={ref}
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
))
Table.displayName = "Table"
HTMLTableElement,
React.HTMLAttributes<HTMLTableElement> & { sticky?: boolean }
>(({ className, sticky, ...props }, ref) => (
<div
className={cn("relative w-full", {
"overflow-auto": !sticky
})}
>
<table
ref={ref}
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
));
Table.displayName = "Table";
const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
))
TableHeader.displayName = "TableHeader"
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement> & { sticky?: boolean }
>(({ className, sticky, ...props }, ref) => (
<thead
ref={ref}
className={cn(
"[&_tr]:border-b",
{
"sticky top-0": sticky
},
className
)}
{...props}
/>
));
TableHeader.displayName = "TableHeader";
const TableBody = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tbody
ref={ref}
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
))
TableBody.displayName = "TableBody"
<tbody
ref={ref}
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
));
TableBody.displayName = "TableBody";
const TableFooter = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tfoot
ref={ref}
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
))
TableFooter.displayName = "TableFooter"
<tfoot
ref={ref}
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
));
TableFooter.displayName = "TableFooter";
const TableRow = React.forwardRef<
HTMLTableRowElement,
React.HTMLAttributes<HTMLTableRowElement>
HTMLTableRowElement,
React.HTMLAttributes<HTMLTableRowElement>
>(({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
"border-b transition-colors data-[state=selected]:bg-muted",
className
)}
{...props}
/>
))
TableRow.displayName = "TableRow"
<tr
ref={ref}
className={cn(
"border-b transition-colors data-[state=selected]:bg-muted",
className
)}
{...props}
/>
));
TableRow.displayName = "TableRow";
const TableHead = React.forwardRef<
HTMLTableCellElement,
React.ThHTMLAttributes<HTMLTableCellElement>
HTMLTableCellElement,
React.ThHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
"h-10 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
))
TableHead.displayName = "TableHead"
<th
ref={ref}
className={cn(
"h-10 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
));
TableHead.displayName = "TableHead";
const TableCell = React.forwardRef<
HTMLTableCellElement,
React.TdHTMLAttributes<HTMLTableCellElement>
HTMLTableCellElement,
React.TdHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn("p-3 align-middle [&:has([role=checkbox])]:pr-0", className)}
{...props}
/>
))
TableCell.displayName = "TableCell"
<td
ref={ref}
className={cn(
"p-3 align-middle [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
));
TableCell.displayName = "TableCell";
const TableCaption = React.forwardRef<
HTMLTableCaptionElement,
React.HTMLAttributes<HTMLTableCaptionElement>
HTMLTableCaptionElement,
React.HTMLAttributes<HTMLTableCaptionElement>
>(({ className, ...props }, ref) => (
<caption
ref={ref}
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
))
TableCaption.displayName = "TableCaption"
<caption
ref={ref}
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
));
TableCaption.displayName = "TableCaption";
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption
};

View file

@ -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<GetResourceResponse>) => void;
updateAuthInfo: (

View file

@ -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<GetSiteResponse>();
const [dockerSocket, setDockerSocket] = useState<GetDockerStatusResponse>();
const [containers, setContainers] = useState<Container[]>([]);
const api = createApiClient(useEnvContext());
const { dockerSocketEnabled: isEnabled = false } = site || {};
const { isAvailable = false, socketPath } = dockerSocket || {};
const fetchSite = useCallback(async () => {
try {
const res = await api.get<AxiosResponse<GetSiteResponse>>(
`/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<AxiosResponse<GetDockerStatusResponse>>(
`/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<ListContainersResponse>
>(`/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<AxiosResponse<TriggerFetchResponse>>(
`/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
};
}

View file

@ -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<GetResourceResponse>(serverResource);
@ -34,7 +37,7 @@ export function ResourceProvider({
return {
...prev,
...updatedResource,
...updatedResource
};
});
};
@ -53,14 +56,14 @@ export function ResourceProvider({
return {
...prev,
...updatedAuthInfo,
...updatedAuthInfo
};
});
};
return (
<ResourceContext.Provider
value={{ resource, updateResource, authInfo, updateAuthInfo }}
value={{ resource, updateResource, site, authInfo, updateAuthInfo }}
>
{children}
</ResourceContext.Provider>