Compare commits
19 Commits
24965d2b88
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ebf3612c62 | ||
|
|
b324540ce0 | ||
|
|
6bbf48fe20 | ||
|
|
4307bc9996 | ||
|
|
35bc44bc32 | ||
|
|
a63519968e | ||
|
|
960282d4ed | ||
|
|
854a6023cd | ||
|
|
ee63423cab | ||
|
|
dfc7694fb9 | ||
|
|
2469acbc88 | ||
|
|
6f438901e0 | ||
|
|
35296b3bd2 | ||
|
|
1d2f0b7891 | ||
|
|
5260373fee | ||
|
|
684f29ba4f | ||
|
|
4cb8b11e59 | ||
|
|
fe226dafef | ||
|
|
f1a01f5ba4 |
@@ -146,10 +146,168 @@ pnpm dlx shadcn@latest add alert-dialog
|
||||
|
||||
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
|
||||
- Support light and dark mode with Tailwind's built-in dark mode support: https://tailwindcss.com/docs/dark-mode
|
||||
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.
|
||||
|
||||
#### 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
|
||||
|
||||
|
||||
@@ -16,12 +16,17 @@
|
||||
"check": "pnpm run lint && pnpm run type-check && pnpm run test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@heroicons/react": "^2.2.0",
|
||||
"@hookform/resolvers": "^5.1.1",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-collapsible": "^1.1.11",
|
||||
"@radix-ui/react-dialog": "^1.1.14",
|
||||
"@radix-ui/react-label": "^2.1.7",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.7",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.7",
|
||||
"@tailwindcss/vite": "^4.1.10",
|
||||
"@tanstack/react-query": "^5.62.10",
|
||||
@@ -31,16 +36,20 @@
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-hook-form": "^7.58.1",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router": "^7.9.4",
|
||||
"react-router-dom": "^7.9.4",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"tailwindcss": "^4.1.10",
|
||||
"zod": "^3.25.67"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.25.0",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/node": "^24.0.3",
|
||||
"@types/react": "^19.1.2",
|
||||
"@types/react-dom": "^19.1.2",
|
||||
|
||||
1075
pnpm-lock.yaml
generated
1075
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
@@ -8,12 +9,42 @@ import {
|
||||
} from "./ui/card";
|
||||
import { ConfigEditor } from "./ConfigEditor";
|
||||
import { Button, Input, Label } from "./ui";
|
||||
import { Check, Edit2, HelpCircle, X } from "lucide-react";
|
||||
import { Check, Edit2, HelpCircle, X, ExternalLink, Copy } from "lucide-react";
|
||||
import { useDashboardToken } from "../services/api/hooks/useUtilities";
|
||||
import { useInstance } from "../services/api";
|
||||
|
||||
export function Advanced() {
|
||||
const { instanceId } = useParams<{ instanceId: string }>();
|
||||
const [copied, setCopied] = useState(false);
|
||||
const { data: instance } = useInstance(instanceId || '');
|
||||
const { data: dashboardToken, isLoading: tokenLoading } = useDashboardToken(instanceId || '');
|
||||
|
||||
const [upstreamValue, setUpstreamValue] = useState("https://mywildcloud.org");
|
||||
const [editingUpstream, setEditingUpstream] = useState(false);
|
||||
const [tempUpstream, setTempUpstream] = useState(upstreamValue);
|
||||
|
||||
const handleCopyToken = async () => {
|
||||
if (dashboardToken?.token) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(dashboardToken.token);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch (err) {
|
||||
console.error('Failed to copy token:', err);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenDashboard = () => {
|
||||
// Build dashboard URL from instance config
|
||||
// Dashboard is available at: https://dashboard.{cloud.internalDomain}
|
||||
const internalDomain = instance?.config?.cloud?.internalDomain;
|
||||
const dashboardUrl = internalDomain
|
||||
? `https://dashboard.${internalDomain}`
|
||||
: 'https://dashboard.internal.wild.cloud';
|
||||
window.open(dashboardUrl, '_blank');
|
||||
};
|
||||
|
||||
const handleUpstreamEdit = () => {
|
||||
setTempUpstream(upstreamValue);
|
||||
setEditingUpstream(true);
|
||||
@@ -51,6 +82,47 @@ export function Advanced() {
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Kubernetes Dashboard Access */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Kubernetes Dashboard</CardTitle>
|
||||
<CardDescription>
|
||||
Access the Kubernetes dashboard for advanced cluster management
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center gap-3">
|
||||
<Button onClick={handleOpenDashboard} disabled={!instance}>
|
||||
<ExternalLink className="h-4 w-4 mr-2" />
|
||||
Open Dashboard
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleCopyToken}
|
||||
disabled={tokenLoading || !dashboardToken?.token}
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<Check className="h-4 w-4 mr-2" />
|
||||
Copied!
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="h-4 w-4 mr-2" />
|
||||
Copy Token
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
{instance?.config?.cloud?.internalDomain && (
|
||||
<p className="text-xs text-muted-foreground mt-3">
|
||||
Dashboard URL: https://dashboard.{instance.config.cloud.internalDomain}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Upstream Section */}
|
||||
<Card className="p-4 border-l-4 border-l-blue-500">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NavLink, useParams } from 'react-router';
|
||||
import { Server, Play, Container, AppWindow, Settings, CloudLightning, Sun, Moon, Monitor, ChevronDown, Globe, Wifi, HardDrive, Usb } from 'lucide-react';
|
||||
import { Server, Play, Container, AppWindow, Settings, CloudLightning, Sun, Moon, Monitor, ChevronDown, Globe, Usb, Download, CheckCircle } from 'lucide-react';
|
||||
import { cn } from '../lib/utils';
|
||||
import {
|
||||
Sidebar,
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
} from './ui/sidebar';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from './ui/collapsible';
|
||||
import { useTheme } from '../contexts/ThemeContext';
|
||||
import { InstanceSwitcher } from './InstanceSwitcher';
|
||||
|
||||
export function AppSidebar() {
|
||||
const { theme, setTheme } = useTheme();
|
||||
@@ -61,13 +62,31 @@ export function AppSidebar() {
|
||||
return (
|
||||
<Sidebar variant="sidebar" collapsible="icon">
|
||||
<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">
|
||||
<CloudLightning className="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
<div className="group-data-[collapsible=icon]:hidden">
|
||||
<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>
|
||||
</SidebarHeader>
|
||||
@@ -97,29 +116,6 @@ export function AppSidebar() {
|
||||
</NavLink>
|
||||
</SidebarMenuItem>
|
||||
|
||||
<SidebarMenuItem>
|
||||
<NavLink to={`/instances/${instanceId}/cloud`}>
|
||||
{({ isActive }) => (
|
||||
<SidebarMenuButton
|
||||
isActive={isActive}
|
||||
tooltip="Configure cloud settings and domains"
|
||||
>
|
||||
<div className={cn(
|
||||
"p-1 rounded-md",
|
||||
isActive && "bg-primary/10"
|
||||
)}>
|
||||
<CloudLightning className={cn(
|
||||
"h-4 w-4",
|
||||
isActive && "text-primary",
|
||||
!isActive && "text-muted-foreground"
|
||||
)} />
|
||||
</div>
|
||||
<span className="truncate">Cloud</span>
|
||||
</SidebarMenuButton>
|
||||
)}
|
||||
</NavLink>
|
||||
</SidebarMenuItem>
|
||||
|
||||
<Collapsible defaultOpen className="group/collapsible">
|
||||
<SidebarMenuItem>
|
||||
<CollapsibleTrigger asChild>
|
||||
@@ -174,6 +170,54 @@ export function AppSidebar() {
|
||||
</NavLink>
|
||||
</SidebarMenuSubButton>
|
||||
</SidebarMenuSubItem> */}
|
||||
</SidebarMenuSub>
|
||||
</CollapsibleContent>
|
||||
</SidebarMenuItem>
|
||||
</Collapsible>
|
||||
|
||||
<Collapsible defaultOpen className="group/collapsible">
|
||||
<SidebarMenuItem>
|
||||
<CollapsibleTrigger asChild>
|
||||
<SidebarMenuButton>
|
||||
<Container className="h-4 w-4" />
|
||||
Cluster
|
||||
<ChevronDown className="ml-auto transition-transform group-data-[state=open]/collapsible:rotate-180" />
|
||||
</SidebarMenuButton>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<SidebarMenuSub>
|
||||
<SidebarMenuSubItem>
|
||||
<SidebarMenuSubButton asChild>
|
||||
<NavLink to={`/instances/${instanceId}/control`}>
|
||||
<div className="p-1 rounded-md">
|
||||
<Cpu className="h-4 w-4" />
|
||||
</div>
|
||||
<span className="truncate">Control Nodes</span>
|
||||
</NavLink>
|
||||
</SidebarMenuSubButton>
|
||||
</SidebarMenuSubItem>
|
||||
|
||||
<SidebarMenuSubItem>
|
||||
<SidebarMenuSubButton asChild>
|
||||
<NavLink to={`/instances/${instanceId}/worker`}>
|
||||
<div className="p-1 rounded-md">
|
||||
<HardDrive className="h-4 w-4" />
|
||||
</div>
|
||||
<span className="truncate">Worker Nodes</span>
|
||||
</NavLink>
|
||||
</SidebarMenuSubButton>
|
||||
</SidebarMenuSubItem>
|
||||
|
||||
<SidebarMenuSubItem>
|
||||
<SidebarMenuSubButton asChild>
|
||||
<NavLink to={`/instances/${instanceId}/cluster`}>
|
||||
<div className="p-1 rounded-md">
|
||||
<Container className="h-4 w-4" />
|
||||
</div>
|
||||
<span className="truncate">Cluster Services</span>
|
||||
</NavLink>
|
||||
</SidebarMenuSubButton>
|
||||
</SidebarMenuSubItem>
|
||||
|
||||
<SidebarMenuSubItem>
|
||||
<SidebarMenuSubButton asChild>
|
||||
@@ -194,8 +238,8 @@ export function AppSidebar() {
|
||||
<SidebarMenuItem>
|
||||
<CollapsibleTrigger asChild>
|
||||
<SidebarMenuButton>
|
||||
<Container className="h-4 w-4" />
|
||||
Cluster
|
||||
<AppWindow className="h-4 w-4" />
|
||||
Apps
|
||||
<ChevronDown className="ml-auto transition-transform group-data-[state=open]/collapsible:rotate-180" />
|
||||
</SidebarMenuButton>
|
||||
</CollapsibleTrigger>
|
||||
@@ -203,22 +247,22 @@ export function AppSidebar() {
|
||||
<SidebarMenuSub>
|
||||
<SidebarMenuSubItem>
|
||||
<SidebarMenuSubButton asChild>
|
||||
<NavLink to={`/instances/${instanceId}/infrastructure`}>
|
||||
<NavLink to={`/instances/${instanceId}/apps/available`}>
|
||||
<div className="p-1 rounded-md">
|
||||
<Play className="h-4 w-4" />
|
||||
<Download className="h-4 w-4" />
|
||||
</div>
|
||||
<span className="truncate">Cluster Nodes</span>
|
||||
<span className="truncate">Available</span>
|
||||
</NavLink>
|
||||
</SidebarMenuSubButton>
|
||||
</SidebarMenuSubItem>
|
||||
|
||||
<SidebarMenuSubItem>
|
||||
<SidebarMenuSubButton asChild>
|
||||
<NavLink to={`/instances/${instanceId}/cluster`}>
|
||||
<NavLink to={`/instances/${instanceId}/apps/installed`}>
|
||||
<div className="p-1 rounded-md">
|
||||
<Container className="h-4 w-4" />
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
</div>
|
||||
<span className="truncate">Cluster Services</span>
|
||||
<span className="truncate">Installed</span>
|
||||
</NavLink>
|
||||
</SidebarMenuSubButton>
|
||||
</SidebarMenuSubItem>
|
||||
@@ -227,17 +271,6 @@ export function AppSidebar() {
|
||||
</SidebarMenuItem>
|
||||
</Collapsible>
|
||||
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton asChild tooltip="Install and manage applications">
|
||||
<NavLink to={`/instances/${instanceId}/apps`}>
|
||||
<div className="p-1 rounded-md">
|
||||
<AppWindow className="h-4 w-4" />
|
||||
</div>
|
||||
<span className="truncate">Apps</span>
|
||||
</NavLink>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton asChild tooltip="Advanced settings and system configuration">
|
||||
<NavLink to={`/instances/${instanceId}/advanced`}>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { useLocation } from 'react-router';
|
||||
import { Card } from './ui/card';
|
||||
import { Button } from './ui/button';
|
||||
import { Badge } from './ui/badge';
|
||||
@@ -20,18 +21,24 @@ import {
|
||||
Archive,
|
||||
RotateCcw,
|
||||
Settings,
|
||||
Eye,
|
||||
} from 'lucide-react';
|
||||
import { useInstanceContext } from '../hooks/useInstanceContext';
|
||||
import { useAvailableApps, useDeployedApps, useAppBackups } from '../hooks/useApps';
|
||||
import { BackupRestoreModal } from './BackupRestoreModal';
|
||||
import { AppConfigDialog } from './apps/AppConfigDialog';
|
||||
import { AppDetailModal } from './apps/AppDetailModal';
|
||||
import type { App } from '../services/api';
|
||||
|
||||
interface MergedApp extends App {
|
||||
deploymentStatus?: 'added' | 'deployed';
|
||||
url?: string;
|
||||
}
|
||||
|
||||
type TabView = 'available' | 'installed';
|
||||
|
||||
export function AppsComponent() {
|
||||
const location = useLocation();
|
||||
const { currentInstance } = useInstanceContext();
|
||||
const { data: availableAppsData, isLoading: loadingAvailable, error: availableError } = useAvailableApps();
|
||||
const {
|
||||
@@ -46,6 +53,8 @@ export function AppsComponent() {
|
||||
isDeleting
|
||||
} = useDeployedApps(currentInstance);
|
||||
|
||||
// Determine active tab from URL path
|
||||
const activeTab: TabView = location.pathname.endsWith('/installed') ? 'installed' : 'available';
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [selectedCategory, setSelectedCategory] = useState<string>('all');
|
||||
const [configDialogOpen, setConfigDialogOpen] = useState(false);
|
||||
@@ -53,6 +62,8 @@ export function AppsComponent() {
|
||||
const [backupModalOpen, setBackupModalOpen] = useState(false);
|
||||
const [restoreModalOpen, setRestoreModalOpen] = useState(false);
|
||||
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
|
||||
const {
|
||||
@@ -64,18 +75,27 @@ export function AppsComponent() {
|
||||
isRestoring,
|
||||
} = useAppBackups(currentInstance, selectedAppForBackup);
|
||||
|
||||
// Merge available and deployed apps
|
||||
// DeployedApps now includes status: 'added' | 'deployed'
|
||||
// Merge available and deployed apps with URL from deployment
|
||||
const applications: MergedApp[] = (availableAppsData?.apps || []).map(app => {
|
||||
const deployedApp = deployedApps.find(d => d.name === app.name);
|
||||
return {
|
||||
...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;
|
||||
|
||||
// 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) => {
|
||||
switch (status) {
|
||||
case 'running':
|
||||
@@ -88,6 +108,8 @@ export function AppsComponent() {
|
||||
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:
|
||||
@@ -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;
|
||||
|
||||
switch (action) {
|
||||
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);
|
||||
setConfigDialogOpen(true);
|
||||
break;
|
||||
@@ -170,19 +217,21 @@ export function AppsComponent() {
|
||||
setSelectedAppForBackup(app.name);
|
||||
setRestoreModalOpen(true);
|
||||
break;
|
||||
case 'view':
|
||||
setSelectedAppForDetail(app.name);
|
||||
setDetailModalOpen(true);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfigSave = (config: Record<string, string>) => {
|
||||
if (!selectedAppForConfig) return;
|
||||
|
||||
// Call addApp with the configuration
|
||||
addApp({
|
||||
name: selectedAppForConfig.name,
|
||||
config: config,
|
||||
});
|
||||
|
||||
// Close dialog
|
||||
setConfigDialogOpen(false);
|
||||
setSelectedAppForConfig(null);
|
||||
};
|
||||
@@ -199,15 +248,15 @@ export function AppsComponent() {
|
||||
|
||||
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()) ||
|
||||
app.description.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
const matchesCategory = selectedCategory === 'all' || app.category === selectedCategory;
|
||||
return matchesSearch && matchesCategory;
|
||||
});
|
||||
|
||||
const runningApps = applications.filter(app => app.status?.status === 'running').length;
|
||||
|
||||
// Show message if no instance is selected
|
||||
if (!currentInstance) {
|
||||
return (
|
||||
@@ -248,12 +297,12 @@ export function AppsComponent() {
|
||||
What are Apps in your Personal Cloud?
|
||||
</h3>
|
||||
<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
|
||||
(media server), Google Drive (file storage), or Gmail (email server) running on your own hardware.
|
||||
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.
|
||||
Instead of relying on big tech companies, you control your data and services.
|
||||
</p>
|
||||
<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.
|
||||
</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">
|
||||
@@ -288,14 +337,14 @@ export function AppsComponent() {
|
||||
className="w-full pl-10 pr-4 py-2 border rounded-lg bg-background"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex gap-2 overflow-x-auto">
|
||||
{categories.map(category => (
|
||||
<Button
|
||||
key={category}
|
||||
variant={selectedCategory === category ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setSelectedCategory(category)}
|
||||
className="capitalize"
|
||||
className="capitalize whitespace-nowrap"
|
||||
>
|
||||
{category}
|
||||
</Button>
|
||||
@@ -311,7 +360,7 @@ export function AppsComponent() {
|
||||
Loading apps...
|
||||
</span>
|
||||
) : (
|
||||
`${runningApps} applications running • ${applications.length} total available`
|
||||
`${runningApps} applications running • ${installedApps.length} installed • ${availableApps.length} available`
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -322,14 +371,45 @@ export function AppsComponent() {
|
||||
<Loader2 className="h-12 w-12 text-primary mx-auto mb-4 animate-spin" />
|
||||
<p className="text-muted-foreground">Loading applications...</p>
|
||||
</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) => (
|
||||
<Card key={app.name} className="p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="p-2 bg-muted rounded-lg">
|
||||
{getCategoryIcon(app.category)}
|
||||
</div>
|
||||
<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>
|
||||
@@ -338,10 +418,23 @@ export function AppsComponent() {
|
||||
{app.version}
|
||||
</Badge>
|
||||
)}
|
||||
{getStatusIcon(app.status?.status)}
|
||||
{getStatusIcon(app.status?.status || app.deploymentStatus)}
|
||||
</div>
|
||||
<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' && (
|
||||
<div className="space-y-1 text-xs text-muted-foreground">
|
||||
{app.status.namespace && (
|
||||
@@ -350,31 +443,14 @@ export function AppsComponent() {
|
||||
{app.status.replicas && (
|
||||
<div>Replicas: {app.status.replicas}</div>
|
||||
)}
|
||||
{app.status.resources && (
|
||||
<div>
|
||||
Resources: {app.status.resources.cpu} CPU, {app.status.resources.memory} RAM
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{app.status?.message && (
|
||||
<p className="text-xs text-muted-foreground mt-1">{app.status.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
{getStatusBadge(app)}
|
||||
<div className="flex flex-col gap-1">
|
||||
{/* Available: not added yet */}
|
||||
{!app.deploymentStatus && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleAppAction(app, 'configure')}
|
||||
disabled={isAdding}
|
||||
>
|
||||
{isAdding ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Configure & Add'}
|
||||
</Button>
|
||||
)}
|
||||
{/* Available: not added yet - shouldn't show here */}
|
||||
|
||||
{/* Added: in config but not deployed */}
|
||||
{app.deploymentStatus === 'added' && (
|
||||
@@ -408,6 +484,14 @@ export function AppsComponent() {
|
||||
{/* Deployed: running in Kubernetes */}
|
||||
{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' && (
|
||||
<>
|
||||
<Button
|
||||
@@ -455,7 +539,9 @@ export function AppsComponent() {
|
||||
<p className="text-muted-foreground mb-4">
|
||||
{searchTerm || selectedCategory !== 'all'
|
||||
? '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>
|
||||
</Card>
|
||||
@@ -498,6 +584,19 @@ export function AppsComponent() {
|
||||
onSave={handleConfigSave}
|
||||
isSaving={isAdding}
|
||||
/>
|
||||
|
||||
{/* App Detail Modal */}
|
||||
{selectedAppForDetail && currentInstance && (
|
||||
<AppDetailModal
|
||||
instanceName={currentInstance}
|
||||
appName={selectedAppForDetail}
|
||||
open={detailModalOpen}
|
||||
onClose={() => {
|
||||
setDetailModalOpen(false);
|
||||
setSelectedAppForDetail(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,15 +135,6 @@ export function CentralComponent() {
|
||||
</div>
|
||||
</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>
|
||||
|
||||
|
||||
@@ -20,23 +20,39 @@ interface CloudConfig {
|
||||
};
|
||||
}
|
||||
|
||||
interface ClusterConfig {
|
||||
endpointIp: string;
|
||||
hostnamePrefix?: string;
|
||||
nodes: {
|
||||
talos: {
|
||||
version: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export function CloudComponent() {
|
||||
const { currentInstance } = useInstanceContext();
|
||||
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 clusterConfig = fullConfig?.cluster as ClusterConfig | undefined;
|
||||
|
||||
const [editingDomains, setEditingDomains] = useState(false);
|
||||
const [editingNetwork, setEditingNetwork] = useState(false);
|
||||
const [editingCluster, setEditingCluster] = useState(false);
|
||||
const [formValues, setFormValues] = useState<CloudConfig | null>(null);
|
||||
const [clusterFormValues, setClusterFormValues] = useState<ClusterConfig | null>(null);
|
||||
|
||||
// Sync form values when config loads
|
||||
useEffect(() => {
|
||||
if (config && !formValues) {
|
||||
setFormValues(config as CloudConfig);
|
||||
}
|
||||
}, [config, formValues]);
|
||||
if (clusterConfig && !clusterFormValues) {
|
||||
setClusterFormValues(clusterConfig as ClusterConfig);
|
||||
}
|
||||
}, [config, clusterConfig, formValues, clusterFormValues]);
|
||||
|
||||
const handleDomainsEdit = () => {
|
||||
if (config) {
|
||||
@@ -106,6 +122,33 @@ export function CloudComponent() {
|
||||
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) => {
|
||||
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
|
||||
if (!currentInstance) {
|
||||
return (
|
||||
@@ -390,6 +462,120 @@ export function CloudComponent() {
|
||||
</div>
|
||||
)}
|
||||
</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>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -1,73 +1,124 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { Card } from './ui/card';
|
||||
import { Button } from './ui/button';
|
||||
import { Badge } from './ui/badge';
|
||||
import { Alert } from './ui/alert';
|
||||
import { Input } from './ui/input';
|
||||
import { Cpu, HardDrive, Network, Monitor, CheckCircle, AlertCircle, BookOpen, ExternalLink, Loader2 } from 'lucide-react';
|
||||
import { useInstanceContext } from '../hooks/useInstanceContext';
|
||||
import { useNodes, useDiscoveryStatus } from '../hooks/useNodes';
|
||||
import { useCluster } from '../hooks/useCluster';
|
||||
import { useClusterStatus } from '../services/api/hooks/useCluster';
|
||||
import { BootstrapModal } from './cluster/BootstrapModal';
|
||||
import { NodeStatusBadge } from './nodes/NodeStatusBadge';
|
||||
import { NodeFormDrawer } from './nodes/NodeFormDrawer';
|
||||
import type { NodeFormData } from './nodes/NodeForm';
|
||||
import type { Node, HardwareInfo, DiscoveredNode } from '../services/api/types';
|
||||
|
||||
export function ClusterNodesComponent() {
|
||||
interface ClusterNodesComponentProps {
|
||||
filterRole?: 'controlplane' | 'worker';
|
||||
hideDiscoveryWhenNodesGte?: number;
|
||||
showBootstrap?: boolean;
|
||||
}
|
||||
|
||||
export function ClusterNodesComponent({
|
||||
filterRole,
|
||||
hideDiscoveryWhenNodesGte,
|
||||
showBootstrap = true
|
||||
}: ClusterNodesComponentProps = {}) {
|
||||
const { currentInstance } = useInstanceContext();
|
||||
const {
|
||||
nodes,
|
||||
isLoading,
|
||||
error,
|
||||
addNode,
|
||||
isAdding,
|
||||
addError,
|
||||
deleteNode,
|
||||
isDeleting,
|
||||
deleteError,
|
||||
discover,
|
||||
isDiscovering,
|
||||
detect,
|
||||
isDetecting
|
||||
discoverError: discoverMutationError,
|
||||
getHardware,
|
||||
isGettingHardware,
|
||||
getHardwareError,
|
||||
cancelDiscovery,
|
||||
isCancellingDiscovery,
|
||||
updateNode,
|
||||
applyNode,
|
||||
isApplying,
|
||||
refetch
|
||||
} = useNodes(currentInstance);
|
||||
|
||||
const {
|
||||
data: discoveryStatus
|
||||
} = useDiscoveryStatus(currentInstance);
|
||||
|
||||
const [subnet, setSubnet] = useState('192.168.1.0/24');
|
||||
const {
|
||||
status: clusterStatus
|
||||
} = useCluster(currentInstance);
|
||||
|
||||
const getStatusIcon = (status?: string) => {
|
||||
switch (status) {
|
||||
case 'ready':
|
||||
case 'healthy':
|
||||
return <CheckCircle className="h-5 w-5 text-green-500" />;
|
||||
case 'error':
|
||||
return <AlertCircle className="h-5 w-5 text-red-500" />;
|
||||
case 'connecting':
|
||||
case 'provisioning':
|
||||
return <Loader2 className="h-5 w-5 text-blue-500 animate-spin" />;
|
||||
default:
|
||||
return <Monitor className="h-5 w-5 text-muted-foreground" />;
|
||||
const { data: clusterStatusData } = useClusterStatus(currentInstance || '');
|
||||
|
||||
const [addNodeIp, setAddNodeIp] = useState('');
|
||||
const [discoverError, setDiscoverError] = useState<string | null>(null);
|
||||
const [detectError, setDetectError] = useState<string | null>(null);
|
||||
const [discoverSuccess, setDiscoverSuccess] = useState<string | null>(null);
|
||||
const [showBootstrapModal, setShowBootstrapModal] = useState(false);
|
||||
const [bootstrapNode, setBootstrapNode] = useState<{ name: string; ip: string } | null>(null);
|
||||
const [drawerState, setDrawerState] = useState<{
|
||||
open: boolean;
|
||||
mode: 'add' | 'configure';
|
||||
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) => {
|
||||
const variants: Record<string, 'secondary' | 'default' | 'success' | 'destructive'> = {
|
||||
pending: 'secondary',
|
||||
connecting: 'default',
|
||||
provisioning: 'default',
|
||||
ready: 'success',
|
||||
healthy: 'success',
|
||||
error: 'destructive',
|
||||
};
|
||||
useEffect(() => {
|
||||
if (getHardwareError) {
|
||||
const errorMsg = (getHardwareError as any)?.message || 'Failed to detect hardware';
|
||||
setDetectError(errorMsg);
|
||||
}
|
||||
}, [getHardwareError]);
|
||||
|
||||
const labels: Record<string, string> = {
|
||||
pending: 'Pending',
|
||||
connecting: 'Connecting',
|
||||
provisioning: 'Provisioning',
|
||||
ready: 'Ready',
|
||||
healthy: 'Healthy',
|
||||
error: 'Error',
|
||||
};
|
||||
// Track previous discovery status to detect completion
|
||||
const [prevDiscoveryActive, setPrevDiscoveryActive] = useState<boolean | null>(null);
|
||||
|
||||
return (
|
||||
<Badge variant={variants[status || 'pending']}>
|
||||
{labels[status || 'pending'] || status}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
// Handle discovery completion (when active changes from true to false)
|
||||
useEffect(() => {
|
||||
const isActive = discoveryStatus?.active ?? false;
|
||||
|
||||
// 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) => {
|
||||
return role === 'controlplane' ? (
|
||||
@@ -77,30 +128,142 @@ export function ClusterNodesComponent() {
|
||||
);
|
||||
};
|
||||
|
||||
const handleAddNode = (ip: string, hostname: string, role: 'controlplane' | 'worker') => {
|
||||
if (!currentInstance) return;
|
||||
addNode({ target_ip: ip, hostname, role, disk: '/dev/sda' });
|
||||
const handleAddFromDiscovery = async (discovered: DiscoveredNode) => {
|
||||
// Fetch full hardware details for the discovered node
|
||||
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 (confirm(`Are you sure you want to remove node ${hostname}?`)) {
|
||||
deleteNode(hostname);
|
||||
if (confirm(`Reset and remove node ${hostname}?\n\nThis will reset the node and remove it from the cluster. The node will reboot to maintenance mode and can be reconfigured.`)) {
|
||||
setDeletingNodeHostname(hostname);
|
||||
try {
|
||||
await deleteNode(hostname);
|
||||
} finally {
|
||||
setDeletingNodeHostname(null);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleDiscover = () => {
|
||||
if (!currentInstance) return;
|
||||
discover(subnet);
|
||||
setDiscoverError(null);
|
||||
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
|
||||
const assignedNodes = nodes.map(node => {
|
||||
const assignedNodes = useMemo(() => {
|
||||
const allNodes = nodes.map(node => {
|
||||
// Get runtime status from cluster status
|
||||
const runtimeStatus = clusterStatusData?.node_statuses?.[node.hostname];
|
||||
|
||||
let status = 'pending';
|
||||
if (node.maintenance) {
|
||||
status = 'provisioning';
|
||||
@@ -109,11 +272,44 @@ export function ClusterNodesComponent() {
|
||||
} else if (node.applied) {
|
||||
status = 'ready';
|
||||
}
|
||||
return { ...node, status };
|
||||
|
||||
return {
|
||||
...node,
|
||||
status,
|
||||
isReachable: runtimeStatus?.ready,
|
||||
inKubernetes: runtimeStatus?.ready, // Whether in cluster (from backend 'ready' field)
|
||||
kubernetesReady: runtimeStatus?.kubernetes_ready, // Whether K8s Ready condition is true
|
||||
};
|
||||
});
|
||||
|
||||
// Extract IPs from discovered nodes
|
||||
const discoveredIps = discoveryStatus?.nodes_found?.map(n => n.ip) || [];
|
||||
// Filter by role if specified
|
||||
if (filterRole) {
|
||||
return allNodes.filter(node => node.role === filterRole);
|
||||
}
|
||||
return allNodes;
|
||||
}, [nodes, clusterStatusData, filterRole]);
|
||||
|
||||
// Check if cluster needs bootstrap
|
||||
const needsBootstrap = useMemo(() => {
|
||||
// Find first ready control plane node
|
||||
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
|
||||
if (!currentInstance) {
|
||||
@@ -155,12 +351,12 @@ export function ClusterNodesComponent() {
|
||||
What are Cluster Nodes?
|
||||
</h3>
|
||||
<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
|
||||
that contributes its processing power, memory, and storage to the overall cluster. Some nodes are "controllers"
|
||||
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"
|
||||
(like managers) that coordinate the work, while others are "workers" that do the heavy lifting.
|
||||
</p>
|
||||
<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.
|
||||
</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">
|
||||
@@ -171,6 +367,32 @@ export function ClusterNodesComponent() {
|
||||
</div>
|
||||
</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">
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<div className="p-2 bg-primary/10 rounded-lg">
|
||||
@@ -191,41 +413,165 @@ export function ClusterNodesComponent() {
|
||||
</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="flex items-center justify-between">
|
||||
<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>
|
||||
|
||||
{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="p-2 bg-muted rounded-lg">
|
||||
{getRoleIcon(node.role)}
|
||||
@@ -236,13 +582,17 @@ export function ClusterNodesComponent() {
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{node.role}
|
||||
</Badge>
|
||||
{getStatusIcon(node.status)}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground mb-2">
|
||||
IP: {node.target_ip}
|
||||
Target: {node.target_ip}
|
||||
</div>
|
||||
{node.disk && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Disk: {node.disk}
|
||||
</div>
|
||||
)}
|
||||
{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 && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Cpu className="h-3 w-3" />
|
||||
@@ -263,22 +613,48 @@ export function ClusterNodesComponent() {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{node.talosVersion && (
|
||||
{(node.version || node.schematic_id) && (
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
Talos: {node.talosVersion}
|
||||
{node.kubernetesVersion && ` • K8s: ${node.kubernetesVersion}`}
|
||||
{node.version && <span>Talos: {node.version}</span>}
|
||||
{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 className="flex items-center gap-3">
|
||||
{getStatusBadge(node.status)}
|
||||
<div className="flex flex-col gap-2">
|
||||
<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
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -295,78 +671,37 @@ export function ClusterNodesComponent() {
|
||||
</Card>
|
||||
)}
|
||||
</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 className="p-6">
|
||||
<h3 className="text-lg font-medium mb-4">PXE Boot Instructions</h3>
|
||||
<div className="space-y-3 text-sm">
|
||||
<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">
|
||||
1
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">Power on your nodes</p>
|
||||
<p className="text-muted-foreground">
|
||||
Ensure network boot (PXE) is enabled in BIOS/UEFI settings
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<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">
|
||||
2
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">Connect to the wild-cloud network</p>
|
||||
<p className="text-muted-foreground">
|
||||
Nodes will automatically receive IP addresses via DHCP
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
{/* Bootstrap Modal */}
|
||||
{showBootstrapModal && bootstrapNode && (
|
||||
<BootstrapModal
|
||||
instanceName={currentInstance!}
|
||||
nodeName={bootstrapNode.name}
|
||||
nodeIp={bootstrapNode.ip}
|
||||
onClose={() => {
|
||||
setShowBootstrapModal(false);
|
||||
setBootstrapNode(null);
|
||||
refetch();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Node Form Drawer - only render after first open to prevent infinite loop on initial mount */}
|
||||
{drawerEverOpened && (
|
||||
<NodeFormDrawer
|
||||
open={drawerState.open}
|
||||
onClose={closeDrawer}
|
||||
mode={drawerState.mode}
|
||||
node={drawerState.mode === 'configure' ? drawerState.node : undefined}
|
||||
detection={drawerState.detection}
|
||||
onSubmit={drawerState.mode === 'add' ? handleAddSubmit : handleConfigureSubmit}
|
||||
onApply={drawerState.mode === 'configure' ? handleApply : undefined}
|
||||
instanceName={currentInstance || ''}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { Card } from './ui/card';
|
||||
import { Button } from './ui/button';
|
||||
import { Badge } from './ui/badge';
|
||||
@@ -5,6 +6,9 @@ import { Container, Shield, Network, Database, CheckCircle, AlertCircle, Termina
|
||||
import { useInstanceContext } from '../hooks/useInstanceContext';
|
||||
import { useServices } from '../hooks/useServices';
|
||||
import type { Service } from '../services/api';
|
||||
import { ServiceDetailModal } from './services/ServiceDetailModal';
|
||||
import { ServiceConfigEditor } from './services/ServiceConfigEditor';
|
||||
import { Dialog, DialogContent } from './ui/dialog';
|
||||
|
||||
export function ClusterServicesComponent() {
|
||||
const { currentInstance } = useInstanceContext();
|
||||
@@ -20,10 +24,14 @@ export function ClusterServicesComponent() {
|
||||
isDeleting
|
||||
} = useServices(currentInstance);
|
||||
|
||||
const [selectedService, setSelectedService] = useState<string | null>(null);
|
||||
const [configService, setConfigService] = useState<string | null>(null);
|
||||
|
||||
const getStatusIcon = (status?: string) => {
|
||||
switch (status) {
|
||||
case 'running':
|
||||
case 'ready':
|
||||
case 'deployed':
|
||||
return <CheckCircle className="h-5 w-5 text-green-500" />;
|
||||
case 'error':
|
||||
return <AlertCircle className="h-5 w-5 text-red-500" />;
|
||||
@@ -36,19 +44,23 @@ export function ClusterServicesComponent() {
|
||||
};
|
||||
|
||||
const getStatusBadge = (service: Service) => {
|
||||
const status = service.status?.status || (service.deployed ? 'deployed' : 'available');
|
||||
// Handle both old format (status as string) and new format (status as object)
|
||||
const status = typeof service.status === 'string' ? service.status :
|
||||
service.status?.status || (service.deployed ? 'deployed' : 'available');
|
||||
|
||||
const variants: Record<string, 'secondary' | 'default' | 'success' | 'destructive' | 'outline'> = {
|
||||
'not-deployed': 'secondary',
|
||||
available: 'secondary',
|
||||
deploying: 'default',
|
||||
installing: 'default',
|
||||
running: 'success',
|
||||
ready: 'success',
|
||||
deployed: 'success',
|
||||
error: 'destructive',
|
||||
deployed: 'outline',
|
||||
};
|
||||
|
||||
const labels: Record<string, string> = {
|
||||
'not-deployed': 'Not Deployed',
|
||||
available: 'Available',
|
||||
deploying: 'Deploying',
|
||||
installing: 'Installing',
|
||||
@@ -59,7 +71,7 @@ export function ClusterServicesComponent() {
|
||||
};
|
||||
|
||||
return (
|
||||
<Badge variant={variants[status]}>
|
||||
<Badge variant={variants[status] || 'secondary'}>
|
||||
{labels[status] || status}
|
||||
</Badge>
|
||||
);
|
||||
@@ -210,16 +222,18 @@ export function ClusterServicesComponent() {
|
||||
{service.version}
|
||||
</Badge>
|
||||
)}
|
||||
{getStatusIcon(service.status?.status)}
|
||||
{getStatusIcon(typeof service.status === 'string' ? service.status : service.status?.status)}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{service.description}</p>
|
||||
{service.status?.message && (
|
||||
{typeof service.status === 'object' && service.status?.message && (
|
||||
<p className="text-xs text-muted-foreground mt-1">{service.status.message}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{getStatusBadge(service)}
|
||||
{!service.deployed && (
|
||||
{((typeof service.status === 'string' && service.status === 'not-deployed') ||
|
||||
(!service.status || service.status === 'not-deployed') ||
|
||||
(typeof service.status === 'object' && service.status?.status === 'not-deployed')) && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleInstallService(service.name)}
|
||||
@@ -228,15 +242,34 @@ export function ClusterServicesComponent() {
|
||||
{isInstalling ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Install'}
|
||||
</Button>
|
||||
)}
|
||||
{service.deployed && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={() => handleDeleteService(service.name)}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
{isDeleting ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Remove'}
|
||||
</Button>
|
||||
{((typeof service.status === 'string' && service.status === 'deployed') ||
|
||||
(typeof service.status === 'object' && service.status?.status === 'deployed')) && (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setSelectedService(service.name)}
|
||||
>
|
||||
View
|
||||
</Button>
|
||||
{service.hasConfig && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setConfigService(service.name)}
|
||||
>
|
||||
Configure
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={() => handleDeleteService(service.name)}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
{isDeleting ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Remove'}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -277,6 +310,31 @@ export function ClusterServicesComponent() {
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{selectedService && (
|
||||
<ServiceDetailModal
|
||||
instanceName={currentInstance}
|
||||
serviceName={selectedService}
|
||||
open={!!selectedService}
|
||||
onClose={() => setSelectedService(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{configService && (
|
||||
<Dialog open={!!configService} onOpenChange={(open) => !open && setConfigService(null)}>
|
||||
<DialogContent className="sm:max-w-4xl max-w-[95vw] max-h-[90vh] overflow-y-auto w-full">
|
||||
<ServiceConfigEditor
|
||||
instanceName={currentInstance}
|
||||
serviceName={configService}
|
||||
manifest={services.find(s => s.name === configService)}
|
||||
onClose={() => setConfigService(null)}
|
||||
onSuccess={() => {
|
||||
setConfigService(null);
|
||||
}}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -237,6 +237,22 @@ export const ConfigurationForm = () => {
|
||||
</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
|
||||
control={form.control}
|
||||
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) {
|
||||
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
|
||||
if (app.defaultConfig) {
|
||||
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 { DhcpComponent } from './DhcpComponent';
|
||||
export { PxeComponent } from './PxeComponent';
|
||||
export { ClusterNodesComponent } from './ClusterNodesComponent';
|
||||
export { ClusterServicesComponent } from './ClusterServicesComponent';
|
||||
export { AppsComponent } from './AppsComponent';
|
||||
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';
|
||||
|
||||
interface OperationProgressProps {
|
||||
instanceName: string;
|
||||
operationId: string;
|
||||
onComplete?: () => void;
|
||||
onError?: (error: string) => void;
|
||||
@@ -12,12 +13,13 @@ interface OperationProgressProps {
|
||||
}
|
||||
|
||||
export function OperationProgress({
|
||||
instanceName,
|
||||
operationId,
|
||||
onComplete,
|
||||
onError,
|
||||
showDetails = true
|
||||
}: OperationProgressProps) {
|
||||
const { operation, error, isLoading, cancel, isCancelling } = useOperation(operationId);
|
||||
const { operation, error, isLoading, cancel, isCancelling } = useOperation(instanceName, operationId);
|
||||
|
||||
// Handle operation completion
|
||||
if (operation?.status === 'completed' && onComplete) {
|
||||
|
||||
215
src/components/services/ServiceConfigEditor.tsx
Normal file
215
src/components/services/ServiceConfigEditor.tsx
Normal file
@@ -0,0 +1,215 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Loader2, Save } from 'lucide-react';
|
||||
import { useServiceConfig, useServiceStatus } from '@/hooks/useServices';
|
||||
import type { ServiceManifest } from '@/services/api/types';
|
||||
|
||||
interface ServiceConfigEditorProps {
|
||||
instanceName: string;
|
||||
serviceName: string;
|
||||
manifest?: ServiceManifest;
|
||||
onClose: () => void;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
export function ServiceConfigEditor({
|
||||
instanceName,
|
||||
serviceName,
|
||||
manifest: _manifest, // Ignore the prop, fetch from status instead
|
||||
onClose,
|
||||
onSuccess,
|
||||
}: ServiceConfigEditorProps) {
|
||||
// Suppress unused variable warning - kept for API compatibility
|
||||
void _manifest;
|
||||
const { config, isLoading: configLoading, updateConfig, isUpdating } = useServiceConfig(instanceName, serviceName);
|
||||
const { data: statusData, isLoading: statusLoading } = useServiceStatus(instanceName, serviceName);
|
||||
|
||||
// Use manifest from status endpoint which includes full serviceConfig
|
||||
const manifest = statusData?.manifest;
|
||||
const [formData, setFormData] = useState<Record<string, unknown>>({});
|
||||
const [redeploy, setRedeploy] = useState(true);
|
||||
const [fetch, setFetch] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState(false);
|
||||
|
||||
// Initialize form data when config loads
|
||||
useEffect(() => {
|
||||
if (config) {
|
||||
setFormData(config);
|
||||
}
|
||||
}, [config]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setSuccess(false);
|
||||
|
||||
try {
|
||||
await updateConfig({ config: formData, redeploy, fetch });
|
||||
setSuccess(true);
|
||||
if (onSuccess) {
|
||||
setTimeout(() => {
|
||||
onSuccess();
|
||||
}, 1500);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to update configuration');
|
||||
}
|
||||
};
|
||||
|
||||
const handleInputChange = (key: string, value: string) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[key]: value,
|
||||
}));
|
||||
};
|
||||
|
||||
const getDisplayValue = (value: unknown): string => {
|
||||
if (value === null || value === undefined) return '';
|
||||
if (typeof value === 'object') {
|
||||
return JSON.stringify(value, null, 4);
|
||||
}
|
||||
return String(value);
|
||||
};
|
||||
|
||||
const isObjectValue = (value: unknown): boolean => {
|
||||
return value !== null && value !== undefined && typeof value === 'object';
|
||||
};
|
||||
|
||||
const isLoading = configLoading || statusLoading;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Get configurable keys from serviceConfig definitions
|
||||
const configKeys = manifest?.serviceConfig
|
||||
? Object.keys(manifest.serviceConfig).map(key => manifest.serviceConfig![key].path)
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">Edit Service Configuration</h2>
|
||||
<p className="text-sm text-muted-foreground">{serviceName}</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-4 max-h-[60vh] overflow-y-auto pr-2">
|
||||
{configKeys.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-8">
|
||||
No configuration options available for this service.
|
||||
</p>
|
||||
) : (
|
||||
configKeys.map((key) => {
|
||||
const value = formData[key];
|
||||
const isObject = isObjectValue(value);
|
||||
|
||||
// Find the config definition for this path
|
||||
const configDef = manifest?.serviceConfig
|
||||
? Object.values(manifest.serviceConfig).find(def => def.path === key)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div key={key} className="space-y-2">
|
||||
<Label htmlFor={key}>
|
||||
{configDef?.prompt || key}
|
||||
{configDef?.default && (
|
||||
<span className="text-xs text-muted-foreground ml-2">
|
||||
(default: {configDef.default})
|
||||
</span>
|
||||
)}
|
||||
</Label>
|
||||
{isObject ? (
|
||||
<Textarea
|
||||
id={key}
|
||||
value={getDisplayValue(value)}
|
||||
onChange={(e) => handleInputChange(key, e.target.value)}
|
||||
placeholder={configDef?.default || ''}
|
||||
rows={5}
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
id={key}
|
||||
value={getDisplayValue(value)}
|
||||
onChange={(e) => handleInputChange(key, e.target.value)}
|
||||
placeholder={configDef?.default || ''}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
|
||||
<div className="space-y-2 pt-4 border-t">
|
||||
<div className="flex items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="redeploy-checkbox"
|
||||
checked={redeploy}
|
||||
onChange={(e) => setRedeploy(e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
<Label htmlFor="redeploy-checkbox" className="cursor-pointer">
|
||||
Redeploy service after updating configuration
|
||||
</Label>
|
||||
</div>
|
||||
{redeploy && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="fetch-checkbox"
|
||||
checked={fetch}
|
||||
onChange={(e) => setFetch(e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
<Label htmlFor="fetch-checkbox" className="cursor-pointer">
|
||||
Fetch fresh templates from directory before redeploying
|
||||
</Label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<div className="rounded-md bg-green-500/10 p-3 text-sm text-green-600 dark:text-green-400">
|
||||
Configuration updated successfully!
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-4 border-t">
|
||||
<Button type="button" variant="outline" onClick={onClose} disabled={isUpdating}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isUpdating}>
|
||||
{isUpdating ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
Save Changes
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
239
src/components/services/ServiceDetailModal.tsx
Normal file
239
src/components/services/ServiceDetailModal.tsx
Normal file
@@ -0,0 +1,239 @@
|
||||
import { useState } from 'react';
|
||||
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 { ServiceStatusBadge } from './ServiceStatusBadge';
|
||||
import { ServiceLogViewer } from './ServiceLogViewer';
|
||||
import { useServiceStatus } from '@/hooks/useServices';
|
||||
import { RefreshCw, FileText, Eye } from 'lucide-react';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
|
||||
interface ServiceDetailModalProps {
|
||||
instanceName: string;
|
||||
serviceName: string;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ServiceDetailModal({
|
||||
instanceName,
|
||||
serviceName,
|
||||
open,
|
||||
onClose,
|
||||
}: ServiceDetailModalProps) {
|
||||
const { data: status, isLoading, refetch } = useServiceStatus(instanceName, serviceName);
|
||||
const [viewMode, setViewMode] = useState<'details' | 'logs'>('details');
|
||||
|
||||
const getPodStatusColor = (status: string) => {
|
||||
if (status.toLowerCase().includes('running')) return 'text-green-600 dark:text-green-400';
|
||||
if (status.toLowerCase().includes('pending')) return 'text-yellow-600 dark:text-yellow-400';
|
||||
if (status.toLowerCase().includes('failed')) return 'text-red-600 dark:text-red-400';
|
||||
return 'text-muted-foreground';
|
||||
};
|
||||
|
||||
const getContainerNames = () => {
|
||||
// Return empty array - let the backend auto-detect the container
|
||||
// This avoids passing invalid container names like 'main' which don't exist
|
||||
return [];
|
||||
};
|
||||
|
||||
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">
|
||||
{serviceName}
|
||||
{status && <ServiceStatusBadge status={status.deploymentStatus} />}
|
||||
</DialogTitle>
|
||||
<DialogDescription>Service details and configuration</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* View Mode Selector */}
|
||||
<div className="flex gap-2 border-b pb-4">
|
||||
<Button
|
||||
variant={viewMode === 'details' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setViewMode('details')}
|
||||
>
|
||||
<Eye className="h-4 w-4 mr-2" />
|
||||
Details
|
||||
</Button>
|
||||
<Button
|
||||
variant={viewMode === 'logs' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setViewMode('logs')}
|
||||
>
|
||||
<FileText className="h-4 w-4 mr-2" />
|
||||
Logs
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Details View */}
|
||||
{viewMode === 'details' && (
|
||||
<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>
|
||||
) : status ? (
|
||||
<>
|
||||
{/* Status Section */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Status 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">Service Name</p>
|
||||
<p className="text-sm">{status.name}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Namespace</p>
|
||||
<p className="text-sm">{status.namespace}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{status.replicas && (
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground mb-2">Replicas</p>
|
||||
<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">{status.replicas.desired}</p>
|
||||
</div>
|
||||
<div className="bg-muted rounded p-2">
|
||||
<p className="text-xs text-muted-foreground">Current</p>
|
||||
<p className="font-semibold">{status.replicas.current}</p>
|
||||
</div>
|
||||
<div className="bg-muted rounded p-2">
|
||||
<p className="text-xs text-muted-foreground">Ready</p>
|
||||
<p className="font-semibold">{status.replicas.ready}</p>
|
||||
</div>
|
||||
<div className="bg-muted rounded p-2">
|
||||
<p className="text-xs text-muted-foreground">Available</p>
|
||||
<p className="font-semibold">{status.replicas.available}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Pods Section */}
|
||||
{status.pods && status.pods.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Pods</CardTitle>
|
||||
<CardDescription>{status.pods.length} pod(s)</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
{status.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>
|
||||
)}
|
||||
|
||||
{/* Configuration Preview */}
|
||||
{status.config && Object.keys(status.config).length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Current Configuration</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
{Object.entries(status.config).map(([key, value]) => (
|
||||
<div key={key} className="flex justify-between text-sm">
|
||||
<span className="font-medium text-muted-foreground">{key}:</span>
|
||||
<span className="font-mono text-xs">
|
||||
{typeof value === 'object' && value !== null
|
||||
? JSON.stringify(value, null, 2)
|
||||
: String(value)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p className="text-center text-muted-foreground py-8">No status information available</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Logs View */}
|
||||
{viewMode === 'logs' && (
|
||||
<ServiceLogViewer
|
||||
instanceName={instanceName}
|
||||
serviceName={serviceName}
|
||||
containers={getContainerNames()}
|
||||
onClose={() => setViewMode('details')}
|
||||
/>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
236
src/components/services/ServiceLogViewer.tsx
Normal file
236
src/components/services/ServiceLogViewer.tsx
Normal file
@@ -0,0 +1,236 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { servicesApi } from '@/services/api';
|
||||
import { Copy, Download, RefreshCw, X } from 'lucide-react';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
|
||||
interface ServiceLogViewerProps {
|
||||
instanceName: string;
|
||||
serviceName: string;
|
||||
containers?: string[];
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ServiceLogViewer({
|
||||
instanceName,
|
||||
serviceName,
|
||||
containers = [],
|
||||
onClose,
|
||||
}: ServiceLogViewerProps) {
|
||||
const [logs, setLogs] = useState<string[]>([]);
|
||||
const [follow, setFollow] = useState(false);
|
||||
const [tail, setTail] = useState(100);
|
||||
const [container, setContainer] = useState<string | undefined>(containers[0]);
|
||||
const [autoScroll, setAutoScroll] = useState(true);
|
||||
const logsEndRef = useRef<HTMLDivElement>(null);
|
||||
const logsContainerRef = useRef<HTMLDivElement>(null);
|
||||
const eventSourceRef = useRef<EventSource | null>(null);
|
||||
|
||||
// Scroll to bottom when logs change and autoScroll is enabled
|
||||
useEffect(() => {
|
||||
if (autoScroll && logsEndRef.current) {
|
||||
logsEndRef.current.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
}, [logs, autoScroll]);
|
||||
|
||||
// Fetch initial buffered logs
|
||||
const fetchLogs = useCallback(async () => {
|
||||
try {
|
||||
const url = servicesApi.getLogsUrl(instanceName, serviceName, tail, false, container);
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch logs: ${response.statusText}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
// API returns { lines: string[] }
|
||||
if (data.lines && Array.isArray(data.lines)) {
|
||||
setLogs(data.lines);
|
||||
} else {
|
||||
setLogs([]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching logs:', error);
|
||||
setLogs([`Error: ${error instanceof Error ? error.message : 'Failed to fetch logs'}`]);
|
||||
}
|
||||
}, [instanceName, serviceName, tail, container]);
|
||||
|
||||
// Set up SSE streaming when follow is enabled
|
||||
useEffect(() => {
|
||||
if (follow) {
|
||||
const url = servicesApi.getLogsUrl(instanceName, serviceName, tail, true, container);
|
||||
const eventSource = new EventSource(url);
|
||||
eventSourceRef.current = eventSource;
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
const line = event.data;
|
||||
if (line && line.trim() !== '') {
|
||||
setLogs((prev) => [...prev, line]);
|
||||
}
|
||||
};
|
||||
|
||||
eventSource.onerror = (error) => {
|
||||
console.error('SSE error:', error);
|
||||
eventSource.close();
|
||||
setFollow(false);
|
||||
};
|
||||
|
||||
return () => {
|
||||
eventSource.close();
|
||||
};
|
||||
} else {
|
||||
if (eventSourceRef.current) {
|
||||
eventSourceRef.current.close();
|
||||
eventSourceRef.current = null;
|
||||
}
|
||||
}
|
||||
}, [follow, instanceName, serviceName, tail, container]);
|
||||
|
||||
// Fetch initial logs on mount and when parameters change
|
||||
useEffect(() => {
|
||||
if (!follow) {
|
||||
fetchLogs();
|
||||
}
|
||||
}, [fetchLogs, follow]);
|
||||
|
||||
const handleCopyLogs = () => {
|
||||
const text = logs.join('\n');
|
||||
navigator.clipboard.writeText(text);
|
||||
};
|
||||
|
||||
const handleDownloadLogs = () => {
|
||||
const text = logs.join('\n');
|
||||
const blob = new Blob([text], { type: 'text/plain' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${serviceName}-logs.txt`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const handleClearLogs = () => {
|
||||
setLogs([]);
|
||||
};
|
||||
|
||||
const handleRefresh = () => {
|
||||
setLogs([]);
|
||||
fetchLogs();
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="flex flex-col h-full max-h-[80vh]">
|
||||
<CardHeader className="flex-shrink-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle>Service Logs: {serviceName}</CardTitle>
|
||||
<Button variant="ghost" size="sm" onClick={onClose}>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-4 mt-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label htmlFor="tail-select">Lines:</Label>
|
||||
<Select value={tail.toString()} onValueChange={(v) => setTail(Number(v))}>
|
||||
<SelectTrigger id="tail-select" className="w-24">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="50">50</SelectItem>
|
||||
<SelectItem value="100">100</SelectItem>
|
||||
<SelectItem value="200">200</SelectItem>
|
||||
<SelectItem value="500">500</SelectItem>
|
||||
<SelectItem value="1000">1000</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{containers.length > 1 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Label htmlFor="container-select">Container:</Label>
|
||||
<Select value={container} onValueChange={setContainer}>
|
||||
<SelectTrigger id="container-select" className="w-40">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{containers.map((c) => (
|
||||
<SelectItem key={c} value={c}>
|
||||
{c}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="follow-checkbox"
|
||||
checked={follow}
|
||||
onChange={(e) => setFollow(e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
<Label htmlFor="follow-checkbox">Follow</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="autoscroll-checkbox"
|
||||
checked={autoScroll}
|
||||
onChange={(e) => setAutoScroll(e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
<Label htmlFor="autoscroll-checkbox">Auto-scroll</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 ml-auto">
|
||||
<Button variant="outline" size="sm" onClick={handleRefresh} disabled={follow}>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
Refresh
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={handleCopyLogs}>
|
||||
<Copy className="h-4 w-4" />
|
||||
Copy
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={handleDownloadLogs}>
|
||||
<Download className="h-4 w-4" />
|
||||
Download
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={handleClearLogs}>
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="flex-1 overflow-hidden p-0">
|
||||
<div
|
||||
ref={logsContainerRef}
|
||||
className="h-full overflow-y-auto bg-slate-950 dark:bg-slate-900 p-4 font-mono text-xs text-green-400"
|
||||
>
|
||||
{logs.length === 0 ? (
|
||||
<div className="text-slate-500">No logs available</div>
|
||||
) : (
|
||||
logs.map((line, index) => (
|
||||
<div key={index} className="whitespace-pre-wrap break-all">
|
||||
{line}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
<div ref={logsEndRef} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
42
src/components/services/ServiceStatusBadge.tsx
Normal file
42
src/components/services/ServiceStatusBadge.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { CheckCircle, AlertCircle, Loader2, XCircle } from 'lucide-react';
|
||||
|
||||
interface ServiceStatusBadgeProps {
|
||||
status: 'Ready' | 'Progressing' | 'Degraded' | 'NotFound';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ServiceStatusBadge({ status, className }: ServiceStatusBadgeProps) {
|
||||
const statusConfig = {
|
||||
Ready: {
|
||||
variant: 'success' as const,
|
||||
icon: CheckCircle,
|
||||
label: 'Ready',
|
||||
},
|
||||
Progressing: {
|
||||
variant: 'warning' as const,
|
||||
icon: Loader2,
|
||||
label: 'Progressing',
|
||||
},
|
||||
Degraded: {
|
||||
variant: 'destructive' as const,
|
||||
icon: AlertCircle,
|
||||
label: 'Degraded',
|
||||
},
|
||||
NotFound: {
|
||||
variant: 'secondary' as const,
|
||||
icon: XCircle,
|
||||
label: 'Not Found',
|
||||
},
|
||||
};
|
||||
|
||||
const config = statusConfig[status];
|
||||
const Icon = config.icon;
|
||||
|
||||
return (
|
||||
<Badge variant={config.variant} className={className}>
|
||||
<Icon className={status === 'Progressing' ? 'animate-spin' : ''} />
|
||||
{config.label}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
4
src/components/services/index.ts
Normal file
4
src/components/services/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { ServiceStatusBadge } from './ServiceStatusBadge';
|
||||
export { ServiceLogViewer } from './ServiceLogViewer';
|
||||
export { ServiceConfigEditor } from './ServiceConfigEditor';
|
||||
export { ServiceDetailModal } from './ServiceDetailModal';
|
||||
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 { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } from './card';
|
||||
export { Badge, badgeVariants } from './badge';
|
||||
export { Alert, AlertTitle, AlertDescription } from './alert';
|
||||
export { Input } from './input';
|
||||
export { Label } from './label';
|
||||
export { Textarea } from './textarea';
|
||||
|
||||
@@ -8,8 +8,8 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
type={type}
|
||||
data-slot="input"
|
||||
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",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
"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:bg-transparent dark:focus-visible:bg-input/30",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
className
|
||||
)}
|
||||
|
||||
158
src/components/ui/select.tsx
Normal file
158
src/components/ui/select.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
import * as React from "react"
|
||||
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||
import { Check, ChevronDown, ChevronUp } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Select = SelectPrimitive.Root
|
||||
|
||||
const SelectGroup = SelectPrimitive.Group
|
||||
|
||||
const SelectValue = SelectPrimitive.Value
|
||||
|
||||
const SelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
))
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
|
||||
|
||||
const SelectScrollUpButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
))
|
||||
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
|
||||
|
||||
const SelectScrollDownButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
))
|
||||
SelectScrollDownButton.displayName =
|
||||
SelectPrimitive.ScrollDownButton.displayName
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = "popper", ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
))
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName
|
||||
|
||||
const SelectLabel = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SelectLabel.displayName = SelectPrimitive.Label.displayName
|
||||
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
))
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName
|
||||
|
||||
const SelectSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectGroup,
|
||||
SelectValue,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectLabel,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectScrollUpButton,
|
||||
SelectScrollDownButton,
|
||||
}
|
||||
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';
|
||||
|
||||
// Mock the API service
|
||||
vi.mock('../../services/api', () => ({
|
||||
vi.mock('../../services/api-legacy', () => ({
|
||||
apiService: {
|
||||
getConfig: 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(), {
|
||||
wrapper: createWrapper(),
|
||||
@@ -81,7 +81,7 @@ describe('useConfig', () => {
|
||||
message: 'No configuration found',
|
||||
};
|
||||
|
||||
vi.mocked(apiService.getConfig).mockResolvedValue(mockConfigResponse);
|
||||
(apiService.getConfig as ReturnType<typeof vi.fn>).mockResolvedValue(mockConfigResponse);
|
||||
|
||||
const { result } = renderHook(() => useConfig(), {
|
||||
wrapper: createWrapper(),
|
||||
@@ -122,8 +122,8 @@ describe('useConfig', () => {
|
||||
},
|
||||
};
|
||||
|
||||
vi.mocked(apiService.getConfig).mockResolvedValue(mockConfigResponse);
|
||||
vi.mocked(apiService.createConfig).mockResolvedValue(mockCreateResponse);
|
||||
(apiService.getConfig as ReturnType<typeof vi.fn>).mockResolvedValue(mockConfigResponse);
|
||||
(apiService.createConfig as ReturnType<typeof vi.fn>).mockResolvedValue(mockCreateResponse);
|
||||
|
||||
const { result } = renderHook(() => useConfig(), {
|
||||
wrapper: createWrapper(),
|
||||
@@ -149,7 +149,7 @@ describe('useConfig', () => {
|
||||
|
||||
it('should handle error when fetching config fails', async () => {
|
||||
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(), {
|
||||
wrapper: createWrapper(),
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useStatus } from '../useStatus';
|
||||
import { apiService } from '../../services/api-legacy';
|
||||
|
||||
// Mock the API service
|
||||
vi.mock('../../services/api', () => ({
|
||||
vi.mock('../../services/api-legacy', () => ({
|
||||
apiService: {
|
||||
getStatus: vi.fn(),
|
||||
},
|
||||
@@ -40,7 +40,7 @@ describe('useStatus', () => {
|
||||
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(), {
|
||||
wrapper: createWrapper(),
|
||||
@@ -60,7 +60,7 @@ describe('useStatus', () => {
|
||||
|
||||
it('should handle error when fetching status fails', async () => {
|
||||
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(), {
|
||||
wrapper: createWrapper(),
|
||||
@@ -82,7 +82,7 @@ describe('useStatus', () => {
|
||||
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(), {
|
||||
wrapper: createWrapper(),
|
||||
|
||||
@@ -108,3 +108,58 @@ export function useAppBackups(instanceName: string | null | undefined, appName:
|
||||
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({
|
||||
mutationFn: (subnet: string) => nodesApi.discover(instanceName!, subnet),
|
||||
mutationFn: (subnet?: string) => nodesApi.discover(instanceName!, subnet),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['instances', instanceName, 'discovery'] });
|
||||
},
|
||||
});
|
||||
|
||||
const detectMutation = useMutation({
|
||||
mutationFn: () => nodesApi.detect(instanceName!),
|
||||
mutationFn: (ip: string) => nodesApi.detect(instanceName!, ip),
|
||||
});
|
||||
|
||||
const addMutation = useMutation({
|
||||
@@ -24,6 +27,10 @@ export function useNodes(instanceName: string | null | undefined) {
|
||||
onSuccess: () => {
|
||||
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({
|
||||
@@ -39,16 +46,41 @@ export function useNodes(instanceName: string | null | undefined) {
|
||||
onSuccess: () => {
|
||||
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({
|
||||
mutationFn: (nodeName: string) => nodesApi.apply(instanceName!, nodeName),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['instances', instanceName, 'nodes'] });
|
||||
},
|
||||
});
|
||||
|
||||
const fetchTemplatesMutation = useMutation({
|
||||
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 {
|
||||
nodes: nodesQuery.data?.nodes || [],
|
||||
isLoading: nodesQuery.isLoading,
|
||||
@@ -57,19 +89,31 @@ export function useNodes(instanceName: string | null | undefined) {
|
||||
discover: discoverMutation.mutate,
|
||||
isDiscovering: discoverMutation.isPending,
|
||||
discoverResult: discoverMutation.data,
|
||||
discoverError: discoverMutation.error,
|
||||
detect: detectMutation.mutate,
|
||||
isDetecting: detectMutation.isPending,
|
||||
detectResult: detectMutation.data,
|
||||
detectError: detectMutation.error,
|
||||
getHardware: getHardwareMutation.mutateAsync,
|
||||
isGettingHardware: getHardwareMutation.isPending,
|
||||
getHardwareError: getHardwareMutation.error,
|
||||
addNode: addMutation.mutate,
|
||||
isAdding: addMutation.isPending,
|
||||
addError: addMutation.error,
|
||||
updateNode: updateMutation.mutate,
|
||||
isUpdating: updateMutation.isPending,
|
||||
deleteNode: deleteMutation.mutate,
|
||||
isDeleting: deleteMutation.isPending,
|
||||
deleteError: deleteMutation.error,
|
||||
applyNode: applyMutation.mutate,
|
||||
isApplying: applyMutation.isPending,
|
||||
fetchTemplates: fetchTemplatesMutation.mutate,
|
||||
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 [error, setError] = useState<Error | null>(null);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
useEffect(() => {
|
||||
if (!operationId) return;
|
||||
if (!instanceName || !operationId) return;
|
||||
|
||||
// Fetch initial state
|
||||
operationsApi.get(operationId).then(setOperation).catch(setError);
|
||||
operationsApi.get(instanceName, operationId).then(setOperation).catch(setError);
|
||||
|
||||
// Set up SSE stream
|
||||
const eventSource = operationsApi.createStream(operationId);
|
||||
const eventSource = operationsApi.createStream(instanceName, operationId);
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
try {
|
||||
@@ -54,14 +54,14 @@ export function useOperation(operationId: string | null | undefined) {
|
||||
return () => {
|
||||
eventSource.close();
|
||||
};
|
||||
}, [operationId, queryClient]);
|
||||
}, [instanceName, operationId, queryClient]);
|
||||
|
||||
const cancelMutation = useMutation({
|
||||
mutationFn: () => {
|
||||
if (!operation?.instance_name) {
|
||||
throw new Error('Cannot cancel operation: instance name not available');
|
||||
if (!instanceName || !operationId) {
|
||||
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: () => {
|
||||
// Operation state will be updated via SSE
|
||||
|
||||
@@ -74,6 +74,34 @@ export function useServiceStatus(instanceName: string | null | undefined, servic
|
||||
});
|
||||
}
|
||||
|
||||
export function useServiceConfig(instanceName: string | null | undefined, serviceName: string | null | undefined) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const configQuery = useQuery({
|
||||
queryKey: ['instances', instanceName, 'services', serviceName, 'config'],
|
||||
queryFn: () => servicesApi.getConfig(instanceName!, serviceName!),
|
||||
enabled: !!instanceName && !!serviceName,
|
||||
});
|
||||
|
||||
const updateConfigMutation = useMutation({
|
||||
mutationFn: (request: { config: Record<string, any>; redeploy?: boolean }) =>
|
||||
servicesApi.updateConfig(instanceName!, serviceName!, request),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['instances', instanceName, 'services', serviceName, 'config'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['instances', instanceName, 'services', serviceName, 'status'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['instances', instanceName, 'services'] });
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
config: configQuery.data,
|
||||
isLoading: configQuery.isLoading,
|
||||
error: configQuery.error,
|
||||
updateConfig: updateConfigMutation.mutateAsync,
|
||||
isUpdating: updateConfigMutation.isPending,
|
||||
};
|
||||
}
|
||||
|
||||
export function useServiceManifest(serviceName: string | null | undefined) {
|
||||
return useQuery({
|
||||
queryKey: ['services', serviceName, 'manifest'],
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@plugin "@tailwindcss/typography";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
|
||||
@@ -11,19 +11,17 @@ import {
|
||||
BookOpen,
|
||||
ExternalLink,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Usb,
|
||||
ArrowLeft,
|
||||
CloudLightning,
|
||||
} from 'lucide-react';
|
||||
import { useAssetList, useDownloadAsset, useAssetStatus } from '../../services/api/hooks/useAssets';
|
||||
import { assetsApi } from '../../services/api/assets';
|
||||
import type { AssetType } from '../../services/api/types/asset';
|
||||
|
||||
export function AssetsIsoPage() {
|
||||
const { data, isLoading, error } = useAssetList();
|
||||
const downloadAsset = useDownloadAsset();
|
||||
const [selectedSchematicId, setSelectedSchematicId] = useState<string | null>(null);
|
||||
const [selectedSchematicId] = useState<string | null>(null);
|
||||
const [selectedVersion, setSelectedVersion] = useState('v1.8.0');
|
||||
const { data: statusData } = useAssetStatus(selectedSchematicId);
|
||||
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '../../components/ui/card';
|
||||
import { Button } from '../../components/ui/button';
|
||||
import { Skeleton } from '../../components/ui/skeleton';
|
||||
import { ServiceCard } from '../../components/ServiceCard';
|
||||
import { Package, AlertTriangle, RefreshCw } from 'lucide-react';
|
||||
import { useBaseServices, useInstallService } from '../../hooks/useBaseServices';
|
||||
|
||||
export function BaseServicesPage() {
|
||||
const { instanceId } = useParams<{ instanceId: string }>();
|
||||
const { data: servicesData, isLoading, refetch } = useBaseServices(instanceId);
|
||||
const installMutation = useInstallService(instanceId);
|
||||
|
||||
const handleInstall = async (serviceName: string) => {
|
||||
await installMutation.mutateAsync({ name: serviceName });
|
||||
};
|
||||
|
||||
if (!instanceId) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-96">
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center gap-3 text-muted-foreground">
|
||||
<AlertTriangle className="h-5 w-5" />
|
||||
<p>No instance selected</p>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const services = servicesData?.services || [];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold tracking-tight">Base Services</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Manage essential cluster infrastructure services
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => refetch()} variant="outline" size="sm" disabled={isLoading}>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Package className="h-5 w-5" />
|
||||
Available Services
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Core infrastructure services for your Wild Cloud cluster
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
<Skeleton className="h-48 w-full" />
|
||||
<Skeleton className="h-48 w-full" />
|
||||
<Skeleton className="h-48 w-full" />
|
||||
<Skeleton className="h-48 w-full" />
|
||||
<Skeleton className="h-48 w-full" />
|
||||
<Skeleton className="h-48 w-full" />
|
||||
</div>
|
||||
) : services.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<Package className="h-12 w-12 mx-auto mb-3 opacity-50" />
|
||||
<p className="text-sm">No services available</p>
|
||||
<p className="text-xs mt-1">Base services will appear here once configured</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{services.map((service) => (
|
||||
<ServiceCard
|
||||
key={service.name}
|
||||
service={service}
|
||||
onInstall={() => handleInstall(service.name)}
|
||||
isInstalling={installMutation.isPending}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-blue-200 bg-blue-50 dark:bg-blue-950/20 dark:border-blue-900">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex gap-3">
|
||||
<Package className="h-5 w-5 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="font-medium text-blue-900 dark:text-blue-200">
|
||||
About Base Services
|
||||
</p>
|
||||
<p className="text-sm text-blue-800 dark:text-blue-300 mt-1">
|
||||
Base services provide essential infrastructure components for your cluster:
|
||||
</p>
|
||||
<ul className="text-sm text-blue-800 dark:text-blue-300 mt-2 space-y-1 list-disc list-inside">
|
||||
<li><strong>Cilium</strong> - Network connectivity and security</li>
|
||||
<li><strong>MetalLB</strong> - Load balancer for bare metal clusters</li>
|
||||
<li><strong>Traefik</strong> - Ingress controller and reverse proxy</li>
|
||||
<li><strong>Cert-Manager</strong> - Automatic TLS certificate management</li>
|
||||
<li><strong>External-DNS</strong> - Automatic DNS record management</li>
|
||||
</ul>
|
||||
<p className="text-sm text-blue-800 dark:text-blue-300 mt-2">
|
||||
Install these services to enable full cluster functionality.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -116,8 +116,8 @@ export function ClusterHealthPage() {
|
||||
<Skeleton className="h-8 w-24" />
|
||||
) : status ? (
|
||||
<div>
|
||||
<Badge variant={status.ready ? 'outline' : 'secondary'} className={status.ready ? 'border-green-500' : ''}>
|
||||
{status.ready ? 'Ready' : 'Not Ready'}
|
||||
<Badge variant={status.status === 'ready' ? 'outline' : 'secondary'} className={status.status === 'ready' ? 'border-green-500' : ''}>
|
||||
{status.status === 'ready' ? 'Ready' : 'Not Ready'}
|
||||
</Badge>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
{status.nodes} nodes total
|
||||
|
||||
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 className="text-xl font-bold font-mono">{status.kubernetesVersion}</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{status.ready ? 'Ready' : 'Not ready'}
|
||||
{status.status === 'ready' ? 'Ready' : 'Not ready'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -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';
|
||||
import { useAssetList, useDownloadAsset, useDeleteAsset } from '../../services/api/hooks/useAssets';
|
||||
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
|
||||
// Filename format: talos-v1.11.2-metal-amd64.iso
|
||||
function extractVersionFromPath(path: string): string {
|
||||
// Helper function to extract platform from filename
|
||||
// Filename format: metal-amd64.iso
|
||||
function extractPlatformFromPath(path: string): string {
|
||||
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';
|
||||
}
|
||||
|
||||
// Helper function to extract platform from ISO filename
|
||||
// Filename format: talos-v1.11.2-metal-amd64.iso
|
||||
function extractPlatformFromPath(path: string): string {
|
||||
const filename = path.split('/').pop() || '';
|
||||
const match = filename.match(/-(amd64|arm64)\.iso$/);
|
||||
return match ? match[1] : 'unknown';
|
||||
// Type for ISO asset with schematic and version info
|
||||
interface IsoAssetWithMetadata extends Asset {
|
||||
schematic_id: string;
|
||||
version: string;
|
||||
}
|
||||
|
||||
export function IsoPage() {
|
||||
@@ -38,8 +36,8 @@ export function IsoPage() {
|
||||
const downloadAsset = useDownloadAsset();
|
||||
const deleteAsset = useDeleteAsset();
|
||||
|
||||
const [schematicId, setSchematicId] = useState('');
|
||||
const [selectedVersion, setSelectedVersion] = useState('v1.11.2');
|
||||
const [schematicId, setSchematicId] = useState('434a0300db532066f1098e05ac068159371d00f0aba0a3103a0e826e83825c82');
|
||||
const [selectedVersion, setSelectedVersion] = useState('v1.11.5');
|
||||
const [selectedPlatform, setSelectedPlatform] = useState<Platform>('amd64');
|
||||
const [isDownloading, setIsDownloading] = useState(false);
|
||||
|
||||
@@ -53,10 +51,10 @@ export function IsoPage() {
|
||||
try {
|
||||
await downloadAsset.mutateAsync({
|
||||
schematicId,
|
||||
version: selectedVersion,
|
||||
request: {
|
||||
version: selectedVersion,
|
||||
platform: selectedPlatform,
|
||||
assets: ['iso']
|
||||
asset_types: ['iso']
|
||||
},
|
||||
});
|
||||
// Refresh the list after download
|
||||
@@ -69,13 +67,13 @@ export function IsoPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (schematicIdToDelete: string) => {
|
||||
if (!confirm('Are you sure you want to delete this schematic and all its assets? This action cannot be undone.')) {
|
||||
const handleDelete = async (schematicIdToDelete: string, versionToDelete: string) => {
|
||||
if (!confirm(`Are you sure you want to delete ${schematicIdToDelete}@${versionToDelete} and all its assets? This action cannot be undone.`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await deleteAsset.mutateAsync(schematicIdToDelete);
|
||||
await deleteAsset.mutateAsync({ schematicId: schematicIdToDelete, version: versionToDelete });
|
||||
await refetch();
|
||||
} catch (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)
|
||||
const isoAssets = data?.schematics
|
||||
.flatMap(schematic => {
|
||||
// Get ALL ISO assets for this schematic (not just the first one)
|
||||
const isoAssetsForSchematic = schematic.assets.filter(asset => asset.type === 'iso');
|
||||
return isoAssetsForSchematic.map(isoAsset => ({
|
||||
...isoAsset,
|
||||
schematic_id: schematic.schematic_id,
|
||||
version: schematic.version
|
||||
}));
|
||||
}) || [];
|
||||
// Find all ISO assets from all assets (schematic@version combinations)
|
||||
const isoAssets = data?.assets?.flatMap(asset => {
|
||||
// Get ALL ISO assets for this schematic@version
|
||||
const isoAssetsForAsset = asset.assets.filter(a => a.type === 'iso');
|
||||
return isoAssetsForAsset.map(isoAsset => ({
|
||||
...isoAsset,
|
||||
schematic_id: asset.schematic_id,
|
||||
version: asset.version
|
||||
}));
|
||||
}) || [];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -146,46 +143,6 @@ export function IsoPage() {
|
||||
</div>
|
||||
</CardHeader>
|
||||
<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 */}
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-2 block">Platform</label>
|
||||
@@ -215,6 +172,49 @@ export function IsoPage() {
|
||||
</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 */}
|
||||
<Button
|
||||
onClick={handleDownload}
|
||||
@@ -264,11 +264,12 @@ export function IsoPage() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{isoAssets.map((asset: any) => {
|
||||
const version = extractVersionFromPath(asset.path || '');
|
||||
{isoAssets.map((asset: IsoAssetWithMetadata) => {
|
||||
const platform = extractPlatformFromPath(asset.path || '');
|
||||
// Use composite key for React key
|
||||
const compositeKey = `${asset.schematic_id}@${asset.version}`;
|
||||
return (
|
||||
<Card key={asset.schematic_id} className="p-4">
|
||||
<Card key={compositeKey} className="p-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-2 bg-muted rounded-lg">
|
||||
<Disc className="h-5 w-5 text-primary" />
|
||||
@@ -276,7 +277,7 @@ export function IsoPage() {
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<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>
|
||||
{asset.downloaded ? (
|
||||
<Badge variant="success" className="flex items-center gap-1">
|
||||
@@ -292,7 +293,7 @@ export function IsoPage() {
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground space-y-1">
|
||||
<div className="font-mono text-xs truncate">
|
||||
Schematic: {asset.schematic_id}
|
||||
{asset.schematic_id}@{asset.version}
|
||||
</div>
|
||||
{asset.size && (
|
||||
<div>Size: {(asset.size / 1024 / 1024).toFixed(2)} MB</div>
|
||||
@@ -305,7 +306,7 @@ export function IsoPage() {
|
||||
size="sm"
|
||||
variant="outline"
|
||||
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" />
|
||||
@@ -315,7 +316,7 @@ export function IsoPage() {
|
||||
size="sm"
|
||||
variant="outline"
|
||||
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" />
|
||||
</Button>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { useParams } from 'react-router';
|
||||
import { UtilityCard, CopyableValue } from '../../components/UtilityCard';
|
||||
import { Button } from '../../components/ui/button';
|
||||
import {
|
||||
@@ -18,18 +19,25 @@ import {
|
||||
} from '../../services/api/hooks/useUtilities';
|
||||
|
||||
export function UtilitiesPage() {
|
||||
const { instanceId } = useParams<{ instanceId: string }>();
|
||||
const [secretToCopy, setSecretToCopy] = useState('');
|
||||
const [targetInstance, setTargetInstance] = useState('');
|
||||
const [sourceNamespace, setSourceNamespace] = useState('');
|
||||
const [destinationNamespace, setDestinationNamespace] = useState('');
|
||||
|
||||
const dashboardToken = useDashboardToken();
|
||||
const versions = useClusterVersions();
|
||||
const nodeIPs = useNodeIPs();
|
||||
const controlPlaneIP = useControlPlaneIP();
|
||||
const dashboardToken = useDashboardToken(instanceId || '');
|
||||
const versions = useClusterVersions(instanceId || '');
|
||||
const nodeIPs = useNodeIPs(instanceId || '');
|
||||
const controlPlaneIP = useControlPlaneIP(instanceId || '');
|
||||
const copySecret = useCopySecret();
|
||||
|
||||
const handleCopySecret = () => {
|
||||
if (secretToCopy && targetInstance) {
|
||||
copySecret.mutate({ secret: secretToCopy, targetInstance });
|
||||
if (secretToCopy && sourceNamespace && destinationNamespace && instanceId) {
|
||||
copySecret.mutate({
|
||||
instanceName: instanceId,
|
||||
secret: secretToCopy,
|
||||
sourceNamespace,
|
||||
destinationNamespace
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -130,7 +138,7 @@ export function UtilitiesPage() {
|
||||
{/* Secret Copy Utility */}
|
||||
<UtilityCard
|
||||
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" />}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
@@ -146,19 +154,31 @@ export function UtilitiesPage() {
|
||||
</div>
|
||||
<div>
|
||||
<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>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="e.g., production"
|
||||
value={targetInstance}
|
||||
onChange={(e) => setTargetInstance(e.target.value)}
|
||||
value={destinationNamespace}
|
||||
onChange={(e) => setDestinationNamespace(e.target.value)}
|
||||
className="w-full px-3 py-2 border rounded-lg bg-background"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleCopySecret}
|
||||
disabled={!secretToCopy || !targetInstance || copySecret.isPending}
|
||||
disabled={!secretToCopy || !sourceNamespace || !destinationNamespace || copySecret.isPending}
|
||||
className="w-full"
|
||||
>
|
||||
{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>
|
||||
);
|
||||
}
|
||||
@@ -8,7 +8,6 @@ import { OperationsPage } from './pages/OperationsPage';
|
||||
import { ClusterHealthPage } from './pages/ClusterHealthPage';
|
||||
import { ClusterAccessPage } from './pages/ClusterAccessPage';
|
||||
import { SecretsPage } from './pages/SecretsPage';
|
||||
import { BaseServicesPage } from './pages/BaseServicesPage';
|
||||
import { UtilitiesPage } from './pages/UtilitiesPage';
|
||||
import { CloudPage } from './pages/CloudPage';
|
||||
import { CentralPage } from './pages/CentralPage';
|
||||
@@ -16,9 +15,11 @@ import { DnsPage } from './pages/DnsPage';
|
||||
import { DhcpPage } from './pages/DhcpPage';
|
||||
import { PxePage } from './pages/PxePage';
|
||||
import { IsoPage } from './pages/IsoPage';
|
||||
import { InfrastructurePage } from './pages/InfrastructurePage';
|
||||
import { ControlNodesPage } from './pages/ControlNodesPage';
|
||||
import { WorkerNodesPage } from './pages/WorkerNodesPage';
|
||||
import { ClusterPage } from './pages/ClusterPage';
|
||||
import { AppsPage } from './pages/AppsPage';
|
||||
import { BackupsPage } from './pages/BackupsPage';
|
||||
import { AdvancedPage } from './pages/AdvancedPage';
|
||||
import { AssetsIsoPage } from './pages/AssetsIsoPage';
|
||||
import { AssetsPxePage } from './pages/AssetsPxePage';
|
||||
@@ -28,7 +29,6 @@ export const routes: RouteObject[] = [
|
||||
path: '/',
|
||||
element: <LandingPage />,
|
||||
},
|
||||
// Centralized asset routes (not under instance context)
|
||||
{
|
||||
path: '/iso',
|
||||
element: <AssetsIsoPage />,
|
||||
@@ -65,10 +65,6 @@ export const routes: RouteObject[] = [
|
||||
path: 'secrets',
|
||||
element: <SecretsPage />,
|
||||
},
|
||||
{
|
||||
path: 'services',
|
||||
element: <BaseServicesPage />,
|
||||
},
|
||||
{
|
||||
path: 'utilities',
|
||||
element: <UtilitiesPage />,
|
||||
@@ -98,8 +94,12 @@ export const routes: RouteObject[] = [
|
||||
element: <IsoPage />,
|
||||
},
|
||||
{
|
||||
path: 'infrastructure',
|
||||
element: <InfrastructurePage />,
|
||||
path: 'control',
|
||||
element: <ControlNodesPage />,
|
||||
},
|
||||
{
|
||||
path: 'worker',
|
||||
element: <WorkerNodesPage />,
|
||||
},
|
||||
{
|
||||
path: 'cluster',
|
||||
@@ -107,7 +107,24 @@ export const routes: RouteObject[] = [
|
||||
},
|
||||
{
|
||||
path: 'apps',
|
||||
element: <AppsPage />,
|
||||
children: [
|
||||
{
|
||||
index: true,
|
||||
element: <Navigate to="available" replace />,
|
||||
},
|
||||
{
|
||||
path: 'available',
|
||||
element: <AppsPage />,
|
||||
},
|
||||
{
|
||||
path: 'installed',
|
||||
element: <AppsPage />,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: 'backups',
|
||||
element: <BackupsPage />,
|
||||
},
|
||||
{
|
||||
path: 'advanced',
|
||||
|
||||
@@ -74,6 +74,7 @@ const nodesConfigSchema = z.object({
|
||||
// Cluster configuration schema
|
||||
const clusterConfigSchema = z.object({
|
||||
endpointIp: ipAddressSchema,
|
||||
hostnamePrefix: z.string().optional(),
|
||||
nodes: nodesConfigSchema,
|
||||
});
|
||||
|
||||
@@ -138,6 +139,7 @@ export const configFormSchema = z.object({
|
||||
(val) => ipAddressSchema.safeParse(val).success,
|
||||
'Must be a valid IP address'
|
||||
),
|
||||
hostnamePrefix: z.string().optional(),
|
||||
nodes: z.object({
|
||||
talos: z.object({
|
||||
version: z.string().min(1, 'Talos version is required').refine(
|
||||
@@ -175,6 +177,7 @@ export const defaultConfigValues: ConfigFormData = {
|
||||
},
|
||||
cluster: {
|
||||
endpointIp: '192.168.8.60',
|
||||
hostnamePrefix: '',
|
||||
nodes: {
|
||||
talos: {
|
||||
version: 'v1.8.0',
|
||||
|
||||
@@ -6,6 +6,10 @@ import type {
|
||||
AppAddResponse,
|
||||
AppStatus,
|
||||
OperationResponse,
|
||||
EnhancedApp,
|
||||
RuntimeStatus,
|
||||
LogEntry,
|
||||
KubernetesEvent,
|
||||
} from './types';
|
||||
|
||||
export const appsApi = {
|
||||
@@ -39,6 +43,33 @@ export const appsApi = {
|
||||
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
|
||||
async backup(instanceName: string, appName: string): Promise<OperationResponse> {
|
||||
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> {
|
||||
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 type { AssetListResponse, Schematic, DownloadAssetRequest, AssetStatusResponse } from './types/asset';
|
||||
import type { AssetListResponse, PXEAsset, DownloadAssetRequest, AssetStatusResponse } from './types/asset';
|
||||
|
||||
// Get API base URL
|
||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:5055';
|
||||
|
||||
export const assetsApi = {
|
||||
// List all schematics
|
||||
// List all assets (schematic@version combinations)
|
||||
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;
|
||||
},
|
||||
|
||||
// Get schematic details
|
||||
get: async (schematicId: string): Promise<Schematic> => {
|
||||
const response = await apiClient.get(`/api/v1/assets/${schematicId}`);
|
||||
return response as Schematic;
|
||||
// Get asset details for specific schematic@version
|
||||
get: async (schematicId: string, version: string): Promise<PXEAsset> => {
|
||||
const response = await apiClient.get(`/api/v1/pxe/assets/${schematicId}/${version}`);
|
||||
return response as PXEAsset;
|
||||
},
|
||||
|
||||
// Download assets for a schematic
|
||||
download: async (schematicId: string, request: DownloadAssetRequest): Promise<{ message: string }> => {
|
||||
const response = await apiClient.post(`/api/v1/assets/${schematicId}/download`, request);
|
||||
// Download assets for a schematic@version
|
||||
download: async (schematicId: string, version: string, request: DownloadAssetRequest): Promise<{ message: string }> => {
|
||||
const response = await apiClient.post(`/api/v1/pxe/assets/${schematicId}/${version}/download`, request);
|
||||
return response as { message: string };
|
||||
},
|
||||
|
||||
// Get download status
|
||||
status: async (schematicId: string): Promise<AssetStatusResponse> => {
|
||||
const response = await apiClient.get(`/api/v1/assets/${schematicId}/status`);
|
||||
status: async (schematicId: string, version: string): Promise<AssetStatusResponse> => {
|
||||
const response = await apiClient.get(`/api/v1/pxe/assets/${schematicId}/${version}/status`);
|
||||
return response as AssetStatusResponse;
|
||||
},
|
||||
|
||||
// Get download URL for an asset (includes base URL for direct download)
|
||||
getAssetUrl: (schematicId: string, assetType: 'kernel' | 'initramfs' | 'iso'): string => {
|
||||
return `${API_BASE_URL}/api/v1/assets/${schematicId}/pxe/${assetType}`;
|
||||
getAssetUrl: (schematicId: string, version: string, assetType: 'kernel' | 'initramfs' | 'iso'): string => {
|
||||
return `${API_BASE_URL}/api/v1/pxe/assets/${schematicId}/${version}/pxe/${assetType}`;
|
||||
},
|
||||
|
||||
// Delete a schematic and all its assets
|
||||
delete: async (schematicId: string): Promise<{ message: string }> => {
|
||||
const response = await apiClient.delete(`/api/v1/assets/${schematicId}`);
|
||||
// Delete an asset (schematic@version) and all its files
|
||||
delete: async (schematicId: string, version: string): Promise<{ message: string }> => {
|
||||
const response = await apiClient.delete(`/api/v1/pxe/assets/${schematicId}/${version}`);
|
||||
return response as { message: string };
|
||||
},
|
||||
};
|
||||
|
||||
@@ -13,8 +13,8 @@ export const clusterApi = {
|
||||
return apiClient.post(`/api/v1/instances/${instanceName}/cluster/config/generate`, config);
|
||||
},
|
||||
|
||||
async bootstrap(instanceName: string, node: string): Promise<OperationResponse> {
|
||||
return apiClient.post<OperationResponse>(`/api/v1/instances/${instanceName}/cluster/bootstrap`, { node });
|
||||
async bootstrap(instanceName: string, nodeName: string): Promise<OperationResponse> {
|
||||
return apiClient.post<OperationResponse>(`/api/v1/instances/${instanceName}/cluster/bootstrap`, { node_name: nodeName });
|
||||
},
|
||||
|
||||
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({
|
||||
queryKey: ['assets', schematicId],
|
||||
queryFn: () => assetsApi.get(schematicId!),
|
||||
enabled: !!schematicId,
|
||||
queryKey: ['assets', schematicId, version],
|
||||
queryFn: () => assetsApi.get(schematicId!, version!),
|
||||
enabled: !!schematicId && !!version,
|
||||
});
|
||||
}
|
||||
|
||||
export function useAssetStatus(schematicId: string | null | undefined) {
|
||||
export function useAssetStatus(schematicId: string | null | undefined, version: string | null | undefined) {
|
||||
return useQuery({
|
||||
queryKey: ['assets', schematicId, 'status'],
|
||||
queryFn: () => assetsApi.status(schematicId!),
|
||||
enabled: !!schematicId,
|
||||
queryKey: ['assets', schematicId, version, 'status'],
|
||||
queryFn: () => assetsApi.status(schematicId!, version!),
|
||||
enabled: !!schematicId && !!version,
|
||||
refetchInterval: (query) => {
|
||||
const data = query.state.data;
|
||||
// Poll every 2 seconds if downloading
|
||||
@@ -34,12 +34,12 @@ export function useDownloadAsset() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ schematicId, request }: { schematicId: string; request: DownloadAssetRequest }) =>
|
||||
assetsApi.download(schematicId, request),
|
||||
mutationFn: ({ schematicId, version, request }: { schematicId: string; version: string; request: DownloadAssetRequest }) =>
|
||||
assetsApi.download(schematicId, version, request),
|
||||
onSuccess: (_, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['assets'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['assets', variables.schematicId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['assets', variables.schematicId, 'status'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['assets', variables.schematicId, variables.version] });
|
||||
queryClient.invalidateQueries({ queryKey: ['assets', variables.schematicId, variables.version, 'status'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -48,11 +48,12 @@ export function useDeleteAsset() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (schematicId: string) => assetsApi.delete(schematicId),
|
||||
onSuccess: (_, schematicId) => {
|
||||
mutationFn: ({ schematicId, version }: { schematicId: string; version: string }) =>
|
||||
assetsApi.delete(schematicId, version),
|
||||
onSuccess: (_, { schematicId, version }) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['assets'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['assets', schematicId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['assets', schematicId, 'status'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['assets', schematicId, version] });
|
||||
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>({
|
||||
queryKey: ['operation', operationId],
|
||||
queryFn: () => operationsApi.get(operationId),
|
||||
enabled: !!operationId,
|
||||
queryKey: ['operation', instanceName, operationId],
|
||||
queryFn: () => operationsApi.get(instanceName, operationId),
|
||||
enabled: !!instanceName && !!operationId,
|
||||
refetchInterval: (query) => {
|
||||
// Stop polling if operation is completed, failed, or cancelled
|
||||
const status = query.state.data?.status;
|
||||
@@ -47,12 +47,12 @@ export const useCancelOperation = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ operationId, instanceName }: { operationId: string; instanceName: string }) =>
|
||||
operationsApi.cancel(operationId, instanceName),
|
||||
onSuccess: (_, { operationId }) => {
|
||||
mutationFn: ({ instanceName, operationId }: { instanceName: string; operationId: string }) =>
|
||||
operationsApi.cancel(instanceName, operationId),
|
||||
onSuccess: (_, { instanceName, operationId }) => {
|
||||
// Invalidate operation queries to refresh data
|
||||
queryClient.invalidateQueries({ queryKey: ['operation', operationId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['operations'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['operation', instanceName, operationId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['operations', instanceName] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,35 +1,39 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { utilitiesApi } from '../utilities';
|
||||
|
||||
export function useDashboardToken() {
|
||||
export function useDashboardToken(instanceName: string) {
|
||||
return useQuery({
|
||||
queryKey: ['utilities', 'dashboard', 'token'],
|
||||
queryFn: utilitiesApi.getDashboardToken,
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
queryKey: ['instances', instanceName, 'utilities', 'dashboard', 'token'],
|
||||
queryFn: () => utilitiesApi.getDashboardToken(instanceName),
|
||||
staleTime: 30 * 60 * 1000, // 30 minutes
|
||||
enabled: !!instanceName,
|
||||
});
|
||||
}
|
||||
|
||||
export function useClusterVersions() {
|
||||
export function useClusterVersions(instanceName: string) {
|
||||
return useQuery({
|
||||
queryKey: ['utilities', 'version'],
|
||||
queryFn: utilitiesApi.getVersion,
|
||||
queryKey: ['instances', instanceName, 'utilities', 'version'],
|
||||
queryFn: () => utilitiesApi.getVersion(instanceName),
|
||||
staleTime: 10 * 60 * 1000, // 10 minutes
|
||||
enabled: !!instanceName,
|
||||
});
|
||||
}
|
||||
|
||||
export function useNodeIPs() {
|
||||
export function useNodeIPs(instanceName: string) {
|
||||
return useQuery({
|
||||
queryKey: ['utilities', 'nodes', 'ips'],
|
||||
queryFn: utilitiesApi.getNodeIPs,
|
||||
queryKey: ['instances', instanceName, 'utilities', 'nodes', 'ips'],
|
||||
queryFn: () => utilitiesApi.getNodeIPs(instanceName),
|
||||
staleTime: 30 * 1000, // 30 seconds
|
||||
enabled: !!instanceName,
|
||||
});
|
||||
}
|
||||
|
||||
export function useControlPlaneIP() {
|
||||
export function useControlPlaneIP(instanceName: string) {
|
||||
return useQuery({
|
||||
queryKey: ['utilities', 'controlplane', 'ip'],
|
||||
queryFn: utilitiesApi.getControlPlaneIP,
|
||||
queryKey: ['instances', instanceName, 'utilities', 'controlplane', 'ip'],
|
||||
queryFn: () => utilitiesApi.getControlPlaneIP(instanceName),
|
||||
staleTime: 60 * 1000, // 1 minute
|
||||
enabled: !!instanceName,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -37,8 +41,12 @@ export function useCopySecret() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ secret, targetInstance }: { secret: string; targetInstance: string }) =>
|
||||
utilitiesApi.copySecret(secret, targetInstance),
|
||||
mutationFn: ({ instanceName, secret, sourceNamespace, destinationNamespace }: {
|
||||
instanceName: string;
|
||||
secret: string;
|
||||
sourceNamespace: string;
|
||||
destinationNamespace: string;
|
||||
}) => utilitiesApi.copySecret(instanceName, secret, sourceNamespace, destinationNamespace),
|
||||
onSuccess: () => {
|
||||
// Invalidate secrets queries
|
||||
queryClient.invalidateQueries({ queryKey: ['secrets'] });
|
||||
|
||||
@@ -35,18 +35,23 @@ export const nodesApi = {
|
||||
},
|
||||
|
||||
// Discovery
|
||||
async discover(instanceName: string, subnet: string): Promise<OperationResponse> {
|
||||
return apiClient.post(`/api/v1/instances/${instanceName}/nodes/discover`, { subnet });
|
||||
async discover(instanceName: string, subnet?: string): Promise<OperationResponse> {
|
||||
const body = subnet ? { subnet } : {};
|
||||
return apiClient.post(`/api/v1/instances/${instanceName}/nodes/discover`, body);
|
||||
},
|
||||
|
||||
async detect(instanceName: string): Promise<OperationResponse> {
|
||||
return apiClient.post(`/api/v1/instances/${instanceName}/nodes/detect`);
|
||||
async detect(instanceName: string, ip: string): Promise<HardwareInfo> {
|
||||
return apiClient.post(`/api/v1/instances/${instanceName}/nodes/detect`, { ip });
|
||||
},
|
||||
|
||||
async discoveryStatus(instanceName: string): Promise<DiscoveryStatus> {
|
||||
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> {
|
||||
return apiClient.get(`/api/v1/instances/${instanceName}/nodes/hardware/${ip}`);
|
||||
},
|
||||
@@ -54,4 +59,8 @@ export const nodesApi = {
|
||||
async fetchTemplates(instanceName: string): Promise<OperationResponse> {
|
||||
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`);
|
||||
},
|
||||
|
||||
async get(operationId: string, instanceName?: string): Promise<Operation> {
|
||||
const params = instanceName ? `?instance=${instanceName}` : '';
|
||||
return apiClient.get(`/api/v1/operations/${operationId}${params}`);
|
||||
async get(instanceName: string, operationId: string): Promise<Operation> {
|
||||
return apiClient.get(`/api/v1/instances/${instanceName}/operations/${operationId}`);
|
||||
},
|
||||
|
||||
async cancel(operationId: string, instanceName: string): Promise<{ message: string }> {
|
||||
return apiClient.post(`/api/v1/operations/${operationId}/cancel?instance=${instanceName}`);
|
||||
async cancel(instanceName: string, operationId: string): Promise<{ message: string }> {
|
||||
return apiClient.post(`/api/v1/instances/${instanceName}/operations/${operationId}/cancel`);
|
||||
},
|
||||
|
||||
// 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';
|
||||
return new EventSource(`${baseUrl}/api/v1/operations/${operationId}/stream`);
|
||||
return new EventSource(`${baseUrl}/api/v1/instances/${instanceName}/operations/${operationId}/stream`);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -2,9 +2,10 @@ import { apiClient } from './client';
|
||||
import type {
|
||||
ServiceListResponse,
|
||||
Service,
|
||||
ServiceStatus,
|
||||
DetailedServiceStatus,
|
||||
ServiceManifest,
|
||||
ServiceInstallRequest,
|
||||
ServiceConfigUpdateRequest,
|
||||
OperationResponse,
|
||||
} from './types';
|
||||
|
||||
@@ -30,12 +31,30 @@ export const servicesApi = {
|
||||
return apiClient.delete(`/api/v1/instances/${instanceName}/services/${serviceName}`);
|
||||
},
|
||||
|
||||
async getStatus(instanceName: string, serviceName: string): Promise<ServiceStatus> {
|
||||
async getStatus(instanceName: string, serviceName: string): Promise<DetailedServiceStatus> {
|
||||
return apiClient.get(`/api/v1/instances/${instanceName}/services/${serviceName}/status`);
|
||||
},
|
||||
|
||||
async getConfig(instanceName: string, serviceName: string): Promise<Record<string, unknown>> {
|
||||
return apiClient.get(`/api/v1/instances/${instanceName}/services/${serviceName}/config`);
|
||||
const response = await apiClient.get<{ config: Record<string, unknown> }>(
|
||||
`/api/v1/instances/${instanceName}/services/${serviceName}/config`
|
||||
);
|
||||
return response.config;
|
||||
},
|
||||
|
||||
async updateConfig(instanceName: string, serviceName: string, request: ServiceConfigUpdateRequest): Promise<OperationResponse> {
|
||||
return apiClient.patch(`/api/v1/instances/${instanceName}/services/${serviceName}/config`, request);
|
||||
},
|
||||
|
||||
// Service logs
|
||||
getLogsUrl(instanceName: string, serviceName: string, tail?: number, follow?: boolean, container?: string): string {
|
||||
const baseUrl = import.meta.env.VITE_API_BASE_URL || 'http://localhost:5055';
|
||||
const params = new URLSearchParams();
|
||||
if (tail) params.append('tail', tail.toString());
|
||||
if (follow) params.append('follow', 'true');
|
||||
if (container) params.append('container', container);
|
||||
const queryString = params.toString();
|
||||
return `${baseUrl}/api/v1/instances/${instanceName}/services/${serviceName}/logs${queryString ? '?' + queryString : ''}`;
|
||||
},
|
||||
|
||||
// Service lifecycle
|
||||
|
||||
@@ -10,6 +10,8 @@ export interface App {
|
||||
dependencies?: string[];
|
||||
config?: Record<string, string>;
|
||||
status?: AppStatus;
|
||||
readme?: string;
|
||||
documentation?: string;
|
||||
}
|
||||
|
||||
export interface AppRequirement {
|
||||
@@ -38,6 +40,92 @@ export interface AppResources {
|
||||
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 {
|
||||
apps: App[];
|
||||
}
|
||||
|
||||
@@ -10,8 +10,8 @@ export interface Asset {
|
||||
downloaded: boolean;
|
||||
}
|
||||
|
||||
// Schematic representation matching backend
|
||||
export interface Schematic {
|
||||
// PXEAsset represents a schematic@version combination (composite key)
|
||||
export interface PXEAsset {
|
||||
schematic_id: string;
|
||||
version: string;
|
||||
path: string;
|
||||
@@ -19,13 +19,12 @@ export interface Schematic {
|
||||
}
|
||||
|
||||
export interface AssetListResponse {
|
||||
schematics: Schematic[];
|
||||
assets: PXEAsset[];
|
||||
}
|
||||
|
||||
export interface DownloadAssetRequest {
|
||||
version: string;
|
||||
platform?: Platform;
|
||||
assets?: AssetType[];
|
||||
asset_types?: string[];
|
||||
force?: boolean;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,13 +4,21 @@ export interface ClusterConfig {
|
||||
version?: string;
|
||||
}
|
||||
|
||||
export interface ClusterStatus {
|
||||
export interface NodeStatus {
|
||||
hostname: string;
|
||||
ready: boolean;
|
||||
kubernetes_ready: boolean;
|
||||
role: string;
|
||||
}
|
||||
|
||||
export interface ClusterStatus {
|
||||
status: string; // "ready", "pending", "error", "not_bootstrapped", "unreachable", "degraded"
|
||||
nodes: number;
|
||||
controlPlaneNodes: number;
|
||||
workerNodes: number;
|
||||
kubernetesVersion?: string;
|
||||
talosVersion?: string;
|
||||
node_statuses?: Record<string, NodeStatus>;
|
||||
}
|
||||
|
||||
export interface HealthCheck {
|
||||
|
||||
@@ -11,6 +11,14 @@ export interface Node {
|
||||
maintenance?: boolean;
|
||||
configured?: 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)
|
||||
hardware?: HardwareInfo;
|
||||
talosVersion?: string;
|
||||
@@ -23,15 +31,19 @@ export interface HardwareInfo {
|
||||
disk?: string;
|
||||
manufacturer?: string;
|
||||
model?: string;
|
||||
// Hardware detection fields
|
||||
ip?: string;
|
||||
interface?: string;
|
||||
interfaces?: string[];
|
||||
disks?: Array<{ path: string; size: number }>;
|
||||
selected_disk?: string;
|
||||
}
|
||||
|
||||
export interface DiscoveredNode {
|
||||
ip: string;
|
||||
hostname?: string;
|
||||
maintenance_mode?: boolean;
|
||||
maintenance_mode: boolean;
|
||||
version?: string;
|
||||
interface?: string;
|
||||
disks?: string[];
|
||||
}
|
||||
|
||||
export interface DiscoveryStatus {
|
||||
@@ -50,6 +62,10 @@ export interface NodeAddRequest {
|
||||
target_ip: string;
|
||||
role: 'controlplane' | 'worker';
|
||||
disk?: string;
|
||||
current_ip?: string;
|
||||
interface?: string;
|
||||
schematic_id?: string;
|
||||
maintenance?: boolean;
|
||||
}
|
||||
|
||||
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 {
|
||||
id: string;
|
||||
instance_name: string;
|
||||
@@ -9,6 +21,7 @@ export interface Operation {
|
||||
started: string;
|
||||
completed?: string;
|
||||
error?: string;
|
||||
details?: OperationDetails;
|
||||
}
|
||||
|
||||
export interface OperationListResponse {
|
||||
|
||||
@@ -2,8 +2,10 @@ export interface Service {
|
||||
name: string;
|
||||
description: string;
|
||||
version?: string;
|
||||
status?: ServiceStatus;
|
||||
status?: ServiceStatus | string; // Can be either an object or a string like 'deployed', 'not-deployed'
|
||||
deployed?: boolean;
|
||||
namespace?: string;
|
||||
hasConfig?: boolean; // Whether service has configurable fields
|
||||
}
|
||||
|
||||
export interface ServiceStatus {
|
||||
@@ -13,17 +15,58 @@ export interface ServiceStatus {
|
||||
ready?: boolean;
|
||||
}
|
||||
|
||||
export interface PodStatus {
|
||||
name: string;
|
||||
status: string;
|
||||
ready: string;
|
||||
restarts: number;
|
||||
age: string;
|
||||
node?: string;
|
||||
ip?: string;
|
||||
}
|
||||
|
||||
export interface ReplicaStatus {
|
||||
desired: number;
|
||||
current: number;
|
||||
ready: number;
|
||||
available: number;
|
||||
}
|
||||
|
||||
export interface DetailedServiceStatus {
|
||||
name: string;
|
||||
namespace: string;
|
||||
deploymentStatus: 'Ready' | 'Progressing' | 'Degraded' | 'NotFound';
|
||||
replicas?: ReplicaStatus;
|
||||
pods?: PodStatus[];
|
||||
config?: Record<string, any>;
|
||||
manifest?: ServiceManifest;
|
||||
}
|
||||
|
||||
export interface ServiceListResponse {
|
||||
services: Service[];
|
||||
}
|
||||
|
||||
export interface ConfigDefinition {
|
||||
path: string;
|
||||
prompt: string;
|
||||
default: string;
|
||||
type?: string;
|
||||
}
|
||||
|
||||
export interface ServiceManifest {
|
||||
name: string;
|
||||
version: string;
|
||||
description: string;
|
||||
config: Record<string, unknown>;
|
||||
namespace?: string;
|
||||
configReferences?: string[];
|
||||
serviceConfig?: Record<string, ConfigDefinition>;
|
||||
}
|
||||
|
||||
export interface ServiceInstallRequest {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface ServiceConfigUpdateRequest {
|
||||
config: Record<string, any>;
|
||||
redeploy?: boolean;
|
||||
fetch?: boolean;
|
||||
}
|
||||
|
||||
@@ -11,31 +11,31 @@ export interface VersionResponse {
|
||||
}
|
||||
|
||||
export const utilitiesApi = {
|
||||
async health(): Promise<HealthResponse> {
|
||||
return apiClient.get('/api/v1/utilities/health');
|
||||
},
|
||||
|
||||
async instanceHealth(instanceName: string): Promise<HealthResponse> {
|
||||
async health(instanceName: string): Promise<HealthResponse> {
|
||||
return apiClient.get(`/api/v1/instances/${instanceName}/utilities/health`);
|
||||
},
|
||||
|
||||
async getDashboardToken(): Promise<{ token: string }> {
|
||||
return apiClient.get('/api/v1/utilities/dashboard/token');
|
||||
async getDashboardToken(instanceName: string): Promise<{ token: string }> {
|
||||
const response = await apiClient.get<{ data: { token: string }; success: boolean }>(`/api/v1/instances/${instanceName}/utilities/dashboard/token`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async getNodeIPs(): Promise<{ ips: string[] }> {
|
||||
return apiClient.get('/api/v1/utilities/nodes/ips');
|
||||
async getNodeIPs(instanceName: string): Promise<{ ips: string[] }> {
|
||||
return apiClient.get(`/api/v1/instances/${instanceName}/utilities/nodes/ips`);
|
||||
},
|
||||
|
||||
async getControlPlaneIP(): Promise<{ ip: string }> {
|
||||
return apiClient.get('/api/v1/utilities/controlplane/ip');
|
||||
async getControlPlaneIP(instanceName: string): Promise<{ ip: string }> {
|
||||
return apiClient.get(`/api/v1/instances/${instanceName}/utilities/controlplane/ip`);
|
||||
},
|
||||
|
||||
async copySecret(secret: string, targetInstance: string): Promise<{ message: string }> {
|
||||
return apiClient.post(`/api/v1/utilities/secrets/${secret}/copy`, { target: targetInstance });
|
||||
async copySecret(instanceName: string, secret: string, sourceNamespace: string, destinationNamespace: string): Promise<{ message: string }> {
|
||||
return apiClient.post(`/api/v1/instances/${instanceName}/utilities/secrets/${secret}/copy`, {
|
||||
source_namespace: sourceNamespace,
|
||||
destination_namespace: destinationNamespace
|
||||
});
|
||||
},
|
||||
|
||||
async getVersion(): Promise<VersionResponse> {
|
||||
return apiClient.get('/api/v1/utilities/version');
|
||||
async getVersion(instanceName: string): Promise<VersionResponse> {
|
||||
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 {
|
||||
version: string;
|
||||
schematicId?: string;
|
||||
}
|
||||
|
||||
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