Better UX on domain forms.
This commit is contained in:
@@ -5,7 +5,7 @@ import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Trash2, CheckCircle, AlertCircle, Loader2, Plus, Undo2, Save, RotateCw } from 'lucide-react';
|
||||
import { Trash2, CheckCircle, AlertCircle, Loader2, Plus, Undo2, Save, RotateCw, Pencil } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useIsMobile } from '@/hooks/use-mobile';
|
||||
import { DomainTopology } from './DomainTopology';
|
||||
@@ -107,7 +107,7 @@ function describeChanges(server: DomainDraft, draft: DomainDraft): string[] {
|
||||
return changes;
|
||||
}
|
||||
|
||||
/** Compact controls for mobile */
|
||||
/** Compact controls for mobile — vertical pipeline: Source → Routing → Destination */
|
||||
function MobileControls({
|
||||
draft, onUpdateDraft, domain, cert, ddnsRecord,
|
||||
onProvisionCert, onRenewCert, isProvisioningCert, isRenewingCert,
|
||||
@@ -138,14 +138,29 @@ function MobileControls({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3 py-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs">Public (internet-accessible)</Label>
|
||||
<Switch checked={draft.public} onCheckedChange={v => onUpdateDraft({ public: v })} />
|
||||
<div className="py-2 space-y-3">
|
||||
{/* === Section 1: Source === */}
|
||||
<div className="space-y-2">
|
||||
<div className="text-[10px] text-muted-foreground font-medium uppercase tracking-wider">Source</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs">Internet-accessible</Label>
|
||||
<Switch checked={draft.public} onCheckedChange={v => onUpdateDraft({ public: v })} />
|
||||
</div>
|
||||
{draft.public && ddnsRecord && !ddnsRecord.ok && (
|
||||
<div className="flex items-center gap-2 text-[10px] text-red-600 dark:text-red-400 bg-red-50/50 dark:bg-red-950/20 rounded-md px-3 py-1.5">
|
||||
<AlertCircle className="h-3 w-3 shrink-0" />
|
||||
DDNS sync failed{ddnsRecord.error ? `: ${ddnsRecord.error}` : ''}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs">Include subdomains</Label>
|
||||
<Switch checked={draft.subdomains} onCheckedChange={v => onUpdateDraft({ subdomains: v })} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-xs mb-1.5 block">Routing mode</Label>
|
||||
{/* === Section 2: Routing === */}
|
||||
<div className="border-t border-border/50 pt-3 space-y-2">
|
||||
<div className="text-[10px] text-muted-foreground font-medium uppercase tracking-wider">Routing</div>
|
||||
<div className="flex gap-1 bg-muted rounded-md p-1">
|
||||
{([
|
||||
{ value: 'dns-only' as const, label: 'Direct' },
|
||||
@@ -163,114 +178,112 @@ function MobileControls({
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{isDnsOnly && (
|
||||
<div className="flex items-center gap-2 text-[10px] text-amber-600 dark:text-amber-400 bg-amber-50/50 dark:bg-amber-950/20 rounded-md px-3 py-1.5">
|
||||
<AlertCircle className="h-3 w-3 shrink-0" />
|
||||
No firewall, proxy, or TLS protection — traffic goes directly to your server
|
||||
</div>
|
||||
)}
|
||||
{isL7 && (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
{hasCert ? (
|
||||
<>
|
||||
<CheckCircle className="h-3.5 w-3.5 text-green-500" />
|
||||
<span>TLS: {(cert!.cert.wildcard || cert!.coveredBy) ? `*.${cert!.domain}` : cert!.domain}</span>
|
||||
{cert!.cert.daysLeft != null && <span className="text-muted-foreground">({cert!.cert.daysLeft}d)</span>}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<AlertCircle className="h-3.5 w-3.5 text-red-500" />
|
||||
<span>No TLS certificate</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{!hasCert && (
|
||||
<Button variant="outline" size="sm" className="h-7 text-xs gap-1"
|
||||
disabled={isProvisioningCert}
|
||||
onClick={() => onProvisionCert(draft.subdomains ? `*.` : '')}
|
||||
>
|
||||
{isProvisioningCert ? <Loader2 className="h-3 w-3 animate-spin" /> : <Plus className="h-3 w-3" />}
|
||||
Provision
|
||||
</Button>
|
||||
)}
|
||||
{hasCert && daysLeft < 30 && (
|
||||
<Button variant="outline" size="sm" className="h-7 text-xs gap-1"
|
||||
disabled={isRenewingCert}
|
||||
onClick={() => onRenewCert()}
|
||||
>
|
||||
{isRenewingCert ? <Loader2 className="h-3 w-3 animate-spin" /> : <RotateCw className="h-3 w-3" />}
|
||||
Renew
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{!hasCert && (
|
||||
<div className="flex items-center gap-2 text-[10px] text-red-600 dark:text-red-400 bg-red-50/50 dark:bg-red-950/20 rounded-md px-3 py-1.5">
|
||||
<AlertCircle className="h-3 w-3 shrink-0" />
|
||||
L7 routing is inactive until a TLS certificate is provisioned
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isL7 && (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
{hasCert ? (
|
||||
<>
|
||||
<CheckCircle className="h-3.5 w-3.5 text-green-500" />
|
||||
<span>TLS: {(cert!.cert.wildcard || cert!.coveredBy) ? `*.${cert!.domain}` : cert!.domain}</span>
|
||||
{cert!.cert.daysLeft != null && <span className="text-muted-foreground">({cert!.cert.daysLeft}d)</span>}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<AlertCircle className="h-3.5 w-3.5 text-red-500" />
|
||||
<span>No TLS certificate</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{!hasCert && (
|
||||
<Button variant="outline" size="sm" className="h-7 text-xs gap-1"
|
||||
disabled={isProvisioningCert}
|
||||
onClick={() => onProvisionCert(draft.subdomains ? `*.` : '')}
|
||||
>
|
||||
{isProvisioningCert ? <Loader2 className="h-3 w-3 animate-spin" /> : <Plus className="h-3 w-3" />}
|
||||
Provision
|
||||
</Button>
|
||||
)}
|
||||
{hasCert && daysLeft < 30 && (
|
||||
<Button variant="outline" size="sm" className="h-7 text-xs gap-1"
|
||||
disabled={isRenewingCert}
|
||||
onClick={() => onRenewCert()}
|
||||
>
|
||||
{isRenewingCert ? <Loader2 className="h-3 w-3 animate-spin" /> : <RotateCw className="h-3 w-3" />}
|
||||
Renew
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="text-xs">
|
||||
<span className="text-muted-foreground">Backend: </span>
|
||||
{/* === Section 3: Destination === */}
|
||||
<div className="border-t border-border/50 pt-3 space-y-2">
|
||||
<div className="text-[10px] text-muted-foreground font-medium uppercase tracking-wider">Destination</div>
|
||||
{editingBackend ? (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<div className="space-y-1.5">
|
||||
<input
|
||||
ref={backendInputRef}
|
||||
defaultValue={draft.backendAddress}
|
||||
className="font-mono bg-muted rounded px-1.5 py-0.5 text-xs w-40 border border-border focus:outline-none focus:ring-1 focus:ring-ring"
|
||||
className="font-mono bg-muted rounded px-2 py-1.5 text-xs w-full border border-border focus:outline-none focus:ring-1 focus:ring-ring"
|
||||
autoFocus
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter') handleBackendSave();
|
||||
if (e.key === 'Escape') setEditingBackend(false);
|
||||
}}
|
||||
/>
|
||||
<Button variant="ghost" size="sm" className="h-5 text-[10px] px-1" onClick={handleBackendSave}>Save</Button>
|
||||
<Button variant="ghost" size="sm" className="h-5 text-[10px] px-1" onClick={() => setEditingBackend(false)}>Cancel</Button>
|
||||
</span>
|
||||
<div className="flex gap-1">
|
||||
<Button variant="outline" size="sm" className="h-7 text-xs flex-1" onClick={handleBackendSave}>Save</Button>
|
||||
<Button variant="ghost" size="sm" className="h-7 text-xs flex-1" onClick={() => setEditingBackend(false)}>Cancel</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className={cn('font-mono', !hasRoutes && 'underline decoration-dotted cursor-pointer hover:text-foreground')}
|
||||
onClick={!hasRoutes ? () => setEditingBackend(true) : undefined}
|
||||
disabled={hasRoutes}
|
||||
>
|
||||
{hasRoutes ? `${domain.routes!.length} backends` : draft.backendAddress}
|
||||
</button>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-mono text-xs bg-muted rounded px-2 py-1">
|
||||
{hasRoutes ? `${domain.routes!.length} backends` : draft.backendAddress}
|
||||
</span>
|
||||
{!hasRoutes && (
|
||||
<Button variant="ghost" size="sm" className="h-7 w-7 p-0"
|
||||
onClick={() => setEditingBackend(true)}
|
||||
>
|
||||
<Pencil className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{isL7 && domain.routes && domain.routes.length > 0 && (
|
||||
<div className="bg-muted/30 rounded-md p-2 space-y-1">
|
||||
<div className="text-[10px] text-muted-foreground font-medium mb-1">Route table</div>
|
||||
{domain.routes.map((route, i) => (
|
||||
<div key={i} className="flex items-center gap-2 text-[10px] flex-wrap">
|
||||
<span className="font-mono text-muted-foreground truncate">
|
||||
{route.paths?.length ? route.paths.join(', ') : '/*'}
|
||||
</span>
|
||||
<span className="text-muted-foreground">{'->'}</span>
|
||||
<span className="font-mono">{route.backend.address}</span>
|
||||
{route.ipAllow && route.ipAllow.length > 0 && (
|
||||
<span className="text-muted-foreground flex items-center gap-0.5">
|
||||
{route.ipAllow.length} IP rules
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Route table */}
|
||||
{isL7 && domain.routes && domain.routes.length > 0 && (
|
||||
<div className="bg-muted/30 rounded-md p-2 space-y-1">
|
||||
<div className="text-[10px] text-muted-foreground font-medium mb-1">Route table</div>
|
||||
{domain.routes.map((route, i) => (
|
||||
<div key={i} className="flex items-center gap-2 text-[10px] flex-wrap">
|
||||
<span className="font-mono text-muted-foreground truncate">
|
||||
{route.paths?.length ? route.paths.join(', ') : '/*'}
|
||||
</span>
|
||||
<span className="text-muted-foreground">{'->'}</span>
|
||||
<span className="font-mono">{route.backend.address}</span>
|
||||
{route.ipAllow && route.ipAllow.length > 0 && (
|
||||
<span className="text-muted-foreground flex items-center gap-0.5">
|
||||
{route.ipAllow.length} IP rules
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Warnings */}
|
||||
{isDnsOnly && (
|
||||
<div className="flex items-center gap-2 text-[10px] text-amber-600 dark:text-amber-400 bg-amber-50/50 dark:bg-amber-950/20 rounded-md px-3 py-1.5">
|
||||
<AlertCircle className="h-3 w-3 shrink-0" />
|
||||
No firewall, proxy, or TLS protection — traffic goes directly to your server
|
||||
</div>
|
||||
)}
|
||||
{isL7 && !hasCert && (
|
||||
<div className="flex items-center gap-2 text-[10px] text-red-600 dark:text-red-400 bg-red-50/50 dark:bg-red-950/20 rounded-md px-3 py-1.5">
|
||||
<AlertCircle className="h-3 w-3 shrink-0" />
|
||||
L7 routing is inactive until a TLS certificate is provisioned
|
||||
</div>
|
||||
)}
|
||||
{draft.public && ddnsRecord && !ddnsRecord.ok && (
|
||||
<div className="flex items-center gap-2 text-[10px] text-red-600 dark:text-red-400 bg-red-50/50 dark:bg-red-950/20 rounded-md px-3 py-1.5">
|
||||
<AlertCircle className="h-3 w-3 shrink-0" />
|
||||
DDNS sync failed{ddnsRecord.error ? `: ${ddnsRecord.error}` : ''}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -507,16 +520,18 @@ export function DomainCard({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* === Footer: subdomains + source + deregister === */}
|
||||
{/* === Footer: subdomains (desktop only) + source + deregister === */}
|
||||
<div className="flex items-center justify-between pt-2 border-t border-border/50">
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
type="button"
|
||||
className="text-[10px] text-muted-foreground hover:text-foreground transition-colors"
|
||||
onClick={() => updateDraft({ subdomains: !draft.subdomains })}
|
||||
>
|
||||
{draft.subdomains ? '*.wildcard on' : 'wildcard off'}
|
||||
</button>
|
||||
{!isMobile && (
|
||||
<button
|
||||
type="button"
|
||||
className="text-[10px] text-muted-foreground hover:text-foreground transition-colors"
|
||||
onClick={() => updateDraft({ subdomains: !draft.subdomains })}
|
||||
>
|
||||
{draft.subdomains ? '*.wildcard on' : 'wildcard off'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
Source: <span className="font-mono">{domain.source}</span>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useMemo, useEffect, useState, useCallback, useRef } from 'react';
|
||||
import { Link } from 'react-router';
|
||||
import {
|
||||
ReactFlow, Handle, Position, applyNodeChanges,
|
||||
type Node, type Edge, type NodeProps, type NodeChange, type ReactFlowInstance,
|
||||
@@ -169,23 +168,17 @@ function FlowNode({ data }: NodeProps) {
|
||||
<>
|
||||
{leftHandle}
|
||||
<div className="flex flex-col gap-1">
|
||||
<Link to="/firewall" className="group nopan nodrag" style={{ pointerEvents: 'all' }} onPointerDown={(e) => e.stopPropagation()}>
|
||||
<TopologyNode
|
||||
label="Firewall"
|
||||
status={fwOk ? 'ok' : 'na'}
|
||||
icon={<Shield className="h-3 w-3" />}
|
||||
className="group-hover:border-primary/40"
|
||||
/>
|
||||
</Link>
|
||||
<Link to="/crowdsec" className="group nopan nodrag" style={{ pointerEvents: 'all' }} onPointerDown={(e) => e.stopPropagation()}>
|
||||
<TopologyNode
|
||||
label="CrowdSec"
|
||||
sublabel={isL4 ? 'IP only' : 'HTTP + IP'}
|
||||
status={csOk ? 'ok' : 'na'}
|
||||
icon={<ShieldAlert className="h-3 w-3" />}
|
||||
className="group-hover:border-primary/40"
|
||||
/>
|
||||
</Link>
|
||||
<TopologyNode
|
||||
label="Firewall"
|
||||
status={fwOk ? 'ok' : 'na'}
|
||||
icon={<Shield className="h-3 w-3" />}
|
||||
/>
|
||||
<TopologyNode
|
||||
label="CrowdSec"
|
||||
sublabel={isL4 ? 'IP only' : 'HTTP + IP'}
|
||||
status={csOk ? 'ok' : 'na'}
|
||||
icon={<ShieldAlert className="h-3 w-3" />}
|
||||
/>
|
||||
</div>
|
||||
{rightHandle}
|
||||
</>
|
||||
@@ -317,15 +310,12 @@ function FlowNode({ data }: NodeProps) {
|
||||
return (
|
||||
<>
|
||||
{leftHandle}
|
||||
<Link to="/vpn" className="nopan nodrag" style={{ pointerEvents: 'all' }} onPointerDown={(e) => e.stopPropagation()}>
|
||||
<TopologyNode
|
||||
label="VPN"
|
||||
sublabel={running ? `${peerCount} peer${peerCount !== 1 ? 's' : ''}` : 'Not running'}
|
||||
status={running ? 'ok' : 'na'}
|
||||
icon={<Lock className="h-3.5 w-3.5" />}
|
||||
className="hover:border-primary/40"
|
||||
/>
|
||||
</Link>
|
||||
<TopologyNode
|
||||
label="VPN"
|
||||
sublabel={running ? `${peerCount} peer${peerCount !== 1 ? 's' : ''}` : 'Not running'}
|
||||
status={running ? 'ok' : 'na'}
|
||||
icon={<Lock className="h-3.5 w-3.5" />}
|
||||
/>
|
||||
{rightHandle}
|
||||
</>
|
||||
);
|
||||
@@ -336,16 +326,13 @@ function FlowNode({ data }: NodeProps) {
|
||||
return (
|
||||
<>
|
||||
{leftHandle}
|
||||
<Link to="/" className="nopan nodrag" style={{ pointerEvents: 'all' }} onPointerDown={(e) => e.stopPropagation()}>
|
||||
<TopologyNode
|
||||
label="Router"
|
||||
sublabel={ports}
|
||||
status="na"
|
||||
icon={<Router className="h-3 w-3" />}
|
||||
className="hover:border-primary/40"
|
||||
tooltip="Your LAN router port-forwards these ports to Wild Central. See Dashboard for setup."
|
||||
/>
|
||||
</Link>
|
||||
<TopologyNode
|
||||
label="Router"
|
||||
sublabel={ports}
|
||||
status="na"
|
||||
icon={<Router className="h-3 w-3" />}
|
||||
tooltip="Your LAN router port-forwards these ports to Wild Central"
|
||||
/>
|
||||
{rightHandle}
|
||||
</>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user