Compare commits

...

7 Commits

Author SHA1 Message Date
Paul Payne
ebf3612c62 Separate pages for controls and workers 2025-11-24 17:36:19 +00:00
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
25 changed files with 790 additions and 298 deletions

View File

@@ -18,12 +18,15 @@
"dependencies": {
"@heroicons/react": "^2.2.0",
"@hookform/resolvers": "^5.1.1",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-collapsible": "^1.1.11",
"@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.7",
"@tailwindcss/vite": "^4.1.10",
"@tanstack/react-query": "^5.62.10",
@@ -36,6 +39,7 @@
"react-markdown": "^10.1.0",
"react-router": "^7.9.4",
"react-router-dom": "^7.9.4",
"sonner": "^2.0.7",
"tailwind-merge": "^3.3.1",
"tailwindcss": "^4.1.10",
"zod": "^3.25.67"

162
pnpm-lock.yaml generated
View File

@@ -14,6 +14,9 @@ importers:
'@hookform/resolvers':
specifier: ^5.1.1
version: 5.1.1(react-hook-form@7.58.1(react@19.1.0))
'@radix-ui/react-checkbox':
specifier: ^1.3.3
version: 1.3.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-collapsible':
specifier: ^1.1.11
version: 1.1.11(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
@@ -32,6 +35,12 @@ importers:
'@radix-ui/react-slot':
specifier: ^1.2.3
version: 1.2.3(@types/react@19.1.8)(react@19.1.0)
'@radix-ui/react-switch':
specifier: ^1.2.6
version: 1.2.6(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-tabs':
specifier: ^1.1.13
version: 1.1.13(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-tooltip':
specifier: ^1.2.7
version: 1.2.7(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
@@ -68,6 +77,9 @@ importers:
react-router-dom:
specifier: ^7.9.4
version: 7.9.4(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
sonner:
specifier: ^2.0.7
version: 2.0.7(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
tailwind-merge:
specifier: ^3.3.1
version: 3.3.1
@@ -564,6 +576,19 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/react-checkbox@1.3.3':
resolution: {integrity: sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-collapsible@1.1.11':
resolution: {integrity: sha512-2qrRsVGSCYasSz1RFOorXwl0H7g7J1frQtgpQgYrt+MOidtPAINHn9CPovQXb83r8ahapdx3Tu0fa/pdFFSdPg==}
peerDependencies:
@@ -761,6 +786,19 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/react-presence@1.1.5':
resolution: {integrity: sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-primitive@2.1.3':
resolution: {integrity: sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==}
peerDependencies:
@@ -774,6 +812,19 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/react-roving-focus@1.1.11':
resolution: {integrity: sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-select@2.2.6':
resolution: {integrity: sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==}
peerDependencies:
@@ -809,6 +860,32 @@ packages:
'@types/react':
optional: true
'@radix-ui/react-switch@1.2.6':
resolution: {integrity: sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-tabs@1.1.13':
resolution: {integrity: sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-tooltip@1.2.7':
resolution: {integrity: sha512-Ap+fNYwKTYJ9pzqW+Xe2HtMRbQ/EeWkj2qykZ6SuEV4iS/o1bZI5ssJbk4D2r8XuDuOBVz/tIx2JObtuqU+5Zw==}
peerDependencies:
@@ -2273,6 +2350,12 @@ packages:
siginfo@2.0.0:
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
sonner@2.0.7:
resolution: {integrity: sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==}
peerDependencies:
react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc
react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc
source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
@@ -2977,6 +3060,22 @@ snapshots:
'@types/react': 19.1.8
'@types/react-dom': 19.1.6(@types/react@19.1.8)
'@radix-ui/react-checkbox@1.3.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.8)(react@19.1.0)
'@radix-ui/react-context': 1.1.2(@types/react@19.1.8)(react@19.1.0)
'@radix-ui/react-presence': 1.1.5(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.8)(react@19.1.0)
'@radix-ui/react-use-previous': 1.1.1(@types/react@19.1.8)(react@19.1.0)
'@radix-ui/react-use-size': 1.1.1(@types/react@19.1.8)(react@19.1.0)
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
optionalDependencies:
'@types/react': 19.1.8
'@types/react-dom': 19.1.6(@types/react@19.1.8)
'@radix-ui/react-collapsible@1.1.11(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
dependencies:
'@radix-ui/primitive': 1.1.2
@@ -3166,6 +3265,16 @@ snapshots:
'@types/react': 19.1.8
'@types/react-dom': 19.1.6(@types/react@19.1.8)
'@radix-ui/react-presence@1.1.5(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
dependencies:
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.8)(react@19.1.0)
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.8)(react@19.1.0)
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
optionalDependencies:
'@types/react': 19.1.8
'@types/react-dom': 19.1.6(@types/react@19.1.8)
'@radix-ui/react-primitive@2.1.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
dependencies:
'@radix-ui/react-slot': 1.2.3(@types/react@19.1.8)(react@19.1.0)
@@ -3175,6 +3284,23 @@ snapshots:
'@types/react': 19.1.8
'@types/react-dom': 19.1.6(@types/react@19.1.8)
'@radix-ui/react-roving-focus@1.1.11(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-collection': 1.1.7(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.8)(react@19.1.0)
'@radix-ui/react-context': 1.1.2(@types/react@19.1.8)(react@19.1.0)
'@radix-ui/react-direction': 1.1.1(@types/react@19.1.8)(react@19.1.0)
'@radix-ui/react-id': 1.1.1(@types/react@19.1.8)(react@19.1.0)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.8)(react@19.1.0)
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.8)(react@19.1.0)
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
optionalDependencies:
'@types/react': 19.1.8
'@types/react-dom': 19.1.6(@types/react@19.1.8)
'@radix-ui/react-select@2.2.6(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
dependencies:
'@radix-ui/number': 1.1.1
@@ -3220,6 +3346,37 @@ snapshots:
optionalDependencies:
'@types/react': 19.1.8
'@radix-ui/react-switch@1.2.6(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.8)(react@19.1.0)
'@radix-ui/react-context': 1.1.2(@types/react@19.1.8)(react@19.1.0)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.8)(react@19.1.0)
'@radix-ui/react-use-previous': 1.1.1(@types/react@19.1.8)(react@19.1.0)
'@radix-ui/react-use-size': 1.1.1(@types/react@19.1.8)(react@19.1.0)
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
optionalDependencies:
'@types/react': 19.1.8
'@types/react-dom': 19.1.6(@types/react@19.1.8)
'@radix-ui/react-tabs@1.1.13(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-context': 1.1.2(@types/react@19.1.8)(react@19.1.0)
'@radix-ui/react-direction': 1.1.1(@types/react@19.1.8)(react@19.1.0)
'@radix-ui/react-id': 1.1.1(@types/react@19.1.8)(react@19.1.0)
'@radix-ui/react-presence': 1.1.5(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.8)(react@19.1.0)
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
optionalDependencies:
'@types/react': 19.1.8
'@types/react-dom': 19.1.6(@types/react@19.1.8)
'@radix-ui/react-tooltip@1.2.7(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
dependencies:
'@radix-ui/primitive': 1.1.2
@@ -4781,6 +4938,11 @@ snapshots:
siginfo@2.0.0: {}
sonner@2.0.7(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
dependencies:
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
source-map-js@1.2.1: {}
space-separated-tokens@2.0.2: {}

View File

@@ -1,5 +1,5 @@
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 {
Sidebar,
@@ -71,7 +71,23 @@ export function AppSidebar() {
</div>
</div>
<div className="px-2 group-data-[collapsible=icon]:px-2">
<InstanceSwitcher />
<div className="flex items-center gap-2">
<div className="flex-1 min-w-0">
<InstanceSwitcher />
</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>
@@ -100,29 +116,6 @@ export function AppSidebar() {
</NavLink>
</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">
<SidebarMenuItem>
<CollapsibleTrigger asChild>
@@ -177,6 +170,54 @@ export function AppSidebar() {
</NavLink>
</SidebarMenuSubButton>
</SidebarMenuSubItem> */}
</SidebarMenuSub>
</CollapsibleContent>
</SidebarMenuItem>
</Collapsible>
<Collapsible defaultOpen className="group/collapsible">
<SidebarMenuItem>
<CollapsibleTrigger asChild>
<SidebarMenuButton>
<Container className="h-4 w-4" />
Cluster
<ChevronDown className="ml-auto transition-transform group-data-[state=open]/collapsible:rotate-180" />
</SidebarMenuButton>
</CollapsibleTrigger>
<CollapsibleContent>
<SidebarMenuSub>
<SidebarMenuSubItem>
<SidebarMenuSubButton asChild>
<NavLink to={`/instances/${instanceId}/control`}>
<div className="p-1 rounded-md">
<Cpu className="h-4 w-4" />
</div>
<span className="truncate">Control Nodes</span>
</NavLink>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
<SidebarMenuSubItem>
<SidebarMenuSubButton asChild>
<NavLink to={`/instances/${instanceId}/worker`}>
<div className="p-1 rounded-md">
<HardDrive className="h-4 w-4" />
</div>
<span className="truncate">Worker Nodes</span>
</NavLink>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
<SidebarMenuSubItem>
<SidebarMenuSubButton asChild>
<NavLink to={`/instances/${instanceId}/cluster`}>
<div className="p-1 rounded-md">
<Container className="h-4 w-4" />
</div>
<span className="truncate">Cluster Services</span>
</NavLink>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
<SidebarMenuSubItem>
<SidebarMenuSubButton asChild>
@@ -197,8 +238,8 @@ export function AppSidebar() {
<SidebarMenuItem>
<CollapsibleTrigger asChild>
<SidebarMenuButton>
<Container className="h-4 w-4" />
Cluster
<AppWindow className="h-4 w-4" />
Apps
<ChevronDown className="ml-auto transition-transform group-data-[state=open]/collapsible:rotate-180" />
</SidebarMenuButton>
</CollapsibleTrigger>
@@ -206,22 +247,22 @@ export function AppSidebar() {
<SidebarMenuSub>
<SidebarMenuSubItem>
<SidebarMenuSubButton asChild>
<NavLink to={`/instances/${instanceId}/infrastructure`}>
<NavLink to={`/instances/${instanceId}/apps/available`}>
<div className="p-1 rounded-md">
<Play className="h-4 w-4" />
<Download className="h-4 w-4" />
</div>
<span className="truncate">Cluster Nodes</span>
<span className="truncate">Available</span>
</NavLink>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
<SidebarMenuSubItem>
<SidebarMenuSubButton asChild>
<NavLink to={`/instances/${instanceId}/cluster`}>
<NavLink to={`/instances/${instanceId}/apps/installed`}>
<div className="p-1 rounded-md">
<Container className="h-4 w-4" />
<CheckCircle className="h-4 w-4" />
</div>
<span className="truncate">Cluster Services</span>
<span className="truncate">Installed</span>
</NavLink>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
@@ -230,17 +271,6 @@ export function AppSidebar() {
</SidebarMenuItem>
</Collapsible>
<SidebarMenuItem>
<SidebarMenuButton asChild tooltip="Install and manage applications">
<NavLink to={`/instances/${instanceId}/apps`}>
<div className="p-1 rounded-md">
<AppWindow className="h-4 w-4" />
</div>
<span className="truncate">Apps</span>
</NavLink>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton asChild tooltip="Advanced settings and system configuration">
<NavLink to={`/instances/${instanceId}/advanced`}>

View File

@@ -1,4 +1,5 @@
import { useState } from 'react';
import { useLocation } from 'react-router';
import { Card } from './ui/card';
import { Button } from './ui/button';
import { Badge } from './ui/badge';
@@ -37,6 +38,7 @@ interface MergedApp extends App {
type TabView = 'available' | 'installed';
export function AppsComponent() {
const location = useLocation();
const { currentInstance } = useInstanceContext();
const { data: availableAppsData, isLoading: loadingAvailable, error: availableError } = useAvailableApps();
const {
@@ -51,7 +53,8 @@ export function AppsComponent() {
isDeleting
} = 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 [selectedCategory, setSelectedCategory] = useState<string>('all');
const [configDialogOpen, setConfigDialogOpen] = useState(false);
@@ -323,22 +326,6 @@ export function AppsComponent() {
</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="relative flex-1">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />

View File

@@ -4,17 +4,28 @@ import { Button } from './ui/button';
import { Badge } from './ui/badge';
import { Alert } from './ui/alert';
import { Input } from './ui/input';
import { Cpu, HardDrive, Network, Monitor, CheckCircle, AlertCircle, BookOpen, ExternalLink, Loader2, RotateCcw } from 'lucide-react';
import { Cpu, HardDrive, Network, Monitor, CheckCircle, AlertCircle, BookOpen, ExternalLink, Loader2 } from 'lucide-react';
import { useInstanceContext } from '../hooks/useInstanceContext';
import { useNodes, useDiscoveryStatus } from '../hooks/useNodes';
import { useCluster } from '../hooks/useCluster';
import { useClusterStatus } from '../services/api/hooks/useCluster';
import { BootstrapModal } from './cluster/BootstrapModal';
import { NodeStatusBadge } from './nodes/NodeStatusBadge';
import { NodeFormDrawer } from './nodes/NodeFormDrawer';
import type { NodeFormData } from './nodes/NodeForm';
import type { Node, HardwareInfo, DiscoveredNode } from '../services/api/types';
export function ClusterNodesComponent() {
interface ClusterNodesComponentProps {
filterRole?: 'controlplane' | 'worker';
hideDiscoveryWhenNodesGte?: number;
showBootstrap?: boolean;
}
export function ClusterNodesComponent({
filterRole,
hideDiscoveryWhenNodesGte,
showBootstrap = true
}: ClusterNodesComponentProps = {}) {
const { currentInstance } = useInstanceContext();
const {
nodes,
@@ -23,7 +34,6 @@ export function ClusterNodesComponent() {
addNode,
addError,
deleteNode,
isDeleting,
deleteError,
discover,
isDiscovering,
@@ -36,8 +46,6 @@ export function ClusterNodesComponent() {
updateNode,
applyNode,
isApplying,
resetNode,
isResetting,
refetch
} = useNodes(currentInstance);
@@ -49,7 +57,8 @@ export function ClusterNodesComponent() {
status: clusterStatus
} = useCluster(currentInstance);
const [discoverSubnet, setDiscoverSubnet] = useState('');
const { data: clusterStatusData } = useClusterStatus(currentInstance || '');
const [addNodeIp, setAddNodeIp] = useState('');
const [discoverError, setDiscoverError] = useState<string | null>(null);
const [detectError, setDetectError] = useState<string | null>(null);
@@ -65,6 +74,8 @@ export function ClusterNodesComponent() {
open: false,
mode: 'add',
});
const [drawerEverOpened, setDrawerEverOpened] = useState(false);
const [deletingNodeHostname, setDeletingNodeHostname] = useState<string | null>(null);
const closeDrawer = () => setDrawerState({ ...drawerState, open: false });
@@ -121,6 +132,7 @@ export function ClusterNodesComponent() {
// Fetch full hardware details for the discovered node
try {
const hardware = await getHardware(discovered.ip);
setDrawerEverOpened(true);
setDrawerState({
open: true,
mode: 'add',
@@ -137,6 +149,7 @@ export function ClusterNodesComponent() {
try {
const hardware = await getHardware(addNodeIp);
setDrawerEverOpened(true);
setDrawerState({
open: true,
mode: 'add',
@@ -153,6 +166,7 @@ export function ClusterNodesComponent() {
if (node.target_ip) {
try {
const hardware = await getHardware(node.target_ip);
setDrawerEverOpened(true);
setDrawerState({
open: true,
mode: 'configure',
@@ -167,6 +181,7 @@ export function ClusterNodesComponent() {
}
// Open drawer without detection data (either no target_ip or detection failed)
setDrawerEverOpened(true);
setDrawerState({
open: true,
mode: 'configure',
@@ -175,16 +190,27 @@ export function ClusterNodesComponent() {
};
const handleAddSubmit = async (data: NodeFormData) => {
await addNode({
const nodeData = {
hostname: data.hostname,
role: data.role,
role: filterRole || data.role,
disk: data.disk,
target_ip: data.targetIp,
current_ip: data.currentIp,
interface: data.interface,
schematic_id: data.schematicId,
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();
setAddNodeIp('');
};
@@ -196,9 +222,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,
@@ -214,33 +238,32 @@ export function ClusterNodesComponent() {
await applyNode(drawerState.node.hostname);
};
const handleResetNode = (node: Node) => {
if (
confirm(
`Reset node ${node.hostname}?\n\nThis will wipe the node and return it to maintenance mode. The node will need to be reconfigured.`
)
) {
resetNode(node.hostname);
}
};
const handleDeleteNode = (hostname: string) => {
const handleDeleteNode = async (hostname: string) => {
if (!currentInstance) return;
if (confirm(`Are you sure you want to remove node ${hostname}?`)) {
deleteNode(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.`)) {
setDeletingNodeHostname(hostname);
try {
await deleteNode(hostname);
} finally {
setDeletingNodeHostname(null);
}
}
};
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);
};
// Derive status from backend state flags for each node
const assignedNodes = nodes.map(node => {
const assignedNodes = useMemo(() => {
const allNodes = nodes.map(node => {
// Get runtime status from cluster status
const runtimeStatus = clusterStatusData?.node_statuses?.[node.hostname];
let status = 'pending';
if (node.maintenance) {
status = 'provisioning';
@@ -249,9 +272,23 @@ export function ClusterNodesComponent() {
} else if (node.applied) {
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
};
});
// Filter by role if specified
if (filterRole) {
return allNodes.filter(node => node.role === filterRole);
}
return allNodes;
}, [nodes, clusterStatusData, filterRole]);
// Check if cluster needs bootstrap
const needsBootstrap = useMemo(() => {
// Find first ready control plane node
@@ -261,7 +298,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?.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;
}, [assignedNodes, clusterStatus]);
@@ -329,7 +368,7 @@ export function ClusterNodesComponent() {
</Card>
{/* Bootstrap Alert */}
{needsBootstrap && firstReadyControl && (
{showBootstrap && needsBootstrap && firstReadyControl && (
<Alert variant="info" className="mb-6">
<CheckCircle className="h-5 w-5" />
<div className="flex-1">
@@ -426,26 +465,22 @@ export function ClusterNodesComponent() {
</Alert>
)}
{/* DISCOVERY SECTION - Scan subnet for nodes */}
{/* ADD NODES SECTION - Discovery and manual add combined */}
{(!hideDiscoveryWhenNodesGte || assignedNodes.length < hideDiscoveryWhenNodesGte) && (
<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 ? (
<>
@@ -453,7 +488,7 @@ export function ClusterNodesComponent() {
Discovering...
</>
) : (
'Discover'
'Discover Nodes'
)}
</Button>
{(isDiscovering || discoveryStatus?.active) && (
@@ -468,71 +503,63 @@ 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>
)}
<div className="space-y-4">
<div className="flex items-center justify-between">
@@ -621,25 +648,13 @@ export function ClusterNodesComponent() {
{isApplying ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Apply'}
</Button>
)}
{!node.maintenance && (node.configured || node.applied) && (
<Button
size="sm"
variant="outline"
onClick={() => handleResetNode(node)}
disabled={isResetting}
className="border-orange-500 text-orange-500 hover:bg-orange-50 hover:text-orange-600"
>
<RotateCcw className="h-4 w-4 mr-1" />
Reset
</Button>
)}
<Button
size="sm"
variant="destructive"
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>
</div>
</div>
@@ -674,17 +689,19 @@ export function ClusterNodesComponent() {
/>
)}
{/* Node Form Drawer */}
<NodeFormDrawer
open={drawerState.open}
onClose={closeDrawer}
mode={drawerState.mode}
node={drawerState.mode === 'configure' ? drawerState.node : undefined}
detection={drawerState.detection}
onSubmit={drawerState.mode === 'add' ? handleAddSubmit : handleConfigureSubmit}
onApply={drawerState.mode === 'configure' ? handleApply : undefined}
instanceName={currentInstance || ''}
/>
{/* Node Form Drawer - only render after first open to prevent infinite loop on initial mount */}
{drawerEverOpened && (
<NodeFormDrawer
open={drawerState.open}
onClose={closeDrawer}
mode={drawerState.mode}
node={drawerState.mode === 'configure' ? drawerState.node : undefined}
detection={drawerState.detection}
onSubmit={drawerState.mode === 'add' ? handleAddSubmit : handleConfigureSubmit}
onApply={drawerState.mode === 'configure' ? handleApply : undefined}
instanceName={currentInstance || ''}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,5 @@
import { ClusterNodesComponent } from './ClusterNodesComponent';
export function ControlNodesComponent() {
return <ClusterNodesComponent filterRole="controlplane" hideDiscoveryWhenNodesGte={3} showBootstrap={true} />;
}

View File

@@ -0,0 +1,5 @@
import { ClusterNodesComponent } from './ClusterNodesComponent';
export function WorkerNodesComponent() {
return <ClusterNodesComponent filterRole="worker" hideDiscoveryWhenNodesGte={undefined} showBootstrap={false} />;
}

View File

@@ -14,7 +14,6 @@ export { CentralComponent } from './CentralComponent';
export { DnsComponent } from './DnsComponent';
export { DhcpComponent } from './DhcpComponent';
export { PxeComponent } from './PxeComponent';
export { ClusterNodesComponent } from './ClusterNodesComponent';
export { ClusterServicesComponent } from './ClusterServicesComponent';
export { AppsComponent } from './AppsComponent';
export { SecretInput } from './SecretInput';

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

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,19 +150,29 @@ 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);
}
}, [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
useEffect(() => {
@@ -178,14 +186,16 @@ export function NodeForm({
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
useEffect(() => {
if (!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)
useEffect(() => {
@@ -284,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
useEffect(() => {
// 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 +360,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 +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
const diskOptions = (() => {
@@ -433,21 +452,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 +510,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

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

@@ -0,0 +1,30 @@
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { CheckIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="grid place-content-center text-current transition-none"
>
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}
export { Checkbox }

View File

@@ -0,0 +1,29 @@
import * as React from "react"
import * as SwitchPrimitive from "@radix-ui/react-switch"
import { cn } from "@/lib/utils"
function Switch({
className,
...props
}: React.ComponentProps<typeof SwitchPrimitive.Root>) {
return (
<SwitchPrimitive.Root
data-slot="switch"
className={cn(
"peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className={cn(
"bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitive.Root>
)
}
export { Switch }

View File

@@ -0,0 +1,64 @@
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "@/lib/utils"
function Tabs({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
}
function TabsList({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.List>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
className={cn(
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
className
)}
{...props}
/>
)
}
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn("flex-1 outline-none", className)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent }

View File

@@ -54,6 +54,9 @@ export function useNodes(instanceName: string | null | undefined) {
const applyMutation = useMutation({
mutationFn: (nodeName: string) => nodesApi.apply(instanceName!, nodeName),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['instances', instanceName, 'nodes'] });
},
});
const fetchTemplatesMutation = useMutation({

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

@@ -0,0 +1,10 @@
import { ErrorBoundary } from '../../components';
import { ControlNodesComponent } from '../../components/ControlNodesComponent';
export function ControlNodesPage() {
return (
<ErrorBoundary>
<ControlNodesComponent />
</ErrorBoundary>
);
}

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

@@ -1,11 +0,0 @@
import { ErrorBoundary } from '../../components';
import { ClusterNodesComponent } from '../../components/ClusterNodesComponent';
export function InfrastructurePage() {
// Note: onComplete callback removed as phase management will be handled differently with routing
return (
<ErrorBoundary>
<ClusterNodesComponent />
</ErrorBoundary>
);
}

View File

@@ -0,0 +1,10 @@
import { ErrorBoundary } from '../../components';
import { WorkerNodesComponent } from '../../components/WorkerNodesComponent';
export function WorkerNodesPage() {
return (
<ErrorBoundary>
<WorkerNodesComponent />
</ErrorBoundary>
);
}

View File

@@ -15,9 +15,11 @@ import { DnsPage } from './pages/DnsPage';
import { DhcpPage } from './pages/DhcpPage';
import { PxePage } from './pages/PxePage';
import { IsoPage } from './pages/IsoPage';
import { InfrastructurePage } from './pages/InfrastructurePage';
import { ControlNodesPage } from './pages/ControlNodesPage';
import { WorkerNodesPage } from './pages/WorkerNodesPage';
import { ClusterPage } from './pages/ClusterPage';
import { AppsPage } from './pages/AppsPage';
import { BackupsPage } from './pages/BackupsPage';
import { AdvancedPage } from './pages/AdvancedPage';
import { AssetsIsoPage } from './pages/AssetsIsoPage';
import { AssetsPxePage } from './pages/AssetsPxePage';
@@ -92,8 +94,12 @@ export const routes: RouteObject[] = [
element: <IsoPage />,
},
{
path: 'infrastructure',
element: <InfrastructurePage />,
path: 'control',
element: <ControlNodesPage />,
},
{
path: 'worker',
element: <WorkerNodesPage />,
},
{
path: 'cluster',
@@ -101,7 +107,24 @@ export const routes: RouteObject[] = [
},
{
path: 'apps',
element: <AppsPage />,
children: [
{
index: true,
element: <Navigate to="available" replace />,
},
{
path: 'available',
element: <AppsPage />,
},
{
path: 'installed',
element: <AppsPage />,
},
],
},
{
path: 'backups',
element: <BackupsPage />,
},
{
path: 'advanced',

View File

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

View File

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

View File

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