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,
addError,
deleteNode,
isDeleting,
deleteError,
discover,
isDiscovering,
@@ -50,7 +49,6 @@ export function ClusterNodesComponent() {
const { data: clusterStatusData } = useClusterStatus(currentInstance || '');
const [discoverSubnet, setDiscoverSubnet] = useState('');
const [addNodeIp, setAddNodeIp] = useState('');
const [discoverError, setDiscoverError] = useState<string | null>(null);
const [detectError, setDetectError] = useState<string | null>(null);
@@ -182,7 +180,6 @@ export function ClusterNodesComponent() {
role: data.role,
disk: data.disk,
target_ip: data.targetIp,
current_ip: data.currentIp,
interface: data.interface,
schematic_id: data.schematicId,
maintenance: data.maintenance,
@@ -198,9 +195,7 @@ export function ClusterNodesComponent() {
nodeName: drawerState.node.hostname,
updates: {
role: data.role,
disk: data.disk,
target_ip: data.targetIp,
current_ip: data.currentIp,
interface: data.interface,
schematic_id: data.schematicId,
maintenance: data.maintenance,
@@ -231,8 +226,8 @@ export function ClusterNodesComponent() {
const handleDiscover = () => {
setDiscoverError(null);
setDiscoverSuccess(null);
// Pass subnet only if it's not empty, otherwise auto-detect
discover(discoverSubnet || undefined);
// Always use auto-detect to scan all local networks
discover(undefined);
};
@@ -268,7 +263,9 @@ export function ClusterNodesComponent() {
// Check if cluster is already bootstrapped using cluster status
// 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;
}, [assignedNodes, clusterStatus]);
@@ -433,26 +430,21 @@ export function ClusterNodesComponent() {
</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">
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-4">
Discover Nodes on Network
Add Nodes to Cluster
</h3>
<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>
<div className="flex gap-3 mb-4">
<Input
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"
/>
{/* Discovery button */}
<div className="flex gap-2 mb-4">
<Button
onClick={handleDiscover}
disabled={isDiscovering || discoveryStatus?.active}
className="flex-1"
>
{isDiscovering || discoveryStatus?.active ? (
<>
@@ -460,7 +452,7 @@ export function ClusterNodesComponent() {
Discovering...
</>
) : (
'Discover'
'Discover Nodes'
)}
</Button>
{(isDiscovering || discoveryStatus?.active) && (
@@ -475,22 +467,18 @@ export function ClusterNodesComponent() {
)}
</div>
{/* Discovered nodes display */}
{discoveryStatus?.nodes_found && discoveryStatus.nodes_found.length > 0 && (
<div className="mt-6">
<h4 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
Discovered {discoveryStatus.nodes_found.length} node(s)
</h4>
<div className="space-y-3">
<div className="space-y-3 mb-4">
{discoveryStatus.nodes_found.map((discovered) => (
<div key={discovered.ip} className="border border-gray-300 dark:border-gray-600 rounded-lg p-4">
<div className="flex items-center justify-between">
<div>
<p className="font-medium font-mono text-gray-900 dark:text-gray-100">{discovered.ip}</p>
{discovered.version && discovered.version !== 'maintenance' && (
<p className="text-sm text-gray-600 dark:text-gray-400">
Maintenance mode{discovered.version ? ` • Talos ${discovered.version}` : ''}
{discovered.version}
</p>
{discovered.hostname && (
<p className="text-xs text-gray-500 dark:text-gray-500 mt-1">{discovered.hostname}</p>
)}
</div>
<Button
@@ -503,31 +491,22 @@ export function ClusterNodesComponent() {
</div>
))}
</div>
</div>
)}
</div>
{/* ADD NODE SECTION - Add single node by IP */}
<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">
Add Single Node
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
Add a node by IP address to detect hardware and configure
</p>
<div className="flex gap-3">
{/* Manual add by IP - styled like a list item */}
<div className="border border-dashed border-gray-300 dark:border-gray-600 rounded-lg p-4">
<div className="flex items-center gap-3">
<Input
type="text"
value={addNodeIp}
onChange={(e) => setAddNodeIp(e.target.value)}
placeholder="192.168.8.128"
className="flex-1"
className="flex-1 font-mono"
/>
<Button
onClick={handleAddNode}
disabled={isGettingHardware}
variant="secondary"
size="sm"
>
{isGettingHardware ? (
<>
@@ -535,10 +514,14 @@ export function ClusterNodesComponent() {
Detecting...
</>
) : (
'Add Node'
'Add to Cluster'
)}
</Button>
</div>
<p className="text-xs text-gray-500 dark:text-gray-500 mt-2">
Add a node by IP address if not discovered automatically
</p>
</div>
</div>
<div className="space-y-4">

View File

@@ -17,7 +17,6 @@ export interface NodeFormData {
role: 'controlplane' | 'worker';
disk: string;
targetIp: string;
currentIp?: string;
interface?: string;
schematicId?: string;
maintenance: boolean;
@@ -111,8 +110,7 @@ function getInitialValues(
hostname: initial?.hostname || defaultHostname,
role,
disk: defaultDisk,
targetIp: initial?.targetIp || '', // Don't auto-fill targetIp from detection
currentIp: initial?.currentIp || detection?.ip || '', // Auto-fill from detection
targetIp: initial?.targetIp || detection?.ip || '', // Auto-fill from detection
interface: defaultInterface,
schematicId: initial?.schematicId || '',
maintenance: initial?.maintenance ?? true,
@@ -152,15 +150,24 @@ export function NodeForm({
const role = watch('role');
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
// 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 prevModeRef = useRef<'add' | 'configure' | undefined>(undefined);
useEffect(() => {
const currentHostname = initialValues?.hostname;
// Only reset if the hostname actually changed (switching between nodes)
if (currentHostname !== prevHostnameRef.current) {
const currentMode = initialValues?.hostname ? 'configure' : 'add';
// 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;
prevModeRef.current = currentMode;
const newValues = getInitialValues(initialValues, detection, nodes, hostnamePrefix);
reset(newValues);
}
@@ -291,6 +298,13 @@ export function NodeForm({
// Skip if this is an existing node (configure mode)
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 vip = clusterConfig?.nodes?.control?.vip as string | undefined;
@@ -342,8 +356,8 @@ export function NodeForm({
// Set the calculated IP
setValue('targetIp', `${vipPrefix}.${nextOctet}`);
} else if (role === 'worker') {
// For new worker nodes, clear target IP (let user set if needed)
} else if (role === 'worker' && !detection?.ip) {
// For worker nodes without detection, only clear if it looks like an auto-calculated control plane IP
const currentTargetIp = watch('targetIp');
// Only clear if it looks like an auto-calculated IP (matches VIP pattern)
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
const diskOptions = (() => {
@@ -433,8 +447,11 @@ export function NodeForm({
name="disk"
control={control}
rules={{ required: 'Disk is required' }}
render={({ field }) => (
<Select value={field.value || ''} onValueChange={field.onChange}>
render={({ field }) => {
// Ensure we have a value - use the field value or fall back to first option
const value = field.value || (diskOptions.length > 0 ? diskOptions[0].path : '');
return (
<Select value={value} onValueChange={field.onChange}>
<SelectTrigger className="mt-1">
<SelectValue placeholder="Select a disk" />
</SelectTrigger>
@@ -447,7 +464,8 @@ export function NodeForm({
))}
</SelectContent>
</Select>
)}
);
}}
/>
) : (
<Controller
@@ -487,33 +505,17 @@ export function NodeForm({
)}
</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>
<Label htmlFor="interface">Network Interface</Label>
{interfaceOptions.length > 0 ? (
<Controller
name="interface"
control={control}
render={({ field }) => (
<Select value={field.value || ''} onValueChange={field.onChange}>
render={({ field }) => {
// Ensure we have a value - use the field value or fall back to first option
const value = field.value || (interfaceOptions.length > 0 ? interfaceOptions[0] : '');
return (
<Select value={value} onValueChange={field.onChange}>
<SelectTrigger className="mt-1">
<SelectValue placeholder="Select interface..." />
</SelectTrigger>
@@ -525,7 +527,8 @@ export function NodeForm({
))}
</SelectContent>
</Select>
)}
);
}}
/>
) : (
<Controller

View File

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

View File

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

View File

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

View File

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