Compare commits

...

10 Commits

Author SHA1 Message Date
Paul Payne
dfc7694fb9 Refactor subnet discovery logic to allow auto-detection and improve error handling 2025-11-04 17:17:01 +00:00
Paul Payne
2469acbc88 Makes cluster-nodes functional. 2025-11-04 16:44:11 +00:00
Paul Payne
6f438901e0 Makes app status more resilient. 2025-10-22 23:18:14 +00:00
Paul Payne
35296b3bd2 Major update to Apps page. Add instance switcher. 2025-10-22 22:28:02 +00:00
Paul Payne
1d2f0b7891 Instance-namespace various endpoints and services. 2025-10-14 21:05:53 +00:00
Paul Payne
5260373fee Fix dashboard token button. 2025-10-14 18:54:23 +00:00
Paul Payne
684f29ba4f Lint fixes. 2025-10-14 07:32:13 +00:00
Paul Payne
4cb8b11e59 Adds kubernetes dashboard access to advanced page. 2025-10-14 07:14:41 +00:00
Paul Payne
fe226dafef Service config. Service logs. Service status. 2025-10-14 05:28:24 +00:00
Paul Payne
f1a01f5ba4 Fix cluster service page. 2025-10-13 12:23:21 +00:00
63 changed files with 7727 additions and 452 deletions

View File

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

View File

@@ -16,10 +16,12 @@
"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-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-tooltip": "^1.2.7",
@@ -31,6 +33,7 @@
"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",
"tailwind-merge": "^3.3.1",
@@ -39,8 +42,10 @@
},
"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",

913
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -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 } 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,15 +62,17 @@ 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">
<InstanceSwitcher />
</div>
</SidebarHeader>
<SidebarContent>

View File

@@ -20,17 +20,22 @@ 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 { currentInstance } = useInstanceContext();
const { data: availableAppsData, isLoading: loadingAvailable, error: availableError } = useAvailableApps();
@@ -46,6 +51,7 @@ export function AppsComponent() {
isDeleting
} = useDeployedApps(currentInstance);
const [activeTab, setActiveTab] = useState<TabView>('available');
const [searchTerm, setSearchTerm] = useState('');
const [selectedCategory, setSelectedCategory] = useState<string>('all');
const [configDialogOpen, setConfigDialogOpen] = useState(false);
@@ -53,6 +59,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 +72,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 +105,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 +164,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 +214,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 +245,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 +294,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">
@@ -277,6 +323,22 @@ export function AppsComponent() {
</div>
</div>
{/* Tab Navigation */}
<div className="flex gap-2 mb-6 border-b pb-4">
<Button
variant={activeTab === 'available' ? 'default' : 'outline'}
onClick={() => setActiveTab('available')}
>
Available Apps ({availableApps.length})
</Button>
<Button
variant={activeTab === 'installed' ? 'default' : 'outline'}
onClick={() => setActiveTab('installed')}
>
Installed Apps ({installedApps.length})
</Button>
</div>
<div className="flex flex-col sm:flex-row gap-4 mb-6">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
@@ -288,14 +350,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 +373,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 +384,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 +431,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 +456,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 +497,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 +552,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 +597,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>
);
}
}

View File

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

View File

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

View File

