mirror of
https://github.com/fosrl/pangolin.git
synced 2026-01-28 22:00:51 +00:00
adjust target config column
This commit is contained in:
@@ -469,6 +469,8 @@
|
||||
"proxyErrorInvalidHeader": "Invalid custom Host Header value. Use domain name format, or save empty to unset custom Host Header.",
|
||||
"proxyErrorTls": "Invalid TLS Server Name. Use domain name format, or save empty to remove the TLS Server Name.",
|
||||
"proxyEnableSSL": "Enable SSL (https)",
|
||||
"target": "Target",
|
||||
"configureTargets": "Configure Targets",
|
||||
"targetErrorFetch": "Failed to fetch targets",
|
||||
"targetErrorFetchDescription": "An error occurred while fetching targets",
|
||||
"siteErrorFetch": "Failed to fetch resource",
|
||||
|
||||
@@ -110,6 +110,8 @@ import {
|
||||
} from "@app/components/PathMatchRenameModal";
|
||||
import { Badge } from "@app/components/ui/badge";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@app/components/ui/tooltip";
|
||||
import { TargetModal } from "@app/components/TargetModal";
|
||||
import { TargetDisplay } from "@app/components/TargetDisplay";
|
||||
|
||||
const addTargetSchema = z
|
||||
.object({
|
||||
@@ -537,11 +539,11 @@ export default function ReverseProxyTargets(props: {
|
||||
targets.map((target) =>
|
||||
target.targetId === targetId
|
||||
? {
|
||||
...target,
|
||||
...data,
|
||||
updated: true,
|
||||
siteType: site?.type || null
|
||||
}
|
||||
...target,
|
||||
...data,
|
||||
updated: true,
|
||||
siteType: site?.type || null
|
||||
}
|
||||
: target
|
||||
)
|
||||
);
|
||||
@@ -552,10 +554,10 @@ export default function ReverseProxyTargets(props: {
|
||||
targets.map((target) =>
|
||||
target.targetId === targetId
|
||||
? {
|
||||
...target,
|
||||
...config,
|
||||
updated: true
|
||||
}
|
||||
...target,
|
||||
...config,
|
||||
updated: true
|
||||
}
|
||||
: target
|
||||
)
|
||||
);
|
||||
@@ -743,9 +745,9 @@ export default function ReverseProxyTargets(props: {
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
variant="text"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="px-1"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
updateTarget(row.original.targetId, {
|
||||
@@ -760,7 +762,7 @@ export default function ReverseProxyTargets(props: {
|
||||
×
|
||||
</Button>
|
||||
|
||||
{/* <MoveRight className="ml-1 h-4 w-4" /> */}
|
||||
<MoveRight className="ml-1 h-4 w-4" />
|
||||
</div>
|
||||
) : (
|
||||
<PathMatchModal
|
||||
@@ -806,7 +808,7 @@ export default function ReverseProxyTargets(props: {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex gap-2 items-center">
|
||||
<div className="flex gap-1 items-center">
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
@@ -815,7 +817,7 @@ export default function ReverseProxyTargets(props: {
|
||||
className={cn(
|
||||
"justify-between flex-1",
|
||||
!row.original.siteId &&
|
||||
"text-muted-foreground"
|
||||
"text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{row.original.siteId
|
||||
@@ -892,91 +894,84 @@ export default function ReverseProxyTargets(props: {
|
||||
);
|
||||
}
|
||||
},
|
||||
...(resource.http
|
||||
? [
|
||||
{
|
||||
accessorKey: "method",
|
||||
header: t("method"),
|
||||
cell: ({ row }: { row: Row<LocalTarget> }) => (
|
||||
<Select
|
||||
defaultValue={row.original.method ?? ""}
|
||||
onValueChange={(value) =>
|
||||
updateTarget(row.original.targetId, {
|
||||
...row.original,
|
||||
method: value
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
{row.original.method}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="http">http</SelectItem>
|
||||
<SelectItem value="https">https</SelectItem>
|
||||
<SelectItem value="h2c">h2c</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
}
|
||||
]
|
||||
: []),
|
||||
{
|
||||
accessorKey: "ip",
|
||||
header: t("targetAddr"),
|
||||
cell: ({ row }) => (
|
||||
<Input
|
||||
defaultValue={row.original.ip}
|
||||
className="min-w-[150px]"
|
||||
onBlur={(e) => {
|
||||
const input = e.target.value.trim();
|
||||
const hasProtocol = /^(https?|h2c):\/\//.test(input);
|
||||
const hasPort = /:\d+(?:\/|$)/.test(input);
|
||||
accessorKey: "target",
|
||||
header: t("target"),
|
||||
cell: ({ row }) => {
|
||||
const hasTarget = !!(row.original.ip || row.original.port || row.original.method);
|
||||
|
||||
if (hasProtocol || hasPort) {
|
||||
const parsed = parseHostTarget(input);
|
||||
if (parsed) {
|
||||
return hasTarget ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<TargetModal
|
||||
value={{
|
||||
method: row.original.method,
|
||||
ip: row.original.ip,
|
||||
port: row.original.port
|
||||
}}
|
||||
onChange={(config) =>
|
||||
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
|
||||
});
|
||||
...config
|
||||
})
|
||||
}
|
||||
} else {
|
||||
showMethod={resource.http}
|
||||
trigger={
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex items-center gap-2 p-2 max-w-md w-full text-left cursor-pointer"
|
||||
>
|
||||
<TargetDisplay
|
||||
value={{
|
||||
method: row.original.method,
|
||||
ip: row.original.ip,
|
||||
port: row.original.port
|
||||
}}
|
||||
showMethod={resource.http}
|
||||
/>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
updateTarget(row.original.targetId, {
|
||||
...row.original,
|
||||
method: null,
|
||||
ip: "",
|
||||
port: undefined
|
||||
});
|
||||
}}
|
||||
>
|
||||
×
|
||||
</Button>
|
||||
<MoveRight className="mr-2 h-4 w-4" />
|
||||
</div>
|
||||
) : (
|
||||
<TargetModal
|
||||
value={{
|
||||
method: row.original.method,
|
||||
ip: row.original.ip,
|
||||
port: row.original.port
|
||||
}}
|
||||
onChange={(config) =>
|
||||
updateTarget(row.original.targetId, {
|
||||
...row.original,
|
||||
ip: input
|
||||
});
|
||||
...config
|
||||
})
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)
|
||||
},
|
||||
{
|
||||
accessorKey: "port",
|
||||
header: t("targetPort"),
|
||||
cell: ({ row }) => (
|
||||
<Input
|
||||
type="number"
|
||||
defaultValue={row.original.port}
|
||||
className="min-w-[100px]"
|
||||
onBlur={(e) =>
|
||||
updateTarget(row.original.targetId, {
|
||||
...row.original,
|
||||
port: parseInt(e.target.value, 10)
|
||||
})
|
||||
}
|
||||
/>
|
||||
)
|
||||
showMethod={resource.http}
|
||||
trigger={
|
||||
<Button variant="outline">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
{t("configureTarget")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "rewritePath",
|
||||
@@ -990,7 +985,6 @@ export default function ReverseProxyTargets(props: {
|
||||
|
||||
return hasRewritePath && !noPathMatch ? (
|
||||
<div className="flex items-center gap-1">
|
||||
{/* <MoveRight className="mr-2 h-4 w-4" /> */}
|
||||
<PathRewriteModal
|
||||
value={{
|
||||
rewritePath: row.original.rewritePath,
|
||||
@@ -1017,9 +1011,9 @@ export default function ReverseProxyTargets(props: {
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
variant="text"
|
||||
className="px-1"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
updateTarget(row.original.targetId, {
|
||||
@@ -1238,21 +1232,21 @@ export default function ReverseProxyTargets(props: {
|
||||
className={cn(
|
||||
"justify-between flex-1",
|
||||
!field.value &&
|
||||
"text-muted-foreground"
|
||||
"text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{field.value
|
||||
? sites.find(
|
||||
(
|
||||
site
|
||||
) =>
|
||||
site.siteId ===
|
||||
field.value
|
||||
)
|
||||
?.name
|
||||
(
|
||||
site
|
||||
) =>
|
||||
site.siteId ===
|
||||
field.value
|
||||
)
|
||||
?.name
|
||||
: t(
|
||||
"siteSelect"
|
||||
)}
|
||||
"siteSelect"
|
||||
)}
|
||||
<CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</FormControl>
|
||||
@@ -1318,34 +1312,34 @@ export default function ReverseProxyTargets(props: {
|
||||
);
|
||||
return selectedSite &&
|
||||
selectedSite.type ===
|
||||
"newt"
|
||||
"newt"
|
||||
? (() => {
|
||||
const dockerState =
|
||||
getDockerStateForSite(
|
||||
selectedSite.siteId
|
||||
);
|
||||
return (
|
||||
<ContainersSelector
|
||||
site={
|
||||
selectedSite
|
||||
}
|
||||
containers={
|
||||
dockerState.containers
|
||||
}
|
||||
isAvailable={
|
||||
dockerState.isAvailable
|
||||
}
|
||||
onContainerSelect={
|
||||
handleContainerSelect
|
||||
}
|
||||
onRefresh={() =>
|
||||
refreshContainersForSite(
|
||||
selectedSite.siteId
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
})()
|
||||
const dockerState =
|
||||
getDockerStateForSite(
|
||||
selectedSite.siteId
|
||||
);
|
||||
return (
|
||||
<ContainersSelector
|
||||
site={
|
||||
selectedSite
|
||||
}
|
||||
containers={
|
||||
dockerState.containers
|
||||
}
|
||||
isAvailable={
|
||||
dockerState.isAvailable
|
||||
}
|
||||
onContainerSelect={
|
||||
handleContainerSelect
|
||||
}
|
||||
onRefresh={() =>
|
||||
refreshContainersForSite(
|
||||
selectedSite.siteId
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
})()
|
||||
: null;
|
||||
})()}
|
||||
</div>
|
||||
@@ -1558,7 +1552,7 @@ export default function ReverseProxyTargets(props: {
|
||||
</form>
|
||||
</Form>
|
||||
</SettingsSectionForm>
|
||||
<div className="">
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table
|
||||
@@ -1573,12 +1567,12 @@ export default function ReverseProxyTargets(props: {
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header
|
||||
.column
|
||||
.columnDef
|
||||
.header,
|
||||
header.getContext()
|
||||
)}
|
||||
header
|
||||
.column
|
||||
.columnDef
|
||||
.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</TableHead>
|
||||
)
|
||||
)}
|
||||
|
||||
44
src/components/TargetDisplay.tsx
Normal file
44
src/components/TargetDisplay.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { Globe, Hash, Shield } from "lucide-react";
|
||||
|
||||
interface TargetDisplayProps {
|
||||
value: {
|
||||
method?: string | null;
|
||||
ip?: string;
|
||||
port?: number;
|
||||
};
|
||||
showMethod?: boolean;
|
||||
}
|
||||
|
||||
export function TargetDisplay({ value, showMethod = true }: TargetDisplayProps) {
|
||||
const { method, ip, port } = value;
|
||||
|
||||
if (!ip && !port && !method) {
|
||||
return <span className="text-muted-foreground text-sm">Not configured</span>;
|
||||
}
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-0 text-sm font-mono">
|
||||
{showMethod && method && (
|
||||
<span className="inline-flex items-center gap-1 font-medium">
|
||||
{method === "https" && <Shield className="h-3 w-3 text-green-600 dark:text-green-400" />}
|
||||
<span className={method === "https" ? "text-green-600 dark:text-green-400" : ""}>
|
||||
{method}<span className="text-muted-foreground">://</span>
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
{ip && (
|
||||
<span className="inline-flex items-center font-medium">
|
||||
{ip}
|
||||
{port && <span className="text-muted-foreground">:</span>}
|
||||
</span>
|
||||
)}
|
||||
{port && (
|
||||
<span className="inline-flex items-center text-muted-foreground font-medium">
|
||||
{port}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
144
src/components/TargetModal.tsx
Normal file
144
src/components/TargetModal.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { useState } from "react";
|
||||
|
||||
interface TargetConfig {
|
||||
method?: string | null;
|
||||
ip?: string;
|
||||
port?: number;
|
||||
}
|
||||
|
||||
interface TargetModalProps {
|
||||
value: TargetConfig;
|
||||
onChange: (config: TargetConfig) => void;
|
||||
trigger: React.ReactNode;
|
||||
showMethod?: boolean;
|
||||
}
|
||||
|
||||
export function TargetModal({
|
||||
value,
|
||||
onChange,
|
||||
trigger,
|
||||
showMethod = true
|
||||
}: TargetModalProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [config, setConfig] = useState<TargetConfig>(value);
|
||||
|
||||
const handleSave = () => {
|
||||
onChange(config);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const parseHostTarget = (input: string) => {
|
||||
const protocolMatch = input.match(/^(https?|h2c):\/\//);
|
||||
const protocol = protocolMatch ? protocolMatch[1] : null;
|
||||
const withoutProtocol = input.replace(/^(https?|h2c):\/\//, '');
|
||||
|
||||
const portMatch = withoutProtocol.match(/:(\d+)(?:\/|$)/);
|
||||
const port = portMatch ? parseInt(portMatch[1], 10) : null;
|
||||
const host = withoutProtocol.replace(/:\d+(?:\/|$)/, '').replace(/\/$/, '');
|
||||
|
||||
return { protocol, host, port };
|
||||
};
|
||||
|
||||
const handleHostChange = (input: string) => {
|
||||
const trimmed = input.trim();
|
||||
const hasProtocol = /^(https?|h2c):\/\//.test(trimmed);
|
||||
const hasPort = /:\d+(?:\/|$)/.test(trimmed);
|
||||
|
||||
if (hasProtocol || hasPort) {
|
||||
const parsed = parseHostTarget(trimmed);
|
||||
setConfig({
|
||||
...config,
|
||||
...(hasProtocol && parsed.protocol ? { method: parsed.protocol } : {}),
|
||||
ip: parsed.host,
|
||||
...(hasPort && parsed.port ? { port: parsed.port } : {})
|
||||
});
|
||||
} else {
|
||||
setConfig({ ...config, ip: trimmed });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>{trigger}</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Configure Target</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
{showMethod && (
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="method">Method</Label>
|
||||
<Select
|
||||
value={config.method || "http"}
|
||||
onValueChange={(value) =>
|
||||
setConfig({ ...config, method: value })
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="method">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="http">http</SelectItem>
|
||||
<SelectItem value="https">https</SelectItem>
|
||||
<SelectItem value="h2c">h2c</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="host">IP Address / Hostname</Label>
|
||||
<Input
|
||||
id="host"
|
||||
placeholder="e.g., 192.168.1.1 or example.com"
|
||||
value={config.ip || ""}
|
||||
onChange={(e) => setConfig({ ...config, ip: e.target.value })}
|
||||
onBlur={(e) => handleHostChange(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
You can also paste: http://example.com:8080
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="port">Port</Label>
|
||||
<Input
|
||||
id="port"
|
||||
type="number"
|
||||
placeholder="e.g., 8080"
|
||||
value={config.port || ""}
|
||||
onChange={(e) =>
|
||||
setConfig({
|
||||
...config,
|
||||
port: parseInt(e.target.value, 10) || undefined
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={() => setOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSave}>Save</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user