rework ui

This commit is contained in:
Owen
2026-06-30 17:44:35 -04:00
parent 686789ee4c
commit cfbbdedaf5
4 changed files with 167 additions and 172 deletions

View File

@@ -2388,23 +2388,21 @@
"sidebarRemoteExitNodes": "Remote Nodes",
"remoteExitNodeId": "ID",
"remoteExitNodeSecretKey": "Secret",
"remoteExitNodeNetworkingTitle": "Network Settings",
"remoteExitNodeNetworkingDescription": "Configure how this remote exit node routes traffic and which sites prefer to connect through it.",
"remoteExitNodeNetworkingSave": "Save Settings",
"remoteExitNodeNetworkingSaveSuccessTitle": "Network settings saved",
"remoteExitNodeNetworkingSaveSuccessDescription": "Network settings have been updated successfully.",
"remoteExitNodeNetworkingSaveError": "Failed to save network settings",
"remoteExitNodeNetworkingSubnetsTitle": "Remote Subnets",
"remoteExitNodeNetworkingSubnetsDescription": "Define the CIDR ranges that this remote exit node will route traffic to. Type a valid CIDR (e.g. <code>10.0.0.0/8</code>) and press Enter to add.",
"remoteExitNodeNetworkingSubnetsPlaceholder": "Add a CIDR range (e.g. 10.0.0.0/8)",
"remoteExitNodeNetworkingSubnetsSave": "Save Subnets",
"remoteExitNodeNetworkingSubnetsSaveSuccessTitle": "Subnets saved",
"remoteExitNodeNetworkingSubnetsSaveSuccessDescription": "Remote subnets have been updated successfully.",
"remoteExitNodeNetworkingSubnetsLoadError": "Failed to load subnets",
"remoteExitNodeNetworkingSubnetsSaveError": "Failed to save subnets",
"remoteExitNodeNetworkingLabelsTitle": "Preference Labels",
"remoteExitNodeNetworkingLabelsDescription": "Sites with these labels will be enforced to connect through this remote exit node.",
"remoteExitNodeNetworkingLabelsButtonText": "Select labels...",
"remoteExitNodeNetworkingLabelsSearchPlaceholder": "Search labels...",
"remoteExitNodeNetworkingLabelsSave": "Save Labels",
"remoteExitNodeNetworkingLabelsSaveSuccessTitle": "Labels saved",
"remoteExitNodeNetworkingLabelsSaveSuccessDescription": "Preference labels have been updated successfully.",
"remoteExitNodeNetworkingLabelsLoadError": "Failed to load labels",
"remoteExitNodeNetworkingLabelsSaveError": "Failed to save labels",
"remoteExitNodeCreate": {
"title": "Create Remote Node",
"description": "Create a new self-hosted remote relay and proxy server node",

View File

@@ -3,14 +3,18 @@
import { useEffect, useMemo, useState } from "react";
import {
SettingsContainer,
SettingsFormCell,
SettingsFormGrid,
SettingsSection,
SettingsSectionBody,
SettingsSectionDescription,
SettingsSectionFooter,
SettingsSectionForm,
SettingsSectionHeader,
SettingsSectionTitle
} from "@app/components/Settings";
import { Button } from "@app/components/ui/button";
import { Label } from "@app/components/ui/label";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { toast } from "@app/hooks/useToast";
@@ -47,13 +51,13 @@ export default function NetworkingPage() {
const [subnets, setSubnets] = useState<Tag[]>([]);
const [activeTagIndex, setActiveTagIndex] = useState<number | null>(null);
const [loadingSubnets, setLoadingSubnets] = useState(true);
const [savingSubnets, setSavingSubnets] = useState(false);
// Labels state
const [selectedLabels, setSelectedLabels] = useState<TagValue[]>([]);
const [labelSearchQuery, setLabelSearchQuery] = useState("");
const [loadingLabels, setLoadingLabels] = useState(true);
const [savingLabels, setSavingLabels] = useState(false);
const [saving, setSaving] = useState(false);
const [debouncedLabelQuery] = useDebounce(labelSearchQuery, 150);
@@ -135,17 +139,27 @@ export default function NetworkingPage() {
loadLabels();
}, [remoteExitNode.remoteExitNodeId]);
const handleSaveSubnets = async () => {
setSavingSubnets(true);
const handleSave = async () => {
setSaving(true);
try {
await api.post<AxiosResponse<SetRemoteExitNodeResourcesResponse>>(
`/org/${orgId}/remote-exit-node/${remoteExitNode.remoteExitNodeId}/resources`,
{ destinations: subnets.map((s) => s.text) }
);
await Promise.all([
api.post<
AxiosResponse<SetRemoteExitNodeResourcesResponse>
>(
`/org/${orgId}/remote-exit-node/${remoteExitNode.remoteExitNodeId}/resources`,
{ destinations: subnets.map((s) => s.text) }
),
api.post<
AxiosResponse<SetRemoteExitNodePreferenceLabelsResponse>
>(
`/org/${orgId}/remote-exit-node/${remoteExitNode.remoteExitNodeId}/preference-labels`,
{ labelIds: selectedLabels.map((l) => parseInt(l.id)) }
)
]);
toast({
title: t("remoteExitNodeNetworkingSubnetsSaveSuccessTitle"),
title: t("remoteExitNodeNetworkingSaveSuccessTitle"),
description: t(
"remoteExitNodeNetworkingSubnetsSaveSuccessDescription"
"remoteExitNodeNetworkingSaveSuccessDescription"
)
});
} catch (error) {
@@ -154,38 +168,10 @@ export default function NetworkingPage() {
title: t("error"),
description:
formatAxiosError(error) ||
t("remoteExitNodeNetworkingSubnetsSaveError")
t("remoteExitNodeNetworkingSaveError")
});
} finally {
setSavingSubnets(false);
}
};
const handleSaveLabels = async () => {
setSavingLabels(true);
try {
await api.post<
AxiosResponse<SetRemoteExitNodePreferenceLabelsResponse>
>(
`/org/${orgId}/remote-exit-node/${remoteExitNode.remoteExitNodeId}/preference-labels`,
{ labelIds: selectedLabels.map((l) => parseInt(l.id)) }
);
toast({
title: t("remoteExitNodeNetworkingLabelsSaveSuccessTitle"),
description: t(
"remoteExitNodeNetworkingLabelsSaveSuccessDescription"
)
});
} catch (error) {
toast({
variant: "destructive",
title: t("error"),
description:
formatAxiosError(error) ||
t("remoteExitNodeNetworkingLabelsSaveError")
});
} finally {
setSavingLabels(false);
setSaving(false);
}
};
@@ -194,73 +180,93 @@ export default function NetworkingPage() {
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("remoteExitNodeNetworkingSubnetsTitle")}
{t("remoteExitNodeNetworkingTitle")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t.rich("remoteExitNodeNetworkingSubnetsDescription", {
code: (chunks) => <code>{chunks}</code>
})}{" "}
<a
href="https://docs.pangolin.net/placeholder"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline inline-flex items-center gap-1"
>
{t("learnMore")}
<ExternalLink className="size-3.5 shrink-0" />
</a>
{t("remoteExitNodeNetworkingDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<TagInput
tags={subnets}
setTags={setSubnets}
placeholder={t(
"remoteExitNodeNetworkingSubnetsPlaceholder"
)}
validateTag={(tag) => cidrRegex.test(tag.trim())}
activeTagIndex={activeTagIndex}
setActiveTagIndex={setActiveTagIndex}
disabled={loadingSubnets}
allowDuplicates={false}
inlineTags={true}
/>
<SettingsSectionForm variant="half">
<SettingsFormGrid>
<SettingsFormCell span="half">
<div className="grid gap-2">
<Label>
{t(
"remoteExitNodeNetworkingSubnetsTitle"
)}
</Label>
<TagInput
tags={subnets}
setTags={setSubnets}
placeholder={t(
"remoteExitNodeNetworkingSubnetsPlaceholder"
)}
validateTag={(tag) =>
cidrRegex.test(tag.trim())
}
activeTagIndex={activeTagIndex}
setActiveTagIndex={setActiveTagIndex}
disabled={loadingSubnets}
allowDuplicates={false}
size="sm"
inlineTags={true}
/>
<p className="text-sm text-muted-foreground">
{t.rich(
"remoteExitNodeNetworkingSubnetsDescription",
{
code: (chunks) => (
<code>{chunks}</code>
)
}
)}{" "}
<a
href="https://docs.pangolin.net/placeholder"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline inline-flex items-center gap-1"
>
{t("learnMore")}
<ExternalLink className="size-3.5 shrink-0" />
</a>
</p>
</div>
</SettingsFormCell>
<SettingsFormCell span="half">
<div className="grid gap-2">
<Label>
{t(
"remoteExitNodeNetworkingLabelsTitle"
)}
</Label>
<MultiSelectTagInput
value={selectedLabels}
options={labelsShown}
onChange={setSelectedLabels}
onSearch={setLabelSearchQuery}
searchQuery={labelSearchQuery}
disabled={loadingLabels}
buttonText={t(
"remoteExitNodeNetworkingLabelsButtonText"
)}
searchPlaceholder={t(
"remoteExitNodeNetworkingLabelsSearchPlaceholder"
)}
/>
<p className="text-sm text-muted-foreground">
{t(
"remoteExitNodeNetworkingLabelsDescription"
)}
</p>
</div>
</SettingsFormCell>
</SettingsFormGrid>
</SettingsSectionForm>
</SettingsSectionBody>
<SettingsSectionFooter>
<Button onClick={handleSaveSubnets} loading={savingSubnets}>
{t("remoteExitNodeNetworkingSubnetsSave")}
</Button>
</SettingsSectionFooter>
</SettingsSection>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("remoteExitNodeNetworkingLabelsTitle")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("remoteExitNodeNetworkingLabelsDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<MultiSelectTagInput
value={selectedLabels}
options={labelsShown}
onChange={setSelectedLabels}
onSearch={setLabelSearchQuery}
searchQuery={labelSearchQuery}
disabled={loadingLabels}
buttonText={t(
"remoteExitNodeNetworkingLabelsButtonText"
)}
searchPlaceholder={t(
"remoteExitNodeNetworkingLabelsSearchPlaceholder"
)}
/>
</SettingsSectionBody>
<SettingsSectionFooter>
<Button onClick={handleSaveLabels} loading={savingLabels}>
{t("remoteExitNodeNetworkingLabelsSave")}
<Button onClick={handleSave} loading={saving}>
{t("remoteExitNodeNetworkingSave")}
</Button>
</SettingsSectionFooter>
</SettingsSection>

View File

@@ -43,7 +43,6 @@ 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"),
@@ -73,23 +72,6 @@ export default function GeneralPage() {
const [activeCidrTagIndex, setActiveCidrTagIndex] = useState<number | null>(
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;
@@ -367,52 +349,6 @@ export default function GeneralPage() {
</Button>
</SettingsSectionFooter>
</SettingsSection>
{site && site.type === "newt" && (
<>
<ConfirmDeleteDialog
open={isRestartDialogOpen}
setOpen={setIsRestartDialogOpen}
dialog={
<p>
{t.rich("siteRestartDialogMessage", {
name: site.name,
b: (chunks) => <b>{chunks}</b>
})}
</p>
}
buttonText={t("siteRestartButton")}
onConfirm={restartSite}
string={site.name}
warningText={t("siteRestartWarning")}
title={t("siteRestartTitle")}
/>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("siteRestartTitle")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("siteRestartDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
<p className="text-sm text-muted-foreground">
{t("siteRestartBody")}
</p>
</SettingsSectionForm>
</SettingsSectionBody>
<SettingsSectionFooter>
<Button
variant="outline"
onClick={() => setIsRestartDialogOpen(true)}
>
{t("siteRestartButton")}
</Button>
</SettingsSectionFooter>
</SettingsSection>
</>
)}
</SettingsContainer>
);
}

View File

@@ -107,6 +107,7 @@ export default function SitesTable({
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [deleteWithResources, setDeleteWithResources] = useState(false);
const [selectedSite, setSelectedSite] = useState<SiteRow | null>(null);
const [restartingSite, setRestartingSite] = useState<SiteRow | null>(null);
const [resourcesDialogSite, setResourcesDialogSite] =
useState<SiteRow | null>(null);
const [isRefreshing, startTransition] = useTransition();
@@ -159,6 +160,24 @@ export default function SitesTable({
});
}
async function restartSite(siteId: number) {
try {
await api.post(`/site/${siteId}/restart`);
toast({
title: t("siteRestarted"),
description: t("siteRestartedDescription")
});
} catch (e) {
toast({
variant: "destructive",
title: t("siteErrorRestart"),
description: formatAxiosError(e, t("siteErrorRestartDescription"))
});
} finally {
setRestartingSite(null);
}
}
function deleteSite(siteId: number, withResources: boolean) {
startTransition(async () => {
await api
@@ -526,6 +545,20 @@ export default function SitesTable({
</DropdownMenuItem>
</Link>
<DropdownMenuSeparator />
{siteRow.type === "newt" && (
<>
<DropdownMenuItem
onClick={() =>
setRestartingSite(siteRow)
}
>
<span className="text-orange-500">
{t("siteRestartButton")}
</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
<DropdownMenuItem
onClick={() => {
setSelectedSite(siteRow);
@@ -654,6 +687,28 @@ export default function SitesTable({
</CredenzaContent>
</Credenza>
{restartingSite && (
<ConfirmDeleteDialog
open={Boolean(restartingSite)}
setOpen={(val) => {
if (!val) setRestartingSite(null);
}}
dialog={
<p>
{t.rich("siteRestartDialogMessage", {
name: restartingSite.name,
b: (chunks) => <b>{chunks}</b>
})}
</p>
}
buttonText={t("siteRestartButton")}
onConfirm={() => restartSite(restartingSite.id)}
string={restartingSite.name}
warningText={t("siteRestartWarning")}
title={t("siteRestartTitle")}
/>
)}
{selectedSite && (
<ConfirmDeleteDialog
open={isDeleteModalOpen}