Simplify detection UI.
This commit is contained in:
@@ -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,69 +467,60 @@ 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">
|
||||
{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>
|
||||
<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
|
||||
onClick={() => handleAddFromDiscovery(discovered)}
|
||||
size="sm"
|
||||
>
|
||||
Add to Cluster
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => handleAddFromDiscovery(discovered)}
|
||||
size="sm"
|
||||
>
|
||||
Add to Cluster
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</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">
|
||||
<Input
|
||||
type="text"
|
||||
value={addNodeIp}
|
||||
onChange={(e) => setAddNodeIp(e.target.value)}
|
||||
placeholder="192.168.8.128"
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
onClick={handleAddNode}
|
||||
disabled={isGettingHardware}
|
||||
variant="secondary"
|
||||
>
|
||||
{isGettingHardware ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||
Detecting...
|
||||
</>
|
||||
) : (
|
||||
'Add Node'
|
||||
)}
|
||||
</Button>
|
||||
{/* 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 font-mono"
|
||||
/>
|
||||
<Button
|
||||
onClick={handleAddNode}
|
||||
disabled={isGettingHardware}
|
||||
size="sm"
|
||||
>
|
||||
{isGettingHardware ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||
Detecting...
|
||||
</>
|
||||
) : (
|
||||
'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>
|
||||
|
||||
|
||||
@@ -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,21 +447,25 @@ export function NodeForm({
|
||||
name="disk"
|
||||
control={control}
|
||||
rules={{ required: 'Disk is required' }}
|
||||
render={({ field }) => (
|
||||
<Select value={field.value || ''} onValueChange={field.onChange}>
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectValue placeholder="Select a disk" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{diskOptions.map((disk) => (
|
||||
<SelectItem key={disk.path} value={disk.path}>
|
||||
{disk.path}
|
||||
{disk.size > 0 && ` (${formatBytes(disk.size)})`}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
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>
|
||||
<SelectContent>
|
||||
{diskOptions.map((disk) => (
|
||||
<SelectItem key={disk.path} value={disk.path}>
|
||||
{disk.path}
|
||||
{disk.size > 0 && ` (${formatBytes(disk.size)})`}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Controller
|
||||
@@ -487,45 +505,30 @@ 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}>
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectValue placeholder="Select interface..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{interfaceOptions.map((iface) => (
|
||||
<SelectItem key={iface} value={iface}>
|
||||
{iface}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
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>
|
||||
<SelectContent>
|
||||
{interfaceOptions.map((iface) => (
|
||||
<SelectItem key={iface} value={iface}>
|
||||
{iface}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Controller
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user