mirror of
https://github.com/fosrl/pangolin.git
synced 2026-05-27 11:12:55 +00:00
Consolidate target components
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -69,7 +69,11 @@ import type { ListSitesResponse } from "@server/routers/site";
|
||||
import { CreateTargetResponse } from "@server/routers/target";
|
||||
import { ListTargetsResponse } from "@server/routers/target/listTargets";
|
||||
import { ArrayElement } from "@server/types/ArrayElement";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
LocalTarget,
|
||||
ProxyResourceTargetsForm
|
||||
} from "@app/app/[orgId]/settings/resources/proxy/ProxyResourceTargetsForm";
|
||||
import {
|
||||
ColumnDef,
|
||||
flexRender,
|
||||
@@ -106,15 +110,6 @@ const targetsSettingsSchema = z.object({
|
||||
stickySession: z.boolean()
|
||||
});
|
||||
|
||||
type LocalTarget = Omit<
|
||||
ArrayElement<ListTargetsResponse["targets"]> & {
|
||||
new?: boolean;
|
||||
updated?: boolean;
|
||||
siteType: string | null;
|
||||
},
|
||||
"protocol"
|
||||
>;
|
||||
|
||||
export default function ReverseProxyTargetsPage(props: {
|
||||
params: Promise<{ resourceId: number; orgId: string }>;
|
||||
}) {
|
||||
@@ -135,6 +130,7 @@ export default function ReverseProxyTargetsPage(props: {
|
||||
<SettingsContainer>
|
||||
<ProxyResourceTargetsForm
|
||||
orgId={params.orgId}
|
||||
isHttp={resource.http}
|
||||
initialTargets={remoteTargets}
|
||||
resource={resource}
|
||||
updateResource={updateResource}
|
||||
@@ -157,954 +153,6 @@ export default function ReverseProxyTargetsPage(props: {
|
||||
);
|
||||
}
|
||||
|
||||
function ProxyResourceTargetsForm({
|
||||
orgId,
|
||||
initialTargets,
|
||||
resource,
|
||||
updateResource
|
||||
}: {
|
||||
initialTargets: LocalTarget[];
|
||||
orgId: string;
|
||||
resource: GetResourceResponse;
|
||||
updateResource: ResourceContextType["updateResource"];
|
||||
}) {
|
||||
const t = useTranslations();
|
||||
const api = createApiClient(useEnvContext());
|
||||
|
||||
const [targets, setTargets] = useState<LocalTarget[]>(initialTargets);
|
||||
const [targetsToRemove, setTargetsToRemove] = useState<number[]>([]);
|
||||
|
||||
const { data: polledTargets } = useQuery({
|
||||
...resourceQueries.resourceTargets({
|
||||
resourceId: resource.resourceId
|
||||
}),
|
||||
refetchInterval: 10_000
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!polledTargets) return;
|
||||
setTargets((prev) =>
|
||||
prev.map((t) => {
|
||||
const fresh = polledTargets.find(
|
||||
(p) => p.targetId === t.targetId
|
||||
);
|
||||
if (!fresh) return t;
|
||||
return {
|
||||
...t,
|
||||
hcHealth: fresh.hcHealth,
|
||||
hcEnabled: t.updated ? t.hcEnabled : fresh.hcEnabled
|
||||
};
|
||||
})
|
||||
);
|
||||
}, [polledTargets]);
|
||||
const [dockerStates, setDockerStates] = useState<Map<number, DockerState>>(
|
||||
new Map()
|
||||
);
|
||||
const [healthCheckDialogOpen, setHealthCheckDialogOpen] = useState(false);
|
||||
const [selectedTargetForHealthCheck, setSelectedTargetForHealthCheck] =
|
||||
useState<LocalTarget | null>(null);
|
||||
|
||||
const [bgDestination, setBgDestination] = useState("");
|
||||
const [bgDestinationPort, setBgDestinationPort] = useState("");
|
||||
const [bgSiteId, setBgSiteId] = useState<number | null>(null);
|
||||
const [bgTargetId, setBgTargetId] = useState<number | null>(null);
|
||||
|
||||
const initializeDockerForSite = async (siteId: number) => {
|
||||
if (dockerStates.has(siteId)) {
|
||||
return; // Already initialized
|
||||
}
|
||||
|
||||
const dockerManager = new DockerManager(api, siteId);
|
||||
const dockerState = await dockerManager.initializeDocker();
|
||||
|
||||
setDockerStates((prev) => new Map(prev.set(siteId, dockerState)));
|
||||
};
|
||||
|
||||
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;
|
||||
});
|
||||
},
|
||||
[api]
|
||||
);
|
||||
|
||||
const getDockerStateForSite = useCallback(
|
||||
(siteId: number): DockerState => {
|
||||
return (
|
||||
dockerStates.get(siteId) || {
|
||||
isEnabled: false,
|
||||
isAvailable: false,
|
||||
containers: []
|
||||
}
|
||||
);
|
||||
},
|
||||
[dockerStates]
|
||||
);
|
||||
|
||||
const [isAdvancedMode, setIsAdvancedMode] = useState(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
const saved = localStorage.getItem("proxy-advanced-mode");
|
||||
return saved === "true";
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
const isHttp = resource.http;
|
||||
|
||||
const removeTarget = useCallback((targetId: number) => {
|
||||
setTargets((prevTargets) => {
|
||||
const targetToRemove = prevTargets.find(
|
||||
(target) => target.targetId === targetId
|
||||
);
|
||||
if (targetToRemove && !targetToRemove.new) {
|
||||
setTargetsToRemove((prev) => [...prev, targetId]);
|
||||
}
|
||||
return prevTargets.filter((target) => target.targetId !== targetId);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const { data: sites = [] } = useQuery(
|
||||
orgQueries.sites({
|
||||
orgId
|
||||
})
|
||||
);
|
||||
|
||||
const { data: bgTargetsResponse } = useQuery({
|
||||
queryKey: ["browserGatewayTargets", resource.resourceId, orgId],
|
||||
queryFn: async () => {
|
||||
const res = await api.get(
|
||||
`/org/${orgId}/resource/${resource.resourceId}/browser-gateway-targets`
|
||||
);
|
||||
return res.data.data as {
|
||||
targets: Array<{
|
||||
browserGatewayTargetId: number;
|
||||
resourceId: number;
|
||||
siteId: number;
|
||||
type: string;
|
||||
destination: string;
|
||||
destinationPort: number;
|
||||
}>;
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!bgTargetsResponse?.targets?.length) return;
|
||||
const bgt = bgTargetsResponse.targets[0];
|
||||
setBgDestination(bgt.destination);
|
||||
setBgDestinationPort(String(bgt.destinationPort));
|
||||
setBgSiteId(bgt.siteId);
|
||||
setBgTargetId(bgt.browserGatewayTargetId);
|
||||
}, [bgTargetsResponse]);
|
||||
|
||||
useEffect(() => {
|
||||
if (sites.length > 0 && bgSiteId === null) {
|
||||
setBgSiteId(sites[0].siteId);
|
||||
}
|
||||
}, [sites, bgSiteId]);
|
||||
|
||||
const updateTarget = useCallback(
|
||||
(targetId: number, data: Partial<LocalTarget>) => {
|
||||
setTargets((prevTargets) => {
|
||||
return prevTargets.map((target) =>
|
||||
target.targetId === targetId
|
||||
? {
|
||||
...target,
|
||||
...data,
|
||||
updated: true
|
||||
}
|
||||
: target
|
||||
);
|
||||
});
|
||||
},
|
||||
[sites]
|
||||
);
|
||||
|
||||
const openHealthCheckDialog = useCallback((target: LocalTarget) => {
|
||||
setSelectedTargetForHealthCheck(target);
|
||||
setHealthCheckDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
const columns = useMemo((): ColumnDef<LocalTarget>[] => {
|
||||
const priorityColumn: ColumnDef<LocalTarget> = {
|
||||
id: "priority",
|
||||
header: () => (
|
||||
<div className="flex items-center gap-2">
|
||||
{t("priority")}
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<Info className="h-4 w-4 text-muted-foreground" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs">
|
||||
<p>{t("priorityDescription")}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full">
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
max="1000"
|
||||
onClick={(e) => e.currentTarget.focus()}
|
||||
defaultValue={row.original.priority || 100}
|
||||
className="w-full max-w-20"
|
||||
onBlur={(e) => {
|
||||
const value = parseInt(e.target.value, 10);
|
||||
if (value >= 1 && value <= 1000) {
|
||||
updateTarget(row.original.targetId, {
|
||||
...row.original,
|
||||
priority: value
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
size: 120,
|
||||
minSize: 100,
|
||||
maxSize: 150
|
||||
};
|
||||
|
||||
const healthCheckColumn: ColumnDef<LocalTarget> = {
|
||||
accessorKey: "healthCheck",
|
||||
header: () => <span className="p-3">{t("healthCheck")}</span>,
|
||||
cell: ({ row }) => {
|
||||
const status = row.original.hcHealth || "unknown";
|
||||
|
||||
const getStatusText = (status: string) => {
|
||||
switch (status) {
|
||||
case "healthy":
|
||||
return t("healthCheckHealthy");
|
||||
case "unhealthy":
|
||||
return t("healthCheckUnhealthy");
|
||||
case "unknown":
|
||||
default:
|
||||
return t("healthCheckUnknown");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full">
|
||||
{row.original.siteType === "newt" ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex items-center gap-2 w-full text-left cursor-pointer"
|
||||
onClick={() =>
|
||||
openHealthCheckDialog(row.original)
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={`flex items-center gap-2 ${status === "healthy" ? "text-green-500" : status === "unhealthy" ? "text-destructive" : "text-neutral-500"}`}
|
||||
>
|
||||
<div
|
||||
className={`w-2 h-2 rounded-full ${status === "healthy" ? "bg-green-500" : status === "unhealthy" ? "bg-destructive" : "bg-neutral-500"}`}
|
||||
></div>
|
||||
{getStatusText(status)}
|
||||
</div>
|
||||
</Button>
|
||||
) : (
|
||||
<span>-</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
size: 200,
|
||||
minSize: 180,
|
||||
maxSize: 250
|
||||
};
|
||||
|
||||
const matchPathColumn: ColumnDef<LocalTarget> = {
|
||||
accessorKey: "path",
|
||||
header: () => <span className="p-3">{t("matchPath")}</span>,
|
||||
cell: ({ row }) => {
|
||||
const hasPathMatch = !!(
|
||||
row.original.path || row.original.pathMatchType
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full">
|
||||
{hasPathMatch ? (
|
||||
<PathMatchModal
|
||||
value={{
|
||||
path: row.original.path,
|
||||
pathMatchType: row.original.pathMatchType
|
||||
}}
|
||||
onChange={(config) =>
|
||||
updateTarget(
|
||||
row.original.targetId,
|
||||
config.path === null &&
|
||||
config.pathMatchType === null
|
||||
? {
|
||||
...config,
|
||||
rewritePath: null,
|
||||
rewritePathType: null
|
||||
}
|
||||
: config
|
||||
)
|
||||
}
|
||||
trigger={
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex items-center gap-2 p-2 w-full text-left cursor-pointer max-w-[200px]"
|
||||
>
|
||||
<PathMatchDisplay
|
||||
value={{
|
||||
path: row.original.path,
|
||||
pathMatchType:
|
||||
row.original.pathMatchType
|
||||
}}
|
||||
/>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<PathMatchModal
|
||||
value={{
|
||||
path: row.original.path,
|
||||
pathMatchType: row.original.pathMatchType
|
||||
}}
|
||||
onChange={(config) =>
|
||||
updateTarget(
|
||||
row.original.targetId,
|
||||
config.path === null &&
|
||||
config.pathMatchType === null
|
||||
? {
|
||||
...config,
|
||||
rewritePath: null,
|
||||
rewritePathType: null
|
||||
}
|
||||
: config
|
||||
)
|
||||
}
|
||||
trigger={
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full max-w-[200px]"
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
{t("matchPath")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
size: 200,
|
||||
minSize: 180,
|
||||
maxSize: 200
|
||||
};
|
||||
|
||||
const addressColumn: ColumnDef<LocalTarget> = {
|
||||
accessorKey: "address",
|
||||
header: () => <span className="p-3">{t("address")}</span>,
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<ResourceTargetAddressItem
|
||||
isHttp={isHttp}
|
||||
orgId={orgId}
|
||||
getDockerStateForSite={getDockerStateForSite}
|
||||
proxyTarget={row.original}
|
||||
refreshContainersForSite={refreshContainersForSite}
|
||||
updateTarget={updateTarget}
|
||||
/>
|
||||
);
|
||||
},
|
||||
size: 400,
|
||||
minSize: 350,
|
||||
maxSize: 500
|
||||
};
|
||||
|
||||
const rewritePathColumn: ColumnDef<LocalTarget> = {
|
||||
accessorKey: "rewritePath",
|
||||
header: () => <span className="p-3">{t("rewritePath")}</span>,
|
||||
cell: ({ row }) => {
|
||||
const hasRewritePath = !!(
|
||||
row.original.rewritePath || row.original.rewritePathType
|
||||
);
|
||||
const noPathMatch =
|
||||
!row.original.path && !row.original.pathMatchType;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full">
|
||||
{hasRewritePath && !noPathMatch ? (
|
||||
<PathRewriteModal
|
||||
value={{
|
||||
rewritePath: row.original.rewritePath,
|
||||
rewritePathType:
|
||||
row.original.rewritePathType
|
||||
}}
|
||||
onChange={(config) =>
|
||||
updateTarget(row.original.targetId, config)
|
||||
}
|
||||
trigger={
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex items-center gap-2 p-2 w-full text-left cursor-pointer max-w-[200px]"
|
||||
disabled={noPathMatch}
|
||||
>
|
||||
<PathRewriteDisplay
|
||||
value={{
|
||||
rewritePath:
|
||||
row.original.rewritePath,
|
||||
rewritePathType:
|
||||
row.original.rewritePathType
|
||||
}}
|
||||
/>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<PathRewriteModal
|
||||
value={{
|
||||
rewritePath: row.original.rewritePath,
|
||||
rewritePathType:
|
||||
row.original.rewritePathType
|
||||
}}
|
||||
onChange={(config) =>
|
||||
updateTarget(row.original.targetId, config)
|
||||
}
|
||||
trigger={
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={noPathMatch}
|
||||
className="w-full max-w-[200px]"
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
{t("rewritePath")}
|
||||
</Button>
|
||||
}
|
||||
disabled={noPathMatch}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
size: 200,
|
||||
minSize: 180,
|
||||
maxSize: 200
|
||||
};
|
||||
|
||||
const enabledColumn: ColumnDef<LocalTarget> = {
|
||||
accessorKey: "enabled",
|
||||
header: () => <span className="p-3">{t("enabled")}</span>,
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center justify-center w-full">
|
||||
<Switch
|
||||
defaultChecked={row.original.enabled}
|
||||
onCheckedChange={(val) =>
|
||||
updateTarget(row.original.targetId, {
|
||||
...row.original,
|
||||
enabled: val
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
size: 100,
|
||||
minSize: 80,
|
||||
maxSize: 120
|
||||
};
|
||||
|
||||
const actionsColumn: ColumnDef<LocalTarget> = {
|
||||
id: "actions",
|
||||
header: () => <span className="p-3">{t("actions")}</span>,
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center w-full">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => removeTarget(row.original.targetId)}
|
||||
>
|
||||
{t("delete")}
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
size: 100,
|
||||
minSize: 80,
|
||||
maxSize: 120
|
||||
};
|
||||
|
||||
if (isAdvancedMode) {
|
||||
const columns = [
|
||||
addressColumn,
|
||||
healthCheckColumn,
|
||||
enabledColumn,
|
||||
actionsColumn
|
||||
];
|
||||
|
||||
// Only include path-related columns for HTTP resources
|
||||
if (isHttp) {
|
||||
columns.unshift(matchPathColumn);
|
||||
columns.splice(3, 0, rewritePathColumn, priorityColumn);
|
||||
}
|
||||
|
||||
return columns;
|
||||
} else {
|
||||
return [
|
||||
addressColumn,
|
||||
healthCheckColumn,
|
||||
enabledColumn,
|
||||
actionsColumn
|
||||
];
|
||||
}
|
||||
}, [
|
||||
isAdvancedMode,
|
||||
isHttp,
|
||||
sites,
|
||||
updateTarget,
|
||||
getDockerStateForSite,
|
||||
refreshContainersForSite,
|
||||
openHealthCheckDialog,
|
||||
removeTarget,
|
||||
t
|
||||
]);
|
||||
|
||||
function addNewTarget() {
|
||||
const isHttp = resource.http;
|
||||
|
||||
const newTarget: LocalTarget = {
|
||||
targetId: -Date.now(), // Use negative timestamp as temporary ID
|
||||
ip: "",
|
||||
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,
|
||||
rewritePathType: isHttp ? null : null,
|
||||
priority: isHttp ? 100 : 100,
|
||||
enabled: true,
|
||||
resourceId: resource.resourceId,
|
||||
hcEnabled: false,
|
||||
hcPath: null,
|
||||
hcMethod: null,
|
||||
hcInterval: null,
|
||||
hcTimeout: null,
|
||||
hcHeaders: null,
|
||||
hcFollowRedirects: null,
|
||||
hcScheme: null,
|
||||
hcHostname: null,
|
||||
hcPort: null,
|
||||
hcHealth: "unknown",
|
||||
hcStatus: null,
|
||||
hcMode: null,
|
||||
hcUnhealthyInterval: null,
|
||||
hcTlsServerName: null,
|
||||
hcHealthyThreshold: null,
|
||||
hcUnhealthyThreshold: null,
|
||||
siteType: sites.length > 0 ? sites[0].type : null,
|
||||
new: true,
|
||||
updated: false
|
||||
};
|
||||
|
||||
setTargets((prev) => [...prev, newTarget]);
|
||||
}
|
||||
|
||||
function updateTargetHealthCheck(targetId: number, config: any) {
|
||||
setTargets(
|
||||
targets.map((target) =>
|
||||
target.targetId === targetId
|
||||
? {
|
||||
...target,
|
||||
...config,
|
||||
updated: true
|
||||
}
|
||||
: target
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const table = useReactTable({
|
||||
data: targets,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
getRowId: (row) => String(row.targetId),
|
||||
state: {
|
||||
pagination: {
|
||||
pageIndex: 0,
|
||||
pageSize: 1000
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
useEffect(() => {
|
||||
const newtSites = sites.filter((site) => site.type === "newt");
|
||||
for (const site of newtSites) {
|
||||
initializeDockerForSite(site.siteId);
|
||||
}
|
||||
}, [sites]);
|
||||
|
||||
// Save advanced mode preference to localStorage
|
||||
useEffect(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.setItem(
|
||||
"proxy-advanced-mode",
|
||||
isAdvancedMode.toString()
|
||||
);
|
||||
}
|
||||
}, [isAdvancedMode]);
|
||||
|
||||
const [, formAction, isSubmitting] = useActionState(saveTargets, null);
|
||||
|
||||
async function saveTargets() {
|
||||
// Validate that no targets have blank IPs or invalid ports
|
||||
const targetsWithInvalidFields = targets.filter(
|
||||
(target) =>
|
||||
!target.ip ||
|
||||
target.ip.trim() === "" ||
|
||||
!target.port ||
|
||||
target.port <= 0 ||
|
||||
isNaN(target.port)
|
||||
);
|
||||
console.log(targetsWithInvalidFields);
|
||||
if (targetsWithInvalidFields.length > 0) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("targetErrorInvalidIp"),
|
||||
description: t("targetErrorInvalidIpDescription")
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await Promise.all(
|
||||
targetsToRemove.map((targetId) =>
|
||||
api.delete(`/target/${targetId}`)
|
||||
)
|
||||
);
|
||||
|
||||
// Save targets
|
||||
for (const target of targets) {
|
||||
const data: any = {
|
||||
ip: target.ip,
|
||||
port: target.port,
|
||||
method: target.method,
|
||||
enabled: target.enabled,
|
||||
siteId: target.siteId,
|
||||
hcEnabled: target.hcEnabled,
|
||||
hcPath: target.hcPath || null,
|
||||
hcScheme: target.hcScheme || null,
|
||||
hcHostname: target.hcHostname || null,
|
||||
hcPort: target.hcPort || null,
|
||||
hcInterval: target.hcInterval || null,
|
||||
hcTimeout: target.hcTimeout || null,
|
||||
hcHeaders: target.hcHeaders || null,
|
||||
hcFollowRedirects: target.hcFollowRedirects || null,
|
||||
hcMethod: target.hcMethod || null,
|
||||
hcStatus: target.hcStatus || null,
|
||||
hcUnhealthyInterval: target.hcUnhealthyInterval || null,
|
||||
hcMode: target.hcMode || null,
|
||||
hcTlsServerName: target.hcTlsServerName,
|
||||
hcHealthyThreshold: target.hcHealthyThreshold || null,
|
||||
hcUnhealthyThreshold: target.hcUnhealthyThreshold || null
|
||||
};
|
||||
|
||||
// Only include path-related fields for HTTP resources
|
||||
if (resource.http) {
|
||||
data.path = target.path;
|
||||
data.pathMatchType = target.pathMatchType;
|
||||
data.rewritePath = target.rewritePath;
|
||||
data.rewritePathType = target.rewritePathType;
|
||||
data.priority = target.priority;
|
||||
}
|
||||
|
||||
if (target.new) {
|
||||
const res = await api.put<
|
||||
AxiosResponse<CreateTargetResponse>
|
||||
>(`/resource/${resource.resourceId}/target`, data);
|
||||
target.targetId = res.data.data.targetId;
|
||||
target.new = false;
|
||||
} else if (target.updated) {
|
||||
await api.post(`/target/${target.targetId}`, data);
|
||||
target.updated = false;
|
||||
}
|
||||
}
|
||||
|
||||
toast({
|
||||
title:
|
||||
targets.length === 0
|
||||
? t("targetTargetsCleared")
|
||||
: t("settingsUpdated"),
|
||||
description:
|
||||
targets.length === 0
|
||||
? t("targetTargetsClearedDescription")
|
||||
: t("settingsUpdatedDescription")
|
||||
});
|
||||
|
||||
setTargetsToRemove([]);
|
||||
router.refresh();
|
||||
await queryClient.invalidateQueries(
|
||||
resourceQueries.resourceTargets({
|
||||
resourceId: resource.resourceId
|
||||
})
|
||||
);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("settingsErrorUpdate"),
|
||||
description: formatAxiosError(
|
||||
err,
|
||||
t("settingsErrorUpdateDescription")
|
||||
)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>{t("targets")}</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t("targetsDescription")}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
{targets.length > 0 ? (
|
||||
<>
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table
|
||||
.getHeaderGroups()
|
||||
.map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map(
|
||||
(header) => {
|
||||
const isActionsColumn =
|
||||
header.column
|
||||
.id ===
|
||||
"actions";
|
||||
return (
|
||||
<TableHead
|
||||
key={
|
||||
header.id
|
||||
}
|
||||
className={
|
||||
isActionsColumn
|
||||
? "sticky right-0 z-10 w-auto min-w-fit bg-card"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header
|
||||
.column
|
||||
.columnDef
|
||||
.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</TableHead>
|
||||
);
|
||||
}
|
||||
)}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows?.length ? (
|
||||
table
|
||||
.getRowModel()
|
||||
.rows.map((row) => (
|
||||
<TableRow key={row.id}>
|
||||
{row
|
||||
.getVisibleCells()
|
||||
.map((cell) => {
|
||||
const isActionsColumn =
|
||||
cell.column
|
||||
.id ===
|
||||
"actions";
|
||||
return (
|
||||
<TableCell
|
||||
key={
|
||||
cell.id
|
||||
}
|
||||
className={
|
||||
isActionsColumn
|
||||
? "sticky right-0 z-10 w-auto min-w-fit bg-card"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
{flexRender(
|
||||
cell
|
||||
.column
|
||||
.columnDef
|
||||
.cell,
|
||||
cell.getContext()
|
||||
)}
|
||||
</TableCell>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={columns.length}
|
||||
className="h-24 text-center"
|
||||
>
|
||||
{t("targetNoOne")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
{/* <TableCaption> */}
|
||||
{/* {t('targetNoOneDescription')} */}
|
||||
{/* </TableCaption> */}
|
||||
</Table>
|
||||
</div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center justify-between w-full gap-2">
|
||||
<Button
|
||||
onClick={addNewTarget}
|
||||
variant="outline"
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
{t("addTarget")}
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id="advanced-mode-toggle"
|
||||
checked={isAdvancedMode}
|
||||
onCheckedChange={setIsAdvancedMode}
|
||||
/>
|
||||
<label
|
||||
htmlFor="advanced-mode-toggle"
|
||||
className="text-sm"
|
||||
>
|
||||
{t("advancedMode")}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<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>
|
||||
<Button onClick={addNewTarget} variant="outline">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
{t("addTarget")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{build === "saas" &&
|
||||
targets.length > 1 &&
|
||||
new Set(targets.map((t) => t.siteId)).size > 1 && (
|
||||
<p className="text-sm text-muted-foreground mt-3">
|
||||
{t("proxyMultiSiteRoundRobinNodeHelp")}{" "}
|
||||
<a
|
||||
href="https://docs.pangolin.net/manage/resources/public/targets#distributing-sites-load-across-servers"
|
||||
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>
|
||||
)}
|
||||
</SettingsSectionBody>
|
||||
|
||||
<form className="self-end mt-4" action={formAction}>
|
||||
<Button
|
||||
disabled={isSubmitting}
|
||||
loading={isSubmitting}
|
||||
type="submit"
|
||||
>
|
||||
{t("saveResourceTargets")}
|
||||
</Button>
|
||||
</form>
|
||||
</SettingsSection>
|
||||
|
||||
{selectedTargetForHealthCheck && (
|
||||
<HealthCheckCredenza
|
||||
mode="autoSave"
|
||||
open={healthCheckDialogOpen}
|
||||
setOpen={setHealthCheckDialogOpen}
|
||||
targetAddress={`${selectedTargetForHealthCheck.ip}:${selectedTargetForHealthCheck.port}`}
|
||||
targetMethod={
|
||||
selectedTargetForHealthCheck.method || undefined
|
||||
}
|
||||
initialConfig={{
|
||||
hcEnabled:
|
||||
selectedTargetForHealthCheck.hcEnabled || false,
|
||||
hcPath: selectedTargetForHealthCheck.hcPath || "/",
|
||||
hcMethod:
|
||||
selectedTargetForHealthCheck.hcMethod || "GET",
|
||||
hcInterval:
|
||||
selectedTargetForHealthCheck.hcInterval || 5,
|
||||
hcTimeout: selectedTargetForHealthCheck.hcTimeout || 5,
|
||||
hcHeaders:
|
||||
selectedTargetForHealthCheck.hcHeaders || undefined,
|
||||
hcScheme:
|
||||
selectedTargetForHealthCheck.hcScheme || undefined,
|
||||
hcHostname:
|
||||
selectedTargetForHealthCheck.hcHostname ||
|
||||
selectedTargetForHealthCheck.ip,
|
||||
hcPort:
|
||||
selectedTargetForHealthCheck.hcPort ||
|
||||
selectedTargetForHealthCheck.port,
|
||||
hcFollowRedirects:
|
||||
selectedTargetForHealthCheck.hcFollowRedirects ??
|
||||
true,
|
||||
hcStatus:
|
||||
selectedTargetForHealthCheck.hcStatus || undefined,
|
||||
hcMode: selectedTargetForHealthCheck.hcMode || "http",
|
||||
hcUnhealthyInterval:
|
||||
selectedTargetForHealthCheck.hcUnhealthyInterval ||
|
||||
30,
|
||||
hcTlsServerName:
|
||||
selectedTargetForHealthCheck.hcTlsServerName ||
|
||||
undefined,
|
||||
hcHealthyThreshold:
|
||||
selectedTargetForHealthCheck.hcHealthyThreshold ||
|
||||
1,
|
||||
hcUnhealthyThreshold:
|
||||
selectedTargetForHealthCheck.hcUnhealthyThreshold ||
|
||||
1
|
||||
}}
|
||||
onChanges={async (config) => {
|
||||
if (selectedTargetForHealthCheck) {
|
||||
updateTargetHealthCheck(
|
||||
selectedTargetForHealthCheck.targetId,
|
||||
config
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ProxyResourceHttpForm({
|
||||
resource,
|
||||
updateResource
|
||||
|
||||
@@ -86,6 +86,10 @@ 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 {
|
||||
LocalTarget,
|
||||
ProxyResourceTargetsForm
|
||||
} from "@app/app/[orgId]/settings/resources/proxy/ProxyResourceTargetsForm";
|
||||
import {
|
||||
ColumnDef,
|
||||
flexRender,
|
||||
@@ -206,15 +210,6 @@ const addTargetSchema = z
|
||||
|
||||
type NewResourceType = "http" | "ssh" | "rdp" | "vnc" | "tcp" | "udp";
|
||||
|
||||
export type LocalTarget = Omit<
|
||||
ArrayElement<ListTargetsResponse["targets"]> & {
|
||||
new?: boolean;
|
||||
updated?: boolean;
|
||||
siteType: string | null;
|
||||
},
|
||||
"protocol"
|
||||
>;
|
||||
|
||||
export default function Page() {
|
||||
const { env } = useEnvContext();
|
||||
const api = createApiClient({ env });
|
||||
@@ -238,15 +233,8 @@ export default function Page() {
|
||||
// Resource type state
|
||||
const [resourceType, setResourceType] = useState<NewResourceType>("http");
|
||||
|
||||
// Target management state
|
||||
// Target management state (managed by ProxyResourceTargetsForm; mirrored here for onSubmit)
|
||||
const [targets, setTargets] = useState<LocalTarget[]>([]);
|
||||
const [dockerStates, setDockerStates] = useState<Map<number, DockerState>>(
|
||||
new Map()
|
||||
);
|
||||
|
||||
const [selectedTargetForHealthCheck, setSelectedTargetForHealthCheck] =
|
||||
useState<LocalTarget | null>(null);
|
||||
const [healthCheckDialogOpen, setHealthCheckDialogOpen] = useState(false);
|
||||
|
||||
// SSH-specific state
|
||||
const [sshServerMode, setSshServerMode] = useState<"standard" | "native">(
|
||||
@@ -306,23 +294,6 @@ export default function Page() {
|
||||
fetchExitNodes();
|
||||
}, [orgId]);
|
||||
|
||||
const [isAdvancedMode, setIsAdvancedMode] = useState(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
const saved = localStorage.getItem("create-advanced-mode");
|
||||
return saved === "true";
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.setItem(
|
||||
"create-advanced-mode",
|
||||
isAdvancedMode.toString()
|
||||
);
|
||||
}
|
||||
}, [isAdvancedMode]);
|
||||
|
||||
// Derived flags
|
||||
const isHttpResource = resourceType !== "tcp" && resourceType !== "udp";
|
||||
const isNative = sshServerMode === "native";
|
||||
@@ -334,48 +305,6 @@ export default function Page() {
|
||||
pamMode === "push" &&
|
||||
standardDaemonLocation === "remote";
|
||||
|
||||
function addNewTarget() {
|
||||
const isHttp = resourceType === "http";
|
||||
|
||||
const newTarget: LocalTarget = {
|
||||
targetId: -Date.now(),
|
||||
ip: "",
|
||||
method: isHttp ? "http" : null,
|
||||
port: 0,
|
||||
siteId: sites.length > 0 ? sites[0].siteId : 0,
|
||||
siteName: sites.length > 0 ? sites[0].name : "",
|
||||
path: null,
|
||||
pathMatchType: null,
|
||||
rewritePath: null,
|
||||
rewritePathType: null,
|
||||
priority: isHttp ? 100 : 100,
|
||||
enabled: true,
|
||||
resourceId: 0,
|
||||
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,
|
||||
hcHealthyThreshold: null,
|
||||
hcUnhealthyThreshold: null,
|
||||
siteType: sites.length > 0 ? sites[0].type : null,
|
||||
new: true,
|
||||
updated: false
|
||||
};
|
||||
|
||||
setTargets((prev) => [...prev, newTarget]);
|
||||
}
|
||||
|
||||
// Whether raw (TCP/UDP) resources are available
|
||||
const rawResourcesAllowed =
|
||||
env.flags.allowRawResources &&
|
||||
@@ -417,20 +346,6 @@ export default function Page() {
|
||||
}
|
||||
});
|
||||
|
||||
const addTargetForm = useForm({
|
||||
resolver: zodResolver(addTargetSchema),
|
||||
defaultValues: {
|
||||
ip: "",
|
||||
method: "http",
|
||||
port: "" as any as number,
|
||||
path: null,
|
||||
pathMatchType: null,
|
||||
rewritePath: null,
|
||||
rewritePathType: null,
|
||||
priority: 100
|
||||
} as z.infer<typeof addTargetSchema>
|
||||
});
|
||||
|
||||
// Sync form http field with resourceType
|
||||
useEffect(() => {
|
||||
baseForm.setValue("http", isHttpResource);
|
||||
@@ -470,72 +385,6 @@ export default function Page() {
|
||||
});
|
||||
};
|
||||
|
||||
const initializeDockerForSite = async (siteId: number) => {
|
||||
if (dockerStates.has(siteId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const dockerManager = new DockerManager(api, siteId);
|
||||
const dockerState = await dockerManager.initializeDocker();
|
||||
|
||||
setDockerStates((prev) => new Map(prev.set(siteId, dockerState)));
|
||||
};
|
||||
|
||||
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;
|
||||
});
|
||||
},
|
||||
[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 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);
|
||||
|
||||
@@ -787,402 +636,6 @@ export default function Page() {
|
||||
setCreateLoading(false);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
for (const site of sites) {
|
||||
if (site.type === "newt") {
|
||||
initializeDockerForSite(site.siteId);
|
||||
}
|
||||
}
|
||||
|
||||
if (sites.length > 0) {
|
||||
addTargetForm.setValue("siteId", sites[0].siteId);
|
||||
}
|
||||
}, [sites]);
|
||||
|
||||
function TargetHealthCheck(targetId: number, config: any) {
|
||||
setTargets(
|
||||
targets.map((target) =>
|
||||
target.targetId === targetId
|
||||
? {
|
||||
...target,
|
||||
...config,
|
||||
updated: true
|
||||
}
|
||||
: target
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const openHealthCheckDialog = useCallback((target: LocalTarget) => {
|
||||
console.log(target);
|
||||
setSelectedTargetForHealthCheck(target);
|
||||
setHealthCheckDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
const isHttp = resourceType === "http";
|
||||
|
||||
const columns = useMemo((): ColumnDef<LocalTarget>[] => {
|
||||
const priorityColumn: ColumnDef<LocalTarget> = {
|
||||
id: "priority",
|
||||
header: () => (
|
||||
<div className="flex items-center gap-2 p-3">
|
||||
{t("priority")}
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Info className="h-4 w-4 text-muted-foreground cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p className="max-w-xs">
|
||||
{t("priorityDescription")}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<div className="p-3">
|
||||
<Input
|
||||
type="number"
|
||||
defaultValue={row.original.priority ?? 100}
|
||||
min={1}
|
||||
max={1000}
|
||||
className="w-20 h-7 text-sm"
|
||||
onChange={(e) => {
|
||||
const val = parseInt(e.target.value);
|
||||
if (!isNaN(val) && val >= 1 && val <= 1000) {
|
||||
updateTarget(row.original.targetId, {
|
||||
priority: val
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
size: 120,
|
||||
minSize: 100,
|
||||
maxSize: 150
|
||||
};
|
||||
|
||||
const healthCheckColumn: ColumnDef<LocalTarget> = {
|
||||
accessorKey: "healthCheck",
|
||||
header: () => <span className="p-3">{t("healthCheck")}</span>,
|
||||
cell: ({ row }) => {
|
||||
const status = row.original.hcHealth || "unknown";
|
||||
|
||||
const getStatusText = (status: string) => {
|
||||
switch (status) {
|
||||
case "healthy":
|
||||
return t("healthy");
|
||||
case "unhealthy":
|
||||
return t("unhealthy");
|
||||
default:
|
||||
return t("unknown");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 p-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 text-xs gap-1"
|
||||
onClick={() => openHealthCheckDialog(row.original)}
|
||||
>
|
||||
<Settings className="h-3.5 w-3.5" />
|
||||
{row.original.hcEnabled ? (
|
||||
<>
|
||||
{status === "healthy" && (
|
||||
<CircleCheck className="h-3.5 w-3.5 text-green-500" />
|
||||
)}
|
||||
{status === "unhealthy" && (
|
||||
<CircleX className="h-3.5 w-3.5 text-red-500" />
|
||||
)}
|
||||
{status === "unknown" && (
|
||||
<Info className="h-3.5 w-3.5 text-yellow-500" />
|
||||
)}
|
||||
{getStatusText(status)}
|
||||
</>
|
||||
) : (
|
||||
t("configure")
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
size: 200,
|
||||
minSize: 180,
|
||||
maxSize: 250
|
||||
};
|
||||
|
||||
const matchPathColumn: ColumnDef<LocalTarget> = {
|
||||
accessorKey: "path",
|
||||
header: () => <span className="p-3">{t("matchPath")}</span>,
|
||||
cell: ({ row }) => {
|
||||
const hasPathMatch = !!(
|
||||
row.original.path || row.original.pathMatchType
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="p-3">
|
||||
{hasPathMatch ? (
|
||||
<PathMatchModal
|
||||
value={{
|
||||
path: row.original.path,
|
||||
pathMatchType: row.original.pathMatchType
|
||||
}}
|
||||
onChange={(config) =>
|
||||
updateTarget(
|
||||
row.original.targetId,
|
||||
config.path === null &&
|
||||
config.pathMatchType === null
|
||||
? {
|
||||
...config,
|
||||
rewritePath: null,
|
||||
rewritePathType: null
|
||||
}
|
||||
: config
|
||||
)
|
||||
}
|
||||
trigger={
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex items-center gap-2 p-2 w-full text-left cursor-pointer max-w-[200px]"
|
||||
>
|
||||
<PathMatchDisplay
|
||||
value={{
|
||||
path: row.original.path,
|
||||
pathMatchType:
|
||||
row.original.pathMatchType
|
||||
}}
|
||||
/>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<PathMatchModal
|
||||
value={{
|
||||
path: row.original.path,
|
||||
pathMatchType: row.original.pathMatchType
|
||||
}}
|
||||
onChange={(config) =>
|
||||
updateTarget(
|
||||
row.original.targetId,
|
||||
config.path === null &&
|
||||
config.pathMatchType === null
|
||||
? {
|
||||
...config,
|
||||
rewritePath: null,
|
||||
rewritePathType: null
|
||||
}
|
||||
: config
|
||||
)
|
||||
}
|
||||
trigger={
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full max-w-[200px]"
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
{t("matchPath")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
size: 200,
|
||||
minSize: 180,
|
||||
maxSize: 200
|
||||
};
|
||||
|
||||
const addressColumn: ColumnDef<LocalTarget> = {
|
||||
accessorKey: "address",
|
||||
header: () => <span className="p-3">{t("address")}</span>,
|
||||
cell: ({ row }) => (
|
||||
<ResourceTargetAddressItem
|
||||
isHttp={isHttp}
|
||||
orgId={orgId!.toString()}
|
||||
getDockerStateForSite={getDockerStateForSite}
|
||||
proxyTarget={row.original}
|
||||
refreshContainersForSite={refreshContainersForSite}
|
||||
updateTarget={updateTarget}
|
||||
/>
|
||||
),
|
||||
size: 400,
|
||||
minSize: 350,
|
||||
maxSize: 500
|
||||
};
|
||||
|
||||
const rewritePathColumn: ColumnDef<LocalTarget> = {
|
||||
accessorKey: "rewritePath",
|
||||
header: () => <span className="p-3">{t("rewritePath")}</span>,
|
||||
cell: ({ row }) => {
|
||||
const hasRewritePath = !!(
|
||||
row.original.rewritePath || row.original.rewritePathType
|
||||
);
|
||||
const noPathMatch =
|
||||
!row.original.path && !row.original.pathMatchType;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full">
|
||||
{hasRewritePath && !noPathMatch ? (
|
||||
<PathRewriteModal
|
||||
value={{
|
||||
rewritePath: row.original.rewritePath,
|
||||
rewritePathType:
|
||||
row.original.rewritePathType
|
||||
}}
|
||||
onChange={(config) =>
|
||||
updateTarget(row.original.targetId, config)
|
||||
}
|
||||
trigger={
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex items-center gap-2 p-2 w-full text-left cursor-pointer max-w-[200px]"
|
||||
disabled={noPathMatch}
|
||||
>
|
||||
<PathRewriteDisplay
|
||||
value={{
|
||||
rewritePath:
|
||||
row.original.rewritePath,
|
||||
rewritePathType:
|
||||
row.original.rewritePathType
|
||||
}}
|
||||
/>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<PathRewriteModal
|
||||
value={{
|
||||
rewritePath: row.original.rewritePath,
|
||||
rewritePathType:
|
||||
row.original.rewritePathType
|
||||
}}
|
||||
onChange={(config) =>
|
||||
updateTarget(row.original.targetId, config)
|
||||
}
|
||||
trigger={
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={noPathMatch}
|
||||
className="w-full max-w-[200px]"
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
{t("rewritePath")}
|
||||
</Button>
|
||||
}
|
||||
disabled={noPathMatch}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
size: 200,
|
||||
minSize: 180,
|
||||
maxSize: 200
|
||||
};
|
||||
|
||||
const enabledColumn: ColumnDef<LocalTarget> = {
|
||||
accessorKey: "enabled",
|
||||
header: () => <span className="p-3">{t("enabled")}</span>,
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center justify-center w-full">
|
||||
<Switch
|
||||
defaultChecked={row.original.enabled}
|
||||
onCheckedChange={(val) =>
|
||||
updateTarget(row.original.targetId, {
|
||||
...row.original,
|
||||
enabled: val
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
size: 100,
|
||||
minSize: 80,
|
||||
maxSize: 120
|
||||
};
|
||||
|
||||
const actionsColumn: ColumnDef<LocalTarget> = {
|
||||
id: "actions",
|
||||
header: () => <span className="p-3">{t("actions")}</span>,
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center justify-end w-full">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => removeTarget(row.original.targetId)}
|
||||
>
|
||||
<CircleX className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
size: 100,
|
||||
minSize: 80,
|
||||
maxSize: 120
|
||||
};
|
||||
|
||||
if (isAdvancedMode) {
|
||||
const cols = [
|
||||
addressColumn,
|
||||
healthCheckColumn,
|
||||
enabledColumn,
|
||||
actionsColumn
|
||||
];
|
||||
|
||||
if (isHttp) {
|
||||
cols.splice(
|
||||
1,
|
||||
0,
|
||||
matchPathColumn,
|
||||
rewritePathColumn,
|
||||
priorityColumn
|
||||
);
|
||||
}
|
||||
|
||||
return cols;
|
||||
} else {
|
||||
return [
|
||||
addressColumn,
|
||||
healthCheckColumn,
|
||||
enabledColumn,
|
||||
actionsColumn
|
||||
];
|
||||
}
|
||||
}, [
|
||||
isAdvancedMode,
|
||||
isHttp,
|
||||
sites,
|
||||
updateTarget,
|
||||
getDockerStateForSite,
|
||||
refreshContainersForSite,
|
||||
openHealthCheckDialog,
|
||||
removeTarget,
|
||||
t
|
||||
]);
|
||||
|
||||
const table = useReactTable({
|
||||
data: targets,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
getRowId: (row) => String(row.targetId),
|
||||
state: {
|
||||
pagination: {
|
||||
pageIndex: 0,
|
||||
pageSize: 1000
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// SSH strategy options
|
||||
const sshModeOptions: StrategyOption<"standard" | "native">[] = [
|
||||
{
|
||||
@@ -1732,207 +1185,11 @@ export default function Page() {
|
||||
{(resourceType === "http" ||
|
||||
resourceType === "tcp" ||
|
||||
resourceType === "udp") && (
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t("targets")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t("targetsDescription")}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
{targets.length > 0 ? (
|
||||
<>
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table
|
||||
.getHeaderGroups()
|
||||
.map(
|
||||
(
|
||||
headerGroup
|
||||
) => (
|
||||
<TableRow
|
||||
key={
|
||||
headerGroup.id
|
||||
}
|
||||
>
|
||||
{headerGroup.headers.map(
|
||||
(
|
||||
header
|
||||
) => {
|
||||
const isActionsColumn =
|
||||
header
|
||||
.column
|
||||
.id ===
|
||||
"actions";
|
||||
return (
|
||||
<TableHead
|
||||
key={
|
||||
header.id
|
||||
}
|
||||
className={
|
||||
isActionsColumn
|
||||
? "sticky right-0 z-10 w-auto min-w-fit bg-card"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header
|
||||
.column
|
||||
.columnDef
|
||||
.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</TableHead>
|
||||
);
|
||||
}
|
||||
)}
|
||||
</TableRow>
|
||||
)
|
||||
)}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel()
|
||||
.rows
|
||||
?.length ? (
|
||||
table
|
||||
.getRowModel()
|
||||
.rows.map(
|
||||
(
|
||||
row
|
||||
) => (
|
||||
<TableRow
|
||||
key={
|
||||
row.id
|
||||
}
|
||||
>
|
||||
{row
|
||||
.getVisibleCells()
|
||||
.map(
|
||||
(
|
||||
cell
|
||||
) => {
|
||||
const isActionsColumn =
|
||||
cell
|
||||
.column
|
||||
.id ===
|
||||
"actions";
|
||||
return (
|
||||
<TableCell
|
||||
key={
|
||||
cell.id
|
||||
}
|
||||
className={
|
||||
isActionsColumn
|
||||
? "sticky right-0 z-10 w-auto min-w-fit bg-card"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
{flexRender(
|
||||
cell
|
||||
.column
|
||||
.columnDef
|
||||
.cell,
|
||||
cell.getContext()
|
||||
)}
|
||||
</TableCell>
|
||||
);
|
||||
}
|
||||
)}
|
||||
</TableRow>
|
||||
)
|
||||
)
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={
|
||||
columns.length
|
||||
}
|
||||
className="h-24 text-center"
|
||||
>
|
||||
{t(
|
||||
"targetNoOne"
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center justify-between w-full gap-2">
|
||||
<Button
|
||||
onClick={
|
||||
addNewTarget
|
||||
}
|
||||
variant="outline"
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
{t("addTarget")}
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id="advanced-mode-toggle"
|
||||
checked={
|
||||
isAdvancedMode
|
||||
}
|
||||
onCheckedChange={
|
||||
setIsAdvancedMode
|
||||
}
|
||||
/>
|
||||
<label
|
||||
htmlFor="advanced-mode-toggle"
|
||||
className="text-sm"
|
||||
>
|
||||
{t(
|
||||
"advancedMode"
|
||||
)}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<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>
|
||||
<Button
|
||||
onClick={addNewTarget}
|
||||
variant="outline"
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
{t("addTarget")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{build === "saas" &&
|
||||
targets.length > 1 &&
|
||||
new Set(
|
||||
targets.map((t) => t.siteId)
|
||||
).size > 1 && (
|
||||
<p className="text-sm text-muted-foreground mt-3">
|
||||
{t(
|
||||
"proxyMultiSiteRoundRobinNodeHelp"
|
||||
)}{" "}
|
||||
<a
|
||||
href="https://docs.pangolin.net/manage/resources/public/targets#distributing-sites-load-across-servers"
|
||||
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>
|
||||
)}
|
||||
</SettingsSectionBody>
|
||||
</SettingsSection>
|
||||
<ProxyResourceTargetsForm
|
||||
orgId={orgId!.toString()}
|
||||
isHttp={resourceType === "http"}
|
||||
onChange={setTargets}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end space-x-2 mt-8">
|
||||
@@ -1977,77 +1234,6 @@ export default function Page() {
|
||||
{t("resourceCreate")}
|
||||
</Button>
|
||||
</div>
|
||||
{selectedTargetForHealthCheck && (
|
||||
<HealthCheckCredenza
|
||||
mode="autoSave"
|
||||
open={healthCheckDialogOpen}
|
||||
setOpen={setHealthCheckDialogOpen}
|
||||
targetAddress={`${selectedTargetForHealthCheck.ip}:${selectedTargetForHealthCheck.port}`}
|
||||
targetMethod={
|
||||
selectedTargetForHealthCheck.method ||
|
||||
undefined
|
||||
}
|
||||
initialConfig={{
|
||||
hcEnabled:
|
||||
selectedTargetForHealthCheck.hcEnabled ||
|
||||
false,
|
||||
hcPath:
|
||||
selectedTargetForHealthCheck.hcPath ||
|
||||
"/",
|
||||
hcMethod:
|
||||
selectedTargetForHealthCheck.hcMethod ||
|
||||
"GET",
|
||||
hcInterval:
|
||||
selectedTargetForHealthCheck.hcInterval ||
|
||||
5,
|
||||
hcTimeout:
|
||||
selectedTargetForHealthCheck.hcTimeout ||
|
||||
5,
|
||||
hcHeaders:
|
||||
selectedTargetForHealthCheck.hcHeaders ||
|
||||
undefined,
|
||||
hcScheme:
|
||||
selectedTargetForHealthCheck.hcScheme ||
|
||||
undefined,
|
||||
hcHostname:
|
||||
selectedTargetForHealthCheck.hcHostname ||
|
||||
selectedTargetForHealthCheck.ip,
|
||||
hcPort:
|
||||
selectedTargetForHealthCheck.hcPort ||
|
||||
selectedTargetForHealthCheck.port,
|
||||
hcFollowRedirects:
|
||||
selectedTargetForHealthCheck.hcFollowRedirects ??
|
||||
true,
|
||||
hcStatus:
|
||||
selectedTargetForHealthCheck.hcStatus ||
|
||||
undefined,
|
||||
hcMode:
|
||||
selectedTargetForHealthCheck.hcMode ||
|
||||
"http",
|
||||
hcUnhealthyInterval:
|
||||
selectedTargetForHealthCheck.hcUnhealthyInterval ||
|
||||
30,
|
||||
hcTlsServerName:
|
||||
selectedTargetForHealthCheck.hcTlsServerName ||
|
||||
undefined,
|
||||
hcHealthyThreshold:
|
||||
selectedTargetForHealthCheck.hcHealthyThreshold ||
|
||||
1,
|
||||
hcUnhealthyThreshold:
|
||||
selectedTargetForHealthCheck.hcUnhealthyThreshold ||
|
||||
1
|
||||
}}
|
||||
onChanges={async (config) => {
|
||||
if (selectedTargetForHealthCheck) {
|
||||
console.log(config);
|
||||
TargetHealthCheck(
|
||||
selectedTargetForHealthCheck.targetId,
|
||||
config
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</SettingsContainer>
|
||||
) : (
|
||||
<SettingsContainer>
|
||||
|
||||
Reference in New Issue
Block a user