Simplify detection UI.

This commit is contained in:
2025-11-09 00:42:38 +00:00
parent a63519968e
commit 35bc44bc32
6 changed files with 123 additions and 138 deletions

View File

@@ -24,7 +24,6 @@ export function ClusterNodesComponent() {
addNode, addNode,
addError, addError,
deleteNode, deleteNode,
isDeleting,
deleteError, deleteError,
discover, discover,
isDiscovering, isDiscovering,
@@ -50,7 +49,6 @@ export function ClusterNodesComponent() {
const { data: clusterStatusData } = useClusterStatus(currentInstance || ''); const { data: clusterStatusData } = useClusterStatus(currentInstance || '');
const [discoverSubnet, setDiscoverSubnet] = useState('');
const [addNodeIp, setAddNodeIp] = useState(''); const [addNodeIp, setAddNodeIp] = useState('');
const [discoverError, setDiscoverError] = useState<string | null>(null); const [discoverError, setDiscoverError] = useState<string | null>(null);
const [detectError, setDetectError] = useState<string | null>(null); const [detectError, setDetectError] = useState<string | null>(null);
@@ -182,7 +180,6 @@ export function ClusterNodesComponent() {
role: data.role, role: data.role,
disk: data.disk, disk: data.disk,
target_ip: data.targetIp, target_ip: data.targetIp,
current_ip: data.currentIp,
interface: data.interface, interface: data.interface,
schematic_id: data.schematicId, schematic_id: data.schematicId,
maintenance: data.maintenance, maintenance: data.maintenance,
@@ -198,9 +195,7 @@ export function ClusterNodesComponent() {
nodeName: drawerState.node.hostname, nodeName: drawerState.node.hostname,
updates: { updates: {
role: data.role, role: data.role,
disk: data.disk,
target_ip: data.targetIp, target_ip: data.targetIp,
current_ip: data.currentIp,
interface: data.interface, interface: data.interface,
schematic_id: data.schematicId, schematic_id: data.schematicId,
maintenance: data.maintenance, maintenance: data.maintenance,
@@ -231,8 +226,8 @@ export function ClusterNodesComponent() {
const handleDiscover = () => { const handleDiscover = () => {
setDiscoverError(null); setDiscoverError(null);
setDiscoverSuccess(null); setDiscoverSuccess(null);
// Pass subnet only if it's not empty, otherwise auto-detect // Always use auto-detect to scan all local networks
discover(discoverSubnet || undefined); discover(undefined);
}; };
@@ -268,7 +263,9 @@ export function ClusterNodesComponent() {
// Check if cluster is already bootstrapped using cluster status // Check if cluster is already bootstrapped using cluster status
// The backend checks for kubeconfig existence and cluster connectivity // The backend checks for kubeconfig existence and cluster connectivity
const hasBootstrapped = clusterStatus?.ready === true; // Status is "not_bootstrapped" when kubeconfig doesn't exist
// Any other status (ready, degraded, unreachable) means cluster is bootstrapped
const hasBootstrapped = clusterStatus?.status !== 'not_bootstrapped';
return hasReadyControlPlane && !hasBootstrapped; return hasReadyControlPlane && !hasBootstrapped;
}, [assignedNodes, clusterStatus]); }, [assignedNodes, clusterStatus]);
@@ -433,26 +430,21 @@ export function ClusterNodesComponent() {
</Alert> </Alert>
)} )}
{/* DISCOVERY SECTION - Scan subnet for nodes */} {/* ADD NODES SECTION - Discovery and manual add combined */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6 mb-6"> <div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6 mb-6">
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-4"> <h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-4">
Discover Nodes on Network Add Nodes to Cluster
</h3> </h3>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4"> <p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
Scan a specific subnet or leave empty to auto-detect all local networks Discover nodes on the network or manually add by IP address
</p> </p>
<div className="flex gap-3 mb-4"> {/* Discovery button */}
<Input <div className="flex gap-2 mb-4">
type="text"
value={discoverSubnet}
onChange={(e) => setDiscoverSubnet(e.target.value)}
placeholder="192.168.1.0/24 (optional - leave empty to auto-detect)"
className="flex-1"
/>
<Button <Button
onClick={handleDiscover} onClick={handleDiscover}
disabled={isDiscovering || discoveryStatus?.active} disabled={isDiscovering || discoveryStatus?.active}
className="flex-1"
> >
{isDiscovering || discoveryStatus?.active ? ( {isDiscovering || discoveryStatus?.active ? (
<> <>
@@ -460,7 +452,7 @@ export function ClusterNodesComponent() {
Discovering... Discovering...
</> </>
) : ( ) : (
'Discover' 'Discover Nodes'
)} )}
</Button> </Button>
{(isDiscovering || discoveryStatus?.active) && ( {(isDiscovering || discoveryStatus?.active) && (
@@ -475,69 +467,60 @@ export function ClusterNodesComponent() {
)} )}
</div> </div>
{/* Discovered nodes display */}
{discoveryStatus?.nodes_found && discoveryStatus.nodes_found.length > 0 && ( {discoveryStatus?.nodes_found && discoveryStatus.nodes_found.length > 0 && (
<div className="mt-6"> <div className="space-y-3 mb-4">
<h4 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3"> {discoveryStatus.nodes_found.map((discovered) => (
Discovered {discoveryStatus.nodes_found.length} node(s) <div key={discovered.ip} className="border border-gray-300 dark:border-gray-600 rounded-lg p-4">
</h4> <div className="flex items-center justify-between">
<div className="space-y-3"> <div>
{discoveryStatus.nodes_found.map((discovered) => ( <p className="font-medium font-mono text-gray-900 dark:text-gray-100">{discovered.ip}</p>
<div key={discovered.ip} className="border border-gray-300 dark:border-gray-600 rounded-lg p-4"> {discovered.version && discovered.version !== 'maintenance' && (
<div className="flex items-center justify-between">
<div>
<p className="font-medium font-mono text-gray-900 dark:text-gray-100">{discovered.ip}</p>
<p className="text-sm text-gray-600 dark:text-gray-400"> <p className="text-sm text-gray-600 dark:text-gray-400">
Maintenance mode{discovered.version ? ` • Talos ${discovered.version}` : ''} {discovered.version}
</p> </p>
{discovered.hostname && ( )}
<p className="text-xs text-gray-500 dark:text-gray-500 mt-1">{discovered.hostname}</p>
)}
</div>
<Button
onClick={() => handleAddFromDiscovery(discovered)}
size="sm"
>
Add to Cluster
</Button>
</div> </div>
<Button
onClick={() => handleAddFromDiscovery(discovered)}
size="sm"
>
Add to Cluster
</Button>
</div> </div>
))} </div>
</div> ))}
</div> </div>
)} )}
</div>
{/* ADD NODE SECTION - Add single node by IP */} {/* Manual add by IP - styled like a list item */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6 mb-6"> <div className="border border-dashed border-gray-300 dark:border-gray-600 rounded-lg p-4">
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-4"> <div className="flex items-center gap-3">
Add Single Node <Input
</h3> type="text"
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4"> value={addNodeIp}
Add a node by IP address to detect hardware and configure onChange={(e) => setAddNodeIp(e.target.value)}
</p> placeholder="192.168.8.128"
className="flex-1 font-mono"
<div className="flex gap-3"> />
<Input <Button
type="text" onClick={handleAddNode}
value={addNodeIp} disabled={isGettingHardware}
onChange={(e) => setAddNodeIp(e.target.value)} size="sm"
placeholder="192.168.8.128" >
className="flex-1" {isGettingHardware ? (
/> <>
<Button <Loader2 className="h-4 w-4 animate-spin mr-2" />
onClick={handleAddNode} Detecting...
disabled={isGettingHardware} </>
variant="secondary" ) : (
> 'Add to Cluster'
{isGettingHardware ? ( )}
<> </Button>
<Loader2 className="h-4 w-4 animate-spin mr-2" /> </div>
Detecting... <p className="text-xs text-gray-500 dark:text-gray-500 mt-2">
</> Add a node by IP address if not discovered automatically
) : ( </p>
'Add Node'
)}
</Button>
</div> </div>
</div> </div>

View File

@@ -17,7 +17,6 @@ export interface NodeFormData {
role: 'controlplane' | 'worker'; role: 'controlplane' | 'worker';
disk: string; disk: string;
targetIp: string; targetIp: string;
currentIp?: string;
interface?: string; interface?: string;
schematicId?: string; schematicId?: string;
maintenance: boolean; maintenance: boolean;
@@ -111,8 +110,7 @@ function getInitialValues(
hostname: initial?.hostname || defaultHostname, hostname: initial?.hostname || defaultHostname,
role, role,
disk: defaultDisk, disk: defaultDisk,
targetIp: initial?.targetIp || '', // Don't auto-fill targetIp from detection targetIp: initial?.targetIp || detection?.ip || '', // Auto-fill from detection
currentIp: initial?.currentIp || detection?.ip || '', // Auto-fill from detection
interface: defaultInterface, interface: defaultInterface,
schematicId: initial?.schematicId || '', schematicId: initial?.schematicId || '',
maintenance: initial?.maintenance ?? true, maintenance: initial?.maintenance ?? true,
@@ -152,15 +150,24 @@ export function NodeForm({
const role = watch('role'); const role = watch('role');
const hostname = watch('hostname'); const hostname = watch('hostname');
// Reset form when initialValues change (e.g., switching to configure a different node) // Reset form when switching between different nodes in configure mode
// This ensures select boxes and all fields show the current values // This ensures select boxes and all fields show the current values
// Use a ref to track the hostname to avoid infinite loops from object reference changes // Use refs to track both the hostname and mode to avoid unnecessary resets
const prevHostnameRef = useRef<string | undefined>(undefined); const prevHostnameRef = useRef<string | undefined>(undefined);
const prevModeRef = useRef<'add' | 'configure' | undefined>(undefined);
useEffect(() => { useEffect(() => {
const currentHostname = initialValues?.hostname; const currentHostname = initialValues?.hostname;
// Only reset if the hostname actually changed (switching between nodes) const currentMode = initialValues?.hostname ? 'configure' : 'add';
if (currentHostname !== prevHostnameRef.current) {
// Only reset if we're actually switching between different nodes in configure mode
// or switching from add to configure mode (or vice versa)
const modeChanged = currentMode !== prevModeRef.current;
const hostnameChanged = currentMode === 'configure' && currentHostname !== prevHostnameRef.current;
if (modeChanged || hostnameChanged) {
prevHostnameRef.current = currentHostname; prevHostnameRef.current = currentHostname;
prevModeRef.current = currentMode;
const newValues = getInitialValues(initialValues, detection, nodes, hostnamePrefix); const newValues = getInitialValues(initialValues, detection, nodes, hostnamePrefix);
reset(newValues); reset(newValues);
} }
@@ -291,6 +298,13 @@ export function NodeForm({
// Skip if this is an existing node (configure mode) // Skip if this is an existing node (configure mode)
if (initialValues?.targetIp) return; if (initialValues?.targetIp) return;
// Skip if there's a detection IP (hardware detection provides the actual IP)
if (detection?.ip) return;
// Skip if there's already a targetIp from detection
const currentTargetIp = watch('targetIp');
if (currentTargetIp && role === 'worker') return; // For workers, keep any existing value
const clusterConfig = instanceConfig?.cluster as any; const clusterConfig = instanceConfig?.cluster as any;
const vip = clusterConfig?.nodes?.control?.vip as string | undefined; const vip = clusterConfig?.nodes?.control?.vip as string | undefined;
@@ -342,8 +356,8 @@ export function NodeForm({
// Set the calculated IP // Set the calculated IP
setValue('targetIp', `${vipPrefix}.${nextOctet}`); setValue('targetIp', `${vipPrefix}.${nextOctet}`);
} else if (role === 'worker') { } else if (role === 'worker' && !detection?.ip) {
// For new worker nodes, clear target IP (let user set if needed) // For worker nodes without detection, only clear if it looks like an auto-calculated control plane IP
const currentTargetIp = watch('targetIp'); const currentTargetIp = watch('targetIp');
// Only clear if it looks like an auto-calculated IP (matches VIP pattern) // Only clear if it looks like an auto-calculated IP (matches VIP pattern)
if (currentTargetIp && vip) { if (currentTargetIp && vip) {
@@ -353,7 +367,7 @@ export function NodeForm({
} }
} }
} }
}, [role, instanceConfig, nodes, setValue, watch, initialValues?.targetIp]); }, [role, instanceConfig, nodes, setValue, watch, initialValues?.targetIp, detection?.ip]);
// Build disk options from both detection and initial values // Build disk options from both detection and initial values
const diskOptions = (() => { const diskOptions = (() => {
@@ -433,21 +447,25 @@ export function NodeForm({
name="disk" name="disk"
control={control} control={control}
rules={{ required: 'Disk is required' }} rules={{ required: 'Disk is required' }}
render={({ field }) => ( render={({ field }) => {
<Select value={field.value || ''} onValueChange={field.onChange}> // Ensure we have a value - use the field value or fall back to first option
<SelectTrigger className="mt-1"> const value = field.value || (diskOptions.length > 0 ? diskOptions[0].path : '');
<SelectValue placeholder="Select a disk" /> return (
</SelectTrigger> <Select value={value} onValueChange={field.onChange}>
<SelectContent> <SelectTrigger className="mt-1">
{diskOptions.map((disk) => ( <SelectValue placeholder="Select a disk" />
<SelectItem key={disk.path} value={disk.path}> </SelectTrigger>
{disk.path} <SelectContent>
{disk.size > 0 && ` (${formatBytes(disk.size)})`} {diskOptions.map((disk) => (
</SelectItem> <SelectItem key={disk.path} value={disk.path}>
))} {disk.path}
</SelectContent> {disk.size > 0 && ` (${formatBytes(disk.size)})`}
</Select> </SelectItem>
)} ))}
</SelectContent>
</Select>
);
}}
/> />
) : ( ) : (
<Controller <Controller
@@ -487,45 +505,30 @@ export function NodeForm({
)} )}
</div> </div>
<div>
<Label htmlFor="currentIp">Current IP Address</Label>
<Input
id="currentIp"
type="text"
{...register('currentIp')}
className="mt-1"
disabled={!!detection?.ip}
/>
{errors.currentIp && (
<p className="text-sm text-red-600 mt-1">{errors.currentIp.message}</p>
)}
{detection?.ip && (
<p className="mt-1 text-xs text-muted-foreground">
Auto-detected from hardware (read-only)
</p>
)}
</div>
<div> <div>
<Label htmlFor="interface">Network Interface</Label> <Label htmlFor="interface">Network Interface</Label>
{interfaceOptions.length > 0 ? ( {interfaceOptions.length > 0 ? (
<Controller <Controller
name="interface" name="interface"
control={control} control={control}
render={({ field }) => ( render={({ field }) => {
<Select value={field.value || ''} onValueChange={field.onChange}> // Ensure we have a value - use the field value or fall back to first option
<SelectTrigger className="mt-1"> const value = field.value || (interfaceOptions.length > 0 ? interfaceOptions[0] : '');
<SelectValue placeholder="Select interface..." /> return (
</SelectTrigger> <Select value={value} onValueChange={field.onChange}>
<SelectContent> <SelectTrigger className="mt-1">
{interfaceOptions.map((iface) => ( <SelectValue placeholder="Select interface..." />
<SelectItem key={iface} value={iface}> </SelectTrigger>
{iface} <SelectContent>
</SelectItem> {interfaceOptions.map((iface) => (
))} <SelectItem key={iface} value={iface}>
</SelectContent> {iface}
</Select> </SelectItem>
)} ))}
</SelectContent>
</Select>
);
}}
/> />
) : ( ) : (
<Controller <Controller

View File

@@ -50,7 +50,6 @@ export function NodeFormDrawer({
role: node.role, role: node.role,
disk: node.disk, disk: node.disk,
targetIp: node.target_ip, targetIp: node.target_ip,
currentIp: node.current_ip,
interface: node.interface, interface: node.interface,
schematicId: node.schematic_id, schematicId: node.schematic_id,
maintenance: node.maintenance ?? true, maintenance: node.maintenance ?? true,

View File

@@ -116,8 +116,8 @@ export function ClusterHealthPage() {
<Skeleton className="h-8 w-24" /> <Skeleton className="h-8 w-24" />
) : status ? ( ) : status ? (
<div> <div>
<Badge variant={status.ready ? 'outline' : 'secondary'} className={status.ready ? 'border-green-500' : ''}> <Badge variant={status.status === 'ready' ? 'outline' : 'secondary'} className={status.status === 'ready' ? 'border-green-500' : ''}>
{status.ready ? 'Ready' : 'Not Ready'} {status.status === 'ready' ? 'Ready' : 'Not Ready'}
</Badge> </Badge>
<p className="text-xs text-muted-foreground mt-2"> <p className="text-xs text-muted-foreground mt-2">
{status.nodes} nodes total {status.nodes} nodes total

View File

@@ -154,7 +154,7 @@ export function DashboardPage() {
<div> <div>
<div className="text-xl font-bold font-mono">{status.kubernetesVersion}</div> <div className="text-xl font-bold font-mono">{status.kubernetesVersion}</div>
<p className="text-xs text-muted-foreground mt-1"> <p className="text-xs text-muted-foreground mt-1">
{status.ready ? 'Ready' : 'Not ready'} {status.status === 'ready' ? 'Ready' : 'Not ready'}
</p> </p>
</div> </div>
) : ( ) : (

View File

@@ -12,7 +12,7 @@ export interface NodeStatus {
} }
export interface ClusterStatus { export interface ClusterStatus {
ready: boolean; status: string; // "ready", "pending", "error", "not_bootstrapped", "unreachable", "degraded"
nodes: number; nodes: number;
controlPlaneNodes: number; controlPlaneNodes: number;
workerNodes: number; workerNodes: number;