From b41c1f5b270002e9c787ef617d76fc9740b831e4 Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 29 Jun 2026 21:10:49 -0400 Subject: [PATCH] Add restart button --- messages/en-US.json | 10 ++ server/routers/external.ts | 8 ++ server/routers/site/index.ts | 1 + server/routers/site/restartSite.ts | 116 ++++++++++++++++++ .../settings/sites/[niceId]/general/page.tsx | 64 ++++++++++ 5 files changed, 199 insertions(+) create mode 100644 server/routers/site/restartSite.ts diff --git a/messages/en-US.json b/messages/en-US.json index c7964f8be..388e34015 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -123,6 +123,16 @@ "siteUpdated": "Site updated", "siteUpdatedDescription": "The site has been updated.", "siteGeneralDescription": "Configure the general settings for this site", + "siteRestartTitle": "Restart Site", + "siteRestartDescription": "Restart the WireGuard tunnel for this site. This will briefly interrupt connectivity.", + "siteRestartBody": "Use this if the site tunnel is not functioning correctly and you want to force a reconnect without restarting the host.", + "siteRestartButton": "Restart Site", + "siteRestartDialogMessage": "Are you sure you want to restart the WireGuard tunnel for {name}? The site will briefly lose connectivity.", + "siteRestartWarning": "The site will briefly disconnect while the tunnel restarts.", + "siteRestarted": "Site restarted", + "siteRestartedDescription": "The WireGuard tunnel has been restarted.", + "siteErrorRestart": "Failed to restart site", + "siteErrorRestartDescription": "An error occurred while restarting the site.", "siteSettingDescription": "Configure the settings on the site", "siteResourcesTab": "Resources", "siteResourcesNoneOnSite": "This site has no public or private resources yet.", diff --git a/server/routers/external.ts b/server/routers/external.ts index a64162e6a..326e555a8 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -254,6 +254,14 @@ authenticated.delete( site.deleteSite ); +authenticated.post( + "/site/:siteId/restart", + verifySiteAccess, + verifyUserHasAction(ActionsEnum.updateSite), + logActionAudit(ActionsEnum.updateSite), + site.restartSite +); + // TODO: BREAK OUT THESE ACTIONS SO THEY ARE NOT ALL "getSite" authenticated.get( "/site/:siteId/docker/status", diff --git a/server/routers/site/index.ts b/server/routers/site/index.ts index 00fdeda91..292c57971 100644 --- a/server/routers/site/index.ts +++ b/server/routers/site/index.ts @@ -7,3 +7,4 @@ export * from "./listSites"; export * from "./listSiteRoles"; export * from "./pickSiteDefaults"; export * from "./socketIntegration"; +export * from "./restartSite"; diff --git a/server/routers/site/restartSite.ts b/server/routers/site/restartSite.ts new file mode 100644 index 000000000..705b65db8 --- /dev/null +++ b/server/routers/site/restartSite.ts @@ -0,0 +1,116 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db, newts } from "@server/db"; +import { sites } from "@server/db"; +import { eq } from "drizzle-orm"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; +import { sendToClient } from "../ws"; + +const updateSiteParamsSchema = z.strictObject({ + siteId: z.coerce.number().int().positive() +}); + +registry.registerPath({ + method: "post", + path: "/site/{siteId}/restart", + description: "Restart a site.", + tags: [OpenAPITags.Site], + request: { + params: updateSiteParamsSchema + }, + responses: { + 200: { + description: "Successful response", + content: { + "application/json": { + schema: z.object({ + data: z.record(z.string(), z.any()).nullable(), + success: z.boolean(), + error: z.boolean(), + message: z.string(), + status: z.number() + }) + } + } + } + } +}); + +export async function restartSite( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = updateSiteParamsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { siteId } = parsedParams.data; + + const [existingSite] = await db + .select() + .from(sites) + .where(eq(sites.siteId, siteId)) + .limit(1); + + if (!existingSite) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Site with ID ${siteId} not found` + ) + ); + } + + // get the newt + + const [newt] = await db + .select() + .from(newts) + .where(eq(newts.siteId, siteId)) + .limit(1); + + if (!newt) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Newt for site with ID ${siteId} not found` + ) + ); + } + + await sendToClient( + newt.newtId, + { + type: "newt/wg/restart", + data: {} + }, + { incrementConfigVersion: false, compress: false } + ); + + return response(res, { + data: null, + success: true, + error: false, + message: "Site restarted successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/src/app/[orgId]/settings/sites/[niceId]/general/page.tsx b/src/app/[orgId]/settings/sites/[niceId]/general/page.tsx index fdbfc387d..37a92c409 100644 --- a/src/app/[orgId]/settings/sites/[niceId]/general/page.tsx +++ b/src/app/[orgId]/settings/sites/[niceId]/general/page.tsx @@ -43,6 +43,7 @@ import { usePaidStatus } from "@app/hooks/usePaidStatus"; import { tierMatrix, TierFeature } from "@server/lib/billing/tierMatrix"; import { Button as ButtonUI } from "@/components/ui/button"; import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert"; +import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; const GeneralFormSchema = z.object({ name: z.string().nonempty("Name is required"), @@ -72,6 +73,23 @@ export default function GeneralPage() { const [activeCidrTagIndex, setActiveCidrTagIndex] = useState( null ); + const [isRestartDialogOpen, setIsRestartDialogOpen] = useState(false); + + async function restartSite() { + try { + await api.post(`/site/${site?.siteId}/restart`); + toast({ + title: t("siteRestarted"), + description: t("siteRestartedDescription") + }); + } catch (e) { + toast({ + variant: "destructive", + title: t("siteErrorRestart"), + description: formatAxiosError(e, t("siteErrorRestartDescription")) + }); + } + } const orgAutoUpdate = org.org.settingsEnableGlobalNewtAutoUpdate ?? false; @@ -349,6 +367,52 @@ export default function GeneralPage() { + {site && site.type === "newt" && ( + <> + + {t.rich("siteRestartDialogMessage", { + name: site.name, + b: (chunks) => {chunks} + })} +

+ } + buttonText={t("siteRestartButton")} + onConfirm={restartSite} + string={site.name} + warningText={t("siteRestartWarning")} + title={t("siteRestartTitle")} + /> + + + + {t("siteRestartTitle")} + + + {t("siteRestartDescription")} + + + + +

+ {t("siteRestartBody")} +

+
+
+ + + +
+ + )} ); }