Compare commits

...

8 Commits

Author SHA1 Message Date
Paul Payne
b324540ce0 Sidebar cleanup. 2025-11-21 16:16:03 +00:00
Paul Payne
6bbf48fe20 Fix tests. 2025-11-09 00:59:36 +00:00
Paul Payne
4307bc9996 Adding a node should immediately provision it. 2025-11-09 00:58:06 +00:00
Paul Payne
35bc44bc32 Simplify detection UI. 2025-11-09 00:42:38 +00:00
Paul Payne
a63519968e Node delete should reset. 2025-11-09 00:15:52 +00:00
Paul Payne
960282d4ed Make node status live. 2025-11-08 23:16:42 +00:00
Paul Payne
854a6023cd Reset a node to maintenance mode. 2025-11-08 22:56:48 +00:00
Paul Payne
ee63423cab ISOs need version AND schema 2025-11-08 22:24:46 +00:00
22 changed files with 583 additions and 395 deletions

View File

@@ -1,5 +1,5 @@
import { NavLink, useParams } from 'react-router'; import { NavLink, useParams } from 'react-router';
import { Server, Play, Container, AppWindow, Settings, CloudLightning, Sun, Moon, Monitor, ChevronDown, Globe, Usb } from 'lucide-react'; import { Server, Play, Container, AppWindow, Settings, CloudLightning, Sun, Moon, Monitor, ChevronDown, Globe, Usb, Download, CheckCircle } from 'lucide-react';
import { cn } from '../lib/utils'; import { cn } from '../lib/utils';
import { import {
Sidebar, Sidebar,
@@ -71,8 +71,24 @@ export function AppSidebar() {
</div> </div>
</div> </div>
<div className="px-2 group-data-[collapsible=icon]:px-2"> <div className="px-2 group-data-[collapsible=icon]:px-2">
<div className="flex items-center gap-2">
<div className="flex-1 min-w-0">
<InstanceSwitcher /> <InstanceSwitcher />
</div> </div>
<NavLink to={`/instances/${instanceId}/cloud`}>
{({ isActive }) => (
<SidebarMenuButton
isActive={isActive}
tooltip="Configure instance settings"
size="sm"
className="h-8 w-8 p-0"
>
<Settings className="h-4 w-4" />
</SidebarMenuButton>
)}
</NavLink>
</div>
</div>
</SidebarHeader> </SidebarHeader>
<SidebarContent> <SidebarContent>
@@ -100,29 +116,6 @@ export function AppSidebar() {
</NavLink> </NavLink>
</SidebarMenuItem> </SidebarMenuItem>
<SidebarMenuItem>
<NavLink to={`/instances/${instanceId}/cloud`}>
{({ isActive }) => (
<SidebarMenuButton
isActive={isActive}
tooltip="Configure cloud settings and domains"
>
<div className={cn(
"p-1 rounded-md",
isActive && "bg-primary/10"
)}>
<CloudLightning className={cn(
"h-4 w-4",
isActive && "text-primary",
!isActive && "text-muted-foreground"
)} />
</div>
<span className="truncate">Cloud</span>
</SidebarMenuButton>
)}
</NavLink>
</SidebarMenuItem>
<Collapsible defaultOpen className="group/collapsible"> <Collapsible defaultOpen className="group/collapsible">
<SidebarMenuItem> <SidebarMenuItem>
<CollapsibleTrigger asChild> <CollapsibleTrigger asChild>
@@ -177,17 +170,6 @@ export function AppSidebar() {
</NavLink> </NavLink>
</SidebarMenuSubButton> </SidebarMenuSubButton>
</SidebarMenuSubItem> */} </SidebarMenuSubItem> */}
<SidebarMenuSubItem>
<SidebarMenuSubButton asChild>
<NavLink to={`/instances/${instanceId}/iso`}>
<div className="p-1 rounded-md">
<Usb className="h-4 w-4" />
</div>
<span className="truncate">ISO / USB</span>
</NavLink>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
</SidebarMenuSub> </SidebarMenuSub>
</CollapsibleContent> </CollapsibleContent>
</SidebarMenuItem> </SidebarMenuItem>
@@ -225,21 +207,58 @@ export function AppSidebar() {
</NavLink> </NavLink>
</SidebarMenuSubButton> </SidebarMenuSubButton>
</SidebarMenuSubItem> </SidebarMenuSubItem>
<SidebarMenuSubItem>
<SidebarMenuSubButton asChild>
<NavLink to={`/instances/${instanceId}/iso`}>
<div className="p-1 rounded-md">
<Usb className="h-4 w-4" />
</div>
<span className="truncate">ISO / USB</span>
</NavLink>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
</SidebarMenuSub> </SidebarMenuSub>
</CollapsibleContent> </CollapsibleContent>
</SidebarMenuItem> </SidebarMenuItem>
</Collapsible> </Collapsible>
<Collapsible defaultOpen className="group/collapsible">
<SidebarMenuItem> <SidebarMenuItem>
<SidebarMenuButton asChild tooltip="Install and manage applications"> <CollapsibleTrigger asChild>
<NavLink to={`/instances/${instanceId}/apps`}> <SidebarMenuButton>
<div className="p-1 rounded-md">
<AppWindow className="h-4 w-4" /> <AppWindow className="h-4 w-4" />
</div> Apps
<span className="truncate">Apps</span> <ChevronDown className="ml-auto transition-transform group-data-[state=open]/collapsible:rotate-180" />
</NavLink>
</SidebarMenuButton> </SidebarMenuButton>
</CollapsibleTrigger>
<CollapsibleContent>
<SidebarMenuSub>
<SidebarMenuSubItem>
<SidebarMenuSubButton asChild>
<NavLink to={`/instances/${instanceId}/apps/available`}>
<div className="p-1 rounded-md">
<Download className="h-4 w-4" />
</div>
<span className="truncate">Available</span>
</NavLink>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
<SidebarMenuSubItem>
<SidebarMenuSubButton asChild>
<NavLink to={`/instances/${instanceId}/apps/installed`}>
<div className="p-1 rounded-md">
<CheckCircle className="h-4 w-4" />
</div>
<span className="truncate">Installed</span>
</NavLink>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
</SidebarMenuSub>
</CollapsibleContent>
</SidebarMenuItem> </SidebarMenuItem>
</Collapsible>
<SidebarMenuItem> <SidebarMenuItem>
<SidebarMenuButton asChild tooltip="Advanced settings and system configuration"> <SidebarMenuButton asChild tooltip="Advanced settings and system configuration">

View File

@@ -1,4 +1,5 @@
import { useState } from 'react'; import { useState } from 'react';
import { useLocation } from 'react-router';
import { Card } from './ui/card'; import { Card } from './ui/card';
import { Button } from './ui/button'; import { Button } from './ui/button';
import { Badge } from './ui/badge'; import { Badge } from './ui/badge';
@@ -37,6 +38,7 @@ interface MergedApp extends App {
type TabView = 'available' | 'installed'; type TabView = 'available' | 'installed';
export function AppsComponent() { export function AppsComponent() {
const location = useLocation();
const { currentInstance } = useInstanceContext(); const { currentInstance } = useInstanceContext();
const { data: availableAppsData, isLoading: loadingAvailable, error: availableError } = useAvailableApps(); const { data: availableAppsData, isLoading: loadingAvailable, error: availableError } = useAvailableApps();
const { const {
@@ -51,7 +53,8 @@ export function AppsComponent() {
isDeleting isDeleting
} = useDeployedApps(currentInstance); } = useDeployedApps(currentInstance);
const [activeTab, setActiveTab] = useState<TabView>('available'); // Determine active tab from URL path
const activeTab: TabView = location.pathname.endsWith('/installed') ? 'installed' : 'available';
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
const [selectedCategory, setSelectedCategory] = useState<string>('all'); const [selectedCategory, setSelectedCategory] = useState<string>('all');
const [configDialogOpen, setConfigDialogOpen] = useState(false); const [configDialogOpen, setConfigDialogOpen] = useState(false);
@@ -323,22 +326,6 @@ export function AppsComponent() {
</div> </div>
</div> </div>
{/* Tab Navigation */}
<div className="flex gap-2 mb-6 border-b pb-4">
<Button
variant={activeTab === 'available' ? 'default' : 'outline'}
onClick={() => setActiveTab('available')}
>
Available Apps ({availableApps.length})
</Button>
<Button
variant={activeTab === 'installed' ? 'default' : 'outline'}
onClick={() => setActiveTab('installed')}
>
Installed Apps ({installedApps.length})
</Button>
</div>
<div className="flex flex-col sm:flex-row gap-4 mb-6"> <div className="flex flex-col sm:flex-row gap-4 mb-6">
<div className="relative flex-1"> <div className="relative flex-1">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" /> <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />

View File

@@ -8,6 +8,7 @@ import { Cpu, HardDrive, Network, Monitor, CheckCircle, AlertCircle, BookOpen, E
import { useInstanceContext } from '../hooks/useInstanceContext'; import { useInstanceContext } from '../hooks/useInstanceContext';
import { useNodes, useDiscoveryStatus } from '../hooks/useNodes'; import { useNodes, useDiscoveryStatus } from '../hooks/useNodes';
import { useCluster } from '../hooks/useCluster'; import { useCluster } from '../hooks/useCluster';
import { useClusterStatus } from '../services/api/hooks/useCluster';
import { BootstrapModal } from './cluster/BootstrapModal'; import { BootstrapModal } from './cluster/BootstrapModal';
import { NodeStatusBadge } from './nodes/NodeStatusBadge'; import { NodeStatusBadge } from './nodes/NodeStatusBadge';
import { NodeFormDrawer } from './nodes/NodeFormDrawer'; import { NodeFormDrawer } from './nodes/NodeFormDrawer';
@@ -23,7 +24,6 @@ export function ClusterNodesComponent() {
addNode, addNode,
addError, addError,
deleteNode, deleteNode,
isDeleting,
deleteError, deleteError,
discover, discover,
isDiscovering, isDiscovering,
@@ -47,7 +47,8 @@ export function ClusterNodesComponent() {
status: clusterStatus status: clusterStatus
} = useCluster(currentInstance); } = useCluster(currentInstance);
const [discoverSubnet, setDiscoverSubnet] = useState(''); const { data: clusterStatusData } = useClusterStatus(currentInstance || '');
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);
@@ -63,6 +64,7 @@ export function ClusterNodesComponent() {
open: false, open: false,
mode: 'add', mode: 'add',
}); });
const [deletingNodeHostname, setDeletingNodeHostname] = useState<string | null>(null);
const closeDrawer = () => setDrawerState({ ...drawerState, open: false }); const closeDrawer = () => setDrawerState({ ...drawerState, open: false });
@@ -173,16 +175,27 @@ export function ClusterNodesComponent() {
}; };
const handleAddSubmit = async (data: NodeFormData) => { const handleAddSubmit = async (data: NodeFormData) => {
await addNode({ const nodeData = {
hostname: data.hostname, hostname: data.hostname,
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,
}); };
// Add node configuration (if this fails, error is shown and drawer stays open)
await addNode(nodeData);
// Apply configuration immediately for new nodes
try {
await applyNode(data.hostname);
} catch (applyError) {
// Apply failed but node is added - user can use Apply button on card
console.error('Failed to apply node configuration:', applyError);
}
closeDrawer(); closeDrawer();
setAddNodeIp(''); setAddNodeIp('');
}; };
@@ -194,15 +207,11 @@ export function ClusterNodesComponent() {
nodeName: drawerState.node.hostname, nodeName: drawerState.node.hostname,
updates: { updates: {
role: data.role, role: data.role,
config: {
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,
}, },
},
}); });
closeDrawer(); closeDrawer();
}; };
@@ -214,23 +223,31 @@ export function ClusterNodesComponent() {
await applyNode(drawerState.node.hostname); await applyNode(drawerState.node.hostname);
}; };
const handleDeleteNode = (hostname: string) => { const handleDeleteNode = async (hostname: string) => {
if (!currentInstance) return; if (!currentInstance) return;
if (confirm(`Are you sure you want to remove node ${hostname}?`)) { if (confirm(`Reset and remove node ${hostname}?\n\nThis will reset the node and remove it from the cluster. The node will reboot to maintenance mode and can be reconfigured.`)) {
deleteNode(hostname); setDeletingNodeHostname(hostname);
try {
await deleteNode(hostname);
} finally {
setDeletingNodeHostname(null);
}
} }
}; };
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);
}; };
// Derive status from backend state flags for each node // Derive status from backend state flags for each node
const assignedNodes = nodes.map(node => { const assignedNodes = nodes.map(node => {
// Get runtime status from cluster status
const runtimeStatus = clusterStatusData?.node_statuses?.[node.hostname];
let status = 'pending'; let status = 'pending';
if (node.maintenance) { if (node.maintenance) {
status = 'provisioning'; status = 'provisioning';
@@ -239,7 +256,14 @@ export function ClusterNodesComponent() {
} else if (node.applied) { } else if (node.applied) {
status = 'ready'; status = 'ready';
} }
return { ...node, status };
return {
...node,
status,
isReachable: runtimeStatus?.ready,
inKubernetes: runtimeStatus?.ready, // Whether in cluster (from backend 'ready' field)
kubernetesReady: runtimeStatus?.kubernetes_ready, // Whether K8s Ready condition is true
};
}); });
// Check if cluster needs bootstrap // Check if cluster needs bootstrap
@@ -251,7 +275,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?.status !== 'not_bootstrapped' && clusterStatus?.status !== undefined; // 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]);
@@ -416,26 +442,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 ? (
<> <>
@@ -443,7 +464,7 @@ export function ClusterNodesComponent() {
Discovering... Discovering...
</> </>
) : ( ) : (
'Discover' 'Discover Nodes'
)} )}
</Button> </Button>
{(isDiscovering || discoveryStatus?.active) && ( {(isDiscovering || discoveryStatus?.active) && (
@@ -458,22 +479,18 @@ 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">
Discovered {discoveryStatus.nodes_found.length} node(s)
</h4>
<div className="space-y-3">
{discoveryStatus.nodes_found.map((discovered) => ( {discoveryStatus.nodes_found.map((discovered) => (
<div key={discovered.ip} className="border border-gray-300 dark:border-gray-600 rounded-lg p-4"> <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 className="flex items-center justify-between">
<div> <div>
<p className="font-medium font-mono text-gray-900 dark:text-gray-100">{discovered.ip}</p> <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"> <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> </div>
<Button <Button
@@ -486,31 +503,22 @@ export function ClusterNodesComponent() {
</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
</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 <Input
type="text" type="text"
value={addNodeIp} value={addNodeIp}
onChange={(e) => setAddNodeIp(e.target.value)} onChange={(e) => setAddNodeIp(e.target.value)}
placeholder="192.168.8.128" placeholder="192.168.8.128"
className="flex-1" className="flex-1 font-mono"
/> />
<Button <Button
onClick={handleAddNode} onClick={handleAddNode}
disabled={isGettingHardware} disabled={isGettingHardware}
variant="secondary" size="sm"
> >
{isGettingHardware ? ( {isGettingHardware ? (
<> <>
@@ -518,10 +526,14 @@ export function ClusterNodesComponent() {
Detecting... Detecting...
</> </>
) : ( ) : (
'Add Node' 'Add to Cluster'
)} )}
</Button> </Button>
</div> </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>
<div className="space-y-4"> <div className="space-y-4">
@@ -576,10 +588,21 @@ export function ClusterNodesComponent() {
)} )}
</div> </div>
)} )}
{node.talosVersion && ( {(node.version || node.schematic_id) && (
<div className="text-xs text-muted-foreground mt-1"> <div className="text-xs text-muted-foreground mt-1">
Talos: {node.talosVersion} {node.version && <span>Talos: {node.version}</span>}
{node.kubernetesVersion && ` • K8s: ${node.kubernetesVersion}`} {node.version && node.schematic_id && <span> </span>}
{node.schematic_id && (
<span
title={node.schematic_id}
onClick={() => {
navigator.clipboard.writeText(node.schematic_id!);
}}
className="cursor-pointer hover:text-primary hover:underline"
>
Schema: {node.schematic_id.substring(0, 8)}...
</span>
)}
</div> </div>
)} )}
</div> </div>
@@ -604,9 +627,9 @@ export function ClusterNodesComponent() {
size="sm" size="sm"
variant="destructive" variant="destructive"
onClick={() => handleDeleteNode(node.hostname)} onClick={() => handleDeleteNode(node.hostname)}
disabled={isDeleting} disabled={deletingNodeHostname === node.hostname}
> >
{isDeleting ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Delete'} {deletingNodeHostname === node.hostname ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Delete'}
</Button> </Button>
</div> </div>
</div> </div>

View File

@@ -221,7 +221,7 @@ export function AppDetailModal({
<ReactMarkdown <ReactMarkdown
components={{ components={{
// Style code blocks // Style code blocks
code: ({node, inline, className, children, ...props}) => { code: ({inline, children, ...props}) => {
return inline ? ( return inline ? (
<code className="bg-muted px-1 py-0.5 rounded text-sm" {...props}> <code className="bg-muted px-1 py-0.5 rounded text-sm" {...props}>
{children} {children}
@@ -233,7 +233,7 @@ export function AppDetailModal({
); );
}, },
// Make links open in new tab // Make links open in new tab
a: ({node, children, href, ...props}) => ( a: ({children, href, ...props}) => (
<a href={href} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline" {...props}> <a href={href} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline" {...props}>
{children} {children}
</a> </a>

View File

@@ -106,7 +106,7 @@ describe('NodeForm Integration Tests', () => {
}); });
}); });
it('auto-fills currentIp from detection', async () => { it('auto-fills targetIp from detection', async () => {
const config = createMockConfig(); const config = createMockConfig();
vi.mocked(useInstanceConfig).mockReturnValue(mockUseInstanceConfig(config)); vi.mocked(useInstanceConfig).mockReturnValue(mockUseInstanceConfig(config));
vi.mocked(useNodes).mockReturnValue(mockUseNodes([])); vi.mocked(useNodes).mockReturnValue(mockUseNodes([]));
@@ -122,8 +122,8 @@ describe('NodeForm Integration Tests', () => {
{ wrapper: createWrapper(createTestQueryClient()) } { wrapper: createWrapper(createTestQueryClient()) }
); );
const currentIpInput = screen.getByLabelText(/current ip/i) as HTMLInputElement; const targetIpInput = screen.getByLabelText(/target ip/i) as HTMLInputElement;
expect(currentIpInput.value).toBe('192.168.1.75'); expect(targetIpInput.value).toBe('192.168.1.75');
}); });
it('submits form with correct data', async () => { it('submits form with correct data', async () => {
@@ -132,7 +132,8 @@ describe('NodeForm Integration Tests', () => {
vi.mocked(useInstanceConfig).mockReturnValue(mockUseInstanceConfig(config)); vi.mocked(useInstanceConfig).mockReturnValue(mockUseInstanceConfig(config));
vi.mocked(useNodes).mockReturnValue(mockUseNodes([])); vi.mocked(useNodes).mockReturnValue(mockUseNodes([]));
const detection = createMockHardwareInfo(); // Don't provide detection.ip so VIP-based auto-calculation happens
const detection = createMockHardwareInfo({ ip: undefined });
render( render(
<NodeForm <NodeForm
@@ -154,7 +155,6 @@ describe('NodeForm Integration Tests', () => {
role: 'controlplane', role: 'controlplane',
disk: '/dev/sda', disk: '/dev/sda',
interface: 'eth0', interface: 'eth0',
currentIp: '192.168.1.50',
maintenance: true, maintenance: true,
schematicId: 'default-schematic-123', schematicId: 'default-schematic-123',
targetIp: '192.168.1.101', targetIp: '192.168.1.101',
@@ -201,7 +201,8 @@ describe('NodeForm Integration Tests', () => {
vi.mocked(useInstanceConfig).mockReturnValue(mockUseInstanceConfig(config)); vi.mocked(useInstanceConfig).mockReturnValue(mockUseInstanceConfig(config));
vi.mocked(useNodes).mockReturnValue(mockUseNodes([])); vi.mocked(useNodes).mockReturnValue(mockUseNodes([]));
const detection = createMockHardwareInfo(); // Don't provide detection.ip so VIP-based auto-calculation happens
const detection = createMockHardwareInfo({ ip: undefined });
render( render(
<NodeForm <NodeForm
@@ -239,7 +240,8 @@ describe('NodeForm Integration Tests', () => {
vi.mocked(useInstanceConfig).mockReturnValue(mockUseInstanceConfig(config)); vi.mocked(useInstanceConfig).mockReturnValue(mockUseInstanceConfig(config));
vi.mocked(useNodes).mockReturnValue(mockUseNodes(existingNodes)); vi.mocked(useNodes).mockReturnValue(mockUseNodes(existingNodes));
const detection = createMockHardwareInfo(); // Don't provide detection.ip so VIP-based auto-calculation happens
const detection = createMockHardwareInfo({ ip: undefined });
render( render(
<NodeForm <NodeForm
@@ -275,7 +277,8 @@ describe('NodeForm Integration Tests', () => {
vi.mocked(useInstanceConfig).mockReturnValue(mockUseInstanceConfig(config)); vi.mocked(useInstanceConfig).mockReturnValue(mockUseInstanceConfig(config));
vi.mocked(useNodes).mockReturnValue(mockUseNodes(existingNodes)); vi.mocked(useNodes).mockReturnValue(mockUseNodes(existingNodes));
const detection = createMockHardwareInfo(); // Don't provide detection.ip so VIP-based auto-calculation happens
const detection = createMockHardwareInfo({ ip: undefined });
render( render(
<NodeForm <NodeForm
@@ -306,7 +309,6 @@ describe('NodeForm Integration Tests', () => {
role: 'controlplane', role: 'controlplane',
disk: '/dev/nvme0n1', disk: '/dev/nvme0n1',
targetIp: '192.168.1.105', targetIp: '192.168.1.105',
currentIp: '192.168.1.60',
interface: 'eth1', interface: 'eth1',
schematicId: 'existing-schematic-456', schematicId: 'existing-schematic-456',
maintenance: false, maintenance: false,
@@ -327,14 +329,8 @@ describe('NodeForm Integration Tests', () => {
const targetIpInput = screen.getByLabelText(/target ip/i) as HTMLInputElement; const targetIpInput = screen.getByLabelText(/target ip/i) as HTMLInputElement;
expect(targetIpInput.value).toBe('192.168.1.105'); expect(targetIpInput.value).toBe('192.168.1.105');
const currentIpInput = screen.getByLabelText(/current ip/i) as HTMLInputElement;
expect(currentIpInput.value).toBe('192.168.1.60');
const schematicInput = screen.getByLabelText(/schematic id/i) as HTMLInputElement; const schematicInput = screen.getByLabelText(/schematic id/i) as HTMLInputElement;
expect(schematicInput.value).toBe('existing-schematic-456'); expect(schematicInput.value).toBe('existing-schematic-456');
const maintenanceCheckbox = screen.getByLabelText(/maintenance/i) as HTMLInputElement;
expect(maintenanceCheckbox.checked).toBe(false);
}); });
it('does NOT auto-generate hostname', async () => { it('does NOT auto-generate hostname', async () => {
@@ -418,7 +414,6 @@ describe('NodeForm Integration Tests', () => {
role: 'controlplane', role: 'controlplane',
disk: '/dev/nvme0n1', disk: '/dev/nvme0n1',
targetIp: '192.168.1.105', targetIp: '192.168.1.105',
currentIp: '192.168.1.60',
interface: 'eth0', interface: 'eth0',
schematicId: 'existing-schematic-456', schematicId: 'existing-schematic-456',
maintenance: false, maintenance: false,
@@ -553,7 +548,6 @@ describe('NodeForm Integration Tests', () => {
disk: '/dev/nvme0n1', disk: '/dev/nvme0n1',
interface: 'eth1', interface: 'eth1',
targetIp: '192.168.1.105', targetIp: '192.168.1.105',
currentIp: '192.168.1.60',
schematicId: 'existing-schematic', schematicId: 'existing-schematic',
maintenance: false, maintenance: false,
}; };
@@ -589,7 +583,6 @@ describe('NodeForm Integration Tests', () => {
disk: '/dev/nvme0n1', // NOT /dev/sda from detection disk: '/dev/nvme0n1', // NOT /dev/sda from detection
interface: 'eth1', // NOT eth0 from detection interface: 'eth1', // NOT eth0 from detection
targetIp: '192.168.1.105', targetIp: '192.168.1.105',
currentIp: '192.168.1.60',
}); });
}); });
}); });
@@ -881,8 +874,9 @@ describe('NodeForm Integration Tests', () => {
const hostnameInput = screen.getByLabelText(/hostname/i) as HTMLInputElement; const hostnameInput = screen.getByLabelText(/hostname/i) as HTMLInputElement;
expect(hostnameInput.value).toBe('test-control-1'); expect(hostnameInput.value).toBe('test-control-1');
const currentIpInput = screen.getByLabelText(/current ip/i) as HTMLInputElement; // Control plane nodes should auto-calculate targetIp from VIP (192.168.1.100 + 1)
expect(currentIpInput.value).toBe(''); const targetIpInput = screen.getByLabelText(/target ip/i) as HTMLInputElement;
expect(targetIpInput.value).toBe('192.168.1.101');
const diskInput = screen.getByLabelText(/disk/i) as HTMLInputElement; const diskInput = screen.getByLabelText(/disk/i) as HTMLInputElement;
expect(diskInput.value).toBe(''); expect(diskInput.value).toBe('');
@@ -906,8 +900,8 @@ describe('NodeForm Integration Tests', () => {
{ wrapper: createWrapper(createTestQueryClient()) } { wrapper: createWrapper(createTestQueryClient()) }
); );
const currentIpInput = screen.getByLabelText(/current ip/i) as HTMLInputElement; const targetIpInput = screen.getByLabelText(/target ip/i) as HTMLInputElement;
expect(currentIpInput.value).toBe('192.168.1.75'); expect(targetIpInput.value).toBe('192.168.1.75');
}); });
it('handles detection with no disks', async () => { it('handles detection with no disks', async () => {
@@ -1219,7 +1213,6 @@ describe('NodeForm Integration Tests', () => {
role: 'worker' as const, role: 'worker' as const,
disk: '/dev/sda', disk: '/dev/sda',
interface: 'eth0', interface: 'eth0',
currentIp: '192.168.1.50',
maintenance: true, maintenance: true,
}; };

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;
@@ -28,6 +27,7 @@ interface NodeFormProps {
detection?: HardwareInfo; detection?: HardwareInfo;
onSubmit: (data: NodeFormData) => Promise<void>; onSubmit: (data: NodeFormData) => Promise<void>;
onApply?: (data: NodeFormData) => Promise<void>; onApply?: (data: NodeFormData) => Promise<void>;
onCancel?: () => void;
submitLabel?: string; submitLabel?: string;
showApplyButton?: boolean; showApplyButton?: boolean;
instanceName?: string; instanceName?: string;
@@ -110,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,
@@ -123,6 +122,7 @@ export function NodeForm({
detection, detection,
onSubmit, onSubmit,
onApply, onApply,
onCancel,
submitLabel = 'Save', submitLabel = 'Save',
showApplyButton = false, showApplyButton = false,
instanceName, instanceName,
@@ -150,19 +150,29 @@ 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);
} }
}, [initialValues, detection, nodes, hostnamePrefix, reset]); // eslint-disable-next-line react-hooks/exhaustive-deps
}, [initialValues, detection, nodes, hostnamePrefix]);
// Set default role based on existing control plane nodes // Set default role based on existing control plane nodes
useEffect(() => { useEffect(() => {
@@ -176,14 +186,16 @@ export function NodeForm({
setValue('role', defaultRole); setValue('role', defaultRole);
} }
} }
}, [nodes, initialValues?.role, setValue, watch]); // eslint-disable-next-line react-hooks/exhaustive-deps
}, [nodes, initialValues?.role]);
// Pre-populate schematic ID from cluster config if available // Pre-populate schematic ID from cluster config if available
useEffect(() => { useEffect(() => {
if (!schematicId && instanceConfig?.cluster?.nodes?.talos?.schematicId) { if (!schematicId && instanceConfig?.cluster?.nodes?.talos?.schematicId) {
setValue('schematicId', instanceConfig.cluster.nodes.talos.schematicId); setValue('schematicId', instanceConfig.cluster.nodes.talos.schematicId);
} }
}, [instanceConfig, schematicId, setValue]); // eslint-disable-next-line react-hooks/exhaustive-deps
}, [instanceConfig, schematicId]);
// Auto-generate hostname when role changes (only for NEW nodes without initial hostname) // Auto-generate hostname when role changes (only for NEW nodes without initial hostname)
useEffect(() => { useEffect(() => {
@@ -282,13 +294,21 @@ export function NodeForm({
} }
} }
} }
}, [role, nodes, hostnamePrefix, setValue, watch, isExistingNode]); // eslint-disable-next-line react-hooks/exhaustive-deps
}, [role, nodes, hostnamePrefix, isExistingNode]);
// Auto-calculate target IP for control plane nodes // Auto-calculate target IP for control plane nodes
useEffect(() => { useEffect(() => {
// 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;
@@ -340,8 +360,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) {
@@ -351,7 +371,8 @@ export function NodeForm({
} }
} }
} }
}, [role, instanceConfig, nodes, setValue, watch, initialValues?.targetIp]); // eslint-disable-next-line react-hooks/exhaustive-deps
}, [role, instanceConfig, nodes, 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 = (() => {
@@ -431,8 +452,11 @@ 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
const value = field.value || (diskOptions.length > 0 ? diskOptions[0].path : '');
return (
<Select value={value} onValueChange={field.onChange}>
<SelectTrigger className="mt-1"> <SelectTrigger className="mt-1">
<SelectValue placeholder="Select a disk" /> <SelectValue placeholder="Select a disk" />
</SelectTrigger> </SelectTrigger>
@@ -445,7 +469,8 @@ export function NodeForm({
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
)} );
}}
/> />
) : ( ) : (
<Controller <Controller
@@ -485,33 +510,17 @@ 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
const value = field.value || (interfaceOptions.length > 0 ? interfaceOptions[0] : '');
return (
<Select value={value} onValueChange={field.onChange}>
<SelectTrigger className="mt-1"> <SelectTrigger className="mt-1">
<SelectValue placeholder="Select interface..." /> <SelectValue placeholder="Select interface..." />
</SelectTrigger> </SelectTrigger>
@@ -523,7 +532,8 @@ export function NodeForm({
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
)} );
}}
/> />
) : ( ) : (
<Controller <Controller
@@ -557,19 +567,30 @@ export function NodeForm({
</p> </p>
</div> </div>
<div className="flex items-center gap-2">
<input
id="maintenance"
type="checkbox"
{...register('maintenance')}
className="h-4 w-4 rounded border-input"
/>
<Label htmlFor="maintenance" className="font-normal">
Start in maintenance mode
</Label>
</div>
<div className="flex gap-2"> <div className="flex gap-2">
{onCancel && (
<Button
type="button"
variant="outline"
onClick={() => {
reset();
onCancel();
}}
disabled={isSubmitting}
>
Cancel
</Button>
)}
{showApplyButton && onApply ? (
<Button
type="button"
onClick={handleSubmit(onApply)}
disabled={isSubmitting}
className="flex-1"
>
{isSubmitting ? 'Applying...' : 'Apply Configuration'}
</Button>
) : (
<Button <Button
type="submit" type="submit"
disabled={isSubmitting} disabled={isSubmitting}
@@ -577,17 +598,6 @@ export function NodeForm({
> >
{isSubmitting ? 'Saving...' : submitLabel} {isSubmitting ? 'Saving...' : submitLabel}
</Button> </Button>
{showApplyButton && onApply && (
<Button
type="button"
onClick={handleSubmit(onApply)}
disabled={isSubmitting}
variant="secondary"
className="flex-1"
>
{isSubmitting ? 'Applying...' : 'Apply Configuration'}
</Button>
)} )}
</div> </div>
</form> </form>

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,
@@ -58,6 +57,7 @@ export function NodeFormDrawer({
detection={detection} detection={detection}
onSubmit={onSubmit} onSubmit={onSubmit}
onApply={onApply} onApply={onApply}
onCancel={onClose}
submitLabel={mode === 'add' ? 'Add Node' : 'Save'} submitLabel={mode === 'add' ? 'Add Node' : 'Save'}
showApplyButton={mode === 'configure'} showApplyButton={mode === 'configure'}
instanceName={instanceName} instanceName={instanceName}

View File

@@ -18,10 +18,12 @@ interface ServiceConfigEditorProps {
export function ServiceConfigEditor({ export function ServiceConfigEditor({
instanceName, instanceName,
serviceName, serviceName,
manifest: _manifestProp, // Ignore the prop, fetch from status instead manifest: _manifest, // Ignore the prop, fetch from status instead
onClose, onClose,
onSuccess, onSuccess,
}: ServiceConfigEditorProps) { }: ServiceConfigEditorProps) {
// Suppress unused variable warning - kept for API compatibility
void _manifest;
const { config, isLoading: configLoading, updateConfig, isUpdating } = useServiceConfig(instanceName, serviceName); const { config, isLoading: configLoading, updateConfig, isUpdating } = useServiceConfig(instanceName, serviceName);
const { data: statusData, isLoading: statusLoading } = useServiceStatus(instanceName, serviceName); const { data: statusData, isLoading: statusLoading } = useServiceStatus(instanceName, serviceName);

View File

@@ -54,6 +54,9 @@ export function useNodes(instanceName: string | null | undefined) {
const applyMutation = useMutation({ const applyMutation = useMutation({
mutationFn: (nodeName: string) => nodesApi.apply(instanceName!, nodeName), mutationFn: (nodeName: string) => nodesApi.apply(instanceName!, nodeName),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['instances', instanceName, 'nodes'] });
},
}); });
const fetchTemplatesMutation = useMutation({ const fetchTemplatesMutation = useMutation({
@@ -71,6 +74,13 @@ export function useNodes(instanceName: string | null | undefined) {
mutationFn: (ip: string) => nodesApi.getHardware(instanceName!, ip), mutationFn: (ip: string) => nodesApi.getHardware(instanceName!, ip),
}); });
const resetMutation = useMutation({
mutationFn: (nodeName: string) => nodesApi.reset(instanceName!, nodeName),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['instances', instanceName, 'nodes'] });
},
});
return { return {
nodes: nodesQuery.data?.nodes || [], nodes: nodesQuery.data?.nodes || [],
isLoading: nodesQuery.isLoading, isLoading: nodesQuery.isLoading,
@@ -101,6 +111,9 @@ export function useNodes(instanceName: string | null | undefined) {
isFetchingTemplates: fetchTemplatesMutation.isPending, isFetchingTemplates: fetchTemplatesMutation.isPending,
cancelDiscovery: cancelDiscoveryMutation.mutate, cancelDiscovery: cancelDiscoveryMutation.mutate,
isCancellingDiscovery: cancelDiscoveryMutation.isPending, isCancellingDiscovery: cancelDiscoveryMutation.isPending,
resetNode: resetMutation.mutate,
isResetting: resetMutation.isPending,
resetError: resetMutation.error,
}; };
} }

View File

@@ -11,19 +11,17 @@ import {
BookOpen, BookOpen,
ExternalLink, ExternalLink,
CheckCircle, CheckCircle,
XCircle,
Usb, Usb,
ArrowLeft, ArrowLeft,
CloudLightning, CloudLightning,
} from 'lucide-react'; } from 'lucide-react';
import { useAssetList, useDownloadAsset, useAssetStatus } from '../../services/api/hooks/useAssets'; import { useAssetList, useDownloadAsset, useAssetStatus } from '../../services/api/hooks/useAssets';
import { assetsApi } from '../../services/api/assets'; import { assetsApi } from '../../services/api/assets';
import type { AssetType } from '../../services/api/types/asset';
export function AssetsIsoPage() { export function AssetsIsoPage() {
const { data, isLoading, error } = useAssetList(); const { data, isLoading, error } = useAssetList();
const downloadAsset = useDownloadAsset(); const downloadAsset = useDownloadAsset();
const [selectedSchematicId, setSelectedSchematicId] = useState<string | null>(null); const [selectedSchematicId] = useState<string | null>(null);
const [selectedVersion, setSelectedVersion] = useState('v1.8.0'); const [selectedVersion, setSelectedVersion] = useState('v1.8.0');
const { data: statusData } = useAssetStatus(selectedSchematicId); const { data: statusData } = useAssetStatus(selectedSchematicId);

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

@@ -15,22 +15,20 @@ import {
} from 'lucide-react'; } from 'lucide-react';
import { useAssetList, useDownloadAsset, useDeleteAsset } from '../../services/api/hooks/useAssets'; import { useAssetList, useDownloadAsset, useDeleteAsset } from '../../services/api/hooks/useAssets';
import { assetsApi } from '../../services/api/assets'; import { assetsApi } from '../../services/api/assets';
import type { Platform } from '../../services/api/types/asset'; import type { Platform, Asset } from '../../services/api/types/asset';
// Helper function to extract version from ISO filename // Helper function to extract platform from filename
// Filename format: talos-v1.11.2-metal-amd64.iso // Filename format: metal-amd64.iso
function extractVersionFromPath(path: string): string { function extractPlatformFromPath(path: string): string {
const filename = path.split('/').pop() || ''; const filename = path.split('/').pop() || '';
const match = filename.match(/talos-(v\d+\.\d+\.\d+)-metal/); const match = filename.match(/-(amd64|arm64)\./);
return match ? match[1] : 'unknown'; return match ? match[1] : 'unknown';
} }
// Helper function to extract platform from ISO filename // Type for ISO asset with schematic and version info
// Filename format: talos-v1.11.2-metal-amd64.iso interface IsoAssetWithMetadata extends Asset {
function extractPlatformFromPath(path: string): string { schematic_id: string;
const filename = path.split('/').pop() || ''; version: string;
const match = filename.match(/-(amd64|arm64)\.iso$/);
return match ? match[1] : 'unknown';
} }
export function IsoPage() { export function IsoPage() {
@@ -38,8 +36,8 @@ export function IsoPage() {
const downloadAsset = useDownloadAsset(); const downloadAsset = useDownloadAsset();
const deleteAsset = useDeleteAsset(); const deleteAsset = useDeleteAsset();
const [schematicId, setSchematicId] = useState(''); const [schematicId, setSchematicId] = useState('434a0300db532066f1098e05ac068159371d00f0aba0a3103a0e826e83825c82');
const [selectedVersion, setSelectedVersion] = useState('v1.11.2'); const [selectedVersion, setSelectedVersion] = useState('v1.11.5');
const [selectedPlatform, setSelectedPlatform] = useState<Platform>('amd64'); const [selectedPlatform, setSelectedPlatform] = useState<Platform>('amd64');
const [isDownloading, setIsDownloading] = useState(false); const [isDownloading, setIsDownloading] = useState(false);
@@ -53,10 +51,10 @@ export function IsoPage() {
try { try {
await downloadAsset.mutateAsync({ await downloadAsset.mutateAsync({
schematicId, schematicId,
request: {
version: selectedVersion, version: selectedVersion,
request: {
platform: selectedPlatform, platform: selectedPlatform,
assets: ['iso'] asset_types: ['iso']
}, },
}); });
// Refresh the list after download // Refresh the list after download
@@ -69,13 +67,13 @@ export function IsoPage() {
} }
}; };
const handleDelete = async (schematicIdToDelete: string) => { const handleDelete = async (schematicIdToDelete: string, versionToDelete: string) => {
if (!confirm('Are you sure you want to delete this schematic and all its assets? This action cannot be undone.')) { if (!confirm(`Are you sure you want to delete ${schematicIdToDelete}@${versionToDelete} and all its assets? This action cannot be undone.`)) {
return; return;
} }
try { try {
await deleteAsset.mutateAsync(schematicIdToDelete); await deleteAsset.mutateAsync({ schematicId: schematicIdToDelete, version: versionToDelete });
await refetch(); await refetch();
} catch (err) { } catch (err) {
console.error('Delete failed:', err); console.error('Delete failed:', err);
@@ -83,15 +81,14 @@ export function IsoPage() {
} }
}; };
// Find all ISO assets from all schematics (including multiple ISOs per schematic) // Find all ISO assets from all assets (schematic@version combinations)
const isoAssets = data?.schematics const isoAssets = data?.assets?.flatMap(asset => {
.flatMap(schematic => { // Get ALL ISO assets for this schematic@version
// Get ALL ISO assets for this schematic (not just the first one) const isoAssetsForAsset = asset.assets.filter(a => a.type === 'iso');
const isoAssetsForSchematic = schematic.assets.filter(asset => asset.type === 'iso'); return isoAssetsForAsset.map(isoAsset => ({
return isoAssetsForSchematic.map(isoAsset => ({
...isoAsset, ...isoAsset,
schematic_id: schematic.schematic_id, schematic_id: asset.schematic_id,
version: schematic.version version: asset.version
})); }));
}) || []; }) || [];
@@ -146,46 +143,6 @@ export function IsoPage() {
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
{/* Schematic ID Input */}
<div>
<label className="text-sm font-medium mb-2 block">
Schematic ID
<span className="text-muted-foreground ml-2">(64-character hex string)</span>
</label>
<input
type="text"
value={schematicId}
onChange={(e) => setSchematicId(e.target.value)}
placeholder="e.g., 434a0300db532066f1098e05ac068159371d00f0aba0a3103a0e826e83825c82"
className="w-full px-3 py-2 border rounded-lg bg-background font-mono text-sm"
/>
<p className="text-xs text-muted-foreground mt-1">
Get your schematic ID from the{' '}
<a
href="https://factory.talos.dev"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
Talos Image Factory
</a>
</p>
</div>
{/* Version Selection */}
<div>
<label className="text-sm font-medium mb-2 block">Talos Version</label>
<select
value={selectedVersion}
onChange={(e) => setSelectedVersion(e.target.value)}
className="w-full md:w-64 px-3 py-2 border rounded-lg bg-background"
>
<option value="v1.11.2">v1.11.2</option>
<option value="v1.11.1">v1.11.1</option>
<option value="v1.11.0">v1.11.0</option>
</select>
</div>
{/* Platform Selection */} {/* Platform Selection */}
<div> <div>
<label className="text-sm font-medium mb-2 block">Platform</label> <label className="text-sm font-medium mb-2 block">Platform</label>
@@ -215,6 +172,49 @@ export function IsoPage() {
</div> </div>
</div> </div>
{/* Version Selection */}
<div>
<label className="text-sm font-medium mb-2 block">Talos Version</label>
<select
value={selectedVersion}
onChange={(e) => setSelectedVersion(e.target.value)}
className="w-full md:w-64 px-3 py-2 border rounded-lg bg-background"
>
<option value="v1.11.5">v1.11.5</option>
<option value="v1.11.4">v1.11.4</option>
<option value="v1.11.3">v1.11.3</option>
<option value="v1.11.2">v1.11.2</option>
<option value="v1.11.1">v1.11.1</option>
<option value="v1.11.0">v1.11.0</option>
</select>
</div>
{/* Schematic ID Input */}
<div>
<label className="text-sm font-medium mb-2 block">
Schematic ID
<span className="text-muted-foreground ml-2">(64-character hex string)</span>
</label>
<input
type="text"
value={schematicId}
onChange={(e) => setSchematicId(e.target.value)}
placeholder="e.g., 434a0300db532066f1098e05ac068159371d00f0aba0a3103a0e826e83825c82"
className="w-full px-3 py-2 border rounded-lg bg-background font-mono text-sm"
/>
<p className="text-xs text-muted-foreground mt-1">
Get your schematic ID from the{' '}
<a
href="https://factory.talos.dev"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
Talos Image Factory
</a>
</p>
</div>
{/* Download Button */} {/* Download Button */}
<Button <Button
onClick={handleDownload} onClick={handleDownload}
@@ -264,11 +264,12 @@ export function IsoPage() {
</div> </div>
) : ( ) : (
<div className="space-y-3"> <div className="space-y-3">
{isoAssets.map((asset: any) => { {isoAssets.map((asset: IsoAssetWithMetadata) => {
const version = extractVersionFromPath(asset.path || '');
const platform = extractPlatformFromPath(asset.path || ''); const platform = extractPlatformFromPath(asset.path || '');
// Use composite key for React key
const compositeKey = `${asset.schematic_id}@${asset.version}`;
return ( return (
<Card key={asset.schematic_id} className="p-4"> <Card key={compositeKey} className="p-4">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<div className="p-2 bg-muted rounded-lg"> <div className="p-2 bg-muted rounded-lg">
<Disc className="h-5 w-5 text-primary" /> <Disc className="h-5 w-5 text-primary" />
@@ -276,7 +277,7 @@ export function IsoPage() {
<div className="flex-1"> <div className="flex-1">
<div className="flex items-center gap-2 mb-1"> <div className="flex items-center gap-2 mb-1">
<h5 className="font-medium">Talos ISO</h5> <h5 className="font-medium">Talos ISO</h5>
<Badge variant="outline">{version}</Badge> <Badge variant="outline">{asset.version}</Badge>
<Badge variant="outline" className="uppercase">{platform}</Badge> <Badge variant="outline" className="uppercase">{platform}</Badge>
{asset.downloaded ? ( {asset.downloaded ? (
<Badge variant="success" className="flex items-center gap-1"> <Badge variant="success" className="flex items-center gap-1">
@@ -292,7 +293,7 @@ export function IsoPage() {
</div> </div>
<div className="text-sm text-muted-foreground space-y-1"> <div className="text-sm text-muted-foreground space-y-1">
<div className="font-mono text-xs truncate"> <div className="font-mono text-xs truncate">
Schematic: {asset.schematic_id} {asset.schematic_id}@{asset.version}
</div> </div>
{asset.size && ( {asset.size && (
<div>Size: {(asset.size / 1024 / 1024).toFixed(2)} MB</div> <div>Size: {(asset.size / 1024 / 1024).toFixed(2)} MB</div>
@@ -305,7 +306,7 @@ export function IsoPage() {
size="sm" size="sm"
variant="outline" variant="outline"
onClick={() => { onClick={() => {
window.location.href = assetsApi.getAssetUrl(asset.schematic_id, 'iso'); window.location.href = assetsApi.getAssetUrl(asset.schematic_id, asset.version, 'iso');
}} }}
> >
<Download className="h-4 w-4 mr-1" /> <Download className="h-4 w-4 mr-1" />
@@ -315,7 +316,7 @@ export function IsoPage() {
size="sm" size="sm"
variant="outline" variant="outline"
className="text-red-600 hover:text-red-700 hover:bg-red-50" className="text-red-600 hover:text-red-700 hover:bg-red-50"
onClick={() => handleDelete(asset.schematic_id)} onClick={() => handleDelete(asset.schematic_id, asset.version)}
> >
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
</Button> </Button>

View File

@@ -101,8 +101,21 @@ export const routes: RouteObject[] = [
}, },
{ {
path: 'apps', path: 'apps',
children: [
{
index: true,
element: <Navigate to="available" replace />,
},
{
path: 'available',
element: <AppsPage />, element: <AppsPage />,
}, },
{
path: 'installed',
element: <AppsPage />,
},
],
},
{ {
path: 'advanced', path: 'advanced',
element: <AdvancedPage />, element: <AdvancedPage />,

View File

@@ -1,42 +1,42 @@
import { apiClient } from './client'; import { apiClient } from './client';
import type { AssetListResponse, Schematic, DownloadAssetRequest, AssetStatusResponse } from './types/asset'; import type { AssetListResponse, PXEAsset, DownloadAssetRequest, AssetStatusResponse } from './types/asset';
// Get API base URL // Get API base URL
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:5055'; const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:5055';
export const assetsApi = { export const assetsApi = {
// List all schematics // List all assets (schematic@version combinations)
list: async (): Promise<AssetListResponse> => { list: async (): Promise<AssetListResponse> => {
const response = await apiClient.get('/api/v1/assets'); const response = await apiClient.get('/api/v1/pxe/assets');
return response as AssetListResponse; return response as AssetListResponse;
}, },
// Get schematic details // Get asset details for specific schematic@version
get: async (schematicId: string): Promise<Schematic> => { get: async (schematicId: string, version: string): Promise<PXEAsset> => {
const response = await apiClient.get(`/api/v1/assets/${schematicId}`); const response = await apiClient.get(`/api/v1/pxe/assets/${schematicId}/${version}`);
return response as Schematic; return response as PXEAsset;
}, },
// Download assets for a schematic // Download assets for a schematic@version
download: async (schematicId: string, request: DownloadAssetRequest): Promise<{ message: string }> => { download: async (schematicId: string, version: string, request: DownloadAssetRequest): Promise<{ message: string }> => {
const response = await apiClient.post(`/api/v1/assets/${schematicId}/download`, request); const response = await apiClient.post(`/api/v1/pxe/assets/${schematicId}/${version}/download`, request);
return response as { message: string }; return response as { message: string };
}, },
// Get download status // Get download status
status: async (schematicId: string): Promise<AssetStatusResponse> => { status: async (schematicId: string, version: string): Promise<AssetStatusResponse> => {
const response = await apiClient.get(`/api/v1/assets/${schematicId}/status`); const response = await apiClient.get(`/api/v1/pxe/assets/${schematicId}/${version}/status`);
return response as AssetStatusResponse; return response as AssetStatusResponse;
}, },
// Get download URL for an asset (includes base URL for direct download) // Get download URL for an asset (includes base URL for direct download)
getAssetUrl: (schematicId: string, assetType: 'kernel' | 'initramfs' | 'iso'): string => { getAssetUrl: (schematicId: string, version: string, assetType: 'kernel' | 'initramfs' | 'iso'): string => {
return `${API_BASE_URL}/api/v1/assets/${schematicId}/pxe/${assetType}`; return `${API_BASE_URL}/api/v1/pxe/assets/${schematicId}/${version}/pxe/${assetType}`;
}, },
// Delete a schematic and all its assets // Delete an asset (schematic@version) and all its files
delete: async (schematicId: string): Promise<{ message: string }> => { delete: async (schematicId: string, version: string): Promise<{ message: string }> => {
const response = await apiClient.delete(`/api/v1/assets/${schematicId}`); const response = await apiClient.delete(`/api/v1/pxe/assets/${schematicId}/${version}`);
return response as { message: string }; return response as { message: string };
}, },
}; };

View File

@@ -9,19 +9,19 @@ export function useAssetList() {
}); });
} }
export function useAsset(schematicId: string | null | undefined) { export function useAsset(schematicId: string | null | undefined, version: string | null | undefined) {
return useQuery({ return useQuery({
queryKey: ['assets', schematicId], queryKey: ['assets', schematicId, version],
queryFn: () => assetsApi.get(schematicId!), queryFn: () => assetsApi.get(schematicId!, version!),
enabled: !!schematicId, enabled: !!schematicId && !!version,
}); });
} }
export function useAssetStatus(schematicId: string | null | undefined) { export function useAssetStatus(schematicId: string | null | undefined, version: string | null | undefined) {
return useQuery({ return useQuery({
queryKey: ['assets', schematicId, 'status'], queryKey: ['assets', schematicId, version, 'status'],
queryFn: () => assetsApi.status(schematicId!), queryFn: () => assetsApi.status(schematicId!, version!),
enabled: !!schematicId, enabled: !!schematicId && !!version,
refetchInterval: (query) => { refetchInterval: (query) => {
const data = query.state.data; const data = query.state.data;
// Poll every 2 seconds if downloading // Poll every 2 seconds if downloading
@@ -34,12 +34,12 @@ export function useDownloadAsset() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: ({ schematicId, request }: { schematicId: string; request: DownloadAssetRequest }) => mutationFn: ({ schematicId, version, request }: { schematicId: string; version: string; request: DownloadAssetRequest }) =>
assetsApi.download(schematicId, request), assetsApi.download(schematicId, version, request),
onSuccess: (_, variables) => { onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: ['assets'] }); queryClient.invalidateQueries({ queryKey: ['assets'] });
queryClient.invalidateQueries({ queryKey: ['assets', variables.schematicId] }); queryClient.invalidateQueries({ queryKey: ['assets', variables.schematicId, variables.version] });
queryClient.invalidateQueries({ queryKey: ['assets', variables.schematicId, 'status'] }); queryClient.invalidateQueries({ queryKey: ['assets', variables.schematicId, variables.version, 'status'] });
}, },
}); });
} }
@@ -48,11 +48,12 @@ export function useDeleteAsset() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: (schematicId: string) => assetsApi.delete(schematicId), mutationFn: ({ schematicId, version }: { schematicId: string; version: string }) =>
onSuccess: (_, schematicId) => { assetsApi.delete(schematicId, version),
onSuccess: (_, { schematicId, version }) => {
queryClient.invalidateQueries({ queryKey: ['assets'] }); queryClient.invalidateQueries({ queryKey: ['assets'] });
queryClient.invalidateQueries({ queryKey: ['assets', schematicId] }); queryClient.invalidateQueries({ queryKey: ['assets', schematicId, version] });
queryClient.invalidateQueries({ queryKey: ['assets', schematicId, 'status'] }); queryClient.invalidateQueries({ queryKey: ['assets', schematicId, version, 'status'] });
}, },
}); });
} }

View File

@@ -59,4 +59,8 @@ export const nodesApi = {
async fetchTemplates(instanceName: string): Promise<OperationResponse> { async fetchTemplates(instanceName: string): Promise<OperationResponse> {
return apiClient.post(`/api/v1/instances/${instanceName}/nodes/fetch-templates`); return apiClient.post(`/api/v1/instances/${instanceName}/nodes/fetch-templates`);
}, },
async reset(instanceName: string, nodeName: string): Promise<OperationResponse> {
return apiClient.post(`/api/v1/instances/${instanceName}/nodes/${nodeName}/reset`);
},
}; };

View File

@@ -10,8 +10,8 @@ export interface Asset {
downloaded: boolean; downloaded: boolean;
} }
// Schematic representation matching backend // PXEAsset represents a schematic@version combination (composite key)
export interface Schematic { export interface PXEAsset {
schematic_id: string; schematic_id: string;
version: string; version: string;
path: string; path: string;
@@ -19,13 +19,12 @@ export interface Schematic {
} }
export interface AssetListResponse { export interface AssetListResponse {
schematics: Schematic[]; assets: PXEAsset[];
} }
export interface DownloadAssetRequest { export interface DownloadAssetRequest {
version: string;
platform?: Platform; platform?: Platform;
assets?: AssetType[]; asset_types?: string[];
force?: boolean; force?: boolean;
} }

View File

@@ -4,13 +4,21 @@ export interface ClusterConfig {
version?: string; version?: string;
} }
export interface ClusterStatus { export interface NodeStatus {
hostname: string;
ready: boolean; ready: boolean;
kubernetes_ready: boolean;
role: string;
}
export interface ClusterStatus {
status: string; // "ready", "pending", "error", "not_bootstrapped", "unreachable", "degraded"
nodes: number; nodes: number;
controlPlaneNodes: number; controlPlaneNodes: number;
workerNodes: number; workerNodes: number;
kubernetesVersion?: string; kubernetesVersion?: string;
talosVersion?: string; talosVersion?: string;
node_statuses?: Record<string, NodeStatus>;
} }
export interface HealthCheck { export interface HealthCheck {

View File

@@ -17,6 +17,7 @@ export interface Node {
// Optional runtime fields for enhanced status // Optional runtime fields for enhanced status
isReachable?: boolean; isReachable?: boolean;
inKubernetes?: boolean; inKubernetes?: boolean;
kubernetesReady?: boolean;
lastHealthCheck?: string; lastHealthCheck?: string;
// Optional fields (not yet returned by API) // Optional fields (not yet returned by API)
hardware?: HardwareInfo; hardware?: HardwareInfo;

View File

@@ -35,24 +35,29 @@ export function deriveNodeStatus(node: Node): NodeStatus {
} }
if (node.applied) { if (node.applied) {
// Check Kubernetes membership for healthy state // Check Kubernetes membership and readiness
if (node.inKubernetes === true) { if (node.inKubernetes === true && node.kubernetesReady === true) {
return NodeStatus.HEALTHY; return NodeStatus.HEALTHY;
} }
// Applied but not yet in Kubernetes (could be provisioning or ready) // In Kubernetes but not Ready
if (node.isReachable === true) { if (node.inKubernetes === true && node.kubernetesReady === false) {
return NodeStatus.DEGRADED;
}
// Applied and reachable but not yet in Kubernetes
if (node.isReachable === true && node.inKubernetes !== true) {
return NodeStatus.READY; return NodeStatus.READY;
} }
// Applied but status unknown // Applied but status unknown (no cluster status data yet)
if (node.isReachable === undefined && node.inKubernetes === undefined) { if (node.isReachable === undefined && node.inKubernetes === undefined) {
return NodeStatus.READY; return NodeStatus.READY;
} }
// Applied but having issues // Applied but not reachable at all
if (node.inKubernetes === false) { if (node.isReachable === false) {
return NodeStatus.DEGRADED; return NodeStatus.UNREACHABLE;
} }
} }

View File

@@ -0,0 +1,111 @@
import { useState } from 'react';
import {
AppWindow,
Database,
Globe,
Shield,
BarChart3,
MessageSquare,
CheckCircle,
AlertCircle,
Download,
Loader2,
Settings,
} from 'lucide-react';
import { Badge } from '../ui/badge';
import type { App } from '../../services/api';
export interface MergedApp extends App {
deploymentStatus?: 'added' | 'deployed';
url?: string;
}
export function getStatusIcon(status?: string) {
switch (status) {
case 'running':
return <CheckCircle className="h-5 w-5 text-green-500" />;
case 'error':
return <AlertCircle className="h-5 w-5 text-red-500" />;
case 'deploying':
return <Loader2 className="h-5 w-5 text-blue-500 animate-spin" />;
case 'stopped':
return <AlertCircle className="h-5 w-5 text-yellow-500" />;
case 'added':
return <Settings className="h-5 w-5 text-blue-500" />;
case 'deployed':
return <CheckCircle className="h-5 w-5 text-green-500" />;
case 'available':
return <Download className="h-5 w-5 text-muted-foreground" />;
default:
return null;
}
}
export function getStatusBadge(app: MergedApp) {
// Determine status: runtime status > deployment status > available
const status = app.status?.status || app.deploymentStatus || 'available';
const variants: Record<string, 'secondary' | 'default' | 'success' | 'destructive' | 'warning' | 'outline'> = {
available: 'secondary',
added: 'outline',
deploying: 'default',
running: 'success',
error: 'destructive',
stopped: 'warning',
deployed: 'outline',
};
const labels: Record<string, string> = {
available: 'Available',
added: 'Added',
deploying: 'Deploying',
running: 'Running',
error: 'Error',
stopped: 'Stopped',
deployed: 'Deployed',
};
return (
<Badge variant={variants[status]}>
{labels[status] || status}
</Badge>
);
}
export function getCategoryIcon(category?: string) {
switch (category) {
case 'database':
return <Database className="h-4 w-4" />;
case 'web':
return <Globe className="h-4 w-4" />;
case 'security':
return <Shield className="h-4 w-4" />;
case 'monitoring':
return <BarChart3 className="h-4 w-4" />;
case 'communication':
return <MessageSquare className="h-4 w-4" />;
case 'storage':
return <Database className="h-4 w-4" />;
default:
return <AppWindow className="h-4 w-4" />;
}
}
export function AppIcon({ app }: { app: MergedApp }) {
const [imageError, setImageError] = useState(false);
return (
<div className="h-12 w-12 rounded-lg bg-muted flex items-center justify-center overflow-hidden">
{app.icon && !imageError ? (
<img
src={app.icon}
alt={app.name}
className="h-full w-full object-contain p-1"
onError={() => setImageError(true)}
/>
) : (
getCategoryIcon(app.category)
)}
</div>
);
}