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
+6 -8
View File
@@ -2388,23 +2388,21 @@
"sidebarRemoteExitNodes": "Remote Nodes", "sidebarRemoteExitNodes": "Remote Nodes",
"remoteExitNodeId": "ID", "remoteExitNodeId": "ID",
"remoteExitNodeSecretKey": "Secret", "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", "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.", "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)", "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", "remoteExitNodeNetworkingSubnetsLoadError": "Failed to load subnets",
"remoteExitNodeNetworkingSubnetsSaveError": "Failed to save subnets",
"remoteExitNodeNetworkingLabelsTitle": "Preference Labels", "remoteExitNodeNetworkingLabelsTitle": "Preference Labels",
"remoteExitNodeNetworkingLabelsDescription": "Sites with these labels will be enforced to connect through this remote exit node.", "remoteExitNodeNetworkingLabelsDescription": "Sites with these labels will be enforced to connect through this remote exit node.",
"remoteExitNodeNetworkingLabelsButtonText": "Select labels...", "remoteExitNodeNetworkingLabelsButtonText": "Select labels...",
"remoteExitNodeNetworkingLabelsSearchPlaceholder": "Search labels...", "remoteExitNodeNetworkingLabelsSearchPlaceholder": "Search labels...",
"remoteExitNodeNetworkingLabelsSave": "Save Labels",
"remoteExitNodeNetworkingLabelsSaveSuccessTitle": "Labels saved",
"remoteExitNodeNetworkingLabelsSaveSuccessDescription": "Preference labels have been updated successfully.",
"remoteExitNodeNetworkingLabelsLoadError": "Failed to load labels", "remoteExitNodeNetworkingLabelsLoadError": "Failed to load labels",
"remoteExitNodeNetworkingLabelsSaveError": "Failed to save labels",
"remoteExitNodeCreate": { "remoteExitNodeCreate": {
"title": "Create Remote Node", "title": "Create Remote Node",
"description": "Create a new self-hosted remote relay and proxy server node", "description": "Create a new self-hosted remote relay and proxy server node",
@@ -3,14 +3,18 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { import {
SettingsContainer, SettingsContainer,
SettingsFormCell,
SettingsFormGrid,
SettingsSection, SettingsSection,
SettingsSectionBody, SettingsSectionBody,
SettingsSectionDescription, SettingsSectionDescription,
SettingsSectionFooter, SettingsSectionFooter,
SettingsSectionForm,
SettingsSectionHeader, SettingsSectionHeader,
SettingsSectionTitle SettingsSectionTitle
} from "@app/components/Settings"; } from "@app/components/Settings";
import { Button } from "@app/components/ui/button"; import { Button } from "@app/components/ui/button";
import { Label } from "@app/components/ui/label";
import { createApiClient, formatAxiosError } from "@app/lib/api"; import { createApiClient, formatAxiosError } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext"; import { useEnvContext } from "@app/hooks/useEnvContext";
import { toast } from "@app/hooks/useToast"; import { toast } from "@app/hooks/useToast";
@@ -47,13 +51,13 @@ export default function NetworkingPage() {
const [subnets, setSubnets] = useState<Tag[]>([]); const [subnets, setSubnets] = useState<Tag[]>([]);
const [activeTagIndex, setActiveTagIndex] = useState<number | null>(null); const [activeTagIndex, setActiveTagIndex] = useState<number | null>(null);
const [loadingSubnets, setLoadingSubnets] = useState(true); const [loadingSubnets, setLoadingSubnets] = useState(true);
const [savingSubnets, setSavingSubnets] = useState(false);
// Labels state // Labels state
const [selectedLabels, setSelectedLabels] = useState<TagValue[]>([]); const [selectedLabels, setSelectedLabels] = useState<TagValue[]>([]);
const [labelSearchQuery, setLabelSearchQuery] = useState(""); const [labelSearchQuery, setLabelSearchQuery] = useState("");
const [loadingLabels, setLoadingLabels] = useState(true); const [loadingLabels, setLoadingLabels] = useState(true);
const [savingLabels, setSavingLabels] = useState(false);
const [saving, setSaving] = useState(false);
const [debouncedLabelQuery] = useDebounce(labelSearchQuery, 150); const [debouncedLabelQuery] = useDebounce(labelSearchQuery, 150);
@@ -135,17 +139,27 @@ export default function NetworkingPage() {
loadLabels(); loadLabels();
}, [remoteExitNode.remoteExitNodeId]); }, [remoteExitNode.remoteExitNodeId]);
const handleSaveSubnets = async () => { const handleSave = async () => {
setSavingSubnets(true); setSaving(true);
try { try {
await api.post<AxiosResponse<SetRemoteExitNodeResourcesResponse>>( await Promise.all([
`/org/${orgId}/remote-exit-node/${remoteExitNode.remoteExitNodeId}/resources`, api.post<
{ destinations: subnets.map((s) => s.text) } 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({ toast({
title: t("remoteExitNodeNetworkingSubnetsSaveSuccessTitle"), title: t("remoteExitNodeNetworkingSaveSuccessTitle"),
description: t( description: t(
"remoteExitNodeNetworkingSubnetsSaveSuccessDescription" "remoteExitNodeNetworkingSaveSuccessDescription"
) )
}); });
} catch (error) { } catch (error) {
@@ -154,38 +168,10 @@ export default function NetworkingPage() {
title: t("error"), title: t("error"),
description: description:
formatAxiosError(error) || formatAxiosError(error) ||
t("remoteExitNodeNetworkingSubnetsSaveError") t("remoteExitNodeNetworkingSaveError")
}); });
} finally { } finally {
setSavingSubnets(false); setSaving(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);
} }
}; };
@@ -194,73 +180,93 @@ export default function NetworkingPage() {
<SettingsSection> <SettingsSection>
<SettingsSectionHeader> <SettingsSectionHeader>
<SettingsSectionTitle> <SettingsSectionTitle>
{t("remoteExitNodeNetworkingSubnetsTitle")} {t("remoteExitNodeNetworkingTitle")}
</SettingsSectionTitle> </SettingsSectionTitle>
<SettingsSectionDescription> <SettingsSectionDescription>
{t.rich("remoteExitNodeNetworkingSubnetsDescription", { {t("remoteExitNodeNetworkingDescription")}
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>
</SettingsSectionDescription> </SettingsSectionDescription>
</SettingsSectionHeader> </SettingsSectionHeader>
<SettingsSectionBody> <SettingsSectionBody>
<TagInput <SettingsSectionForm variant="half">
tags={subnets} <SettingsFormGrid>
setTags={setSubnets} <SettingsFormCell span="half">
placeholder={t( <div className="grid gap-2">
"remoteExitNodeNetworkingSubnetsPlaceholder" <Label>
)} {t(
validateTag={(tag) => cidrRegex.test(tag.trim())} "remoteExitNodeNetworkingSubnetsTitle"
activeTagIndex={activeTagIndex} )}
setActiveTagIndex={setActiveTagIndex} </Label>
disabled={loadingSubnets} <TagInput
allowDuplicates={false} tags={subnets}
inlineTags={true} 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> </SettingsSectionBody>
<SettingsSectionFooter> <SettingsSectionFooter>
<Button onClick={handleSaveSubnets} loading={savingSubnets}> <Button onClick={handleSave} loading={saving}>
{t("remoteExitNodeNetworkingSubnetsSave")} {t("remoteExitNodeNetworkingSave")}
</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> </Button>
</SettingsSectionFooter> </SettingsSectionFooter>
</SettingsSection> </SettingsSection>
@@ -43,7 +43,6 @@ import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { tierMatrix, TierFeature } from "@server/lib/billing/tierMatrix"; import { tierMatrix, TierFeature } from "@server/lib/billing/tierMatrix";
import { Button as ButtonUI } from "@/components/ui/button"; import { Button as ButtonUI } from "@/components/ui/button";
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert"; import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
const GeneralFormSchema = z.object({ const GeneralFormSchema = z.object({
name: z.string().nonempty("Name is required"), name: z.string().nonempty("Name is required"),
@@ -73,23 +72,6 @@ export default function GeneralPage() {
const [activeCidrTagIndex, setActiveCidrTagIndex] = useState<number | null>( const [activeCidrTagIndex, setActiveCidrTagIndex] = useState<number | null>(
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; const orgAutoUpdate = org.org.settingsEnableGlobalNewtAutoUpdate ?? false;
@@ -367,52 +349,6 @@ export default function GeneralPage() {
</Button> </Button>
</SettingsSectionFooter> </SettingsSectionFooter>
</SettingsSection> </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> </SettingsContainer>
); );
} }
+55
View File
@@ -107,6 +107,7 @@ export default function SitesTable({
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [deleteWithResources, setDeleteWithResources] = useState(false); const [deleteWithResources, setDeleteWithResources] = useState(false);
const [selectedSite, setSelectedSite] = useState<SiteRow | null>(null); const [selectedSite, setSelectedSite] = useState<SiteRow | null>(null);
const [restartingSite, setRestartingSite] = useState<SiteRow | null>(null);
const [resourcesDialogSite, setResourcesDialogSite] = const [resourcesDialogSite, setResourcesDialogSite] =
useState<SiteRow | null>(null); useState<SiteRow | null>(null);
const [isRefreshing, startTransition] = useTransition(); 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) { function deleteSite(siteId: number, withResources: boolean) {
startTransition(async () => { startTransition(async () => {
await api await api
@@ -526,6 +545,20 @@ export default function SitesTable({
</DropdownMenuItem> </DropdownMenuItem>
</Link> </Link>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
{siteRow.type === "newt" && (
<>
<DropdownMenuItem
onClick={() =>
setRestartingSite(siteRow)
}
>
<span className="text-orange-500">
{t("siteRestartButton")}
</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
<DropdownMenuItem <DropdownMenuItem
onClick={() => { onClick={() => {
setSelectedSite(siteRow); setSelectedSite(siteRow);
@@ -654,6 +687,28 @@ export default function SitesTable({
</CredenzaContent> </CredenzaContent>
</Credenza> </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 && ( {selectedSite && (
<ConfirmDeleteDialog <ConfirmDeleteDialog
open={isDeleteModalOpen} open={isDeleteModalOpen}