Compare commits

...

5 Commits

Author SHA1 Message Date
Owen
3539b9ddb4 Working 2026-05-21 17:30:06 -07:00
Owen
4530aac4f3 Update setting is working
Adjust the ui

Adjust description
2026-05-21 16:34:32 -07:00
Owen
6d4afd0953 Control updates from the ui 2026-05-21 15:43:31 -07:00
Owen
dee0ca6864 Add permissions check, shasum check, & build info 2026-05-21 14:34:16 -07:00
Owen
ed73d089d0 Auto update newt 2026-05-21 14:13:32 -07:00
14 changed files with 622 additions and 52 deletions

View File

@@ -1601,7 +1601,17 @@
"contents": "Contents",
"parsedContents": "Parsed Contents (Read Only)",
"enableDockerSocket": "Enable Docker Blueprint",
"enableDockerSocketDescription": "Enable Docker Socket label scraping for blueprint labels. Socket path must be provided to Newt. Read about how this works in <docsLink>the documentation</docsLink>.",
"enableDockerSocketDescription": "Enable Docker Socket label scraping for blueprint labels. Socket path must be provided to the site connector. Read about how this works in <docsLink>the documentation</docsLink>.",
"newtAutoUpdate": "Enable Site Auto-Update",
"newtAutoUpdateDescription": "When enabled, site connectors will automatically update to the latest version when a new release is available.",
"siteAutoUpdate": "Site Auto-Update",
"siteAutoUpdateLabel": "Enable Auto-Update",
"siteAutoUpdateDescription": "Control whether this site's connector automatically downloads the latest version.",
"siteAutoUpdateOrgDefault": "Organization default: {state}",
"siteAutoUpdateOverriding": "Overriding organization setting",
"siteAutoUpdateResetToOrg": "Reset to Organization Default",
"siteAutoUpdateEnabled": "enabled",
"siteAutoUpdateDisabled": "disabled",
"viewDockerContainers": "View Docker Containers",
"containersIn": "Containers in {siteName}",
"selectContainerDescription": "Select any container to use as a hostname for this target. Click a port to use a port.",

View File

