adjust target config column

This commit is contained in:
Pallavi Kumari
2025-10-06 00:50:38 +05:30
parent c7c3e3ee73
commit ca146a1b57
4 changed files with 322 additions and 138 deletions

View File

@@ -469,6 +469,8 @@
"proxyErrorInvalidHeader": "Invalid custom Host Header value. Use domain name format, or save empty to unset custom Host Header.", "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.", "proxyErrorTls": "Invalid TLS Server Name. Use domain name format, or save empty to remove the TLS Server Name.",
"proxyEnableSSL": "Enable SSL (https)", "proxyEnableSSL": "Enable SSL (https)",
"target": "Target",
"configureTargets": "Configure Targets",
"targetErrorFetch": "Failed to fetch targets", "targetErrorFetch": "Failed to fetch targets",
"targetErrorFetchDescription": "An error occurred while fetching targets", "targetErrorFetchDescription": "An error occurred while fetching targets",
"siteErrorFetch": "Failed to fetch resource", "siteErrorFetch": "Failed to fetch resource",

View File

@@ -110,6 +110,8 @@ import {
} from "@app/components/PathMatchRenameModal"; } from "@app/components/PathMatchRenameModal";
import { Badge } from "@app/components/ui/badge"; import { Badge } from "@app/components/ui/badge";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@app/components/ui/tooltip"; 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 const addTargetSchema = z
.object({ .object({
@@ -537,11 +539,11 @@ export default function ReverseProxyTargets(props: {
targets.map((target) => targets.map((target) =>
target.targetId === targetId target.targetId === targetId
? { ? {
...target, ...target,
...data, ...data,
updated: true, updated: true,
siteType: site?.type || null siteType: site?.type || null
} }
: target : target
) )
); );
@@ -552,10 +554,10 @@ export default function ReverseProxyTargets(props: {
targets.map((target) => targets.map((target) =>
target.targetId === targetId target.targetId === targetId
? { ? {
...target, ...target,
...config, ...config,
updated: true updated: true
} }
: target : target
) )
); );
@@ -743,9 +745,9 @@ export default function ReverseProxyTargets(props: {
} }
/> />
<Button <Button
variant="text" variant="ghost"
size="sm" size="sm"
className="px-1" className="h-8 w-8 p-0"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
updateTarget(row.original.targetId, { updateTarget(row.original.targetId, {
@@ -760,7 +762,7 @@ export default function ReverseProxyTargets(props: {
× ×
</Button> </Button>
{/* <MoveRight className="ml-1 h-4 w-4" /> */} <MoveRight className="ml-1 h-4 w-4" />
</div> </div>
) : ( ) : (
<PathMatchModal <PathMatchModal
@@ -806,7 +808,7 @@ export default function ReverseProxyTargets(props: {
}; };
return ( return (
<div className="flex gap-2 items-center"> <div className="flex gap-1 items-center">
<Popover> <Popover>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button <Button
@@ -815,7 +817,7 @@ export default function ReverseProxyTargets(props: {
className={cn( className={cn(
"justify-between flex-1", "justify-between flex-1",
!row.original.siteId && !row.original.siteId &&
"text-muted-foreground" "text-muted-foreground"
)} )}
> >
{row.original.siteId {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", accessorKey: "target",
header: t("targetAddr"), header: t("target"),
cell: ({ row }) => ( cell: ({ row }) => {
<Input const hasTarget = !!(row.original.ip || row.original.port || row.original.method);
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);
if (hasProtocol || hasPort) { return hasTarget ? (
const parsed = parseHostTarget(input); <div className="flex items-center gap-1">
if (parsed) { <TargetModal
value={{
method: row.original.method,
ip: row.original.ip,
port: row.original.port
}}
onChange={(config) =>
updateTarget(row.original.targetId, { updateTarget(row.original.targetId, {
...row.original, ...row.original,
method: hasProtocol ...config
? 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 { 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, { updateTarget(row.original.targetId, {
...row.original, ...row.original,
ip: input ...config
}); })
} }
}} showMethod={resource.http}
/> trigger={
) <Button variant="outline">
}, <Plus className="h-4 w-4 mr-2" />
{ {t("configureTarget")}
accessorKey: "port", </Button>
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)
})
}
/>
)
}, },
{ {
accessorKey: "rewritePath", accessorKey: "rewritePath",
@@ -990,7 +985,6 @@ export default function ReverseProxyTargets(props: {
return hasRewritePath && !noPathMatch ? ( return hasRewritePath && !noPathMatch ? (
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
{/* <MoveRight className="mr-2 h-4 w-4" /> */}
<PathRewriteModal <PathRewriteModal
value={{ value={{
rewritePath: row.original.rewritePath, rewritePath: row.original.rewritePath,
@@ -1017,9 +1011,9 @@ export default function ReverseProxyTargets(props: {
} }
/> />
<Button <Button
variant="ghost"
size="sm" size="sm"
variant="text" className="h-8 w-8 p-0"
className="px-1"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
updateTarget(row.original.targetId, { updateTarget(row.original.targetId, {
@@ -1238,21 +1232,21 @@ export default function ReverseProxyTargets(props: {
className={cn( className={cn(
"justify-between flex-1", "justify-between flex-1",
!field.value && !field.value &&
"text-muted-foreground" "text-muted-foreground"
)} )}
> >
{field.value {field.value
? sites.find( ? sites.find(
( (
site site
) => ) =>
site.siteId === site.siteId ===
field.value field.value
) )
?.name ?.name
: t( : t(
"siteSelect" "siteSelect"
)} )}
<CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" /> <CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button> </Button>
</FormControl> </FormControl>
@@ -1318,34 +1312,34 @@ export default function ReverseProxyTargets(props: {
); );
return selectedSite && return selectedSite &&
selectedSite.type === selectedSite.type ===
"newt" "newt"
? (() => { ? (() => {
const dockerState = const dockerState =
getDockerStateForSite( getDockerStateForSite(
selectedSite.siteId selectedSite.siteId
); );
return ( return (
<ContainersSelector <ContainersSelector
site={ site={
selectedSite selectedSite
} }
containers={ containers={
dockerState.containers dockerState.containers
} }
isAvailable={ isAvailable={
dockerState.isAvailable dockerState.isAvailable
} }
onContainerSelect={ onContainerSelect={
handleContainerSelect handleContainerSelect
} }
onRefresh={() => onRefresh={() =>
refreshContainersForSite( refreshContainersForSite(
selectedSite.siteId selectedSite.siteId
) )
} }
/> />
); );
})() })()
: null; : null;
})()} })()}
</div> </div>
@@ -1558,7 +1552,7 @@ export default function ReverseProxyTargets(props: {
</form> </form>
</Form> </Form>
</SettingsSectionForm> </SettingsSectionForm>
<div className=""> <div className="overflow-x-auto">
<Table> <Table>
<TableHeader> <TableHeader>
{table {table
@@ -1573,12 +1567,12 @@ export default function ReverseProxyTargets(props: {
{header.isPlaceholder {header.isPlaceholder
? null ? null
: flexRender( : flexRender(
header header
.column .column
.columnDef .columnDef
.header, .header,
header.getContext() header.getContext()
)} )}
</TableHead> </TableHead>
) )
)} )}

View 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>
);
}

View 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>
);
}