mirror of
https://github.com/fosrl/pangolin.git
synced 2026-07-01 18:13:49 +00:00
rework ui
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user