@@ -65,7 +65,12 @@ export const orgs = pgTable("orgs", {
sshCaPrivateKey: text("sshCaPrivateKey"), // Encrypted SSH CA private key (PEM format)
sshCaPublicKey: text("sshCaPublicKey"), // SSH CA public key (OpenSSH format)
isBillingOrg: boolean("isBillingOrg"),
billingOrgId: varchar("billingOrgId")
billingOrgId: varchar("billingOrgId"),
settingsEnableGlobalNewtAutoUpdate: boolean(
"settingsEnableGlobalNewtAutoUpdate"
)
.notNull()
.default(false)
});
export const orgDomains = pgTable("orgDomains", {
@@ -103,6 +108,10 @@ export const sites = pgTable("sites", {
lastHolePunch: bigint("lastHolePunch", { mode: "number" }),
listenPort: integer("listenPort"),
dockerSocketEnabled: boolean("dockerSocketEnabled").notNull().default(true),
autoUpdateEnabled: boolean("autoUpdateEnabled").notNull().default(false),
autoUpdateOverrideOrg: boolean("autoUpdateOverrideOrg")
.notNull()
.default(false),
status: varchar("status")
.$type<"pending" | "approved">()
.default("approved")

View File

@@ -62,7 +62,13 @@ export const orgs = sqliteTable("orgs", {
sshCaPrivateKey: text("sshCaPrivateKey"), // Encrypted SSH CA private key (PEM format)
sshCaPublicKey: text("sshCaPublicKey"), // SSH CA public key (OpenSSH format)
isBillingOrg: integer("isBillingOrg", { mode: "boolean" }),
billingOrgId: text("billingOrgId")
billingOrgId: text("billingOrgId"),
settingsEnableGlobalNewtAutoUpdate: integer(
"settingsEnableGlobalNewtAutoUpdate",
{ mode: "boolean" }
)
.notNull()
.default(false)
});
export const userDomains = sqliteTable("userDomains", {
@@ -116,6 +122,14 @@ export const sites = sqliteTable("sites", {
dockerSocketEnabled: integer("dockerSocketEnabled", { mode: "boolean" })
.notNull()
.default(true),
autoUpdateEnabled: integer("autoUpdateEnabled", { mode: "boolean" })
.notNull()
.default(false),
autoUpdateOverrideOrg: integer("autoUpdateOverrideOrg", {
mode: "boolean"
})
.notNull()
.default(false),
status: text("status").$type<"pending" | "approved">().default("approved")
});

View File

@@ -25,7 +25,8 @@ export enum TierFeature {
StandaloneHealthChecks = "standaloneHealthChecks",
AlertingRules = "alertingRules",
WildcardSubdomain = "wildcardSubdomain",
Labels = "labels"
Labels = "labels",
NewtAutoUpdate = "newtAutoUpdate"
}
export const tierMatrix: Record<TierFeature, Tier[]> = {
@@ -68,5 +69,6 @@ export const tierMatrix: Record<TierFeature, Tier[]> = {
[TierFeature.DomainNamespaces]: ["tier1", "tier2", "tier3", "enterprise"],
[TierFeature.StandaloneHealthChecks]: ["tier3", "enterprise"],
[TierFeature.AlertingRules]: ["tier3", "enterprise"],
[TierFeature.WildcardSubdomain]: ["tier1", "tier2", "tier3", "enterprise"]
[TierFeature.WildcardSubdomain]: ["tier1", "tier2", "tier3", "enterprise"],
[TierFeature.NewtAutoUpdate]: ["tier1", "tier2", "tier3", "enterprise"]
};

View File

@@ -522,13 +522,13 @@ const sendToClientLocal = async (
const messageString = JSON.stringify(messageWithVersion);
if (options.compress) {
logger.debug(
`Message size before compression: ${messageString.length} bytes`
);
// logger.debug(
// `Message size before compression: ${messageString.length} bytes`
// );
const compressed = zlib.gzipSync(Buffer.from(messageString, "utf8"));
logger.debug(
`Message size after compression: ${compressed.length} bytes`
);
// logger.debug(
// `Message size after compression: ${compressed.length} bytes`
// );
clients.forEach((client) => {
if (client.readyState === WebSocket.OPEN) {
client.send(compressed);

View File

@@ -1231,6 +1231,22 @@ authRouter.post(
newt.getNewtToken
);
authRouter.post(
"/newt/version",
rateLimit({
windowMs: 15 * 60 * 1000,
max: 60,
keyGenerator: (req) =>
`newtVersion:${req.body.newtId || ipKeyGenerator(req.ip || "")}`,
handler: (req, res, next) => {
const message = `You can only check the Newt version ${60} times every ${15} minutes. Please try again later.`;
return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message));
},
store: createStore()
}),
newt.getNewtVersion
);
authRouter.post(
"/newt/register",
rateLimit({

View File

@@ -0,0 +1,317 @@
import { db, orgs, sites } from "@server/db";
import { newts } from "@server/db";
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 semver from "semver";
import { verifyPassword } from "@server/auth/password";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import logger from "@server/logger";
import cache from "#dynamic/lib/cache";
import config from "@server/lib/config";
// Stale-while-revalidate in-memory fallback for the releases API.
type ReleaseInfo = {
version: string;
// binary filename -> sha256 hex (sourced from asset `digest` field in GitHub API)
assetDigests: Record<string, string>;
};
let staleReleaseInfo: ReleaseInfo | null = null;
/**
* Fetches the latest stable newt release from GitHub and returns the version
* tag together with a map of asset-name → sha256 hex digest.
* Results are cached for one hour; stale data is returned on failure.
*/
async function getLatestReleaseInfo(): Promise<ReleaseInfo | null> {
try {
const cached = await cache.get<ReleaseInfo>("cache:newtReleaseInfo");
if (cached) {
return cached;
}
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 5000);
const fetchResponse = await fetch(
"https://api.github.com/repos/fosrl/newt/releases",
{ signal: controller.signal }
);
clearTimeout(timeoutId);
if (!fetchResponse.ok) {
logger.warn(
`Failed to fetch Newt releases from GitHub: ${fetchResponse.status} ${fetchResponse.statusText}`
);
return staleReleaseInfo;
}
let releases: any[] = await fetchResponse.json();
if (!Array.isArray(releases) || releases.length === 0) {
logger.warn("No releases found for Newt repository");
return staleReleaseInfo;
}
// Drop drafts, pre-releases, and anything with "rc" in the tag name.
releases = releases.filter(
(r: any) =>
!r.draft &&
!r.prerelease &&
!r.tag_name.includes("rc") &&
!r.tag_name.includes("v")
);
// Sort descending by semver to find the true latest stable release.
releases.sort((a: any, b: any) => {
const va = semver.coerce(a.tag_name);
const vb = semver.coerce(b.tag_name);
if (!va && !vb) return 0;
if (!va) return 1;
if (!vb) return -1;
return semver.rcompare(va, vb);
});
if (releases.length === 0) {
logger.warn("No stable releases found for Newt repository");
return staleReleaseInfo;
}
const latest = releases[0];
const version: string = latest.tag_name;
// Build a map of binary filename → sha256 hex from the asset `digest`
// field returned by the GitHub API (format: "sha256:<hex>").
const assetDigests: Record<string, string> = {};
if (Array.isArray(latest.assets)) {
for (const asset of latest.assets) {
if (
typeof asset.name === "string" &&
typeof asset.digest === "string" &&
asset.digest.startsWith("sha256:")
) {
assetDigests[asset.name] = asset.digest.slice(
"sha256:".length
);
}
}
}
const info: ReleaseInfo = { version, assetDigests };
staleReleaseInfo = info;
await cache.set("cache:newtReleaseInfo", info, 3600);
return info;
} catch (error: any) {
if (error.name === "AbortError") {
logger.warn("Request to fetch Newt releases timed out (5s)");
} else {
logger.warn(
"Error fetching Newt releases:",
error.message || error
);
}
return staleReleaseInfo;
}
}
const bodySchema = z.object({
newtId: z.string(),
secret: z.string(),
platform: z.string() // e.g. "linux_amd64", "darwin_arm64"
});
export type GetNewtVersionBody = z.infer<typeof bodySchema>;
export type GetNewtVersionResponse = {
latestVersion: string;
currentIsLatest: boolean;
downloadUrl: string;
sha256: string;
};
export async function getNewtVersion(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
const parsedBody = bodySchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const { newtId, secret, platform } = parsedBody.data;
try {
// Verify newt credentials
const [existingNewt] = await db
.select()
.from(newts)
.where(eq(newts.newtId, newtId))
.limit(1);
if (!existingNewt) {
if (config.getRawConfig().app.log_failed_attempts) {
logger.info(
`Newt version check: no newt found with ID ${newtId}. IP: ${req.ip}.`
);
}
return next(
createHttpError(HttpCode.UNAUTHORIZED, "Invalid credentials")
);
}
if (!existingNewt.siteId) {
logger.warn(`Newt ${newtId} has no associated site`);
return next(
createHttpError(
HttpCode.UNAUTHORIZED,
"Not associated with a site"
)
);
}
const validSecret = await verifyPassword(
secret,
existingNewt.secretHash
);
if (!validSecret) {
if (config.getRawConfig().app.log_failed_attempts) {
logger.info(
`Newt version check: invalid secret for newt ID ${newtId}. IP: ${req.ip}.`
);
}
return next(
createHttpError(HttpCode.UNAUTHORIZED, "Invalid credentials")
);
}
// check if udpates are enabled for the org or the site
const [site] = await db
.select()
.from(sites)
.where(eq(sites.siteId, existingNewt.siteId))
.limit(1);
if (!site) {
logger.warn(`Site with ID ${existingNewt.siteId} not found`);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Associated site not found"
)
);
}
const [org] = await db
.select()
.from(orgs)
.where(eq(orgs.orgId, site.orgId))
.limit(1);
if (!org) {
logger.warn(`Org with ID ${site.orgId} not found`);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Associated organization not found"
)
);
}
let doUpdate = false;
if (site.autoUpdateOverrideOrg) {
doUpdate = site.autoUpdateEnabled;
} else {
doUpdate = org.settingsEnableGlobalNewtAutoUpdate;
}
if (!doUpdate) {
// return no content http code
return response(res, {
data: {
latestVersion: existingNewt.version ?? "",
currentIsLatest: true,
downloadUrl: "",
sha256: ""
},
success: true,
error: false,
message:
"Auto-updates are disabled for this site and organization",
status: HttpCode.NO_CONTENT
});
}
// Fetch latest release info (version + asset digests) in one API call.
const releaseInfo = await getLatestReleaseInfo();
if (!releaseInfo) {
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Unable to determine latest Newt version"
)
);
}
const latestVersion = releaseInfo.version;
// Binary name follows the get-newt.sh convention: newt_<platform>[.exe]
const binaryName = platform.includes("windows")
? `newt_${platform}.exe`
: `newt_${platform}`;
const downloadUrl = `https://github.com/fosrl/newt/releases/download/${latestVersion}/${binaryName}`;
// Look up the SHA256 digest for this specific binary from the GitHub
// release asset metadata (the `digest` field, format "sha256:<hex>").
const sha256 = releaseInfo.assetDigests[binaryName] ?? "";
// Determine whether the newt that's asking is already up to date.
// We store the current version on the newt row when it registers.
const currentVersion = existingNewt.version ?? null;
let currentIsLatest = false;
if (currentVersion) {
try {
const latest = semver.coerce(latestVersion);
const current = semver.coerce(currentVersion);
if (latest && current) {
currentIsLatest = !semver.lt(current, latest);
}
} catch {
// If we can't compare, assume not latest
}
}
return response<GetNewtVersionResponse>(res, {
data: {
latestVersion,
currentIsLatest,
downloadUrl,
sha256
},
success: true,
error: false,
message: "Version info retrieved successfully",
status: HttpCode.OK
});
} catch (e) {
logger.error(e);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Failed to retrieve version info"
)
);
}
}

View File

@@ -1,5 +1,6 @@
export * from "./createNewt";
export * from "./getNewtToken";
export * from "./getNewtVersion";
export * from "./handleNewtRegisterMessage";
export * from "./handleReceiveBandwidthMessage";
export * from "./handleNewtGetConfigMessage";

View File

@@ -40,7 +40,8 @@ const updateOrgBodySchema = z
settingsLogRetentionDaysConnection: z
.number()
.min(build === "saas" ? 0 : -1)
.optional()
.optional(),
settingsEnableGlobalNewtAutoUpdate: z.boolean().optional()
})
.refine((data) => Object.keys(data).length > 0, {
error: "At least one field must be provided for update"
@@ -118,6 +119,15 @@ export async function updateOrg(
if (!hasPasswordExpirationFeature) {
parsedBody.data.passwordExpiryDays = undefined;
}
const hasNewtAutoUpdateFeature = await isLicensedOrSubscribed(
orgId,
tierMatrix[TierFeature.NewtAutoUpdate]
);
if (!hasNewtAutoUpdateFeature) {
parsedBody.data.settingsEnableGlobalNewtAutoUpdate = false; // force it off
}
if (build == "saas") {
const { tier } = await getOrgTierData(orgId);
@@ -136,8 +146,10 @@ export async function updateOrg(
if (maxRetentionDays !== null) {
if (
parsedBody.data.settingsLogRetentionDaysRequest !== undefined &&
parsedBody.data.settingsLogRetentionDaysRequest > maxRetentionDays
parsedBody.data.settingsLogRetentionDaysRequest !==
undefined &&
parsedBody.data.settingsLogRetentionDaysRequest >
maxRetentionDays
) {
return next(
createHttpError(
@@ -147,8 +159,10 @@ export async function updateOrg(
);
}
if (
parsedBody.data.settingsLogRetentionDaysAccess !== undefined &&
parsedBody.data.settingsLogRetentionDaysAccess > maxRetentionDays
parsedBody.data.settingsLogRetentionDaysAccess !==
undefined &&
parsedBody.data.settingsLogRetentionDaysAccess >
maxRetentionDays
) {
return next(
createHttpError(
@@ -158,8 +172,10 @@ export async function updateOrg(
);
}
if (
parsedBody.data.settingsLogRetentionDaysAction !== undefined &&
parsedBody.data.settingsLogRetentionDaysAction > maxRetentionDays
parsedBody.data.settingsLogRetentionDaysAction !==
undefined &&
parsedBody.data.settingsLogRetentionDaysAction >
maxRetentionDays
) {
return next(
createHttpError(
@@ -169,8 +185,10 @@ export async function updateOrg(
);
}
if (
parsedBody.data.settingsLogRetentionDaysConnection !== undefined &&
parsedBody.data.settingsLogRetentionDaysConnection > maxRetentionDays
parsedBody.data.settingsLogRetentionDaysConnection !==
undefined &&
parsedBody.data.settingsLogRetentionDaysConnection >
maxRetentionDays
) {
return next(
createHttpError(
@@ -196,7 +214,9 @@ export async function updateOrg(
settingsLogRetentionDaysAction:
parsedBody.data.settingsLogRetentionDaysAction,
settingsLogRetentionDaysConnection:
parsedBody.data.settingsLogRetentionDaysConnection
parsedBody.data.settingsLogRetentionDaysConnection,
settingsEnableGlobalNewtAutoUpdate:
parsedBody.data.settingsEnableGlobalNewtAutoUpdate
})
.where(eq(orgs.orgId, orgId))
.returning();

View File

@@ -1,6 +1,6 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import { db, Site } from "@server/db";
import { sites } from "@server/db";
import { eq, and, ne } from "drizzle-orm";
import response from "@server/lib/response";
@@ -9,7 +9,8 @@ import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
import { isValidCIDR } from "@server/lib/validators";
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
import { tierMatrix, TierFeature } from "@server/lib/billing/tierMatrix";
const updateSiteParamsSchema = z.strictObject({
siteId: z.string().transform(Number).pipe(z.int().positive())
@@ -21,18 +22,8 @@ const updateSiteBodySchema = z
niceId: z.string().min(1).max(255).optional(),
dockerSocketEnabled: z.boolean().optional(),
status: z.enum(["pending", "approved"]).optional(),
// remoteSubnets: z.string().optional()
// subdomain: z
// .string()
// .min(1)
// .max(255)
// .transform((val) => val.toLowerCase())
// .optional()
// pubKey: z.string().optional(),
// subnet: z.string().optional(),
// exitNode: z.number().int().positive().optional(),
// megabytesIn: z.number().int().nonnegative().optional(),
// megabytesOut: z.number().int().nonnegative().optional(),
autoUpdateEnabled: z.boolean().optional(),
autoUpdateOverrideOrg: z.boolean().optional()
})
.refine((data) => Object.keys(data).length > 0, {
error: "At least one field must be provided for update"
@@ -85,9 +76,24 @@ export async function updateSite(
const { siteId } = parsedParams.data;
const updateData = parsedBody.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`
)
);
}
// if niceId is provided, check if it's already in use by another site
if (updateData.niceId) {
const [existingSite] = await db
const [existingSiteNiceIdOverlap] = await db
.select()
.from(sites)
.where(
@@ -99,7 +105,7 @@ export async function updateSite(
)
.limit(1);
if (existingSite) {
if (existingSiteNiceIdOverlap) {
return next(
createHttpError(
HttpCode.CONFLICT,
@@ -109,6 +115,15 @@ export async function updateSite(
}
}
const hasNewtAutoUpdateFeature = await isLicensedOrSubscribed(
existingSite.orgId,
tierMatrix[TierFeature.NewtAutoUpdate]
);
if (!hasNewtAutoUpdateFeature) {
parsedBody.data.autoUpdateEnabled = false; // force it off
parsedBody.data.autoUpdateOverrideOrg = false; // force it off
}
// // if remoteSubnets is provided, ensure it's a valid comma-separated list of cidrs
// if (updateData.remoteSubnets) {
// const subnets = updateData.remoteSubnets

View File

@@ -38,11 +38,16 @@ import { useUserContext } from "@app/hooks/useUserContext";
import { useTranslations } from "next-intl";
import { build } from "@server/build";
import type { OrgContextType } from "@app/contexts/orgContext";
import { SwitchInput } from "@app/components/SwitchInput";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { tierMatrix, TierFeature } from "@server/lib/billing/tierMatrix";
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
// Schema for general organization settings
const GeneralFormSchema = z.object({
name: z.string(),
subnet: z.string().optional()
subnet: z.string().optional(),
settingsEnableGlobalNewtAutoUpdate: z.boolean().optional()
});
export default function GeneralPage() {
@@ -163,17 +168,24 @@ function GeneralSectionForm({ org }: SectionFormProps) {
resolver: zodResolver(
GeneralFormSchema.pick({
name: true,
subnet: true
subnet: true,
settingsEnableGlobalNewtAutoUpdate: true
})
),
defaultValues: {
name: org.name,
subnet: org.subnet || "" // Add default value for subnet
subnet: org.subnet || "",
settingsEnableGlobalNewtAutoUpdate:
org.settingsEnableGlobalNewtAutoUpdate ?? false
},
mode: "onChange"
});
const t = useTranslations();
const router = useRouter();
const { isPaidUser } = usePaidStatus();
const hasAutoUpdateFeature = isPaidUser(
tierMatrix[TierFeature.NewtAutoUpdate]
);
const [, formAction, loadingSave] = useActionState(performSave, null);
const api = createApiClient(useEnvContext());
@@ -186,7 +198,9 @@ function GeneralSectionForm({ org }: SectionFormProps) {
try {
const reqData = {
name: data.name
name: data.name,
settingsEnableGlobalNewtAutoUpdate:
data.settingsEnableGlobalNewtAutoUpdate
} as any;
// Update organization
@@ -194,13 +208,16 @@ function GeneralSectionForm({ org }: SectionFormProps) {
// Update the org context to reflect the change in the info card
updateOrg({
name: data.name
name: data.name,
settingsEnableGlobalNewtAutoUpdate:
data.settingsEnableGlobalNewtAutoUpdate
});
toast({
title: t("orgUpdated"),
description: t("orgUpdatedDescription")
});
router.refresh();
} catch (e) {
toast({
@@ -243,6 +260,31 @@ function GeneralSectionForm({ org }: SectionFormProps) {
</FormItem>
)}
/>
<PaidFeaturesAlert
tiers={tierMatrix.newtAutoUpdate}
/>
<FormField
control={form.control}
name="settingsEnableGlobalNewtAutoUpdate"
render={({ field }) => (
<FormItem>
<FormControl>
<SwitchInput
id="settings-enable-global-newt-auto-update"
label={t("newtAutoUpdate")}
checked={field.value}
onCheckedChange={field.onChange}
disabled={!hasAutoUpdateFeature}
/>
</FormControl>
<FormDescription>
{t("newtAutoUpdateDescription")}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</SettingsSectionForm>

View File

@@ -36,35 +36,53 @@ import { useState } from "react";
import { SwitchInput } from "@app/components/SwitchInput";
import { ExternalLink } from "lucide-react";
import { useTranslations } from "next-intl";
import { useOrgContext } from "@app/hooks/useOrgContext";
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";
const GeneralFormSchema = z.object({
name: z.string().nonempty("Name is required"),
niceId: z.string().min(1).max(255).optional(),
dockerSocketEnabled: z.boolean().optional()
dockerSocketEnabled: z.boolean().optional(),
autoUpdateEnabled: z.boolean().optional(),
autoUpdateOverrideOrg: z.boolean().optional()
});
type GeneralFormValues = z.infer<typeof GeneralFormSchema>;
export default function GeneralPage() {
const { site, updateSite } = useSiteContext();
const { org } = useOrgContext();
const { env } = useEnvContext();
const api = createApiClient(useEnvContext());
const router = useRouter();
const t = useTranslations();
const { toast } = useToast();
const { isPaidUser } = usePaidStatus();
const hasAutoUpdateFeature = isPaidUser(
tierMatrix[TierFeature.NewtAutoUpdate]
);
const [loading, setLoading] = useState(false);
const [activeCidrTagIndex, setActiveCidrTagIndex] = useState<number | null>(
null
);
const orgAutoUpdate = org.org.settingsEnableGlobalNewtAutoUpdate ?? false;
const form = useForm({
resolver: zodResolver(GeneralFormSchema),
defaultValues: {
name: site?.name,
niceId: site?.niceId || "",
dockerSocketEnabled: site?.dockerSocketEnabled ?? false
dockerSocketEnabled: site?.dockerSocketEnabled ?? false,
autoUpdateEnabled: site?.autoUpdateOverrideOrg
? (site?.autoUpdateEnabled ?? false)
: orgAutoUpdate,
autoUpdateOverrideOrg: site?.autoUpdateOverrideOrg ?? false
},
mode: "onChange"
});
@@ -76,13 +94,17 @@ export default function GeneralPage() {
await api.post(`/site/${site?.siteId}`, {
name: data.name,
niceId: data.niceId,
dockerSocketEnabled: data.dockerSocketEnabled
dockerSocketEnabled: data.dockerSocketEnabled,
autoUpdateEnabled: data.autoUpdateEnabled,
autoUpdateOverrideOrg: data.autoUpdateOverrideOrg
});
updateSite({
name: data.name,
niceId: data.niceId,
dockerSocketEnabled: data.dockerSocketEnabled
dockerSocketEnabled: data.dockerSocketEnabled,
autoUpdateEnabled: data.autoUpdateEnabled,
autoUpdateOverrideOrg: data.autoUpdateOverrideOrg
});
if (data.niceId && data.niceId !== site?.niceId) {
@@ -199,7 +221,9 @@ export default function GeneralPage() {
{t.rich(
"enableDockerSocketDescription",
{
docsLink: (chunks) => (
docsLink: (
chunks
) => (
<a
href="https://docs.pangolin.net/manage/sites/configure-site#docker-socket-integration"
target="_blank"
@@ -217,6 +241,80 @@ export default function GeneralPage() {
)}
/>
)}
<PaidFeaturesAlert
tiers={tierMatrix.newtAutoUpdate}
/>
{site && site.type === "newt" && (
<FormField
control={form.control}
name="autoUpdateEnabled"
render={({ field }) => {
const isOverriding = form.watch(
"autoUpdateOverrideOrg"
);
return (
<FormItem>
<FormControl>
<div className="flex items-center gap-3">
<SwitchInput
id="auto-update-enabled"
label={t(
"siteAutoUpdateLabel"
)}
checked={
field.value
}
onCheckedChange={(
checked
) => {
field.onChange(
checked
);
form.setValue(
"autoUpdateOverrideOrg",
true
);
}}
disabled={
!hasAutoUpdateFeature
}
/>
{isOverriding && (
<ButtonUI
type="button"
variant="link"
size="sm"
className="h-auto p-0 pb-2 text-xs"
onClick={() => {
form.setValue(
"autoUpdateOverrideOrg",
false
);
form.setValue(
"autoUpdateEnabled",
orgAutoUpdate
);
}}
>
{t(
"siteAutoUpdateResetToOrg"
)}
</ButtonUI>
)}
</div>
</FormControl>
<FormDescription>
{t(
"siteAutoUpdateDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
);
}}
/>
)}
</form>
</Form>
</SettingsSectionForm>

View File

@@ -1,6 +1,8 @@
import SiteProvider from "@app/providers/SiteProvider";
import OrgProvider from "@app/providers/OrgProvider";
import { internal } from "@app/lib/api";
import { GetSiteResponse } from "@server/routers/site";
import { GetOrgResponse } from "@server/routers/org";
import { AxiosResponse } from "axios";
import { redirect } from "next/navigation";
import { authCookieHeader } from "@app/lib/api/cookies";
@@ -35,6 +37,17 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
redirect(`/${params.orgId}/settings/sites`);
}
let org = null;
try {
const res = await internal.get<AxiosResponse<GetOrgResponse>>(
`/org/${params.orgId}`,
await authCookieHeader()
);
org = res.data.data;
} catch {
redirect(`/${params.orgId}/settings/sites`);
}
const t = await getTranslations();
const navItems = [
@@ -64,10 +77,14 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
/>
<SiteProvider site={site}>
<div className="space-y-4">
<SiteInfoCard />
<HorizontalTabs items={navItems}>{children}</HorizontalTabs>
</div>
<OrgProvider org={org}>
<div className="space-y-4">
<SiteInfoCard />
<HorizontalTabs items={navItems}>
{children}
</HorizontalTabs>
</div>
</OrgProvider>
</SiteProvider>
</>
);

View File

@@ -45,7 +45,16 @@ export function SwitchInput({
return (
<div>
<div className="flex items-center space-x-2 mb-2">
{label && <Label htmlFor={id}>{label}</Label>}
{label && (
<Label
htmlFor={id}
className={
disabled ? "opacity-50 cursor-not-allowed" : ""
}
>
{label}
</Label>
)}
<Switch
id={id}
checked={checked}