Compare commits
16 Commits
4cb8b11e59
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ebf3612c62 | ||
|
|
b324540ce0 | ||
|
|
6bbf48fe20 | ||
|
|
4307bc9996 | ||
|
|
35bc44bc32 | ||
|
|
a63519968e | ||
|
|
960282d4ed | ||
|
|
854a6023cd | ||
|
|
ee63423cab | ||
|
|
dfc7694fb9 | ||
|
|
2469acbc88 | ||
|
|
6f438901e0 | ||
|
|
35296b3bd2 | ||
|
|
1d2f0b7891 | ||
|
|
5260373fee | ||
|
|
684f29ba4f |
@@ -146,10 +146,168 @@ pnpm dlx shadcn@latest add alert-dialog
|
|||||||
|
|
||||||
You can then use components with `import { Button } from "@/components/ui/button"`
|
You can then use components with `import { Button } from "@/components/ui/button"`
|
||||||
|
|
||||||
### UI Principles
|
### UX Principles
|
||||||
|
|
||||||
- Use shadcn AppSideBar as the main navigation for the app: https://ui.shadcn.com/docs/components/sidebar
|
These principles ensure consistent, intuitive interfaces that align with Wild Cloud's philosophy of simplicity and clarity. Use them as quality control when building new components.
|
||||||
- Support light and dark mode with Tailwind's built-in dark mode support: https://tailwindcss.com/docs/dark-mode
|
|
||||||
|
#### Navigation & Structure
|
||||||
|
|
||||||
|
- **Use shadcn AppSideBar** as the main navigation: https://ui.shadcn.com/docs/components/sidebar
|
||||||
|
- **Card-Based Layout**: Group related content in Card components
|
||||||
|
- Primary cards: `p-6` padding
|
||||||
|
- Nested cards: `p-4` padding with subtle shadows
|
||||||
|
- Use cards to create visual hierarchy through nesting
|
||||||
|
- **Spacing Rhythm**: Maintain consistent vertical spacing
|
||||||
|
- Major sections: `space-y-6`
|
||||||
|
- Related items: `space-y-4`
|
||||||
|
- Form fields: `space-y-3`
|
||||||
|
- Inline elements: `gap-2`, `gap-3`, or `gap-4`
|
||||||
|
|
||||||
|
#### Visual Design
|
||||||
|
|
||||||
|
- **Dark Mode**: Support both light and dark modes using Tailwind's `dark:` prefix
|
||||||
|
- Test all components in both modes for contrast and readability
|
||||||
|
- Use semantic color tokens that adapt to theme
|
||||||
|
- **Status Color System**: Use semantic left border colors to categorize content
|
||||||
|
- Blue (`border-l-blue-500`): Configuration sections
|
||||||
|
- Green (`border-l-green-500`): Network/infrastructure
|
||||||
|
- Red (`border-l-red-500`): Errors and warnings
|
||||||
|
- Cyan: Educational content
|
||||||
|
- **Icon-Text Pairing**: Pair important text with Lucide icons
|
||||||
|
- Place icons in colored containers: `p-2 bg-primary/10 rounded-lg`
|
||||||
|
- Provides visual anchors and improves scannability
|
||||||
|
- **Technical Data Display**: Show technical information clearly
|
||||||
|
- Use `font-mono` class for IPs, domains, configuration values
|
||||||
|
- Display in `bg-muted rounded-md p-2` containers
|
||||||
|
|
||||||
|
#### Component Patterns
|
||||||
|
|
||||||
|
- **Edit/View Mode Toggle**: For configuration sections
|
||||||
|
- Read-only: Display in `bg-muted rounded-md font-mono` containers with Edit button
|
||||||
|
- Edit mode: Replace with form inputs in-place
|
||||||
|
- Provides lightweight editing without context switching
|
||||||
|
- **Drawers for Complex Forms**: Use side panels for detailed input
|
||||||
|
- Maintains context with main content
|
||||||
|
- Better than modals for forms that benefit from seeing related data
|
||||||
|
- **Educational Content**: Use gradient cards for helpful information
|
||||||
|
- Background: `from-cyan-50 to-blue-50 dark:from-cyan-900/20 dark:to-blue-900/20`
|
||||||
|
- Include book icon and clear, concise guidance
|
||||||
|
- Makes learning feel integrated, not intrusive
|
||||||
|
- **Empty States**: Center content with clear next actions
|
||||||
|
- Large icon: `h-12 w-12 text-muted-foreground`
|
||||||
|
- Descriptive title and explanation
|
||||||
|
- Suggest action to resolve empty state
|
||||||
|
|
||||||
|
#### Section Headers
|
||||||
|
|
||||||
|
Structure all major section headers consistently:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<div className="flex items-center gap-4 mb-6">
|
||||||
|
<div className="p-2 bg-primary/10 rounded-lg">
|
||||||
|
<IconComponent className="h-6 w-6 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-semibold">Section Title</h2>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Brief description of section purpose
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Status & Feedback
|
||||||
|
|
||||||
|
- **Status Badges**: Use colored badges with icons for state indication
|
||||||
|
- Keep compact but descriptive
|
||||||
|
- Include hover/expansion for additional detail
|
||||||
|
- **Alert Positioning**: Place alerts near related content
|
||||||
|
- Use semantic colors and icons (CheckCircle, AlertCircle, XCircle)
|
||||||
|
- Include dismissible X button for manual dismissal
|
||||||
|
- **Success Messages**: Auto-dismiss after 5 seconds
|
||||||
|
- Green color with CheckCircle icon
|
||||||
|
- Clear, affirmative message
|
||||||
|
- **Error Messages**: Structured and actionable
|
||||||
|
- Title in bold, detailed message below
|
||||||
|
- Red color with AlertCircle icon
|
||||||
|
- Suggest resolution when possible
|
||||||
|
- **Loading States**: Context-appropriate indicators
|
||||||
|
- Inline: Use `Loader2` spinner in buttons/actions
|
||||||
|
- Full section: Card with centered spinner and descriptive text
|
||||||
|
|
||||||
|
#### Form Components
|
||||||
|
|
||||||
|
Use react-hook-form for all forms. Never duplicate component styling.
|
||||||
|
|
||||||
|
**Standard Form Pattern**:
|
||||||
|
```tsx
|
||||||
|
import { useForm, Controller } from 'react-hook-form';
|
||||||
|
import { Input, Label, Button } from '@/components/ui';
|
||||||
|
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/select';
|
||||||
|
|
||||||
|
const { register, handleSubmit, control, formState: { errors } } = useForm({
|
||||||
|
defaultValues: { /* ... */ }
|
||||||
|
});
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="text">Text Field</Label>
|
||||||
|
<Input {...register('text', { required: 'Required' })} className="mt-1" />
|
||||||
|
{errors.text && <p className="text-sm text-red-600 mt-1">{errors.text.message}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="select">Select Field</Label>
|
||||||
|
<Controller
|
||||||
|
name="select"
|
||||||
|
control={control}
|
||||||
|
rules={{ required: 'Required' }}
|
||||||
|
render={({ field }) => (
|
||||||
|
<Select value={field.value} onValueChange={field.onChange}>
|
||||||
|
<SelectTrigger className="mt-1">
|
||||||
|
<SelectValue placeholder="Choose..." />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="option1">Option 1</SelectItem>
|
||||||
|
<SelectItem value="option2">Option 2</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{errors.select && <p className="text-sm text-red-600 mt-1">{errors.select.message}</p>}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Rules**:
|
||||||
|
- **Text inputs**: Use `Input` with `register()`
|
||||||
|
- **Select dropdowns**: Use `Select` components with `Controller` (never native `<select>`)
|
||||||
|
- **All labels**: Use `Label` with `htmlFor` attribute
|
||||||
|
- **Never copy classes**: Components provide default styling, only add spacing like `mt-1`
|
||||||
|
- **Form spacing**: `space-y-3` on form containers
|
||||||
|
- **Error messages**: `text-sm text-red-600 mt-1`
|
||||||
|
- **Multi-action forms**: Place buttons side-by-side with `flex gap-2`
|
||||||
|
|
||||||
|
#### Accessibility
|
||||||
|
|
||||||
|
- **Focus Indicators**: All interactive elements must have visible focus states
|
||||||
|
- Use consistent `focus-visible:ring-*` styles
|
||||||
|
- Test keyboard navigation on all new components
|
||||||
|
- **Screen Reader Support**: Proper semantic HTML and ARIA labels
|
||||||
|
- Use Label components for form inputs
|
||||||
|
- Provide descriptive text for icon-only buttons
|
||||||
|
- Test with screen readers when adding complex interactions
|
||||||
|
|
||||||
|
#### Progressive Disclosure
|
||||||
|
|
||||||
|
- **Just-in-Time Information**: Start simple, reveal details on demand
|
||||||
|
- Summary view by default
|
||||||
|
- Details through drawers, accordions, or inline expansion
|
||||||
|
- Reduces initial cognitive load
|
||||||
|
- **Educational Context**: Provide help without interrupting flow
|
||||||
|
- Use gradient educational cards in logical places
|
||||||
|
- Include "Learn more" links to external documentation
|
||||||
|
- Keep content concise and actionable
|
||||||
|
|
||||||
### App Layout
|
### App Layout
|
||||||
|
|
||||||
|
|||||||
@@ -16,13 +16,17 @@
|
|||||||
"check": "pnpm run lint && pnpm run type-check && pnpm run test"
|
"check": "pnpm run lint && pnpm run type-check && pnpm run test"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@heroicons/react": "^2.2.0",
|
||||||
"@hookform/resolvers": "^5.1.1",
|
"@hookform/resolvers": "^5.1.1",
|
||||||
|
"@radix-ui/react-checkbox": "^1.3.3",
|
||||||
"@radix-ui/react-collapsible": "^1.1.11",
|
"@radix-ui/react-collapsible": "^1.1.11",
|
||||||
"@radix-ui/react-dialog": "^1.1.14",
|
"@radix-ui/react-dialog": "^1.1.14",
|
||||||
"@radix-ui/react-label": "^2.1.7",
|
"@radix-ui/react-label": "^2.1.7",
|
||||||
"@radix-ui/react-select": "^2.2.6",
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
"@radix-ui/react-separator": "^1.1.7",
|
"@radix-ui/react-separator": "^1.1.7",
|
||||||
"@radix-ui/react-slot": "^1.2.3",
|
"@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",
|
"@radix-ui/react-tooltip": "^1.2.7",
|
||||||
"@tailwindcss/vite": "^4.1.10",
|
"@tailwindcss/vite": "^4.1.10",
|
||||||
"@tanstack/react-query": "^5.62.10",
|
"@tanstack/react-query": "^5.62.10",
|
||||||
@@ -32,16 +36,20 @@
|
|||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
"react-hook-form": "^7.58.1",
|
"react-hook-form": "^7.58.1",
|
||||||
|
"react-markdown": "^10.1.0",
|
||||||
"react-router": "^7.9.4",
|
"react-router": "^7.9.4",
|
||||||
"react-router-dom": "^7.9.4",
|
"react-router-dom": "^7.9.4",
|
||||||
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
"tailwindcss": "^4.1.10",
|
"tailwindcss": "^4.1.10",
|
||||||
"zod": "^3.25.67"
|
"zod": "^3.25.67"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.25.0",
|
"@eslint/js": "^9.25.0",
|
||||||
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/react": "^16.3.0",
|
"@testing-library/react": "^16.3.0",
|
||||||
|
"@testing-library/user-event": "^14.6.1",
|
||||||
"@types/node": "^24.0.3",
|
"@types/node": "^24.0.3",
|
||||||
"@types/react": "^19.1.2",
|
"@types/react": "^19.1.2",
|
||||||
"@types/react-dom": "^19.1.2",
|
"@types/react-dom": "^19.1.2",
|
||||||
|
|||||||
893
pnpm-lock.yaml
generated
893
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -17,7 +17,7 @@ export function Advanced() {
|
|||||||
const { instanceId } = useParams<{ instanceId: string }>();
|
const { instanceId } = useParams<{ instanceId: string }>();
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
const { data: instance } = useInstance(instanceId || '');
|
const { data: instance } = useInstance(instanceId || '');
|
||||||
const { data: dashboardToken, isLoading: tokenLoading } = useDashboardToken();
|
const { data: dashboardToken, isLoading: tokenLoading } = useDashboardToken(instanceId || '');
|
||||||
|
|
||||||
const [upstreamValue, setUpstreamValue] = useState("https://mywildcloud.org");
|
const [upstreamValue, setUpstreamValue] = useState("https://mywildcloud.org");
|
||||||
const [editingUpstream, setEditingUpstream] = useState(false);
|
const [editingUpstream, setEditingUpstream] = useState(false);
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
} from './ui/sidebar';
|
} from './ui/sidebar';
|
||||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from './ui/collapsible';
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from './ui/collapsible';
|
||||||
import { useTheme } from '../contexts/ThemeContext';
|
import { useTheme } from '../contexts/ThemeContext';
|
||||||
|
import { InstanceSwitcher } from './InstanceSwitcher';
|
||||||
|
|
||||||
export function AppSidebar() {
|
export function AppSidebar() {
|
||||||
const { theme, setTheme } = useTheme();
|
const { theme, setTheme } = useTheme();
|
||||||
@@ -61,13 +62,31 @@ export function AppSidebar() {
|
|||||||
return (
|
return (
|
||||||
<Sidebar variant="sidebar" collapsible="icon">
|
<Sidebar variant="sidebar" collapsible="icon">
|
||||||
<SidebarHeader>
|
<SidebarHeader>
|
||||||
<div className="flex items-center gap-2 px-2">
|
<div className="flex items-center gap-2 px-2 pb-2">
|
||||||
<div className="p-1 bg-primary/10 rounded-lg">
|
<div className="p-1 bg-primary/10 rounded-lg">
|
||||||
<CloudLightning className="h-6 w-6 text-primary" />
|
<CloudLightning className="h-6 w-6 text-primary" />
|
||||||
</div>
|
</div>
|
||||||
<div className="group-data-[collapsible=icon]:hidden">
|
<div className="group-data-[collapsible=icon]:hidden">
|
||||||
<h2 className="text-lg font-bold text-foreground">Wild Cloud</h2>
|
<h2 className="text-lg font-bold text-foreground">Wild Cloud</h2>
|
||||||
<p className="text-sm text-muted-foreground">{instanceId}</p>
|
</div>
|
||||||
|
</div>
|
||||||
|
<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 />
|
||||||
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</SidebarHeader>
|
</SidebarHeader>
|
||||||
@@ -97,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>
|
||||||
@@ -174,6 +170,54 @@ export function AppSidebar() {
|
|||||||
</NavLink>
|
</NavLink>
|
||||||
</SidebarMenuSubButton>
|
</SidebarMenuSubButton>
|
||||||
</SidebarMenuSubItem> */}
|
</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>
|
<SidebarMenuSubItem>
|
||||||
<SidebarMenuSubButton asChild>
|
<SidebarMenuSubButton asChild>
|
||||||
@@ -194,8 +238,8 @@ export function AppSidebar() {
|
|||||||
<SidebarMenuItem>
|
<SidebarMenuItem>
|
||||||
<CollapsibleTrigger asChild>
|
<CollapsibleTrigger asChild>
|
||||||
<SidebarMenuButton>
|
<SidebarMenuButton>
|
||||||
<Container className="h-4 w-4" />
|
<AppWindow className="h-4 w-4" />
|
||||||
Cluster
|
Apps
|
||||||
<ChevronDown className="ml-auto transition-transform group-data-[state=open]/collapsible:rotate-180" />
|
<ChevronDown className="ml-auto transition-transform group-data-[state=open]/collapsible:rotate-180" />
|
||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
</CollapsibleTrigger>
|
</CollapsibleTrigger>
|
||||||
@@ -203,22 +247,22 @@ export function AppSidebar() {
|
|||||||
<SidebarMenuSub>
|
<SidebarMenuSub>
|
||||||
<SidebarMenuSubItem>
|
<SidebarMenuSubItem>
|
||||||
<SidebarMenuSubButton asChild>
|
<SidebarMenuSubButton asChild>
|
||||||
<NavLink to={`/instances/${instanceId}/infrastructure`}>
|
<NavLink to={`/instances/${instanceId}/apps/available`}>
|
||||||
<div className="p-1 rounded-md">
|
<div className="p-1 rounded-md">
|
||||||
<Play className="h-4 w-4" />
|
<Download className="h-4 w-4" />
|
||||||
</div>
|
</div>
|
||||||
<span className="truncate">Cluster Nodes</span>
|
<span className="truncate">Available</span>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
</SidebarMenuSubButton>
|
</SidebarMenuSubButton>
|
||||||
</SidebarMenuSubItem>
|
</SidebarMenuSubItem>
|
||||||
|
|
||||||
<SidebarMenuSubItem>
|
<SidebarMenuSubItem>
|
||||||
<SidebarMenuSubButton asChild>
|
<SidebarMenuSubButton asChild>
|
||||||
<NavLink to={`/instances/${instanceId}/cluster`}>
|
<NavLink to={`/instances/${instanceId}/apps/installed`}>
|
||||||
<div className="p-1 rounded-md">
|
<div className="p-1 rounded-md">
|
||||||
<Container className="h-4 w-4" />
|
<CheckCircle className="h-4 w-4" />
|
||||||
</div>
|
</div>
|
||||||
<span className="truncate">Cluster Services</span>
|
<span className="truncate">Installed</span>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
</SidebarMenuSubButton>
|
</SidebarMenuSubButton>
|
||||||
</SidebarMenuSubItem>
|
</SidebarMenuSubItem>
|
||||||
@@ -227,17 +271,6 @@ export function AppSidebar() {
|
|||||||
</SidebarMenuItem>
|
</SidebarMenuItem>
|
||||||
</Collapsible>
|
</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>
|
<SidebarMenuItem>
|
||||||
<SidebarMenuButton asChild tooltip="Advanced settings and system configuration">
|
<SidebarMenuButton asChild tooltip="Advanced settings and system configuration">
|
||||||
<NavLink to={`/instances/${instanceId}/advanced`}>
|
<NavLink to={`/instances/${instanceId}/advanced`}>
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -20,18 +21,24 @@ import {
|
|||||||
Archive,
|
Archive,
|
||||||
RotateCcw,
|
RotateCcw,
|
||||||
Settings,
|
Settings,
|
||||||
|
Eye,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useInstanceContext } from '../hooks/useInstanceContext';
|
import { useInstanceContext } from '../hooks/useInstanceContext';
|
||||||
import { useAvailableApps, useDeployedApps, useAppBackups } from '../hooks/useApps';
|
import { useAvailableApps, useDeployedApps, useAppBackups } from '../hooks/useApps';
|
||||||
import { BackupRestoreModal } from './BackupRestoreModal';
|
import { BackupRestoreModal } from './BackupRestoreModal';
|
||||||
import { AppConfigDialog } from './apps/AppConfigDialog';
|
import { AppConfigDialog } from './apps/AppConfigDialog';
|
||||||
|
import { AppDetailModal } from './apps/AppDetailModal';
|
||||||
import type { App } from '../services/api';
|
import type { App } from '../services/api';
|
||||||
|
|
||||||
interface MergedApp extends App {
|
interface MergedApp extends App {
|
||||||
deploymentStatus?: 'added' | 'deployed';
|
deploymentStatus?: 'added' | 'deployed';
|
||||||
|
url?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
@@ -46,6 +53,8 @@ export function AppsComponent() {
|
|||||||
isDeleting
|
isDeleting
|
||||||
} = useDeployedApps(currentInstance);
|
} = useDeployedApps(currentInstance);
|
||||||
|
|
||||||
|
// 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);
|
||||||
@@ -53,6 +62,8 @@ export function AppsComponent() {
|
|||||||
const [backupModalOpen, setBackupModalOpen] = useState(false);
|
const [backupModalOpen, setBackupModalOpen] = useState(false);
|
||||||
const [restoreModalOpen, setRestoreModalOpen] = useState(false);
|
const [restoreModalOpen, setRestoreModalOpen] = useState(false);
|
||||||
const [selectedAppForBackup, setSelectedAppForBackup] = useState<string | null>(null);
|
const [selectedAppForBackup, setSelectedAppForBackup] = useState<string | null>(null);
|
||||||
|
const [detailModalOpen, setDetailModalOpen] = useState(false);
|
||||||
|
const [selectedAppForDetail, setSelectedAppForDetail] = useState<string | null>(null);
|
||||||
|
|
||||||
// Fetch backups for the selected app
|
// Fetch backups for the selected app
|
||||||
const {
|
const {
|
||||||
@@ -64,18 +75,27 @@ export function AppsComponent() {
|
|||||||
isRestoring,
|
isRestoring,
|
||||||
} = useAppBackups(currentInstance, selectedAppForBackup);
|
} = useAppBackups(currentInstance, selectedAppForBackup);
|
||||||
|
|
||||||
// Merge available and deployed apps
|
// Merge available and deployed apps with URL from deployment
|
||||||
// DeployedApps now includes status: 'added' | 'deployed'
|
|
||||||
const applications: MergedApp[] = (availableAppsData?.apps || []).map(app => {
|
const applications: MergedApp[] = (availableAppsData?.apps || []).map(app => {
|
||||||
const deployedApp = deployedApps.find(d => d.name === app.name);
|
const deployedApp = deployedApps.find(d => d.name === app.name);
|
||||||
return {
|
return {
|
||||||
...app,
|
...app,
|
||||||
deploymentStatus: deployedApp?.status as 'added' | 'deployed' | undefined, // 'added' or 'deployed' from API
|
deploymentStatus: deployedApp?.status as 'added' | 'deployed' | undefined,
|
||||||
|
url: deployedApp?.url,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const isLoading = loadingAvailable || loadingDeployed;
|
const isLoading = loadingAvailable || loadingDeployed;
|
||||||
|
|
||||||
|
// Filter for available apps (not added or deployed)
|
||||||
|
const availableApps = applications.filter(app => !app.deploymentStatus);
|
||||||
|
|
||||||
|
// Filter for installed apps (added or deployed)
|
||||||
|
const installedApps = applications.filter(app => app.deploymentStatus);
|
||||||
|
|
||||||
|
// Count running apps - apps that are deployed (not just added)
|
||||||
|
const runningApps = installedApps.filter(app => app.deploymentStatus === 'deployed').length;
|
||||||
|
|
||||||
const getStatusIcon = (status?: string) => {
|
const getStatusIcon = (status?: string) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'running':
|
case 'running':
|
||||||
@@ -88,6 +108,8 @@ export function AppsComponent() {
|
|||||||
return <AlertCircle className="h-5 w-5 text-yellow-500" />;
|
return <AlertCircle className="h-5 w-5 text-yellow-500" />;
|
||||||
case 'added':
|
case 'added':
|
||||||
return <Settings className="h-5 w-5 text-blue-500" />;
|
return <Settings className="h-5 w-5 text-blue-500" />;
|
||||||
|
case 'deployed':
|
||||||
|
return <CheckCircle className="h-5 w-5 text-green-500" />;
|
||||||
case 'available':
|
case 'available':
|
||||||
return <Download className="h-5 w-5 text-muted-foreground" />;
|
return <Download className="h-5 w-5 text-muted-foreground" />;
|
||||||
default:
|
default:
|
||||||
@@ -145,12 +167,37 @@ export function AppsComponent() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAppAction = (app: MergedApp, action: 'configure' | 'deploy' | 'delete' | 'backup' | 'restore') => {
|
// Separate component for app icon with error handling
|
||||||
|
const 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAppAction = (app: MergedApp, action: 'configure' | 'deploy' | 'delete' | 'backup' | 'restore' | 'view') => {
|
||||||
if (!currentInstance) return;
|
if (!currentInstance) return;
|
||||||
|
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case 'configure':
|
case 'configure':
|
||||||
// Open config dialog for adding or reconfiguring app
|
console.log('[AppsComponent] Configuring app:', {
|
||||||
|
name: app.name,
|
||||||
|
hasDefaultConfig: !!app.defaultConfig,
|
||||||
|
defaultConfigKeys: app.defaultConfig ? Object.keys(app.defaultConfig) : [],
|
||||||
|
fullApp: app,
|
||||||
|
});
|
||||||
setSelectedAppForConfig(app);
|
setSelectedAppForConfig(app);
|
||||||
setConfigDialogOpen(true);
|
setConfigDialogOpen(true);
|
||||||
break;
|
break;
|
||||||
@@ -170,19 +217,21 @@ export function AppsComponent() {
|
|||||||
setSelectedAppForBackup(app.name);
|
setSelectedAppForBackup(app.name);
|
||||||
setRestoreModalOpen(true);
|
setRestoreModalOpen(true);
|
||||||
break;
|
break;
|
||||||
|
case 'view':
|
||||||
|
setSelectedAppForDetail(app.name);
|
||||||
|
setDetailModalOpen(true);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleConfigSave = (config: Record<string, string>) => {
|
const handleConfigSave = (config: Record<string, string>) => {
|
||||||
if (!selectedAppForConfig) return;
|
if (!selectedAppForConfig) return;
|
||||||
|
|
||||||
// Call addApp with the configuration
|
|
||||||
addApp({
|
addApp({
|
||||||
name: selectedAppForConfig.name,
|
name: selectedAppForConfig.name,
|
||||||
config: config,
|
config: config,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Close dialog
|
|
||||||
setConfigDialogOpen(false);
|
setConfigDialogOpen(false);
|
||||||
setSelectedAppForConfig(null);
|
setSelectedAppForConfig(null);
|
||||||
};
|
};
|
||||||
@@ -199,15 +248,15 @@ export function AppsComponent() {
|
|||||||
|
|
||||||
const categories = ['all', 'database', 'web', 'security', 'monitoring', 'communication', 'storage'];
|
const categories = ['all', 'database', 'web', 'security', 'monitoring', 'communication', 'storage'];
|
||||||
|
|
||||||
const filteredApps = applications.filter(app => {
|
const appsToDisplay = activeTab === 'available' ? availableApps : installedApps;
|
||||||
|
|
||||||
|
const filteredApps = appsToDisplay.filter(app => {
|
||||||
const matchesSearch = app.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
const matchesSearch = app.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
app.description.toLowerCase().includes(searchTerm.toLowerCase());
|
app.description.toLowerCase().includes(searchTerm.toLowerCase());
|
||||||
const matchesCategory = selectedCategory === 'all' || app.category === selectedCategory;
|
const matchesCategory = selectedCategory === 'all' || app.category === selectedCategory;
|
||||||
return matchesSearch && matchesCategory;
|
return matchesSearch && matchesCategory;
|
||||||
});
|
});
|
||||||
|
|
||||||
const runningApps = applications.filter(app => app.status?.status === 'running').length;
|
|
||||||
|
|
||||||
// Show message if no instance is selected
|
// Show message if no instance is selected
|
||||||
if (!currentInstance) {
|
if (!currentInstance) {
|
||||||
return (
|
return (
|
||||||
@@ -248,12 +297,12 @@ export function AppsComponent() {
|
|||||||
What are Apps in your Personal Cloud?
|
What are Apps in your Personal Cloud?
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-pink-800 dark:text-pink-200 mb-3 leading-relaxed">
|
<p className="text-pink-800 dark:text-pink-200 mb-3 leading-relaxed">
|
||||||
Apps are the useful programs that make your personal cloud valuable - like having a personal Netflix
|
Apps are the useful programs that make your personal cloud valuable - like having a personal Netflix
|
||||||
(media server), Google Drive (file storage), or Gmail (email server) running on your own hardware.
|
(media server), Google Drive (file storage), or Gmail (email server) running on your own hardware.
|
||||||
Instead of relying on big tech companies, you control your data and services.
|
Instead of relying on big tech companies, you control your data and services.
|
||||||
</p>
|
</p>
|
||||||
<p className="text-pink-700 dark:text-pink-300 mb-4 text-sm">
|
<p className="text-pink-700 dark:text-pink-300 mb-4 text-sm">
|
||||||
Your cluster can run databases, web servers, photo galleries, password managers, backup services, and much more.
|
Your cluster can run databases, web servers, photo galleries, password managers, backup services, and much more.
|
||||||
Each app runs in its own secure container, so they don't interfere with each other and can be easily managed.
|
Each app runs in its own secure container, so they don't interfere with each other and can be easily managed.
|
||||||
</p>
|
</p>
|
||||||
<Button variant="outline" size="sm" className="text-pink-700 border-pink-300 hover:bg-pink-100 dark:text-pink-300 dark:border-pink-700 dark:hover:bg-pink-900/20">
|
<Button variant="outline" size="sm" className="text-pink-700 border-pink-300 hover:bg-pink-100 dark:text-pink-300 dark:border-pink-700 dark:hover:bg-pink-900/20">
|
||||||
@@ -288,14 +337,14 @@ export function AppsComponent() {
|
|||||||
className="w-full pl-10 pr-4 py-2 border rounded-lg bg-background"
|
className="w-full pl-10 pr-4 py-2 border rounded-lg bg-background"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2 overflow-x-auto">
|
||||||
{categories.map(category => (
|
{categories.map(category => (
|
||||||
<Button
|
<Button
|
||||||
key={category}
|
key={category}
|
||||||
variant={selectedCategory === category ? 'default' : 'outline'}
|
variant={selectedCategory === category ? 'default' : 'outline'}
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setSelectedCategory(category)}
|
onClick={() => setSelectedCategory(category)}
|
||||||
className="capitalize"
|
className="capitalize whitespace-nowrap"
|
||||||
>
|
>
|
||||||
{category}
|
{category}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -311,7 +360,7 @@ export function AppsComponent() {
|
|||||||
Loading apps...
|
Loading apps...
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
`${runningApps} applications running • ${applications.length} total available`
|
`${runningApps} applications running • ${installedApps.length} installed • ${availableApps.length} available`
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -322,14 +371,45 @@ export function AppsComponent() {
|
|||||||
<Loader2 className="h-12 w-12 text-primary mx-auto mb-4 animate-spin" />
|
<Loader2 className="h-12 w-12 text-primary mx-auto mb-4 animate-spin" />
|
||||||
<p className="text-muted-foreground">Loading applications...</p>
|
<p className="text-muted-foreground">Loading applications...</p>
|
||||||
</Card>
|
</Card>
|
||||||
|
) : activeTab === 'available' ? (
|
||||||
|
// Available Apps Grid
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{filteredApps.map((app) => (
|
||||||
|
<Card key={app.name} className="p-4 hover:shadow-lg transition-shadow">
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<AppIcon app={app} />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<h3 className="font-medium truncate">{app.name}</h3>
|
||||||
|
</div>
|
||||||
|
{app.version && (
|
||||||
|
<Badge variant="outline" className="text-xs mb-2">
|
||||||
|
{app.version}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground line-clamp-2">{app.description}</p>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleAppAction(app, 'configure')}
|
||||||
|
disabled={isAdding}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
{isAdding ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Configure & Add'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
// Installed Apps List
|
||||||
|
<div className="space-y-3">
|
||||||
{filteredApps.map((app) => (
|
{filteredApps.map((app) => (
|
||||||
<Card key={app.name} className="p-4">
|
<Card key={app.name} className="p-4">
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<div className="p-2 bg-muted rounded-lg">
|
<AppIcon app={app} />
|
||||||
{getCategoryIcon(app.category)}
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
<h3 className="font-medium truncate">{app.name}</h3>
|
<h3 className="font-medium truncate">{app.name}</h3>
|
||||||
@@ -338,10 +418,23 @@ export function AppsComponent() {
|
|||||||
{app.version}
|
{app.version}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
{getStatusIcon(app.status?.status)}
|
{getStatusIcon(app.status?.status || app.deploymentStatus)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground mb-2">{app.description}</p>
|
<p className="text-sm text-muted-foreground mb-2">{app.description}</p>
|
||||||
|
|
||||||
|
{/* Show ingress URL if available */}
|
||||||
|
{app.url && (
|
||||||
|
<a
|
||||||
|
href={app.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex items-center gap-1 text-xs text-primary hover:underline mb-2"
|
||||||
|
>
|
||||||
|
<ExternalLink className="h-3 w-3" />
|
||||||
|
{app.url}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
|
||||||
{app.status?.status === 'running' && (
|
{app.status?.status === 'running' && (
|
||||||
<div className="space-y-1 text-xs text-muted-foreground">
|
<div className="space-y-1 text-xs text-muted-foreground">
|
||||||
{app.status.namespace && (
|
{app.status.namespace && (
|
||||||
@@ -350,31 +443,14 @@ export function AppsComponent() {
|
|||||||
{app.status.replicas && (
|
{app.status.replicas && (
|
||||||
<div>Replicas: {app.status.replicas}</div>
|
<div>Replicas: {app.status.replicas}</div>
|
||||||
)}
|
)}
|
||||||
{app.status.resources && (
|
|
||||||
<div>
|
|
||||||
Resources: {app.status.resources.cpu} CPU, {app.status.resources.memory} RAM
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{app.status?.message && (
|
|
||||||
<p className="text-xs text-muted-foreground mt-1">{app.status.message}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
{getStatusBadge(app)}
|
{getStatusBadge(app)}
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
{/* Available: not added yet */}
|
{/* Available: not added yet - shouldn't show here */}
|
||||||
{!app.deploymentStatus && (
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
onClick={() => handleAppAction(app, 'configure')}
|
|
||||||
disabled={isAdding}
|
|
||||||
>
|
|
||||||
{isAdding ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Configure & Add'}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Added: in config but not deployed */}
|
{/* Added: in config but not deployed */}
|
||||||
{app.deploymentStatus === 'added' && (
|
{app.deploymentStatus === 'added' && (
|
||||||
@@ -408,6 +484,14 @@ export function AppsComponent() {
|
|||||||
{/* Deployed: running in Kubernetes */}
|
{/* Deployed: running in Kubernetes */}
|
||||||
{app.deploymentStatus === 'deployed' && (
|
{app.deploymentStatus === 'deployed' && (
|
||||||
<>
|
<>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => handleAppAction(app, 'view')}
|
||||||
|
title="View details"
|
||||||
|
>
|
||||||
|
<Eye className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
{app.status?.status === 'running' && (
|
{app.status?.status === 'running' && (
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
@@ -455,7 +539,9 @@ export function AppsComponent() {
|
|||||||
<p className="text-muted-foreground mb-4">
|
<p className="text-muted-foreground mb-4">
|
||||||
{searchTerm || selectedCategory !== 'all'
|
{searchTerm || selectedCategory !== 'all'
|
||||||
? 'Try adjusting your search or category filter'
|
? 'Try adjusting your search or category filter'
|
||||||
: 'No applications available to display'
|
: activeTab === 'available'
|
||||||
|
? 'All available apps have been installed'
|
||||||
|
: 'No apps are currently installed'
|
||||||
}
|
}
|
||||||
</p>
|
</p>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -498,6 +584,19 @@ export function AppsComponent() {
|
|||||||
onSave={handleConfigSave}
|
onSave={handleConfigSave}
|
||||||
isSaving={isAdding}
|
isSaving={isAdding}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* App Detail Modal */}
|
||||||
|
{selectedAppForDetail && currentInstance && (
|
||||||
|
<AppDetailModal
|
||||||
|
instanceName={currentInstance}
|
||||||
|
appName={selectedAppForDetail}
|
||||||
|
open={detailModalOpen}
|
||||||
|
onClose={() => {
|
||||||
|
setDetailModalOpen(false);
|
||||||
|
setSelectedAppForDetail(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -135,15 +135,6 @@ export function CentralComponent() {
|
|||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card className="p-4 border-l-4 border-l-orange-500">
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<CheckCircle className="h-5 w-5 text-orange-500 mt-0.5" />
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="text-sm text-muted-foreground mb-1">Setup Files</div>
|
|
||||||
<div className="font-medium capitalize">{centralStatus?.setupFiles || 'Unknown'}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -20,23 +20,39 @@ interface CloudConfig {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ClusterConfig {
|
||||||
|
endpointIp: string;
|
||||||
|
hostnamePrefix?: string;
|
||||||
|
nodes: {
|
||||||
|
talos: {
|
||||||
|
version: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function CloudComponent() {
|
export function CloudComponent() {
|
||||||
const { currentInstance } = useInstanceContext();
|
const { currentInstance } = useInstanceContext();
|
||||||
const { config: fullConfig, isLoading, error, updateConfig, isUpdating } = useInstanceConfig(currentInstance);
|
const { config: fullConfig, isLoading, error, updateConfig, isUpdating } = useInstanceConfig(currentInstance);
|
||||||
|
|
||||||
// Extract cloud config from full config
|
// Extract cloud and cluster config from full config
|
||||||
const config = fullConfig?.cloud as CloudConfig | undefined;
|
const config = fullConfig?.cloud as CloudConfig | undefined;
|
||||||
|
const clusterConfig = fullConfig?.cluster as ClusterConfig | undefined;
|
||||||
|
|
||||||
const [editingDomains, setEditingDomains] = useState(false);
|
const [editingDomains, setEditingDomains] = useState(false);
|
||||||
const [editingNetwork, setEditingNetwork] = useState(false);
|
const [editingNetwork, setEditingNetwork] = useState(false);
|
||||||
|
const [editingCluster, setEditingCluster] = useState(false);
|
||||||
const [formValues, setFormValues] = useState<CloudConfig | null>(null);
|
const [formValues, setFormValues] = useState<CloudConfig | null>(null);
|
||||||
|
const [clusterFormValues, setClusterFormValues] = useState<ClusterConfig | null>(null);
|
||||||
|
|
||||||
// Sync form values when config loads
|
// Sync form values when config loads
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (config && !formValues) {
|
if (config && !formValues) {
|
||||||
setFormValues(config as CloudConfig);
|
setFormValues(config as CloudConfig);
|
||||||
}
|
}
|
||||||
}, [config, formValues]);
|
if (clusterConfig && !clusterFormValues) {
|
||||||
|
setClusterFormValues(clusterConfig as ClusterConfig);
|
||||||
|
}
|
||||||
|
}, [config, clusterConfig, formValues, clusterFormValues]);
|
||||||
|
|
||||||
const handleDomainsEdit = () => {
|
const handleDomainsEdit = () => {
|
||||||
if (config) {
|
if (config) {
|
||||||
@@ -106,6 +122,33 @@ export function CloudComponent() {
|
|||||||
setEditingNetwork(false);
|
setEditingNetwork(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleClusterEdit = () => {
|
||||||
|
if (clusterConfig) {
|
||||||
|
setClusterFormValues(clusterConfig as ClusterConfig);
|
||||||
|
setEditingCluster(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClusterSave = async () => {
|
||||||
|
if (!clusterFormValues || !fullConfig) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Update only the cluster section, preserving other config sections
|
||||||
|
await updateConfig({
|
||||||
|
...fullConfig,
|
||||||
|
cluster: clusterFormValues,
|
||||||
|
});
|
||||||
|
setEditingCluster(false);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to save cluster settings:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClusterCancel = () => {
|
||||||
|
setClusterFormValues(clusterConfig as ClusterConfig);
|
||||||
|
setEditingCluster(false);
|
||||||
|
};
|
||||||
|
|
||||||
const updateFormValue = (path: string, value: string) => {
|
const updateFormValue = (path: string, value: string) => {
|
||||||
if (!formValues) return;
|
if (!formValues) return;
|
||||||
|
|
||||||
@@ -130,6 +173,35 @@ export function CloudComponent() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const updateClusterFormValue = (path: string, value: string) => {
|
||||||
|
if (!clusterFormValues) return;
|
||||||
|
|
||||||
|
setClusterFormValues(prev => {
|
||||||
|
if (!prev) return prev;
|
||||||
|
|
||||||
|
// Handle nested paths like "nodes.talos.version"
|
||||||
|
const keys = path.split('.');
|
||||||
|
if (keys.length === 1) {
|
||||||
|
return { ...prev, [keys[0]]: value };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (keys.length === 3 && keys[0] === 'nodes' && keys[1] === 'talos') {
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
nodes: {
|
||||||
|
...prev.nodes,
|
||||||
|
talos: {
|
||||||
|
...prev.nodes.talos,
|
||||||
|
[keys[2]]: value,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return prev;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
// Show message if no instance is selected
|
// Show message if no instance is selected
|
||||||
if (!currentInstance) {
|
if (!currentInstance) {
|
||||||
return (
|
return (
|
||||||
@@ -390,6 +462,120 @@ export function CloudComponent() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Cluster Configuration Section */}
|
||||||
|
{clusterFormValues && (
|
||||||
|
<Card className="p-4 border-l-4 border-l-purple-500">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-medium">Cluster Configuration</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Kubernetes cluster and node settings
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button variant="ghost" size="sm">
|
||||||
|
<HelpCircle className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
{!editingCluster && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleClusterEdit}
|
||||||
|
disabled={isUpdating}
|
||||||
|
>
|
||||||
|
<Edit2 className="h-4 w-4 mr-1" />
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{editingCluster ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="endpoint-ip-edit">Cluster Endpoint IP</Label>
|
||||||
|
<Input
|
||||||
|
id="endpoint-ip-edit"
|
||||||
|
value={clusterFormValues.endpointIp}
|
||||||
|
onChange={(e) => updateClusterFormValue('endpointIp', e.target.value)}
|
||||||
|
placeholder="192.168.1.60"
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
Virtual IP for the Kubernetes API endpoint
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="hostname-prefix-edit">Hostname Prefix (Optional)</Label>
|
||||||
|
<Input
|
||||||
|
id="hostname-prefix-edit"
|
||||||
|
value={clusterFormValues.hostnamePrefix || ''}
|
||||||
|
onChange={(e) => updateClusterFormValue('hostnamePrefix', e.target.value)}
|
||||||
|
placeholder="mycluster-"
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
Prefix for auto-generated node hostnames (e.g., "mycluster-control-1")
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="talos-version-edit">Talos Version</Label>
|
||||||
|
<Input
|
||||||
|
id="talos-version-edit"
|
||||||
|
value={clusterFormValues.nodes.talos.version}
|
||||||
|
onChange={(e) => updateClusterFormValue('nodes.talos.version', e.target.value)}
|
||||||
|
placeholder="v1.8.0"
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
Talos Linux version for cluster nodes
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button size="sm" onClick={handleClusterSave} disabled={isUpdating}>
|
||||||
|
{isUpdating ? (
|
||||||
|
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Check className="h-4 w-4 mr-1" />
|
||||||
|
)}
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleClusterCancel}
|
||||||
|
disabled={isUpdating}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4 mr-1" />
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<Label>Cluster Endpoint IP</Label>
|
||||||
|
<div className="mt-1 p-2 bg-muted rounded-md font-mono text-sm">
|
||||||
|
{clusterFormValues.endpointIp}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>Hostname Prefix</Label>
|
||||||
|
<div className="mt-1 p-2 bg-muted rounded-md font-mono text-sm">
|
||||||
|
{clusterFormValues.hostnamePrefix || '(none)'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>Talos Version</Label>
|
||||||
|
<div className="mt-1 p-2 bg-muted rounded-md font-mono text-sm">
|
||||||
|
{clusterFormValues.nodes.talos.version}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,73 +1,124 @@
|
|||||||
import { useState } from 'react';
|
import { useState, useEffect, useMemo } from 'react';
|
||||||
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';
|
||||||
|
import { Alert } from './ui/alert';
|
||||||
|
import { Input } from './ui/input';
|
||||||
import { Cpu, HardDrive, Network, Monitor, CheckCircle, AlertCircle, BookOpen, ExternalLink, Loader2 } from 'lucide-react';
|
import { Cpu, HardDrive, Network, Monitor, CheckCircle, AlertCircle, BookOpen, ExternalLink, Loader2 } from 'lucide-react';
|
||||||
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 { 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 { currentInstance } = useInstanceContext();
|
||||||
const {
|
const {
|
||||||
nodes,
|
nodes,
|
||||||
isLoading,
|
isLoading,
|
||||||
error,
|
error,
|
||||||
addNode,
|
addNode,
|
||||||
isAdding,
|
addError,
|
||||||
deleteNode,
|
deleteNode,
|
||||||
isDeleting,
|
deleteError,
|
||||||
discover,
|
discover,
|
||||||
isDiscovering,
|
isDiscovering,
|
||||||
detect,
|
discoverError: discoverMutationError,
|
||||||
isDetecting
|
getHardware,
|
||||||
|
isGettingHardware,
|
||||||
|
getHardwareError,
|
||||||
|
cancelDiscovery,
|
||||||
|
isCancellingDiscovery,
|
||||||
|
updateNode,
|
||||||
|
applyNode,
|
||||||
|
isApplying,
|
||||||
|
refetch
|
||||||
} = useNodes(currentInstance);
|
} = useNodes(currentInstance);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: discoveryStatus
|
data: discoveryStatus
|
||||||
} = useDiscoveryStatus(currentInstance);
|
} = useDiscoveryStatus(currentInstance);
|
||||||
|
|
||||||
const [subnet, setSubnet] = useState('192.168.1.0/24');
|
const {
|
||||||
|
status: clusterStatus
|
||||||
|
} = useCluster(currentInstance);
|
||||||
|
|
||||||
const getStatusIcon = (status?: string) => {
|
const { data: clusterStatusData } = useClusterStatus(currentInstance || '');
|
||||||
switch (status) {
|
|
||||||
case 'ready':
|
const [addNodeIp, setAddNodeIp] = useState('');
|
||||||
case 'healthy':
|
const [discoverError, setDiscoverError] = useState<string | null>(null);
|
||||||
return <CheckCircle className="h-5 w-5 text-green-500" />;
|
const [detectError, setDetectError] = useState<string | null>(null);
|
||||||
case 'error':
|
const [discoverSuccess, setDiscoverSuccess] = useState<string | null>(null);
|
||||||
return <AlertCircle className="h-5 w-5 text-red-500" />;
|
const [showBootstrapModal, setShowBootstrapModal] = useState(false);
|
||||||
case 'connecting':
|
const [bootstrapNode, setBootstrapNode] = useState<{ name: string; ip: string } | null>(null);
|
||||||
case 'provisioning':
|
const [drawerState, setDrawerState] = useState<{
|
||||||
return <Loader2 className="h-5 w-5 text-blue-500 animate-spin" />;
|
open: boolean;
|
||||||
default:
|
mode: 'add' | 'configure';
|
||||||
return <Monitor className="h-5 w-5 text-muted-foreground" />;
|
node?: Node;
|
||||||
|
detection?: HardwareInfo;
|
||||||
|
}>({
|
||||||
|
open: false,
|
||||||
|
mode: 'add',
|
||||||
|
});
|
||||||
|
const [drawerEverOpened, setDrawerEverOpened] = useState(false);
|
||||||
|
const [deletingNodeHostname, setDeletingNodeHostname] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const closeDrawer = () => setDrawerState({ ...drawerState, open: false });
|
||||||
|
|
||||||
|
// Sync mutation errors to local state for display
|
||||||
|
useEffect(() => {
|
||||||
|
if (discoverMutationError) {
|
||||||
|
const errorMsg = (discoverMutationError as any)?.message || 'Failed to discover nodes';
|
||||||
|
setDiscoverError(errorMsg);
|
||||||
}
|
}
|
||||||
};
|
}, [discoverMutationError]);
|
||||||
|
|
||||||
const getStatusBadge = (status?: string) => {
|
useEffect(() => {
|
||||||
const variants: Record<string, 'secondary' | 'default' | 'success' | 'destructive'> = {
|
if (getHardwareError) {
|
||||||
pending: 'secondary',
|
const errorMsg = (getHardwareError as any)?.message || 'Failed to detect hardware';
|
||||||
connecting: 'default',
|
setDetectError(errorMsg);
|
||||||
provisioning: 'default',
|
}
|
||||||
ready: 'success',
|
}, [getHardwareError]);
|
||||||
healthy: 'success',
|
|
||||||
error: 'destructive',
|
|
||||||
};
|
|
||||||
|
|
||||||
const labels: Record<string, string> = {
|
// Track previous discovery status to detect completion
|
||||||
pending: 'Pending',
|
const [prevDiscoveryActive, setPrevDiscoveryActive] = useState<boolean | null>(null);
|
||||||
connecting: 'Connecting',
|
|
||||||
provisioning: 'Provisioning',
|
|
||||||
ready: 'Ready',
|
|
||||||
healthy: 'Healthy',
|
|
||||||
error: 'Error',
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
// Handle discovery completion (when active changes from true to false)
|
||||||
<Badge variant={variants[status || 'pending']}>
|
useEffect(() => {
|
||||||
{labels[status || 'pending'] || status}
|
const isActive = discoveryStatus?.active ?? false;
|
||||||
</Badge>
|
|
||||||
);
|
// Discovery just completed (was active, now inactive)
|
||||||
};
|
if (prevDiscoveryActive === true && isActive === false && discoveryStatus) {
|
||||||
|
const count = discoveryStatus.nodes_found?.length || 0;
|
||||||
|
if (count === 0) {
|
||||||
|
setDiscoverSuccess(`Discovery complete! No nodes were found.`);
|
||||||
|
} else {
|
||||||
|
setDiscoverSuccess(`Discovery complete! Found ${count} node${count !== 1 ? 's' : ''}.`);
|
||||||
|
}
|
||||||
|
setDiscoverError(null);
|
||||||
|
refetch();
|
||||||
|
|
||||||
|
const timer = setTimeout(() => setDiscoverSuccess(null), 5000);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update previous state
|
||||||
|
setPrevDiscoveryActive(isActive);
|
||||||
|
}, [discoveryStatus, prevDiscoveryActive, refetch]);
|
||||||
|
|
||||||
const getRoleIcon = (role: string) => {
|
const getRoleIcon = (role: string) => {
|
||||||
return role === 'controlplane' ? (
|
return role === 'controlplane' ? (
|
||||||
@@ -77,30 +128,142 @@ export function ClusterNodesComponent() {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAddNode = (ip: string, hostname: string, role: 'controlplane' | 'worker') => {
|
const handleAddFromDiscovery = async (discovered: DiscoveredNode) => {
|
||||||
if (!currentInstance) return;
|
// Fetch full hardware details for the discovered node
|
||||||
addNode({ target_ip: ip, hostname, role, disk: '/dev/sda' });
|
try {
|
||||||
|
const hardware = await getHardware(discovered.ip);
|
||||||
|
setDrawerEverOpened(true);
|
||||||
|
setDrawerState({
|
||||||
|
open: true,
|
||||||
|
mode: 'add',
|
||||||
|
detection: hardware,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to detect hardware:', err);
|
||||||
|
setDetectError((err as any)?.message || 'Failed to detect hardware');
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteNode = (hostname: string) => {
|
const handleAddNode = async () => {
|
||||||
|
if (!addNodeIp) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const hardware = await getHardware(addNodeIp);
|
||||||
|
setDrawerEverOpened(true);
|
||||||
|
setDrawerState({
|
||||||
|
open: true,
|
||||||
|
mode: 'add',
|
||||||
|
detection: hardware,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to detect hardware:', err);
|
||||||
|
setDetectError((err as any)?.message || 'Failed to detect hardware');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfigureNode = async (node: Node) => {
|
||||||
|
// Try to detect hardware if target_ip is available
|
||||||
|
if (node.target_ip) {
|
||||||
|
try {
|
||||||
|
const hardware = await getHardware(node.target_ip);
|
||||||
|
setDrawerEverOpened(true);
|
||||||
|
setDrawerState({
|
||||||
|
open: true,
|
||||||
|
mode: 'configure',
|
||||||
|
node,
|
||||||
|
detection: hardware,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to detect hardware:', err);
|
||||||
|
// Fall through to open drawer without detection data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open drawer without detection data (either no target_ip or detection failed)
|
||||||
|
setDrawerEverOpened(true);
|
||||||
|
setDrawerState({
|
||||||
|
open: true,
|
||||||
|
mode: 'configure',
|
||||||
|
node,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddSubmit = async (data: NodeFormData) => {
|
||||||
|
const nodeData = {
|
||||||
|
hostname: data.hostname,
|
||||||
|
role: filterRole || data.role,
|
||||||
|
disk: data.disk,
|
||||||
|
target_ip: data.targetIp,
|
||||||
|
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('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfigureSubmit = async (data: NodeFormData) => {
|
||||||
|
if (!drawerState.node) return;
|
||||||
|
|
||||||
|
await updateNode({
|
||||||
|
nodeName: drawerState.node.hostname,
|
||||||
|
updates: {
|
||||||
|
role: data.role,
|
||||||
|
target_ip: data.targetIp,
|
||||||
|
interface: data.interface,
|
||||||
|
schematic_id: data.schematicId,
|
||||||
|
maintenance: data.maintenance,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
closeDrawer();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleApply = async (data: NodeFormData) => {
|
||||||
|
if (!drawerState.node) return;
|
||||||
|
|
||||||
|
await handleConfigureSubmit(data);
|
||||||
|
await applyNode(drawerState.node.hostname);
|
||||||
|
};
|
||||||
|
|
||||||
|
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 = () => {
|
||||||
if (!currentInstance) return;
|
setDiscoverError(null);
|
||||||
discover(subnet);
|
setDiscoverSuccess(null);
|
||||||
|
// Always use auto-detect to scan all local networks
|
||||||
|
discover(undefined);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDetect = () => {
|
|
||||||
if (!currentInstance) return;
|
|
||||||
detect();
|
|
||||||
};
|
|
||||||
|
|
||||||
// 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 = useMemo(() => {
|
||||||
|
const allNodes = 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';
|
||||||
@@ -109,11 +272,44 @@ 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
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// Extract IPs from discovered nodes
|
// Filter by role if specified
|
||||||
const discoveredIps = discoveryStatus?.nodes_found?.map(n => n.ip) || [];
|
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
|
||||||
|
const hasReadyControlPlane = assignedNodes.some(
|
||||||
|
n => n.role === 'controlplane' && n.status === 'ready'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check if cluster is already bootstrapped using cluster status
|
||||||
|
// The backend checks for kubeconfig existence and cluster connectivity
|
||||||
|
// 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]);
|
||||||
|
|
||||||
|
const firstReadyControl = useMemo(() => {
|
||||||
|
return assignedNodes.find(
|
||||||
|
n => n.role === 'controlplane' && n.status === 'ready'
|
||||||
|
);
|
||||||
|
}, [assignedNodes]);
|
||||||
|
|
||||||
// Show message if no instance is selected
|
// Show message if no instance is selected
|
||||||
if (!currentInstance) {
|
if (!currentInstance) {
|
||||||
@@ -155,12 +351,12 @@ export function ClusterNodesComponent() {
|
|||||||
What are Cluster Nodes?
|
What are Cluster Nodes?
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-cyan-800 dark:text-cyan-200 mb-3 leading-relaxed">
|
<p className="text-cyan-800 dark:text-cyan-200 mb-3 leading-relaxed">
|
||||||
Think of cluster nodes as the "workers" in your personal cloud factory. Each node is a separate computer
|
Think of cluster nodes as the "workers" in your personal cloud factory. Each node is a separate computer
|
||||||
that contributes its processing power, memory, and storage to the overall cluster. Some nodes are "controllers"
|
that contributes its processing power, memory, and storage to the overall cluster. Some nodes are "controllers"
|
||||||
(like managers) that coordinate the work, while others are "workers" that do the heavy lifting.
|
(like managers) that coordinate the work, while others are "workers" that do the heavy lifting.
|
||||||
</p>
|
</p>
|
||||||
<p className="text-cyan-700 dark:text-cyan-300 mb-4 text-sm">
|
<p className="text-cyan-700 dark:text-cyan-300 mb-4 text-sm">
|
||||||
By connecting multiple computers together as nodes, you create a powerful, resilient system where if one
|
By connecting multiple computers together as nodes, you create a powerful, resilient system where if one
|
||||||
computer fails, the others can pick up the work. This is how you scale your personal cloud from one machine to many.
|
computer fails, the others can pick up the work. This is how you scale your personal cloud from one machine to many.
|
||||||
</p>
|
</p>
|
||||||
<Button variant="outline" size="sm" className="text-cyan-700 border-cyan-300 hover:bg-cyan-100 dark:text-cyan-300 dark:border-cyan-700 dark:hover:bg-cyan-900/20">
|
<Button variant="outline" size="sm" className="text-cyan-700 border-cyan-300 hover:bg-cyan-100 dark:text-cyan-300 dark:border-cyan-700 dark:hover:bg-cyan-900/20">
|
||||||
@@ -171,6 +367,32 @@ export function ClusterNodesComponent() {
|
|||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Bootstrap Alert */}
|
||||||
|
{showBootstrap && needsBootstrap && firstReadyControl && (
|
||||||
|
<Alert variant="info" className="mb-6">
|
||||||
|
<CheckCircle className="h-5 w-5" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className="font-semibold mb-1">First Control Plane Node Ready!</h3>
|
||||||
|
<p className="text-sm text-muted-foreground mb-3">
|
||||||
|
Your first control plane node ({firstReadyControl.hostname}) is ready.
|
||||||
|
Bootstrap the cluster to initialize etcd and start Kubernetes control plane components.
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
setBootstrapNode({
|
||||||
|
name: firstReadyControl.hostname,
|
||||||
|
ip: firstReadyControl.target_ip
|
||||||
|
});
|
||||||
|
setShowBootstrapModal(true);
|
||||||
|
}}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
Bootstrap Cluster
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
<Card className="p-6">
|
<Card className="p-6">
|
||||||
<div className="flex items-center gap-4 mb-6">
|
<div className="flex items-center gap-4 mb-6">
|
||||||
<div className="p-2 bg-primary/10 rounded-lg">
|
<div className="p-2 bg-primary/10 rounded-lg">
|
||||||
@@ -191,41 +413,165 @@ export function ClusterNodesComponent() {
|
|||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
{/* Error and Success Alerts */}
|
||||||
|
{discoverError && (
|
||||||
|
<Alert variant="error" onClose={() => setDiscoverError(null)} className="mb-4">
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<div>
|
||||||
|
<strong>Discovery Failed</strong>
|
||||||
|
<p className="text-sm mt-1">{discoverError}</p>
|
||||||
|
</div>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{discoverSuccess && (
|
||||||
|
<Alert variant="success" onClose={() => setDiscoverSuccess(null)} className="mb-4">
|
||||||
|
<CheckCircle className="h-4 w-4" />
|
||||||
|
<div>
|
||||||
|
<strong>Discovery Successful</strong>
|
||||||
|
<p className="text-sm mt-1">{discoverSuccess}</p>
|
||||||
|
</div>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{detectError && (
|
||||||
|
<Alert variant="error" onClose={() => setDetectError(null)} className="mb-4">
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<div>
|
||||||
|
<strong>Auto-Detect Failed</strong>
|
||||||
|
<p className="text-sm mt-1">{detectError}</p>
|
||||||
|
</div>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
|
||||||
|
{addError && (
|
||||||
|
<Alert variant="error" onClose={() => {}} className="mb-4">
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<div>
|
||||||
|
<strong>Failed to Add Node</strong>
|
||||||
|
<p className="text-sm mt-1">{(addError as any)?.message || 'An error occurred'}</p>
|
||||||
|
</div>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{deleteError && (
|
||||||
|
<Alert variant="error" onClose={() => {}} className="mb-4">
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<div>
|
||||||
|
<strong>Failed to Remove Node</strong>
|
||||||
|
<p className="text-sm mt-1">{(deleteError as any)?.message || 'An error occurred'}</p>
|
||||||
|
</div>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 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
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
||||||
|
Discover nodes on the network or manually add by IP address
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Discovery button */}
|
||||||
|
<div className="flex gap-2 mb-4">
|
||||||
|
<Button
|
||||||
|
onClick={handleDiscover}
|
||||||
|
disabled={isDiscovering || discoveryStatus?.active}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
{isDiscovering || discoveryStatus?.active ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||||
|
Discovering...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Discover Nodes'
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
{(isDiscovering || discoveryStatus?.active) && (
|
||||||
|
<Button
|
||||||
|
onClick={() => cancelDiscovery()}
|
||||||
|
disabled={isCancellingDiscovery}
|
||||||
|
variant="destructive"
|
||||||
|
>
|
||||||
|
{isCancellingDiscovery && <Loader2 className="h-4 w-4 animate-spin mr-2" />}
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Discovered nodes display */}
|
||||||
|
{discoveryStatus?.nodes_found && discoveryStatus.nodes_found.length > 0 && (
|
||||||
|
<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">
|
||||||
|
{discovered.version}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={() => handleAddFromDiscovery(discovered)}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
Add to Cluster
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 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="space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h2 className="text-lg font-medium">Cluster Nodes ({assignedNodes.length})</h2>
|
<h2 className="text-lg font-medium">Cluster Nodes ({assignedNodes.length})</h2>
|
||||||
<div className="flex gap-2">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="Subnet (e.g., 192.168.1.0/24)"
|
|
||||||
value={subnet}
|
|
||||||
onChange={(e) => setSubnet(e.target.value)}
|
|
||||||
className="px-3 py-1 text-sm border rounded-lg"
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
onClick={handleDiscover}
|
|
||||||
disabled={isDiscovering || discoveryStatus?.active}
|
|
||||||
>
|
|
||||||
{isDiscovering || discoveryStatus?.active ? (
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
|
||||||
) : null}
|
|
||||||
{discoveryStatus?.active ? 'Discovering...' : 'Discover'}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
onClick={handleDetect}
|
|
||||||
disabled={isDetecting}
|
|
||||||
>
|
|
||||||
{isDetecting ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : null}
|
|
||||||
Auto Detect
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{assignedNodes.map((node) => (
|
{assignedNodes.map((node) => (
|
||||||
<Card key={node.hostname} className="p-4">
|
<Card key={node.hostname} className="p-4 hover:shadow-md transition-shadow">
|
||||||
|
<div className="mb-2">
|
||||||
|
<NodeStatusBadge node={node} compact />
|
||||||
|
</div>
|
||||||
|
|
||||||
<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">
|
||||||
{getRoleIcon(node.role)}
|
{getRoleIcon(node.role)}
|
||||||
@@ -236,13 +582,17 @@ export function ClusterNodesComponent() {
|
|||||||
<Badge variant="outline" className="text-xs">
|
<Badge variant="outline" className="text-xs">
|
||||||
{node.role}
|
{node.role}
|
||||||
</Badge>
|
</Badge>
|
||||||
{getStatusIcon(node.status)}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-muted-foreground mb-2">
|
<div className="text-sm text-muted-foreground mb-2">
|
||||||
IP: {node.target_ip}
|
Target: {node.target_ip}
|
||||||
</div>
|
</div>
|
||||||
|
{node.disk && (
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
Disk: {node.disk}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{node.hardware && (
|
{node.hardware && (
|
||||||
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
<div className="flex items-center gap-4 text-xs text-muted-foreground mt-2">
|
||||||
{node.hardware.cpu && (
|
{node.hardware.cpu && (
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<Cpu className="h-3 w-3" />
|
<Cpu className="h-3 w-3" />
|
||||||
@@ -263,22 +613,48 @@ 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>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex flex-col gap-2">
|
||||||
{getStatusBadge(node.status)}
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleConfigureNode(node)}
|
||||||
|
>
|
||||||
|
Configure
|
||||||
|
</Button>
|
||||||
|
{node.configured && !node.applied && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => applyNode(node.hostname)}
|
||||||
|
disabled={isApplying}
|
||||||
|
variant="secondary"
|
||||||
|
>
|
||||||
|
{isApplying ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Apply'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
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" /> : 'Remove'}
|
{deletingNodeHostname === node.hostname ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Delete'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -295,78 +671,37 @@ export function ClusterNodesComponent() {
|
|||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{discoveredIps.length > 0 && (
|
|
||||||
<div className="mt-6">
|
|
||||||
<h3 className="text-lg font-medium mb-4">Discovered IPs ({discoveredIps.length})</h3>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{discoveredIps.map((ip) => (
|
|
||||||
<Card key={ip} className="p-3 flex items-center justify-between">
|
|
||||||
<span className="text-sm font-mono">{ip}</span>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
onClick={() => handleAddNode(ip, `node-${ip}`, 'worker')}
|
|
||||||
disabled={isAdding}
|
|
||||||
>
|
|
||||||
Add as Worker
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => handleAddNode(ip, `controlplane-${ip}`, 'controlplane')}
|
|
||||||
disabled={isAdding}
|
|
||||||
>
|
|
||||||
Add as Control Plane
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card className="p-6">
|
{/* Bootstrap Modal */}
|
||||||
<h3 className="text-lg font-medium mb-4">PXE Boot Instructions</h3>
|
{showBootstrapModal && bootstrapNode && (
|
||||||
<div className="space-y-3 text-sm">
|
<BootstrapModal
|
||||||
<div className="flex items-start gap-3">
|
instanceName={currentInstance!}
|
||||||
<div className="w-6 h-6 rounded-full bg-primary text-primary-foreground flex items-center justify-center text-xs font-medium">
|
nodeName={bootstrapNode.name}
|
||||||
1
|
nodeIp={bootstrapNode.ip}
|
||||||
</div>
|
onClose={() => {
|
||||||
<div>
|
setShowBootstrapModal(false);
|
||||||
<p className="font-medium">Power on your nodes</p>
|
setBootstrapNode(null);
|
||||||
<p className="text-muted-foreground">
|
refetch();
|
||||||
Ensure network boot (PXE) is enabled in BIOS/UEFI settings
|
}}
|
||||||
</p>
|
/>
|
||||||
</div>
|
)}
|
||||||
</div>
|
|
||||||
<div className="flex items-start gap-3">
|
{/* Node Form Drawer - only render after first open to prevent infinite loop on initial mount */}
|
||||||
<div className="w-6 h-6 rounded-full bg-primary text-primary-foreground flex items-center justify-center text-xs font-medium">
|
{drawerEverOpened && (
|
||||||
2
|
<NodeFormDrawer
|
||||||
</div>
|
open={drawerState.open}
|
||||||
<div>
|
onClose={closeDrawer}
|
||||||
<p className="font-medium">Connect to the wild-cloud network</p>
|
mode={drawerState.mode}
|
||||||
<p className="text-muted-foreground">
|
node={drawerState.mode === 'configure' ? drawerState.node : undefined}
|
||||||
Nodes will automatically receive IP addresses via DHCP
|
detection={drawerState.detection}
|
||||||
</p>
|
onSubmit={drawerState.mode === 'add' ? handleAddSubmit : handleConfigureSubmit}
|
||||||
</div>
|
onApply={drawerState.mode === 'configure' ? handleApply : undefined}
|
||||||
</div>
|
instanceName={currentInstance || ''}
|
||||||
<div className="flex items-start gap-3">
|
/>
|
||||||
<div className="w-6 h-6 rounded-full bg-primary text-primary-foreground flex items-center justify-center text-xs font-medium">
|
)}
|
||||||
3
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="font-medium">Boot Talos Linux</p>
|
|
||||||
<p className="text-muted-foreground">
|
|
||||||
Nodes will automatically download and boot Talos Linux via PXE
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -237,6 +237,22 @@ export const ConfigurationForm = () => {
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="cluster.hostnamePrefix"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Hostname Prefix (Optional)</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="test-" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
Optional prefix for node hostnames (e.g., 'test-' for unique names on LAN)
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="cluster.nodes.talos.version"
|
name="cluster.nodes.talos.version"
|
||||||
|
|||||||
5
src/components/ControlNodesComponent.tsx
Normal file
5
src/components/ControlNodesComponent.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { ClusterNodesComponent } from './ClusterNodesComponent';
|
||||||
|
|
||||||
|
export function ControlNodesComponent() {
|
||||||
|
return <ClusterNodesComponent filterRole="controlplane" hideDiscoveryWhenNodesGte={3} showBootstrap={true} />;
|
||||||
|
}
|
||||||
216
src/components/InstanceSwitcher.tsx
Normal file
216
src/components/InstanceSwitcher.tsx
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useNavigate, useLocation, useParams } from 'react-router';
|
||||||
|
import { Plus } from 'lucide-react';
|
||||||
|
import { useInstances } from '../hooks/useInstances';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
SelectSeparator,
|
||||||
|
} from './ui/select';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from './ui/dialog';
|
||||||
|
import { Button } from './ui/button';
|
||||||
|
import { Input } from './ui/input';
|
||||||
|
import { Label } from './ui/label';
|
||||||
|
|
||||||
|
const ADD_INSTANCE_VALUE = '__add_new__';
|
||||||
|
|
||||||
|
export function InstanceSwitcher() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
const { instanceId } = useParams<{ instanceId: string }>();
|
||||||
|
const { instances, isLoading, error, createInstance, isCreating } = useInstances();
|
||||||
|
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
|
const [newInstanceName, setNewInstanceName] = useState('');
|
||||||
|
|
||||||
|
const handleInstanceChange = (value: string) => {
|
||||||
|
// Check if user selected "Add new instance"
|
||||||
|
if (value === ADD_INSTANCE_VALUE) {
|
||||||
|
setDialogOpen(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!instanceId) return;
|
||||||
|
|
||||||
|
// Extract the page path after /instances/:instanceId
|
||||||
|
const instancePrefix = `/instances/${instanceId}`;
|
||||||
|
const pagePath = location.pathname.startsWith(instancePrefix)
|
||||||
|
? location.pathname.slice(instancePrefix.length)
|
||||||
|
: '/dashboard';
|
||||||
|
|
||||||
|
// Navigate to the same page in the new instance
|
||||||
|
navigate(`/instances/${value}${pagePath || '/dashboard'}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateInstance = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!newInstanceName.trim()) return;
|
||||||
|
|
||||||
|
createInstance(
|
||||||
|
{ name: newInstanceName.trim() },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
setDialogOpen(false);
|
||||||
|
setNewInstanceName('');
|
||||||
|
// Navigate to the new instance's dashboard
|
||||||
|
navigate(`/instances/${newInstanceName.trim()}/dashboard`);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Loading state
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<Select disabled value="">
|
||||||
|
<SelectTrigger className="h-8 text-sm">
|
||||||
|
<SelectValue placeholder="Loading..." />
|
||||||
|
</SelectTrigger>
|
||||||
|
</Select>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error state
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<Select disabled value="">
|
||||||
|
<SelectTrigger className="h-8 text-sm">
|
||||||
|
<SelectValue placeholder="Error loading instances" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</Select>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// No instances state - show dialog immediately
|
||||||
|
if (!instances || instances.length === 0) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Select disabled value="">
|
||||||
|
<SelectTrigger className="h-8 text-sm">
|
||||||
|
<SelectValue placeholder="No instances" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</Select>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setDialogOpen(true)}
|
||||||
|
className="mt-2 w-full h-8 text-sm"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
|
Add Instance
|
||||||
|
</Button>
|
||||||
|
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<form onSubmit={handleCreateInstance}>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Create New Instance</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Enter a name for your new Wild Cloud instance.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="grid gap-4 py-4">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="name">Instance Name</Label>
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
placeholder="my-instance"
|
||||||
|
value={newInstanceName}
|
||||||
|
onChange={(e) => setNewInstanceName(e.target.value)}
|
||||||
|
disabled={isCreating}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setDialogOpen(false)}
|
||||||
|
disabled={isCreating}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={isCreating || !newInstanceName.trim()}>
|
||||||
|
{isCreating ? 'Creating...' : 'Create Instance'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Select value={instanceId || ''} onValueChange={handleInstanceChange}>
|
||||||
|
<SelectTrigger className="h-8 text-sm">
|
||||||
|
<SelectValue placeholder="Select instance" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{instances.map((instance) => (
|
||||||
|
<SelectItem key={instance} value={instance}>
|
||||||
|
{instance}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
<SelectSeparator />
|
||||||
|
<SelectItem value={ADD_INSTANCE_VALUE}>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
|
Add new instance...
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<form onSubmit={handleCreateInstance}>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Create New Instance</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Enter a name for your new Wild Cloud instance.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="grid gap-4 py-4">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="name">Instance Name</Label>
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
placeholder="my-instance"
|
||||||
|
value={newInstanceName}
|
||||||
|
onChange={(e) => setNewInstanceName(e.target.value)}
|
||||||
|
disabled={isCreating}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setDialogOpen(false)}
|
||||||
|
disabled={isCreating}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={isCreating || !newInstanceName.trim()}>
|
||||||
|
{isCreating ? 'Creating...' : 'Create Instance'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
5
src/components/WorkerNodesComponent.tsx
Normal file
5
src/components/WorkerNodesComponent.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { ClusterNodesComponent } from './ClusterNodesComponent';
|
||||||
|
|
||||||
|
export function WorkerNodesComponent() {
|
||||||
|
return <ClusterNodesComponent filterRole="worker" hideDiscoveryWhenNodesGte={undefined} showBootstrap={false} />;
|
||||||
|
}
|
||||||
@@ -37,6 +37,15 @@ export function AppConfigDialog({
|
|||||||
if (app && open) {
|
if (app && open) {
|
||||||
const initialConfig: Record<string, string> = {};
|
const initialConfig: Record<string, string> = {};
|
||||||
|
|
||||||
|
// Debug logging to diagnose the issue
|
||||||
|
console.log('[AppConfigDialog] App data:', {
|
||||||
|
name: app.name,
|
||||||
|
hasDefaultConfig: !!app.defaultConfig,
|
||||||
|
defaultConfigKeys: app.defaultConfig ? Object.keys(app.defaultConfig) : [],
|
||||||
|
hasExistingConfig: !!existingConfig,
|
||||||
|
existingConfigKeys: existingConfig ? Object.keys(existingConfig) : [],
|
||||||
|
});
|
||||||
|
|
||||||
// Start with default config
|
// Start with default config
|
||||||
if (app.defaultConfig) {
|
if (app.defaultConfig) {
|
||||||
Object.entries(app.defaultConfig).forEach(([key, value]) => {
|
Object.entries(app.defaultConfig).forEach(([key, value]) => {
|
||||||
|
|||||||
608
src/components/apps/AppDetailModal.tsx
Normal file
608
src/components/apps/AppDetailModal.tsx
Normal file
@@ -0,0 +1,608 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import ReactMarkdown from 'react-markdown';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { useAppEnhanced, useAppLogs, useAppEvents, useAppReadme } from '@/hooks/useApps';
|
||||||
|
import {
|
||||||
|
RefreshCw,
|
||||||
|
Eye,
|
||||||
|
Settings,
|
||||||
|
Activity,
|
||||||
|
FileText,
|
||||||
|
ExternalLink,
|
||||||
|
AlertCircle,
|
||||||
|
CheckCircle,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
interface AppDetailModalProps {
|
||||||
|
instanceName: string;
|
||||||
|
appName: string;
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ViewMode = 'overview' | 'configuration' | 'status' | 'logs';
|
||||||
|
|
||||||
|
export function AppDetailModal({
|
||||||
|
instanceName,
|
||||||
|
appName,
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
}: AppDetailModalProps) {
|
||||||
|
const [viewMode, setViewMode] = useState<ViewMode>('overview');
|
||||||
|
const [showSecrets, setShowSecrets] = useState(false);
|
||||||
|
const [logParams, setLogParams] = useState({ tail: 100, sinceSeconds: 3600 });
|
||||||
|
|
||||||
|
const { data: appDetails, isLoading, refetch } = useAppEnhanced(instanceName, appName);
|
||||||
|
const { data: logs, refetch: refetchLogs } = useAppLogs(
|
||||||
|
instanceName,
|
||||||
|
appName,
|
||||||
|
viewMode === 'logs' ? logParams : undefined
|
||||||
|
);
|
||||||
|
const { data: eventsData } = useAppEvents(instanceName, appName, 20);
|
||||||
|
const { data: readmeContent, isLoading: readmeLoading } = useAppReadme(instanceName, appName);
|
||||||
|
|
||||||
|
const getPodStatusColor = (status: string | undefined) => {
|
||||||
|
if (!status) return 'text-muted-foreground';
|
||||||
|
const lowerStatus = status.toLowerCase();
|
||||||
|
if (lowerStatus.includes('running')) return 'text-green-600 dark:text-green-400';
|
||||||
|
if (lowerStatus.includes('pending')) return 'text-yellow-600 dark:text-yellow-400';
|
||||||
|
if (lowerStatus.includes('failed')) return 'text-red-600 dark:text-red-400';
|
||||||
|
return 'text-muted-foreground';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusBadge = (status: string) => {
|
||||||
|
const variants: Record<string, 'success' | 'destructive' | 'warning' | 'outline'> = {
|
||||||
|
running: 'success',
|
||||||
|
error: 'destructive',
|
||||||
|
deploying: 'outline',
|
||||||
|
stopped: 'warning',
|
||||||
|
added: 'outline',
|
||||||
|
deployed: 'outline',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Badge variant={variants[status] || 'outline'}>
|
||||||
|
{status.charAt(0).toUpperCase() + status.slice(1)}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(open) => !open && onClose()}>
|
||||||
|
<DialogContent className="max-w-5xl max-h-[90vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-3">
|
||||||
|
{appName}
|
||||||
|
{appDetails && getStatusBadge(appDetails.status)}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{appDetails?.description || 'Application details and configuration'}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{/* View Mode Selector */}
|
||||||
|
<div className="flex gap-2 border-b pb-4">
|
||||||
|
<Button
|
||||||
|
variant={viewMode === 'overview' ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setViewMode('overview')}
|
||||||
|
>
|
||||||
|
<Eye className="h-4 w-4 mr-2" />
|
||||||
|
Overview
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={viewMode === 'configuration' ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setViewMode('configuration')}
|
||||||
|
>
|
||||||
|
<Settings className="h-4 w-4 mr-2" />
|
||||||
|
Configuration
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={viewMode === 'status' ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setViewMode('status')}
|
||||||
|
>
|
||||||
|
<Activity className="h-4 w-4 mr-2" />
|
||||||
|
Status
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={viewMode === 'logs' ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setViewMode('logs')}
|
||||||
|
>
|
||||||
|
<FileText className="h-4 w-4 mr-2" />
|
||||||
|
Logs
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Overview Tab */}
|
||||||
|
{viewMode === 'overview' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button variant="outline" size="sm" onClick={() => refetch()}>
|
||||||
|
<RefreshCw className="h-4 w-4 mr-2" />
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Skeleton className="h-32 w-full" />
|
||||||
|
<Skeleton className="h-48 w-full" />
|
||||||
|
</div>
|
||||||
|
) : appDetails ? (
|
||||||
|
<>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg">Application Information</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">Name</p>
|
||||||
|
<p className="text-sm">{appDetails.name}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">Version</p>
|
||||||
|
<p className="text-sm">{appDetails.version || 'N/A'}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">Namespace</p>
|
||||||
|
<p className="text-sm">{appDetails.namespace}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">Status</p>
|
||||||
|
<p className="text-sm">{appDetails.status}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{appDetails.url && (
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-muted-foreground mb-1">URL</p>
|
||||||
|
<a
|
||||||
|
href={appDetails.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex items-center gap-1 text-sm text-primary hover:underline"
|
||||||
|
>
|
||||||
|
<ExternalLink className="h-3 w-3" />
|
||||||
|
{appDetails.url}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{appDetails.description && (
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-muted-foreground mb-1">Description</p>
|
||||||
|
<p className="text-sm">{appDetails.description}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{appDetails.manifest?.dependencies && appDetails.manifest.dependencies.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-muted-foreground mb-2">Dependencies</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{appDetails.manifest.dependencies.map((dep) => (
|
||||||
|
<Badge key={dep} variant="outline">
|
||||||
|
{dep}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* README Documentation */}
|
||||||
|
{readmeContent && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg flex items-center gap-2">
|
||||||
|
<FileText className="h-5 w-5" />
|
||||||
|
README
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{readmeLoading ? (
|
||||||
|
<Skeleton className="h-40 w-full" />
|
||||||
|
) : (
|
||||||
|
<div className="prose prose-sm max-w-none dark:prose-invert overflow-auto max-h-96 p-4 bg-muted/30 rounded-lg">
|
||||||
|
<ReactMarkdown
|
||||||
|
components={{
|
||||||
|
// Style code blocks
|
||||||
|
code: ({inline, children, ...props}) => {
|
||||||
|
return inline ? (
|
||||||
|
<code className="bg-muted px-1 py-0.5 rounded text-sm" {...props}>
|
||||||
|
{children}
|
||||||
|
</code>
|
||||||
|
) : (
|
||||||
|
<code className="block bg-muted p-3 rounded text-sm overflow-x-auto" {...props}>
|
||||||
|
{children}
|
||||||
|
</code>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
// Make links open in new tab
|
||||||
|
a: ({children, href, ...props}) => (
|
||||||
|
<a href={href} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline" {...props}>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{readmeContent}
|
||||||
|
</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="text-center text-muted-foreground py-8">No information available</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Configuration Tab */}
|
||||||
|
{viewMode === 'configuration' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button variant="outline" size="sm" onClick={() => refetch()}>
|
||||||
|
<RefreshCw className="h-4 w-4 mr-2" />
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Skeleton className="h-48 w-full" />
|
||||||
|
<Skeleton className="h-32 w-full" />
|
||||||
|
</div>
|
||||||
|
) : appDetails ? (
|
||||||
|
<>
|
||||||
|
{/* Configuration Values */}
|
||||||
|
{((appDetails.config && Object.keys(appDetails.config).length > 0) ||
|
||||||
|
(appDetails.manifest?.defaultConfig && Object.keys(appDetails.manifest.defaultConfig).length > 0)) && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg">Configuration</CardTitle>
|
||||||
|
<CardDescription>Current configuration values</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{Object.entries(appDetails.config || appDetails.manifest?.defaultConfig || {}).map(([key, value]) => (
|
||||||
|
<div key={key} className="flex justify-between text-sm border-b pb-2">
|
||||||
|
<span className="font-medium text-muted-foreground">{key}:</span>
|
||||||
|
<span className="font-mono text-xs break-all">
|
||||||
|
{typeof value === 'object' && value !== null
|
||||||
|
? JSON.stringify(value, null, 2)
|
||||||
|
: String(value)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Secrets */}
|
||||||
|
{appDetails.manifest?.requiredSecrets && appDetails.manifest.requiredSecrets.length > 0 && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg flex items-center justify-between">
|
||||||
|
<span>Secrets</span>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowSecrets(!showSecrets)}
|
||||||
|
>
|
||||||
|
{showSecrets ? 'Hide' : 'Show'}
|
||||||
|
</Button>
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>Sensitive configuration values (redacted)</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{appDetails.manifest.requiredSecrets.map((secret) => (
|
||||||
|
<div key={secret} className="flex justify-between text-sm border-b pb-2">
|
||||||
|
<span className="font-medium text-muted-foreground">{secret}:</span>
|
||||||
|
<span className="font-mono text-xs">
|
||||||
|
{showSecrets ? '**hidden**' : '••••••••'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="text-center text-muted-foreground py-8">No configuration available</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Status Tab */}
|
||||||
|
{viewMode === 'status' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button variant="outline" size="sm" onClick={() => refetch()}>
|
||||||
|
<RefreshCw className="h-4 w-4 mr-2" />
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Skeleton className="h-32 w-full" />
|
||||||
|
<Skeleton className="h-48 w-full" />
|
||||||
|
</div>
|
||||||
|
) : appDetails?.runtime ? (
|
||||||
|
<>
|
||||||
|
{/* Replicas */}
|
||||||
|
{appDetails.runtime.replicas && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg">Replicas</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid grid-cols-4 gap-2 text-sm">
|
||||||
|
<div className="bg-muted rounded p-2">
|
||||||
|
<p className="text-xs text-muted-foreground">Desired</p>
|
||||||
|
<p className="font-semibold">{appDetails.runtime.replicas.desired}</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-muted rounded p-2">
|
||||||
|
<p className="text-xs text-muted-foreground">Current</p>
|
||||||
|
<p className="font-semibold">{appDetails.runtime.replicas.current}</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-muted rounded p-2">
|
||||||
|
<p className="text-xs text-muted-foreground">Ready</p>
|
||||||
|
<p className="font-semibold">{appDetails.runtime.replicas.ready}</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-muted rounded p-2">
|
||||||
|
<p className="text-xs text-muted-foreground">Available</p>
|
||||||
|
<p className="font-semibold">{appDetails.runtime.replicas.available}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Pods */}
|
||||||
|
{appDetails.runtime.pods && appDetails.runtime.pods.length > 0 && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg">Pods</CardTitle>
|
||||||
|
<CardDescription>{appDetails.runtime.pods.length} pod(s)</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{appDetails.runtime.pods.map((pod) => (
|
||||||
|
<div
|
||||||
|
key={pod.name}
|
||||||
|
className="border rounded-lg p-3 hover:bg-muted/50 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between mb-2">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium truncate">{pod.name}</p>
|
||||||
|
{pod.node && (
|
||||||
|
<p className="text-xs text-muted-foreground">Node: {pod.node}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 ml-2">
|
||||||
|
<Badge variant="outline" className={getPodStatusColor(pod.status)}>
|
||||||
|
{pod.status}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-3 gap-2 text-xs">
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">Ready:</span>{' '}
|
||||||
|
<span className="font-medium">{pod.ready}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">Restarts:</span>{' '}
|
||||||
|
<span className="font-medium">{pod.restarts}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">Age:</span>{' '}
|
||||||
|
<span className="font-medium">{pod.age}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{pod.ip && (
|
||||||
|
<div className="text-xs mt-1">
|
||||||
|
<span className="text-muted-foreground">IP:</span>{' '}
|
||||||
|
<span className="font-mono">{pod.ip}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Resource Usage */}
|
||||||
|
{appDetails.runtime.resources && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg">Resource Usage</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
{appDetails.runtime.resources.cpu && (
|
||||||
|
<div>
|
||||||
|
<div className="flex justify-between text-sm mb-1">
|
||||||
|
<span>CPU</span>
|
||||||
|
<span className="font-mono text-xs">
|
||||||
|
{appDetails.runtime.resources.cpu.used} / {appDetails.runtime.resources.cpu.limit}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full bg-muted rounded-full h-2">
|
||||||
|
<div
|
||||||
|
className="bg-primary rounded-full h-2 transition-all"
|
||||||
|
style={{ width: `${Math.min(appDetails.runtime.resources.cpu.percentage, 100)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
{appDetails.runtime.resources.cpu.percentage.toFixed(1)}% used
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{appDetails.runtime.resources.memory && (
|
||||||
|
<div>
|
||||||
|
<div className="flex justify-between text-sm mb-1">
|
||||||
|
<span>Memory</span>
|
||||||
|
<span className="font-mono text-xs">
|
||||||
|
{appDetails.runtime.resources.memory.used} / {appDetails.runtime.resources.memory.limit}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full bg-muted rounded-full h-2">
|
||||||
|
<div
|
||||||
|
className="bg-primary rounded-full h-2 transition-all"
|
||||||
|
style={{ width: `${Math.min(appDetails.runtime.resources.memory.percentage, 100)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
{appDetails.runtime.resources.memory.percentage.toFixed(1)}% used
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{appDetails.runtime.resources.storage && (
|
||||||
|
<div>
|
||||||
|
<div className="flex justify-between text-sm mb-1">
|
||||||
|
<span>Storage</span>
|
||||||
|
<span className="font-mono text-xs">
|
||||||
|
{appDetails.runtime.resources.storage.used} / {appDetails.runtime.resources.storage.limit}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full bg-muted rounded-full h-2">
|
||||||
|
<div
|
||||||
|
className="bg-primary rounded-full h-2 transition-all"
|
||||||
|
style={{ width: `${Math.min(appDetails.runtime.resources.storage.percentage, 100)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
{appDetails.runtime.resources.storage.percentage.toFixed(1)}% used
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Recent Events */}
|
||||||
|
{eventsData?.events && eventsData.events.length > 0 && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg">Recent Events</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{eventsData.events.map((event, idx) => (
|
||||||
|
<div key={idx} className="flex items-start gap-2 text-sm border-b pb-2">
|
||||||
|
{event.type === 'Warning' ? (
|
||||||
|
<AlertCircle className="h-4 w-4 text-yellow-500 mt-0.5" />
|
||||||
|
) : (
|
||||||
|
<CheckCircle className="h-4 w-4 text-green-500 mt-0.5" />
|
||||||
|
)}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="font-medium">{event.reason}</p>
|
||||||
|
<p className="text-muted-foreground text-xs">{event.message}</p>
|
||||||
|
<p className="text-muted-foreground text-xs mt-1">
|
||||||
|
{event.timestamp} {event.count > 1 && `(${event.count}x)`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="text-center text-muted-foreground py-8">No status information available</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Logs Tab */}
|
||||||
|
{viewMode === 'logs' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<select
|
||||||
|
value={logParams.tail}
|
||||||
|
onChange={(e) => setLogParams({ ...logParams, tail: parseInt(e.target.value) })}
|
||||||
|
className="px-3 py-1 border rounded text-sm"
|
||||||
|
>
|
||||||
|
<option value={50}>Last 50 lines</option>
|
||||||
|
<option value={100}>Last 100 lines</option>
|
||||||
|
<option value={200}>Last 200 lines</option>
|
||||||
|
<option value={500}>Last 500 lines</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => refetchLogs()}>
|
||||||
|
<RefreshCw className="h-4 w-4 mr-2" />
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="bg-black text-green-400 font-mono text-xs p-4 rounded-lg max-h-96 overflow-y-auto">
|
||||||
|
{logs && logs.logs && Array.isArray(logs.logs) && logs.logs.length > 0 ? (
|
||||||
|
logs.logs.map((line, idx) => {
|
||||||
|
// Handle both string format and object format {timestamp, message, pod}
|
||||||
|
if (typeof line === 'string') {
|
||||||
|
return (
|
||||||
|
<div key={idx} className="whitespace-pre-wrap break-all">
|
||||||
|
{line}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} else if (line && typeof line === 'object' && 'message' in line) {
|
||||||
|
// Display timestamp and message nicely
|
||||||
|
const timestamp = line.timestamp ? new Date(line.timestamp).toLocaleTimeString() : '';
|
||||||
|
return (
|
||||||
|
<div key={idx} className="whitespace-pre-wrap break-all">
|
||||||
|
{timestamp && <span className="text-gray-500">[{timestamp}] </span>}
|
||||||
|
{line.message}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
<div key={idx} className="whitespace-pre-wrap break-all">
|
||||||
|
{JSON.stringify(line)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
) : logs && typeof logs === 'object' && !Array.isArray(logs) ? (
|
||||||
|
// Handle case where logs might be an object with different structure
|
||||||
|
<div className="whitespace-pre-wrap break-all">
|
||||||
|
{JSON.stringify(logs, null, 2)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-gray-500">No logs available</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
184
src/components/cluster/BootstrapModal.tsx
Normal file
184
src/components/cluster/BootstrapModal.tsx
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '../ui/dialog';
|
||||||
|
import { Button } from '../ui/button';
|
||||||
|
import { Alert } from '../ui/alert';
|
||||||
|
import { CheckCircle, AlertCircle, Loader2 } from 'lucide-react';
|
||||||
|
import { BootstrapProgress } from './BootstrapProgress';
|
||||||
|
import { clusterApi } from '../../services/api/cluster';
|
||||||
|
import { useOperation } from '../../services/api/hooks/useOperations';
|
||||||
|
|
||||||
|
interface BootstrapModalProps {
|
||||||
|
instanceName: string;
|
||||||
|
nodeName: string;
|
||||||
|
nodeIp: string;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BootstrapModal({
|
||||||
|
instanceName,
|
||||||
|
nodeName,
|
||||||
|
nodeIp,
|
||||||
|
onClose,
|
||||||
|
}: BootstrapModalProps) {
|
||||||
|
const [operationId, setOperationId] = useState<string | null>(null);
|
||||||
|
const [isStarting, setIsStarting] = useState(false);
|
||||||
|
const [startError, setStartError] = useState<string | null>(null);
|
||||||
|
const [showConfirmation, setShowConfirmation] = useState(true);
|
||||||
|
|
||||||
|
const { data: operation } = useOperation(instanceName, operationId || '');
|
||||||
|
|
||||||
|
const handleStartBootstrap = async () => {
|
||||||
|
setIsStarting(true);
|
||||||
|
setStartError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await clusterApi.bootstrap(instanceName, nodeName);
|
||||||
|
setOperationId(response.operation_id);
|
||||||
|
setShowConfirmation(false);
|
||||||
|
} catch (err) {
|
||||||
|
setStartError((err as Error).message || 'Failed to start bootstrap');
|
||||||
|
} finally {
|
||||||
|
setIsStarting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (operation?.status === 'completed') {
|
||||||
|
setTimeout(() => onClose(), 2000);
|
||||||
|
}
|
||||||
|
}, [operation?.status, onClose]);
|
||||||
|
|
||||||
|
const isComplete = operation?.status === 'completed';
|
||||||
|
const isFailed = operation?.status === 'failed';
|
||||||
|
const isRunning = operation?.status === 'running' || operation?.status === 'pending';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open onOpenChange={onClose}>
|
||||||
|
<DialogContent
|
||||||
|
className="max-w-2xl"
|
||||||
|
showCloseButton={!isRunning}
|
||||||
|
>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Bootstrap Cluster</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Initialize the Kubernetes cluster on {nodeName} ({nodeIp})
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{showConfirmation ? (
|
||||||
|
<>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Alert>
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<div>
|
||||||
|
<strong className="font-semibold">Important</strong>
|
||||||
|
<p className="text-sm mt-1">
|
||||||
|
This will initialize the etcd cluster and start the control plane
|
||||||
|
components. This operation can only be performed once per cluster and
|
||||||
|
should be run on the first control plane node.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
{startError && (
|
||||||
|
<Alert variant="error" onClose={() => setStartError(null)}>
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<div>
|
||||||
|
<strong className="font-semibold">Bootstrap Failed</strong>
|
||||||
|
<p className="text-sm mt-1">{startError}</p>
|
||||||
|
</div>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-2 text-sm">
|
||||||
|
<p className="font-medium">Before bootstrapping, ensure:</p>
|
||||||
|
<ul className="ml-4 list-disc space-y-1 text-muted-foreground">
|
||||||
|
<li>Node configuration has been applied successfully</li>
|
||||||
|
<li>Node is in maintenance mode and ready</li>
|
||||||
|
<li>This is the first control plane node</li>
|
||||||
|
<li>No other nodes have been bootstrapped</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={onClose} disabled={isStarting}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleStartBootstrap} disabled={isStarting}>
|
||||||
|
{isStarting ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||||
|
Starting...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Start Bootstrap'
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="py-4">
|
||||||
|
{operation && operation.details?.bootstrap ? (
|
||||||
|
<BootstrapProgress
|
||||||
|
progress={operation.details.bootstrap}
|
||||||
|
error={isFailed ? operation.error : undefined}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-center p-8">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||||
|
<span className="ml-3 text-muted-foreground">
|
||||||
|
Starting bootstrap...
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isComplete && (
|
||||||
|
<Alert variant="success">
|
||||||
|
<CheckCircle className="h-4 w-4" />
|
||||||
|
<div>
|
||||||
|
<strong className="font-semibold">Bootstrap Complete!</strong>
|
||||||
|
<p className="text-sm mt-1">
|
||||||
|
The cluster has been successfully initialized. Additional control
|
||||||
|
plane nodes can now join automatically.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isFailed && (
|
||||||
|
<Alert variant="error">
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<div>
|
||||||
|
<strong className="font-semibold">Bootstrap Failed</strong>
|
||||||
|
<p className="text-sm mt-1">
|
||||||
|
{operation.error || 'The bootstrap process encountered an error.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
{isComplete || isFailed ? (
|
||||||
|
<Button onClick={onClose}>Close</Button>
|
||||||
|
) : (
|
||||||
|
<Button variant="outline" disabled>
|
||||||
|
Bootstrap in progress...
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</DialogFooter>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
115
src/components/cluster/BootstrapProgress.tsx
Normal file
115
src/components/cluster/BootstrapProgress.tsx
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import { CheckCircle, AlertCircle, Loader2, Clock } from 'lucide-react';
|
||||||
|
import { Card } from '../ui/card';
|
||||||
|
import { Badge } from '../ui/badge';
|
||||||
|
import { TroubleshootingPanel } from './TroubleshootingPanel';
|
||||||
|
import type { BootstrapProgress as BootstrapProgressType } from '../../services/api/types';
|
||||||
|
|
||||||
|
interface BootstrapProgressProps {
|
||||||
|
progress: BootstrapProgressType;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BOOTSTRAP_STEPS = [
|
||||||
|
{ id: 0, name: 'Bootstrap Command', description: 'Running talosctl bootstrap' },
|
||||||
|
{ id: 1, name: 'etcd Health', description: 'Verifying etcd cluster health' },
|
||||||
|
{ id: 2, name: 'VIP Assignment', description: 'Waiting for VIP assignment' },
|
||||||
|
{ id: 3, name: 'Control Plane', description: 'Waiting for control plane components' },
|
||||||
|
{ id: 4, name: 'API Server', description: 'Waiting for API server on VIP' },
|
||||||
|
{ id: 5, name: 'Cluster Access', description: 'Configuring cluster access' },
|
||||||
|
{ id: 6, name: 'Node Registration', description: 'Verifying node registration' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function BootstrapProgress({ progress, error }: BootstrapProgressProps) {
|
||||||
|
const getStepIcon = (stepId: number) => {
|
||||||
|
if (stepId < progress.current_step) {
|
||||||
|
return <CheckCircle className="h-5 w-5 text-green-500" />;
|
||||||
|
}
|
||||||
|
if (stepId === progress.current_step) {
|
||||||
|
if (error) {
|
||||||
|
return <AlertCircle className="h-5 w-5 text-red-500" />;
|
||||||
|
}
|
||||||
|
return <Loader2 className="h-5 w-5 text-blue-500 animate-spin" />;
|
||||||
|
}
|
||||||
|
return <Clock className="h-5 w-5 text-gray-400" />;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStepStatus = (stepId: number) => {
|
||||||
|
if (stepId < progress.current_step) {
|
||||||
|
return 'completed';
|
||||||
|
}
|
||||||
|
if (stepId === progress.current_step) {
|
||||||
|
return error ? 'error' : 'running';
|
||||||
|
}
|
||||||
|
return 'pending';
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-3">
|
||||||
|
{BOOTSTRAP_STEPS.map((step) => {
|
||||||
|
const status = getStepStatus(step.id);
|
||||||
|
const isActive = step.id === progress.current_step;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
key={step.id}
|
||||||
|
className={`p-4 ${
|
||||||
|
isActive
|
||||||
|
? error
|
||||||
|
? 'border-red-300 bg-red-50 dark:bg-red-950/20'
|
||||||
|
: 'border-blue-300 bg-blue-50 dark:bg-blue-950/20'
|
||||||
|
: status === 'completed'
|
||||||
|
? 'border-green-200 bg-green-50 dark:bg-green-950/20'
|
||||||
|
: ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="mt-0.5">{getStepIcon(step.id)}</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<h4 className="font-medium text-sm">{step.name}</h4>
|
||||||
|
{status === 'completed' && (
|
||||||
|
<Badge variant="success" className="text-xs">
|
||||||
|
Complete
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{status === 'running' && !error && (
|
||||||
|
<Badge variant="default" className="text-xs">
|
||||||
|
In Progress
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{status === 'error' && (
|
||||||
|
<Badge variant="destructive" className="text-xs">
|
||||||
|
Failed
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">{step.description}</p>
|
||||||
|
{isActive && !error && (
|
||||||
|
<div className="mt-2 space-y-1">
|
||||||
|
<div className="flex justify-between text-xs text-muted-foreground">
|
||||||
|
<span>
|
||||||
|
Attempt {progress.attempt} of {progress.max_attempts}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-1.5">
|
||||||
|
<div
|
||||||
|
className="bg-blue-500 h-1.5 rounded-full transition-all duration-300"
|
||||||
|
style={{
|
||||||
|
width: `${(progress.attempt / progress.max_attempts) * 100}%`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <TroubleshootingPanel step={progress.current_step} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
61
src/components/cluster/TroubleshootingPanel.tsx
Normal file
61
src/components/cluster/TroubleshootingPanel.tsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { Alert } from '../ui/alert';
|
||||||
|
import { AlertCircle } from 'lucide-react';
|
||||||
|
|
||||||
|
interface TroubleshootingPanelProps {
|
||||||
|
step: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TROUBLESHOOTING_STEPS: Record<number, string[]> = {
|
||||||
|
1: [
|
||||||
|
'Check etcd service status with: talosctl -n <node-ip> service etcd',
|
||||||
|
'View etcd logs: talosctl -n <node-ip> logs etcd',
|
||||||
|
'Verify bootstrap completed successfully',
|
||||||
|
],
|
||||||
|
2: [
|
||||||
|
'Check VIP controller logs: kubectl logs -n kube-system -l k8s-app=kube-vip',
|
||||||
|
'Verify network configuration allows VIP assignment',
|
||||||
|
'Check that VIP range is configured correctly in cluster config',
|
||||||
|
],
|
||||||
|
3: [
|
||||||
|
'Check kubelet logs: talosctl -n <node-ip> logs kubelet',
|
||||||
|
'Verify static pod manifests: talosctl -n <node-ip> list /etc/kubernetes/manifests',
|
||||||
|
'Try restarting kubelet: talosctl -n <node-ip> service kubelet restart',
|
||||||
|
],
|
||||||
|
4: [
|
||||||
|
'Check API server logs: kubectl logs -n kube-system kube-apiserver-<node>',
|
||||||
|
'Verify API server is running: talosctl -n <node-ip> service kubelet',
|
||||||
|
'Test API server on node IP: curl -k https://<node-ip>:6443/healthz',
|
||||||
|
],
|
||||||
|
5: [
|
||||||
|
'Check API server logs for connection errors',
|
||||||
|
'Test API server on node IP first: curl -k https://<node-ip>:6443/healthz',
|
||||||
|
'Verify network connectivity to VIP address',
|
||||||
|
],
|
||||||
|
6: [
|
||||||
|
'Check kubelet logs: talosctl -n <node-ip> logs kubelet',
|
||||||
|
'Verify API server is accessible: kubectl get nodes',
|
||||||
|
'Check network connectivity between node and API server',
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export function TroubleshootingPanel({ step }: TroubleshootingPanelProps) {
|
||||||
|
const steps = TROUBLESHOOTING_STEPS[step] || [
|
||||||
|
'Check logs for detailed error information',
|
||||||
|
'Verify network connectivity',
|
||||||
|
'Ensure all prerequisites are met',
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Alert variant="error" className="mt-4">
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<div>
|
||||||
|
<strong className="font-semibold">Troubleshooting Steps</strong>
|
||||||
|
<ul className="mt-2 ml-4 list-disc space-y-1 text-sm">
|
||||||
|
{steps.map((troubleshootingStep, index) => (
|
||||||
|
<li key={index}>{troubleshootingStep}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
}
|
||||||
3
src/components/cluster/index.ts
Normal file
3
src/components/cluster/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export { BootstrapModal } from './BootstrapModal';
|
||||||
|
export { BootstrapProgress } from './BootstrapProgress';
|
||||||
|
export { TroubleshootingPanel } from './TroubleshootingPanel';
|
||||||
@@ -14,7 +14,6 @@ export { CentralComponent } from './CentralComponent';
|
|||||||
export { DnsComponent } from './DnsComponent';
|
export { DnsComponent } from './DnsComponent';
|
||||||
export { DhcpComponent } from './DhcpComponent';
|
export { DhcpComponent } from './DhcpComponent';
|
||||||
export { PxeComponent } from './PxeComponent';
|
export { PxeComponent } from './PxeComponent';
|
||||||
export { ClusterNodesComponent } from './ClusterNodesComponent';
|
|
||||||
export { ClusterServicesComponent } from './ClusterServicesComponent';
|
export { ClusterServicesComponent } from './ClusterServicesComponent';
|
||||||
export { AppsComponent } from './AppsComponent';
|
export { AppsComponent } from './AppsComponent';
|
||||||
export { SecretInput } from './SecretInput';
|
export { SecretInput } from './SecretInput';
|
||||||
|
|||||||
90
src/components/nodes/HardwareDetectionDisplay.tsx
Normal file
90
src/components/nodes/HardwareDetectionDisplay.tsx
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import type { HardwareInfo } from '../../services/api/types';
|
||||||
|
|
||||||
|
interface HardwareDetectionDisplayProps {
|
||||||
|
detection: HardwareInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBytes(bytes: number): string {
|
||||||
|
if (bytes === 0) return '0 B';
|
||||||
|
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
|
||||||
|
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HardwareDetectionDisplay({ detection }: HardwareDetectionDisplayProps) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 p-4 space-y-3">
|
||||||
|
<div className="flex items-start">
|
||||||
|
<svg
|
||||||
|
className="h-5 w-5 text-green-500 mt-0.5 mr-3 flex-shrink-0"
|
||||||
|
fill="currentColor"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
|
||||||
|
clipRule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">IP Address</p>
|
||||||
|
<p className="text-sm text-gray-900 dark:text-gray-100 font-mono">{detection.ip}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{detection.interface && (
|
||||||
|
<div className="flex items-start">
|
||||||
|
<svg
|
||||||
|
className="h-5 w-5 text-green-500 mt-0.5 mr-3 flex-shrink-0"
|
||||||
|
fill="currentColor"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
|
||||||
|
clipRule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">Network Interface</p>
|
||||||
|
<p className="text-sm text-gray-900 dark:text-gray-100 font-mono">{detection.interface}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{detection.disks && detection.disks.length > 0 && (
|
||||||
|
<div className="flex items-start">
|
||||||
|
<svg
|
||||||
|
className="h-5 w-5 text-green-500 mt-0.5 mr-3 flex-shrink-0"
|
||||||
|
fill="currentColor"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
|
||||||
|
clipRule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">Available Disks</p>
|
||||||
|
<ul className="mt-1 space-y-1">
|
||||||
|
{detection.disks.map((disk) => (
|
||||||
|
<li key={disk.path} className="text-sm text-gray-900 dark:text-gray-100">
|
||||||
|
<span className="font-mono">{disk.path}</span>
|
||||||
|
{disk.size > 0 && (
|
||||||
|
<span className="text-gray-500 dark:text-gray-400 ml-2">
|
||||||
|
({formatBytes(disk.size)})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
1349
src/components/nodes/NodeForm.test.tsx
Normal file
1349
src/components/nodes/NodeForm.test.tsx
Normal file
File diff suppressed because it is too large
Load Diff
615
src/components/nodes/NodeForm.tsx
Normal file
615
src/components/nodes/NodeForm.tsx
Normal file
@@ -0,0 +1,615 @@
|
|||||||
|
import { useForm, Controller } from 'react-hook-form';
|
||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import { useInstanceConfig } from '../../hooks/useInstances';
|
||||||
|
import { useNodes } from '../../hooks/useNodes';
|
||||||
|
import type { HardwareInfo } from '../../services/api/types';
|
||||||
|
import { Input, Label, Button } from '../ui';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '../ui/select';
|
||||||
|
|
||||||
|
export interface NodeFormData {
|
||||||
|
hostname: string;
|
||||||
|
role: 'controlplane' | 'worker';
|
||||||
|
disk: string;
|
||||||
|
targetIp: string;
|
||||||
|
interface?: string;
|
||||||
|
schematicId?: string;
|
||||||
|
maintenance: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NodeFormProps {
|
||||||
|
initialValues?: Partial<NodeFormData>;
|
||||||
|
detection?: HardwareInfo;
|
||||||
|
onSubmit: (data: NodeFormData) => Promise<void>;
|
||||||
|
onApply?: (data: NodeFormData) => Promise<void>;
|
||||||
|
onCancel?: () => void;
|
||||||
|
submitLabel?: string;
|
||||||
|
showApplyButton?: boolean;
|
||||||
|
instanceName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getInitialValues(
|
||||||
|
initial?: Partial<NodeFormData>,
|
||||||
|
detection?: HardwareInfo,
|
||||||
|
nodes?: Array<{ role: string; hostname?: string }>,
|
||||||
|
hostnamePrefix?: string
|
||||||
|
): NodeFormData {
|
||||||
|
// Determine default role: controlplane unless there are already 3+ control nodes
|
||||||
|
let defaultRole: 'controlplane' | 'worker' = 'controlplane';
|
||||||
|
if (nodes) {
|
||||||
|
const controlPlaneCount = nodes.filter(n => n.role === 'controlplane').length;
|
||||||
|
if (controlPlaneCount >= 3) {
|
||||||
|
defaultRole = 'worker';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const role = initial?.role || defaultRole;
|
||||||
|
|
||||||
|
// Generate default hostname based on role and existing nodes
|
||||||
|
let defaultHostname = '';
|
||||||
|
if (!initial?.hostname) {
|
||||||
|
const prefix = hostnamePrefix || '';
|
||||||
|
|
||||||
|
// Generate a hostname even if nodes is not loaded yet
|
||||||
|
// The useEffect will fix it later when data is available
|
||||||
|
if (role === 'controlplane') {
|
||||||
|
if (nodes) {
|
||||||
|
// Find next control plane number
|
||||||
|
const controlNumbers = nodes
|
||||||
|
.filter(n => n.role === 'controlplane')
|
||||||
|
.map(n => {
|
||||||
|
const match = n.hostname?.match(new RegExp(`${prefix}control-(\\d+)$`));
|
||||||
|
return match ? parseInt(match[1], 10) : null;
|
||||||
|
})
|
||||||
|
.filter((n): n is number => n !== null);
|
||||||
|
|
||||||
|
const nextNumber = controlNumbers.length > 0 ? Math.max(...controlNumbers) + 1 : 1;
|
||||||
|
defaultHostname = `${prefix}control-${nextNumber}`;
|
||||||
|
} else {
|
||||||
|
// No nodes loaded yet, default to 1
|
||||||
|
defaultHostname = `${prefix}control-1`;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (nodes) {
|
||||||
|
// Find next worker number
|
||||||
|
const workerNumbers = nodes
|
||||||
|
.filter(n => n.role === 'worker')
|
||||||
|
.map(n => {
|
||||||
|
const match = n.hostname?.match(new RegExp(`${prefix}worker-(\\d+)$`));
|
||||||
|
return match ? parseInt(match[1], 10) : null;
|
||||||
|
})
|
||||||
|
.filter((n): n is number => n !== null);
|
||||||
|
|
||||||
|
const nextNumber = workerNumbers.length > 0 ? Math.max(...workerNumbers) + 1 : 1;
|
||||||
|
defaultHostname = `${prefix}worker-${nextNumber}`;
|
||||||
|
} else {
|
||||||
|
// No nodes loaded yet, default to 1
|
||||||
|
defaultHostname = `${prefix}worker-1`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-select first disk if none specified
|
||||||
|
let defaultDisk = initial?.disk || detection?.selected_disk || '';
|
||||||
|
if (!defaultDisk && detection?.disks && detection.disks.length > 0) {
|
||||||
|
defaultDisk = detection.disks[0].path;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-select first interface if none specified
|
||||||
|
let defaultInterface = initial?.interface || detection?.interface || '';
|
||||||
|
if (!defaultInterface && detection?.interfaces && detection.interfaces.length > 0) {
|
||||||
|
defaultInterface = detection.interfaces[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
hostname: initial?.hostname || defaultHostname,
|
||||||
|
role,
|
||||||
|
disk: defaultDisk,
|
||||||
|
targetIp: initial?.targetIp || detection?.ip || '', // Auto-fill from detection
|
||||||
|
interface: defaultInterface,
|
||||||
|
schematicId: initial?.schematicId || '',
|
||||||
|
maintenance: initial?.maintenance ?? true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NodeForm({
|
||||||
|
initialValues,
|
||||||
|
detection,
|
||||||
|
onSubmit,
|
||||||
|
onApply,
|
||||||
|
onCancel,
|
||||||
|
submitLabel = 'Save',
|
||||||
|
showApplyButton = false,
|
||||||
|
instanceName,
|
||||||
|
}: NodeFormProps) {
|
||||||
|
// Track if we're editing an existing node (has initial hostname from backend)
|
||||||
|
const isExistingNode = Boolean(initialValues?.hostname);
|
||||||
|
const { config: instanceConfig } = useInstanceConfig(instanceName);
|
||||||
|
const { nodes } = useNodes(instanceName);
|
||||||
|
|
||||||
|
const hostnamePrefix = instanceConfig?.cluster?.hostnamePrefix || '';
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
setValue,
|
||||||
|
watch,
|
||||||
|
control,
|
||||||
|
reset,
|
||||||
|
formState: { errors, isSubmitting },
|
||||||
|
} = useForm<NodeFormData>({
|
||||||
|
defaultValues: getInitialValues(initialValues, detection, nodes, hostnamePrefix),
|
||||||
|
});
|
||||||
|
|
||||||
|
const schematicId = watch('schematicId');
|
||||||
|
const role = watch('role');
|
||||||
|
const hostname = watch('hostname');
|
||||||
|
|
||||||
|
// Reset form when switching between different nodes in configure mode
|
||||||
|
// This ensures select boxes and all fields show the current values
|
||||||
|
// 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;
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [initialValues, detection, nodes, hostnamePrefix]);
|
||||||
|
|
||||||
|
// Set default role based on existing control plane nodes
|
||||||
|
useEffect(() => {
|
||||||
|
if (!initialValues?.role && nodes) {
|
||||||
|
const controlPlaneCount = nodes.filter(n => n.role === 'controlplane').length;
|
||||||
|
const defaultRole: 'controlplane' | 'worker' = controlPlaneCount >= 3 ? 'worker' : 'controlplane';
|
||||||
|
const currentRole = watch('role');
|
||||||
|
|
||||||
|
// Only update if the current role is still the initial default and we now have node data
|
||||||
|
if (currentRole === 'controlplane' && controlPlaneCount >= 3) {
|
||||||
|
setValue('role', defaultRole);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [instanceConfig, schematicId]);
|
||||||
|
|
||||||
|
// Auto-generate hostname when role changes (only for NEW nodes without initial hostname)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!nodes) return;
|
||||||
|
|
||||||
|
// Don't auto-generate if this is an existing node with initial hostname
|
||||||
|
// This check must happen FIRST to prevent regeneration when hostnamePrefix loads
|
||||||
|
if (isExistingNode) return;
|
||||||
|
|
||||||
|
const prefix = hostnamePrefix || '';
|
||||||
|
const currentHostname = watch('hostname');
|
||||||
|
|
||||||
|
if (!currentHostname) return;
|
||||||
|
|
||||||
|
// Check if current hostname follows our naming pattern WITH prefix
|
||||||
|
const hostnameMatch = currentHostname.match(new RegExp(`^${prefix}(control|worker)-(\\d+)$`));
|
||||||
|
|
||||||
|
// If no match with prefix, check if it matches WITHOUT prefix (generated before prefix was loaded)
|
||||||
|
const hostnameMatchNoPrefix = !hostnameMatch && prefix ?
|
||||||
|
currentHostname.match(/^(control|worker)-(\d+)$/) : null;
|
||||||
|
|
||||||
|
// Check if this is a generated hostname (either with or without prefix)
|
||||||
|
const isGeneratedHostname = hostnameMatch !== null || hostnameMatchNoPrefix !== null;
|
||||||
|
|
||||||
|
// Use whichever match succeeded
|
||||||
|
const activeMatch = hostnameMatch || hostnameMatchNoPrefix;
|
||||||
|
|
||||||
|
// Check if the role prefix in the hostname matches the current role
|
||||||
|
const hostnameRolePrefix = activeMatch?.[1]; // 'control' or 'worker'
|
||||||
|
const expectedRolePrefix = role === 'controlplane' ? 'control' : 'worker';
|
||||||
|
const roleMatches = hostnameRolePrefix === expectedRolePrefix;
|
||||||
|
|
||||||
|
// Check if the hostname has the expected prefix
|
||||||
|
const hasCorrectPrefix = hostnameMatch !== null;
|
||||||
|
|
||||||
|
// Auto-update hostname if it was previously auto-generated AND either:
|
||||||
|
// 1. The role prefix doesn't match (e.g., hostname is "control-1" but role is "worker")
|
||||||
|
// 2. The hostname is missing the prefix (e.g., "control-1" instead of "test-control-1")
|
||||||
|
// 3. The number needs updating (existing logic)
|
||||||
|
if (isGeneratedHostname && (!roleMatches || !hasCorrectPrefix)) {
|
||||||
|
// Role changed, need to regenerate with correct prefix
|
||||||
|
if (role === 'controlplane') {
|
||||||
|
const controlNumbers = nodes
|
||||||
|
.filter(n => n.role === 'controlplane')
|
||||||
|
.map(n => {
|
||||||
|
const match = n.hostname?.match(new RegExp(`${prefix}control-(\\d+)$`));
|
||||||
|
return match ? parseInt(match[1], 10) : null;
|
||||||
|
})
|
||||||
|
.filter((n): n is number => n !== null);
|
||||||
|
|
||||||
|
const nextNumber = controlNumbers.length > 0 ? Math.max(...controlNumbers) + 1 : 1;
|
||||||
|
const newHostname = `${prefix}control-${nextNumber}`;
|
||||||
|
setValue('hostname', newHostname);
|
||||||
|
} else {
|
||||||
|
const workerNumbers = nodes
|
||||||
|
.filter(n => n.role === 'worker')
|
||||||
|
.map(n => {
|
||||||
|
const match = n.hostname?.match(new RegExp(`${prefix}worker-(\\d+)$`));
|
||||||
|
return match ? parseInt(match[1], 10) : null;
|
||||||
|
})
|
||||||
|
.filter((n): n is number => n !== null);
|
||||||
|
|
||||||
|
const nextNumber = workerNumbers.length > 0 ? Math.max(...workerNumbers) + 1 : 1;
|
||||||
|
const newHostname = `${prefix}worker-${nextNumber}`;
|
||||||
|
setValue('hostname', newHostname);
|
||||||
|
}
|
||||||
|
} else if (isGeneratedHostname && roleMatches && hasCorrectPrefix) {
|
||||||
|
// Role matches and prefix is correct, but check if the number needs updating (original logic)
|
||||||
|
if (role === 'controlplane') {
|
||||||
|
const controlNumbers = nodes
|
||||||
|
.filter(n => n.role === 'controlplane')
|
||||||
|
.map(n => {
|
||||||
|
const match = n.hostname?.match(new RegExp(`${prefix}control-(\\d+)$`));
|
||||||
|
return match ? parseInt(match[1], 10) : null;
|
||||||
|
})
|
||||||
|
.filter((n): n is number => n !== null);
|
||||||
|
|
||||||
|
const nextNumber = controlNumbers.length > 0 ? Math.max(...controlNumbers) + 1 : 1;
|
||||||
|
const newHostname = `${prefix}control-${nextNumber}`;
|
||||||
|
if (currentHostname !== newHostname) {
|
||||||
|
setValue('hostname', newHostname);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const workerNumbers = nodes
|
||||||
|
.filter(n => n.role === 'worker')
|
||||||
|
.map(n => {
|
||||||
|
const match = n.hostname?.match(new RegExp(`${prefix}worker-(\\d+)$`));
|
||||||
|
return match ? parseInt(match[1], 10) : null;
|
||||||
|
})
|
||||||
|
.filter((n): n is number => n !== null);
|
||||||
|
|
||||||
|
const nextNumber = workerNumbers.length > 0 ? Math.max(...workerNumbers) + 1 : 1;
|
||||||
|
const newHostname = `${prefix}worker-${nextNumber}`;
|
||||||
|
if (currentHostname !== newHostname) {
|
||||||
|
setValue('hostname', newHostname);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 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;
|
||||||
|
|
||||||
|
if (role === 'controlplane' && vip) {
|
||||||
|
|
||||||
|
// Parse VIP to get base and last octet
|
||||||
|
const vipParts = vip.split('.');
|
||||||
|
if (vipParts.length !== 4) return;
|
||||||
|
|
||||||
|
const vipLastOctet = parseInt(vipParts[3], 10);
|
||||||
|
if (isNaN(vipLastOctet)) return;
|
||||||
|
|
||||||
|
const vipPrefix = vipParts.slice(0, 3).join('.');
|
||||||
|
|
||||||
|
// Find all control plane IPs in the same subnet range
|
||||||
|
const usedOctets = nodes
|
||||||
|
.filter(node => node.role === 'controlplane' && node.target_ip)
|
||||||
|
.map(node => {
|
||||||
|
const parts = node.target_ip.split('.');
|
||||||
|
if (parts.length !== 4) return null;
|
||||||
|
// Only consider IPs in the same subnet
|
||||||
|
if (parts.slice(0, 3).join('.') !== vipPrefix) return null;
|
||||||
|
const octet = parseInt(parts[3], 10);
|
||||||
|
return isNaN(octet) ? null : octet;
|
||||||
|
})
|
||||||
|
.filter((octet): octet is number => octet !== null && octet > vipLastOctet);
|
||||||
|
|
||||||
|
// Find the first available IP after VIP
|
||||||
|
let nextOctet = vipLastOctet + 1;
|
||||||
|
|
||||||
|
// Sort used octets to find gaps
|
||||||
|
const sortedOctets = [...usedOctets].sort((a, b) => a - b);
|
||||||
|
|
||||||
|
// Check for gaps in the sequence starting from VIP+1
|
||||||
|
for (const usedOctet of sortedOctets) {
|
||||||
|
if (usedOctet === nextOctet) {
|
||||||
|
nextOctet++;
|
||||||
|
} else if (usedOctet > nextOctet) {
|
||||||
|
// Found a gap, use it
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure we don't exceed valid IP range
|
||||||
|
if (nextOctet > 254) {
|
||||||
|
console.warn('No available IPs in subnet after VIP');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set the calculated IP
|
||||||
|
setValue('targetIp', `${vipPrefix}.${nextOctet}`);
|
||||||
|
} 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) {
|
||||||
|
const vipPrefix = vip.split('.').slice(0, 3).join('.');
|
||||||
|
if (currentTargetIp.startsWith(vipPrefix)) {
|
||||||
|
setValue('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 = (() => {
|
||||||
|
const options = [...(detection?.disks || [])];
|
||||||
|
// If configuring existing node, ensure its disk is in options
|
||||||
|
if (initialValues?.disk && !options.some(d => d.path === initialValues.disk)) {
|
||||||
|
options.push({ path: initialValues.disk, size: 0 });
|
||||||
|
}
|
||||||
|
return options;
|
||||||
|
})();
|
||||||
|
|
||||||
|
// Build interface options from both detection and initial values
|
||||||
|
const interfaceOptions = (() => {
|
||||||
|
const options = [...(detection?.interfaces || [])];
|
||||||
|
// If configuring existing node, ensure its interface is in options
|
||||||
|
if (initialValues?.interface && !options.includes(initialValues.interface)) {
|
||||||
|
options.push(initialValues.interface);
|
||||||
|
}
|
||||||
|
// Also add detection.interface if present
|
||||||
|
if (detection?.interface && !options.includes(detection.interface)) {
|
||||||
|
options.push(detection.interface);
|
||||||
|
}
|
||||||
|
return options;
|
||||||
|
})();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="role">Role</Label>
|
||||||
|
<Controller
|
||||||
|
name="role"
|
||||||
|
control={control}
|
||||||
|
rules={{ required: 'Role is required' }}
|
||||||
|
render={({ field }) => (
|
||||||
|
<Select value={field.value || ''} onValueChange={field.onChange}>
|
||||||
|
<SelectTrigger className="mt-1">
|
||||||
|
<SelectValue placeholder="Select role" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="controlplane">Control Plane</SelectItem>
|
||||||
|
<SelectItem value="worker">Worker</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{errors.role && <p className="text-sm text-red-600 mt-1">{errors.role.message}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="hostname">Hostname</Label>
|
||||||
|
<Input
|
||||||
|
id="hostname"
|
||||||
|
type="text"
|
||||||
|
{...register('hostname', {
|
||||||
|
required: 'Hostname is required',
|
||||||
|
pattern: {
|
||||||
|
value: /^[a-z0-9-]+$/,
|
||||||
|
message: 'Hostname must contain only lowercase letters, numbers, and hyphens',
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
{errors.hostname && (
|
||||||
|
<p className="text-sm text-red-600 mt-1">{errors.hostname.message}</p>
|
||||||
|
)}
|
||||||
|
{hostname && hostname.match(/^.*?(control|worker)-\d+$/) && (
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
Auto-generated based on role and existing nodes
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="disk">Disk</Label>
|
||||||
|
{diskOptions.length > 0 ? (
|
||||||
|
<Controller
|
||||||
|
name="disk"
|
||||||
|
control={control}
|
||||||
|
rules={{ required: 'Disk is required' }}
|
||||||
|
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
|
||||||
|
name="disk"
|
||||||
|
control={control}
|
||||||
|
rules={{ required: 'Disk is required' }}
|
||||||
|
render={({ field }) => (
|
||||||
|
<Input
|
||||||
|
id="disk"
|
||||||
|
type="text"
|
||||||
|
value={field.value || ''}
|
||||||
|
onChange={field.onChange}
|
||||||
|
className="mt-1"
|
||||||
|
placeholder="/dev/sda"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{errors.disk && <p className="text-sm text-red-600 mt-1">{errors.disk.message}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="targetIp">Target IP Address</Label>
|
||||||
|
<Input
|
||||||
|
id="targetIp"
|
||||||
|
type="text"
|
||||||
|
{...register('targetIp')}
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
{errors.targetIp && (
|
||||||
|
<p className="text-sm text-red-600 mt-1">{errors.targetIp.message}</p>
|
||||||
|
)}
|
||||||
|
{role === 'controlplane' && (instanceConfig?.cluster as any)?.nodes?.control?.vip && (
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
Auto-calculated from VIP ({(instanceConfig?.cluster as any)?.nodes?.control?.vip})
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="interface">Network Interface</Label>
|
||||||
|
{interfaceOptions.length > 0 ? (
|
||||||
|
<Controller
|
||||||
|
name="interface"
|
||||||
|
control={control}
|
||||||
|
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
|
||||||
|
name="interface"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<Input
|
||||||
|
id="interface"
|
||||||
|
type="text"
|
||||||
|
value={field.value || ''}
|
||||||
|
onChange={field.onChange}
|
||||||
|
className="mt-1"
|
||||||
|
placeholder="eth0"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="schematicId">Schematic ID (Optional)</Label>
|
||||||
|
<Input
|
||||||
|
id="schematicId"
|
||||||
|
type="text"
|
||||||
|
{...register('schematicId')}
|
||||||
|
className="mt-1 font-mono text-xs"
|
||||||
|
placeholder="abc123def456..."
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
Leave blank to use default Talos configuration
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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
|
||||||
|
type="submit"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
{isSubmitting ? 'Saving...' : submitLabel}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBytes(bytes: number): string {
|
||||||
|
if (bytes === 0) return '0 B';
|
||||||
|
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
|
||||||
|
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
|
||||||
|
}
|
||||||
392
src/components/nodes/NodeForm.unit.test.tsx
Normal file
392
src/components/nodes/NodeForm.unit.test.tsx
Normal file
@@ -0,0 +1,392 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import type { NodeFormData } from './NodeForm';
|
||||||
|
import { createMockNode, createMockNodes, createMockHardwareInfo } from '../../test/utils/nodeFormTestUtils';
|
||||||
|
import type { HardwareInfo } from '../../services/api/types';
|
||||||
|
|
||||||
|
function getInitialValues(
|
||||||
|
initial?: Partial<NodeFormData>,
|
||||||
|
detection?: HardwareInfo,
|
||||||
|
nodes?: Array<{ role: string; hostname?: string }>,
|
||||||
|
hostnamePrefix?: string
|
||||||
|
): NodeFormData {
|
||||||
|
let defaultRole: 'controlplane' | 'worker' = 'controlplane';
|
||||||
|
if (nodes) {
|
||||||
|
const controlPlaneCount = nodes.filter(n => n.role === 'controlplane').length;
|
||||||
|
if (controlPlaneCount >= 3) {
|
||||||
|
defaultRole = 'worker';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const role = initial?.role || defaultRole;
|
||||||
|
|
||||||
|
let defaultHostname = '';
|
||||||
|
if (!initial?.hostname && nodes && hostnamePrefix !== undefined) {
|
||||||
|
const prefix = hostnamePrefix || '';
|
||||||
|
if (role === 'controlplane') {
|
||||||
|
const controlNumbers = nodes
|
||||||
|
.filter(n => n.role === 'controlplane')
|
||||||
|
.map(n => {
|
||||||
|
const match = n.hostname?.match(new RegExp(`${prefix}control-(\\d+)$`));
|
||||||
|
return match ? parseInt(match[1], 10) : null;
|
||||||
|
})
|
||||||
|
.filter((n): n is number => n !== null);
|
||||||
|
|
||||||
|
const nextNumber = controlNumbers.length > 0 ? Math.max(...controlNumbers) + 1 : 1;
|
||||||
|
defaultHostname = `${prefix}control-${nextNumber}`;
|
||||||
|
} else {
|
||||||
|
const workerNumbers = nodes
|
||||||
|
.filter(n => n.role === 'worker')
|
||||||
|
.map(n => {
|
||||||
|
const match = n.hostname?.match(new RegExp(`${prefix}worker-(\\d+)$`));
|
||||||
|
return match ? parseInt(match[1], 10) : null;
|
||||||
|
})
|
||||||
|
.filter((n): n is number => n !== null);
|
||||||
|
|
||||||
|
const nextNumber = workerNumbers.length > 0 ? Math.max(...workerNumbers) + 1 : 1;
|
||||||
|
defaultHostname = `${prefix}worker-${nextNumber}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let defaultDisk = initial?.disk || detection?.selected_disk || '';
|
||||||
|
if (!defaultDisk && detection?.disks && detection.disks.length > 0) {
|
||||||
|
defaultDisk = detection.disks[0].path;
|
||||||
|
}
|
||||||
|
|
||||||
|
let defaultInterface = initial?.interface || detection?.interface || '';
|
||||||
|
if (!defaultInterface && detection?.interfaces && detection.interfaces.length > 0) {
|
||||||
|
defaultInterface = detection.interfaces[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
hostname: initial?.hostname || defaultHostname,
|
||||||
|
role,
|
||||||
|
disk: defaultDisk,
|
||||||
|
targetIp: initial?.targetIp || '',
|
||||||
|
currentIp: initial?.currentIp || detection?.ip || '',
|
||||||
|
interface: defaultInterface,
|
||||||
|
schematicId: initial?.schematicId || '',
|
||||||
|
maintenance: initial?.maintenance ?? true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('getInitialValues', () => {
|
||||||
|
describe('Role Selection', () => {
|
||||||
|
it('defaults to controlplane when no nodes exist', () => {
|
||||||
|
const result = getInitialValues(undefined, undefined, [], 'test-');
|
||||||
|
expect(result.role).toBe('controlplane');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults to controlplane when fewer than 3 control nodes exist', () => {
|
||||||
|
const nodes = createMockNodes(2, 'controlplane');
|
||||||
|
const result = getInitialValues(undefined, undefined, nodes, 'test-');
|
||||||
|
expect(result.role).toBe('controlplane');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults to worker when 3 or more control nodes exist', () => {
|
||||||
|
const nodes = createMockNodes(3, 'controlplane');
|
||||||
|
const result = getInitialValues(undefined, undefined, nodes, 'test-');
|
||||||
|
expect(result.role).toBe('worker');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('respects explicit role in initial values', () => {
|
||||||
|
const nodes = createMockNodes(3, 'controlplane');
|
||||||
|
const result = getInitialValues({ role: 'controlplane' }, undefined, nodes, 'test-');
|
||||||
|
expect(result.role).toBe('controlplane');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Hostname Generation', () => {
|
||||||
|
it('generates first control node hostname', () => {
|
||||||
|
const result = getInitialValues(undefined, undefined, [], 'test-');
|
||||||
|
expect(result.hostname).toBe('test-control-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('generates second control node hostname', () => {
|
||||||
|
const nodes = [createMockNode({ hostname: 'test-control-1', role: 'controlplane' })];
|
||||||
|
const result = getInitialValues(undefined, undefined, nodes, 'test-');
|
||||||
|
expect(result.hostname).toBe('test-control-2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('generates third control node hostname', () => {
|
||||||
|
const nodes = [
|
||||||
|
createMockNode({ hostname: 'test-control-1', role: 'controlplane' }),
|
||||||
|
createMockNode({ hostname: 'test-control-2', role: 'controlplane' }),
|
||||||
|
];
|
||||||
|
const result = getInitialValues(undefined, undefined, nodes, 'test-');
|
||||||
|
expect(result.hostname).toBe('test-control-3');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('generates first worker node hostname', () => {
|
||||||
|
const nodes = createMockNodes(3, 'controlplane');
|
||||||
|
const result = getInitialValues({ role: 'worker' }, undefined, nodes, 'test-');
|
||||||
|
expect(result.hostname).toBe('test-worker-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('generates second worker node hostname', () => {
|
||||||
|
const nodes = [
|
||||||
|
...createMockNodes(3, 'controlplane'),
|
||||||
|
createMockNode({ hostname: 'test-worker-1', role: 'worker' }),
|
||||||
|
];
|
||||||
|
const result = getInitialValues({ role: 'worker' }, undefined, nodes, 'test-');
|
||||||
|
expect(result.hostname).toBe('test-worker-2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles empty hostname prefix', () => {
|
||||||
|
const result = getInitialValues(undefined, undefined, [], '');
|
||||||
|
expect(result.hostname).toBe('control-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles gaps in hostname numbering for control nodes', () => {
|
||||||
|
const nodes = [
|
||||||
|
createMockNode({ hostname: 'test-control-1', role: 'controlplane' }),
|
||||||
|
createMockNode({ hostname: 'test-control-3', role: 'controlplane' }),
|
||||||
|
];
|
||||||
|
const result = getInitialValues(undefined, undefined, nodes, 'test-');
|
||||||
|
expect(result.hostname).toBe('test-control-4');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles gaps in hostname numbering for worker nodes', () => {
|
||||||
|
const nodes = [
|
||||||
|
...createMockNodes(3, 'controlplane'),
|
||||||
|
createMockNode({ hostname: 'test-worker-1', role: 'worker' }),
|
||||||
|
createMockNode({ hostname: 'test-worker-5', role: 'worker' }),
|
||||||
|
];
|
||||||
|
const result = getInitialValues({ role: 'worker' }, undefined, nodes, 'test-');
|
||||||
|
expect(result.hostname).toBe('test-worker-6');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves initial hostname when provided', () => {
|
||||||
|
const result = getInitialValues(
|
||||||
|
{ hostname: 'custom-hostname' },
|
||||||
|
undefined,
|
||||||
|
[],
|
||||||
|
'test-'
|
||||||
|
);
|
||||||
|
expect(result.hostname).toBe('custom-hostname');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not generate hostname when hostnamePrefix is undefined', () => {
|
||||||
|
const result = getInitialValues(undefined, undefined, [], undefined);
|
||||||
|
expect(result.hostname).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not generate hostname when initial hostname is provided', () => {
|
||||||
|
const result = getInitialValues(
|
||||||
|
{ hostname: 'existing-node' },
|
||||||
|
undefined,
|
||||||
|
[],
|
||||||
|
'test-'
|
||||||
|
);
|
||||||
|
expect(result.hostname).toBe('existing-node');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Disk Selection', () => {
|
||||||
|
it('uses disk from initial values', () => {
|
||||||
|
const result = getInitialValues(
|
||||||
|
{ disk: '/dev/nvme0n1' },
|
||||||
|
createMockHardwareInfo(),
|
||||||
|
[],
|
||||||
|
'test-'
|
||||||
|
);
|
||||||
|
expect(result.disk).toBe('/dev/nvme0n1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses selected_disk from detection', () => {
|
||||||
|
const detection = createMockHardwareInfo({ selected_disk: '/dev/sdb' });
|
||||||
|
const result = getInitialValues(undefined, detection, [], 'test-');
|
||||||
|
expect(result.disk).toBe('/dev/sdb');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('auto-selects first disk from detection when no selected_disk', () => {
|
||||||
|
const detection = createMockHardwareInfo({ selected_disk: undefined });
|
||||||
|
const result = getInitialValues(undefined, detection, [], 'test-');
|
||||||
|
expect(result.disk).toBe('/dev/sda');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty string when no disk info available', () => {
|
||||||
|
const result = getInitialValues(undefined, undefined, [], 'test-');
|
||||||
|
expect(result.disk).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty string when detection has no disks', () => {
|
||||||
|
const detection = createMockHardwareInfo({ disks: [], selected_disk: undefined });
|
||||||
|
const result = getInitialValues(undefined, detection, [], 'test-');
|
||||||
|
expect(result.disk).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Interface Selection', () => {
|
||||||
|
it('uses interface from initial values', () => {
|
||||||
|
const result = getInitialValues(
|
||||||
|
{ interface: 'eth2' },
|
||||||
|
createMockHardwareInfo(),
|
||||||
|
[],
|
||||||
|
'test-'
|
||||||
|
);
|
||||||
|
expect(result.interface).toBe('eth2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses interface from detection', () => {
|
||||||
|
const detection = createMockHardwareInfo({ interface: 'eth1' });
|
||||||
|
const result = getInitialValues(undefined, detection, [], 'test-');
|
||||||
|
expect(result.interface).toBe('eth1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('auto-selects first interface from detection when no interface set', () => {
|
||||||
|
const detection = createMockHardwareInfo({ interface: undefined });
|
||||||
|
const result = getInitialValues(undefined, detection, [], 'test-');
|
||||||
|
expect(result.interface).toBe('eth0');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty string when no interface info available', () => {
|
||||||
|
const result = getInitialValues(undefined, undefined, [], 'test-');
|
||||||
|
expect(result.interface).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty string when detection has no interfaces', () => {
|
||||||
|
const detection = createMockHardwareInfo({ interface: undefined, interfaces: [] });
|
||||||
|
const result = getInitialValues(undefined, detection, [], 'test-');
|
||||||
|
expect(result.interface).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('IP Address Handling', () => {
|
||||||
|
it('does not auto-fill targetIp', () => {
|
||||||
|
const result = getInitialValues(undefined, createMockHardwareInfo(), [], 'test-');
|
||||||
|
expect(result.targetIp).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves initial targetIp', () => {
|
||||||
|
const result = getInitialValues(
|
||||||
|
{ targetIp: '192.168.1.200' },
|
||||||
|
createMockHardwareInfo(),
|
||||||
|
[],
|
||||||
|
'test-'
|
||||||
|
);
|
||||||
|
expect(result.targetIp).toBe('192.168.1.200');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('auto-fills currentIp from detection', () => {
|
||||||
|
const detection = createMockHardwareInfo({ ip: '192.168.1.75' });
|
||||||
|
const result = getInitialValues(undefined, detection, [], 'test-');
|
||||||
|
expect(result.currentIp).toBe('192.168.1.75');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves initial currentIp over detection', () => {
|
||||||
|
const detection = createMockHardwareInfo({ ip: '192.168.1.75' });
|
||||||
|
const result = getInitialValues({ currentIp: '192.168.1.80' }, detection, [], 'test-');
|
||||||
|
expect(result.currentIp).toBe('192.168.1.80');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty currentIp when no detection', () => {
|
||||||
|
const result = getInitialValues(undefined, undefined, [], 'test-');
|
||||||
|
expect(result.currentIp).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('SchematicId Handling', () => {
|
||||||
|
it('uses initial schematicId when provided', () => {
|
||||||
|
const result = getInitialValues({ schematicId: 'custom-123' }, undefined, [], 'test-');
|
||||||
|
expect(result.schematicId).toBe('custom-123');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty string when no initial schematicId', () => {
|
||||||
|
const result = getInitialValues(undefined, undefined, [], 'test-');
|
||||||
|
expect(result.schematicId).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Maintenance Mode', () => {
|
||||||
|
it('defaults to true when not provided', () => {
|
||||||
|
const result = getInitialValues(undefined, undefined, [], 'test-');
|
||||||
|
expect(result.maintenance).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('respects explicit true value', () => {
|
||||||
|
const result = getInitialValues({ maintenance: true }, undefined, [], 'test-');
|
||||||
|
expect(result.maintenance).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('respects explicit false value', () => {
|
||||||
|
const result = getInitialValues({ maintenance: false }, undefined, [], 'test-');
|
||||||
|
expect(result.maintenance).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Combined Scenarios', () => {
|
||||||
|
it('handles adding first control node with full detection', () => {
|
||||||
|
const detection = createMockHardwareInfo();
|
||||||
|
const result = getInitialValues(undefined, detection, [], 'prod-');
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
hostname: 'prod-control-1',
|
||||||
|
role: 'controlplane',
|
||||||
|
disk: '/dev/sda',
|
||||||
|
targetIp: '',
|
||||||
|
currentIp: '192.168.1.50',
|
||||||
|
interface: 'eth0',
|
||||||
|
schematicId: '',
|
||||||
|
maintenance: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles configuring existing node (all initial values)', () => {
|
||||||
|
const initial: Partial<NodeFormData> = {
|
||||||
|
hostname: 'existing-control-1',
|
||||||
|
role: 'controlplane',
|
||||||
|
disk: '/dev/nvme0n1',
|
||||||
|
targetIp: '192.168.1.105',
|
||||||
|
currentIp: '192.168.1.60',
|
||||||
|
interface: 'eth1',
|
||||||
|
schematicId: 'existing-schematic-456',
|
||||||
|
maintenance: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = getInitialValues(initial, undefined, [], 'test-');
|
||||||
|
|
||||||
|
expect(result).toEqual(initial);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles adding second control node with partial detection', () => {
|
||||||
|
const nodes = [createMockNode({ hostname: 'test-control-1', role: 'controlplane' })];
|
||||||
|
const detection = createMockHardwareInfo({
|
||||||
|
interfaces: ['enp0s1'],
|
||||||
|
interface: 'enp0s1'
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = getInitialValues(undefined, detection, nodes, 'test-');
|
||||||
|
|
||||||
|
expect(result.hostname).toBe('test-control-2');
|
||||||
|
expect(result.role).toBe('controlplane');
|
||||||
|
expect(result.interface).toBe('enp0s1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles missing detection data', () => {
|
||||||
|
const result = getInitialValues(undefined, undefined, [], 'test-');
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
hostname: 'test-control-1',
|
||||||
|
role: 'controlplane',
|
||||||
|
disk: '',
|
||||||
|
targetIp: '',
|
||||||
|
currentIp: '',
|
||||||
|
interface: '',
|
||||||
|
schematicId: '',
|
||||||
|
maintenance: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles partial detection data', () => {
|
||||||
|
const detection: HardwareInfo = {
|
||||||
|
ip: '192.168.1.50',
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = getInitialValues(undefined, detection, [], 'test-');
|
||||||
|
|
||||||
|
expect(result.currentIp).toBe('192.168.1.50');
|
||||||
|
expect(result.disk).toBe('');
|
||||||
|
expect(result.interface).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
67
src/components/nodes/NodeFormDrawer.tsx
Normal file
67
src/components/nodes/NodeFormDrawer.tsx
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import { Drawer } from '../ui/drawer';
|
||||||
|
import { HardwareDetectionDisplay } from './HardwareDetectionDisplay';
|
||||||
|
import { NodeForm, type NodeFormData } from './NodeForm';
|
||||||
|
import { NodeStatusBadge } from './NodeStatusBadge';
|
||||||
|
import type { Node, HardwareInfo } from '../../services/api/types';
|
||||||
|
|
||||||
|
interface NodeFormDrawerProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
mode: 'add' | 'configure';
|
||||||
|
node?: Node;
|
||||||
|
detection?: HardwareInfo;
|
||||||
|
onSubmit: (data: NodeFormData) => Promise<void>;
|
||||||
|
onApply?: (data: NodeFormData) => Promise<void>;
|
||||||
|
instanceName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NodeFormDrawer({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
mode,
|
||||||
|
node,
|
||||||
|
detection,
|
||||||
|
onSubmit,
|
||||||
|
onApply,
|
||||||
|
instanceName,
|
||||||
|
}: NodeFormDrawerProps) {
|
||||||
|
const title = mode === 'add' ? 'Add Node to Cluster' : `Configure ${node?.hostname}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Drawer open={open} onClose={onClose} title={title}>
|
||||||
|
{detection && (
|
||||||
|
<div className="mb-6">
|
||||||
|
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
|
||||||
|
Hardware Detection Results
|
||||||
|
</h3>
|
||||||
|
<HardwareDetectionDisplay detection={detection} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{mode === 'configure' && node && (
|
||||||
|
<div className="mb-6">
|
||||||
|
<NodeStatusBadge node={node} showAction />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<NodeForm
|
||||||
|
initialValues={node ? {
|
||||||
|
hostname: node.hostname,
|
||||||
|
role: node.role,
|
||||||
|
disk: node.disk,
|
||||||
|
targetIp: node.target_ip,
|
||||||
|
interface: node.interface,
|
||||||
|
schematicId: node.schematic_id,
|
||||||
|
maintenance: node.maintenance ?? true,
|
||||||
|
} : undefined}
|
||||||
|
detection={detection}
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
onApply={onApply}
|
||||||
|
onCancel={onClose}
|
||||||
|
submitLabel={mode === 'add' ? 'Add Node' : 'Save'}
|
||||||
|
showApplyButton={mode === 'configure'}
|
||||||
|
instanceName={instanceName}
|
||||||
|
/>
|
||||||
|
</Drawer>
|
||||||
|
);
|
||||||
|
}
|
||||||
66
src/components/nodes/NodeStatusBadge.tsx
Normal file
66
src/components/nodes/NodeStatusBadge.tsx
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import {
|
||||||
|
MagnifyingGlassIcon,
|
||||||
|
ClockIcon,
|
||||||
|
ArrowPathIcon,
|
||||||
|
DocumentCheckIcon,
|
||||||
|
CheckCircleIcon,
|
||||||
|
HeartIcon,
|
||||||
|
WrenchScrewdriverIcon,
|
||||||
|
ExclamationTriangleIcon,
|
||||||
|
XCircleIcon,
|
||||||
|
QuestionMarkCircleIcon
|
||||||
|
} from '@heroicons/react/24/outline';
|
||||||
|
import type { Node } from '../../services/api/types';
|
||||||
|
import { deriveNodeStatus } from '../../utils/deriveNodeStatus';
|
||||||
|
import { statusDesigns } from '../../config/nodeStatus';
|
||||||
|
|
||||||
|
interface NodeStatusBadgeProps {
|
||||||
|
node: Node;
|
||||||
|
showAction?: boolean;
|
||||||
|
compact?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const iconComponents = {
|
||||||
|
MagnifyingGlassIcon,
|
||||||
|
ClockIcon,
|
||||||
|
ArrowPathIcon,
|
||||||
|
DocumentCheckIcon,
|
||||||
|
CheckCircleIcon,
|
||||||
|
HeartIcon,
|
||||||
|
WrenchScrewdriverIcon,
|
||||||
|
ExclamationTriangleIcon,
|
||||||
|
XCircleIcon,
|
||||||
|
QuestionMarkCircleIcon
|
||||||
|
};
|
||||||
|
|
||||||
|
export function NodeStatusBadge({ node, showAction = false, compact = false }: NodeStatusBadgeProps) {
|
||||||
|
const status = deriveNodeStatus(node);
|
||||||
|
const design = statusDesigns[status];
|
||||||
|
const IconComponent = iconComponents[design.icon as keyof typeof iconComponents];
|
||||||
|
|
||||||
|
const isSpinning = ['configuring', 'applying', 'provisioning', 'reprovisioning'].includes(status);
|
||||||
|
|
||||||
|
if (compact) {
|
||||||
|
return (
|
||||||
|
<span className={`inline-flex items-center gap-1.5 px-2 py-1 rounded-md text-xs font-medium ${design.color} ${design.bgColor}`}>
|
||||||
|
<IconComponent className={`h-3.5 w-3.5 ${isSpinning ? 'animate-spin' : ''}`} />
|
||||||
|
<span>{design.label}</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`inline-flex flex-col gap-1 px-3 py-2 rounded-lg ${design.color} ${design.bgColor}`}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<IconComponent className={`h-5 w-5 ${isSpinning ? 'animate-spin' : ''}`} />
|
||||||
|
<span className="text-sm font-semibold">{design.label}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs opacity-90">{design.description}</p>
|
||||||
|
{showAction && design.nextAction && (
|
||||||
|
<p className="text-xs font-medium mt-1">
|
||||||
|
→ {design.nextAction}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { Loader2, CheckCircle, AlertCircle, XCircle, Clock } from 'lucide-react'
|
|||||||
import { useOperation } from '../../hooks/useOperations';
|
import { useOperation } from '../../hooks/useOperations';
|
||||||
|
|
||||||
interface OperationProgressProps {
|
interface OperationProgressProps {
|
||||||
|
instanceName: string;
|
||||||
operationId: string;
|
operationId: string;
|
||||||
onComplete?: () => void;
|
onComplete?: () => void;
|
||||||
onError?: (error: string) => void;
|
onError?: (error: string) => void;
|
||||||
@@ -12,12 +13,13 @@ interface OperationProgressProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function OperationProgress({
|
export function OperationProgress({
|
||||||
|
instanceName,
|
||||||
operationId,
|
operationId,
|
||||||
onComplete,
|
onComplete,
|
||||||
onError,
|
onError,
|
||||||
showDetails = true
|
showDetails = true
|
||||||
}: OperationProgressProps) {
|
}: OperationProgressProps) {
|
||||||
const { operation, error, isLoading, cancel, isCancelling } = useOperation(operationId);
|
const { operation, error, isLoading, cancel, isCancelling } = useOperation(instanceName, operationId);
|
||||||
|
|
||||||
// Handle operation completion
|
// Handle operation completion
|
||||||
if (operation?.status === 'completed' && onComplete) {
|
if (operation?.status === 'completed' && onComplete) {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
76
src/components/ui/alert.tsx
Normal file
76
src/components/ui/alert.tsx
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { cva, type VariantProps } from 'class-variance-authority';
|
||||||
|
import { X } from 'lucide-react';
|
||||||
|
|
||||||
|
const alertVariants = cva(
|
||||||
|
'relative w-full rounded-lg border p-4 [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg+div]:translate-y-[-3px] [&:has(svg)]:pl-11',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: 'bg-background text-foreground border-border',
|
||||||
|
success: 'bg-green-50 text-green-900 border-green-200 dark:bg-green-950/20 dark:text-green-100 dark:border-green-800',
|
||||||
|
error: 'bg-red-50 text-red-900 border-red-200 dark:bg-red-950/20 dark:text-red-100 dark:border-red-800',
|
||||||
|
warning: 'bg-yellow-50 text-yellow-900 border-yellow-200 dark:bg-yellow-950/20 dark:text-yellow-100 dark:border-yellow-800',
|
||||||
|
info: 'bg-blue-50 text-blue-900 border-blue-200 dark:bg-blue-950/20 dark:text-blue-100 dark:border-blue-800',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'default',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export interface AlertProps
|
||||||
|
extends React.HTMLAttributes<HTMLDivElement>,
|
||||||
|
VariantProps<typeof alertVariants> {
|
||||||
|
onClose?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Alert = React.forwardRef<HTMLDivElement, AlertProps>(
|
||||||
|
({ className, variant, onClose, children, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
role="alert"
|
||||||
|
className={alertVariants({ variant, className })}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{onClose && (
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="absolute right-4 top-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-offset-2"
|
||||||
|
aria-label="Close"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
Alert.displayName = 'Alert';
|
||||||
|
|
||||||
|
const AlertTitle = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLHeadingElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<h5
|
||||||
|
ref={ref}
|
||||||
|
className={`mb-1 font-medium leading-none tracking-tight ${className || ''}`}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
AlertTitle.displayName = 'AlertTitle';
|
||||||
|
|
||||||
|
const AlertDescription = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLParagraphElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={`text-sm [&_p]:leading-relaxed ${className || ''}`}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
AlertDescription.displayName = 'AlertDescription';
|
||||||
|
|
||||||
|
export { Alert, AlertTitle, AlertDescription };
|
||||||
30
src/components/ui/checkbox.tsx
Normal file
30
src/components/ui/checkbox.tsx
Normal 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 }
|
||||||
95
src/components/ui/drawer.tsx
Normal file
95
src/components/ui/drawer.tsx
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import { useEffect, type ReactNode } from 'react';
|
||||||
|
|
||||||
|
interface DrawerProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
title: string;
|
||||||
|
children: ReactNode;
|
||||||
|
footer?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Drawer({ open, onClose, title, children, footer }: DrawerProps) {
|
||||||
|
useEffect(() => {
|
||||||
|
const handleEscape = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape' && open) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('keydown', handleEscape);
|
||||||
|
return () => document.removeEventListener('keydown', handleEscape);
|
||||||
|
}, [open, onClose]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
} else {
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
};
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Overlay with fade transition */}
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
fixed inset-0 z-50
|
||||||
|
bg-black/50 backdrop-blur-sm
|
||||||
|
transition-opacity duration-300 ease-in-out
|
||||||
|
${open ? 'opacity-100' : 'opacity-0 pointer-events-none'}
|
||||||
|
`}
|
||||||
|
onClick={onClose}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Drawer panel with slide transition */}
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
fixed inset-y-0 right-0 z-50
|
||||||
|
w-full max-w-md
|
||||||
|
transform transition-transform duration-300 ease-in-out
|
||||||
|
${open ? 'translate-x-0' : 'translate-x-full'}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<div className="flex h-full flex-col bg-background shadow-xl">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="border-b border-border px-6 py-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-lg font-semibold text-foreground">{title}</h2>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="rounded-md text-muted-foreground hover:text-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
aria-label="Close drawer"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="h-6 w-6"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
strokeWidth={2}
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 overflow-y-auto px-6 py-6">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
{footer && (
|
||||||
|
<div className="border-t border-border px-6 py-4">
|
||||||
|
{footer}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
export { Button, buttonVariants } from './button';
|
export { Button, buttonVariants } from './button';
|
||||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } from './card';
|
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } from './card';
|
||||||
export { Badge, badgeVariants } from './badge';
|
export { Badge, badgeVariants } from './badge';
|
||||||
|
export { Alert, AlertTitle, AlertDescription } from './alert';
|
||||||
export { Input } from './input';
|
export { Input } from './input';
|
||||||
export { Label } from './label';
|
export { Label } from './label';
|
||||||
export { Textarea } from './textarea';
|
export { Textarea } from './textarea';
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
|||||||
type={type}
|
type={type}
|
||||||
data-slot="input"
|
data-slot="input"
|
||||||
className={cn(
|
className={cn(
|
||||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground bg-transparent dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] focus-visible:bg-transparent dark:focus-visible:bg-input/30",
|
||||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
|
|||||||
29
src/components/ui/switch.tsx
Normal file
29
src/components/ui/switch.tsx
Normal 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 }
|
||||||
64
src/components/ui/tabs.tsx
Normal file
64
src/components/ui/tabs.tsx
Normal 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 }
|
||||||
161
src/config/nodeStatus.ts
Normal file
161
src/config/nodeStatus.ts
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
import { NodeStatus, type StatusDesign } from '../types/nodeStatus';
|
||||||
|
|
||||||
|
export const statusDesigns: Record<NodeStatus, StatusDesign> = {
|
||||||
|
[NodeStatus.DISCOVERED]: {
|
||||||
|
status: NodeStatus.DISCOVERED,
|
||||||
|
color: "text-purple-700",
|
||||||
|
bgColor: "bg-purple-50",
|
||||||
|
icon: "MagnifyingGlassIcon",
|
||||||
|
label: "Discovered",
|
||||||
|
description: "Node detected on network but not yet configured",
|
||||||
|
nextAction: "Configure node settings",
|
||||||
|
severity: "info"
|
||||||
|
},
|
||||||
|
|
||||||
|
[NodeStatus.PENDING]: {
|
||||||
|
status: NodeStatus.PENDING,
|
||||||
|
color: "text-gray-700",
|
||||||
|
bgColor: "bg-gray-50",
|
||||||
|
icon: "ClockIcon",
|
||||||
|
label: "Pending",
|
||||||
|
description: "Node awaiting configuration",
|
||||||
|
nextAction: "Configure and apply settings",
|
||||||
|
severity: "neutral"
|
||||||
|
},
|
||||||
|
|
||||||
|
[NodeStatus.CONFIGURING]: {
|
||||||
|
status: NodeStatus.CONFIGURING,
|
||||||
|
color: "text-blue-700",
|
||||||
|
bgColor: "bg-blue-50",
|
||||||
|
icon: "ArrowPathIcon",
|
||||||
|
label: "Configuring",
|
||||||
|
description: "Node configuration in progress",
|
||||||
|
severity: "info"
|
||||||
|
},
|
||||||
|
|
||||||
|
[NodeStatus.CONFIGURED]: {
|
||||||
|
status: NodeStatus.CONFIGURED,
|
||||||
|
color: "text-indigo-700",
|
||||||
|
bgColor: "bg-indigo-50",
|
||||||
|
icon: "DocumentCheckIcon",
|
||||||
|
label: "Configured",
|
||||||
|
description: "Node configured but not yet applied",
|
||||||
|
nextAction: "Apply configuration to node",
|
||||||
|
severity: "info"
|
||||||
|
},
|
||||||
|
|
||||||
|
[NodeStatus.APPLYING]: {
|
||||||
|
status: NodeStatus.APPLYING,
|
||||||
|
color: "text-blue-700",
|
||||||
|
bgColor: "bg-blue-50",
|
||||||
|
icon: "ArrowPathIcon",
|
||||||
|
label: "Applying",
|
||||||
|
description: "Applying configuration to node",
|
||||||
|
severity: "info"
|
||||||
|
},
|
||||||
|
|
||||||
|
[NodeStatus.PROVISIONING]: {
|
||||||
|
status: NodeStatus.PROVISIONING,
|
||||||
|
color: "text-blue-700",
|
||||||
|
bgColor: "bg-blue-50",
|
||||||
|
icon: "ArrowPathIcon",
|
||||||
|
label: "Provisioning",
|
||||||
|
description: "Node is being provisioned with Talos",
|
||||||
|
severity: "info"
|
||||||
|
},
|
||||||
|
|
||||||
|
[NodeStatus.READY]: {
|
||||||
|
status: NodeStatus.READY,
|
||||||
|
color: "text-green-700",
|
||||||
|
bgColor: "bg-green-50",
|
||||||
|
icon: "CheckCircleIcon",
|
||||||
|
label: "Ready",
|
||||||
|
description: "Node is ready and operational",
|
||||||
|
severity: "success"
|
||||||
|
},
|
||||||
|
|
||||||
|
[NodeStatus.HEALTHY]: {
|
||||||
|
status: NodeStatus.HEALTHY,
|
||||||
|
color: "text-emerald-700",
|
||||||
|
bgColor: "bg-emerald-50",
|
||||||
|
icon: "HeartIcon",
|
||||||
|
label: "Healthy",
|
||||||
|
description: "Node is healthy and part of Kubernetes cluster",
|
||||||
|
severity: "success"
|
||||||
|
},
|
||||||
|
|
||||||
|
[NodeStatus.MAINTENANCE]: {
|
||||||
|
status: NodeStatus.MAINTENANCE,
|
||||||
|
color: "text-yellow-700",
|
||||||
|
bgColor: "bg-yellow-50",
|
||||||
|
icon: "WrenchScrewdriverIcon",
|
||||||
|
label: "Maintenance",
|
||||||
|
description: "Node is in maintenance mode",
|
||||||
|
severity: "warning"
|
||||||
|
},
|
||||||
|
|
||||||
|
[NodeStatus.REPROVISIONING]: {
|
||||||
|
status: NodeStatus.REPROVISIONING,
|
||||||
|
color: "text-orange-700",
|
||||||
|
bgColor: "bg-orange-50",
|
||||||
|
icon: "ArrowPathIcon",
|
||||||
|
label: "Reprovisioning",
|
||||||
|
description: "Node is being reprovisioned",
|
||||||
|
severity: "warning"
|
||||||
|
},
|
||||||
|
|
||||||
|
[NodeStatus.UNREACHABLE]: {
|
||||||
|
status: NodeStatus.UNREACHABLE,
|
||||||
|
color: "text-red-700",
|
||||||
|
bgColor: "bg-red-50",
|
||||||
|
icon: "ExclamationTriangleIcon",
|
||||||
|
label: "Unreachable",
|
||||||
|
description: "Node cannot be contacted",
|
||||||
|
nextAction: "Check network connectivity",
|
||||||
|
severity: "error"
|
||||||
|
},
|
||||||
|
|
||||||
|
[NodeStatus.DEGRADED]: {
|
||||||
|
status: NodeStatus.DEGRADED,
|
||||||
|
color: "text-orange-700",
|
||||||
|
bgColor: "bg-orange-50",
|
||||||
|
icon: "ExclamationTriangleIcon",
|
||||||
|
label: "Degraded",
|
||||||
|
description: "Node is experiencing issues",
|
||||||
|
nextAction: "Check node health",
|
||||||
|
severity: "warning"
|
||||||
|
},
|
||||||
|
|
||||||
|
[NodeStatus.FAILED]: {
|
||||||
|
status: NodeStatus.FAILED,
|
||||||
|
color: "text-red-700",
|
||||||
|
bgColor: "bg-red-50",
|
||||||
|
icon: "XCircleIcon",
|
||||||
|
label: "Failed",
|
||||||
|
description: "Node operation failed",
|
||||||
|
nextAction: "Review logs and retry",
|
||||||
|
severity: "error"
|
||||||
|
},
|
||||||
|
|
||||||
|
[NodeStatus.UNKNOWN]: {
|
||||||
|
status: NodeStatus.UNKNOWN,
|
||||||
|
color: "text-gray-700",
|
||||||
|
bgColor: "bg-gray-50",
|
||||||
|
icon: "QuestionMarkCircleIcon",
|
||||||
|
label: "Unknown",
|
||||||
|
description: "Node status cannot be determined",
|
||||||
|
nextAction: "Check node connection",
|
||||||
|
severity: "neutral"
|
||||||
|
},
|
||||||
|
|
||||||
|
[NodeStatus.ORPHANED]: {
|
||||||
|
status: NodeStatus.ORPHANED,
|
||||||
|
color: "text-purple-700",
|
||||||
|
bgColor: "bg-purple-50",
|
||||||
|
icon: "ExclamationTriangleIcon",
|
||||||
|
label: "Orphaned",
|
||||||
|
description: "Node exists in Kubernetes but not in configuration",
|
||||||
|
nextAction: "Add to configuration or remove from cluster",
|
||||||
|
severity: "warning"
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -6,7 +6,7 @@ import { useConfig } from '../useConfig';
|
|||||||
import { apiService } from '../../services/api-legacy';
|
import { apiService } from '../../services/api-legacy';
|
||||||
|
|
||||||
// Mock the API service
|
// Mock the API service
|
||||||
vi.mock('../../services/api', () => ({
|
vi.mock('../../services/api-legacy', () => ({
|
||||||
apiService: {
|
apiService: {
|
||||||
getConfig: vi.fn(),
|
getConfig: vi.fn(),
|
||||||
createConfig: vi.fn(),
|
createConfig: vi.fn(),
|
||||||
@@ -56,7 +56,7 @@ describe('useConfig', () => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
vi.mocked(apiService.getConfig).mockResolvedValue(mockConfigResponse);
|
(apiService.getConfig as ReturnType<typeof vi.fn>).mockResolvedValue(mockConfigResponse);
|
||||||
|
|
||||||
const { result } = renderHook(() => useConfig(), {
|
const { result } = renderHook(() => useConfig(), {
|
||||||
wrapper: createWrapper(),
|
wrapper: createWrapper(),
|
||||||
@@ -81,7 +81,7 @@ describe('useConfig', () => {
|
|||||||
message: 'No configuration found',
|
message: 'No configuration found',
|
||||||
};
|
};
|
||||||
|
|
||||||
vi.mocked(apiService.getConfig).mockResolvedValue(mockConfigResponse);
|
(apiService.getConfig as ReturnType<typeof vi.fn>).mockResolvedValue(mockConfigResponse);
|
||||||
|
|
||||||
const { result } = renderHook(() => useConfig(), {
|
const { result } = renderHook(() => useConfig(), {
|
||||||
wrapper: createWrapper(),
|
wrapper: createWrapper(),
|
||||||
@@ -122,8 +122,8 @@ describe('useConfig', () => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
vi.mocked(apiService.getConfig).mockResolvedValue(mockConfigResponse);
|
(apiService.getConfig as ReturnType<typeof vi.fn>).mockResolvedValue(mockConfigResponse);
|
||||||
vi.mocked(apiService.createConfig).mockResolvedValue(mockCreateResponse);
|
(apiService.createConfig as ReturnType<typeof vi.fn>).mockResolvedValue(mockCreateResponse);
|
||||||
|
|
||||||
const { result } = renderHook(() => useConfig(), {
|
const { result } = renderHook(() => useConfig(), {
|
||||||
wrapper: createWrapper(),
|
wrapper: createWrapper(),
|
||||||
@@ -149,7 +149,7 @@ describe('useConfig', () => {
|
|||||||
|
|
||||||
it('should handle error when fetching config fails', async () => {
|
it('should handle error when fetching config fails', async () => {
|
||||||
const mockError = new Error('Network error');
|
const mockError = new Error('Network error');
|
||||||
vi.mocked(apiService.getConfig).mockRejectedValue(mockError);
|
(apiService.getConfig as ReturnType<typeof vi.fn>).mockRejectedValue(mockError);
|
||||||
|
|
||||||
const { result } = renderHook(() => useConfig(), {
|
const { result } = renderHook(() => useConfig(), {
|
||||||
wrapper: createWrapper(),
|
wrapper: createWrapper(),
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { useStatus } from '../useStatus';
|
|||||||
import { apiService } from '../../services/api-legacy';
|
import { apiService } from '../../services/api-legacy';
|
||||||
|
|
||||||
// Mock the API service
|
// Mock the API service
|
||||||
vi.mock('../../services/api', () => ({
|
vi.mock('../../services/api-legacy', () => ({
|
||||||
apiService: {
|
apiService: {
|
||||||
getStatus: vi.fn(),
|
getStatus: vi.fn(),
|
||||||
},
|
},
|
||||||
@@ -40,7 +40,7 @@ describe('useStatus', () => {
|
|||||||
timestamp: '2024-01-01T00:00:00Z',
|
timestamp: '2024-01-01T00:00:00Z',
|
||||||
};
|
};
|
||||||
|
|
||||||
vi.mocked(apiService.getStatus).mockResolvedValue(mockStatus);
|
(apiService.getStatus as ReturnType<typeof vi.fn>).mockResolvedValue(mockStatus);
|
||||||
|
|
||||||
const { result } = renderHook(() => useStatus(), {
|
const { result } = renderHook(() => useStatus(), {
|
||||||
wrapper: createWrapper(),
|
wrapper: createWrapper(),
|
||||||
@@ -60,7 +60,7 @@ describe('useStatus', () => {
|
|||||||
|
|
||||||
it('should handle error when fetching status fails', async () => {
|
it('should handle error when fetching status fails', async () => {
|
||||||
const mockError = new Error('Network error');
|
const mockError = new Error('Network error');
|
||||||
vi.mocked(apiService.getStatus).mockRejectedValue(mockError);
|
(apiService.getStatus as ReturnType<typeof vi.fn>).mockRejectedValue(mockError);
|
||||||
|
|
||||||
const { result } = renderHook(() => useStatus(), {
|
const { result } = renderHook(() => useStatus(), {
|
||||||
wrapper: createWrapper(),
|
wrapper: createWrapper(),
|
||||||
@@ -82,7 +82,7 @@ describe('useStatus', () => {
|
|||||||
timestamp: '2024-01-01T00:00:00Z',
|
timestamp: '2024-01-01T00:00:00Z',
|
||||||
};
|
};
|
||||||
|
|
||||||
vi.mocked(apiService.getStatus).mockResolvedValue(mockStatus);
|
(apiService.getStatus as ReturnType<typeof vi.fn>).mockResolvedValue(mockStatus);
|
||||||
|
|
||||||
const { result } = renderHook(() => useStatus(), {
|
const { result } = renderHook(() => useStatus(), {
|
||||||
wrapper: createWrapper(),
|
wrapper: createWrapper(),
|
||||||
|
|||||||
@@ -108,3 +108,58 @@ export function useAppBackups(instanceName: string | null | undefined, appName:
|
|||||||
restoreResult: restoreMutation.data,
|
restoreResult: restoreMutation.data,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Enhanced hooks for app details and runtime status
|
||||||
|
export function useAppEnhanced(instanceName: string | null | undefined, appName: string | null | undefined) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['instances', instanceName, 'apps', appName, 'enhanced'],
|
||||||
|
queryFn: () => appsApi.getEnhanced(instanceName!, appName!),
|
||||||
|
enabled: !!instanceName && !!appName,
|
||||||
|
refetchInterval: 10000, // Poll every 10 seconds
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAppRuntime(instanceName: string | null | undefined, appName: string | null | undefined) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['instances', instanceName, 'apps', appName, 'runtime'],
|
||||||
|
queryFn: () => appsApi.getRuntime(instanceName!, appName!),
|
||||||
|
enabled: !!instanceName && !!appName,
|
||||||
|
refetchInterval: 5000, // Poll every 5 seconds
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAppLogs(
|
||||||
|
instanceName: string | null | undefined,
|
||||||
|
appName: string | null | undefined,
|
||||||
|
params?: { tail?: number; sinceSeconds?: number; pod?: string }
|
||||||
|
) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['instances', instanceName, 'apps', appName, 'logs', params],
|
||||||
|
queryFn: () => appsApi.getLogs(instanceName!, appName!, params),
|
||||||
|
enabled: !!instanceName && !!appName,
|
||||||
|
refetchInterval: false, // Manual refresh only
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAppEvents(
|
||||||
|
instanceName: string | null | undefined,
|
||||||
|
appName: string | null | undefined,
|
||||||
|
limit?: number
|
||||||
|
) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['instances', instanceName, 'apps', appName, 'events', limit],
|
||||||
|
queryFn: () => appsApi.getEvents(instanceName!, appName!, limit),
|
||||||
|
enabled: !!instanceName && !!appName,
|
||||||
|
refetchInterval: 10000, // Poll every 10 seconds
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAppReadme(instanceName: string | null | undefined, appName: string | null | undefined) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['instances', instanceName, 'apps', appName, 'readme'],
|
||||||
|
queryFn: () => appsApi.getReadme(instanceName!, appName!),
|
||||||
|
enabled: !!instanceName && !!appName,
|
||||||
|
staleTime: 5 * 60 * 1000, // 5 minutes - READMEs don't change often
|
||||||
|
retry: false, // Don't retry if README not found (404)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,11 +12,14 @@ export function useNodes(instanceName: string | null | undefined) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const discoverMutation = useMutation({
|
const discoverMutation = useMutation({
|
||||||
mutationFn: (subnet: string) => nodesApi.discover(instanceName!, subnet),
|
mutationFn: (subnet?: string) => nodesApi.discover(instanceName!, subnet),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['instances', instanceName, 'discovery'] });
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const detectMutation = useMutation({
|
const detectMutation = useMutation({
|
||||||
mutationFn: () => nodesApi.detect(instanceName!),
|
mutationFn: (ip: string) => nodesApi.detect(instanceName!, ip),
|
||||||
});
|
});
|
||||||
|
|
||||||
const addMutation = useMutation({
|
const addMutation = useMutation({
|
||||||
@@ -24,6 +27,10 @@ export function useNodes(instanceName: string | null | undefined) {
|
|||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['instances', instanceName, 'nodes'] });
|
queryClient.invalidateQueries({ queryKey: ['instances', instanceName, 'nodes'] });
|
||||||
},
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
// Don't refetch on error to avoid showing inconsistent state
|
||||||
|
console.error('Failed to add node:', error);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const updateMutation = useMutation({
|
const updateMutation = useMutation({
|
||||||
@@ -39,16 +46,41 @@ export function useNodes(instanceName: string | null | undefined) {
|
|||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['instances', instanceName, 'nodes'] });
|
queryClient.invalidateQueries({ queryKey: ['instances', instanceName, 'nodes'] });
|
||||||
},
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
// Don't refetch on error to avoid showing inconsistent state
|
||||||
|
console.error('Failed to delete node:', error);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
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({
|
||||||
mutationFn: () => nodesApi.fetchTemplates(instanceName!),
|
mutationFn: () => nodesApi.fetchTemplates(instanceName!),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const cancelDiscoveryMutation = useMutation({
|
||||||
|
mutationFn: () => nodesApi.cancelDiscovery(instanceName!),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['instances', instanceName, 'discovery'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const getHardwareMutation = useMutation({
|
||||||
|
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,
|
||||||
@@ -57,19 +89,31 @@ export function useNodes(instanceName: string | null | undefined) {
|
|||||||
discover: discoverMutation.mutate,
|
discover: discoverMutation.mutate,
|
||||||
isDiscovering: discoverMutation.isPending,
|
isDiscovering: discoverMutation.isPending,
|
||||||
discoverResult: discoverMutation.data,
|
discoverResult: discoverMutation.data,
|
||||||
|
discoverError: discoverMutation.error,
|
||||||
detect: detectMutation.mutate,
|
detect: detectMutation.mutate,
|
||||||
isDetecting: detectMutation.isPending,
|
isDetecting: detectMutation.isPending,
|
||||||
detectResult: detectMutation.data,
|
detectResult: detectMutation.data,
|
||||||
|
detectError: detectMutation.error,
|
||||||
|
getHardware: getHardwareMutation.mutateAsync,
|
||||||
|
isGettingHardware: getHardwareMutation.isPending,
|
||||||
|
getHardwareError: getHardwareMutation.error,
|
||||||
addNode: addMutation.mutate,
|
addNode: addMutation.mutate,
|
||||||
isAdding: addMutation.isPending,
|
isAdding: addMutation.isPending,
|
||||||
|
addError: addMutation.error,
|
||||||
updateNode: updateMutation.mutate,
|
updateNode: updateMutation.mutate,
|
||||||
isUpdating: updateMutation.isPending,
|
isUpdating: updateMutation.isPending,
|
||||||
deleteNode: deleteMutation.mutate,
|
deleteNode: deleteMutation.mutate,
|
||||||
isDeleting: deleteMutation.isPending,
|
isDeleting: deleteMutation.isPending,
|
||||||
|
deleteError: deleteMutation.error,
|
||||||
applyNode: applyMutation.mutate,
|
applyNode: applyMutation.mutate,
|
||||||
isApplying: applyMutation.isPending,
|
isApplying: applyMutation.isPending,
|
||||||
fetchTemplates: fetchTemplatesMutation.mutate,
|
fetchTemplates: fetchTemplatesMutation.mutate,
|
||||||
isFetchingTemplates: fetchTemplatesMutation.isPending,
|
isFetchingTemplates: fetchTemplatesMutation.isPending,
|
||||||
|
cancelDiscovery: cancelDiscoveryMutation.mutate,
|
||||||
|
isCancellingDiscovery: cancelDiscoveryMutation.isPending,
|
||||||
|
resetNode: resetMutation.mutate,
|
||||||
|
isResetting: resetMutation.isPending,
|
||||||
|
resetError: resetMutation.error,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,19 +12,19 @@ export function useOperations(instanceName: string | null | undefined) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useOperation(operationId: string | null | undefined) {
|
export function useOperation(instanceName: string | null | undefined, operationId: string | null | undefined) {
|
||||||
const [operation, setOperation] = useState<Operation | null>(null);
|
const [operation, setOperation] = useState<Operation | null>(null);
|
||||||
const [error, setError] = useState<Error | null>(null);
|
const [error, setError] = useState<Error | null>(null);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!operationId) return;
|
if (!instanceName || !operationId) return;
|
||||||
|
|
||||||
// Fetch initial state
|
// Fetch initial state
|
||||||
operationsApi.get(operationId).then(setOperation).catch(setError);
|
operationsApi.get(instanceName, operationId).then(setOperation).catch(setError);
|
||||||
|
|
||||||
// Set up SSE stream
|
// Set up SSE stream
|
||||||
const eventSource = operationsApi.createStream(operationId);
|
const eventSource = operationsApi.createStream(instanceName, operationId);
|
||||||
|
|
||||||
eventSource.onmessage = (event) => {
|
eventSource.onmessage = (event) => {
|
||||||
try {
|
try {
|
||||||
@@ -54,14 +54,14 @@ export function useOperation(operationId: string | null | undefined) {
|
|||||||
return () => {
|
return () => {
|
||||||
eventSource.close();
|
eventSource.close();
|
||||||
};
|
};
|
||||||
}, [operationId, queryClient]);
|
}, [instanceName, operationId, queryClient]);
|
||||||
|
|
||||||
const cancelMutation = useMutation({
|
const cancelMutation = useMutation({
|
||||||
mutationFn: () => {
|
mutationFn: () => {
|
||||||
if (!operation?.instance_name) {
|
if (!instanceName || !operationId) {
|
||||||
throw new Error('Cannot cancel operation: instance name not available');
|
throw new Error('Cannot cancel operation: instance name or operation ID not available');
|
||||||
}
|
}
|
||||||
return operationsApi.cancel(operationId!, operation.instance_name);
|
return operationsApi.cancel(instanceName, operationId);
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
// Operation state will be updated via SSE
|
// Operation state will be updated via SSE
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
@import "tw-animate-css";
|
@import "tw-animate-css";
|
||||||
|
@plugin "@tailwindcss/typography";
|
||||||
|
|
||||||
@custom-variant dark (&:is(.dark *));
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
10
src/router/pages/ControlNodesPage.tsx
Normal file
10
src/router/pages/ControlNodesPage.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { ErrorBoundary } from '../../components';
|
||||||
|
import { ControlNodesComponent } from '../../components/ControlNodesComponent';
|
||||||
|
|
||||||
|
export function ControlNodesPage() {
|
||||||
|
return (
|
||||||
|
<ErrorBoundary>
|
||||||
|
<ControlNodesComponent />
|
||||||
|
</ErrorBoundary>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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,
|
||||||
|
version: selectedVersion,
|
||||||
request: {
|
request: {
|
||||||
version: selectedVersion,
|
|
||||||
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,17 +81,16 @@ 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: asset.schematic_id,
|
||||||
schematic_id: schematic.schematic_id,
|
version: asset.version
|
||||||
version: schematic.version
|
}));
|
||||||
}));
|
}) || [];
|
||||||
}) || [];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import { useParams } from 'react-router';
|
||||||
import { UtilityCard, CopyableValue } from '../../components/UtilityCard';
|
import { UtilityCard, CopyableValue } from '../../components/UtilityCard';
|
||||||
import { Button } from '../../components/ui/button';
|
import { Button } from '../../components/ui/button';
|
||||||
import {
|
import {
|
||||||
@@ -18,18 +19,25 @@ import {
|
|||||||
} from '../../services/api/hooks/useUtilities';
|
} from '../../services/api/hooks/useUtilities';
|
||||||
|
|
||||||
export function UtilitiesPage() {
|
export function UtilitiesPage() {
|
||||||
|
const { instanceId } = useParams<{ instanceId: string }>();
|
||||||
const [secretToCopy, setSecretToCopy] = useState('');
|
const [secretToCopy, setSecretToCopy] = useState('');
|
||||||
const [targetInstance, setTargetInstance] = useState('');
|
const [sourceNamespace, setSourceNamespace] = useState('');
|
||||||
|
const [destinationNamespace, setDestinationNamespace] = useState('');
|
||||||
|
|
||||||
const dashboardToken = useDashboardToken();
|
const dashboardToken = useDashboardToken(instanceId || '');
|
||||||
const versions = useClusterVersions();
|
const versions = useClusterVersions(instanceId || '');
|
||||||
const nodeIPs = useNodeIPs();
|
const nodeIPs = useNodeIPs(instanceId || '');
|
||||||
const controlPlaneIP = useControlPlaneIP();
|
const controlPlaneIP = useControlPlaneIP(instanceId || '');
|
||||||
const copySecret = useCopySecret();
|
const copySecret = useCopySecret();
|
||||||
|
|
||||||
const handleCopySecret = () => {
|
const handleCopySecret = () => {
|
||||||
if (secretToCopy && targetInstance) {
|
if (secretToCopy && sourceNamespace && destinationNamespace && instanceId) {
|
||||||
copySecret.mutate({ secret: secretToCopy, targetInstance });
|
copySecret.mutate({
|
||||||
|
instanceName: instanceId,
|
||||||
|
secret: secretToCopy,
|
||||||
|
sourceNamespace,
|
||||||
|
destinationNamespace
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -130,7 +138,7 @@ export function UtilitiesPage() {
|
|||||||
{/* Secret Copy Utility */}
|
{/* Secret Copy Utility */}
|
||||||
<UtilityCard
|
<UtilityCard
|
||||||
title="Copy Secret"
|
title="Copy Secret"
|
||||||
description="Copy a secret between namespaces or instances"
|
description="Copy a secret between namespaces"
|
||||||
icon={<Copy className="h-5 w-5 text-primary" />}
|
icon={<Copy className="h-5 w-5 text-primary" />}
|
||||||
>
|
>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
@@ -146,19 +154,31 @@ export function UtilitiesPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-sm font-medium mb-2 block">
|
<label className="text-sm font-medium mb-2 block">
|
||||||
Target Instance/Namespace
|
Source Namespace
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="e.g., default"
|
||||||
|
value={sourceNamespace}
|
||||||
|
onChange={(e) => setSourceNamespace(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border rounded-lg bg-background"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium mb-2 block">
|
||||||
|
Destination Namespace
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="e.g., production"
|
placeholder="e.g., production"
|
||||||
value={targetInstance}
|
value={destinationNamespace}
|
||||||
onChange={(e) => setTargetInstance(e.target.value)}
|
onChange={(e) => setDestinationNamespace(e.target.value)}
|
||||||
className="w-full px-3 py-2 border rounded-lg bg-background"
|
className="w-full px-3 py-2 border rounded-lg bg-background"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleCopySecret}
|
onClick={handleCopySecret}
|
||||||
disabled={!secretToCopy || !targetInstance || copySecret.isPending}
|
disabled={!secretToCopy || !sourceNamespace || !destinationNamespace || copySecret.isPending}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
>
|
>
|
||||||
{copySecret.isPending ? 'Copying...' : 'Copy Secret'}
|
{copySecret.isPending ? 'Copying...' : 'Copy Secret'}
|
||||||
|
|||||||
10
src/router/pages/WorkerNodesPage.tsx
Normal file
10
src/router/pages/WorkerNodesPage.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { ErrorBoundary } from '../../components';
|
||||||
|
import { WorkerNodesComponent } from '../../components/WorkerNodesComponent';
|
||||||
|
|
||||||
|
export function WorkerNodesPage() {
|
||||||
|
return (
|
||||||
|
<ErrorBoundary>
|
||||||
|
<WorkerNodesComponent />
|
||||||
|
</ErrorBoundary>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -15,9 +15,11 @@ import { DnsPage } from './pages/DnsPage';
|
|||||||
import { DhcpPage } from './pages/DhcpPage';
|
import { DhcpPage } from './pages/DhcpPage';
|
||||||
import { PxePage } from './pages/PxePage';
|
import { PxePage } from './pages/PxePage';
|
||||||
import { IsoPage } from './pages/IsoPage';
|
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 { ClusterPage } from './pages/ClusterPage';
|
||||||
import { AppsPage } from './pages/AppsPage';
|
import { AppsPage } from './pages/AppsPage';
|
||||||
|
import { BackupsPage } from './pages/BackupsPage';
|
||||||
import { AdvancedPage } from './pages/AdvancedPage';
|
import { AdvancedPage } from './pages/AdvancedPage';
|
||||||
import { AssetsIsoPage } from './pages/AssetsIsoPage';
|
import { AssetsIsoPage } from './pages/AssetsIsoPage';
|
||||||
import { AssetsPxePage } from './pages/AssetsPxePage';
|
import { AssetsPxePage } from './pages/AssetsPxePage';
|
||||||
@@ -92,8 +94,12 @@ export const routes: RouteObject[] = [
|
|||||||
element: <IsoPage />,
|
element: <IsoPage />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'infrastructure',
|
path: 'control',
|
||||||
element: <InfrastructurePage />,
|
element: <ControlNodesPage />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'worker',
|
||||||
|
element: <WorkerNodesPage />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'cluster',
|
path: 'cluster',
|
||||||
@@ -101,7 +107,24 @@ export const routes: RouteObject[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'apps',
|
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',
|
path: 'advanced',
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ const nodesConfigSchema = z.object({
|
|||||||
// Cluster configuration schema
|
// Cluster configuration schema
|
||||||
const clusterConfigSchema = z.object({
|
const clusterConfigSchema = z.object({
|
||||||
endpointIp: ipAddressSchema,
|
endpointIp: ipAddressSchema,
|
||||||
|
hostnamePrefix: z.string().optional(),
|
||||||
nodes: nodesConfigSchema,
|
nodes: nodesConfigSchema,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -138,6 +139,7 @@ export const configFormSchema = z.object({
|
|||||||
(val) => ipAddressSchema.safeParse(val).success,
|
(val) => ipAddressSchema.safeParse(val).success,
|
||||||
'Must be a valid IP address'
|
'Must be a valid IP address'
|
||||||
),
|
),
|
||||||
|
hostnamePrefix: z.string().optional(),
|
||||||
nodes: z.object({
|
nodes: z.object({
|
||||||
talos: z.object({
|
talos: z.object({
|
||||||
version: z.string().min(1, 'Talos version is required').refine(
|
version: z.string().min(1, 'Talos version is required').refine(
|
||||||
@@ -175,6 +177,7 @@ export const defaultConfigValues: ConfigFormData = {
|
|||||||
},
|
},
|
||||||
cluster: {
|
cluster: {
|
||||||
endpointIp: '192.168.8.60',
|
endpointIp: '192.168.8.60',
|
||||||
|
hostnamePrefix: '',
|
||||||
nodes: {
|
nodes: {
|
||||||
talos: {
|
talos: {
|
||||||
version: 'v1.8.0',
|
version: 'v1.8.0',
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ import type {
|
|||||||
AppAddResponse,
|
AppAddResponse,
|
||||||
AppStatus,
|
AppStatus,
|
||||||
OperationResponse,
|
OperationResponse,
|
||||||
|
EnhancedApp,
|
||||||
|
RuntimeStatus,
|
||||||
|
LogEntry,
|
||||||
|
KubernetesEvent,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
export const appsApi = {
|
export const appsApi = {
|
||||||
@@ -39,6 +43,33 @@ export const appsApi = {
|
|||||||
return apiClient.get(`/api/v1/instances/${instanceName}/apps/${appName}/status`);
|
return apiClient.get(`/api/v1/instances/${instanceName}/apps/${appName}/status`);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Enhanced app details endpoints
|
||||||
|
async getEnhanced(instanceName: string, appName: string): Promise<EnhancedApp> {
|
||||||
|
return apiClient.get(`/api/v1/instances/${instanceName}/apps/${appName}/enhanced`);
|
||||||
|
},
|
||||||
|
|
||||||
|
async getRuntime(instanceName: string, appName: string): Promise<RuntimeStatus> {
|
||||||
|
return apiClient.get(`/api/v1/instances/${instanceName}/apps/${appName}/runtime`);
|
||||||
|
},
|
||||||
|
|
||||||
|
async getLogs(
|
||||||
|
instanceName: string,
|
||||||
|
appName: string,
|
||||||
|
params?: { tail?: number; sinceSeconds?: number; pod?: string }
|
||||||
|
): Promise<LogEntry> {
|
||||||
|
const queryParams = new URLSearchParams();
|
||||||
|
if (params?.tail) queryParams.append('tail', params.tail.toString());
|
||||||
|
if (params?.sinceSeconds) queryParams.append('sinceSeconds', params.sinceSeconds.toString());
|
||||||
|
if (params?.pod) queryParams.append('pod', params.pod);
|
||||||
|
|
||||||
|
const query = queryParams.toString();
|
||||||
|
return apiClient.get(`/api/v1/instances/${instanceName}/apps/${appName}/logs${query ? `?${query}` : ''}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
async getEvents(instanceName: string, appName: string, limit = 20): Promise<{ events: KubernetesEvent[] }> {
|
||||||
|
return apiClient.get(`/api/v1/instances/${instanceName}/apps/${appName}/events?limit=${limit}`);
|
||||||
|
},
|
||||||
|
|
||||||
// Backup operations
|
// Backup operations
|
||||||
async backup(instanceName: string, appName: string): Promise<OperationResponse> {
|
async backup(instanceName: string, appName: string): Promise<OperationResponse> {
|
||||||
return apiClient.post(`/api/v1/instances/${instanceName}/apps/${appName}/backup`);
|
return apiClient.post(`/api/v1/instances/${instanceName}/apps/${appName}/backup`);
|
||||||
@@ -51,4 +82,16 @@ export const appsApi = {
|
|||||||
async restore(instanceName: string, appName: string, backupId: string): Promise<OperationResponse> {
|
async restore(instanceName: string, appName: string, backupId: string): Promise<OperationResponse> {
|
||||||
return apiClient.post(`/api/v1/instances/${instanceName}/apps/${appName}/restore`, { backup_id: backupId });
|
return apiClient.post(`/api/v1/instances/${instanceName}/apps/${appName}/restore`, { backup_id: backupId });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// README content
|
||||||
|
async getReadme(instanceName: string, appName: string): Promise<string> {
|
||||||
|
const response = await fetch(`${import.meta.env.VITE_API_BASE_URL || 'http://localhost:5055'}/api/v1/instances/${instanceName}/apps/${appName}/readme`);
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 404) {
|
||||||
|
return ''; // Return empty string if README not found
|
||||||
|
}
|
||||||
|
throw new Error(`Failed to fetch README: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
return response.text();
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 };
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ export const clusterApi = {
|
|||||||
return apiClient.post(`/api/v1/instances/${instanceName}/cluster/config/generate`, config);
|
return apiClient.post(`/api/v1/instances/${instanceName}/cluster/config/generate`, config);
|
||||||
},
|
},
|
||||||
|
|
||||||
async bootstrap(instanceName: string, node: string): Promise<OperationResponse> {
|
async bootstrap(instanceName: string, nodeName: string): Promise<OperationResponse> {
|
||||||
return apiClient.post<OperationResponse>(`/api/v1/instances/${instanceName}/cluster/bootstrap`, { node });
|
return apiClient.post<OperationResponse>(`/api/v1/instances/${instanceName}/cluster/bootstrap`, { node_name: nodeName });
|
||||||
},
|
},
|
||||||
|
|
||||||
async configureEndpoints(instanceName: string, includeNodes = false): Promise<OperationResponse> {
|
async configureEndpoints(instanceName: string, includeNodes = false): Promise<OperationResponse> {
|
||||||
|
|||||||
@@ -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'] });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,11 +26,11 @@ export const useOperations = (instanceName: string, filter?: 'running' | 'comple
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useOperation = (operationId: string) => {
|
export const useOperation = (instanceName: string, operationId: string) => {
|
||||||
return useQuery<Operation>({
|
return useQuery<Operation>({
|
||||||
queryKey: ['operation', operationId],
|
queryKey: ['operation', instanceName, operationId],
|
||||||
queryFn: () => operationsApi.get(operationId),
|
queryFn: () => operationsApi.get(instanceName, operationId),
|
||||||
enabled: !!operationId,
|
enabled: !!instanceName && !!operationId,
|
||||||
refetchInterval: (query) => {
|
refetchInterval: (query) => {
|
||||||
// Stop polling if operation is completed, failed, or cancelled
|
// Stop polling if operation is completed, failed, or cancelled
|
||||||
const status = query.state.data?.status;
|
const status = query.state.data?.status;
|
||||||
@@ -47,12 +47,12 @@ export const useCancelOperation = () => {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: ({ operationId, instanceName }: { operationId: string; instanceName: string }) =>
|
mutationFn: ({ instanceName, operationId }: { instanceName: string; operationId: string }) =>
|
||||||
operationsApi.cancel(operationId, instanceName),
|
operationsApi.cancel(instanceName, operationId),
|
||||||
onSuccess: (_, { operationId }) => {
|
onSuccess: (_, { instanceName, operationId }) => {
|
||||||
// Invalidate operation queries to refresh data
|
// Invalidate operation queries to refresh data
|
||||||
queryClient.invalidateQueries({ queryKey: ['operation', operationId] });
|
queryClient.invalidateQueries({ queryKey: ['operation', instanceName, operationId] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['operations'] });
|
queryClient.invalidateQueries({ queryKey: ['operations', instanceName] });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,35 +1,39 @@
|
|||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { utilitiesApi } from '../utilities';
|
import { utilitiesApi } from '../utilities';
|
||||||
|
|
||||||
export function useDashboardToken() {
|
export function useDashboardToken(instanceName: string) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['utilities', 'dashboard', 'token'],
|
queryKey: ['instances', instanceName, 'utilities', 'dashboard', 'token'],
|
||||||
queryFn: utilitiesApi.getDashboardToken,
|
queryFn: () => utilitiesApi.getDashboardToken(instanceName),
|
||||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
staleTime: 30 * 60 * 1000, // 30 minutes
|
||||||
|
enabled: !!instanceName,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useClusterVersions() {
|
export function useClusterVersions(instanceName: string) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['utilities', 'version'],
|
queryKey: ['instances', instanceName, 'utilities', 'version'],
|
||||||
queryFn: utilitiesApi.getVersion,
|
queryFn: () => utilitiesApi.getVersion(instanceName),
|
||||||
staleTime: 10 * 60 * 1000, // 10 minutes
|
staleTime: 10 * 60 * 1000, // 10 minutes
|
||||||
|
enabled: !!instanceName,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useNodeIPs() {
|
export function useNodeIPs(instanceName: string) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['utilities', 'nodes', 'ips'],
|
queryKey: ['instances', instanceName, 'utilities', 'nodes', 'ips'],
|
||||||
queryFn: utilitiesApi.getNodeIPs,
|
queryFn: () => utilitiesApi.getNodeIPs(instanceName),
|
||||||
staleTime: 30 * 1000, // 30 seconds
|
staleTime: 30 * 1000, // 30 seconds
|
||||||
|
enabled: !!instanceName,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useControlPlaneIP() {
|
export function useControlPlaneIP(instanceName: string) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['utilities', 'controlplane', 'ip'],
|
queryKey: ['instances', instanceName, 'utilities', 'controlplane', 'ip'],
|
||||||
queryFn: utilitiesApi.getControlPlaneIP,
|
queryFn: () => utilitiesApi.getControlPlaneIP(instanceName),
|
||||||
staleTime: 60 * 1000, // 1 minute
|
staleTime: 60 * 1000, // 1 minute
|
||||||
|
enabled: !!instanceName,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,8 +41,12 @@ export function useCopySecret() {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: ({ secret, targetInstance }: { secret: string; targetInstance: string }) =>
|
mutationFn: ({ instanceName, secret, sourceNamespace, destinationNamespace }: {
|
||||||
utilitiesApi.copySecret(secret, targetInstance),
|
instanceName: string;
|
||||||
|
secret: string;
|
||||||
|
sourceNamespace: string;
|
||||||
|
destinationNamespace: string;
|
||||||
|
}) => utilitiesApi.copySecret(instanceName, secret, sourceNamespace, destinationNamespace),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
// Invalidate secrets queries
|
// Invalidate secrets queries
|
||||||
queryClient.invalidateQueries({ queryKey: ['secrets'] });
|
queryClient.invalidateQueries({ queryKey: ['secrets'] });
|
||||||
|
|||||||
@@ -35,18 +35,23 @@ export const nodesApi = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
// Discovery
|
// Discovery
|
||||||
async discover(instanceName: string, subnet: string): Promise<OperationResponse> {
|
async discover(instanceName: string, subnet?: string): Promise<OperationResponse> {
|
||||||
return apiClient.post(`/api/v1/instances/${instanceName}/nodes/discover`, { subnet });
|
const body = subnet ? { subnet } : {};
|
||||||
|
return apiClient.post(`/api/v1/instances/${instanceName}/nodes/discover`, body);
|
||||||
},
|
},
|
||||||
|
|
||||||
async detect(instanceName: string): Promise<OperationResponse> {
|
async detect(instanceName: string, ip: string): Promise<HardwareInfo> {
|
||||||
return apiClient.post(`/api/v1/instances/${instanceName}/nodes/detect`);
|
return apiClient.post(`/api/v1/instances/${instanceName}/nodes/detect`, { ip });
|
||||||
},
|
},
|
||||||
|
|
||||||
async discoveryStatus(instanceName: string): Promise<DiscoveryStatus> {
|
async discoveryStatus(instanceName: string): Promise<DiscoveryStatus> {
|
||||||
return apiClient.get(`/api/v1/instances/${instanceName}/discovery`);
|
return apiClient.get(`/api/v1/instances/${instanceName}/discovery`);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async cancelDiscovery(instanceName: string): Promise<OperationResponse> {
|
||||||
|
return apiClient.post(`/api/v1/instances/${instanceName}/discovery/cancel`);
|
||||||
|
},
|
||||||
|
|
||||||
async getHardware(instanceName: string, ip: string): Promise<HardwareInfo> {
|
async getHardware(instanceName: string, ip: string): Promise<HardwareInfo> {
|
||||||
return apiClient.get(`/api/v1/instances/${instanceName}/nodes/hardware/${ip}`);
|
return apiClient.get(`/api/v1/instances/${instanceName}/nodes/hardware/${ip}`);
|
||||||
},
|
},
|
||||||
@@ -54,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`);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,18 +6,17 @@ export const operationsApi = {
|
|||||||
return apiClient.get(`/api/v1/instances/${instanceName}/operations`);
|
return apiClient.get(`/api/v1/instances/${instanceName}/operations`);
|
||||||
},
|
},
|
||||||
|
|
||||||
async get(operationId: string, instanceName?: string): Promise<Operation> {
|
async get(instanceName: string, operationId: string): Promise<Operation> {
|
||||||
const params = instanceName ? `?instance=${instanceName}` : '';
|
return apiClient.get(`/api/v1/instances/${instanceName}/operations/${operationId}`);
|
||||||
return apiClient.get(`/api/v1/operations/${operationId}${params}`);
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async cancel(operationId: string, instanceName: string): Promise<{ message: string }> {
|
async cancel(instanceName: string, operationId: string): Promise<{ message: string }> {
|
||||||
return apiClient.post(`/api/v1/operations/${operationId}/cancel?instance=${instanceName}`);
|
return apiClient.post(`/api/v1/instances/${instanceName}/operations/${operationId}/cancel`);
|
||||||
},
|
},
|
||||||
|
|
||||||
// SSE stream for operation updates
|
// SSE stream for operation updates
|
||||||
createStream(operationId: string): EventSource {
|
createStream(instanceName: string, operationId: string): EventSource {
|
||||||
const baseUrl = import.meta.env.VITE_API_BASE_URL || 'http://localhost:5055';
|
const baseUrl = import.meta.env.VITE_API_BASE_URL || 'http://localhost:5055';
|
||||||
return new EventSource(`${baseUrl}/api/v1/operations/${operationId}/stream`);
|
return new EventSource(`${baseUrl}/api/v1/instances/${instanceName}/operations/${operationId}/stream`);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { apiClient } from './client';
|
|||||||
import type {
|
import type {
|
||||||
ServiceListResponse,
|
ServiceListResponse,
|
||||||
Service,
|
Service,
|
||||||
ServiceStatus,
|
|
||||||
DetailedServiceStatus,
|
DetailedServiceStatus,
|
||||||
ServiceManifest,
|
ServiceManifest,
|
||||||
ServiceInstallRequest,
|
ServiceInstallRequest,
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ export interface App {
|
|||||||
dependencies?: string[];
|
dependencies?: string[];
|
||||||
config?: Record<string, string>;
|
config?: Record<string, string>;
|
||||||
status?: AppStatus;
|
status?: AppStatus;
|
||||||
|
readme?: string;
|
||||||
|
documentation?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AppRequirement {
|
export interface AppRequirement {
|
||||||
@@ -38,6 +40,92 @@ export interface AppResources {
|
|||||||
storage?: string;
|
storage?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Enhanced types for app details with runtime status
|
||||||
|
export interface ContainerInfo {
|
||||||
|
name: string;
|
||||||
|
image: string;
|
||||||
|
ready: boolean;
|
||||||
|
restartCount: number;
|
||||||
|
state: string; // "running", "waiting", "terminated"
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PodInfo {
|
||||||
|
name: string;
|
||||||
|
status: string;
|
||||||
|
ready: string; // "1/1"
|
||||||
|
restarts: number;
|
||||||
|
age: string;
|
||||||
|
node: string;
|
||||||
|
ip: string;
|
||||||
|
containers?: ContainerInfo[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReplicaInfo {
|
||||||
|
desired: number;
|
||||||
|
current: number;
|
||||||
|
ready: number;
|
||||||
|
available: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResourceMetric {
|
||||||
|
used: string;
|
||||||
|
requested: string;
|
||||||
|
limit: string;
|
||||||
|
percentage: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResourceUsage {
|
||||||
|
cpu: ResourceMetric;
|
||||||
|
memory: ResourceMetric;
|
||||||
|
storage?: ResourceMetric;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KubernetesEvent {
|
||||||
|
type: string;
|
||||||
|
reason: string;
|
||||||
|
message: string;
|
||||||
|
timestamp: string;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RuntimeStatus {
|
||||||
|
pods: PodInfo[];
|
||||||
|
replicas?: ReplicaInfo;
|
||||||
|
resources?: ResourceUsage;
|
||||||
|
recentEvents?: KubernetesEvent[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppManifest {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
version: string;
|
||||||
|
category?: string;
|
||||||
|
icon?: string;
|
||||||
|
dependencies?: string[];
|
||||||
|
defaultConfig?: Record<string, unknown>;
|
||||||
|
requiredSecrets?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EnhancedApp {
|
||||||
|
name: string;
|
||||||
|
status: string;
|
||||||
|
version?: string;
|
||||||
|
namespace: string;
|
||||||
|
url?: string;
|
||||||
|
description?: string;
|
||||||
|
icon?: string;
|
||||||
|
manifest?: AppManifest;
|
||||||
|
config?: Record<string, string>;
|
||||||
|
runtime?: RuntimeStatus;
|
||||||
|
readme?: string;
|
||||||
|
documentation?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LogEntry {
|
||||||
|
pod: string;
|
||||||
|
logs: string[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface AppListResponse {
|
export interface AppListResponse {
|
||||||
apps: App[];
|
apps: App[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -11,6 +11,14 @@ export interface Node {
|
|||||||
maintenance?: boolean;
|
maintenance?: boolean;
|
||||||
configured?: boolean;
|
configured?: boolean;
|
||||||
applied?: boolean;
|
applied?: boolean;
|
||||||
|
// Active operation flags
|
||||||
|
configureInProgress?: boolean;
|
||||||
|
applyInProgress?: boolean;
|
||||||
|
// Optional runtime fields for enhanced status
|
||||||
|
isReachable?: boolean;
|
||||||
|
inKubernetes?: boolean;
|
||||||
|
kubernetesReady?: boolean;
|
||||||
|
lastHealthCheck?: string;
|
||||||
// Optional fields (not yet returned by API)
|
// Optional fields (not yet returned by API)
|
||||||
hardware?: HardwareInfo;
|
hardware?: HardwareInfo;
|
||||||
talosVersion?: string;
|
talosVersion?: string;
|
||||||
@@ -23,15 +31,19 @@ export interface HardwareInfo {
|
|||||||
disk?: string;
|
disk?: string;
|
||||||
manufacturer?: string;
|
manufacturer?: string;
|
||||||
model?: string;
|
model?: string;
|
||||||
|
// Hardware detection fields
|
||||||
|
ip?: string;
|
||||||
|
interface?: string;
|
||||||
|
interfaces?: string[];
|
||||||
|
disks?: Array<{ path: string; size: number }>;
|
||||||
|
selected_disk?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DiscoveredNode {
|
export interface DiscoveredNode {
|
||||||
ip: string;
|
ip: string;
|
||||||
hostname?: string;
|
hostname?: string;
|
||||||
maintenance_mode?: boolean;
|
maintenance_mode: boolean;
|
||||||
version?: string;
|
version?: string;
|
||||||
interface?: string;
|
|
||||||
disks?: string[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DiscoveryStatus {
|
export interface DiscoveryStatus {
|
||||||
@@ -50,6 +62,10 @@ export interface NodeAddRequest {
|
|||||||
target_ip: string;
|
target_ip: string;
|
||||||
role: 'controlplane' | 'worker';
|
role: 'controlplane' | 'worker';
|
||||||
disk?: string;
|
disk?: string;
|
||||||
|
current_ip?: string;
|
||||||
|
interface?: string;
|
||||||
|
schematic_id?: string;
|
||||||
|
maintenance?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NodeUpdateRequest {
|
export interface NodeUpdateRequest {
|
||||||
|
|||||||
@@ -1,3 +1,15 @@
|
|||||||
|
export interface BootstrapProgress {
|
||||||
|
current_step: number;
|
||||||
|
step_name: string;
|
||||||
|
attempt: number;
|
||||||
|
max_attempts: number;
|
||||||
|
step_description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OperationDetails {
|
||||||
|
bootstrap?: BootstrapProgress;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Operation {
|
export interface Operation {
|
||||||
id: string;
|
id: string;
|
||||||
instance_name: string;
|
instance_name: string;
|
||||||
@@ -9,6 +21,7 @@ export interface Operation {
|
|||||||
started: string;
|
started: string;
|
||||||
completed?: string;
|
completed?: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
|
details?: OperationDetails;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OperationListResponse {
|
export interface OperationListResponse {
|
||||||
|
|||||||
@@ -11,32 +11,31 @@ export interface VersionResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const utilitiesApi = {
|
export const utilitiesApi = {
|
||||||
async health(): Promise<HealthResponse> {
|
async health(instanceName: string): Promise<HealthResponse> {
|
||||||
return apiClient.get('/api/v1/utilities/health');
|
|
||||||
},
|
|
||||||
|
|
||||||
async instanceHealth(instanceName: string): Promise<HealthResponse> {
|
|
||||||
return apiClient.get(`/api/v1/instances/${instanceName}/utilities/health`);
|
return apiClient.get(`/api/v1/instances/${instanceName}/utilities/health`);
|
||||||
},
|
},
|
||||||
|
|
||||||
async getDashboardToken(): Promise<{ token: string }> {
|
async getDashboardToken(instanceName: string): Promise<{ token: string }> {
|
||||||
const response = await apiClient.get<{ data: { token: string }; success: boolean }>('/api/v1/utilities/dashboard/token');
|
const response = await apiClient.get<{ data: { token: string }; success: boolean }>(`/api/v1/instances/${instanceName}/utilities/dashboard/token`);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
async getNodeIPs(): Promise<{ ips: string[] }> {
|
async getNodeIPs(instanceName: string): Promise<{ ips: string[] }> {
|
||||||
return apiClient.get('/api/v1/utilities/nodes/ips');
|
return apiClient.get(`/api/v1/instances/${instanceName}/utilities/nodes/ips`);
|
||||||
},
|
},
|
||||||
|
|
||||||
async getControlPlaneIP(): Promise<{ ip: string }> {
|
async getControlPlaneIP(instanceName: string): Promise<{ ip: string }> {
|
||||||
return apiClient.get('/api/v1/utilities/controlplane/ip');
|
return apiClient.get(`/api/v1/instances/${instanceName}/utilities/controlplane/ip`);
|
||||||
},
|
},
|
||||||
|
|
||||||
async copySecret(secret: string, targetInstance: string): Promise<{ message: string }> {
|
async copySecret(instanceName: string, secret: string, sourceNamespace: string, destinationNamespace: string): Promise<{ message: string }> {
|
||||||
return apiClient.post(`/api/v1/utilities/secrets/${secret}/copy`, { target: targetInstance });
|
return apiClient.post(`/api/v1/instances/${instanceName}/utilities/secrets/${secret}/copy`, {
|
||||||
|
source_namespace: sourceNamespace,
|
||||||
|
destination_namespace: destinationNamespace
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
async getVersion(): Promise<VersionResponse> {
|
async getVersion(instanceName: string): Promise<VersionResponse> {
|
||||||
return apiClient.get('/api/v1/utilities/version');
|
return apiClient.get(`/api/v1/instances/${instanceName}/utilities/version`);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
129
src/test/utils/nodeFormTestUtils.tsx
Normal file
129
src/test/utils/nodeFormTestUtils.tsx
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
import { ReactNode } from 'react';
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import type { Node, HardwareInfo } from '../../services/api/types';
|
||||||
|
|
||||||
|
export function createTestQueryClient() {
|
||||||
|
return new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
retry: false,
|
||||||
|
gcTime: 0,
|
||||||
|
},
|
||||||
|
mutations: {
|
||||||
|
retry: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createWrapper(queryClient: QueryClient) {
|
||||||
|
return function TestWrapper({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
{children}
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createMockNode(overrides: Partial<Node> = {}): Node {
|
||||||
|
return {
|
||||||
|
hostname: 'test-control-1',
|
||||||
|
target_ip: '192.168.1.101',
|
||||||
|
role: 'controlplane',
|
||||||
|
current_ip: '192.168.1.50',
|
||||||
|
interface: 'eth0',
|
||||||
|
disk: '/dev/sda',
|
||||||
|
maintenance: true,
|
||||||
|
configured: false,
|
||||||
|
applied: false,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createMockNodes(count: number, role: 'controlplane' | 'worker' = 'controlplane'): Node[] {
|
||||||
|
return Array.from({ length: count }, (_, i) =>
|
||||||
|
createMockNode({
|
||||||
|
hostname: `test-${role === 'controlplane' ? 'control' : 'worker'}-${i + 1}`,
|
||||||
|
target_ip: `192.168.1.${100 + i + 1}`,
|
||||||
|
role,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createMockConfig(overrides: any = {}) {
|
||||||
|
return {
|
||||||
|
cluster: {
|
||||||
|
hostnamePrefix: 'test-',
|
||||||
|
nodes: {
|
||||||
|
control: {
|
||||||
|
vip: '192.168.1.100',
|
||||||
|
},
|
||||||
|
talos: {
|
||||||
|
schematicId: 'default-schematic-123',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createMockHardwareInfo(overrides: Partial<HardwareInfo> = {}): HardwareInfo {
|
||||||
|
return {
|
||||||
|
ip: '192.168.1.50',
|
||||||
|
interface: 'eth0',
|
||||||
|
interfaces: ['eth0', 'eth1'],
|
||||||
|
disks: [
|
||||||
|
{ path: '/dev/sda', size: 512000000000 },
|
||||||
|
{ path: '/dev/sdb', size: 1024000000000 },
|
||||||
|
],
|
||||||
|
selected_disk: '/dev/sda',
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mockUseInstanceConfig(config: any = null) {
|
||||||
|
return {
|
||||||
|
config,
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
updateConfig: vi.fn(),
|
||||||
|
isUpdating: false,
|
||||||
|
batchUpdate: vi.fn(),
|
||||||
|
isBatchUpdating: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mockUseNodes(nodes: Node[] = []) {
|
||||||
|
return {
|
||||||
|
nodes,
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
refetch: vi.fn(),
|
||||||
|
discover: vi.fn(),
|
||||||
|
isDiscovering: false,
|
||||||
|
discoverResult: undefined,
|
||||||
|
discoverError: null,
|
||||||
|
detect: vi.fn(),
|
||||||
|
isDetecting: false,
|
||||||
|
detectResult: undefined,
|
||||||
|
detectError: null,
|
||||||
|
getHardware: vi.fn(),
|
||||||
|
isGettingHardware: false,
|
||||||
|
getHardwareError: null,
|
||||||
|
addNode: vi.fn(),
|
||||||
|
isAdding: false,
|
||||||
|
addError: null,
|
||||||
|
updateNode: vi.fn(),
|
||||||
|
isUpdating: false,
|
||||||
|
deleteNode: vi.fn(),
|
||||||
|
isDeleting: false,
|
||||||
|
deleteError: null,
|
||||||
|
applyNode: vi.fn(),
|
||||||
|
isApplying: false,
|
||||||
|
fetchTemplates: vi.fn(),
|
||||||
|
isFetchingTemplates: false,
|
||||||
|
cancelDiscovery: vi.fn(),
|
||||||
|
isCancellingDiscovery: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -33,6 +33,7 @@ export interface CloudConfig {
|
|||||||
|
|
||||||
export interface TalosConfig {
|
export interface TalosConfig {
|
||||||
version: string;
|
version: string;
|
||||||
|
schematicId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NodesConfig {
|
export interface NodesConfig {
|
||||||
|
|||||||
41
src/types/nodeStatus.ts
Normal file
41
src/types/nodeStatus.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
export enum NodeStatus {
|
||||||
|
// Discovery Phase
|
||||||
|
DISCOVERED = "discovered",
|
||||||
|
|
||||||
|
// Configuration Phase
|
||||||
|
PENDING = "pending",
|
||||||
|
CONFIGURING = "configuring",
|
||||||
|
CONFIGURED = "configured",
|
||||||
|
|
||||||
|
// Deployment Phase
|
||||||
|
APPLYING = "applying",
|
||||||
|
PROVISIONING = "provisioning",
|
||||||
|
|
||||||
|
// Operational Phase
|
||||||
|
READY = "ready",
|
||||||
|
HEALTHY = "healthy",
|
||||||
|
|
||||||
|
// Maintenance States
|
||||||
|
MAINTENANCE = "maintenance",
|
||||||
|
REPROVISIONING = "reprovisioning",
|
||||||
|
|
||||||
|
// Error States
|
||||||
|
UNREACHABLE = "unreachable",
|
||||||
|
DEGRADED = "degraded",
|
||||||
|
FAILED = "failed",
|
||||||
|
|
||||||
|
// Special States
|
||||||
|
UNKNOWN = "unknown",
|
||||||
|
ORPHANED = "orphaned"
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StatusDesign {
|
||||||
|
status: NodeStatus;
|
||||||
|
color: string;
|
||||||
|
bgColor: string;
|
||||||
|
icon: string;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
nextAction?: string;
|
||||||
|
severity: "info" | "warning" | "error" | "success" | "neutral";
|
||||||
|
}
|
||||||
66
src/utils/deriveNodeStatus.ts
Normal file
66
src/utils/deriveNodeStatus.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import type { Node } from '../services/api/types';
|
||||||
|
import { NodeStatus } from '../types/nodeStatus';
|
||||||
|
|
||||||
|
export function deriveNodeStatus(node: Node): NodeStatus {
|
||||||
|
// Priority 1: Active operations
|
||||||
|
if (node.applyInProgress) {
|
||||||
|
return NodeStatus.APPLYING;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.configureInProgress) {
|
||||||
|
return NodeStatus.CONFIGURING;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Priority 2: Maintenance states
|
||||||
|
if (node.maintenance) {
|
||||||
|
if (node.applied) {
|
||||||
|
return NodeStatus.MAINTENANCE;
|
||||||
|
} else {
|
||||||
|
return NodeStatus.REPROVISIONING;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Priority 3: Error states
|
||||||
|
if (node.isReachable === false) {
|
||||||
|
return NodeStatus.UNREACHABLE;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Priority 4: Lifecycle progression
|
||||||
|
if (!node.configured) {
|
||||||
|
return NodeStatus.PENDING;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.configured && !node.applied) {
|
||||||
|
return NodeStatus.CONFIGURED;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.applied) {
|
||||||
|
// Check Kubernetes membership and readiness
|
||||||
|
if (node.inKubernetes === true && node.kubernetesReady === true) {
|
||||||
|
return NodeStatus.HEALTHY;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 (no cluster status data yet)
|
||||||
|
if (node.isReachable === undefined && node.inKubernetes === undefined) {
|
||||||
|
return NodeStatus.READY;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Applied but not reachable at all
|
||||||
|
if (node.isReachable === false) {
|
||||||
|
return NodeStatus.UNREACHABLE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback
|
||||||
|
return NodeStatus.UNKNOWN;
|
||||||
|
}
|
||||||
111
wild-web-app/src/components/apps/appUtils.tsx
Normal file
111
wild-web-app/src/components/apps/appUtils.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user