@@ -1,10 +1,18 @@
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 { 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() {
const { currentInstance } = useInstanceContext();
@@ -13,61 +21,91 @@ export function ClusterNodesComponent() {
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 [discoverSubnet, setDiscoverSubnet] = useState('');
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 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,9 +115,103 @@ 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);
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 handleAddNode = async () => {
if (!addNodeIp) return;
try {
const hardware = await getHardware(addNodeIp);
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);
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)
setDrawerState({
open: true,
mode: 'configure',
node,
});
};
const handleAddSubmit = async (data: NodeFormData) => {
await addNode({
hostname: data.hostname,
role: data.role,
disk: data.disk,
target_ip: data.targetIp,
current_ip: data.currentIp,
interface: data.interface,
schematic_id: data.schematicId,
maintenance: data.maintenance,
});
closeDrawer();
setAddNodeIp('');
};
const handleConfigureSubmit = async (data: NodeFormData) => {
if (!drawerState.node) return;
await updateNode({
nodeName: drawerState.node.hostname,
updates: {
role: data.role,
config: {
disk: data.disk,
target_ip: data.targetIp,
current_ip: data.currentIp,
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 = (hostname: string) => {
@@ -90,14 +222,12 @@ export function ClusterNodesComponent() {
};
const handleDiscover = () => {
if (!currentInstance) return;
discover(subnet);
setDiscoverError(null);
setDiscoverSuccess(null);
// Pass subnet only if it's not empty, otherwise auto-detect
discover(discoverSubnet || undefined);
};
const handleDetect = () => {
if (!currentInstance) return;
detect();
};
// Derive status from backend state flags for each node
const assignedNodes = nodes.map(node => {
@@ -112,8 +242,25 @@ export function ClusterNodesComponent() {
return { ...node, status };
});
// Extract IPs from discovered nodes
const discoveredIps = discoveryStatus?.nodes_found?.map(n => n.ip) || [];
// 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
const hasBootstrapped = clusterStatus?.status !== 'not_bootstrapped' && clusterStatus?.status !== undefined;
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 +302,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 +318,32 @@ export function ClusterNodesComponent() {
</div>
</Card>
{/* Bootstrap Alert */}
{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 +364,177 @@ 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>
)}
{/* DISCOVERY SECTION - Scan subnet for nodes */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6 mb-6">
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-4">
Discover Nodes on Network
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
Scan a specific subnet or leave empty to auto-detect all local networks
</p>
<div className="flex gap-3 mb-4">
<Input
type="text"
value={discoverSubnet}
onChange={(e) => setDiscoverSubnet(e.target.value)}
placeholder="192.168.1.0/24 (optional - leave empty to auto-detect)"
className="flex-1"
/>
<Button
onClick={handleDiscover}
disabled={isDiscovering || discoveryStatus?.active}
>
{isDiscovering || discoveryStatus?.active ? (
<>
<Loader2 className="h-4 w-4 animate-spin mr-2" />
Discovering...
</>
) : (
'Discover'
)}
</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>
{discoveryStatus?.nodes_found && discoveryStatus.nodes_found.length > 0 && (
<div className="mt-6">
<h4 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
Discovered {discoveryStatus.nodes_found.length} node(s)
</h4>
<div className="space-y-3">
{discoveryStatus.nodes_found.map((discovered) => (
<div key={discovered.ip} className="border border-gray-300 dark:border-gray-600 rounded-lg p-4">
<div className="flex items-center justify-between">
<div>
<p className="font-medium font-mono text-gray-900 dark:text-gray-100">{discovered.ip}</p>
<p className="text-sm text-gray-600 dark:text-gray-400">
Maintenance mode{discovered.version ? ` • Talos ${discovered.version}` : ''}
</p>
{discovered.hostname && (
<p className="text-xs text-gray-500 dark:text-gray-500 mt-1">{discovered.hostname}</p>
)}
</div>
<Button
onClick={() => handleAddFromDiscovery(discovered)}
size="sm"
>
Add to Cluster
</Button>
</div>
</div>
))}
</div>
</div>
)}
</div>
{/* ADD NODE SECTION - Add single node by IP */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6 mb-6">
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-4">
Add Single Node
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
Add a node by IP address to detect hardware and configure
</p>
<div className="flex gap-3">
<Input
type="text"
value={addNodeIp}
onChange={(e) => setAddNodeIp(e.target.value)}
placeholder="192.168.8.128"
className="flex-1"
/>
<Button
onClick={handleAddNode}
disabled={isGettingHardware}
variant="secondary"
>
{isGettingHardware ? (
<>
<Loader2 className="h-4 w-4 animate-spin mr-2" />
Detecting...
</>
) : (
'Add Node'
)}
</Button>
</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 +545,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" />
@@ -270,15 +583,30 @@ export function ClusterNodesComponent() {
</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}
>
{isDeleting ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Remove'}
{isDeleting ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Delete'}
</Button>
</div>
</div>
@@ -295,78 +623,35 @@ 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 */}
<NodeFormDrawer
open={drawerState.open}
onClose={closeDrawer}
mode={drawerState.mode}
node={drawerState.mode === 'configure' ? drawerState.node : undefined}
detection={drawerState.detection}
onSubmit={drawerState.mode === 'add' ? handleAddSubmit : handleConfigureSubmit}
onApply={drawerState.mode === 'configure' ? handleApply : undefined}
instanceName={currentInstance || ''}
/>
</div>
);
}

View File

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

View File

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

View 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>
</>
);
}

View File

@@ -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]) => {

View 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: ({node, inline, className, 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: ({node, 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -0,0 +1,3 @@
export { BootstrapModal } from './BootstrapModal';
export { BootstrapProgress } from './BootstrapProgress';
export { TroubleshootingPanel } from './TroubleshootingPanel';

View 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>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,605 @@
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;
currentIp?: string;
interface?: string;
schematicId?: string;
maintenance: boolean;
}
interface NodeFormProps {
initialValues?: Partial<NodeFormData>;
detection?: HardwareInfo;
onSubmit: (data: NodeFormData) => Promise<void>;
onApply?: (data: NodeFormData) => Promise<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 || '', // Don't auto-fill targetIp from detection
currentIp: initial?.currentIp || detection?.ip || '', // Auto-fill from detection
interface: defaultInterface,
schematicId: initial?.schematicId || '',
maintenance: initial?.maintenance ?? true,
};
}
export function NodeForm({
initialValues,
detection,
onSubmit,
onApply,
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 initialValues change (e.g., switching to configure a different node)
// This ensures select boxes and all fields show the current values
// Use a ref to track the hostname to avoid infinite loops from object reference changes
const prevHostnameRef = useRef<string | undefined>(undefined);
useEffect(() => {
const currentHostname = initialValues?.hostname;
// Only reset if the hostname actually changed (switching between nodes)
if (currentHostname !== prevHostnameRef.current) {
prevHostnameRef.current = currentHostname;
const newValues = getInitialValues(initialValues, detection, nodes, hostnamePrefix);
reset(newValues);
}
}, [initialValues, detection, nodes, hostnamePrefix, reset]);
// 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);
}
}
}, [nodes, initialValues?.role, setValue, watch]);
// Pre-populate schematic ID from cluster config if available
useEffect(() => {
if (!schematicId && instanceConfig?.cluster?.nodes?.talos?.schematicId) {
setValue('schematicId', instanceConfig.cluster.nodes.talos.schematicId);
}
}, [instanceConfig, schematicId, setValue]);
// 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);
}
}
}
}, [role, nodes, hostnamePrefix, setValue, watch, isExistingNode]);
// Auto-calculate target IP for control plane nodes
useEffect(() => {
// Skip if this is an existing node (configure mode)
if (initialValues?.targetIp) return;
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') {
// For new worker nodes, clear target IP (let user set if needed)
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', '');
}
}
}
}, [role, instanceConfig, nodes, setValue, watch, initialValues?.targetIp]);
// 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 }) => (
<Select value={field.value || ''} onValueChange={field.onChange}>
<SelectTrigger className="mt-1">
<SelectValue placeholder="Select a disk" />
</SelectTrigger>
<SelectContent>
{diskOptions.map((disk) => (
<SelectItem key={disk.path} value={disk.path}>
{disk.path}
{disk.size > 0 && ` (${formatBytes(disk.size)})`}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
) : (
<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="currentIp">Current IP Address</Label>
<Input
id="currentIp"
type="text"
{...register('currentIp')}
className="mt-1"
disabled={!!detection?.ip}
/>
{errors.currentIp && (
<p className="text-sm text-red-600 mt-1">{errors.currentIp.message}</p>
)}
{detection?.ip && (
<p className="mt-1 text-xs text-muted-foreground">
Auto-detected from hardware (read-only)
</p>
)}
</div>
<div>
<Label htmlFor="interface">Network Interface</Label>
{interfaceOptions.length > 0 ? (
<Controller
name="interface"
control={control}
render={({ field }) => (
<Select value={field.value || ''} onValueChange={field.onChange}>
<SelectTrigger className="mt-1">
<SelectValue placeholder="Select interface..." />
</SelectTrigger>
<SelectContent>
{interfaceOptions.map((iface) => (
<SelectItem key={iface} value={iface}>
{iface}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
) : (
<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 items-center gap-2">
<input
id="maintenance"
type="checkbox"
{...register('maintenance')}
className="h-4 w-4 rounded border-input"
/>
<Label htmlFor="maintenance" className="font-normal">
Start in maintenance mode
</Label>
</div>
<div className="flex gap-2">
<Button
type="submit"
disabled={isSubmitting}
className="flex-1"
>
{isSubmitting ? 'Saving...' : submitLabel}
</Button>
{showApplyButton && onApply && (
<Button
type="button"
onClick={handleSubmit(onApply)}
disabled={isSubmitting}
variant="secondary"
className="flex-1"
>
{isSubmitting ? 'Applying...' : 'Apply Configuration'}
</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]}`;
}

View 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('');
});
});
});

View 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,
currentIp: node.current_ip,
interface: node.interface,
schematicId: node.schematic_id,
maintenance: node.maintenance ?? true,
} : undefined}
detection={detection}
onSubmit={onSubmit}
onApply={onApply}
submitLabel={mode === 'add' ? 'Add Node' : 'Save'}
showApplyButton={mode === 'configure'}
instanceName={instanceName}
/>
</Drawer>
);
}

View 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>
);
}

View File

@@ -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) {

View File

@@ -0,0 +1,213 @@
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: _manifestProp, // Ignore the prop, fetch from status instead
onClose,
onSuccess,
}: ServiceConfigEditorProps) {
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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -0,0 +1,4 @@
export { ServiceStatusBadge } from './ServiceStatusBadge';
export { ServiceLogViewer } from './ServiceLogViewer';
export { ServiceConfigEditor } from './ServiceConfigEditor';
export { ServiceDetailModal } from './ServiceDetailModal';

View 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 };

View 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>
</>
);
}

View File

@@ -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';

View File

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

View 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,
}

161
src/config/nodeStatus.ts Normal file
View 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"
}
};

View File

@@ -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(),

View File

@@ -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(),

View File

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

View File

@@ -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,6 +46,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 delete node:', error);
},
});
const applyMutation = useMutation({
@@ -49,6 +60,17 @@ export function useNodes(instanceName: string | null | undefined) {
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),
});
return {
nodes: nodesQuery.data?.nodes || [],
isLoading: nodesQuery.isLoading,
@@ -57,19 +79,28 @@ 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,
};
}

View File

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

View File

@@ -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'],

View File

@@ -1,5 +1,6 @@
@import "tailwindcss";
@import "tw-animate-css";
@plugin "@tailwindcss/typography";
@custom-variant dark (&:is(.dark *));

View File

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

View File

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

View File

@@ -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';
@@ -28,7 +27,6 @@ export const routes: RouteObject[] = [
path: '/',
element: <LandingPage />,
},
// Centralized asset routes (not under instance context)
{
path: '/iso',
element: <AssetsIsoPage />,
@@ -65,10 +63,6 @@ export const routes: RouteObject[] = [
path: 'secrets',
element: <SecretsPage />,
},
{
path: 'services',
element: <BaseServicesPage />,
},
{
path: 'utilities',
element: <UtilitiesPage />,

View File

@@ -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',

View File

@@ -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();
},
};

View File

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

View File

@@ -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] });
},
});
};

View File

@@ -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'] });

View File

@@ -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}`);
},

View File

@@ -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`);
},
};

View File

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

View File

@@ -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[];
}

View File

@@ -11,6 +11,13 @@ 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;
lastHealthCheck?: string;
// Optional fields (not yet returned by API)
hardware?: HardwareInfo;
talosVersion?: string;
@@ -23,15 +30,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 +61,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 {

View File

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

View File

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

View File

@@ -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`);
},
};

View 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,
};
}

View File

@@ -33,6 +33,7 @@ export interface CloudConfig {
export interface TalosConfig {
version: string;
schematicId?: string;
}
export interface NodesConfig {

41
src/types/nodeStatus.ts Normal file
View 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";
}

View File

@@ -0,0 +1,61 @@
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 for healthy state
if (node.inKubernetes === true) {
return NodeStatus.HEALTHY;
}
// Applied but not yet in Kubernetes (could be provisioning or ready)
if (node.isReachable === true) {
return NodeStatus.READY;
}
// Applied but status unknown
if (node.isReachable === undefined && node.inKubernetes === undefined) {
return NodeStatus.READY;
}
// Applied but having issues
if (node.inKubernetes === false) {
return NodeStatus.DEGRADED;
}
}
// Fallback
return NodeStatus.UNKNOWN;
}