First swing.
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
158
src/components/BackupRestoreModal.tsx
Normal file
158
src/components/BackupRestoreModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
17
src/components/ConfigViewer.tsx
Normal file
17
src/components/ConfigViewer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
49
src/components/CopyButton.tsx
Normal file
49
src/components/CopyButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
41
src/components/DownloadButton.tsx
Normal file
41
src/components/DownloadButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
136
src/components/InstanceSelector.tsx
Normal file
136
src/components/InstanceSelector.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
53
src/components/SecretInput.tsx
Normal file
53
src/components/SecretInput.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
84
src/components/ServiceCard.tsx
Normal file
84
src/components/ServiceCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
120
src/components/UtilityCard.tsx
Normal file
120
src/components/UtilityCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
173
src/components/apps/AppConfigDialog.tsx
Normal file
173
src/components/apps/AppConfigDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
65
src/components/operations/HealthIndicator.tsx
Normal file
65
src/components/operations/HealthIndicator.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
97
src/components/operations/NodeStatusCard.tsx
Normal file
97
src/components/operations/NodeStatusCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
149
src/components/operations/OperationCard.tsx
Normal file
149
src/components/operations/OperationCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
204
src/components/operations/OperationProgress.tsx
Normal file
204
src/components/operations/OperationProgress.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
4
src/components/operations/index.ts
Normal file
4
src/components/operations/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { OperationCard } from './OperationCard';
|
||||
export { OperationProgress } from './OperationProgress';
|
||||
export { HealthIndicator } from './HealthIndicator';
|
||||
export { NodeStatusCard } from './NodeStatusCard';
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user