Separate pages for controls and workers

This commit is contained in:
2025-11-24 17:36:19 +00:00
parent b324540ce0
commit ebf3612c62
14 changed files with 388 additions and 33 deletions

View File

@@ -188,11 +188,22 @@ export function AppSidebar() {
<SidebarMenuSub>
<SidebarMenuSubItem>
<SidebarMenuSubButton asChild>
<NavLink to={`/instances/${instanceId}/infrastructure`}>
<NavLink to={`/instances/${instanceId}/control`}>
<div className="p-1 rounded-md">
<Play className="h-4 w-4" />
<Cpu className="h-4 w-4" />
</div>
<span className="truncate">Cluster Nodes</span>
<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>

View File

@@ -15,7 +15,17 @@ 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,
@@ -64,6 +74,7 @@ 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',
@@ -177,7 +192,7 @@ export function ClusterNodesComponent() {
const handleAddSubmit = async (data: NodeFormData) => {
const nodeData = {
hostname: data.hostname,
role: data.role,
role: filterRole || data.role,
disk: data.disk,
target_ip: data.targetIp,
interface: data.interface,
@@ -244,7 +259,8 @@ export function ClusterNodesComponent() {
// 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];
@@ -266,6 +282,13 @@ export function ClusterNodesComponent() {
};
});
// 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
@@ -345,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">
@@ -443,6 +466,7 @@ export function ClusterNodesComponent() {
)}
{/* 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">
Add Nodes to Cluster
@@ -535,6 +559,7 @@ export function ClusterNodesComponent() {
</p>
</div>
</div>
)}
<div className="space-y-4">
<div className="flex items-center justify-between">
@@ -664,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

@@ -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 }