Merge branch 'auto-update' into dev

This commit is contained in:
Owen
2026-05-28 13:59:34 -07:00
14 changed files with 622 additions and 52 deletions

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}