First swing.

This commit is contained in:
2025-10-12 17:44:54 +00:00
parent 33454bc4e1
commit e5bd3c36f5
106 changed files with 7592 additions and 1270 deletions

View File

@@ -1,4 +1,5 @@
import { CheckCircle, Lock, Server, Play, Container, AppWindow, Settings, CloudLightning, Sun, Moon, Monitor, ChevronDown, Globe, Wifi, HardDrive } from 'lucide-react';
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 { cn } from '../lib/utils';
import {
Sidebar,
@@ -16,18 +17,9 @@ import {
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from './ui/collapsible';
import { useTheme } from '../contexts/ThemeContext';
export type Phase = 'setup' | 'infrastructure' | 'cluster' | 'apps';
export type Tab = Phase | 'advanced' | 'cloud' | 'central' | 'dns' | 'dhcp' | 'pxe';
interface AppSidebarProps {
currentTab: Tab;
onTabChange: (tab: Tab) => void;
completedPhases: Phase[];
}
export function AppSidebar({ currentTab, onTabChange, completedPhases }: AppSidebarProps) {
export function AppSidebar() {
const { theme, setTheme } = useTheme();
const { instanceId } = useParams<{ instanceId: string }>();
const cycleTheme = () => {
if (theme === 'light') {
@@ -61,45 +53,10 @@ export function AppSidebar({ currentTab, onTabChange, completedPhases }: AppSide
}
};
const getTabStatus = (tab: Tab) => {
// Non-phase tabs (like advanced and cloud) are always available
if (tab === 'advanced' || tab === 'cloud') {
return 'available';
}
// Central sub-tabs are available if setup phase is available or completed
if (tab === 'central' || tab === 'dns' || tab === 'dhcp' || tab === 'pxe') {
if (completedPhases.includes('setup')) {
return 'completed';
}
return 'available';
}
// For phase tabs, check completion status
if (completedPhases.includes(tab as Phase)) {
return 'completed';
}
// Allow access to the first phase always
if (tab === 'setup') {
return 'available';
}
// Allow access to the next phase if the previous phase is completed
if (tab === 'infrastructure' && completedPhases.includes('setup')) {
return 'available';
}
if (tab === 'cluster' && completedPhases.includes('infrastructure')) {
return 'available';
}
if (tab === 'apps' && completedPhases.includes('cluster')) {
return 'available';
}
return 'locked';
};
// If no instanceId, we're not in an instance context
if (!instanceId) {
return null;
}
return (
<Sidebar variant="sidebar" collapsible="icon">
@@ -110,40 +67,57 @@ export function AppSidebar({ currentTab, onTabChange, completedPhases }: AppSide
</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">Central</p>
<p className="text-sm text-muted-foreground">{instanceId}</p>
</div>
</div>
</SidebarHeader>
<SidebarContent>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton
isActive={currentTab === 'cloud'}
onClick={() => {
const status = getTabStatus('cloud');
if (status !== 'locked') onTabChange('cloud');
}}
disabled={getTabStatus('cloud') === 'locked'}
tooltip="Configure cloud settings and domains"
className={cn(
"transition-colors",
getTabStatus('cloud') === 'locked' && "opacity-50 cursor-not-allowed"
<NavLink to={`/instances/${instanceId}/dashboard`}>
{({ isActive }) => (
<SidebarMenuButton
isActive={isActive}
tooltip="Instance dashboard and overview"
>
<div className={cn(
"p-1 rounded-md",
isActive && "bg-primary/10"
)}>
<CloudLightning className={cn(
"h-4 w-4",
isActive && "text-primary",
!isActive && "text-muted-foreground"
)} />
</div>
<span className="truncate">Dashboard</span>
</SidebarMenuButton>
)}
>
<div className={cn(
"p-1 rounded-md",
currentTab === 'cloud' && "bg-primary/10",
getTabStatus('cloud') === 'locked' && "bg-muted"
)}>
<CloudLightning className={cn(
"h-4 w-4",
currentTab === 'cloud' && "text-primary",
currentTab !== 'cloud' && "text-muted-foreground"
)} />
</div>
<span className="truncate">Cloud</span>
</SidebarMenuButton>
</NavLink>
</SidebarMenuItem>
<SidebarMenuItem>
<NavLink to={`/instances/${instanceId}/cloud`}>
{({ isActive }) => (
<SidebarMenuButton
isActive={isActive}
tooltip="Configure cloud settings and domains"
>
<div className={cn(
"p-1 rounded-md",
isActive && "bg-primary/10"
)}>
<CloudLightning className={cn(
"h-4 w-4",
isActive && "text-primary",
!isActive && "text-muted-foreground"
)} />
</div>
<span className="truncate">Cloud</span>
</SidebarMenuButton>
)}
</NavLink>
</SidebarMenuItem>
<Collapsible defaultOpen className="group/collapsible">
@@ -158,110 +132,57 @@ export function AppSidebar({ currentTab, onTabChange, completedPhases }: AppSide
<CollapsibleContent>
<SidebarMenuSub>
<SidebarMenuSubItem>
<SidebarMenuSubButton
isActive={currentTab === 'central'}
onClick={() => {
const status = getTabStatus('central');
if (status !== 'locked') onTabChange('central');
}}
className={cn(
"transition-colors",
getTabStatus('central') === 'locked' && "opacity-50 cursor-not-allowed"
)}
>
<div className={cn(
"p-1 rounded-md",
currentTab === 'central' && "bg-primary/10",
getTabStatus('central') === 'locked' && "bg-muted"
)}>
<Server className={cn(
"h-4 w-4",
currentTab === 'central' && "text-primary",
currentTab !== 'central' && "text-muted-foreground"
)} />
</div>
<span className="truncate">Central</span>
<SidebarMenuSubButton asChild>
<NavLink to={`/instances/${instanceId}/central`} className={({ isActive }) => isActive ? "data-[active=true]" : ""}>
<div className="p-1 rounded-md">
<Server className="h-4 w-4" />
</div>
<span className="truncate">Central</span>
</NavLink>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
<SidebarMenuSubItem>
<SidebarMenuSubButton
isActive={currentTab === 'dns'}
onClick={() => {
const status = getTabStatus('dns');
if (status !== 'locked') onTabChange('dns');
}}
className={cn(
"transition-colors",
getTabStatus('dns') === 'locked' && "opacity-50 cursor-not-allowed"
)}
>
<div className={cn(
"p-1 rounded-md",
currentTab === 'dns' && "bg-primary/10",
getTabStatus('dns') === 'locked' && "bg-muted"
)}>
<Globe className={cn(
"h-4 w-4",
currentTab === 'dns' && "text-primary",
currentTab !== 'dns' && "text-muted-foreground"
)} />
</div>
<span className="truncate">DNS</span>
<SidebarMenuSubButton asChild>
<NavLink to={`/instances/${instanceId}/dns`}>
<div className="p-1 rounded-md">
<Globe className="h-4 w-4" />
</div>
<span className="truncate">DNS</span>
</NavLink>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
<SidebarMenuSubItem>
<SidebarMenuSubButton
isActive={currentTab === 'dhcp'}
onClick={() => {
const status = getTabStatus('dhcp');
if (status !== 'locked') onTabChange('dhcp');
}}
className={cn(
"transition-colors",
getTabStatus('dhcp') === 'locked' && "opacity-50 cursor-not-allowed"
)}
>
<div className={cn(
"p-1 rounded-md",
currentTab === 'dhcp' && "bg-primary/10",
getTabStatus('dhcp') === 'locked' && "bg-muted"
)}>
<Wifi className={cn(
"h-4 w-4",
currentTab === 'dhcp' && "text-primary",
currentTab !== 'dhcp' && "text-muted-foreground"
)} />
</div>
<span className="truncate">DHCP</span>
<SidebarMenuSubButton asChild>
<NavLink to={`/instances/${instanceId}/dhcp`}>
<div className="p-1 rounded-md">
<Wifi className="h-4 w-4" />
</div>
<span className="truncate">DHCP</span>
</NavLink>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
<SidebarMenuSubItem>
<SidebarMenuSubButton
isActive={currentTab === 'pxe'}
onClick={() => {
const status = getTabStatus('pxe');
if (status !== 'locked') onTabChange('pxe');
}}
className={cn(
"transition-colors",
getTabStatus('pxe') === 'locked' && "opacity-50 cursor-not-allowed"
)}
>
<div className={cn(
"p-1 rounded-md",
currentTab === 'pxe' && "bg-primary/10",
getTabStatus('pxe') === 'locked' && "bg-muted"
)}>
<HardDrive className={cn(
"h-4 w-4",
currentTab === 'pxe' && "text-primary",
currentTab !== 'pxe' && "text-muted-foreground"
)} />
</div>
<span className="truncate">PXE</span>
<SidebarMenuSubButton asChild>
<NavLink to={`/instances/${instanceId}/pxe`}>
<div className="p-1 rounded-md">
<HardDrive className="h-4 w-4" />
</div>
<span className="truncate">PXE</span>
</NavLink>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
<SidebarMenuSubItem>
<SidebarMenuSubButton asChild>
<NavLink to={`/instances/${instanceId}/iso`}>
<div className="p-1 rounded-md">
<Usb className="h-4 w-4" />
</div>
<span className="truncate">ISO / USB</span>
</NavLink>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
</SidebarMenuSub>
@@ -281,56 +202,24 @@ export function AppSidebar({ currentTab, onTabChange, completedPhases }: AppSide
<CollapsibleContent>
<SidebarMenuSub>
<SidebarMenuSubItem>
<SidebarMenuSubButton
isActive={currentTab === 'infrastructure'}
onClick={() => {
const status = getTabStatus('infrastructure');
if (status !== 'locked') onTabChange('infrastructure');
}}
className={cn(
"transition-colors",
getTabStatus('infrastructure') === 'locked' && "opacity-50 cursor-not-allowed"
)}
>
<div className={cn(
"p-1 rounded-md",
currentTab === 'infrastructure' && "bg-primary/10",
getTabStatus('infrastructure') === 'locked' && "bg-muted"
)}>
<Play className={cn(
"h-4 w-4",
currentTab === 'infrastructure' && "text-primary",
currentTab !== 'infrastructure' && "text-muted-foreground"
)} />
</div>
<span className="truncate">Cluster Nodes</span>
<SidebarMenuSubButton asChild>
<NavLink to={`/instances/${instanceId}/infrastructure`}>
<div className="p-1 rounded-md">
<Play className="h-4 w-4" />
</div>
<span className="truncate">Cluster Nodes</span>
</NavLink>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
<SidebarMenuSubItem>
<SidebarMenuSubButton
isActive={currentTab === 'cluster'}
onClick={() => {
const status = getTabStatus('cluster');
if (status !== 'locked') onTabChange('cluster');
}}
className={cn(
"transition-colors",
getTabStatus('cluster') === 'locked' && "opacity-50 cursor-not-allowed"
)}
>
<div className={cn(
"p-1 rounded-md",
currentTab === 'cluster' && "bg-primary/10",
getTabStatus('cluster') === 'locked' && "bg-muted"
)}>
<Container className={cn(
"h-4 w-4",
currentTab === 'cluster' && "text-primary",
currentTab !== 'cluster' && "text-muted-foreground"
)} />
</div>
<span className="truncate">Cluster Services</span>
<SidebarMenuSubButton asChild>
<NavLink to={`/instances/${instanceId}/cluster`}>
<div className="p-1 rounded-md">
<Container className="h-4 w-4" />
</div>
<span className="truncate">Cluster Services</span>
</NavLink>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
</SidebarMenuSub>
@@ -339,60 +228,24 @@ export function AppSidebar({ currentTab, onTabChange, completedPhases }: AppSide
</Collapsible>
<SidebarMenuItem>
<SidebarMenuButton
isActive={currentTab === 'apps'}
onClick={() => {
const status = getTabStatus('apps');
if (status !== 'locked') onTabChange('apps');
}}
disabled={getTabStatus('apps') === 'locked'}
tooltip="Install and manage applications"
className={cn(
"transition-colors",
getTabStatus('apps') === 'locked' && "opacity-50 cursor-not-allowed"
)}
>
<div className={cn(
"p-1 rounded-md",
currentTab === 'apps' && "bg-primary/10",
getTabStatus('apps') === 'locked' && "bg-muted"
)}>
<AppWindow className={cn(
"h-4 w-4",
currentTab === 'apps' && "text-primary",
currentTab !== 'apps' && "text-muted-foreground"
)} />
</div>
<span className="truncate">Apps</span>
<SidebarMenuButton asChild tooltip="Install and manage applications">
<NavLink to={`/instances/${instanceId}/apps`}>
<div className="p-1 rounded-md">
<AppWindow className="h-4 w-4" />
</div>
<span className="truncate">Apps</span>
</NavLink>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
isActive={currentTab === 'advanced'}
onClick={() => {
const status = getTabStatus('advanced');
if (status !== 'locked') onTabChange('advanced');
}}
disabled={getTabStatus('advanced') === 'locked'}
tooltip="Advanced settings and system configuration"
className={cn(
"transition-colors",
getTabStatus('advanced') === 'locked' && "opacity-50 cursor-not-allowed"
)}
>
<div className={cn(
"p-1 rounded-md",
currentTab === 'advanced' && "bg-primary/10",
getTabStatus('advanced') === 'locked' && "bg-muted"
)}>
<Settings className={cn(
"h-4 w-4",
currentTab === 'advanced' && "text-primary",
currentTab !== 'advanced' && "text-muted-foreground"
)} />
</div>
<span className="truncate">Advanced</span>
<SidebarMenuButton asChild tooltip="Advanced settings and system configuration">
<NavLink to={`/instances/${instanceId}/advanced`}>
<div className="p-1 rounded-md">
<Settings className="h-4 w-4" />
</div>
<span className="truncate">Advanced</span>
</NavLink>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
@@ -413,4 +266,4 @@ export function AppSidebar({ currentTab, onTabChange, completedPhases }: AppSide
<SidebarRail/>
</Sidebar>
);
}
}

View File

@@ -2,161 +2,131 @@ import { useState } from 'react';
import { Card } from './ui/card';
import { Button } from './ui/button';
import { Badge } from './ui/badge';
import {
AppWindow,
Database,
Globe,
Shield,
BarChart3,
MessageSquare,
Plus,
Search,
Settings,
import {
AppWindow,
Database,
Globe,
Shield,
BarChart3,
MessageSquare,
Search,
ExternalLink,
CheckCircle,
AlertCircle,
Clock,
Download,
Trash2,
BookOpen
BookOpen,
Loader2,
Archive,
RotateCcw,
Settings,
} 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 type { App } from '../services/api';
interface AppsComponentProps {
onComplete?: () => void;
interface MergedApp extends App {
deploymentStatus?: 'added' | 'deployed';
}
interface Application {
id: string;
name: string;
description: string;
category: 'database' | 'web' | 'security' | 'monitoring' | 'communication' | 'storage';
status: 'available' | 'installing' | 'running' | 'error' | 'stopped';
version?: string;
namespace?: string;
replicas?: number;
resources?: {
cpu: string;
memory: string;
};
urls?: string[];
}
export function AppsComponent({ onComplete }: AppsComponentProps) {
const [applications, setApplications] = useState<Application[]>([
{
id: 'postgres',
name: 'PostgreSQL',
description: 'Reliable, high-performance SQL database',
category: 'database',
status: 'running',
version: 'v15.4',
namespace: 'default',
replicas: 1,
resources: { cpu: '500m', memory: '1Gi' },
urls: ['postgres://postgres.wildcloud.local:5432'],
},
{
id: 'redis',
name: 'Redis',
description: 'In-memory data structure store',
category: 'database',
status: 'running',
version: 'v7.2',
namespace: 'default',
replicas: 1,
resources: { cpu: '250m', memory: '512Mi' },
},
{
id: 'traefik-dashboard',
name: 'Traefik Dashboard',
description: 'Load balancer and reverse proxy dashboard',
category: 'web',
status: 'running',
version: 'v3.0',
namespace: 'kube-system',
urls: ['https://traefik.wildcloud.local'],
},
{
id: 'grafana',
name: 'Grafana',
description: 'Monitoring and observability dashboards',
category: 'monitoring',
status: 'installing',
version: 'v10.2',
namespace: 'monitoring',
},
{
id: 'prometheus',
name: 'Prometheus',
description: 'Time-series monitoring and alerting',
category: 'monitoring',
status: 'running',
version: 'v2.45',
namespace: 'monitoring',
replicas: 1,
resources: { cpu: '1000m', memory: '2Gi' },
},
{
id: 'vault',
name: 'HashiCorp Vault',
description: 'Secrets management and encryption',
category: 'security',
status: 'available',
version: 'v1.15',
},
{
id: 'minio',
name: 'MinIO',
description: 'High-performance object storage',
category: 'storage',
status: 'available',
version: 'RELEASE.2023-12-07',
},
]);
export function AppsComponent() {
const { currentInstance } = useInstanceContext();
const { data: availableAppsData, isLoading: loadingAvailable, error: availableError } = useAvailableApps();
const {
apps: deployedApps,
isLoading: loadingDeployed,
error: deployedError,
addApp,
isAdding,
deployApp,
isDeploying,
deleteApp,
isDeleting
} = useDeployedApps(currentInstance);
const [searchTerm, setSearchTerm] = useState('');
const [selectedCategory, setSelectedCategory] = useState<string>('all');
const [configDialogOpen, setConfigDialogOpen] = useState(false);
const [selectedAppForConfig, setSelectedAppForConfig] = useState<App | null>(null);
const [backupModalOpen, setBackupModalOpen] = useState(false);
const [restoreModalOpen, setRestoreModalOpen] = useState(false);
const [selectedAppForBackup, setSelectedAppForBackup] = useState<string | null>(null);
const getStatusIcon = (status: Application['status']) => {
// Fetch backups for the selected app
const {
backups,
isLoading: backupsLoading,
backup: createBackup,
isBackingUp,
restore: restoreBackup,
isRestoring,
} = useAppBackups(currentInstance, selectedAppForBackup);
// Merge available and deployed apps
// DeployedApps now includes status: 'added' | 'deployed'
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
};
});
const isLoading = loadingAvailable || loadingDeployed;
const getStatusIcon = (status?: string) => {
switch (status) {
case 'running':
return <CheckCircle className="h-5 w-5 text-green-500" />;
case 'error':
return <AlertCircle className="h-5 w-5 text-red-500" />;
case 'installing':
return <Clock className="h-5 w-5 text-blue-500 animate-spin" />;
case 'deploying':
return <Loader2 className="h-5 w-5 text-blue-500 animate-spin" />;
case 'stopped':
return <AlertCircle className="h-5 w-5 text-yellow-500" />;
default:
case 'added':
return <Settings className="h-5 w-5 text-blue-500" />;
case 'available':
return <Download className="h-5 w-5 text-muted-foreground" />;
default:
return null;
}
};
const getStatusBadge = (status: Application['status']) => {
const variants = {
const getStatusBadge = (app: MergedApp) => {
// Determine status: runtime status > deployment status > available
const status = app.status?.status || app.deploymentStatus || 'available';
const variants: Record<string, 'secondary' | 'default' | 'success' | 'destructive' | 'warning' | 'outline'> = {
available: 'secondary',
installing: 'default',
added: 'outline',
deploying: 'default',
running: 'success',
error: 'destructive',
stopped: 'warning',
} as const;
deployed: 'outline',
};
const labels = {
const labels: Record<string, string> = {
available: 'Available',
installing: 'Installing',
added: 'Added',
deploying: 'Deploying',
running: 'Running',
error: 'Error',
stopped: 'Stopped',
deployed: 'Deployed',
};
return (
<Badge variant={variants[status] as any}>
{labels[status]}
<Badge variant={variants[status]}>
{labels[status] || status}
</Badge>
);
};
const getCategoryIcon = (category: Application['category']) => {
const getCategoryIcon = (category?: string) => {
switch (category) {
case 'database':
return <Database className="h-4 w-4" />;
@@ -175,12 +145,60 @@ export function AppsComponent({ onComplete }: AppsComponentProps) {
}
};
const handleAppAction = (appId: string, action: 'install' | 'start' | 'stop' | 'delete' | 'configure') => {
console.log(`${action} app: ${appId}`);
const handleAppAction = (app: MergedApp, action: 'configure' | 'deploy' | 'delete' | 'backup' | 'restore') => {
if (!currentInstance) return;
switch (action) {
case 'configure':
// Open config dialog for adding or reconfiguring app
setSelectedAppForConfig(app);
setConfigDialogOpen(true);
break;
case 'deploy':
deployApp(app.name);
break;
case 'delete':
if (confirm(`Are you sure you want to delete ${app.name}?`)) {
deleteApp(app.name);
}
break;
case 'backup':
setSelectedAppForBackup(app.name);
setBackupModalOpen(true);
break;
case 'restore':
setSelectedAppForBackup(app.name);
setRestoreModalOpen(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);
};
const handleBackupConfirm = () => {
createBackup();
};
const handleRestoreConfirm = (backupId?: string) => {
if (backupId) {
restoreBackup(backupId);
}
};
const categories = ['all', 'database', 'web', 'security', 'monitoring', 'communication', 'storage'];
const filteredApps = applications.filter(app => {
const matchesSearch = app.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
app.description.toLowerCase().includes(searchTerm.toLowerCase());
@@ -188,7 +206,34 @@ export function AppsComponent({ onComplete }: AppsComponentProps) {
return matchesSearch && matchesCategory;
});
const runningApps = applications.filter(app => app.status === 'running').length;
const runningApps = applications.filter(app => app.status?.status === 'running').length;
// Show message if no instance is selected
if (!currentInstance) {
return (
<Card className="p-8 text-center">
<AppWindow className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<h3 className="text-lg font-medium mb-2">No Instance Selected</h3>
<p className="text-muted-foreground mb-4">
Please select or create an instance to manage apps.
</p>
</Card>
);
}
// Show error state
if (availableError || deployedError) {
return (
<Card className="p-8 text-center">
<AlertCircle className="h-12 w-12 text-red-500 mx-auto mb-4" />
<h3 className="text-lg font-medium mb-2">Error Loading Apps</h3>
<p className="text-muted-foreground mb-4">
{(availableError as Error)?.message || (deployedError as Error)?.message || 'An error occurred'}
</p>
<Button onClick={() => window.location.reload()}>Reload Page</Button>
</Card>
);
}
return (
<div className="space-y-6">
@@ -260,135 +305,199 @@ export function AppsComponent({ onComplete }: AppsComponentProps) {
<div className="flex items-center justify-between mb-4">
<div className="text-sm text-muted-foreground">
{runningApps} applications running • {applications.length} total available
{isLoading ? (
<span className="flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
Loading apps...
</span>
) : (
`${runningApps} applications running • ${applications.length} total available`
)}
</div>
<Button size="sm">
<Plus className="h-4 w-4 mr-2" />
Add App
</Button>
</div>
</Card>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{filteredApps.map((app) => (
<Card key={app.id} className="p-4">
<div className="flex items-start gap-3">
<div className="p-2 bg-muted rounded-lg">
{getCategoryIcon(app.category)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h3 className="font-medium truncate">{app.name}</h3>
{app.version && (
<Badge variant="outline" className="text-xs">
{app.version}
</Badge>
)}
{getStatusIcon(app.status)}
{isLoading ? (
<Card className="p-8 text-center">
<Loader2 className="h-12 w-12 text-primary mx-auto mb-4 animate-spin" />
<p className="text-muted-foreground">Loading applications...</p>
</Card>
) : (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{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>
<p className="text-sm text-muted-foreground mb-2">{app.description}</p>
{app.status === 'running' && (
<div className="space-y-1 text-xs text-muted-foreground">
{app.namespace && (
<div>Namespace: {app.namespace}</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h3 className="font-medium truncate">{app.name}</h3>
{app.version && (
<Badge variant="outline" className="text-xs">
{app.version}
</Badge>
)}
{app.replicas && (
<div>Replicas: {app.replicas}</div>
{getStatusIcon(app.status?.status)}
</div>
<p className="text-sm text-muted-foreground mb-2">{app.description}</p>
{app.status?.status === 'running' && (
<div className="space-y-1 text-xs text-muted-foreground">
{app.status.namespace && (
<div>Namespace: {app.status.namespace}</div>
)}
{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>
)}
{app.resources && (
<div>Resources: {app.resources.cpu} CPU, {app.resources.memory} RAM</div>
{/* Added: in config but not deployed */}
{app.deploymentStatus === 'added' && (
<>
<Button
size="sm"
variant="outline"
onClick={() => handleAppAction(app, 'configure')}
title="Edit configuration"
>
<Settings className="h-4 w-4" />
</Button>
<Button
size="sm"
onClick={() => handleAppAction(app, 'deploy')}
disabled={isDeploying}
>
{isDeploying ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Deploy'}
</Button>
<Button
size="sm"
variant="destructive"
onClick={() => handleAppAction(app, 'delete')}
disabled={isDeleting}
>
{isDeleting ? <Loader2 className="h-4 w-4 animate-spin" /> : <Trash2 className="h-4 w-4" />}
</Button>
</>
)}
{app.urls && app.urls.length > 0 && (
<div className="flex items-center gap-1">
<span>URLs:</span>
{app.urls.map((url, index) => (
<Button
key={index}
variant="link"
size="sm"
className="h-auto p-0 text-xs"
onClick={() => window.open(url, '_blank')}
>
<ExternalLink className="h-3 w-3 mr-1" />
Access
</Button>
))}
</div>
{/* Deployed: running in Kubernetes */}
{app.deploymentStatus === 'deployed' && (
<>
{app.status?.status === 'running' && (
<>
<Button
size="sm"
variant="outline"
onClick={() => handleAppAction(app, 'backup')}
disabled={isBackingUp}
title="Create backup"
>
<Archive className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="outline"
onClick={() => handleAppAction(app, 'restore')}
disabled={isRestoring}
title="Restore from backup"
>
<RotateCcw className="h-4 w-4" />
</Button>
</>
)}
<Button
size="sm"
variant="destructive"
onClick={() => handleAppAction(app, 'delete')}
disabled={isDeleting}
>
{isDeleting ? <Loader2 className="h-4 w-4 animate-spin" /> : <Trash2 className="h-4 w-4" />}
</Button>
</>
)}
</div>
)}
</div>
<div className="flex flex-col gap-2">
{getStatusBadge(app.status)}
<div className="flex gap-1">
{app.status === 'available' && (
<Button
size="sm"
onClick={() => handleAppAction(app.id, 'install')}
>
Install
</Button>
)}
{app.status === 'running' && (
<>
<Button
size="sm"
variant="outline"
onClick={() => handleAppAction(app.id, 'configure')}
>
<Settings className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="outline"
onClick={() => handleAppAction(app.id, 'stop')}
>
Stop
</Button>
</>
)}
{app.status === 'stopped' && (
<Button
size="sm"
onClick={() => handleAppAction(app.id, 'start')}
>
Start
</Button>
)}
{(app.status === 'running' || app.status === 'stopped') && (
<Button
size="sm"
variant="destructive"
onClick={() => handleAppAction(app.id, 'delete')}
>
<Trash2 className="h-4 w-4" />
</Button>
)}
</div>
</div>
</div>
</Card>
))}
</div>
</Card>
))}
</div>
)}
{filteredApps.length === 0 && (
{!isLoading && filteredApps.length === 0 && (
<Card className="p-8 text-center">
<AppWindow className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<h3 className="text-lg font-medium mb-2">No applications found</h3>
<p className="text-muted-foreground mb-4">
{searchTerm || selectedCategory !== 'all'
{searchTerm || selectedCategory !== 'all'
? 'Try adjusting your search or category filter'
: 'Install your first application to get started'
: 'No applications available to display'
}
</p>
<Button>
<Plus className="h-4 w-4 mr-2" />
Browse App Catalog
</Button>
</Card>
)}
{/* Backup Modal */}
<BackupRestoreModal
isOpen={backupModalOpen}
onClose={() => {
setBackupModalOpen(false);
setSelectedAppForBackup(null);
}}
mode="backup"
appName={selectedAppForBackup || ''}
onConfirm={handleBackupConfirm}
isPending={isBackingUp}
/>
{/* Restore Modal */}
<BackupRestoreModal
isOpen={restoreModalOpen}
onClose={() => {
setRestoreModalOpen(false);
setSelectedAppForBackup(null);
}}
mode="restore"
appName={selectedAppForBackup || ''}
backups={backups?.backups || []}
isLoading={backupsLoading}
onConfirm={handleRestoreConfirm}
isPending={isRestoring}
/>
{/* App Configuration Dialog */}
<AppConfigDialog
open={configDialogOpen}
onOpenChange={setConfigDialogOpen}
app={selectedAppForConfig}
existingConfig={selectedAppForConfig?.config}
onSave={handleConfigSave}
isSaving={isAdding}
/>
</div>
);
}

View File

@@ -0,0 +1,158 @@
import { useState } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from './ui/dialog';
import { Button } from './ui/button';
import { Badge } from './ui/badge';
import { Loader2, AlertCircle, Clock, HardDrive } from 'lucide-react';
interface Backup {
id: string;
timestamp: string;
size?: string;
}
interface BackupRestoreModalProps {
isOpen: boolean;
onClose: () => void;
mode: 'backup' | 'restore';
appName: string;
backups?: Backup[];
isLoading?: boolean;
onConfirm: (backupId?: string) => void;
isPending?: boolean;
}
export function BackupRestoreModal({
isOpen,
onClose,
mode,
appName,
backups = [],
isLoading = false,
onConfirm,
isPending = false,
}: BackupRestoreModalProps) {
const [selectedBackupId, setSelectedBackupId] = useState<string | null>(null);
const handleConfirm = () => {
if (mode === 'backup') {
onConfirm();
} else if (mode === 'restore' && selectedBackupId) {
onConfirm(selectedBackupId);
}
onClose();
};
const formatTimestamp = (timestamp: string) => {
try {
return new Date(timestamp).toLocaleString();
} catch {
return timestamp;
}
};
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>
{mode === 'backup' ? 'Create Backup' : 'Restore from Backup'}
</DialogTitle>
<DialogDescription>
{mode === 'backup'
? `Create a backup of the ${appName} application data.`
: `Select a backup to restore for the ${appName} application.`}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{mode === 'backup' ? (
<div className="p-4 bg-muted rounded-lg">
<p className="text-sm text-muted-foreground">
This will create a new backup of the current application state. The backup
process may take a few minutes depending on the size of the data.
</p>
</div>
) : (
<div className="space-y-3">
{isLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : backups.length === 0 ? (
<div className="text-center py-8">
<AlertCircle className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<p className="text-sm text-muted-foreground">
No backups available for this application.
</p>
</div>
) : (
<div className="space-y-2 max-h-64 overflow-y-auto">
{backups.map((backup) => (
<button
key={backup.id}
onClick={() => setSelectedBackupId(backup.id)}
className={`w-full p-3 rounded-lg border text-left transition-colors ${
selectedBackupId === backup.id
? 'border-primary bg-primary/10'
: 'border-border hover:bg-muted'
}`}
>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Clock className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">
{formatTimestamp(backup.timestamp)}
</span>
</div>
{selectedBackupId === backup.id && (
<Badge variant="default">Selected</Badge>
)}
</div>
{backup.size && (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<HardDrive className="h-3 w-3" />
<span>{backup.size}</span>
</div>
)}
</button>
))}
</div>
)}
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose} disabled={isPending}>
Cancel
</Button>
<Button
onClick={handleConfirm}
disabled={
isPending ||
(mode === 'restore' && (!selectedBackupId || backups.length === 0))
}
>
{isPending ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
{mode === 'backup' ? 'Creating...' : 'Restoring...'}
</>
) : mode === 'backup' ? (
'Create Backup'
) : (
'Restore'
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,9 +1,48 @@
import { Card } from './ui/card';
import { Button } from './ui/button';
import { Server, Network, Settings, Clock, HelpCircle, CheckCircle, BookOpen, ExternalLink } from 'lucide-react';
import { Input, Label } from './ui';
import { Server, HardDrive, Settings, Clock, CheckCircle, BookOpen, ExternalLink, Loader2, AlertCircle, Database, FolderTree } from 'lucide-react';
import { Badge } from './ui/badge';
import { useCentralStatus } from '../hooks/useCentralStatus';
import { useInstanceConfig, useInstanceContext } from '../hooks';
export function CentralComponent() {
const { currentInstance } = useInstanceContext();
const { data: centralStatus, isLoading: statusLoading, error: statusError } = useCentralStatus();
const { config: fullConfig, isLoading: configLoading } = useInstanceConfig(currentInstance);
const serverConfig = fullConfig?.server as { host?: string; port?: number } | undefined;
const formatUptime = (seconds?: number) => {
if (!seconds) return 'Unknown';
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = Math.floor(seconds % 60);
const parts = [];
if (days > 0) parts.push(`${days}d`);
if (hours > 0) parts.push(`${hours}h`);
if (minutes > 0) parts.push(`${minutes}m`);
if (secs > 0 || parts.length === 0) parts.push(`${secs}s`);
return parts.join(' ');
};
// Show error state
if (statusError) {
return (
<Card className="p-8 text-center">
<AlertCircle className="h-12 w-12 text-red-500 mx-auto mb-4" />
<h3 className="text-lg font-medium mb-2">Error Loading Central Status</h3>
<p className="text-muted-foreground mb-4">
{(statusError as Error)?.message || 'An error occurred'}
</p>
<Button onClick={() => window.location.reload()}>Reload Page</Button>
</Card>
);
}
return (
<div className="space-y-6">
{/* Educational Intro Section */}
@@ -17,8 +56,8 @@ export function CentralComponent() {
What is the Central Service?
</h3>
<p className="text-blue-800 dark:text-blue-200 mb-3 leading-relaxed">
The Central Service is the "brain" of your personal cloud. It acts as the main coordinator that manages
all the different services running on your network. Think of it like the control tower at an airport -
The Central Service is the "brain" of your personal cloud. It acts as the main coordinator that manages
all the different services running on your network. Think of it like the control tower at an airport -
it keeps track of what's happening, routes traffic between services, and ensures everything works together smoothly.
</p>
<p className="text-blue-700 dark:text-blue-300 mb-4 text-sm">
@@ -37,78 +76,123 @@ export function CentralComponent() {
<div className="p-2 bg-primary/10 rounded-lg">
<Server className="h-6 w-6 text-primary" />
</div>
<div>
<h2 className="text-2xl font-semibold">Central Service</h2>
<div className="flex-1">
<h2 className="text-2xl font-semibold">Central Service Status</h2>
<p className="text-muted-foreground">
Monitor and manage the central server service
Monitor the Wild Central server
</p>
</div>
{centralStatus && (
<Badge variant="success" className="flex items-center gap-2">
<CheckCircle className="h-4 w-4" />
{centralStatus.status === 'running' ? 'Running' : centralStatus.status}
</Badge>
)}
</div>
<div>
<h3 className="text-lg font-medium mb-4">Service Status</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6 mb-6">
<div className="flex items-center gap-2">
<Server className="h-5 w-5 text-muted-foreground" />
<span className="text-sm text-muted-foreground">IP Address: 192.168.8.50</span>
</div>
<div className="flex items-center gap-2">
<Network className="h-5 w-5 text-muted-foreground" />
<span className="text-sm text-muted-foreground">Network: 192.168.8.0/24</span>
</div>
<div className="flex items-center gap-2">
<Settings className="h-5 w-5 text-muted-foreground" />
<span className="text-sm text-muted-foreground">Version: 1.0.0 (update available)</span>
</div>
<div className="flex items-center gap-2">
<Clock className="h-5 w-5 text-muted-foreground" />
<span className="text-sm text-muted-foreground">Age: 12s</span>
</div>
<div className="flex items-center gap-2">
<HelpCircle className="h-5 w-5 text-muted-foreground" />
<span className="text-sm text-muted-foreground">Platform: ARM</span>
</div>
<div className="flex items-center gap-2">
<CheckCircle className="h-5 w-5 text-green-500" />
<span className="text-sm text-green-500">File permissions: Good</span>
</div>
{statusLoading || configLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
<div className="space-y-4">
) : (
<div className="space-y-6">
{/* Server Information */}
<div>
<Label htmlFor="ip">IP</Label>
<div className="flex w-full items-center mt-1">
<Input id="ip" value="192.168.5.80"/>
<Button variant="ghost">
<HelpCircle/>
</Button>
<h3 className="text-lg font-medium mb-4">Server Information</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<Card className="p-4 border-l-4 border-l-blue-500">
<div className="flex items-start gap-3">
<Settings className="h-5 w-5 text-blue-500 mt-0.5" />
<div className="flex-1">
<div className="text-sm text-muted-foreground mb-1">Version</div>
<div className="font-medium font-mono">{centralStatus?.version || 'Unknown'}</div>
</div>
</div>
</Card>
<Card className="p-4 border-l-4 border-l-green-500">
<div className="flex items-start gap-3">
<Clock className="h-5 w-5 text-green-500 mt-0.5" />
<div className="flex-1">
<div className="text-sm text-muted-foreground mb-1">Uptime</div>
<div className="font-medium">{formatUptime(centralStatus?.uptimeSeconds)}</div>
</div>
</div>
</Card>
<Card className="p-4 border-l-4 border-l-purple-500">
<div className="flex items-start gap-3">
<Database className="h-5 w-5 text-purple-500 mt-0.5" />
<div className="flex-1">
<div className="text-sm text-muted-foreground mb-1">Instances</div>
<div className="font-medium">{centralStatus?.instances.count || 0} configured</div>
{centralStatus?.instances.names && centralStatus.instances.names.length > 0 && (
<div className="text-xs text-muted-foreground mt-1">
{centralStatus.instances.names.join(', ')}
</div>
)}
</div>
</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>
{/* Configuration */}
<div>
<Label htmlFor="interface">Interface</Label>
<div className="flex w-full items-center mt-1">
<Input id="interface" value="eth0"/>
<Button variant="ghost">
<HelpCircle/>
</Button>
<h3 className="text-lg font-medium mb-4">Configuration</h3>
<div className="space-y-3">
<Card className="p-4 border-l-4 border-l-cyan-500">
<div className="flex items-start gap-3">
<Server className="h-5 w-5 text-cyan-500 mt-0.5" />
<div className="flex-1">
<div className="text-sm text-muted-foreground mb-1">Server Host</div>
<div className="font-medium font-mono">{serverConfig?.host || '0.0.0.0'}</div>
</div>
<div className="flex-1">
<div className="text-sm text-muted-foreground mb-1">Server Port</div>
<div className="font-medium font-mono">{serverConfig?.port || 5055}</div>
</div>
</div>
</Card>
<Card className="p-4 border-l-4 border-l-indigo-500">
<div className="flex items-start gap-3">
<HardDrive className="h-5 w-5 text-indigo-500 mt-0.5" />
<div className="flex-1">
<div className="text-sm text-muted-foreground mb-1">Data Directory</div>
<div className="font-medium font-mono text-sm break-all">
{centralStatus?.dataDir || '/var/lib/wild-central'}
</div>
</div>
</div>
</Card>
<Card className="p-4 border-l-4 border-l-pink-500">
<div className="flex items-start gap-3">
<FolderTree className="h-5 w-5 text-pink-500 mt-0.5" />
<div className="flex-1">
<div className="text-sm text-muted-foreground mb-1">Apps Directory</div>
<div className="font-medium font-mono text-sm break-all">
{centralStatus?.appsDir || '/opt/wild-cloud/apps'}
</div>
</div>
</div>
</Card>
</div>
</div>
</div>
<div className="flex gap-2 justify-end mt-4">
<Button onClick={() => console.log('Update service')}>
Update
</Button>
<Button onClick={() => console.log('Restart service')}>
Restart
</Button>
<Button onClick={() => console.log('View log')}>
View log
</Button>
</div>
</div>
)}
</Card>
</div>
);
}
}

View File

@@ -1,39 +1,171 @@
import { useState } from "react";
import { useState, useEffect } from "react";
import { Card } from "./ui/card";
import { Button } from "./ui/button";
import { Cloud, HelpCircle, Edit2, Check, X } from "lucide-react";
import { Cloud, HelpCircle, Edit2, Check, X, Loader2, AlertCircle } from "lucide-react";
import { Input, Label } from "./ui";
import { useInstanceConfig, useInstanceContext } from "../hooks";
interface CloudConfig {
domain: string;
internalDomain: string;
dhcpRange: string;
dns: {
ip: string;
};
router: {
ip: string;
};
dnsmasq: {
interface: string;
};
}
export function CloudComponent() {
const [domainValue, setDomainValue] = useState("cloud.payne.io");
const [internalDomainValue, setInternalDomainValue] = useState(
"internal.cloud.payne.io"
);
const { currentInstance } = useInstanceContext();
const { config: fullConfig, isLoading, error, updateConfig, isUpdating } = useInstanceConfig(currentInstance);
// Extract cloud config from full config
const config = fullConfig?.cloud as CloudConfig | undefined;
const [editingDomains, setEditingDomains] = useState(false);
const [editingNetwork, setEditingNetwork] = useState(false);
const [formValues, setFormValues] = useState<CloudConfig | null>(null);
const [tempDomain, setTempDomain] = useState(domainValue);
const [tempInternalDomain, setTempInternalDomain] =
useState(internalDomainValue);
// Sync form values when config loads
useEffect(() => {
if (config && !formValues) {
setFormValues(config as CloudConfig);
}
}, [config, formValues]);
const handleDomainsEdit = () => {
setTempDomain(domainValue);
setTempInternalDomain(internalDomainValue);
setEditingDomains(true);
if (config) {
setFormValues(config as CloudConfig);
setEditingDomains(true);
}
};
const handleDomainsSave = () => {
setDomainValue(tempDomain);
setInternalDomainValue(tempInternalDomain);
setEditingDomains(false);
const handleNetworkEdit = () => {
if (config) {
setFormValues(config as CloudConfig);
setEditingNetwork(true);
}
};
const handleDomainsSave = async () => {
if (!formValues || !fullConfig) return;
try {
// Update only the cloud section, preserving other config sections
await updateConfig({
...fullConfig,
cloud: {
domain: formValues.domain,
internalDomain: formValues.internalDomain,
dhcpRange: formValues.dhcpRange,
dns: formValues.dns,
router: formValues.router,
dnsmasq: formValues.dnsmasq,
},
});
setEditingDomains(false);
} catch (err) {
console.error('Failed to save domains:', err);
}
};
const handleNetworkSave = async () => {
if (!formValues || !fullConfig) return;
try {
// Update only the cloud section, preserving other config sections
await updateConfig({
...fullConfig,
cloud: {
domain: formValues.domain,
internalDomain: formValues.internalDomain,
dhcpRange: formValues.dhcpRange,
dns: formValues.dns,
router: formValues.router,
dnsmasq: formValues.dnsmasq,
},
});
setEditingNetwork(false);
} catch (err) {
console.error('Failed to save network settings:', err);
}
};
const handleDomainsCancel = () => {
setTempDomain(domainValue);
setTempInternalDomain(internalDomainValue);
setFormValues(config as CloudConfig);
setEditingDomains(false);
};
const handleNetworkCancel = () => {
setFormValues(config as CloudConfig);
setEditingNetwork(false);
};
const updateFormValue = (path: string, value: string) => {
if (!formValues) return;
setFormValues(prev => {
if (!prev) return prev;
// Handle nested paths like "dns.ip"
const keys = path.split('.');
if (keys.length === 1) {
return { ...prev, [keys[0]]: value };
}
// Handle nested object updates
const [parentKey, childKey] = keys;
return {
...prev,
[parentKey]: {
...(prev[parentKey as keyof CloudConfig] as Record<string, unknown>),
[childKey]: value,
},
};
});
};
// Show message if no instance is selected
if (!currentInstance) {
return (
<Card className="p-8 text-center">
<Cloud className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<h3 className="text-lg font-medium mb-2">No Instance Selected</h3>
<p className="text-muted-foreground mb-4">
Please select or create an instance to manage cloud configuration.
</p>
</Card>
);
}
// Show loading state
if (isLoading || !formValues) {
return (
<Card className="p-8 text-center">
<Loader2 className="h-12 w-12 text-primary mx-auto mb-4 animate-spin" />
<p className="text-muted-foreground">Loading cloud configuration...</p>
</Card>
);
}
// Show error state
if (error) {
return (
<Card className="p-8 text-center">
<AlertCircle className="h-12 w-12 text-red-500 mx-auto mb-4" />
<h3 className="text-lg font-medium mb-2">Error Loading Configuration</h3>
<p className="text-muted-foreground mb-4">
{(error as Error)?.message || 'An error occurred'}
</p>
</Card>
);
}
return (
<div className="space-y-6">
<Card className="p-6">
@@ -51,7 +183,7 @@ export function CloudComponent() {
<div className="space-y-6">
{/* Domains Section */}
<Card className="p-4 border-l-4 border-l-green-500">
<Card className="p-4 border-l-4 border-l-blue-500">
<div className="flex items-center justify-between mb-3">
<div>
<h3 className="font-medium">Domain Configuration</h3>
@@ -68,6 +200,7 @@ export function CloudComponent() {
variant="outline"
size="sm"
onClick={handleDomainsEdit}
disabled={isUpdating}
>
<Edit2 className="h-4 w-4 mr-1" />
Edit
@@ -82,8 +215,8 @@ export function CloudComponent() {
<Label htmlFor="domain-edit">Public Domain</Label>
<Input
id="domain-edit"
value={tempDomain}
onChange={(e) => setTempDomain(e.target.value)}
value={formValues.domain}
onChange={(e) => updateFormValue('domain', e.target.value)}
placeholder="example.com"
className="mt-1"
/>
@@ -92,21 +225,26 @@ export function CloudComponent() {
<Label htmlFor="internal-domain-edit">Internal Domain</Label>
<Input
id="internal-domain-edit"
value={tempInternalDomain}
onChange={(e) => setTempInternalDomain(e.target.value)}
value={formValues.internalDomain}
onChange={(e) => updateFormValue('internalDomain', e.target.value)}
placeholder="internal.example.com"
className="mt-1"
/>
</div>
<div className="flex gap-2">
<Button size="sm" onClick={handleDomainsSave}>
<Check className="h-4 w-4 mr-1" />
<Button size="sm" onClick={handleDomainsSave} 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={handleDomainsCancel}
disabled={isUpdating}
>
<X className="h-4 w-4 mr-1" />
Cancel
@@ -118,13 +256,135 @@ export function CloudComponent() {
<div>
<Label>Public Domain</Label>
<div className="mt-1 p-2 bg-muted rounded-md font-mono text-sm">
{domainValue}
{formValues.domain}
</div>
</div>
<div>
<Label>Internal Domain</Label>
<div className="mt-1 p-2 bg-muted rounded-md font-mono text-sm">
{internalDomainValue}
{formValues.internalDomain}
</div>
</div>
</div>
)}
</Card>
{/* Network Configuration Section */}
<Card className="p-4 border-l-4 border-l-green-500">
<div className="flex items-center justify-between mb-3">
<div>
<h3 className="font-medium">Network Configuration</h3>
<p className="text-sm text-muted-foreground">
Network settings and DHCP configuration
</p>
</div>
<div className="flex items-center gap-2">
<Button variant="ghost" size="sm">
<HelpCircle className="h-4 w-4" />
</Button>
{!editingNetwork && (
<Button
variant="outline"
size="sm"
onClick={handleNetworkEdit}
disabled={isUpdating}
>
<Edit2 className="h-4 w-4 mr-1" />
Edit
</Button>
)}
</div>
</div>
{editingNetwork ? (
<div className="space-y-3">
<div>
<Label htmlFor="dhcp-range-edit">DHCP Range</Label>
<Input
id="dhcp-range-edit"
value={formValues.dhcpRange}
onChange={(e) => updateFormValue('dhcpRange', e.target.value)}
placeholder="192.168.1.100,192.168.1.200"
className="mt-1"
/>
<p className="text-xs text-muted-foreground mt-1">
Format: start_ip,end_ip
</p>
</div>
<div>
<Label htmlFor="dns-ip-edit">DNS Server IP</Label>
<Input
id="dns-ip-edit"
value={formValues.dns.ip}
onChange={(e) => updateFormValue('dns.ip', e.target.value)}
placeholder="192.168.1.1"
className="mt-1"
/>
</div>
<div>
<Label htmlFor="router-ip-edit">Router IP</Label>
<Input
id="router-ip-edit"
value={formValues.router.ip}
onChange={(e) => updateFormValue('router.ip', e.target.value)}
placeholder="192.168.1.1"
className="mt-1"
/>
</div>
<div>
<Label htmlFor="dnsmasq-interface-edit">Dnsmasq Interface</Label>
<Input
id="dnsmasq-interface-edit"
value={formValues.dnsmasq.interface}
onChange={(e) => updateFormValue('dnsmasq.interface', e.target.value)}
placeholder="eth0"
className="mt-1"
/>
</div>
<div className="flex gap-2">
<Button size="sm" onClick={handleNetworkSave} 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={handleNetworkCancel}
disabled={isUpdating}
>
<X className="h-4 w-4 mr-1" />
Cancel
</Button>
</div>
</div>
) : (
<div className="space-y-3">
<div>
<Label>DHCP Range</Label>
<div className="mt-1 p-2 bg-muted rounded-md font-mono text-sm">
{formValues.dhcpRange}
</div>
</div>
<div>
<Label>DNS Server IP</Label>
<div className="mt-1 p-2 bg-muted rounded-md font-mono text-sm">
{formValues.dns.ip}
</div>
</div>
<div>
<Label>Router IP</Label>
<div className="mt-1 p-2 bg-muted rounded-md font-mono text-sm">
{formValues.router.ip}
</div>
</div>
<div>
<Label>Dnsmasq Interface</Label>
<div className="mt-1 p-2 bg-muted rounded-md font-mono text-sm">
{formValues.dnsmasq.interface}
</div>
</div>
</div>

View File

@@ -2,151 +2,145 @@ import { useState } from 'react';
import { Card } from './ui/card';
import { Button } from './ui/button';
import { Badge } from './ui/badge';
import { Cpu, HardDrive, Network, Monitor, Plus, CheckCircle, AlertCircle, Clock, BookOpen, ExternalLink } from 'lucide-react';
import { Cpu, HardDrive, Network, Monitor, CheckCircle, AlertCircle, BookOpen, ExternalLink, Loader2 } from 'lucide-react';
import { useInstanceContext } from '../hooks/useInstanceContext';
import { useNodes, useDiscoveryStatus } from '../hooks/useNodes';
interface ClusterNodesComponentProps {
onComplete?: () => void;
}
export function ClusterNodesComponent() {
const { currentInstance } = useInstanceContext();
const {
nodes,
isLoading,
error,
addNode,
isAdding,
deleteNode,
isDeleting,
discover,
isDiscovering,
detect,
isDetecting
} = useNodes(currentInstance);
interface Node {
id: string;
name: string;
type: 'controller' | 'worker' | 'unassigned';
status: 'pending' | 'connecting' | 'connected' | 'healthy' | 'error';
ipAddress?: string;
macAddress: string;
osVersion?: string;
specs: {
cpu: string;
memory: string;
storage: string;
};
}
const {
data: discoveryStatus
} = useDiscoveryStatus(currentInstance);
export function ClusterNodesComponent({ onComplete }: ClusterNodesComponentProps) {
const [currentOsVersion, setCurrentOsVersion] = useState('v13.0.5');
const [nodes, setNodes] = useState<Node[]>([
{
id: 'controller-1',
name: 'Controller Node 1',
type: 'controller',
status: 'healthy',
macAddress: '00:1A:2B:3C:4D:5E',
osVersion: 'v13.0.4',
specs: {
cpu: '4 cores',
memory: '8GB RAM',
storage: '120GB SSD',
},
},
{
id: 'worker-1',
name: 'Worker Node 1',
type: 'worker',
status: 'healthy',
macAddress: '00:1A:2B:3C:4D:5F',
osVersion: 'v13.0.5',
specs: {
cpu: '8 cores',
memory: '16GB RAM',
storage: '500GB SSD',
},
},
{
id: 'worker-2',
name: 'Worker Node 2',
type: 'worker',
status: 'healthy',
macAddress: '00:1A:2B:3C:4D:60',
osVersion: 'v13.0.4',
specs: {
cpu: '8 cores',
memory: '16GB RAM',
storage: '500GB SSD',
},
},
{
id: 'node-1',
name: 'Node 1',
type: 'unassigned',
status: 'pending',
macAddress: '00:1A:2B:3C:4D:5E',
osVersion: 'v13.0.5',
specs: {
cpu: '4 cores',
memory: '8GB RAM',
storage: '120GB SSD',
},
},
{
id: 'node-2',
name: 'Node 2',
type: 'unassigned',
status: 'pending',
macAddress: '00:1A:2B:3C:4D:5F',
osVersion: 'v13.0.5',
specs: {
cpu: '8 cores',
memory: '16GB RAM',
storage: '500GB SSD',
},
},
]);
const [subnet, setSubnet] = useState('192.168.1.0/24');
const getStatusIcon = (status: Node['status']) => {
const getStatusIcon = (status?: string) => {
switch (status) {
case 'connected':
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':
return <Clock className="h-5 w-5 text-blue-500 animate-spin" />;
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 getStatusBadge = (status: Node['status']) => {
const variants = {
const getStatusBadge = (status?: string) => {
const variants: Record<string, 'secondary' | 'default' | 'success' | 'destructive'> = {
pending: 'secondary',
connecting: 'default',
connected: 'success',
provisioning: 'default',
ready: 'success',
healthy: 'success',
error: 'destructive',
} as const;
};
const labels = {
const labels: Record<string, string> = {
pending: 'Pending',
connecting: 'Connecting',
connected: 'Connected',
provisioning: 'Provisioning',
ready: 'Ready',
healthy: 'Healthy',
error: 'Error',
};
return (
<Badge variant={variants[status] as any}>
{labels[status]}
<Badge variant={variants[status || 'pending']}>
{labels[status || 'pending'] || status}
</Badge>
);
};
const getTypeIcon = (type: Node['type']) => {
return type === 'controller' ? (
const getRoleIcon = (role: string) => {
return role === 'controlplane' ? (
<Cpu className="h-4 w-4" />
) : (
<HardDrive className="h-4 w-4" />
);
};
const handleNodeAction = (nodeId: string, action: 'connect' | 'retry' | 'upgrade_node') => {
console.log(`${action} node: ${nodeId}`);
const handleAddNode = (ip: string, hostname: string, role: 'controlplane' | 'worker') => {
if (!currentInstance) return;
addNode({ target_ip: ip, hostname, role, disk: '/dev/sda' });
};
const connectedNodes = nodes.filter(node => node.status === 'connected').length;
const assignedNodes = nodes.filter(node => node.type !== 'unassigned');
const unassignedNodes = nodes.filter(node => node.type === 'unassigned');
const totalNodes = nodes.length;
const isComplete = connectedNodes === totalNodes;
const handleDeleteNode = (hostname: string) => {
if (!currentInstance) return;
if (confirm(`Are you sure you want to remove node ${hostname}?`)) {
deleteNode(hostname);
}
};
const handleDiscover = () => {
if (!currentInstance) return;
discover(subnet);
};
const handleDetect = () => {
if (!currentInstance) return;
detect();
};
// Derive status from backend state flags for each node
const assignedNodes = nodes.map(node => {
let status = 'pending';
if (node.maintenance) {
status = 'provisioning';
} else if (node.configured && !node.applied) {
status = 'connecting';
} else if (node.applied) {
status = 'ready';
}
return { ...node, status };
});
// Extract IPs from discovered nodes
const discoveredIps = discoveryStatus?.nodes_found?.map(n => n.ip) || [];
// Show message if no instance is selected
if (!currentInstance) {
return (
<Card className="p-8 text-center">
<Network className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<h3 className="text-lg font-medium mb-2">No Instance Selected</h3>
<p className="text-muted-foreground mb-4">
Please select or create an instance to manage nodes.
</p>
</Card>
);
}
// Show error state
if (error) {
return (
<Card className="p-8 text-center">
<AlertCircle className="h-12 w-12 text-red-500 mx-auto mb-4" />
<h3 className="text-lg font-medium mb-2">Error Loading Nodes</h3>
<p className="text-muted-foreground mb-4">
{(error as Error)?.message || 'An error occurred'}
</p>
<Button onClick={() => window.location.reload()}>Reload Page</Button>
</Card>
);
}
return (
<div className="space-y-6">
@@ -190,148 +184,148 @@ export function ClusterNodesComponent({ onComplete }: ClusterNodesComponentProps
</div>
</div>
<div className="space-y-4">
<h2 className="text-lg font-medium mb-4">Assigned Nodes ({assignedNodes.length}/{totalNodes})</h2>
{assignedNodes.map((node) => (
<Card key={node.id} className="p-4">
<div className="flex items-center gap-4">
<div className="p-2 bg-muted rounded-lg">
{getTypeIcon(node.type)}
{isLoading ? (
<Card className="p-8 text-center">
<Loader2 className="h-12 w-12 text-primary mx-auto mb-4 animate-spin" />
<p className="text-muted-foreground">Loading nodes...</p>
</Card>
) : (
<>
<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 className="flex-1">
<div className="flex items-center gap-2 mb-1">
<h4 className="font-medium">{node.name}</h4>
<Badge variant="outline" className="text-xs">
{node.type}
</Badge>
{getStatusIcon(node.status)}
</div>
<div className="text-sm text-muted-foreground mb-2">
MAC: {node.macAddress}
{node.ipAddress && ` • IP: ${node.ipAddress}`}
</div>
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<span className="flex items-center gap-1">
<Cpu className="h-3 w-3" />
{node.specs.cpu}
</span>
<span className="flex items-center gap-1">
<Monitor className="h-3 w-3" />
{node.specs.memory}
</span>
<span className="flex items-center gap-1">
<HardDrive className="h-3 w-3" />
{node.specs.storage}
</span>
{node.osVersion && (
<span className="flex items-center gap-1">
</div>
{assignedNodes.map((node) => (
<Card key={node.hostname} className="p-4">
<div className="flex items-center gap-4">
<div className="p-2 bg-muted rounded-lg">
{getRoleIcon(node.role)}
</div>
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<h4 className="font-medium">{node.hostname}</h4>
<Badge variant="outline" className="text-xs">
OS: {node.osVersion}
{node.role}
</Badge>
</span>
)}
{getStatusIcon(node.status)}
</div>
<div className="text-sm text-muted-foreground mb-2">
IP: {node.target_ip}
</div>
{node.hardware && (
<div className="flex items-center gap-4 text-xs text-muted-foreground">
{node.hardware.cpu && (
<span className="flex items-center gap-1">
<Cpu className="h-3 w-3" />
{node.hardware.cpu}
</span>
)}
{node.hardware.memory && (
<span className="flex items-center gap-1">
<Monitor className="h-3 w-3" />
{node.hardware.memory}
</span>
)}
{node.hardware.disk && (
<span className="flex items-center gap-1">
<HardDrive className="h-3 w-3" />
{node.hardware.disk}
</span>
)}
</div>
)}
{node.talosVersion && (
<div className="text-xs text-muted-foreground mt-1">
Talos: {node.talosVersion}
{node.kubernetesVersion && ` • K8s: ${node.kubernetesVersion}`}
</div>
)}
</div>
<div className="flex items-center gap-3">
{getStatusBadge(node.status)}
<Button
size="sm"
variant="destructive"
onClick={() => handleDeleteNode(node.hostname)}
disabled={isDeleting}
>
{isDeleting ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Remove'}
</Button>
</div>
</div>
</div>
<div className="flex items-center gap-3">
{getStatusBadge(node.status)}
{node.osVersion !== currentOsVersion && (
<Button
size="sm"
onClick={() => handleNodeAction(node.id, 'upgrade_node')}
>
Upgrade OS
</Button>
)}
{node.status === 'error' && (
<Button
size="sm"
variant="outline"
onClick={() => handleNodeAction(node.id, 'retry')}
>
Retry
</Button>
)}
</div>
</div>
</Card>
))}
</div>
</Card>
))}
<h2 className="text-lg font-medium mb-4 mt-6">Unassigned Nodes ({unassignedNodes.length}/{totalNodes})</h2>
<div className="space-y-4">
{unassignedNodes.map((node) => (
<Card key={node.id} className="p-4">
<div className="flex items-center gap-4">
<div className="p-2 bg-muted rounded-lg">
{getTypeIcon(node.type)}
</div>
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<h4 className="font-medium">{node.name}</h4>
<Badge variant="outline" className="text-xs">
{node.type}
</Badge>
{getStatusIcon(node.status)}
</div>
<div className="text-sm text-muted-foreground mb-2">
MAC: {node.macAddress}
{node.ipAddress && ` • IP: ${node.ipAddress}`}
</div>
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<span className="flex items-center gap-1">
<Cpu className="h-3 w-3" />
{node.specs.cpu}
</span>
<span className="flex items-center gap-1">
<Monitor className="h-3 w-3" />
{node.specs.memory}
</span>
<span className="flex items-center gap-1">
<HardDrive className="h-3 w-3" />
{node.specs.storage}
</span>
</div>
</div>
<div className="flex items-center gap-3">
{getStatusBadge(node.status)}
{node.status === 'pending' && (
<Button
size="sm"
onClick={() => handleNodeAction(node.id, 'connect')}
>
Assign
</Button>
)}
{node.status === 'error' && (
<Button
size="sm"
variant="outline"
onClick={() => handleNodeAction(node.id, 'retry')}
>
Retry
</Button>
)}
</div>
</div>
</Card>
))}
</div>
{isComplete && (
<div className="mt-6 p-4 bg-green-50 dark:bg-green-950 rounded-lg border border-green-200 dark:border-green-800">
<div className="flex items-center gap-2 mb-2">
<CheckCircle className="h-5 w-5 text-green-600" />
<h3 className="font-medium text-green-800 dark:text-green-200">
Infrastructure Ready!
</h3>
{assignedNodes.length === 0 && (
<Card className="p-8 text-center">
<Network className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<h3 className="text-lg font-medium mb-2">No Nodes</h3>
<p className="text-muted-foreground mb-4">
Use the discover or auto-detect buttons above to find nodes on your network.
</p>
</Card>
)}
</div>
<p className="text-sm text-green-700 dark:text-green-300 mb-3">
All nodes are connected and ready for Kubernetes installation.
</p>
<Button onClick={onComplete} className="bg-green-600 hover:bg-green-700">
Continue to Kubernetes Installation
</Button>
</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>

View File

@@ -1,128 +1,128 @@
import { useState } from 'react';
import { Card } from './ui/card';
import { Button } from './ui/button';
import { Badge } from './ui/badge';
import { Container, Shield, Network, Database, CheckCircle, AlertCircle, Clock, Terminal, FileText, BookOpen, ExternalLink } from 'lucide-react';
import { Container, Shield, Network, Database, CheckCircle, AlertCircle, Terminal, BookOpen, ExternalLink, Loader2 } from 'lucide-react';
import { useInstanceContext } from '../hooks/useInstanceContext';
import { useServices } from '../hooks/useServices';
import type { Service } from '../services/api';
interface ClusterServicesComponentProps {
onComplete?: () => void;
}
export function ClusterServicesComponent() {
const { currentInstance } = useInstanceContext();
const {
services,
isLoading,
error,
installService,
isInstalling,
installAll,
isInstallingAll,
deleteService,
isDeleting
} = useServices(currentInstance);
interface ClusterComponent {
id: string;
name: string;
description: string;
status: 'pending' | 'installing' | 'ready' | 'error';
version?: string;
logs?: string[];
}
export function ClusterServicesComponent({ onComplete }: ClusterServicesComponentProps) {
const [components, setComponents] = useState<ClusterComponent[]>([
{
id: 'talos-config',
name: 'Talos Configuration',
description: 'Generate and apply Talos cluster configuration',
status: 'pending',
},
{
id: 'kubernetes-bootstrap',
name: 'Kubernetes Bootstrap',
description: 'Initialize Kubernetes control plane',
status: 'pending',
version: 'v1.29.0',
},
{
id: 'cni-plugin',
name: 'Container Network Interface',
description: 'Install and configure Cilium CNI',
status: 'pending',
version: 'v1.14.5',
},
{
id: 'storage-class',
name: 'Storage Classes',
description: 'Configure persistent volume storage',
status: 'pending',
},
{
id: 'ingress-controller',
name: 'Ingress Controller',
description: 'Install Traefik ingress controller',
status: 'pending',
version: 'v3.0.0',
},
{
id: 'monitoring',
name: 'Cluster Monitoring',
description: 'Deploy Prometheus and Grafana stack',
status: 'pending',
},
]);
const [showLogs, setShowLogs] = useState<string | null>(null);
const getStatusIcon = (status: ClusterComponent['status']) => {
const getStatusIcon = (status?: string) => {
switch (status) {
case 'running':
case 'ready':
return <CheckCircle className="h-5 w-5 text-green-500" />;
case 'error':
return <AlertCircle className="h-5 w-5 text-red-500" />;
case 'deploying':
case 'installing':
return <Clock className="h-5 w-5 text-blue-500 animate-spin" />;
return <Loader2 className="h-5 w-5 text-blue-500 animate-spin" />;
default:
return null;
}
};
const getStatusBadge = (status: ClusterComponent['status']) => {
const variants = {
pending: 'secondary',
const getStatusBadge = (service: Service) => {
const status = service.status?.status || (service.deployed ? 'deployed' : 'available');
const variants: Record<string, 'secondary' | 'default' | 'success' | 'destructive' | 'outline'> = {
available: 'secondary',
deploying: 'default',
installing: 'default',
running: 'success',
ready: 'success',
error: 'destructive',
} as const;
deployed: 'outline',
};
const labels = {
pending: 'Pending',
const labels: Record<string, string> = {
available: 'Available',
deploying: 'Deploying',
installing: 'Installing',
running: 'Running',
ready: 'Ready',
error: 'Error',
deployed: 'Deployed',
};
return (
<Badge variant={variants[status] as any}>
{labels[status]}
<Badge variant={variants[status]}>
{labels[status] || status}
</Badge>
);
};
const getComponentIcon = (id: string) => {
switch (id) {
case 'talos-config':
return <FileText className="h-5 w-5" />;
case 'kubernetes-bootstrap':
return <Container className="h-5 w-5" />;
case 'cni-plugin':
return <Network className="h-5 w-5" />;
case 'storage-class':
return <Database className="h-5 w-5" />;
case 'ingress-controller':
return <Shield className="h-5 w-5" />;
case 'monitoring':
return <Terminal className="h-5 w-5" />;
default:
return <Container className="h-5 w-5" />;
const getServiceIcon = (name: string) => {
const lowerName = name.toLowerCase();
if (lowerName.includes('network') || lowerName.includes('cni') || lowerName.includes('cilium')) {
return <Network className="h-5 w-5" />;
} else if (lowerName.includes('storage') || lowerName.includes('volume')) {
return <Database className="h-5 w-5" />;
} else if (lowerName.includes('ingress') || lowerName.includes('traefik') || lowerName.includes('nginx')) {
return <Shield className="h-5 w-5" />;
} else if (lowerName.includes('monitor') || lowerName.includes('prometheus') || lowerName.includes('grafana')) {
return <Terminal className="h-5 w-5" />;
} else {
return <Container className="h-5 w-5" />;
}
};
const handleComponentAction = (componentId: string, action: 'install' | 'retry') => {
console.log(`${action} component: ${componentId}`);
const handleInstallService = (serviceName: string) => {
if (!currentInstance) return;
installService({ name: serviceName });
};
const readyComponents = components.filter(component => component.status === 'ready').length;
const totalComponents = components.length;
const isComplete = readyComponents === totalComponents;
const handleDeleteService = (serviceName: string) => {
if (!currentInstance) return;
if (confirm(`Are you sure you want to delete service ${serviceName}?`)) {
deleteService(serviceName);
}
};
const handleInstallAll = () => {
if (!currentInstance) return;
installAll();
};
// Show message if no instance is selected
if (!currentInstance) {
return (
<Card className="p-8 text-center">
<Container className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<h3 className="text-lg font-medium mb-2">No Instance Selected</h3>
<p className="text-muted-foreground mb-4">
Please select or create an instance to manage services.
</p>
</Card>
);
}
// Show error state
if (error) {
return (
<Card className="p-8 text-center">
<AlertCircle className="h-12 w-12 text-red-500 mx-auto mb-4" />
<h3 className="text-lg font-medium mb-2">Error Loading Services</h3>
<p className="text-muted-foreground mb-4">
{(error as Error)?.message || 'An error occurred'}
</p>
<Button onClick={() => window.location.reload()}>Reload Page</Button>
</Card>
);
}
return (
<div className="space-y-6">
@@ -167,108 +167,91 @@ export function ClusterServicesComponent({ onComplete }: ClusterServicesComponen
</div>
<div className="flex items-center justify-between mb-4">
<pre className="text-xs text-muted-foreground bg-muted p-2 rounded-lg">
endpoint: civil<br/>
endpointIp: 192.168.8.240<br/>
kubernetes:<br/>
config: /home/payne/.kube/config<br/>
context: default<br/>
loadBalancerRange: 192.168.8.240-192.168.8.250<br/>
dashboard:<br/>
adminUsername: admin<br/>
certManager:<br/>
namespace: cert-manager<br/>
cloudflare:<br/>
domain: payne.io<br/>
ownerId: cloud-payne-io-cluster<br/>
</pre>
</div>
<div className="space-y-4">
{components.map((component) => (
<div key={component.id}>
<div className="flex items-center gap-4 p-4 rounded-lg border bg-card">
<div className="p-2 bg-muted rounded-lg">
{getComponentIcon(component.id)}
</div>
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<h3 className="font-medium">{component.name}</h3>
{component.version && (
<Badge variant="outline" className="text-xs">
{component.version}
</Badge>
)}
{getStatusIcon(component.status)}
</div>
<p className="text-sm text-muted-foreground">{component.description}</p>
</div>
<div className="flex items-center gap-3">
{getStatusBadge(component.status)}
{(component.status === 'installing' || component.status === 'error') && (
<Button
size="sm"
variant="outline"
onClick={() => setShowLogs(showLogs === component.id ? null : component.id)}
>
<Terminal className="h-4 w-4 mr-1" />
Logs
</Button>
)}
{component.status === 'pending' && (
<Button
size="sm"
onClick={() => handleComponentAction(component.id, 'install')}
>
Install
</Button>
)}
{component.status === 'error' && (
<Button
size="sm"
variant="outline"
onClick={() => handleComponentAction(component.id, 'retry')}
>
Retry
</Button>
)}
</div>
</div>
{showLogs === component.id && (
<Card className="mt-2 p-4 bg-black text-green-400 font-mono text-sm">
<div className="max-h-40 overflow-y-auto">
<div>Installing {component.name}...</div>
<div> Checking prerequisites</div>
<div> Downloading manifests</div>
{component.status === 'installing' && (
<div className="animate-pulse"> Applying configuration...</div>
)}
{component.status === 'error' && (
<div className="text-red-400"> Installation failed: timeout waiting for pods</div>
)}
</div>
</Card>
)}
</div>
))}
<div className="text-sm text-muted-foreground">
{isLoading ? (
<span className="flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
Loading services...
</span>
) : (
`${services.length} services available`
)}
</div>
<Button
size="sm"
onClick={handleInstallAll}
disabled={isInstallingAll || services.length === 0}
>
{isInstallingAll ? (
<Loader2 className="h-4 w-4 animate-spin mr-2" />
) : null}
Install All
</Button>
</div>
{isComplete && (
<div className="mt-6 p-4 bg-green-50 dark:bg-green-950 rounded-lg border border-green-200 dark:border-green-800">
<div className="flex items-center gap-2 mb-2">
<CheckCircle className="h-5 w-5 text-green-600" />
<h3 className="font-medium text-green-800 dark:text-green-200">
Kubernetes Cluster Ready!
</h3>
</div>
<p className="text-sm text-green-700 dark:text-green-300 mb-3">
Your Kubernetes cluster is fully configured and ready for application deployment.
</p>
<Button onClick={onComplete} className="bg-green-600 hover:bg-green-700">
Continue to App Management
</Button>
{isLoading ? (
<Card className="p-8 text-center">
<Loader2 className="h-12 w-12 text-primary mx-auto mb-4 animate-spin" />
<p className="text-muted-foreground">Loading services...</p>
</Card>
) : (
<div className="space-y-4">
{services.map((service) => (
<div key={service.name}>
<div className="flex items-center gap-4 p-4 rounded-lg border bg-card">
<div className="p-2 bg-muted rounded-lg">
{getServiceIcon(service.name)}
</div>
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<h3 className="font-medium">{service.name}</h3>
{service.version && (
<Badge variant="outline" className="text-xs">
{service.version}
</Badge>
)}
{getStatusIcon(service.status?.status)}
</div>
<p className="text-sm text-muted-foreground">{service.description}</p>
{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 && (
<Button
size="sm"
onClick={() => handleInstallService(service.name)}
disabled={isInstalling}
>
{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>
)}
</div>
</div>
</div>
))}
{services.length === 0 && (
<Card className="p-8 text-center">
<Container className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<h3 className="text-lg font-medium mb-2">No Services Available</h3>
<p className="text-muted-foreground">
No cluster services are configured for this instance.
</p>
</Card>
)}
</div>
)}
</Card>

View File

@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react';
import { Settings, Save, X } from 'lucide-react';
import { Settings } from 'lucide-react';
import { useConfigYaml } from '../hooks';
import { Button, Textarea } from './ui';
import {

View File

@@ -0,0 +1,17 @@
import { Card } from './ui/card';
import { cn } from '@/lib/utils';
interface ConfigViewerProps {
content: string;
className?: string;
}
export function ConfigViewer({ content, className }: ConfigViewerProps) {
return (
<Card className={cn('p-4', className)}>
<pre className="text-xs overflow-auto max-h-96 whitespace-pre-wrap break-all">
<code>{content}</code>
</pre>
</Card>
);
}

View File

@@ -1,7 +1,7 @@
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { FileText, Check, AlertCircle, Loader2 } from 'lucide-react';
import { useConfig, useMessages } from '../hooks';
import { useConfig } from '../hooks';
import { configFormSchema, defaultConfigValues, type ConfigFormData } from '../schemas/config';
import {
Card,

View File

@@ -0,0 +1,49 @@
import { useState } from 'react';
import { Copy, Check } from 'lucide-react';
import { Button } from './ui/button';
interface CopyButtonProps {
content: string;
label?: string;
variant?: 'default' | 'outline' | 'secondary' | 'ghost';
disabled?: boolean;
}
export function CopyButton({
content,
label = 'Copy',
variant = 'outline',
disabled = false,
}: CopyButtonProps) {
const [copied, setCopied] = useState(false);
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(content);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (error) {
console.error('Failed to copy:', error);
}
};
return (
<Button
onClick={handleCopy}
variant={variant}
disabled={disabled}
>
{copied ? (
<>
<Check className="h-4 w-4" />
Copied
</>
) : (
<>
<Copy className="h-4 w-4" />
{label}
</>
)}
</Button>
);
}

View File

@@ -55,7 +55,7 @@ export function DhcpComponent() {
<div>
<Label htmlFor="dhcpRange">IP Range</Label>
<div className="flex w-full items-center mt-1">
<Input id="dhcpRange" value="192.168.8.100,192.168.8.239"/>
<Input id="dhcpRange" value="192.168.8.100,192.168.8.239" readOnly/>
<Button variant="ghost">
<HelpCircle/>
</Button>

View File

@@ -0,0 +1,41 @@
import { Download } from 'lucide-react';
import { Button } from './ui/button';
interface DownloadButtonProps {
content: string;
filename: string;
label?: string;
variant?: 'default' | 'outline' | 'secondary' | 'ghost';
disabled?: boolean;
}
export function DownloadButton({
content,
filename,
label = 'Download',
variant = 'default',
disabled = false,
}: DownloadButtonProps) {
const handleDownload = () => {
const blob = new Blob([content], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
};
return (
<Button
onClick={handleDownload}
variant={variant}
disabled={disabled}
>
<Download className="h-4 w-4" />
{label}
</Button>
);
}

View File

@@ -0,0 +1,136 @@
import { useState } from 'react';
import { Card } from './ui/card';
import { Button } from './ui/button';
import { Badge } from './ui/badge';
import { Cloud, Plus, Check, Loader2, AlertCircle } from 'lucide-react';
import { useInstanceContext } from '../hooks/useInstanceContext';
import { useInstances } from '../hooks/useInstances';
export function InstanceSelector() {
const { currentInstance, setCurrentInstance } = useInstanceContext();
const { instances, isLoading, error, createInstance, isCreating } = useInstances();
const [showCreateForm, setShowCreateForm] = useState(false);
const [newInstanceName, setNewInstanceName] = useState('');
const handleSelectInstance = (name: string) => {
setCurrentInstance(name);
};
const handleCreateInstance = () => {
if (!newInstanceName.trim()) return;
createInstance({ name: newInstanceName.trim() });
setShowCreateForm(false);
setNewInstanceName('');
};
if (isLoading) {
return (
<Card className="p-4">
<div className="flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
<span className="text-sm">Loading instances...</span>
</div>
</Card>
);
}
if (error) {
return (
<Card className="p-4 border-red-200 bg-red-50 dark:bg-red-950/20">
<div className="flex items-center gap-2">
<AlertCircle className="h-4 w-4 text-red-500" />
<span className="text-sm text-red-700 dark:text-red-300">
Error loading instances: {(error as Error).message}
</span>
</div>
</Card>
);
}
return (
<Card className="p-4">
<div className="flex items-center gap-4">
<Cloud className="h-5 w-5 text-primary" />
<div className="flex-1">
<label className="text-sm font-medium mb-1 block">Instance</label>
<select
value={currentInstance || ''}
onChange={(e) => handleSelectInstance(e.target.value)}
className="w-full px-3 py-2 border rounded-lg bg-background"
>
<option value="">Select an instance...</option>
{instances.map((instance) => (
<option key={instance} value={instance}>
{instance}
</option>
))}
</select>
</div>
{currentInstance && (
<Badge variant="success" className="whitespace-nowrap">
<Check className="h-3 w-3 mr-1" />
Active
</Badge>
)}
<Button
size="sm"
variant="outline"
onClick={() => setShowCreateForm(!showCreateForm)}
>
<Plus className="h-4 w-4 mr-1" />
New
</Button>
</div>
{showCreateForm && (
<div className="mt-4 pt-4 border-t">
<div className="flex gap-2">
<input
type="text"
placeholder="Instance name"
value={newInstanceName}
onChange={(e) => setNewInstanceName(e.target.value)}
className="flex-1 px-3 py-2 border rounded-lg"
onKeyDown={(e) => {
if (e.key === 'Enter' && newInstanceName.trim()) {
handleCreateInstance();
}
}}
/>
<Button
size="sm"
onClick={handleCreateInstance}
disabled={!newInstanceName.trim() || isCreating}
>
{isCreating ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Create'}
</Button>
<Button
size="sm"
variant="outline"
onClick={() => {
setShowCreateForm(false);
setNewInstanceName('');
}}
>
Cancel
</Button>
</div>
</div>
)}
{instances.length === 0 && !showCreateForm && (
<div className="mt-4 pt-4 border-t text-center">
<p className="text-sm text-muted-foreground mb-2">
No instances found. Create your first instance to get started.
</p>
<Button size="sm" onClick={() => setShowCreateForm(true)}>
<Plus className="h-4 w-4 mr-1" />
Create Instance
</Button>
</div>
)}
</Card>
);
}

View File

@@ -0,0 +1,53 @@
import { useState } from 'react';
import { Eye, EyeOff } from 'lucide-react';
import { Input } from './ui/input';
import { Button } from './ui/button';
import { cn } from '@/lib/utils';
interface SecretInputProps {
value: string;
onChange?: (value: string) => void;
placeholder?: string;
readOnly?: boolean;
className?: string;
}
export function SecretInput({
value,
onChange,
placeholder = '••••••••',
readOnly = false,
className,
}: SecretInputProps) {
const [revealed, setRevealed] = useState(false);
// If no onChange handler provided, the field should be read-only
const isReadOnly = readOnly || !onChange;
return (
<div className="relative flex items-center gap-2">
<Input
type={revealed ? 'text' : 'password'}
value={value}
onChange={onChange ? (e) => onChange(e.target.value) : undefined}
placeholder={placeholder}
readOnly={isReadOnly}
className={cn('pr-10', className)}
/>
<Button
type="button"
variant="ghost"
size="icon"
className="absolute right-0 h-full hover:bg-transparent"
onClick={() => setRevealed(!revealed)}
aria-label={revealed ? 'Hide value' : 'Show value'}
>
{revealed ? (
<EyeOff className="h-4 w-4 text-muted-foreground" />
) : (
<Eye className="h-4 w-4 text-muted-foreground" />
)}
</Button>
</div>
);
}

View File

@@ -0,0 +1,84 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from './ui/card';
import { Badge } from './ui/badge';
import { Button } from './ui/button';
import { Loader2 } from 'lucide-react';
import type { Service } from '@/services/api/types';
interface ServiceCardProps {
service: Service;
onInstall?: () => void;
isInstalling?: boolean;
}
export function ServiceCard({ service, onInstall, isInstalling = false }: ServiceCardProps) {
const getStatusColor = (status?: string) => {
switch (status) {
case 'running':
return 'default';
case 'deploying':
return 'secondary';
case 'error':
return 'destructive';
case 'stopped':
return 'outline';
default:
return 'outline';
}
};
const isInstalled = service.deployed || service.status?.status === 'running';
const canInstall = !isInstalled && !isInstalling;
return (
<Card>
<CardHeader>
<div className="flex items-start justify-between">
<div className="space-y-1">
<CardTitle>{service.name}</CardTitle>
{service.version && (
<CardDescription className="text-xs">v{service.version}</CardDescription>
)}
</div>
{service.status && (
<Badge variant={getStatusColor(service.status.status)}>
{service.status.status}
</Badge>
)}
</div>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-muted-foreground">{service.description}</p>
{service.status?.message && (
<p className="text-xs text-muted-foreground italic">{service.status.message}</p>
)}
<div className="flex gap-2">
{canInstall && (
<Button
onClick={onInstall}
disabled={isInstalling}
size="sm"
className="w-full"
>
{isInstalling ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Installing...
</>
) : (
'Install'
)}
</Button>
)}
{isInstalled && (
<Button variant="outline" size="sm" className="w-full" disabled>
Installed
</Button>
)}
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,120 @@
import { ReactNode } from 'react';
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from './ui/card';
import { Button } from './ui/button';
import { Loader2, Copy, Check, AlertCircle } from 'lucide-react';
import { useState } from 'react';
interface UtilityCardProps {
title: string;
description: string;
icon: ReactNode;
action?: {
label: string;
onClick: () => void;
disabled?: boolean;
loading?: boolean;
};
children?: ReactNode;
error?: Error | null;
isLoading?: boolean;
}
export function UtilityCard({
title,
description,
icon,
action,
children,
error,
isLoading,
}: UtilityCardProps) {
return (
<Card>
<CardHeader>
<div className="flex items-start gap-3">
<div className="p-2 bg-primary/10 rounded-lg">
{icon}
</div>
<div className="flex-1">
<CardTitle>{title}</CardTitle>
<CardDescription>{description}</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
{isLoading ? (
<div className="flex items-center justify-center py-4">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : error ? (
<div className="flex items-center gap-2 text-red-500 text-sm">
<AlertCircle className="h-4 w-4" />
<span>{error.message}</span>
</div>
) : (
children
)}
{action && (
<Button
onClick={action.onClick}
disabled={action.disabled || action.loading || isLoading}
className="w-full"
>
{action.loading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
action.label
)}
</Button>
)}
</CardContent>
</Card>
);
}
interface CopyableValueProps {
value: string;
label?: string;
multiline?: boolean;
}
export function CopyableValue({ value, label, multiline = false }: CopyableValueProps) {
const [copied, setCopied] = useState(false);
const handleCopy = async () => {
await navigator.clipboard.writeText(value);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<div className="space-y-2">
{label && <div className="text-sm font-medium">{label}</div>}
<div className="flex items-start gap-2">
<div
className={`flex-1 p-3 bg-muted rounded-lg font-mono text-sm ${
multiline ? '' : 'truncate'
}`}
>
{multiline ? (
<pre className="whitespace-pre-wrap break-all">{value}</pre>
) : (
<span className="block truncate">{value}</span>
)}
</div>
<Button
variant="outline"
size="sm"
onClick={handleCopy}
className="shrink-0"
>
{copied ? (
<Check className="h-4 w-4 text-green-500" />
) : (
<Copy className="h-4 w-4" />
)}
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,173 @@
import { useState, useEffect } from 'react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogDescription,
} from '../ui/dialog';
import { Button } from '../ui/button';
import { Input } from '../ui/input';
import { Label } from '../ui/label';
import { Loader2, Info } from 'lucide-react';
import type { App } from '../../services/api';
interface AppConfigDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
app: App | null;
existingConfig?: Record<string, string>;
onSave: (config: Record<string, string>) => void;
isSaving?: boolean;
}
export function AppConfigDialog({
open,
onOpenChange,
app,
existingConfig,
onSave,
isSaving = false,
}: AppConfigDialogProps) {
const [config, setConfig] = useState<Record<string, string>>({});
// Initialize config when dialog opens or app changes
useEffect(() => {
if (app && open) {
const initialConfig: Record<string, string> = {};
// Start with default config
if (app.defaultConfig) {
Object.entries(app.defaultConfig).forEach(([key, value]) => {
initialConfig[key] = String(value);
});
}
// Override with existing config if provided
if (existingConfig) {
Object.entries(existingConfig).forEach(([key, value]) => {
initialConfig[key] = value;
});
}
setConfig(initialConfig);
}
}, [app, existingConfig, open]);
const handleSave = () => {
onSave(config);
};
const handleChange = (key: string, value: string) => {
setConfig(prev => ({ ...prev, [key]: value }));
};
// Convert snake_case to Title Case for labels
const formatLabel = (key: string): string => {
return key
.split('_')
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
};
if (!app) return null;
const configKeys = Object.keys(app.defaultConfig || {});
const hasConfig = configKeys.length > 0;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Configure {app.name}</DialogTitle>
<DialogDescription>
{app.description}
</DialogDescription>
</DialogHeader>
{hasConfig ? (
<div className="space-y-4 py-4">
{configKeys.map((key) => {
const isRequired = app.requiredSecrets?.some(secret =>
secret.toLowerCase().includes(key.toLowerCase())
);
return (
<div key={key} className="space-y-2">
<div className="flex items-center gap-2">
<Label htmlFor={key}>
{formatLabel(key)}
{isRequired && <span className="text-red-500">*</span>}
</Label>
{isRequired && (
<span title="Required for secrets generation">
<Info className="h-3 w-3 text-muted-foreground" />
</span>
)}
</div>
<Input
id={key}
value={config[key] || ''}
onChange={(e) => handleChange(key, e.target.value)}
placeholder={String(app.defaultConfig?.[key] || '')}
required={isRequired}
/>
{isRequired && (
<p className="text-xs text-muted-foreground">
This value is used to generate application secrets
</p>
)}
</div>
);
})}
{app.dependencies && app.dependencies.length > 0 && (
<div className="mt-6 p-4 bg-blue-50 dark:bg-blue-950/20 rounded-lg border border-blue-200 dark:border-blue-800">
<h4 className="text-sm font-medium text-blue-900 dark:text-blue-100 mb-2">
Dependencies
</h4>
<p className="text-sm text-blue-800 dark:text-blue-200 mb-2">
This app requires the following apps to be deployed first:
</p>
<ul className="text-sm text-blue-700 dark:text-blue-300 list-disc list-inside">
{app.dependencies.map(dep => (
<li key={dep}>{dep}</li>
))}
</ul>
</div>
)}
</div>
) : (
<div className="py-8 text-center text-muted-foreground">
<p>This app doesn't require any configuration.</p>
<p className="text-sm mt-2">Click Add to proceed with default settings.</p>
</div>
)}
<DialogFooter>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isSaving}
>
Cancel
</Button>
<Button
onClick={handleSave}
disabled={isSaving}
>
{isSaving ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Saving...
</>
) : (
existingConfig ? 'Update' : 'Add App'
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -16,4 +16,9 @@ export { DhcpComponent } from './DhcpComponent';
export { PxeComponent } from './PxeComponent';
export { ClusterNodesComponent } from './ClusterNodesComponent';
export { ClusterServicesComponent } from './ClusterServicesComponent';
export { AppsComponent } from './AppsComponent';
export { AppsComponent } from './AppsComponent';
export { SecretInput } from './SecretInput';
export { ConfigViewer } from './ConfigViewer';
export { DownloadButton } from './DownloadButton';
export { CopyButton } from './CopyButton';
export { ServiceCard } from './ServiceCard';

View File

@@ -0,0 +1,65 @@
import { Badge } from '../ui/badge';
import { CheckCircle, AlertTriangle, XCircle } from 'lucide-react';
interface HealthIndicatorProps {
status: 'healthy' | 'degraded' | 'unhealthy' | 'passing' | 'warning' | 'failing';
size?: 'sm' | 'md' | 'lg';
showIcon?: boolean;
}
export function HealthIndicator({ status, size = 'md', showIcon = true }: HealthIndicatorProps) {
const getHealthConfig = () => {
// Normalize status to common values
const normalizedStatus =
status === 'passing' ? 'healthy' :
status === 'warning' ? 'degraded' :
status === 'failing' ? 'unhealthy' :
status;
switch (normalizedStatus) {
case 'healthy':
return {
variant: 'outline' as const,
icon: CheckCircle,
className: 'border-green-500 text-green-700 dark:text-green-400',
label: 'Healthy',
};
case 'degraded':
return {
variant: 'secondary' as const,
icon: AlertTriangle,
className: 'border-yellow-500 text-yellow-700 dark:text-yellow-400',
label: 'Degraded',
};
case 'unhealthy':
return {
variant: 'destructive' as const,
icon: XCircle,
className: 'border-red-500',
label: 'Unhealthy',
};
default:
return {
variant: 'secondary' as const,
icon: AlertTriangle,
className: '',
label: status.charAt(0).toUpperCase() + status.slice(1),
};
}
};
const config = getHealthConfig();
const Icon = config.icon;
const iconSize =
size === 'sm' ? 'h-3 w-3' :
size === 'lg' ? 'h-5 w-5' :
'h-4 w-4';
return (
<Badge variant={config.variant} className={config.className}>
{showIcon && <Icon className={iconSize} />}
{config.label}
</Badge>
);
}

View File

@@ -0,0 +1,97 @@
import { Card, CardContent, CardHeader, CardTitle } from '../ui/card';
import { Badge } from '../ui/badge';
import { Server, Cpu, HardDrive, MemoryStick } from 'lucide-react';
import type { Node } from '../../services/api/types';
import { HealthIndicator } from './HealthIndicator';
interface NodeStatusCardProps {
node: Node;
showHardware?: boolean;
}
export function NodeStatusCard({ node, showHardware = true }: NodeStatusCardProps) {
const getRoleBadgeVariant = (role: string) => {
return role === 'controlplane' ? 'default' : 'secondary';
};
return (
<Card>
<CardHeader>
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-3 flex-1 min-w-0">
<Server className="h-5 w-5 text-muted-foreground shrink-0 mt-0.5" />
<div className="flex-1 min-w-0">
<CardTitle className="text-base truncate">
{node.hostname}
</CardTitle>
<p className="text-sm text-muted-foreground mt-1 font-mono">
{node.target_ip}
</p>
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<Badge variant={getRoleBadgeVariant(node.role)}>
{node.role}
</Badge>
{(node.maintenance || node.configured || node.applied) && (
<HealthIndicator
status={node.applied ? 'healthy' : node.configured ? 'degraded' : 'unhealthy'}
size="sm"
/>
)}
</div>
</div>
</CardHeader>
<CardContent className="space-y-3">
{/* Version Information */}
<div className="grid grid-cols-2 gap-2 text-sm">
{node.talosVersion && (
<div>
<span className="text-muted-foreground">Talos:</span>{' '}
<span className="font-mono text-xs">{node.talosVersion}</span>
</div>
)}
{node.kubernetesVersion && (
<div>
<span className="text-muted-foreground">K8s:</span>{' '}
<span className="font-mono text-xs">{node.kubernetesVersion}</span>
</div>
)}
</div>
{/* Hardware Information */}
{showHardware && node.hardware && (
<div className="pt-3 border-t space-y-2">
{node.hardware.cpu && (
<div className="flex items-center gap-2 text-sm">
<Cpu className="h-4 w-4 text-muted-foreground" />
<span className="text-muted-foreground">CPU:</span>
<span className="text-xs truncate">{node.hardware.cpu}</span>
</div>
)}
{node.hardware.memory && (
<div className="flex items-center gap-2 text-sm">
<MemoryStick className="h-4 w-4 text-muted-foreground" />
<span className="text-muted-foreground">Memory:</span>
<span className="text-xs">{node.hardware.memory}</span>
</div>
)}
{node.hardware.disk && (
<div className="flex items-center gap-2 text-sm">
<HardDrive className="h-4 w-4 text-muted-foreground" />
<span className="text-muted-foreground">Disk:</span>
<span className="text-xs">{node.hardware.disk}</span>
</div>
)}
{node.hardware.manufacturer && node.hardware.model && (
<div className="text-xs text-muted-foreground pt-1">
{node.hardware.manufacturer} {node.hardware.model}
</div>
)}
</div>
)}
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,149 @@
import { Card, CardContent, CardHeader, CardTitle } from '../ui/card';
import { Badge } from '../ui/badge';
import { Button } from '../ui/button';
import { Loader2, CheckCircle, AlertCircle, XCircle, Clock, ChevronDown, ChevronUp } from 'lucide-react';
import { useCancelOperation, type Operation } from '../../services/api';
import { useState } from 'react';
interface OperationCardProps {
operation: Operation;
expandable?: boolean;
}
export function OperationCard({ operation, expandable = false }: OperationCardProps) {
const [isExpanded, setIsExpanded] = useState(false);
const { mutate: cancelOperation, isPending: isCancelling } = useCancelOperation();
const getStatusIcon = () => {
switch (operation.status) {
case 'pending':
return <Clock className="h-4 w-4 text-gray-500" />;
case 'running':
return <Loader2 className="h-4 w-4 animate-spin text-blue-500" />;
case 'completed':
return <CheckCircle className="h-4 w-4 text-green-500" />;
case 'failed':
return <AlertCircle className="h-4 w-4 text-red-500" />;
case 'cancelled':
return <XCircle className="h-4 w-4 text-orange-500" />;
default:
return null;
}
};
const getStatusBadge = () => {
const variants: Record<string, 'secondary' | 'default' | 'destructive' | 'outline'> = {
pending: 'secondary',
running: 'default',
completed: 'outline',
failed: 'destructive',
cancelled: 'secondary',
};
return (
<Badge variant={variants[operation.status]}>
{operation.status.charAt(0).toUpperCase() + operation.status.slice(1)}
</Badge>
);
};
const canCancel = operation.status === 'pending' || operation.status === 'running';
return (
<Card>
<CardHeader>
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-3 flex-1 min-w-0">
{getStatusIcon()}
<div className="flex-1 min-w-0">
<CardTitle className="text-base">
{operation.type}
</CardTitle>
{operation.target && (
<p className="text-sm text-muted-foreground mt-1">
Target: {operation.target}
</p>
)}
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
{getStatusBadge()}
{canCancel && (
<Button
size="sm"
variant="outline"
onClick={() => cancelOperation({ operationId: operation.id, instanceName: operation.instance_name })}
disabled={isCancelling}
>
{isCancelling ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
'Cancel'
)}
</Button>
)}
{expandable && (
<Button
size="sm"
variant="ghost"
onClick={() => setIsExpanded(!isExpanded)}
>
{isExpanded ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
</Button>
)}
</div>
</div>
</CardHeader>
<CardContent className="space-y-3">
{operation.message && (
<p className="text-sm text-muted-foreground">
{operation.message}
</p>
)}
{(operation.status === 'running' || operation.status === 'pending') && (
<div className="space-y-1">
<div className="flex justify-between text-xs text-muted-foreground">
<span>Progress</span>
<span>{operation.progress}%</span>
</div>
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
<div
className="bg-primary h-2 rounded-full transition-all duration-300"
style={{ width: `${operation.progress}%` }}
/>
</div>
</div>
)}
{operation.error && (
<div className="p-2 bg-red-50 dark:bg-red-950/20 rounded border border-red-200 dark:border-red-800">
<p className="text-xs text-red-700 dark:text-red-300">
{operation.error}
</p>
</div>
)}
{isExpanded && (
<div className="pt-3 border-t text-xs text-muted-foreground space-y-2">
<div className="flex justify-between">
<span>Operation ID:</span>
<span className="font-mono">{operation.id}</span>
</div>
<div className="flex justify-between">
<span>Started:</span>
<span>{new Date(operation.started).toLocaleString()}</span>
</div>
{operation.completed && (
<div className="flex justify-between">
<span>Completed:</span>
<span>{new Date(operation.completed).toLocaleString()}</span>
</div>
)}
</div>
)}
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,204 @@
import { Card } from '../ui/card';
import { Button } from '../ui/button';
import { Badge } from '../ui/badge';
import { Loader2, CheckCircle, AlertCircle, XCircle, Clock } from 'lucide-react';
import { useOperation } from '../../hooks/useOperations';
interface OperationProgressProps {
operationId: string;
onComplete?: () => void;
onError?: (error: string) => void;
showDetails?: boolean;
}
export function OperationProgress({
operationId,
onComplete,
onError,
showDetails = true
}: OperationProgressProps) {
const { operation, error, isLoading, cancel, isCancelling } = useOperation(operationId);
// Handle operation completion
if (operation?.status === 'completed' && onComplete) {
setTimeout(onComplete, 100); // Delay slightly to ensure state updates
}
// Handle operation error
if (operation?.status === 'failed' && onError && operation.error) {
setTimeout(() => onError(operation.error!), 100);
}
const getStatusIcon = () => {
if (isLoading) {
return <Loader2 className="h-5 w-5 animate-spin text-blue-500" />;
}
switch (operation?.status) {
case 'pending':
return <Clock className="h-5 w-5 text-gray-500" />;
case 'running':
return <Loader2 className="h-5 w-5 animate-spin text-blue-500" />;
case 'completed':
return <CheckCircle className="h-5 w-5 text-green-500" />;
case 'failed':
return <AlertCircle className="h-5 w-5 text-red-500" />;
case 'cancelled':
return <XCircle className="h-5 w-5 text-orange-500" />;
default:
return null;
}
};
const getStatusBadge = () => {
if (isLoading) {
return <Badge variant="default">Loading...</Badge>;
}
const variants: Record<string, 'secondary' | 'default' | 'success' | 'destructive' | 'warning'> = {
pending: 'secondary',
running: 'default',
completed: 'success',
failed: 'destructive',
cancelled: 'warning',
};
const labels: Record<string, string> = {
pending: 'Pending',
running: 'Running',
completed: 'Completed',
failed: 'Failed',
cancelled: 'Cancelled',
};
const status = operation?.status || 'pending';
return (
<Badge variant={variants[status]}>
{labels[status] || status}
</Badge>
);
};
const getProgressPercentage = () => {
if (!operation) return 0;
if (operation.status === 'completed') return 100;
if (operation.status === 'failed' || operation.status === 'cancelled') return 0;
return operation.progress || 0;
};
if (error) {
return (
<Card className="p-4 border-red-200 bg-red-50 dark:bg-red-950/20">
<div className="flex items-center gap-3">
<AlertCircle className="h-5 w-5 text-red-500" />
<div className="flex-1">
<p className="text-sm font-medium text-red-900 dark:text-red-100">
Error loading operation
</p>
<p className="text-xs text-red-700 dark:text-red-300 mt-1">
{error.message}
</p>
</div>
</div>
</Card>
);
}
if (isLoading) {
return (
<Card className="p-4">
<div className="flex items-center gap-3">
<Loader2 className="h-5 w-5 animate-spin text-primary" />
<span className="text-sm">Loading operation status...</span>
</div>
</Card>
);
}
const progressPercentage = getProgressPercentage();
const canCancel = operation?.status === 'pending' || operation?.status === 'running';
return (
<Card className="p-4">
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
{getStatusIcon()}
<div>
<p className="text-sm font-medium">
{operation?.type || 'Operation'}
</p>
{operation?.message && (
<p className="text-xs text-muted-foreground mt-0.5">
{operation.message}
</p>
)}
</div>
</div>
<div className="flex items-center gap-2">
{getStatusBadge()}
{canCancel && (
<Button
size="sm"
variant="outline"
onClick={() => cancel()}
disabled={isCancelling}
>
{isCancelling ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
'Cancel'
)}
</Button>
)}
</div>
</div>
{(operation?.status === 'running' || operation?.status === 'pending') && (
<div className="space-y-1">
<div className="flex justify-between text-xs text-muted-foreground">
<span>Progress</span>
<span>{progressPercentage}%</span>
</div>
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
<div
className="bg-primary h-2 rounded-full transition-all duration-300"
style={{ width: `${progressPercentage}%` }}
/>
</div>
</div>
)}
{operation?.error && (
<div className="p-2 bg-red-50 dark:bg-red-950/20 rounded border border-red-200 dark:border-red-800">
<p className="text-xs text-red-700 dark:text-red-300">
Error: {operation.error}
</p>
</div>
)}
{showDetails && operation && (
<div className="pt-2 border-t text-xs text-muted-foreground space-y-1">
<div className="flex justify-between">
<span>Operation ID:</span>
<span className="font-mono">{operation.id}</span>
</div>
{operation.started && (
<div className="flex justify-between">
<span>Started:</span>
<span>{new Date(operation.started).toLocaleString()}</span>
</div>
)}
{operation.completed && (
<div className="flex justify-between">
<span>Completed:</span>
<span>{new Date(operation.completed).toLocaleString()}</span>
</div>
)}
</div>
)}
</div>
</Card>
);
}

View File

@@ -0,0 +1,4 @@
export { OperationCard } from './OperationCard';
export { OperationProgress } from './OperationProgress';
export { HealthIndicator } from './HealthIndicator';
export { NodeStatusCard } from './NodeStatusCard';

View File

@@ -17,6 +17,10 @@ const badgeVariants = cva(
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
success:
"border-transparent bg-green-500 text-white [a&]:hover:bg-green-600 dark:bg-green-600 dark:[a&]:hover:bg-green-700",
warning:
"border-transparent bg-yellow-500 text-white [a&]:hover:bg-yellow-600 dark:bg-yellow-600 dark:[a&]:hover:bg-yellow-700",
},
},
defaultVariants: {