mirror of
https://github.com/fosrl/pangolin.git
synced 2026-08-15 16:59:35 +02:00
Merge branch 'dev' into feature/region-rules
This commit is contained in:
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -13,9 +13,10 @@ import { GetOrgResponse } from "@server/routers/org";
|
||||
import OrgProvider from "@app/providers/OrgProvider";
|
||||
import { cache } from "react";
|
||||
import ResourceInfoBox from "@app/components/ResourceInfoBox";
|
||||
import { GetSiteResponse } from "@server/routers/site";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
interface ResourceLayoutProps {
|
||||
children: React.ReactNode;
|
||||
params: Promise<{ niceId: string; orgId: string }>;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -55,7 +55,8 @@ import {
|
||||
SettingsSectionTitle,
|
||||
SettingsSectionDescription,
|
||||
SettingsSectionBody,
|
||||
SettingsSectionFooter
|
||||
SettingsSectionFooter,
|
||||
SettingsSectionForm
|
||||
} from "@app/components/Settings";
|
||||
import { ListResourceRulesResponse } from "@server/routers/resource/listResourceRules";
|
||||
import { SwitchInput } from "@app/components/SwitchInput";
|
||||
@@ -115,7 +116,12 @@ export default function ResourceRules(props: {
|
||||
const [rulesToRemove, setRulesToRemove] = useState<number[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [pageLoading, setPageLoading] = useState(true);
|
||||
const [rulesEnabled, setRulesEnabled] = useState(resource.applyRules);
|
||||
const [rulesEnabled, setRulesEnabled] = useState(resource.applyRules ?? false);
|
||||
|
||||
useEffect(() => {
|
||||
setRulesEnabled(resource.applyRules);
|
||||
}, [resource.applyRules]);
|
||||
|
||||
const [openCountrySelect, setOpenCountrySelect] = useState(false);
|
||||
const [countrySelectValue, setCountrySelectValue] = useState("");
|
||||
const [openAddRuleCountrySelect, setOpenAddRuleCountrySelect] =
|
||||
@@ -151,7 +157,7 @@ export default function ResourceRules(props: {
|
||||
resolver: zodResolver(addRuleSchema),
|
||||
defaultValues: {
|
||||
action: "ACCEPT",
|
||||
match: "IP",
|
||||
match: "PATH",
|
||||
value: ""
|
||||
}
|
||||
});
|
||||
@@ -186,7 +192,7 @@ export default function ResourceRules(props: {
|
||||
// Normalize ASN value
|
||||
if (data.match === "ASN") {
|
||||
const originalValue = data.value.toUpperCase();
|
||||
|
||||
|
||||
// Handle special "ALL" case
|
||||
if (originalValue === "ALL" || originalValue === "AS0") {
|
||||
data.value = "ALL";
|
||||
@@ -585,9 +591,7 @@ export default function ResourceRules(props: {
|
||||
</SelectItem>
|
||||
)}
|
||||
{isMaxmindAsnAvailable && (
|
||||
<SelectItem value="ASN">
|
||||
{RuleMatch.ASN}
|
||||
</SelectItem>
|
||||
<SelectItem value="ASN">{RuleMatch.ASN}</SelectItem>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
@@ -680,9 +684,7 @@ export default function ResourceRules(props: {
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="min-w-[200px] p-0">
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder="Search ASNs or enter custom..."
|
||||
/>
|
||||
<CommandInput placeholder="Search ASNs or enter custom..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
No ASN found. Enter a custom ASN below.
|
||||
@@ -691,7 +693,9 @@ export default function ResourceRules(props: {
|
||||
{MAJOR_ASNS.map((asn) => (
|
||||
<CommandItem
|
||||
key={asn.code}
|
||||
value={asn.name + " " + asn.code}
|
||||
value={
|
||||
asn.name + " " + asn.code
|
||||
}
|
||||
onSelect={() => {
|
||||
updateRule(
|
||||
row.original.ruleId,
|
||||
@@ -943,7 +947,7 @@ export default function ResourceRules(props: {
|
||||
<SwitchInput
|
||||
id="rules-toggle"
|
||||
label={t("rulesEnable")}
|
||||
defaultChecked={rulesEnabled}
|
||||
checked={rulesEnabled}
|
||||
onCheckedChange={(val) => setRulesEnabled(val)}
|
||||
/>
|
||||
</div>
|
||||
@@ -1171,8 +1175,8 @@ export default function ResourceRules(props: {
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
) : addRuleForm.watch(
|
||||
"match"
|
||||
) === "ASN" ? (
|
||||
"match"
|
||||
) === "ASN" ? (
|
||||
<Popover
|
||||
open={
|
||||
openAddRuleAsnSelect
|
||||
@@ -1201,21 +1205,27 @@ export default function ResourceRules(props: {
|
||||
field.value
|
||||
)
|
||||
?.name +
|
||||
" (" +
|
||||
field.value +
|
||||
")" || field.value
|
||||
" (" +
|
||||
field.value +
|
||||
")" ||
|
||||
field.value
|
||||
: "Select ASN"}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-full p-0">
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder="Search ASNs or enter custom..."
|
||||
/>
|
||||
<CommandInput placeholder="Search ASNs or enter custom..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
No ASN found. Use the custom input below.
|
||||
No
|
||||
ASN
|
||||
found.
|
||||
Use
|
||||
the
|
||||
custom
|
||||
input
|
||||
below.
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{MAJOR_ASNS.map(
|
||||
@@ -1227,7 +1237,9 @@ export default function ResourceRules(props: {
|
||||
asn.code
|
||||
}
|
||||
value={
|
||||
asn.name + " " + asn.code
|
||||
asn.name +
|
||||
" " +
|
||||
asn.code
|
||||
}
|
||||
onSelect={() => {
|
||||
field.onChange(
|
||||
@@ -1253,6 +1265,7 @@ export default function ResourceRules(props: {
|
||||
{
|
||||
asn.code
|
||||
}
|
||||
|
||||
)
|
||||
</CommandItem>
|
||||
)
|
||||
@@ -1263,14 +1276,32 @@ export default function ResourceRules(props: {
|
||||
<div className="border-t p-2">
|
||||
<Input
|
||||
placeholder="Enter custom ASN (e.g., AS15169)"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
const value = e.currentTarget.value
|
||||
.toUpperCase()
|
||||
.replace(/^AS/, "");
|
||||
if (/^\d+$/.test(value)) {
|
||||
field.onChange("AS" + value);
|
||||
setOpenAddRuleAsnSelect(false);
|
||||
onKeyDown={(
|
||||
e
|
||||
) => {
|
||||
if (
|
||||
e.key ===
|
||||
"Enter"
|
||||
) {
|
||||
const value =
|
||||
e.currentTarget.value
|
||||
.toUpperCase()
|
||||
.replace(
|
||||
/^AS/,
|
||||
""
|
||||
);
|
||||
if (
|
||||
/^\d+$/.test(
|
||||
value
|
||||
)
|
||||
) {
|
||||
field.onChange(
|
||||
"AS" +
|
||||
value
|
||||
);
|
||||
setOpenAddRuleAsnSelect(
|
||||
false
|
||||
);
|
||||
}
|
||||
}
|
||||
}}
|
||||
@@ -1480,17 +1511,16 @@ export default function ResourceRules(props: {
|
||||
</Table>
|
||||
</div>
|
||||
</SettingsSectionBody>
|
||||
<SettingsSectionFooter>
|
||||
<Button
|
||||
onClick={saveAllSettings}
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
>
|
||||
{t("saveAllSettings")}
|
||||
</Button>
|
||||
</SettingsSectionFooter>
|
||||
</SettingsSection>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
onClick={saveAllSettings}
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
>
|
||||
{t("saveAllSettings")}
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import CopyTextBox from "@app/components/CopyTextBox";
|
||||
import DomainPicker from "@app/components/DomainPicker";
|
||||
import HealthCheckDialog from "@app/components/HealthCheckDialog";
|
||||
import {
|
||||
PathMatchDisplay,
|
||||
PathMatchModal,
|
||||
PathRewriteDisplay,
|
||||
PathRewriteModal
|
||||
} from "@app/components/PathMatchRenameModal";
|
||||
import {
|
||||
SettingsContainer,
|
||||
SettingsSection,
|
||||
@@ -9,6 +18,10 @@ import {
|
||||
SettingsSectionHeader,
|
||||
SettingsSectionTitle
|
||||
} from "@app/components/Settings";
|
||||
import HeaderTitle from "@app/components/SettingsSectionTitle";
|
||||
import { StrategySelect } from "@app/components/StrategySelect";
|
||||
import { ResourceTargetAddressItem } from "@app/components/resource-target-address-item";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
@@ -18,22 +31,7 @@ import {
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from "@app/components/ui/form";
|
||||
import HeaderTitle from "@app/components/SettingsSectionTitle";
|
||||
import { z } from "zod";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { ListSitesResponse } from "@server/routers/site";
|
||||
import { formatAxiosError } from "@app/lib/api";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { Resource } from "@server/db";
|
||||
import { StrategySelect } from "@app/components/StrategySelect";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -41,48 +39,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from "@app/components/ui/select";
|
||||
import { ListDomainsResponse } from "@server/routers/domain";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList
|
||||
} from "@app/components/ui/command";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger
|
||||
} from "@app/components/ui/popover";
|
||||
import { CaretSortIcon, CheckIcon } from "@radix-ui/react-icons";
|
||||
import { cn } from "@app/lib/cn";
|
||||
import {
|
||||
ArrowRight,
|
||||
CircleCheck,
|
||||
CircleX,
|
||||
Info,
|
||||
MoveRight,
|
||||
Plus,
|
||||
Settings,
|
||||
SquareArrowOutUpRight
|
||||
} from "lucide-react";
|
||||
import CopyTextBox from "@app/components/CopyTextBox";
|
||||
import Link from "next/link";
|
||||
import { useTranslations } from "next-intl";
|
||||
import DomainPicker from "@app/components/DomainPicker";
|
||||
import { build } from "@server/build";
|
||||
import { ContainersSelector } from "@app/components/ContainersSelector";
|
||||
import {
|
||||
ColumnDef,
|
||||
getFilteredRowModel,
|
||||
getSortedRowModel,
|
||||
getPaginationRowModel,
|
||||
getCoreRowModel,
|
||||
useReactTable,
|
||||
flexRender,
|
||||
Row
|
||||
} from "@tanstack/react-table";
|
||||
import { Switch } from "@app/components/ui/switch";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -91,30 +48,53 @@ import {
|
||||
TableHeader,
|
||||
TableRow
|
||||
} from "@app/components/ui/table";
|
||||
import { Switch } from "@app/components/ui/switch";
|
||||
import { ArrayElement } from "@server/types/ArrayElement";
|
||||
import { isTargetValid } from "@server/lib/validators";
|
||||
import { ListTargetsResponse } from "@server/routers/target";
|
||||
import { DockerManager, DockerState } from "@app/lib/docker";
|
||||
import { parseHostTarget } from "@app/lib/parseHostTarget";
|
||||
import { toASCII, toUnicode } from "punycode";
|
||||
import { DomainRow } from "@app/components/DomainsTable";
|
||||
import { finalizeSubdomainSanitize } from "@app/lib/subdomain-utils";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger
|
||||
} from "@app/components/ui/tooltip";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import { DockerManager, DockerState } from "@app/lib/docker";
|
||||
import { orgQueries } from "@app/lib/queries";
|
||||
import { finalizeSubdomainSanitize } from "@app/lib/subdomain-utils";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { build } from "@server/build";
|
||||
import { Resource } from "@server/db";
|
||||
import { isTargetValid } from "@server/lib/validators";
|
||||
import { ListTargetsResponse } from "@server/routers/target";
|
||||
import { ListRemoteExitNodesResponse } from "@server/routers/remoteExitNode/types";
|
||||
import { ArrayElement } from "@server/types/ArrayElement";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
PathMatchDisplay,
|
||||
PathMatchModal,
|
||||
PathRewriteDisplay,
|
||||
PathRewriteModal
|
||||
} from "@app/components/PathMatchRenameModal";
|
||||
import { Badge } from "@app/components/ui/badge";
|
||||
import HealthCheckDialog from "@app/components/HealthCheckDialog";
|
||||
import { SwitchInput } from "@app/components/SwitchInput";
|
||||
ColumnDef,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getFilteredRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable
|
||||
} from "@tanstack/react-table";
|
||||
import { AxiosResponse } from "axios";
|
||||
import {
|
||||
CircleCheck,
|
||||
CircleX,
|
||||
Info,
|
||||
InfoIcon,
|
||||
Plus,
|
||||
Settings,
|
||||
SquareArrowOutUpRight
|
||||
} from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Link from "next/link";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { toASCII } from "punycode";
|
||||
import { useEffect, useMemo, useState, useCallback } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
const baseResourceFormSchema = z.object({
|
||||
name: z.string().min(1).max(255),
|
||||
@@ -204,10 +184,6 @@ const addTargetSchema = z
|
||||
}
|
||||
);
|
||||
|
||||
type BaseResourceFormValues = z.infer<typeof baseResourceFormSchema>;
|
||||
type HttpResourceFormValues = z.infer<typeof httpResourceFormSchema>;
|
||||
type TcpUdpResourceFormValues = z.infer<typeof tcpUdpResourceFormSchema>;
|
||||
|
||||
type ResourceType = "http" | "raw";
|
||||
|
||||
interface ResourceTypeOption {
|
||||
@@ -217,7 +193,7 @@ interface ResourceTypeOption {
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
type LocalTarget = Omit<
|
||||
export type LocalTarget = Omit<
|
||||
ArrayElement<ListTargetsResponse["targets"]> & {
|
||||
new?: boolean;
|
||||
updated?: boolean;
|
||||
@@ -233,18 +209,21 @@ export default function Page() {
|
||||
const router = useRouter();
|
||||
const t = useTranslations();
|
||||
|
||||
const [loadingPage, setLoadingPage] = useState(true);
|
||||
const [sites, setSites] = useState<ListSitesResponse["sites"]>([]);
|
||||
const [baseDomains, setBaseDomains] = useState<
|
||||
{ domainId: string; baseDomain: string }[]
|
||||
const { data: sites = [], isLoading: loadingPage } = useQuery(
|
||||
orgQueries.sites({ orgId: orgId as string })
|
||||
);
|
||||
|
||||
const [remoteExitNodes, setRemoteExitNodes] = useState<
|
||||
ListRemoteExitNodesResponse["remoteExitNodes"]
|
||||
>([]);
|
||||
const [loadingExitNodes, setLoadingExitNodes] = useState(build === "saas");
|
||||
|
||||
const [createLoading, setCreateLoading] = useState(false);
|
||||
const [showSnippets, setShowSnippets] = useState(false);
|
||||
const [niceId, setNiceId] = useState<string>("");
|
||||
|
||||
// Target management state
|
||||
const [targets, setTargets] = useState<LocalTarget[]>([]);
|
||||
const [targetsToRemove, setTargetsToRemove] = useState<number[]>([]);
|
||||
const [dockerStates, setDockerStates] = useState<Map<number, DockerState>>(
|
||||
new Map()
|
||||
);
|
||||
@@ -253,6 +232,27 @@ export default function Page() {
|
||||
useState<LocalTarget | null>(null);
|
||||
const [healthCheckDialogOpen, setHealthCheckDialogOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (build !== "saas") return;
|
||||
|
||||
const fetchExitNodes = async () => {
|
||||
try {
|
||||
const res = await api.get<
|
||||
AxiosResponse<ListRemoteExitNodesResponse>
|
||||
>(`/org/${orgId}/remote-exit-nodes`);
|
||||
if (res && res.status === 200) {
|
||||
setRemoteExitNodes(res.data.data.remoteExitNodes);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch remote exit nodes:", e);
|
||||
} finally {
|
||||
setLoadingExitNodes(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchExitNodes();
|
||||
}, [orgId]);
|
||||
|
||||
const [isAdvancedMode, setIsAdvancedMode] = useState(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
const saved = localStorage.getItem("create-advanced-mode");
|
||||
@@ -280,6 +280,7 @@ export default function Page() {
|
||||
method: isHttp ? "http" : null,
|
||||
port: 0,
|
||||
siteId: sites.length > 0 ? sites[0].siteId : 0,
|
||||
siteName: sites.length > 0 ? sites[0].name : "",
|
||||
path: isHttp ? null : null,
|
||||
pathMatchType: isHttp ? null : null,
|
||||
rewritePath: isHttp ? null : null,
|
||||
@@ -318,15 +319,24 @@ export default function Page() {
|
||||
},
|
||||
...(!env.flags.allowRawResources
|
||||
? []
|
||||
: [
|
||||
{
|
||||
id: "raw" as ResourceType,
|
||||
title: t("resourceRaw"),
|
||||
description: t("resourceRawDescription")
|
||||
}
|
||||
])
|
||||
: build === "saas" && remoteExitNodes.length === 0
|
||||
? []
|
||||
: [
|
||||
{
|
||||
id: "raw" as ResourceType,
|
||||
title: t("resourceRaw"),
|
||||
description:
|
||||
build == "saas"
|
||||
? t("resourceRawDescriptionCloud")
|
||||
: t("resourceRawDescription")
|
||||
}
|
||||
])
|
||||
];
|
||||
|
||||
// In saas mode with no exit nodes, force HTTP
|
||||
const showTypeSelector =
|
||||
build !== "saas" || (!loadingExitNodes && remoteExitNodes.length > 0);
|
||||
|
||||
const baseForm = useForm({
|
||||
resolver: zodResolver(baseResourceFormSchema),
|
||||
defaultValues: {
|
||||
@@ -405,102 +415,60 @@ export default function Page() {
|
||||
setDockerStates((prev) => new Map(prev.set(siteId, dockerState)));
|
||||
};
|
||||
|
||||
const refreshContainersForSite = async (siteId: number) => {
|
||||
const dockerManager = new DockerManager(api, siteId);
|
||||
const containers = await dockerManager.fetchContainers();
|
||||
const refreshContainersForSite = useCallback(
|
||||
async (siteId: number) => {
|
||||
const dockerManager = new DockerManager(api, siteId);
|
||||
const containers = await dockerManager.fetchContainers();
|
||||
|
||||
setDockerStates((prev) => {
|
||||
const newMap = new Map(prev);
|
||||
const existingState = newMap.get(siteId);
|
||||
if (existingState) {
|
||||
newMap.set(siteId, { ...existingState, containers });
|
||||
}
|
||||
return newMap;
|
||||
setDockerStates((prev) => {
|
||||
const newMap = new Map(prev);
|
||||
const existingState = newMap.get(siteId);
|
||||
if (existingState) {
|
||||
newMap.set(siteId, { ...existingState, containers });
|
||||
}
|
||||
return newMap;
|
||||
});
|
||||
},
|
||||
[api]
|
||||
);
|
||||
|
||||
const getDockerStateForSite = useCallback(
|
||||
(siteId: number): DockerState => {
|
||||
return (
|
||||
dockerStates.get(siteId) || {
|
||||
isEnabled: false,
|
||||
isAvailable: false,
|
||||
containers: []
|
||||
}
|
||||
);
|
||||
},
|
||||
[dockerStates]
|
||||
);
|
||||
|
||||
const removeTarget = useCallback((targetId: number) => {
|
||||
setTargets((prevTargets) => {
|
||||
return prevTargets.filter((target) => target.targetId !== targetId);
|
||||
});
|
||||
};
|
||||
}, []);
|
||||
|
||||
const getDockerStateForSite = (siteId: number): DockerState => {
|
||||
return (
|
||||
dockerStates.get(siteId) || {
|
||||
isEnabled: false,
|
||||
isAvailable: false,
|
||||
containers: []
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
async function addTarget(data: z.infer<typeof addTargetSchema>) {
|
||||
const site = sites.find((site) => site.siteId === data.siteId);
|
||||
|
||||
const isHttp = baseForm.watch("http");
|
||||
|
||||
const newTarget: LocalTarget = {
|
||||
...data,
|
||||
path: isHttp ? data.path || null : null,
|
||||
pathMatchType: isHttp ? data.pathMatchType || null : null,
|
||||
rewritePath: isHttp ? data.rewritePath || null : null,
|
||||
rewritePathType: isHttp ? data.rewritePathType || null : null,
|
||||
siteType: site?.type || null,
|
||||
enabled: true,
|
||||
targetId: new Date().getTime(),
|
||||
new: true,
|
||||
resourceId: 0, // Will be set when resource is created
|
||||
priority: isHttp ? data.priority || 100 : 100, // Default priority
|
||||
hcEnabled: false,
|
||||
hcPath: null,
|
||||
hcMethod: null,
|
||||
hcInterval: null,
|
||||
hcTimeout: null,
|
||||
hcHeaders: null,
|
||||
hcScheme: null,
|
||||
hcHostname: null,
|
||||
hcPort: null,
|
||||
hcFollowRedirects: null,
|
||||
hcHealth: "unknown",
|
||||
hcStatus: null,
|
||||
hcMode: null,
|
||||
hcUnhealthyInterval: null,
|
||||
hcTlsServerName: null
|
||||
};
|
||||
|
||||
setTargets([...targets, newTarget]);
|
||||
addTargetForm.reset({
|
||||
ip: "",
|
||||
method: baseForm.watch("http") ? "http" : null,
|
||||
port: "" as any as number,
|
||||
path: null,
|
||||
pathMatchType: null,
|
||||
rewritePath: null,
|
||||
rewritePathType: null,
|
||||
priority: isHttp ? 100 : undefined
|
||||
});
|
||||
}
|
||||
|
||||
const removeTarget = (targetId: number) => {
|
||||
setTargets([
|
||||
...targets.filter((target) => target.targetId !== targetId)
|
||||
]);
|
||||
|
||||
if (!targets.find((target) => target.targetId === targetId)?.new) {
|
||||
setTargetsToRemove([...targetsToRemove, targetId]);
|
||||
}
|
||||
};
|
||||
|
||||
async function updateTarget(targetId: number, data: Partial<LocalTarget>) {
|
||||
const site = sites.find((site) => site.siteId === data.siteId);
|
||||
setTargets(
|
||||
targets.map((target) =>
|
||||
target.targetId === targetId
|
||||
? {
|
||||
...target,
|
||||
...data,
|
||||
updated: true,
|
||||
siteType: site ? site.type : target.siteType
|
||||
}
|
||||
: target
|
||||
)
|
||||
);
|
||||
}
|
||||
const updateTarget = useCallback(
|
||||
(targetId: number, data: Partial<LocalTarget>) => {
|
||||
setTargets((prevTargets) => {
|
||||
const site = sites.find((site) => site.siteId === data.siteId);
|
||||
return prevTargets.map((target) =>
|
||||
target.targetId === targetId
|
||||
? {
|
||||
...target,
|
||||
...data,
|
||||
updated: true,
|
||||
siteType: site ? site.type : target.siteType
|
||||
}
|
||||
: target
|
||||
);
|
||||
});
|
||||
},
|
||||
[sites]
|
||||
);
|
||||
|
||||
async function onSubmit() {
|
||||
setCreateLoading(true);
|
||||
@@ -630,7 +598,10 @@ export default function Page() {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("resourceErrorCreate"),
|
||||
description: t("resourceErrorCreateMessageDescription")
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
t("resourceErrorCreateMessageDescription")
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
@@ -638,82 +609,18 @@ export default function Page() {
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
setLoadingPage(true);
|
||||
// Initialize Docker for newt sites
|
||||
for (const site of sites) {
|
||||
if (site.type === "newt") {
|
||||
initializeDockerForSite(site.siteId);
|
||||
}
|
||||
}
|
||||
|
||||
const fetchSites = async () => {
|
||||
const res = await api
|
||||
.get<
|
||||
AxiosResponse<ListSitesResponse>
|
||||
>(`/org/${orgId}/sites/`)
|
||||
.catch((e) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("sitesErrorFetch"),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
t("sitesErrorFetchDescription")
|
||||
)
|
||||
});
|
||||
});
|
||||
|
||||
if (res?.status === 200) {
|
||||
setSites(res.data.data.sites);
|
||||
|
||||
// Initialize Docker for newt sites
|
||||
for (const site of res.data.data.sites) {
|
||||
if (site.type === "newt") {
|
||||
initializeDockerForSite(site.siteId);
|
||||
}
|
||||
}
|
||||
|
||||
// If there's only one site, set it as the default in the form
|
||||
if (res.data.data.sites.length) {
|
||||
addTargetForm.setValue(
|
||||
"siteId",
|
||||
res.data.data.sites[0].siteId
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const fetchDomains = async () => {
|
||||
const res = await api
|
||||
.get<
|
||||
AxiosResponse<ListDomainsResponse>
|
||||
>(`/org/${orgId}/domains/`)
|
||||
.catch((e) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("domainsErrorFetch"),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
t("domainsErrorFetchDescription")
|
||||
)
|
||||
});
|
||||
});
|
||||
|
||||
if (res?.status === 200) {
|
||||
const rawDomains = res.data.data.domains as DomainRow[];
|
||||
const domains = rawDomains.map((domain) => ({
|
||||
...domain,
|
||||
baseDomain: toUnicode(domain.baseDomain)
|
||||
}));
|
||||
setBaseDomains(domains);
|
||||
// if (domains.length) {
|
||||
// httpForm.setValue("domainId", domains[0].domainId);
|
||||
// }
|
||||
}
|
||||
};
|
||||
|
||||
await fetchSites();
|
||||
await fetchDomains();
|
||||
|
||||
setLoadingPage(false);
|
||||
};
|
||||
|
||||
load();
|
||||
}, []);
|
||||
// If there's at least one site, set it as the default in the form
|
||||
if (sites.length > 0) {
|
||||
addTargetForm.setValue("siteId", sites[0].siteId);
|
||||
}
|
||||
}, [sites]);
|
||||
|
||||
function TargetHealthCheck(targetId: number, config: any) {
|
||||
setTargets(
|
||||
@@ -729,16 +636,15 @@ export default function Page() {
|
||||
);
|
||||
}
|
||||
|
||||
const openHealthCheckDialog = (target: LocalTarget) => {
|
||||
const openHealthCheckDialog = useCallback((target: LocalTarget) => {
|
||||
console.log(target);
|
||||
setSelectedTargetForHealthCheck(target);
|
||||
setHealthCheckDialogOpen(true);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const getColumns = (): ColumnDef<LocalTarget>[] => {
|
||||
const baseColumns: ColumnDef<LocalTarget>[] = [];
|
||||
const isHttp = baseForm.watch("http");
|
||||
const isHttp = baseForm.watch("http");
|
||||
|
||||
const columns = useMemo((): ColumnDef<LocalTarget>[] => {
|
||||
const priorityColumn: ColumnDef<LocalTarget> = {
|
||||
id: "priority",
|
||||
header: () => (
|
||||
@@ -875,7 +781,7 @@ export default function Page() {
|
||||
trigger={
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex items-center gap-2 p-2 w-full text-left cursor-pointer max-w-[200px]"
|
||||
className="flex items-center gap-2 p-2 w-full text-left cursor-pointer max-w-50"
|
||||
>
|
||||
<PathMatchDisplay
|
||||
value={{
|
||||
@@ -899,7 +805,7 @@ export default function Page() {
|
||||
trigger={
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full max-w-[200px]"
|
||||
className="w-full max-w-50"
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
{t("matchPath")}
|
||||
@@ -918,216 +824,17 @@ export default function Page() {
|
||||
const addressColumn: ColumnDef<LocalTarget> = {
|
||||
accessorKey: "address",
|
||||
header: () => <span className="p-3">{t("address")}</span>,
|
||||
cell: ({ row }) => {
|
||||
const selectedSite = sites.find(
|
||||
(site) => site.siteId === row.original.siteId
|
||||
);
|
||||
|
||||
const handleContainerSelectForTarget = (
|
||||
hostname: string,
|
||||
port?: number
|
||||
) => {
|
||||
updateTarget(row.original.targetId, {
|
||||
...row.original,
|
||||
ip: hostname,
|
||||
...(port && { port: port })
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center w-full">
|
||||
<div className="flex items-center w-full justify-start py-0 space-x-2 px-0 cursor-default border border-input rounded-md">
|
||||
{selectedSite &&
|
||||
selectedSite.type === "newt" &&
|
||||
(() => {
|
||||
const dockerState = getDockerStateForSite(
|
||||
selectedSite.siteId
|
||||
);
|
||||
return (
|
||||
<ContainersSelector
|
||||
site={selectedSite}
|
||||
containers={dockerState.containers}
|
||||
isAvailable={
|
||||
dockerState.isAvailable
|
||||
}
|
||||
onContainerSelect={
|
||||
handleContainerSelectForTarget
|
||||
}
|
||||
onRefresh={() =>
|
||||
refreshContainersForSite(
|
||||
selectedSite.siteId
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
role="combobox"
|
||||
className={cn(
|
||||
"w-[180px] justify-between text-sm border-r pr-4 rounded-none h-8 hover:bg-transparent",
|
||||
!row.original.siteId &&
|
||||
"text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<span className="truncate max-w-[150px]">
|
||||
{row.original.siteId
|
||||
? selectedSite?.name
|
||||
: t("siteSelect")}
|
||||
</span>
|
||||
<CaretSortIcon className="ml-2h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0 w-[180px]">
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder={t("siteSearch")}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
{t("siteNotFound")}
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{sites.map((site) => (
|
||||
<CommandItem
|
||||
key={site.siteId}
|
||||
value={`${site.siteId}:${site.name}`}
|
||||
onSelect={() =>
|
||||
updateTarget(
|
||||
row.original
|
||||
.targetId,
|
||||
{
|
||||
siteId: site.siteId
|
||||
}
|
||||
)
|
||||
}
|
||||
>
|
||||
<CheckIcon
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
site.siteId ===
|
||||
row.original
|
||||
.siteId
|
||||
? "opacity-100"
|
||||
: "opacity-0"
|
||||
)}
|
||||
/>
|
||||
{site.name}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{isHttp && (
|
||||
<Select
|
||||
defaultValue={row.original.method ?? "http"}
|
||||
onValueChange={(value) =>
|
||||
updateTarget(row.original.targetId, {
|
||||
...row.original,
|
||||
method: value
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8 px-2 w-[70px] border-none bg-transparent shadow-none focus:ring-0 focus:outline-none focus-visible:ring-0 data-[state=open]:bg-transparent">
|
||||
{row.original.method || "http"}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="http">
|
||||
http
|
||||
</SelectItem>
|
||||
<SelectItem value="https">
|
||||
https
|
||||
</SelectItem>
|
||||
<SelectItem value="h2c">h2c</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
|
||||
{isHttp && (
|
||||
<div className="flex items-center justify-center px-2 h-9">
|
||||
{"://"}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Input
|
||||
defaultValue={row.original.ip}
|
||||
placeholder="Host"
|
||||
className="flex-1 min-w-[120px] pl-0 border-none placeholder-gray-400"
|
||||
onBlur={(e) => {
|
||||
const input = e.target.value.trim();
|
||||
const hasProtocol =
|
||||
/^(https?|h2c):\/\//.test(input);
|
||||
const hasPort = /:\d+(?:\/|$)/.test(input);
|
||||
|
||||
if (hasProtocol || hasPort) {
|
||||
const parsed = parseHostTarget(input);
|
||||
if (parsed) {
|
||||
updateTarget(
|
||||
row.original.targetId,
|
||||
{
|
||||
...row.original,
|
||||
method: hasProtocol
|
||||
? parsed.protocol
|
||||
: row.original.method,
|
||||
ip: parsed.host,
|
||||
port: hasPort
|
||||
? parsed.port
|
||||
: row.original.port
|
||||
}
|
||||
);
|
||||
} else {
|
||||
updateTarget(
|
||||
row.original.targetId,
|
||||
{
|
||||
...row.original,
|
||||
ip: input
|
||||
}
|
||||
);
|
||||
}
|
||||
} else {
|
||||
updateTarget(row.original.targetId, {
|
||||
...row.original,
|
||||
ip: input
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="flex items-center justify-center px-2 h-9">
|
||||
{":"}
|
||||
</div>
|
||||
<Input
|
||||
placeholder="Port"
|
||||
defaultValue={
|
||||
row.original.port === 0
|
||||
? ""
|
||||
: row.original.port
|
||||
}
|
||||
className="w-[75px] pl-0 border-none placeholder-gray-400"
|
||||
onBlur={(e) => {
|
||||
const value = parseInt(e.target.value, 10);
|
||||
if (!isNaN(value) && value > 0) {
|
||||
updateTarget(row.original.targetId, {
|
||||
...row.original,
|
||||
port: value
|
||||
});
|
||||
} else {
|
||||
updateTarget(row.original.targetId, {
|
||||
...row.original,
|
||||
port: 0
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => (
|
||||
<ResourceTargetAddressItem
|
||||
isHttp={isHttp}
|
||||
orgId={orgId!.toString()}
|
||||
// sites={sites}
|
||||
getDockerStateForSite={getDockerStateForSite}
|
||||
proxyTarget={row.original}
|
||||
refreshContainersForSite={refreshContainersForSite}
|
||||
updateTarget={updateTarget}
|
||||
/>
|
||||
),
|
||||
size: 400,
|
||||
minSize: 350,
|
||||
maxSize: 500
|
||||
@@ -1186,7 +893,7 @@ export default function Page() {
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={noPathMatch}
|
||||
className="w-full max-w-[200px]"
|
||||
className="w-full max-w-50"
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
{t("rewritePath")}
|
||||
@@ -1265,9 +972,17 @@ export default function Page() {
|
||||
actionsColumn
|
||||
];
|
||||
}
|
||||
};
|
||||
|
||||
const columns = getColumns();
|
||||
}, [
|
||||
isAdvancedMode,
|
||||
isHttp,
|
||||
sites,
|
||||
updateTarget,
|
||||
getDockerStateForSite,
|
||||
refreshContainersForSite,
|
||||
openHealthCheckDialog,
|
||||
removeTarget,
|
||||
t
|
||||
]);
|
||||
|
||||
const table = useReactTable({
|
||||
data: targets,
|
||||
@@ -1312,6 +1027,36 @@ export default function Page() {
|
||||
</SettingsSectionTitle>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
{showTypeSelector &&
|
||||
resourceTypes.length > 1 && (
|
||||
<>
|
||||
<div className="mb-2">
|
||||
<span className="text-sm font-medium">
|
||||
{t("type")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<StrategySelect
|
||||
options={resourceTypes}
|
||||
defaultValue="http"
|
||||
onChange={(value) => {
|
||||
baseForm.setValue(
|
||||
"http",
|
||||
value === "http"
|
||||
);
|
||||
// Update method default when switching resource type
|
||||
addTargetForm.setValue(
|
||||
"method",
|
||||
value === "http"
|
||||
? "http"
|
||||
: null
|
||||
);
|
||||
}}
|
||||
cols={2}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<SettingsSectionForm>
|
||||
<Form {...baseForm}>
|
||||
<form
|
||||
@@ -1348,35 +1093,6 @@ export default function Page() {
|
||||
</form>
|
||||
</Form>
|
||||
</SettingsSectionForm>
|
||||
|
||||
{resourceTypes.length > 1 && (
|
||||
<>
|
||||
<div className="mb-2">
|
||||
<span className="text-sm font-medium">
|
||||
{t("type")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<StrategySelect
|
||||
options={resourceTypes}
|
||||
defaultValue="http"
|
||||
onChange={(value) => {
|
||||
baseForm.setValue(
|
||||
"http",
|
||||
value === "http"
|
||||
);
|
||||
// Update method default when switching resource type
|
||||
addTargetForm.setValue(
|
||||
"method",
|
||||
value === "http"
|
||||
? "http"
|
||||
: null
|
||||
);
|
||||
}}
|
||||
cols={2}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</SettingsSectionBody>
|
||||
</SettingsSection>
|
||||
|
||||
@@ -1395,7 +1111,12 @@ export default function Page() {
|
||||
<SettingsSectionBody>
|
||||
<DomainPicker
|
||||
orgId={orgId as string}
|
||||
warnOnProvidedDomain={
|
||||
remoteExitNodes.length >= 1
|
||||
}
|
||||
onDomainChange={(res) => {
|
||||
if (!res) return;
|
||||
|
||||
httpForm.setValue(
|
||||
"subdomain",
|
||||
res.subdomain
|
||||
@@ -1647,9 +1368,6 @@ export default function Page() {
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
{/* <TableCaption> */}
|
||||
{/* {t('targetNoOneDescription')} */}
|
||||
{/* </TableCaption> */}
|
||||
</Table>
|
||||
</div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
@@ -1682,7 +1400,7 @@ export default function Page() {
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-center p-4">
|
||||
<div className="text-center py-8 border-2 border-dashed border-muted rounded-lg p-4">
|
||||
<p className="text-muted-foreground mb-4">
|
||||
{t("targetNoOne")}
|
||||
</p>
|
||||
@@ -1848,7 +1566,7 @@ export default function Page() {
|
||||
|
||||
<Link
|
||||
className="text-sm text-primary flex items-center gap-1"
|
||||
href="https://docs.pangolin.net/manage/resources/tcp-udp-resources"
|
||||
href="https://docs.pangolin.net/manage/resources/public/raw-resources"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { ResourceRow } from "@app/components/ProxyResourcesTable";
|
||||
import ProxyResourcesTable from "@app/components/ProxyResourcesTable";
|
||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||
import ProxyResourcesBanner from "@app/components/ProxyResourcesBanner";
|
||||
import { internal } from "@app/lib/api";
|
||||
import { authCookieHeader } from "@app/lib/api/cookies";
|
||||
import OrgProvider from "@app/providers/OrgProvider";
|
||||
@@ -15,7 +16,7 @@ import { cache } from "react";
|
||||
|
||||
export interface ProxyResourcesPageProps {
|
||||
params: Promise<{ orgId: string }>;
|
||||
searchParams: Promise<{ view?: string }>;
|
||||
searchParams: Promise<Record<string, string>>;
|
||||
}
|
||||
|
||||
export default async function ProxyResourcesPage(
|
||||
@@ -23,14 +24,22 @@ export default async function ProxyResourcesPage(
|
||||
) {
|
||||
const params = await props.params;
|
||||
const t = await getTranslations();
|
||||
const searchParams = new URLSearchParams(await props.searchParams);
|
||||
|
||||
let resources: ListResourcesResponse["resources"] = [];
|
||||
let pagination: ListResourcesResponse["pagination"] = {
|
||||
total: 0,
|
||||
page: 1,
|
||||
pageSize: 20
|
||||
};
|
||||
try {
|
||||
const res = await internal.get<AxiosResponse<ListResourcesResponse>>(
|
||||
`/org/${params.orgId}/resources`,
|
||||
`/org/${params.orgId}/resources?${searchParams.toString()}`,
|
||||
await authCookieHeader()
|
||||
);
|
||||
resources = res.data.data.resources;
|
||||
const responseData = res.data.data;
|
||||
resources = responseData.resources;
|
||||
pagination = responseData.pagination;
|
||||
} catch (e) {}
|
||||
|
||||
let siteResources: ListAllSiteResourcesByOrgResponse["siteResources"] = [];
|
||||
@@ -97,13 +106,16 @@ export default async function ProxyResourcesPage(
|
||||
description={t("proxyResourceDescription")}
|
||||
/>
|
||||
|
||||
<ProxyResourcesBanner />
|
||||
|
||||
<OrgProvider org={org}>
|
||||
<ProxyResourcesTable
|
||||
resources={resourceRows}
|
||||
orgId={params.orgId}
|
||||
defaultSort={{
|
||||
id: "name",
|
||||
desc: false
|
||||
rowCount={pagination.total}
|
||||
pagination={{
|
||||
pageIndex: pagination.page - 1,
|
||||
pageSize: pagination.pageSize
|
||||
}}
|
||||
/>
|
||||
</OrgProvider>
|
||||
|
||||
Reference in New Issue
Block a user