First swing.

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

View File

@@ -1,140 +1,8 @@
import { useEffect, useState } from 'react';
import { useConfig } from './hooks';
import {
Advanced,
ErrorBoundary
} from './components';
import { CloudComponent } from './components/CloudComponent';
import { CentralComponent } from './components/CentralComponent';
import { DnsComponent } from './components/DnsComponent';
import { DhcpComponent } from './components/DhcpComponent';
import { PxeComponent } from './components/PxeComponent';
import { ClusterNodesComponent } from './components/ClusterNodesComponent';
import { ClusterServicesComponent } from './components/ClusterServicesComponent';
import { AppsComponent } from './components/AppsComponent';
import { AppSidebar } from './components/AppSidebar';
import { SidebarProvider, SidebarInset, SidebarTrigger } from './components/ui/sidebar';
import type { Phase, Tab } from './components/AppSidebar';
import { RouterProvider } from 'react-router';
import { router } from './router';
function App() {
const [currentTab, setCurrentTab] = useState<Tab>('cloud');
const [completedPhases, setCompletedPhases] = useState<Phase[]>([]);
const { config } = useConfig();
// Update phase state from config when it changes
useEffect(() => {
console.log('Config changed:', config);
console.log('config?.wildcloud:', config?.wildcloud);
if (config?.wildcloud?.currentPhase) {
console.log('Setting currentTab to:', config.wildcloud.currentPhase);
setCurrentTab(config.wildcloud.currentPhase as Phase);
}
if (config?.wildcloud?.completedPhases) {
console.log('Setting completedPhases to:', config.wildcloud.completedPhases);
setCompletedPhases(config.wildcloud.completedPhases as Phase[]);
}
}, [config]);
const handlePhaseComplete = (phase: Phase) => {
if (!completedPhases.includes(phase)) {
setCompletedPhases(prev => [...prev, phase]);
}
// Auto-advance to next phase (excluding advanced)
const phases: Phase[] = ['setup', 'infrastructure', 'cluster', 'apps'];
const currentIndex = phases.indexOf(phase);
if (currentIndex < phases.length - 1) {
setCurrentTab(phases[currentIndex + 1]);
}
};
const renderCurrentTab = () => {
switch (currentTab) {
case 'cloud':
return (
<ErrorBoundary>
<CloudComponent />
</ErrorBoundary>
);
case 'central':
return (
<ErrorBoundary>
<CentralComponent />
</ErrorBoundary>
);
case 'dns':
return (
<ErrorBoundary>
<DnsComponent />
</ErrorBoundary>
);
case 'dhcp':
return (
<ErrorBoundary>
<DhcpComponent />
</ErrorBoundary>
);
case 'pxe':
return (
<ErrorBoundary>
<PxeComponent />
</ErrorBoundary>
);
case 'setup':
case 'infrastructure':
return (
<ErrorBoundary>
<ClusterNodesComponent onComplete={() => handlePhaseComplete('infrastructure')} />
</ErrorBoundary>
);
case 'cluster':
return (
<ErrorBoundary>
<ClusterServicesComponent onComplete={() => handlePhaseComplete('cluster')} />
</ErrorBoundary>
);
case 'apps':
return (
<ErrorBoundary>
<AppsComponent onComplete={() => handlePhaseComplete('apps')} />
</ErrorBoundary>
);
case 'advanced':
return (
<ErrorBoundary>
<Advanced />
</ErrorBoundary>
);
default:
return (
<ErrorBoundary>
<CloudComponent />
</ErrorBoundary>
);
}
};
return (
<SidebarProvider>
<AppSidebar
currentTab={currentTab}
onTabChange={setCurrentTab}
completedPhases={completedPhases}
/>
<SidebarInset>
<header className="flex h-16 shrink-0 items-center gap-2 px-4">
<SidebarTrigger className="-ml-1" />
<div className="flex items-center gap-2">
<h1 className="text-lg font-semibold">Dashboard</h1>
</div>
</header>
<div className="flex flex-1 flex-col gap-4 p-4">
{renderCurrentTab()}
</div>
</SidebarInset>
</SidebarProvider>
);
return <RouterProvider router={router} />;
}
export default App;
export default App;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,7 +3,7 @@ import { renderHook, waitFor, act } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import React from 'react';
import { useConfig } from '../useConfig';
import { apiService } from '../../services/api';
import { apiService } from '../../services/api-legacy';
// Mock the API service
vi.mock('../../services/api', () => ({

View File

@@ -3,7 +3,7 @@ import { renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import React from 'react';
import { useStatus } from '../useStatus';
import { apiService } from '../../services/api';
import { apiService } from '../../services/api-legacy';
// Mock the API service
vi.mock('../../services/api', () => ({

View File

@@ -4,4 +4,17 @@ export { useHealth } from './useHealth';
export { useConfig } from './useConfig';
export { useConfigYaml } from './useConfigYaml';
export { useDnsmasq } from './useDnsmasq';
export { useAssets } from './useAssets';
export { useAssets } from './useAssets';
// New API hooks
export { useInstanceContext, InstanceProvider } from './useInstanceContext';
export { useInstances, useInstance, useInstanceConfig } from './useInstances';
export { useNodes, useDiscoveryStatus, useNodeHardware } from './useNodes';
export { useCluster } from './useCluster';
export { useAvailableApps, useAvailableApp, useDeployedApps, useAppStatus, useAppBackups } from './useApps';
export { useServices, useServiceStatus, useServiceManifest } from './useServices';
export { useOperations, useOperation } from './useOperations';
export { useSecrets, useUpdateSecrets } from './useSecrets';
export { useKubeconfig, useTalosconfig, useRegenerateKubeconfig } from './useClusterAccess';
export { useBaseServices, useServiceStatus as useBaseServiceStatus, useInstallService } from './useBaseServices';
export { useCentralStatus } from './useCentralStatus';

110
src/hooks/useApps.ts Normal file
View File

@@ -0,0 +1,110 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { appsApi } from '../services/api';
import type { AppAddRequest } from '../services/api';
export function useAvailableApps() {
return useQuery({
queryKey: ['apps', 'available'],
queryFn: appsApi.listAvailable,
});
}
export function useAvailableApp(appName: string | null | undefined) {
return useQuery({
queryKey: ['apps', 'available', appName],
queryFn: () => appsApi.getAvailable(appName!),
enabled: !!appName,
});
}
export function useDeployedApps(instanceName: string | null | undefined) {
const queryClient = useQueryClient();
const appsQuery = useQuery({
queryKey: ['instances', instanceName, 'apps'],
queryFn: () => appsApi.listDeployed(instanceName!),
enabled: !!instanceName,
// Poll every 3 seconds to catch deployment status changes
refetchInterval: 3000,
});
const addMutation = useMutation({
mutationFn: (app: AppAddRequest) => appsApi.add(instanceName!, app),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['instances', instanceName, 'apps'] });
},
});
const deployMutation = useMutation({
mutationFn: (appName: string) => appsApi.deploy(instanceName!, appName),
onSuccess: () => {
// Deployment is async, so start polling for updates
queryClient.invalidateQueries({ queryKey: ['instances', instanceName, 'apps'] });
},
});
const deleteMutation = useMutation({
mutationFn: (appName: string) => appsApi.delete(instanceName!, appName),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['instances', instanceName, 'apps'] });
},
});
return {
apps: appsQuery.data?.apps || [],
isLoading: appsQuery.isLoading,
error: appsQuery.error,
refetch: appsQuery.refetch,
addApp: addMutation.mutate,
isAdding: addMutation.isPending,
addResult: addMutation.data,
deployApp: deployMutation.mutate,
isDeploying: deployMutation.isPending,
deployResult: deployMutation.data,
deleteApp: deleteMutation.mutate,
isDeleting: deleteMutation.isPending,
};
}
export function useAppStatus(instanceName: string | null | undefined, appName: string | null | undefined) {
return useQuery({
queryKey: ['instances', instanceName, 'apps', appName, 'status'],
queryFn: () => appsApi.getStatus(instanceName!, appName!),
enabled: !!instanceName && !!appName,
refetchInterval: 5000, // Poll every 5 seconds
});
}
export function useAppBackups(instanceName: string | null | undefined, appName: string | null | undefined) {
const queryClient = useQueryClient();
const backupsQuery = useQuery({
queryKey: ['instances', instanceName, 'apps', appName, 'backups'],
queryFn: () => appsApi.listBackups(instanceName!, appName!),
enabled: !!instanceName && !!appName,
});
const backupMutation = useMutation({
mutationFn: () => appsApi.backup(instanceName!, appName!),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ['instances', instanceName, 'apps', appName, 'backups']
});
},
});
const restoreMutation = useMutation({
mutationFn: (backupId: string) => appsApi.restore(instanceName!, appName!, backupId),
});
return {
backups: backupsQuery.data,
isLoading: backupsQuery.isLoading,
backup: backupMutation.mutate,
isBackingUp: backupMutation.isPending,
backupResult: backupMutation.data,
restore: restoreMutation.mutate,
isRestoring: restoreMutation.isPending,
restoreResult: restoreMutation.data,
};
}

View File

@@ -1,5 +1,5 @@
import { useMutation } from '@tanstack/react-query';
import { apiService } from '../services/api';
import { apiService } from '../services/api-legacy';
interface AssetsResponse {
status: string;

View File

@@ -0,0 +1,40 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { servicesApi } from '../services/api';
import type { ServiceInstallRequest } from '../services/api/types';
export function useBaseServices(instanceName: string | null | undefined) {
return useQuery({
queryKey: ['instances', instanceName, 'services'],
queryFn: () => servicesApi.list(instanceName!),
enabled: !!instanceName,
refetchInterval: 5000, // Poll every 5 seconds to get status updates
});
}
export function useServiceStatus(instanceName: string | null | undefined, serviceName: string) {
return useQuery({
queryKey: ['instances', instanceName, 'services', serviceName, 'status'],
queryFn: () => servicesApi.getStatus(instanceName!, serviceName),
enabled: !!instanceName && !!serviceName,
refetchInterval: 5000, // Poll during deployment
});
}
export function useInstallService(instanceName: string | null | undefined) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (service: ServiceInstallRequest) =>
servicesApi.install(instanceName!, service),
onSuccess: () => {
// Invalidate services list to get updated status
queryClient.invalidateQueries({
queryKey: ['instances', instanceName, 'services'],
});
// Also invalidate operations to show new operation
queryClient.invalidateQueries({
queryKey: ['instances', instanceName, 'operations'],
});
},
});
}

View File

@@ -0,0 +1,31 @@
import { useQuery } from '@tanstack/react-query';
import { apiClient } from '../services/api/client';
interface CentralStatus {
status: string;
version: string;
uptime: string;
uptimeSeconds: number;
dataDir: string;
appsDir: string;
setupFiles: string;
instances: {
count: number;
names: string[];
};
}
/**
* Hook to fetch Wild Central server status
* @returns Central server status information
*/
export function useCentralStatus() {
return useQuery({
queryKey: ['central', 'status'],
queryFn: async (): Promise<CentralStatus> => {
return apiClient.get('/api/v1/status');
},
// Poll every 5 seconds to keep uptime current
refetchInterval: 5000,
});
}

83
src/hooks/useCluster.ts Normal file
View File

@@ -0,0 +1,83 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { clusterApi } from '../services/api';
import type { ClusterConfig } from '../services/api';
export function useCluster(instanceName: string | null | undefined) {
const queryClient = useQueryClient();
const statusQuery = useQuery({
queryKey: ['instances', instanceName, 'cluster', 'status'],
queryFn: () => clusterApi.getStatus(instanceName!),
enabled: !!instanceName,
});
const healthQuery = useQuery({
queryKey: ['instances', instanceName, 'cluster', 'health'],
queryFn: () => clusterApi.getHealth(instanceName!),
enabled: !!instanceName,
});
const kubeconfigQuery = useQuery({
queryKey: ['instances', instanceName, 'cluster', 'kubeconfig'],
queryFn: () => clusterApi.getKubeconfig(instanceName!),
enabled: !!instanceName,
});
const talosconfigQuery = useQuery({
queryKey: ['instances', instanceName, 'cluster', 'talosconfig'],
queryFn: () => clusterApi.getTalosconfig(instanceName!),
enabled: !!instanceName,
});
const generateConfigMutation = useMutation({
mutationFn: (config: ClusterConfig) => clusterApi.generateConfig(instanceName!, config),
});
const bootstrapMutation = useMutation({
mutationFn: (node: string) => clusterApi.bootstrap(instanceName!, node),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['instances', instanceName, 'cluster'] });
},
});
const configureEndpointsMutation = useMutation({
mutationFn: (includeNodes: boolean) => clusterApi.configureEndpoints(instanceName!, includeNodes),
});
const generateKubeconfigMutation = useMutation({
mutationFn: () => clusterApi.generateKubeconfig(instanceName!),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['instances', instanceName, 'cluster', 'kubeconfig'] });
},
});
const resetMutation = useMutation({
mutationFn: () => clusterApi.reset(instanceName!, true),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['instances', instanceName, 'cluster'] });
},
});
return {
status: statusQuery.data,
isLoadingStatus: statusQuery.isLoading,
health: healthQuery.data,
isLoadingHealth: healthQuery.isLoading,
kubeconfig: kubeconfigQuery.data?.kubeconfig,
talosconfig: talosconfigQuery.data?.talosconfig,
generateConfig: generateConfigMutation.mutate,
isGeneratingConfig: generateConfigMutation.isPending,
generateConfigResult: generateConfigMutation.data,
bootstrap: bootstrapMutation.mutate,
isBootstrapping: bootstrapMutation.isPending,
bootstrapResult: bootstrapMutation.data,
configureEndpoints: configureEndpointsMutation.mutate,
isConfiguringEndpoints: configureEndpointsMutation.isPending,
generateKubeconfig: generateKubeconfigMutation.mutate,
isGeneratingKubeconfig: generateKubeconfigMutation.isPending,
reset: resetMutation.mutate,
isResetting: resetMutation.isPending,
refetchStatus: statusQuery.refetch,
refetchHealth: healthQuery.refetch,
};
}

View File

@@ -0,0 +1,31 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { clusterApi } from '../services/api';
export function useKubeconfig(instanceName: string | null | undefined) {
return useQuery({
queryKey: ['instances', instanceName, 'kubeconfig'],
queryFn: () => clusterApi.getKubeconfig(instanceName!),
enabled: !!instanceName,
});
}
export function useTalosconfig(instanceName: string | null | undefined) {
return useQuery({
queryKey: ['instances', instanceName, 'talosconfig'],
queryFn: () => clusterApi.getTalosconfig(instanceName!),
enabled: !!instanceName,
});
}
export function useRegenerateKubeconfig(instanceName: string | null | undefined) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: () => clusterApi.generateKubeconfig(instanceName!),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ['instances', instanceName, 'kubeconfig'],
});
},
});
}

View File

@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiService } from '../services/api';
import { apiService } from '../services/api-legacy';
import type { Config } from '../types';
interface ConfigResponse {

View File

@@ -1,5 +1,5 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiService } from '../services/api';
import { apiService } from '../services/api-legacy';
export const useConfigYaml = () => {
const queryClient = useQueryClient();

View File

@@ -1,6 +1,6 @@
import { useState } from 'react';
import { useMutation } from '@tanstack/react-query';
import { apiService } from '../services/api';
import { apiService } from '../services/api-legacy';
interface DnsmasqResponse {
status: string;

View File

@@ -1,5 +1,5 @@
import { useMutation } from '@tanstack/react-query';
import { apiService } from '../services/api';
import { apiService } from '../services/api-legacy';
interface HealthResponse {
service: string;

View File

@@ -0,0 +1,37 @@
import { useState, createContext, useContext, ReactNode } from 'react';
interface InstanceContextValue {
currentInstance: string | null;
setCurrentInstance: (name: string | null) => void;
}
const InstanceContext = createContext<InstanceContextValue | undefined>(undefined);
export function InstanceProvider({ children }: { children: ReactNode }) {
const [currentInstance, setCurrentInstanceState] = useState<string | null>(
() => localStorage.getItem('currentInstance')
);
const setCurrentInstance = (name: string | null) => {
setCurrentInstanceState(name);
if (name) {
localStorage.setItem('currentInstance', name);
} else {
localStorage.removeItem('currentInstance');
}
};
return (
<InstanceContext.Provider value={{ currentInstance, setCurrentInstance }}>
{children}
</InstanceContext.Provider>
);
}
export function useInstanceContext() {
const context = useContext(InstanceContext);
if (context === undefined) {
throw new Error('useInstanceContext must be used within an InstanceProvider');
}
return context;
}

82
src/hooks/useInstances.ts Normal file
View File

@@ -0,0 +1,82 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { instancesApi } from '../services/api';
import type { CreateInstanceRequest } from '../services/api';
export function useInstances() {
const queryClient = useQueryClient();
const listQuery = useQuery({
queryKey: ['instances'],
queryFn: instancesApi.list,
});
const createMutation = useMutation({
mutationFn: (data: CreateInstanceRequest) => instancesApi.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['instances'] });
},
});
const deleteMutation = useMutation({
mutationFn: (name: string) => instancesApi.delete(name),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['instances'] });
},
});
return {
instances: listQuery.data?.instances || [],
isLoading: listQuery.isLoading,
error: listQuery.error,
refetch: listQuery.refetch,
createInstance: createMutation.mutate,
isCreating: createMutation.isPending,
createError: createMutation.error,
deleteInstance: deleteMutation.mutate,
isDeleting: deleteMutation.isPending,
deleteError: deleteMutation.error,
};
}
export function useInstance(instanceName: string | null | undefined) {
return useQuery({
queryKey: ['instances', instanceName],
queryFn: () => instancesApi.get(instanceName!),
enabled: !!instanceName,
});
}
export function useInstanceConfig(instanceName: string | null | undefined) {
const queryClient = useQueryClient();
const configQuery = useQuery({
queryKey: ['instances', instanceName, 'config'],
queryFn: () => instancesApi.getConfig(instanceName!),
enabled: !!instanceName,
});
const updateMutation = useMutation({
mutationFn: (config: Record<string, unknown>) => instancesApi.updateConfig(instanceName!, config),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['instances', instanceName, 'config'] });
},
});
const batchUpdateMutation = useMutation({
mutationFn: (updates: Array<{path: string; value: unknown}>) =>
instancesApi.batchUpdateConfig(instanceName!, updates),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['instances', instanceName, 'config'] });
},
});
return {
config: configQuery.data,
isLoading: configQuery.isLoading,
error: configQuery.error,
updateConfig: updateMutation.mutate,
isUpdating: updateMutation.isPending,
batchUpdate: batchUpdateMutation.mutate,
isBatchUpdating: batchUpdateMutation.isPending,
};
}

91
src/hooks/useNodes.ts Normal file
View File

@@ -0,0 +1,91 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { nodesApi } from '../services/api';
import type { NodeAddRequest, NodeUpdateRequest } from '../services/api';
export function useNodes(instanceName: string | null | undefined) {
const queryClient = useQueryClient();
const nodesQuery = useQuery({
queryKey: ['instances', instanceName, 'nodes'],
queryFn: () => nodesApi.list(instanceName!),
enabled: !!instanceName,
});
const discoverMutation = useMutation({
mutationFn: (subnet: string) => nodesApi.discover(instanceName!, subnet),
});
const detectMutation = useMutation({
mutationFn: () => nodesApi.detect(instanceName!),
});
const addMutation = useMutation({
mutationFn: (node: NodeAddRequest) => nodesApi.add(instanceName!, node),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['instances', instanceName, 'nodes'] });
},
});
const updateMutation = useMutation({
mutationFn: ({ nodeName, updates }: { nodeName: string; updates: NodeUpdateRequest }) =>
nodesApi.update(instanceName!, nodeName, updates),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['instances', instanceName, 'nodes'] });
},
});
const deleteMutation = useMutation({
mutationFn: (nodeName: string) => nodesApi.delete(instanceName!, nodeName),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['instances', instanceName, 'nodes'] });
},
});
const applyMutation = useMutation({
mutationFn: (nodeName: string) => nodesApi.apply(instanceName!, nodeName),
});
const fetchTemplatesMutation = useMutation({
mutationFn: () => nodesApi.fetchTemplates(instanceName!),
});
return {
nodes: nodesQuery.data?.nodes || [],
isLoading: nodesQuery.isLoading,
error: nodesQuery.error,
refetch: nodesQuery.refetch,
discover: discoverMutation.mutate,
isDiscovering: discoverMutation.isPending,
discoverResult: discoverMutation.data,
detect: detectMutation.mutate,
isDetecting: detectMutation.isPending,
detectResult: detectMutation.data,
addNode: addMutation.mutate,
isAdding: addMutation.isPending,
updateNode: updateMutation.mutate,
isUpdating: updateMutation.isPending,
deleteNode: deleteMutation.mutate,
isDeleting: deleteMutation.isPending,
applyNode: applyMutation.mutate,
isApplying: applyMutation.isPending,
fetchTemplates: fetchTemplatesMutation.mutate,
isFetchingTemplates: fetchTemplatesMutation.isPending,
};
}
export function useDiscoveryStatus(instanceName: string | null | undefined) {
return useQuery({
queryKey: ['instances', instanceName, 'discovery'],
queryFn: () => nodesApi.discoveryStatus(instanceName!),
enabled: !!instanceName,
refetchInterval: (query) => (query.state.data?.active ? 1000 : false),
});
}
export function useNodeHardware(instanceName: string | null | undefined, ip: string | null | undefined) {
return useQuery({
queryKey: ['instances', instanceName, 'nodes', 'hardware', ip],
queryFn: () => nodesApi.getHardware(instanceName!, ip!),
enabled: !!instanceName && !!ip,
});
}

View File

@@ -0,0 +1,78 @@
import { useEffect, useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { operationsApi } from '../services/api';
import type { Operation } from '../services/api';
export function useOperations(instanceName: string | null | undefined) {
return useQuery({
queryKey: ['instances', instanceName, 'operations'],
queryFn: () => operationsApi.list(instanceName!),
enabled: !!instanceName,
refetchInterval: 2000, // Poll every 2 seconds
});
}
export function useOperation(operationId: string | null | undefined) {
const [operation, setOperation] = useState<Operation | null>(null);
const [error, setError] = useState<Error | null>(null);
const queryClient = useQueryClient();
useEffect(() => {
if (!operationId) return;
// Fetch initial state
operationsApi.get(operationId).then(setOperation).catch(setError);
// Set up SSE stream
const eventSource = operationsApi.createStream(operationId);
eventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
setOperation(data);
// Invalidate relevant queries when operation completes
if (data.status === 'completed' || data.status === 'failed') {
eventSource.close();
// Invalidate queries based on operation type
if (data.instance_name) {
queryClient.invalidateQueries({
queryKey: ['instances', data.instance_name]
});
}
}
} catch (err) {
setError(err instanceof Error ? err : new Error('Failed to parse operation update'));
}
};
eventSource.onerror = () => {
setError(new Error('Operation stream failed'));
eventSource.close();
};
return () => {
eventSource.close();
};
}, [operationId, queryClient]);
const cancelMutation = useMutation({
mutationFn: () => {
if (!operation?.instance_name) {
throw new Error('Cannot cancel operation: instance name not available');
}
return operationsApi.cancel(operationId!, operation.instance_name);
},
onSuccess: () => {
// Operation state will be updated via SSE
},
});
return {
operation,
error,
isLoading: !operation && !error,
cancel: cancelMutation.mutate,
isCancelling: cancelMutation.isPending,
};
}

25
src/hooks/useSecrets.ts Normal file
View File

@@ -0,0 +1,25 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { instancesApi } from '../services/api';
export function useSecrets(instanceName: string | null | undefined, raw = false) {
return useQuery({
queryKey: ['instances', instanceName, 'secrets', raw ? 'raw' : 'masked'],
queryFn: () => instancesApi.getSecrets(instanceName!, raw),
enabled: !!instanceName,
});
}
export function useUpdateSecrets(instanceName: string | null | undefined) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (secrets: Record<string, unknown>) =>
instancesApi.updateSecrets(instanceName!, secrets),
onSuccess: () => {
// Invalidate both masked and raw secrets
queryClient.invalidateQueries({
queryKey: ['instances', instanceName, 'secrets'],
});
},
});
}

83
src/hooks/useServices.ts Normal file
View File

@@ -0,0 +1,83 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { servicesApi } from '../services/api';
import type { ServiceInstallRequest } from '../services/api';
export function useServices(instanceName: string | null | undefined) {
const queryClient = useQueryClient();
const servicesQuery = useQuery({
queryKey: ['instances', instanceName, 'services'],
queryFn: () => servicesApi.list(instanceName!),
enabled: !!instanceName,
});
const installMutation = useMutation({
mutationFn: (service: ServiceInstallRequest) => servicesApi.install(instanceName!, service),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['instances', instanceName, 'services'] });
},
});
const installAllMutation = useMutation({
mutationFn: () => servicesApi.installAll(instanceName!),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['instances', instanceName, 'services'] });
},
});
const deleteMutation = useMutation({
mutationFn: (serviceName: string) => servicesApi.delete(instanceName!, serviceName),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['instances', instanceName, 'services'] });
},
});
const fetchMutation = useMutation({
mutationFn: (serviceName: string) => servicesApi.fetch(instanceName!, serviceName),
});
const compileMutation = useMutation({
mutationFn: (serviceName: string) => servicesApi.compile(instanceName!, serviceName),
});
const deployMutation = useMutation({
mutationFn: (serviceName: string) => servicesApi.deploy(instanceName!, serviceName),
});
return {
services: servicesQuery.data?.services || [],
isLoading: servicesQuery.isLoading,
error: servicesQuery.error,
refetch: servicesQuery.refetch,
installService: installMutation.mutate,
isInstalling: installMutation.isPending,
installResult: installMutation.data,
installAll: installAllMutation.mutate,
isInstallingAll: installAllMutation.isPending,
deleteService: deleteMutation.mutate,
isDeleting: deleteMutation.isPending,
fetch: fetchMutation.mutate,
isFetching: fetchMutation.isPending,
compile: compileMutation.mutate,
isCompiling: compileMutation.isPending,
deploy: deployMutation.mutate,
isDeploying: deployMutation.isPending,
};
}
export function useServiceStatus(instanceName: string | null | undefined, serviceName: string | null | undefined) {
return useQuery({
queryKey: ['instances', instanceName, 'services', serviceName, 'status'],
queryFn: () => servicesApi.getStatus(instanceName!, serviceName!),
enabled: !!instanceName && !!serviceName,
refetchInterval: 5000, // Poll every 5 seconds
});
}
export function useServiceManifest(serviceName: string | null | undefined) {
return useQuery({
queryKey: ['services', serviceName, 'manifest'],
queryFn: () => servicesApi.getManifest(serviceName!),
enabled: !!serviceName,
});
}

View File

@@ -1,5 +1,5 @@
import { useQuery } from '@tanstack/react-query';
import { apiService } from '../services/api';
import { apiService } from '../services/api-legacy';
import type { Status } from '../types';
export const useStatus = () => {

View File

@@ -4,6 +4,7 @@ import { QueryClientProvider } from '@tanstack/react-query';
import './index.css';
import App from './App';
import { ThemeProvider } from './contexts/ThemeContext';
import { InstanceProvider } from './hooks';
import { queryClient } from './lib/queryClient';
import { ErrorBoundary } from './components/ErrorBoundary';
@@ -15,9 +16,11 @@ root.render(
<StrictMode>
<ErrorBoundary>
<QueryClientProvider client={queryClient}>
<ThemeProvider defaultTheme="light" storageKey="wild-central-theme">
<App />
</ThemeProvider>
<InstanceProvider>
<ThemeProvider defaultTheme="light" storageKey="wild-central-theme">
<App />
</ThemeProvider>
</InstanceProvider>
</QueryClientProvider>
</ErrorBoundary>
</StrictMode>

View File

@@ -0,0 +1,41 @@
import { useEffect } from 'react';
import { Outlet, useParams, Navigate } from 'react-router';
import { useInstanceContext } from '../hooks/useInstanceContext';
import { AppSidebar } from '../components/AppSidebar';
import { SidebarProvider, SidebarInset, SidebarTrigger } from '../components/ui/sidebar';
export function InstanceLayout() {
const { instanceId } = useParams<{ instanceId: string }>();
const { setCurrentInstance } = useInstanceContext();
useEffect(() => {
if (instanceId) {
setCurrentInstance(instanceId);
}
return () => {
// Don't clear instance on unmount - let it persist
// This allows the instance to stay selected when navigating
};
}, [instanceId, setCurrentInstance]);
if (!instanceId) {
return <Navigate to="/" replace />;
}
return (
<SidebarProvider>
<AppSidebar />
<SidebarInset>
<header className="flex h-16 shrink-0 items-center gap-2 px-4">
<SidebarTrigger className="-ml-1" />
<div className="flex items-center gap-2">
<h1 className="text-lg font-semibold">Wild Cloud</h1>
</div>
</header>
<div className="flex flex-1 flex-col gap-4 p-4">
<Outlet />
</div>
</SidebarInset>
</SidebarProvider>
);
}

14
src/router/index.tsx Normal file
View File

@@ -0,0 +1,14 @@
import { createBrowserRouter } from 'react-router';
import { routes } from './routes';
export const router = createBrowserRouter(routes, {
future: {
v7_startTransition: true,
v7_relativeSplatPath: true,
},
});
export { routes };
export * from './InstanceLayout';
export * from './pages/LandingPage';
export * from './pages/NotFoundPage';

View File

@@ -0,0 +1,10 @@
import { ErrorBoundary } from '../../components';
import { Advanced } from '../../components';
export function AdvancedPage() {
return (
<ErrorBoundary>
<Advanced />
</ErrorBoundary>
);
}

View File

@@ -0,0 +1,11 @@
import { ErrorBoundary } from '../../components';
import { AppsComponent } from '../../components/AppsComponent';
export function AppsPage() {
// Note: onComplete callback removed as phase management will be handled differently with routing
return (
<ErrorBoundary>
<AppsComponent />
</ErrorBoundary>
);
}

View File

@@ -0,0 +1,116 @@
import { useParams } from 'react-router-dom';
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '../../components/ui/card';
import { Button } from '../../components/ui/button';
import { Skeleton } from '../../components/ui/skeleton';
import { ServiceCard } from '../../components/ServiceCard';
import { Package, AlertTriangle, RefreshCw } from 'lucide-react';
import { useBaseServices, useInstallService } from '../../hooks/useBaseServices';
export function BaseServicesPage() {
const { instanceId } = useParams<{ instanceId: string }>();
const { data: servicesData, isLoading, refetch } = useBaseServices(instanceId);
const installMutation = useInstallService(instanceId);
const handleInstall = async (serviceName: string) => {
await installMutation.mutateAsync({ name: serviceName });
};
if (!instanceId) {
return (
<div className="flex items-center justify-center h-96">
<Card className="p-6">
<div className="flex items-center gap-3 text-muted-foreground">
<AlertTriangle className="h-5 w-5" />
<p>No instance selected</p>
</div>
</Card>
</div>
);
}
const services = servicesData?.services || [];
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h2 className="text-3xl font-bold tracking-tight">Base Services</h2>
<p className="text-muted-foreground">
Manage essential cluster infrastructure services
</p>
</div>
<Button onClick={() => refetch()} variant="outline" size="sm" disabled={isLoading}>
<RefreshCw className="h-4 w-4" />
Refresh
</Button>
</div>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Package className="h-5 w-5" />
Available Services
</CardTitle>
<CardDescription>
Core infrastructure services for your Wild Cloud cluster
</CardDescription>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
<Skeleton className="h-48 w-full" />
<Skeleton className="h-48 w-full" />
<Skeleton className="h-48 w-full" />
<Skeleton className="h-48 w-full" />
<Skeleton className="h-48 w-full" />
<Skeleton className="h-48 w-full" />
</div>
) : services.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
<Package className="h-12 w-12 mx-auto mb-3 opacity-50" />
<p className="text-sm">No services available</p>
<p className="text-xs mt-1">Base services will appear here once configured</p>
</div>
) : (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{services.map((service) => (
<ServiceCard
key={service.name}
service={service}
onInstall={() => handleInstall(service.name)}
isInstalling={installMutation.isPending}
/>
))}
</div>
)}
</CardContent>
</Card>
<Card className="border-blue-200 bg-blue-50 dark:bg-blue-950/20 dark:border-blue-900">
<CardContent className="pt-6">
<div className="flex gap-3">
<Package className="h-5 w-5 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5" />
<div>
<p className="font-medium text-blue-900 dark:text-blue-200">
About Base Services
</p>
<p className="text-sm text-blue-800 dark:text-blue-300 mt-1">
Base services provide essential infrastructure components for your cluster:
</p>
<ul className="text-sm text-blue-800 dark:text-blue-300 mt-2 space-y-1 list-disc list-inside">
<li><strong>Cilium</strong> - Network connectivity and security</li>
<li><strong>MetalLB</strong> - Load balancer for bare metal clusters</li>
<li><strong>Traefik</strong> - Ingress controller and reverse proxy</li>
<li><strong>Cert-Manager</strong> - Automatic TLS certificate management</li>
<li><strong>External-DNS</strong> - Automatic DNS record management</li>
</ul>
<p className="text-sm text-blue-800 dark:text-blue-300 mt-2">
Install these services to enable full cluster functionality.
</p>
</div>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,10 @@
import { ErrorBoundary } from '../../components';
import { CentralComponent } from '../../components/CentralComponent';
export function CentralPage() {
return (
<ErrorBoundary>
<CentralComponent />
</ErrorBoundary>
);
}

View File

@@ -0,0 +1,10 @@
import { ErrorBoundary } from '../../components';
import { CloudComponent } from '../../components/CloudComponent';
export function CloudPage() {
return (
<ErrorBoundary>
<CloudComponent />
</ErrorBoundary>
);
}

View File

@@ -0,0 +1,210 @@
import { useState } from 'react';
import { useParams } from 'react-router-dom';
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '../../components/ui/card';
import { Button } from '../../components/ui/button';
import { Skeleton } from '../../components/ui/skeleton';
import { DownloadButton } from '../../components/DownloadButton';
import { CopyButton } from '../../components/CopyButton';
import { ConfigViewer } from '../../components/ConfigViewer';
import { FileText, AlertTriangle, RefreshCw } from 'lucide-react';
import { useKubeconfig, useTalosconfig, useRegenerateKubeconfig } from '../../hooks/useClusterAccess';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '../../components/ui/dialog';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '../../components/ui/collapsible';
export function ClusterAccessPage() {
const { instanceId } = useParams<{ instanceId: string }>();
const [showKubeconfigPreview, setShowKubeconfigPreview] = useState(false);
const [showTalosconfigPreview, setShowTalosconfigPreview] = useState(false);
const [showRegenerateDialog, setShowRegenerateDialog] = useState(false);
const { data: kubeconfig, isLoading: kubeconfigLoading, refetch: refetchKubeconfig } = useKubeconfig(instanceId);
const { data: talosconfig, isLoading: talosconfigLoading } = useTalosconfig(instanceId);
const regenerateMutation = useRegenerateKubeconfig(instanceId);
const handleRegenerate = async () => {
await regenerateMutation.mutateAsync();
await refetchKubeconfig();
setShowRegenerateDialog(false);
};
if (!instanceId) {
return (
<div className="flex items-center justify-center h-96">
<Card className="p-6">
<div className="flex items-center gap-3 text-muted-foreground">
<AlertTriangle className="h-5 w-5" />
<p>No instance selected</p>
</div>
</Card>
</div>
);
}
return (
<div className="space-y-4">
<div>
<h2 className="text-3xl font-bold tracking-tight">Cluster Access</h2>
<p className="text-muted-foreground">
Download kubeconfig and talosconfig files
</p>
</div>
{/* Kubeconfig Card */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="flex items-center gap-2">
<FileText className="h-5 w-5" />
Kubeconfig
</CardTitle>
<CardDescription>
Configuration file for accessing the Kubernetes cluster with kubectl
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
{kubeconfigLoading ? (
<Skeleton className="h-20 w-full" />
) : kubeconfig?.kubeconfig ? (
<>
<div className="flex flex-wrap gap-2">
<DownloadButton
content={kubeconfig.kubeconfig}
filename={`${instanceId}-kubeconfig.yaml`}
label="Download"
/>
<CopyButton content={kubeconfig.kubeconfig} label="Copy" />
<Button
variant="outline"
onClick={() => setShowRegenerateDialog(true)}
>
<RefreshCw className="h-4 w-4" />
Regenerate
</Button>
</div>
<Collapsible open={showKubeconfigPreview} onOpenChange={setShowKubeconfigPreview}>
<CollapsibleTrigger asChild>
<Button variant="ghost" size="sm" className="w-full">
{showKubeconfigPreview ? 'Hide' : 'Show'} Preview
</Button>
</CollapsibleTrigger>
<CollapsibleContent>
<ConfigViewer content={kubeconfig.kubeconfig} className="mt-2" />
</CollapsibleContent>
</Collapsible>
<div className="text-xs text-muted-foreground space-y-1 pt-2 border-t">
<p className="font-medium">Usage:</p>
<code className="block bg-muted p-2 rounded">
kubectl --kubeconfig={instanceId}-kubeconfig.yaml get nodes
</code>
<p className="pt-2">Or set as default:</p>
<code className="block bg-muted p-2 rounded">
export KUBECONFIG=~/.kube/{instanceId}-kubeconfig.yaml
</code>
</div>
</>
) : (
<div className="text-center py-8 text-muted-foreground">
<FileText className="h-12 w-12 mx-auto mb-3 opacity-50" />
<p className="text-sm">Kubeconfig not available</p>
<p className="text-xs mt-1">Generate cluster configuration first</p>
</div>
)}
</CardContent>
</Card>
{/* Talosconfig Card */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="flex items-center gap-2">
<FileText className="h-5 w-5" />
Talosconfig
</CardTitle>
<CardDescription>
Configuration file for accessing Talos nodes with talosctl
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
{talosconfigLoading ? (
<Skeleton className="h-20 w-full" />
) : talosconfig?.talosconfig ? (
<>
<div className="flex flex-wrap gap-2">
<DownloadButton
content={talosconfig.talosconfig}
filename={`${instanceId}-talosconfig.yaml`}
label="Download"
/>
<CopyButton content={talosconfig.talosconfig} label="Copy" />
</div>
<Collapsible open={showTalosconfigPreview} onOpenChange={setShowTalosconfigPreview}>
<CollapsibleTrigger asChild>
<Button variant="ghost" size="sm" className="w-full">
{showTalosconfigPreview ? 'Hide' : 'Show'} Preview
</Button>
</CollapsibleTrigger>
<CollapsibleContent>
<ConfigViewer content={talosconfig.talosconfig} className="mt-2" />
</CollapsibleContent>
</Collapsible>
<div className="text-xs text-muted-foreground space-y-1 pt-2 border-t">
<p className="font-medium">Usage:</p>
<code className="block bg-muted p-2 rounded">
talosctl --talosconfig={instanceId}-talosconfig.yaml get members
</code>
<p className="pt-2">Or set as default:</p>
<code className="block bg-muted p-2 rounded">
export TALOSCONFIG=~/.talos/{instanceId}-talosconfig.yaml
</code>
</div>
</>
) : (
<div className="text-center py-8 text-muted-foreground">
<FileText className="h-12 w-12 mx-auto mb-3 opacity-50" />
<p className="text-sm">Talosconfig not available</p>
<p className="text-xs mt-1">Generate cluster configuration first</p>
</div>
)}
</CardContent>
</Card>
{/* Regenerate Confirmation Dialog */}
<Dialog open={showRegenerateDialog} onOpenChange={setShowRegenerateDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>Regenerate Kubeconfig</DialogTitle>
<DialogDescription>
This will regenerate the kubeconfig file. Any existing kubeconfig files will be invalidated.
Are you sure you want to continue?
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setShowRegenerateDialog(false)}>
Cancel
</Button>
<Button onClick={handleRegenerate} disabled={regenerateMutation.isPending}>
{regenerateMutation.isPending ? 'Regenerating...' : 'Regenerate'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,211 @@
import { useParams } from 'react-router-dom';
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '../../components/ui/card';
import { Badge } from '../../components/ui/badge';
import { Skeleton } from '../../components/ui/skeleton';
import { HeartPulse, AlertCircle, Clock } from 'lucide-react';
import { useClusterHealth, useClusterStatus, useClusterNodes } from '../../services/api';
import { HealthIndicator } from '../../components/operations/HealthIndicator';
import { NodeStatusCard } from '../../components/operations/NodeStatusCard';
export function ClusterHealthPage() {
const { instanceId } = useParams<{ instanceId: string }>();
const { data: health, isLoading: healthLoading, error: healthError } = useClusterHealth(instanceId || '');
const { data: status, isLoading: statusLoading } = useClusterStatus(instanceId || '');
const { data: nodes, isLoading: nodesLoading } = useClusterNodes(instanceId || '');
if (!instanceId) {
return (
<div className="flex items-center justify-center h-96">
<Card className="p-6">
<div className="flex items-center gap-3 text-muted-foreground">
<AlertCircle className="h-5 w-5" />
<p>No instance selected</p>
</div>
</Card>
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div>
<h2 className="text-3xl font-bold tracking-tight">Cluster Health</h2>
<p className="text-muted-foreground">
Monitor health metrics and node status for {instanceId}
</p>
</div>
{/* Overall Health Status */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="flex items-center gap-2">
<HeartPulse className="h-5 w-5" />
Overall Health
</CardTitle>
<CardDescription>
Cluster health aggregated from all checks
</CardDescription>
</div>
{health && (
<HealthIndicator status={health.status} size="lg" />
)}
</div>
</CardHeader>
<CardContent>
{healthError ? (
<div className="flex flex-col items-center justify-center py-8 text-center">
<AlertCircle className="h-12 w-12 text-red-500 mb-3" />
<p className="text-sm font-medium text-red-900 dark:text-red-100">
Error loading health data
</p>
<p className="text-xs text-red-700 dark:text-red-300 mt-1">
{healthError.message}
</p>
</div>
) : healthLoading ? (
<div className="space-y-2">
<Skeleton className="h-16 w-full" />
<Skeleton className="h-16 w-full" />
<Skeleton className="h-16 w-full" />
</div>
) : health && health.checks.length > 0 ? (
<div className="space-y-2">
{health.checks.map((check, index) => (
<div
key={index}
className="flex items-center justify-between p-4 rounded-lg border hover:bg-accent/50 transition-colors"
>
<div className="flex items-center gap-3 flex-1">
<HealthIndicator status={check.status} size="sm" />
<div className="flex-1">
<p className="font-medium text-sm">{check.name}</p>
{check.message && (
<p className="text-xs text-muted-foreground mt-0.5">
{check.message}
</p>
)}
</div>
</div>
</div>
))}
</div>
) : (
<div className="text-center py-8 text-muted-foreground">
<AlertCircle className="h-12 w-12 mx-auto mb-3 opacity-50" />
<p className="text-sm font-medium">No health data available</p>
<p className="text-xs mt-1">
Health checks will appear here once the cluster is running
</p>
</div>
)}
</CardContent>
</Card>
{/* Cluster Information */}
<div className="grid gap-4 md:grid-cols-3">
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium">Cluster Status</CardTitle>
</CardHeader>
<CardContent>
{statusLoading ? (
<Skeleton className="h-8 w-24" />
) : status ? (
<div>
<Badge variant={status.ready ? 'outline' : 'secondary'} className={status.ready ? 'border-green-500' : ''}>
{status.ready ? 'Ready' : 'Not Ready'}
</Badge>
<p className="text-xs text-muted-foreground mt-2">
{status.nodes} nodes total
</p>
</div>
) : (
<div className="text-sm text-muted-foreground">Unknown</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium">Kubernetes Version</CardTitle>
</CardHeader>
<CardContent>
{statusLoading ? (
<Skeleton className="h-8 w-32" />
) : status?.kubernetesVersion ? (
<div>
<div className="text-lg font-bold font-mono">
{status.kubernetesVersion}
</div>
</div>
) : (
<div className="text-sm text-muted-foreground">Not available</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium">Talos Version</CardTitle>
</CardHeader>
<CardContent>
{statusLoading ? (
<Skeleton className="h-8 w-32" />
) : status?.talosVersion ? (
<div>
<div className="text-lg font-bold font-mono">
{status.talosVersion}
</div>
</div>
) : (
<div className="text-sm text-muted-foreground">Not available</div>
)}
</CardContent>
</Card>
</div>
{/* Node Status */}
<Card>
<CardHeader>
<CardTitle>Node Status</CardTitle>
<CardDescription>
Detailed status and information for each node
</CardDescription>
</CardHeader>
<CardContent>
{nodesLoading ? (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
<Skeleton className="h-48 w-full" />
<Skeleton className="h-48 w-full" />
<Skeleton className="h-48 w-full" />
</div>
) : nodes && nodes.nodes.length > 0 ? (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{nodes.nodes.map((node) => (
<NodeStatusCard key={node.hostname} node={node} showHardware={true} />
))}
</div>
) : (
<div className="text-center py-12 text-muted-foreground">
<AlertCircle className="h-12 w-12 mx-auto mb-3 opacity-50" />
<p className="text-sm font-medium">No nodes found</p>
<p className="text-xs mt-1">
Add nodes to your cluster to see them here
</p>
</div>
)}
</CardContent>
</Card>
{/* Auto-refresh indicator */}
<div className="flex items-center justify-center gap-2 text-xs text-muted-foreground">
<Clock className="h-3 w-3" />
<p>Auto-refreshing every 10 seconds</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,11 @@
import { ErrorBoundary } from '../../components';
import { ClusterServicesComponent } from '../../components/ClusterServicesComponent';
export function ClusterPage() {
// Note: onComplete callback removed as phase management will be handled differently with routing
return (
<ErrorBoundary>
<ClusterServicesComponent />
</ErrorBoundary>
);
}

View File

@@ -0,0 +1,243 @@
import { useParams, Link } from 'react-router-dom';
import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardAction } from '../../components/ui/card';
import { Button } from '../../components/ui/button';
import { Skeleton } from '../../components/ui/skeleton';
import { Activity, Server, AlertCircle, RefreshCw, FileText, TrendingUp } from 'lucide-react';
import { useInstance, useInstanceOperations, useInstanceClusterHealth, useClusterStatus } from '../../services/api';
import { OperationCard } from '../../components/operations/OperationCard';
import { HealthIndicator } from '../../components/operations/HealthIndicator';
export function DashboardPage() {
const { instanceId } = useParams<{ instanceId: string }>();
const { data: instance, isLoading: instanceLoading, refetch: refetchInstance } = useInstance(instanceId || '');
const { data: operations, isLoading: operationsLoading } = useInstanceOperations(instanceId || '', 5);
const { data: health, isLoading: healthLoading } = useInstanceClusterHealth(instanceId || '');
const { data: status, isLoading: statusLoading } = useClusterStatus(instanceId || '');
const handleRefresh = () => {
refetchInstance();
};
if (!instanceId) {
return (
<div className="flex items-center justify-center h-96">
<Card className="p-6">
<div className="flex items-center gap-3 text-muted-foreground">
<AlertCircle className="h-5 w-5" />
<p>No instance selected</p>
</div>
</Card>
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-3xl font-bold tracking-tight">Dashboard</h2>
<p className="text-muted-foreground">
Overview and quick status for {instanceId}
</p>
</div>
<Button onClick={handleRefresh} variant="outline" size="sm">
<RefreshCw className="h-4 w-4" />
Refresh
</Button>
</div>
{/* Status Cards Grid */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
{/* Instance Status */}
<Card>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-sm font-medium">Instance Status</CardTitle>
<Activity className="h-4 w-4 text-muted-foreground" />
</div>
</CardHeader>
<CardContent>
{instanceLoading ? (
<Skeleton className="h-8 w-24" />
) : instance ? (
<div>
<div className="text-2xl font-bold">Active</div>
<p className="text-xs text-muted-foreground mt-1">
Instance configured
</p>
</div>
) : (
<div>
<div className="text-2xl font-bold text-muted-foreground">Unknown</div>
<p className="text-xs text-muted-foreground mt-1">
Unable to load status
</p>
</div>
)}
</CardContent>
</Card>
{/* Cluster Health */}
<Card>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-sm font-medium">Cluster Health</CardTitle>
<TrendingUp className="h-4 w-4 text-muted-foreground" />
</div>
</CardHeader>
<CardContent>
{healthLoading ? (
<Skeleton className="h-8 w-24" />
) : health ? (
<div>
<div className="mb-2">
<HealthIndicator status={health.status} size="md" />
</div>
<p className="text-xs text-muted-foreground">
{health.checks.length} health checks
</p>
</div>
) : (
<div>
<div className="text-2xl font-bold text-muted-foreground">Unknown</div>
<p className="text-xs text-muted-foreground mt-1">
Health data unavailable
</p>
</div>
)}
</CardContent>
</Card>
{/* Node Count */}
<Card>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-sm font-medium">Nodes</CardTitle>
<Server className="h-4 w-4 text-muted-foreground" />
</div>
</CardHeader>
<CardContent>
{statusLoading ? (
<Skeleton className="h-8 w-24" />
) : status ? (
<div>
<div className="text-2xl font-bold">{status.nodes}</div>
<p className="text-xs text-muted-foreground mt-1">
{status.controlPlaneNodes} control plane, {status.workerNodes} workers
</p>
</div>
) : (
<div>
<div className="text-2xl font-bold text-muted-foreground">-</div>
<p className="text-xs text-muted-foreground mt-1">
No nodes detected
</p>
</div>
)}
</CardContent>
</Card>
{/* K8s Version */}
<Card>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-sm font-medium">Kubernetes</CardTitle>
<FileText className="h-4 w-4 text-muted-foreground" />
</div>
</CardHeader>
<CardContent>
{statusLoading ? (
<Skeleton className="h-8 w-24" />
) : status?.kubernetesVersion ? (
<div>
<div className="text-xl font-bold font-mono">{status.kubernetesVersion}</div>
<p className="text-xs text-muted-foreground mt-1">
{status.ready ? 'Ready' : 'Not ready'}
</p>
</div>
) : (
<div>
<div className="text-2xl font-bold text-muted-foreground">-</div>
<p className="text-xs text-muted-foreground mt-1">
Version unknown
</p>
</div>
)}
</CardContent>
</Card>
</div>
{/* Cluster Health Details */}
{health && health.checks.length > 0 && (
<Card>
<CardHeader>
<CardTitle>Health Checks</CardTitle>
<CardDescription>
Detailed health status of cluster components
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-2">
{health.checks.map((check, index) => (
<div key={index} className="flex items-center justify-between p-3 rounded-lg border">
<div className="flex items-center gap-3">
<HealthIndicator status={check.status} size="sm" />
<span className="font-medium text-sm">{check.name}</span>
</div>
{check.message && (
<span className="text-xs text-muted-foreground">{check.message}</span>
)}
</div>
))}
</div>
</CardContent>
</Card>
)}
{/* Recent Operations */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>Recent Operations</CardTitle>
<CardDescription>
Last 5 operations for this instance
</CardDescription>
</div>
<CardAction>
<Button asChild variant="outline" size="sm">
<Link to={`/instances/${instanceId}/operations`}>
View All
</Link>
</Button>
</CardAction>
</div>
</CardHeader>
<CardContent>
{operationsLoading ? (
<div className="space-y-3">
<Skeleton className="h-24 w-full" />
<Skeleton className="h-24 w-full" />
<Skeleton className="h-24 w-full" />
</div>
) : operations && operations.operations.length > 0 ? (
<div className="space-y-3">
{operations.operations.map((operation) => (
<OperationCard key={operation.id} operation={operation} />
))}
</div>
) : (
<div className="text-center py-8 text-muted-foreground">
<Activity className="h-12 w-12 mx-auto mb-3 opacity-50" />
<p className="text-sm">No operations found</p>
<p className="text-xs mt-1">Operations will appear here as they are created</p>
</div>
)}
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,10 @@
import { ErrorBoundary } from '../../components';
import { DhcpComponent } from '../../components/DhcpComponent';
export function DhcpPage() {
return (
<ErrorBoundary>
<DhcpComponent />
</ErrorBoundary>
);
}

View File

@@ -0,0 +1,10 @@
import { ErrorBoundary } from '../../components';
import { DnsComponent } from '../../components/DnsComponent';
export function DnsPage() {
return (
<ErrorBoundary>
<DnsComponent />
</ErrorBoundary>
);
}

View File

@@ -0,0 +1,11 @@
import { ErrorBoundary } from '../../components';
import { ClusterNodesComponent } from '../../components/ClusterNodesComponent';
export function InfrastructurePage() {
// Note: onComplete callback removed as phase management will be handled differently with routing
return (
<ErrorBoundary>
<ClusterNodesComponent />
</ErrorBoundary>
);
}

View File

@@ -0,0 +1,290 @@
import { useState } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../../components/ui/card';
import { Button } from '../../components/ui/button';
import { Badge } from '../../components/ui/badge';
import {
Download,
Trash2,
AlertCircle,
Loader2,
Disc,
BookOpen,
ExternalLink,
CheckCircle,
XCircle,
Usb,
} from 'lucide-react';
import { usePxeAssets, useDownloadPxeAsset, useDeletePxeAsset } from '../../services/api/hooks/usePxeAssets';
import { useInstanceContext } from '../../hooks';
import type { PxeAssetType } from '../../services/api/types/pxe';
export function IsoPage() {
const { currentInstance } = useInstanceContext();
const { data, isLoading, error } = usePxeAssets(currentInstance);
const downloadAsset = useDownloadPxeAsset();
const deleteAsset = useDeletePxeAsset();
const [downloadingType, setDownloadingType] = useState<string | null>(null);
const [selectedVersion, setSelectedVersion] = useState('v1.8.0');
// Filter to show only ISO assets
const isoAssets = data?.assets.filter((asset) => asset.type === 'iso') || [];
const handleDownload = async (type: PxeAssetType) => {
if (!currentInstance) return;
setDownloadingType(type);
try {
const url = `https://github.com/siderolabs/talos/releases/download/${selectedVersion}/metal-amd64.iso`;
await downloadAsset.mutateAsync({
instanceName: currentInstance,
request: { type, version: selectedVersion, url },
});
} catch (err) {
console.error('Download failed:', err);
} finally {
setDownloadingType(null);
}
};
const handleDelete = async (type: PxeAssetType) => {
if (!currentInstance) return;
await deleteAsset.mutateAsync({ instanceName: currentInstance, type });
};
const getStatusBadge = (status?: string) => {
const statusValue = status || 'missing';
const variants: Record<string, 'secondary' | 'success' | 'destructive' | 'warning'> = {
available: 'success',
missing: 'secondary',
downloading: 'warning',
error: 'destructive',
};
const icons: Record<string, React.ReactNode> = {
available: <CheckCircle className="h-3 w-3" />,
missing: <AlertCircle className="h-3 w-3" />,
downloading: <Loader2 className="h-3 w-3 animate-spin" />,
error: <XCircle className="h-3 w-3" />,
};
return (
<Badge variant={variants[statusValue] || 'secondary'} className="flex items-center gap-1">
{icons[statusValue]}
{statusValue.charAt(0).toUpperCase() + statusValue.slice(1)}
</Badge>
);
};
const getAssetIcon = (type: string) => {
switch (type) {
case 'iso':
return <Disc className="h-5 w-5 text-primary" />;
default:
return <Disc className="h-5 w-5" />;
}
};
return (
<div className="space-y-6">
{/* Educational Intro Section */}
<Card className="p-6 bg-gradient-to-r from-purple-50 to-pink-50 dark:from-purple-950/20 dark:to-pink-950/20 border-purple-200 dark:border-purple-800">
<div className="flex items-start gap-4">
<div className="p-3 bg-purple-100 dark:bg-purple-900/30 rounded-lg">
<BookOpen className="h-6 w-6 text-purple-600 dark:text-purple-400" />
</div>
<div className="flex-1">
<h3 className="text-lg font-semibold text-purple-900 dark:text-purple-100 mb-2">
What is a Bootable ISO?
</h3>
<p className="text-purple-800 dark:text-purple-200 mb-3 leading-relaxed">
A bootable ISO is a special disk image file that can be written to a USB drive or DVD to create
installation media. When you boot a computer from this USB drive, it can install or run an
operating system directly from the drive without needing anything pre-installed.
</p>
<p className="text-purple-700 dark:text-purple-300 mb-4 text-sm">
This is perfect for setting up individual computers in your cloud infrastructure. Download the
Talos ISO here, write it to a USB drive using tools like Balena Etcher or Rufus, then boot
your computer from the USB to install Talos Linux.
</p>
<Button
variant="outline"
size="sm"
className="text-purple-700 border-purple-300 hover:bg-purple-100 dark:text-purple-300 dark:border-purple-700 dark:hover:bg-purple-900/20"
>
<ExternalLink className="h-4 w-4 mr-2" />
Learn about creating bootable USB drives
</Button>
</div>
</div>
</Card>
<Card>
<CardHeader>
<div className="flex items-center gap-4">
<div className="p-2 bg-primary/10 rounded-lg">
<Usb className="h-6 w-6 text-primary" />
</div>
<div className="flex-1">
<CardTitle>ISO Management</CardTitle>
<CardDescription>
Download Talos ISO images for creating bootable USB drives
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
{!currentInstance ? (
<div className="text-center py-8">
<Usb 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">
Please select or create an instance to manage ISO images.
</p>
</div>
) : error ? (
<div className="text-center py-8">
<AlertCircle className="h-12 w-12 text-red-500 mx-auto mb-4" />
<h3 className="text-lg font-medium mb-2">Error Loading ISO</h3>
<p className="text-muted-foreground mb-4">{(error as Error).message}</p>
<Button onClick={() => window.location.reload()}>Reload Page</Button>
</div>
) : (
<div className="space-y-6">
{/* Version Selection */}
<div>
<label className="text-sm font-medium mb-2 block">Talos Version</label>
<select
value={selectedVersion}
onChange={(e) => setSelectedVersion(e.target.value)}
className="w-full md:w-64 px-3 py-2 border rounded-lg bg-background"
>
<option value="v1.8.0">v1.8.0 (Latest)</option>
<option value="v1.7.6">v1.7.6</option>
<option value="v1.7.5">v1.7.5</option>
<option value="v1.6.7">v1.6.7</option>
</select>
</div>
{/* ISO Asset */}
<div>
<h4 className="font-medium mb-4">ISO Image</h4>
{isLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : isoAssets.length === 0 ? (
<Card className="p-8 text-center">
<Disc className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<h3 className="text-lg font-medium mb-2">No ISO Available</h3>
<p className="text-muted-foreground mb-4">
Download a Talos ISO to get started with USB boot.
</p>
<Button onClick={() => handleDownload('iso')} disabled={downloadAsset.isPending}>
{downloadAsset.isPending ? (
<Loader2 className="h-4 w-4 animate-spin mr-2" />
) : (
<Download className="h-4 w-4 mr-2" />
)}
Download ISO
</Button>
</Card>
) : (
<div className="space-y-3">
{isoAssets.map((asset) => (
<Card key={asset.type} className="p-4">
<div className="flex items-center gap-4">
<div className="p-2 bg-muted rounded-lg">{getAssetIcon(asset.type)}</div>
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<h5 className="font-medium capitalize">Talos ISO</h5>
{getStatusBadge(asset.status)}
</div>
<div className="text-sm text-muted-foreground space-y-1">
{asset.version && <div>Version: {asset.version}</div>}
{asset.size && <div>Size: {asset.size}</div>}
{asset.path && (
<div className="font-mono text-xs truncate">{asset.path}</div>
)}
{asset.error && (
<div className="text-red-500">{asset.error}</div>
)}
</div>
</div>
<div className="flex gap-2">
{asset.status !== 'available' && asset.status !== 'downloading' && (
<Button
size="sm"
onClick={() => handleDownload(asset.type as PxeAssetType)}
disabled={
downloadAsset.isPending || downloadingType === asset.type
}
>
{downloadingType === asset.type ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<>
<Download className="h-4 w-4 mr-1" />
Download
</>
)}
</Button>
)}
{asset.status === 'available' && (
<>
<Button
size="sm"
variant="outline"
onClick={() => {
// Download the ISO file from Wild Central to user's computer
if (asset.path && currentInstance) {
window.location.href = `/api/v1/instances/${currentInstance}/pxe/assets/iso`;
}
}}
>
<Download className="h-4 w-4 mr-1" />
Download to Computer
</Button>
<Button
size="sm"
variant="destructive"
onClick={() => handleDelete(asset.type as PxeAssetType)}
disabled={deleteAsset.isPending}
>
{deleteAsset.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Trash2 className="h-4 w-4" />
)}
</Button>
</>
)}
</div>
</div>
</Card>
))}
</div>
)}
</div>
{/* Instructions Card */}
<Card className="p-6 bg-muted/50">
<h4 className="font-medium mb-3 flex items-center gap-2">
<Usb className="h-5 w-5" />
Next Steps
</h4>
<ol className="space-y-2 text-sm text-muted-foreground list-decimal list-inside">
<li>Download the ISO image above</li>
<li>Download a USB writing tool (e.g., Balena Etcher, Rufus, or dd)</li>
<li>Write the ISO to a USB drive (minimum 2GB)</li>
<li>Boot your target computer from the USB drive</li>
<li>Follow the Talos installation process</li>
</ol>
</Card>
</div>
)}
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,40 @@
import { useNavigate } from 'react-router';
import { useInstanceContext } from '../../hooks/useInstanceContext';
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '../../components/ui/card';
import { Button } from '../../components/ui/button';
import { Server } from 'lucide-react';
export function LandingPage() {
const navigate = useNavigate();
const { currentInstance } = useInstanceContext();
// For now, we'll use a default instance
// In the future, this will show an instance selector
const handleSelectInstance = () => {
const instanceId = currentInstance || 'default';
navigate(`/instances/${instanceId}/dashboard`);
};
return (
<div className="flex items-center justify-center min-h-screen">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<CardTitle className="text-2xl">Wild Cloud</CardTitle>
<CardDescription>
Select an instance to manage your cloud infrastructure
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<Button
onClick={handleSelectInstance}
className="w-full"
size="lg"
>
<Server className="mr-2 h-5 w-5" />
{currentInstance ? `Continue to ${currentInstance}` : 'Go to Default Instance'}
</Button>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,30 @@
import { Link } from 'react-router';
import { AlertCircle, Home } from 'lucide-react';
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '../../components/ui/card';
import { Button } from '../../components/ui/button';
export function NotFoundPage() {
return (
<div className="flex items-center justify-center min-h-[600px]">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<div className="flex justify-center mb-4">
<AlertCircle className="h-16 w-16 text-destructive" />
</div>
<CardTitle className="text-2xl">Page Not Found</CardTitle>
<CardDescription>
The page you're looking for doesn't exist or has been moved.
</CardDescription>
</CardHeader>
<CardContent className="flex justify-center">
<Link to="/">
<Button>
<Home className="mr-2 h-4 w-4" />
Go to Home
</Button>
</Link>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,209 @@
import { useState } from 'react';
import { useParams } from 'react-router-dom';
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '../../components/ui/card';
import { Button } from '../../components/ui/button';
import { Badge } from '../../components/ui/badge';
import { Skeleton } from '../../components/ui/skeleton';
import { Activity, AlertCircle, Filter } from 'lucide-react';
import { useOperations } from '../../services/api';
import { OperationCard } from '../../components/operations/OperationCard';
type FilterType = 'all' | 'running' | 'completed' | 'failed';
export function OperationsPage() {
const { instanceId } = useParams<{ instanceId: string }>();
const [filter, setFilter] = useState<FilterType>('all');
const filterForApi = filter === 'all' ? undefined : filter;
const { data, isLoading, error } = useOperations(instanceId || '', filterForApi);
const getFilterCount = (type: FilterType) => {
if (!data) return 0;
if (type === 'all') return data.operations.length;
if (type === 'running') {
return data.operations.filter(op =>
op.status === 'running' || op.status === 'pending'
).length;
}
return data.operations.filter(op => op.status === type).length;
};
const runningCount = getFilterCount('running');
const completedCount = getFilterCount('completed');
const failedCount = getFilterCount('failed');
if (!instanceId) {
return (
<div className="flex items-center justify-center h-96">
<Card className="p-6">
<div className="flex items-center gap-3 text-muted-foreground">
<AlertCircle className="h-5 w-5" />
<p>No instance selected</p>
</div>
</Card>
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div>
<h2 className="text-3xl font-bold tracking-tight">Operations</h2>
<p className="text-muted-foreground">
Monitor and manage operations for {instanceId}
</p>
</div>
{/* Summary Cards */}
<div className="grid gap-4 md:grid-cols-3">
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium">Running</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{runningCount}</div>
<p className="text-xs text-muted-foreground mt-1">
Active operations
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium">Completed</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-green-600">{completedCount}</div>
<p className="text-xs text-muted-foreground mt-1">
Successfully finished
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium">Failed</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-red-600">{failedCount}</div>
<p className="text-xs text-muted-foreground mt-1">
Encountered errors
</p>
</CardContent>
</Card>
</div>
{/* Filters */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="flex items-center gap-2">
<Activity className="h-5 w-5" />
Operations
</CardTitle>
<CardDescription>
Real-time operation monitoring with auto-refresh
</CardDescription>
</div>
<div className="flex items-center gap-2">
<Filter className="h-4 w-4 text-muted-foreground" />
<div className="flex gap-1">
<Button
size="sm"
variant={filter === 'all' ? 'default' : 'outline'}
onClick={() => setFilter('all')}
>
All
<Badge variant="secondary" className="ml-2">
{data?.operations.length || 0}
</Badge>
</Button>
<Button
size="sm"
variant={filter === 'running' ? 'default' : 'outline'}
onClick={() => setFilter('running')}
>
Running
<Badge variant="secondary" className="ml-2">
{runningCount}
</Badge>
</Button>
<Button
size="sm"
variant={filter === 'completed' ? 'default' : 'outline'}
onClick={() => setFilter('completed')}
>
Completed
<Badge variant="secondary" className="ml-2">
{completedCount}
</Badge>
</Button>
<Button
size="sm"
variant={filter === 'failed' ? 'default' : 'outline'}
onClick={() => setFilter('failed')}
>
Failed
<Badge variant="secondary" className="ml-2">
{failedCount}
</Badge>
</Button>
</div>
</div>
</div>
</CardHeader>
<CardContent>
{error ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<AlertCircle className="h-12 w-12 text-red-500 mb-3" />
<p className="text-sm font-medium text-red-900 dark:text-red-100">
Error loading operations
</p>
<p className="text-xs text-red-700 dark:text-red-300 mt-1">
{error.message}
</p>
</div>
) : isLoading ? (
<div className="space-y-3">
<Skeleton className="h-32 w-full" />
<Skeleton className="h-32 w-full" />
<Skeleton className="h-32 w-full" />
</div>
) : data && data.operations.length > 0 ? (
<div className="space-y-3">
{data.operations.map((operation) => (
<OperationCard
key={operation.id}
operation={operation}
expandable={true}
/>
))}
</div>
) : (
<div className="text-center py-12 text-muted-foreground">
<Activity className="h-12 w-12 mx-auto mb-3 opacity-50" />
<p className="text-sm font-medium">No operations found</p>
<p className="text-xs mt-1">
{filter === 'all'
? 'Operations will appear here as they are created'
: `No ${filter} operations at this time`}
</p>
</div>
)}
</CardContent>
</Card>
{/* Auto-refresh indicator */}
<div className="text-center">
<p className="text-xs text-muted-foreground">
Auto-refreshing every 3 seconds
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,281 @@
import { useState } from 'react';
import { ErrorBoundary } from '../../components';
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '../../components/ui/card';
import { Button } from '../../components/ui/button';
import { Badge } from '../../components/ui/badge';
import {
HardDrive,
BookOpen,
ExternalLink,
Download,
Trash2,
Loader2,
CheckCircle,
AlertCircle,
FileArchive,
} from 'lucide-react';
import { useInstanceContext } from '../../hooks/useInstanceContext';
import {
usePxeAssets,
useDownloadPxeAsset,
useDeletePxeAsset,
} from '../../services/api';
import type { PxeAssetType } from '../../services/api';
export function PxePage() {
const { currentInstance } = useInstanceContext();
const { data, isLoading, error } = usePxeAssets(currentInstance);
const downloadAsset = useDownloadPxeAsset();
const deleteAsset = useDeletePxeAsset();
const [selectedVersion, setSelectedVersion] = useState('v1.8.0');
const [downloadingType, setDownloadingType] = useState<PxeAssetType | null>(null);
const handleDownload = (type: PxeAssetType) => {
if (!currentInstance) return;
setDownloadingType(type);
// Build URL based on asset type
let url = '';
if (type === 'kernel') {
url = `https://github.com/siderolabs/talos/releases/download/${selectedVersion}/kernel-amd64`;
} else if (type === 'initramfs') {
url = `https://github.com/siderolabs/talos/releases/download/${selectedVersion}/initramfs-amd64.xz`;
}
downloadAsset.mutate(
{
instanceName: currentInstance,
request: { type, version: selectedVersion, url },
},
{
onSettled: () => setDownloadingType(null),
}
);
};
const handleDelete = (type: PxeAssetType) => {
if (!currentInstance) return;
if (confirm(`Are you sure you want to delete the ${type} asset?`)) {
deleteAsset.mutate({ instanceName: currentInstance, type });
}
};
const getStatusBadge = (status?: string) => {
// Default to 'missing' if status is undefined
const statusValue = status || 'missing';
const variants: Record<string, 'secondary' | 'default' | 'success' | 'destructive' | 'warning'> = {
available: 'success',
missing: 'secondary',
downloading: 'default',
error: 'destructive',
};
const icons: Record<string, React.ReactNode> = {
available: <CheckCircle className="h-3 w-3" />,
missing: <AlertCircle className="h-3 w-3" />,
downloading: <Loader2 className="h-3 w-3 animate-spin" />,
error: <AlertCircle className="h-3 w-3" />,
};
return (
<Badge variant={variants[statusValue] || 'secondary'} className="flex items-center gap-1">
{icons[statusValue]}
{statusValue.charAt(0).toUpperCase() + statusValue.slice(1)}
</Badge>
);
};
const getAssetIcon = (type: string) => {
switch (type) {
case 'kernel':
return <FileArchive className="h-5 w-5 text-blue-500" />;
case 'initramfs':
return <FileArchive className="h-5 w-5 text-green-500" />;
case 'iso':
return <FileArchive className="h-5 w-5 text-purple-500" />;
default:
return <FileArchive className="h-5 w-5 text-muted-foreground" />;
}
};
return (
<ErrorBoundary>
<div className="space-y-6">
{/* Educational Intro Section */}
<Card className="p-6 bg-gradient-to-r from-orange-50 to-amber-50 dark:from-orange-950/20 dark:to-amber-950/20 border-orange-200 dark:border-orange-800">
<div className="flex items-start gap-4">
<div className="p-3 bg-orange-100 dark:bg-orange-900/30 rounded-lg">
<BookOpen className="h-6 w-6 text-orange-600 dark:text-orange-400" />
</div>
<div className="flex-1">
<h3 className="text-lg font-semibold text-orange-900 dark:text-orange-100 mb-2">
What is PXE Boot?
</h3>
<p className="text-orange-800 dark:text-orange-200 mb-3 leading-relaxed">
PXE (Preboot Execution Environment) is like having a "network installer" that can set
up computers without needing USB drives or DVDs. When you turn on a computer, instead
of booting from its hard drive, it can boot from the network and automatically install
an operating system or run diagnostics.
</p>
<p className="text-orange-700 dark:text-orange-300 mb-4 text-sm">
This is especially useful for setting up multiple computers in your cloud
infrastructure. PXE can automatically install and configure the same operating system
on many machines, making it easy to expand your personal cloud.
</p>
<Button
variant="outline"
size="sm"
className="text-orange-700 border-orange-300 hover:bg-orange-100 dark:text-orange-300 dark:border-orange-700 dark:hover:bg-orange-900/20"
>
<ExternalLink className="h-4 w-4 mr-2" />
Learn more about network booting
</Button>
</div>
</div>
</Card>
<Card>
<CardHeader>
<div className="flex items-center gap-4">
<div className="p-2 bg-primary/10 rounded-lg">
<HardDrive className="h-6 w-6 text-primary" />
</div>
<div className="flex-1">
<CardTitle>PXE Configuration</CardTitle>
<CardDescription>
Manage PXE boot assets and network boot configuration
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
{!currentInstance ? (
<div className="text-center py-8">
<HardDrive 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">
Please select or create an instance to manage PXE assets.
</p>
</div>
) : error ? (
<div className="text-center py-8">
<AlertCircle className="h-12 w-12 text-red-500 mx-auto mb-4" />
<h3 className="text-lg font-medium mb-2">Error Loading Assets</h3>
<p className="text-muted-foreground mb-4">{(error as Error).message}</p>
<Button onClick={() => window.location.reload()}>Reload Page</Button>
</div>
) : (
<div className="space-y-6">
{/* Version Selection */}
<div>
<label className="text-sm font-medium mb-2 block">Talos Version</label>
<select
value={selectedVersion}
onChange={(e) => setSelectedVersion(e.target.value)}
className="w-full md:w-64 px-3 py-2 border rounded-lg bg-background"
>
<option value="v1.8.0">v1.8.0 (Latest)</option>
<option value="v1.7.6">v1.7.6</option>
<option value="v1.7.5">v1.7.5</option>
<option value="v1.6.7">v1.6.7</option>
</select>
</div>
{/* Assets List */}
<div>
<h4 className="font-medium mb-4">Boot Assets</h4>
{isLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : (
<div className="space-y-3">
{data?.assets.filter((asset) => asset.type !== 'iso').map((asset) => (
<Card key={asset.type} className="p-4">
<div className="flex items-center gap-4">
<div className="p-2 bg-muted rounded-lg">{getAssetIcon(asset.type)}</div>
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<h5 className="font-medium capitalize">{asset.type}</h5>
{getStatusBadge(asset.status)}
</div>
<div className="text-sm text-muted-foreground space-y-1">
{asset.version && <div>Version: {asset.version}</div>}
{asset.size && <div>Size: {asset.size}</div>}
{asset.path && (
<div className="font-mono text-xs truncate">{asset.path}</div>
)}
{asset.error && (
<div className="text-red-500">{asset.error}</div>
)}
</div>
</div>
<div className="flex gap-2">
{asset.status !== 'available' && asset.status !== 'downloading' && (
<Button
size="sm"
onClick={() => handleDownload(asset.type as PxeAssetType)}
disabled={
downloadAsset.isPending || downloadingType === asset.type
}
>
{downloadingType === asset.type ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<>
<Download className="h-4 w-4 mr-1" />
Download
</>
)}
</Button>
)}
{asset.status === 'available' && (
<Button
size="sm"
variant="destructive"
onClick={() => handleDelete(asset.type as PxeAssetType)}
disabled={deleteAsset.isPending}
>
{deleteAsset.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Trash2 className="h-4 w-4" />
)}
</Button>
)}
</div>
</div>
</Card>
))}
</div>
)}
</div>
{/* Download All Button */}
{data?.assets && data.assets.some((a) => a.status !== 'available') && (
<div className="flex justify-end">
<Button
onClick={() => {
data.assets
.filter((a) => a.status !== 'available')
.forEach((a) => handleDownload(a.type as PxeAssetType));
}}
disabled={downloadAsset.isPending}
>
<Download className="h-4 w-4 mr-2" />
Download All Missing Assets
</Button>
</div>
)}
</div>
)}
</CardContent>
</Card>
</div>
</ErrorBoundary>
);
}

View File

@@ -0,0 +1,211 @@
import { useState } from 'react';
import { useParams } from 'react-router-dom';
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '../../components/ui/card';
import { Button } from '../../components/ui/button';
import { Label } from '../../components/ui/label';
import { Skeleton } from '../../components/ui/skeleton';
import { SecretInput } from '../../components/SecretInput';
import { Key, AlertTriangle, Save, X } from 'lucide-react';
import { useSecrets, useUpdateSecrets } from '../../hooks/useSecrets';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '../../components/ui/dialog';
export function SecretsPage() {
const { instanceId } = useParams<{ instanceId: string }>();
const [isEditing, setIsEditing] = useState(false);
const [editedSecrets, setEditedSecrets] = useState<Record<string, unknown>>({});
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
const { data: secrets, isLoading } = useSecrets(instanceId, true);
const updateMutation = useUpdateSecrets(instanceId);
const handleEdit = () => {
setEditedSecrets(secrets || {});
setIsEditing(true);
};
const handleCancel = () => {
setIsEditing(false);
setEditedSecrets({});
};
const handleSave = () => {
setShowConfirmDialog(true);
};
const confirmSave = async () => {
await updateMutation.mutateAsync(editedSecrets);
setShowConfirmDialog(false);
setIsEditing(false);
setEditedSecrets({});
};
const handleSecretChange = (path: string, value: string) => {
setEditedSecrets((prev) => {
const updated = { ...prev };
// Support nested paths using dot notation
const keys = path.split('.');
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let current: any = updated;
for (let i = 0; i < keys.length - 1; i++) {
if (!current[keys[i]]) current[keys[i]] = {};
current = current[keys[i]];
}
current[keys[keys.length - 1]] = value;
return updated;
});
};
// Flatten nested object into dot-notation paths
const flattenSecrets = (obj: Record<string, unknown>, prefix = ''): Array<{ path: string; value: string }> => {
const result: Array<{ path: string; value: string }> = [];
for (const [key, value] of Object.entries(obj)) {
const path = prefix ? `${prefix}.${key}` : key;
if (value && typeof value === 'object' && !Array.isArray(value)) {
result.push(...flattenSecrets(value as Record<string, unknown>, path));
} else {
result.push({ path, value: String(value || '') });
}
}
return result;
};
const getValue = (obj: Record<string, unknown>, path: string): string => {
const keys = path.split('.');
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let current: any = obj;
for (const key of keys) {
if (!current || typeof current !== 'object') return '';
current = current[key];
}
return String(current || '');
};
if (!instanceId) {
return (
<div className="flex items-center justify-center h-96">
<Card className="p-6">
<div className="flex items-center gap-3 text-muted-foreground">
<AlertTriangle className="h-5 w-5" />
<p>No instance selected</p>
</div>
</Card>
</div>
);
}
const secretsList = secrets ? flattenSecrets(secrets) : [];
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h2 className="text-3xl font-bold tracking-tight">Secrets Management</h2>
<p className="text-muted-foreground">
Manage instance secrets securely
</p>
</div>
{!isEditing ? (
<Button onClick={handleEdit} disabled={isLoading}>
Edit Secrets
</Button>
) : (
<div className="flex gap-2">
<Button onClick={handleCancel} variant="outline">
<X className="h-4 w-4" />
Cancel
</Button>
<Button onClick={handleSave} disabled={updateMutation.isPending}>
<Save className="h-4 w-4" />
Save Changes
</Button>
</div>
)}
</div>
{isEditing && (
<Card className="border-yellow-500 bg-yellow-50 dark:bg-yellow-950/20">
<CardContent className="pt-6">
<div className="flex gap-3">
<AlertTriangle className="h-5 w-5 text-yellow-600 dark:text-yellow-500 flex-shrink-0 mt-0.5" />
<div>
<p className="font-medium text-yellow-900 dark:text-yellow-200">
Security Warning
</p>
<p className="text-sm text-yellow-800 dark:text-yellow-300 mt-1">
You are editing sensitive secrets. Make sure you are in a secure environment.
Changes will be saved immediately and cannot be undone.
</p>
</div>
</div>
</CardContent>
</Card>
)}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Key className="h-5 w-5" />
Instance Secrets
</CardTitle>
<CardDescription>
{isEditing ? 'Edit secret values below' : 'View encrypted secrets for this instance'}
</CardDescription>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="space-y-4">
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
</div>
) : secretsList.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
<Key className="h-12 w-12 mx-auto mb-3 opacity-50" />
<p className="text-sm">No secrets found</p>
<p className="text-xs mt-1">Secrets will appear here once configured</p>
</div>
) : (
<div className="space-y-4">
{secretsList.map(({ path, value }) => (
<div key={path} className="space-y-2">
<Label htmlFor={path}>{path}</Label>
<SecretInput
value={isEditing ? getValue(editedSecrets, path) : value}
onChange={isEditing ? (newValue) => handleSecretChange(path, newValue) : undefined}
readOnly={!isEditing}
/>
</div>
))}
</div>
)}
</CardContent>
</Card>
<Dialog open={showConfirmDialog} onOpenChange={setShowConfirmDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>Confirm Save</DialogTitle>
<DialogDescription>
Are you sure you want to save these secret changes? This action cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setShowConfirmDialog(false)}>
Cancel
</Button>
<Button onClick={confirmSave} disabled={updateMutation.isPending}>
{updateMutation.isPending ? 'Saving...' : 'Save Changes'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,182 @@
import { useState } from 'react';
import { UtilityCard, CopyableValue } from '../../components/UtilityCard';
import { Button } from '../../components/ui/button';
import {
Key,
Info,
Network,
Server,
Copy,
AlertCircle,
} from 'lucide-react';
import {
useDashboardToken,
useClusterVersions,
useNodeIPs,
useControlPlaneIP,
useCopySecret,
} from '../../services/api/hooks/useUtilities';
export function UtilitiesPage() {
const [secretToCopy, setSecretToCopy] = useState('');
const [targetInstance, setTargetInstance] = useState('');
const dashboardToken = useDashboardToken();
const versions = useClusterVersions();
const nodeIPs = useNodeIPs();
const controlPlaneIP = useControlPlaneIP();
const copySecret = useCopySecret();
const handleCopySecret = () => {
if (secretToCopy && targetInstance) {
copySecret.mutate({ secret: secretToCopy, targetInstance });
}
};
return (
<div className="space-y-6">
<div>
<h2 className="text-3xl font-bold tracking-tight">Utilities</h2>
<p className="text-muted-foreground">
Additional tools and utilities for cluster management
</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Dashboard Token */}
<UtilityCard
title="Dashboard Access Token"
description="Retrieve your Kubernetes dashboard authentication token"
icon={<Key className="h-5 w-5 text-primary" />}
isLoading={dashboardToken.isLoading}
error={dashboardToken.error}
>
{dashboardToken.data && (
<CopyableValue
label="Token"
value={dashboardToken.data.token}
multiline
/>
)}
</UtilityCard>
{/* Cluster Versions */}
<UtilityCard
title="Cluster Version Information"
description="View Kubernetes and Talos versions running on your cluster"
icon={<Info className="h-5 w-5 text-primary" />}
isLoading={versions.isLoading}
error={versions.error}
>
{versions.data && (
<div className="space-y-3">
<div className="flex justify-between items-center p-3 bg-muted rounded-lg">
<span className="text-sm font-medium">Kubernetes</span>
<span className="text-sm font-mono">{versions.data.version}</span>
</div>
{Object.entries(versions.data)
.filter(([key]) => key !== 'version')
.map(([key, value]) => (
<div
key={key}
className="flex justify-between items-center p-3 bg-muted rounded-lg"
>
<span className="text-sm font-medium capitalize">
{key.replace(/([A-Z])/g, ' $1').trim()}
</span>
<span className="text-sm font-mono">{String(value)}</span>
</div>
))}
</div>
)}
</UtilityCard>
{/* Node IPs */}
<UtilityCard
title="Node IP Addresses"
description="List all node IP addresses in your cluster"
icon={<Network className="h-5 w-5 text-primary" />}
isLoading={nodeIPs.isLoading}
error={nodeIPs.error}
>
{nodeIPs.data && (
<div className="space-y-2">
{nodeIPs.data.ips.map((ip, index) => (
<CopyableValue key={index} value={ip} label={`Node ${index + 1}`} />
))}
{nodeIPs.data.ips.length === 0 && (
<div className="flex items-center gap-2 text-muted-foreground text-sm">
<AlertCircle className="h-4 w-4" />
<span>No nodes found</span>
</div>
)}
</div>
)}
</UtilityCard>
{/* Control Plane IP */}
<UtilityCard
title="Control Plane IP"
description="Display the control plane endpoint IP address"
icon={<Server className="h-5 w-5 text-primary" />}
isLoading={controlPlaneIP.isLoading}
error={controlPlaneIP.error}
>
{controlPlaneIP.data && (
<CopyableValue label="Control Plane IP" value={controlPlaneIP.data.ip} />
)}
</UtilityCard>
{/* Secret Copy Utility */}
<UtilityCard
title="Copy Secret"
description="Copy a secret between namespaces or instances"
icon={<Copy className="h-5 w-5 text-primary" />}
>
<div className="space-y-4">
<div>
<label className="text-sm font-medium mb-2 block">Secret Name</label>
<input
type="text"
placeholder="e.g., my-secret"
value={secretToCopy}
onChange={(e) => setSecretToCopy(e.target.value)}
className="w-full px-3 py-2 border rounded-lg bg-background"
/>
</div>
<div>
<label className="text-sm font-medium mb-2 block">
Target Instance/Namespace
</label>
<input
type="text"
placeholder="e.g., production"
value={targetInstance}
onChange={(e) => setTargetInstance(e.target.value)}
className="w-full px-3 py-2 border rounded-lg bg-background"
/>
</div>
<Button
onClick={handleCopySecret}
disabled={!secretToCopy || !targetInstance || copySecret.isPending}
className="w-full"
>
{copySecret.isPending ? 'Copying...' : 'Copy Secret'}
</Button>
{copySecret.isSuccess && (
<div className="text-sm text-green-600 dark:text-green-400">
Secret copied successfully!
</div>
)}
{copySecret.error && (
<div className="flex items-center gap-2 text-red-500 text-sm">
<AlertCircle className="h-4 w-4" />
<span>{copySecret.error.message}</span>
</div>
)}
</div>
</UtilityCard>
</div>
</div>
);
}

111
src/router/routes.tsx Normal file
View File

@@ -0,0 +1,111 @@
import { Navigate } from 'react-router';
import type { RouteObject } from 'react-router';
import { InstanceLayout } from './InstanceLayout';
import { LandingPage } from './pages/LandingPage';
import { NotFoundPage } from './pages/NotFoundPage';
import { DashboardPage } from './pages/DashboardPage';
import { OperationsPage } from './pages/OperationsPage';
import { ClusterHealthPage } from './pages/ClusterHealthPage';
import { ClusterAccessPage } from './pages/ClusterAccessPage';
import { SecretsPage } from './pages/SecretsPage';
import { BaseServicesPage } from './pages/BaseServicesPage';
import { UtilitiesPage } from './pages/UtilitiesPage';
import { CloudPage } from './pages/CloudPage';
import { CentralPage } from './pages/CentralPage';
import { DnsPage } from './pages/DnsPage';
import { DhcpPage } from './pages/DhcpPage';
import { PxePage } from './pages/PxePage';
import { IsoPage } from './pages/IsoPage';
import { InfrastructurePage } from './pages/InfrastructurePage';
import { ClusterPage } from './pages/ClusterPage';
import { AppsPage } from './pages/AppsPage';
import { AdvancedPage } from './pages/AdvancedPage';
export const routes: RouteObject[] = [
{
path: '/',
element: <LandingPage />,
},
{
path: '/instances/:instanceId',
element: <InstanceLayout />,
children: [
{
index: true,
element: <Navigate to="dashboard" replace />,
},
{
path: 'dashboard',
element: <DashboardPage />,
},
{
path: 'operations',
element: <OperationsPage />,
},
{
path: 'cluster/health',
element: <ClusterHealthPage />,
},
{
path: 'cluster/access',
element: <ClusterAccessPage />,
},
{
path: 'secrets',
element: <SecretsPage />,
},
{
path: 'services',
element: <BaseServicesPage />,
},
{
path: 'utilities',
element: <UtilitiesPage />,
},
{
path: 'cloud',
element: <CloudPage />,
},
{
path: 'central',
element: <CentralPage />,
},
{
path: 'dns',
element: <DnsPage />,
},
{
path: 'dhcp',
element: <DhcpPage />,
},
{
path: 'pxe',
element: <PxePage />,
},
{
path: 'iso',
element: <IsoPage />,
},
{
path: 'infrastructure',
element: <InfrastructurePage />,
},
{
path: 'cluster',
element: <ClusterPage />,
},
{
path: 'apps',
element: <AppsPage />,
},
{
path: 'advanced',
element: <AdvancedPage />,
},
],
},
{
path: '*',
element: <NotFoundPage />,
},
];

View File

@@ -0,0 +1,92 @@
import type { Status, ConfigResponse, Config, HealthResponse, StatusResponse } from '../types';
const API_BASE = import.meta.env.VITE_API_BASE_URL || 'http://localhost:5055';
class ApiService {
private baseUrl: string;
constructor(baseUrl: string = API_BASE) {
this.baseUrl = baseUrl;
}
private async request<T>(endpoint: string, options?: RequestInit): Promise<T> {
const url = `${this.baseUrl}${endpoint}`;
const response = await fetch(url, options);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json();
}
private async requestText(endpoint: string, options?: RequestInit): Promise<string> {
const url = `${this.baseUrl}${endpoint}`;
const response = await fetch(url, options);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.text();
}
async getStatus(): Promise<Status> {
return this.request<Status>('/api/status');
}
async getHealth(): Promise<HealthResponse> {
return this.request<HealthResponse>('/api/v1/health');
}
async getConfig(): Promise<ConfigResponse> {
return this.request<ConfigResponse>('/api/v1/config');
}
async getConfigYaml(): Promise<string> {
return this.requestText('/api/v1/config/yaml');
}
async updateConfigYaml(yamlContent: string): Promise<StatusResponse> {
return this.request<StatusResponse>('/api/v1/config/yaml', {
method: 'PUT',
headers: { 'Content-Type': 'text/plain' },
body: yamlContent
});
}
async createConfig(config: Config): Promise<StatusResponse> {
return this.request<StatusResponse>('/api/v1/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config)
});
}
async updateConfig(config: Config): Promise<StatusResponse> {
return this.request<StatusResponse>('/api/v1/config', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config)
});
}
async getDnsmasqConfig(): Promise<string> {
return this.requestText('/api/v1/dnsmasq/config');
}
async restartDnsmasq(): Promise<StatusResponse> {
return this.request<StatusResponse>('/api/v1/dnsmasq/restart', {
method: 'POST'
});
}
async downloadPXEAssets(): Promise<StatusResponse> {
return this.request<StatusResponse>('/api/v1/pxe/assets', {
method: 'POST'
});
}
}
export const apiService = new ApiService();
export default ApiService;

View File

@@ -1,92 +1,3 @@
import type { Status, ConfigResponse, Config, HealthResponse, StatusResponse } from '../types';
const API_BASE = 'http://localhost:5055';
class ApiService {
private baseUrl: string;
constructor(baseUrl: string = API_BASE) {
this.baseUrl = baseUrl;
}
private async request<T>(endpoint: string, options?: RequestInit): Promise<T> {
const url = `${this.baseUrl}${endpoint}`;
const response = await fetch(url, options);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json();
}
private async requestText(endpoint: string, options?: RequestInit): Promise<string> {
const url = `${this.baseUrl}${endpoint}`;
const response = await fetch(url, options);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.text();
}
async getStatus(): Promise<Status> {
return this.request<Status>('/api/status');
}
async getHealth(): Promise<HealthResponse> {
return this.request<HealthResponse>('/api/v1/health');
}
async getConfig(): Promise<ConfigResponse> {
return this.request<ConfigResponse>('/api/v1/config');
}
async getConfigYaml(): Promise<string> {
return this.requestText('/api/v1/config/yaml');
}
async updateConfigYaml(yamlContent: string): Promise<StatusResponse> {
return this.request<StatusResponse>('/api/v1/config/yaml', {
method: 'PUT',
headers: { 'Content-Type': 'text/plain' },
body: yamlContent
});
}
async createConfig(config: Config): Promise<StatusResponse> {
return this.request<StatusResponse>('/api/v1/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config)
});
}
async updateConfig(config: Config): Promise<StatusResponse> {
return this.request<StatusResponse>('/api/v1/config', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config)
});
}
async getDnsmasqConfig(): Promise<string> {
return this.requestText('/api/v1/dnsmasq/config');
}
async restartDnsmasq(): Promise<StatusResponse> {
return this.request<StatusResponse>('/api/v1/dnsmasq/restart', {
method: 'POST'
});
}
async downloadPXEAssets(): Promise<StatusResponse> {
return this.request<StatusResponse>('/api/v1/pxe/assets', {
method: 'POST'
});
}
}
export const apiService = new ApiService();
export default ApiService;
// Re-export everything from the modular API structure
// This file maintains backward compatibility for imports from '../services/api'
export * from './api/index';

54
src/services/api/apps.ts Normal file
View File

@@ -0,0 +1,54 @@
import { apiClient } from './client';
import type {
AppListResponse,
App,
AppAddRequest,
AppAddResponse,
AppStatus,
OperationResponse,
} from './types';
export const appsApi = {
// Available apps (from catalog)
async listAvailable(): Promise<AppListResponse> {
return apiClient.get('/api/v1/apps');
},
async getAvailable(appName: string): Promise<App> {
return apiClient.get(`/api/v1/apps/${appName}`);
},
// Deployed apps (instance-specific)
async listDeployed(instanceName: string): Promise<AppListResponse> {
return apiClient.get(`/api/v1/instances/${instanceName}/apps`);
},
async add(instanceName: string, app: AppAddRequest): Promise<AppAddResponse> {
return apiClient.post(`/api/v1/instances/${instanceName}/apps`, app);
},
async deploy(instanceName: string, appName: string): Promise<OperationResponse> {
return apiClient.post(`/api/v1/instances/${instanceName}/apps/${appName}/deploy`);
},
async delete(instanceName: string, appName: string): Promise<OperationResponse> {
return apiClient.delete(`/api/v1/instances/${instanceName}/apps/${appName}`);
},
async getStatus(instanceName: string, appName: string): Promise<AppStatus> {
return apiClient.get(`/api/v1/instances/${instanceName}/apps/${appName}/status`);
},
// Backup operations
async backup(instanceName: string, appName: string): Promise<OperationResponse> {
return apiClient.post(`/api/v1/instances/${instanceName}/apps/${appName}/backup`);
},
async listBackups(instanceName: string, appName: string): Promise<{ backups: Array<{ id: string; timestamp: string; size?: string }> }> {
return apiClient.get(`/api/v1/instances/${instanceName}/apps/${appName}/backup`);
},
async restore(instanceName: string, appName: string, backupId: string): Promise<OperationResponse> {
return apiClient.post(`/api/v1/instances/${instanceName}/apps/${appName}/restore`, { backup_id: backupId });
},
};

122
src/services/api/client.ts Normal file
View File

@@ -0,0 +1,122 @@
export class ApiError extends Error {
constructor(
message: string,
public statusCode: number,
public details?: unknown
) {
super(message);
this.name = 'ApiError';
}
}
export class ApiClient {
constructor(private baseUrl: string = import.meta.env.VITE_API_BASE_URL || 'http://localhost:5055') {}
private async request<T>(
endpoint: string,
options?: RequestInit
): Promise<T> {
const url = `${this.baseUrl}${endpoint}`;
try {
const response = await fetch(url, {
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers,
},
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new ApiError(
errorData.error || `HTTP ${response.status}: ${response.statusText}`,
response.status,
errorData
);
}
// Handle 204 No Content
if (response.status === 204) {
return {} as T;
}
return response.json();
} catch (error) {
if (error instanceof ApiError) {
throw error;
}
throw new ApiError(
error instanceof Error ? error.message : 'Network error',
0
);
}
}
async get<T>(endpoint: string): Promise<T> {
return this.request<T>(endpoint, { method: 'GET' });
}
async post<T>(endpoint: string, data?: unknown): Promise<T> {
return this.request<T>(endpoint, {
method: 'POST',
body: data ? JSON.stringify(data) : undefined,
});
}
async put<T>(endpoint: string, data?: unknown): Promise<T> {
return this.request<T>(endpoint, {
method: 'PUT',
body: data ? JSON.stringify(data) : undefined,
});
}
async patch<T>(endpoint: string, data?: unknown): Promise<T> {
return this.request<T>(endpoint, {
method: 'PATCH',
body: data ? JSON.stringify(data) : undefined,
});
}
async delete<T>(endpoint: string): Promise<T> {
return this.request<T>(endpoint, { method: 'DELETE' });
}
async getText(endpoint: string): Promise<string> {
const url = `${this.baseUrl}${endpoint}`;
const response = await fetch(url);
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new ApiError(
errorData.error || `HTTP ${response.status}: ${response.statusText}`,
response.status,
errorData
);
}
return response.text();
}
async putText(endpoint: string, text: string): Promise<{ message?: string; [key: string]: unknown }> {
const url = `${this.baseUrl}${endpoint}`;
const response = await fetch(url, {
method: 'PUT',
headers: { 'Content-Type': 'text/plain' },
body: text,
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new ApiError(
errorData.error || `HTTP ${response.status}: ${response.statusText}`,
response.status,
errorData
);
}
return response.json();
}
}
export const apiClient = new ApiClient();

View File

@@ -0,0 +1,47 @@
import { apiClient } from './client';
import type {
ClusterConfig,
ClusterStatus,
ClusterHealthResponse,
KubeconfigResponse,
TalosconfigResponse,
OperationResponse,
} from './types';
export const clusterApi = {
async generateConfig(instanceName: string, config: ClusterConfig): Promise<OperationResponse> {
return apiClient.post(`/api/v1/instances/${instanceName}/cluster/config/generate`, config);
},
async bootstrap(instanceName: string, node: string): Promise<OperationResponse> {
return apiClient.post<OperationResponse>(`/api/v1/instances/${instanceName}/cluster/bootstrap`, { node });
},
async configureEndpoints(instanceName: string, includeNodes = false): Promise<OperationResponse> {
return apiClient.post(`/api/v1/instances/${instanceName}/cluster/endpoints`, { include_nodes: includeNodes });
},
async getStatus(instanceName: string): Promise<ClusterStatus> {
return apiClient.get(`/api/v1/instances/${instanceName}/cluster/status`);
},
async getHealth(instanceName: string): Promise<ClusterHealthResponse> {
return apiClient.get(`/api/v1/instances/${instanceName}/cluster/health`);
},
async getKubeconfig(instanceName: string): Promise<KubeconfigResponse> {
return apiClient.get(`/api/v1/instances/${instanceName}/cluster/kubeconfig`);
},
async generateKubeconfig(instanceName: string): Promise<OperationResponse> {
return apiClient.post(`/api/v1/instances/${instanceName}/cluster/kubeconfig/generate`);
},
async getTalosconfig(instanceName: string): Promise<TalosconfigResponse> {
return apiClient.get(`/api/v1/instances/${instanceName}/cluster/talosconfig`);
},
async reset(instanceName: string, confirm: boolean): Promise<OperationResponse> {
return apiClient.post<OperationResponse>(`/api/v1/instances/${instanceName}/cluster/reset`, { confirm });
},
};

View File

@@ -0,0 +1,12 @@
import { apiClient } from './client';
import type { ContextResponse, SetContextResponse } from './types';
export const contextApi = {
async get(): Promise<ContextResponse> {
return apiClient.get('/api/v1/context');
},
async set(context: string): Promise<SetContextResponse> {
return apiClient.post<SetContextResponse>('/api/v1/context', { context });
},
};

View File

@@ -0,0 +1,28 @@
import { apiClient } from './client';
export interface DnsmasqStatus {
running: boolean;
status?: string;
}
export const dnsmasqApi = {
async getStatus(): Promise<DnsmasqStatus> {
return apiClient.get('/api/v1/dnsmasq/status');
},
async getConfig(): Promise<string> {
return apiClient.getText('/api/v1/dnsmasq/config');
},
async restart(): Promise<{ message: string }> {
return apiClient.post('/api/v1/dnsmasq/restart');
},
async generate(): Promise<{ message: string }> {
return apiClient.post('/api/v1/dnsmasq/generate');
},
async update(): Promise<{ message: string }> {
return apiClient.post('/api/v1/dnsmasq/update');
},
};

View File

@@ -0,0 +1,33 @@
import { useQuery } from '@tanstack/react-query';
import { clusterApi, nodesApi } from '..';
import type { ClusterHealthResponse, ClusterStatus, NodeListResponse } from '../types';
export const useClusterHealth = (instanceName: string) => {
return useQuery<ClusterHealthResponse>({
queryKey: ['cluster-health', instanceName],
queryFn: () => clusterApi.getHealth(instanceName),
enabled: !!instanceName,
refetchInterval: 10000, // Auto-refresh every 10 seconds
staleTime: 5000,
});
};
export const useClusterStatus = (instanceName: string) => {
return useQuery<ClusterStatus>({
queryKey: ['cluster-status', instanceName],
queryFn: () => clusterApi.getStatus(instanceName),
enabled: !!instanceName,
refetchInterval: 10000, // Auto-refresh every 10 seconds
staleTime: 5000,
});
};
export const useClusterNodes = (instanceName: string) => {
return useQuery<NodeListResponse>({
queryKey: ['cluster-nodes', instanceName],
queryFn: () => nodesApi.list(instanceName),
enabled: !!instanceName,
refetchInterval: 10000, // Auto-refresh every 10 seconds
staleTime: 5000,
});
};

View File

@@ -0,0 +1,40 @@
import { useQuery } from '@tanstack/react-query';
import { instancesApi, operationsApi, clusterApi } from '..';
import type { GetInstanceResponse, OperationListResponse, ClusterHealthResponse } from '../types';
export const useInstance = (name: string) => {
return useQuery<GetInstanceResponse>({
queryKey: ['instance', name],
queryFn: () => instancesApi.get(name),
enabled: !!name,
staleTime: 30000, // 30 seconds
});
};
export const useInstanceOperations = (instanceName: string, limit?: number) => {
return useQuery<OperationListResponse>({
queryKey: ['instance-operations', instanceName],
queryFn: async () => {
const response = await operationsApi.list(instanceName);
if (limit) {
return {
operations: response.operations.slice(0, limit)
};
}
return response;
},
enabled: !!instanceName,
refetchInterval: 3000, // Poll every 3 seconds
staleTime: 1000,
});
};
export const useInstanceClusterHealth = (instanceName: string) => {
return useQuery<ClusterHealthResponse>({
queryKey: ['instance-cluster-health', instanceName],
queryFn: () => clusterApi.getHealth(instanceName),
enabled: !!instanceName,
refetchInterval: 10000, // Refresh every 10 seconds
staleTime: 5000,
});
};

View File

@@ -0,0 +1,58 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { operationsApi } from '../operations';
import type { OperationListResponse, Operation } from '../types';
export const useOperations = (instanceName: string, filter?: 'running' | 'completed' | 'failed') => {
return useQuery<OperationListResponse>({
queryKey: ['operations', instanceName, filter],
queryFn: async () => {
const response = await operationsApi.list(instanceName);
if (filter) {
const filtered = response.operations.filter(op => {
if (filter === 'running') return op.status === 'running' || op.status === 'pending';
if (filter === 'completed') return op.status === 'completed';
if (filter === 'failed') return op.status === 'failed';
return true;
});
return { operations: filtered };
}
return response;
},
enabled: !!instanceName,
refetchInterval: 3000, // Poll every 3 seconds for real-time updates
staleTime: 1000,
});
};
export const useOperation = (operationId: string) => {
return useQuery<Operation>({
queryKey: ['operation', operationId],
queryFn: () => operationsApi.get(operationId),
enabled: !!operationId,
refetchInterval: (query) => {
// Stop polling if operation is completed, failed, or cancelled
const status = query.state.data?.status;
if (status === 'completed' || status === 'failed' || status === 'cancelled') {
return false;
}
return 2000; // Poll every 2 seconds while running
},
staleTime: 1000,
});
};
export const useCancelOperation = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ operationId, instanceName }: { operationId: string; instanceName: string }) =>
operationsApi.cancel(operationId, instanceName),
onSuccess: (_, { operationId }) => {
// Invalidate operation queries to refresh data
queryClient.invalidateQueries({ queryKey: ['operation', operationId] });
queryClient.invalidateQueries({ queryKey: ['operations'] });
},
});
};

View File

@@ -0,0 +1,57 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { pxeApi } from '../pxe';
import type { DownloadAssetRequest, PxeAssetType } from '../types';
export function usePxeAssets(instanceName: string | null | undefined) {
return useQuery({
queryKey: ['instances', instanceName, 'pxe', 'assets'],
queryFn: () => pxeApi.listAssets(instanceName!),
enabled: !!instanceName,
refetchInterval: 5000, // Poll every 5 seconds to track download status
});
}
export function usePxeAsset(
instanceName: string | null | undefined,
assetType: PxeAssetType | null | undefined
) {
return useQuery({
queryKey: ['instances', instanceName, 'pxe', 'assets', assetType],
queryFn: () => pxeApi.getAsset(instanceName!, assetType!),
enabled: !!instanceName && !!assetType,
});
}
export function useDownloadPxeAsset() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
instanceName,
request,
}: {
instanceName: string;
request: DownloadAssetRequest;
}) => pxeApi.downloadAsset(instanceName, request),
onSuccess: (_data, variables) => {
// Invalidate assets list to show downloading status
queryClient.invalidateQueries({
queryKey: ['instances', variables.instanceName, 'pxe', 'assets'],
});
},
});
}
export function useDeletePxeAsset() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ instanceName, type }: { instanceName: string; type: PxeAssetType }) =>
pxeApi.deleteAsset(instanceName, type),
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({
queryKey: ['instances', variables.instanceName, 'pxe', 'assets'],
});
},
});
}

View File

@@ -0,0 +1,47 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { utilitiesApi } from '../utilities';
export function useDashboardToken() {
return useQuery({
queryKey: ['utilities', 'dashboard', 'token'],
queryFn: utilitiesApi.getDashboardToken,
staleTime: 5 * 60 * 1000, // 5 minutes
});
}
export function useClusterVersions() {
return useQuery({
queryKey: ['utilities', 'version'],
queryFn: utilitiesApi.getVersion,
staleTime: 10 * 60 * 1000, // 10 minutes
});
}
export function useNodeIPs() {
return useQuery({
queryKey: ['utilities', 'nodes', 'ips'],
queryFn: utilitiesApi.getNodeIPs,
staleTime: 30 * 1000, // 30 seconds
});
}
export function useControlPlaneIP() {
return useQuery({
queryKey: ['utilities', 'controlplane', 'ip'],
queryFn: utilitiesApi.getControlPlaneIP,
staleTime: 60 * 1000, // 1 minute
});
}
export function useCopySecret() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ secret, targetInstance }: { secret: string; targetInstance: string }) =>
utilitiesApi.copySecret(secret, targetInstance),
onSuccess: () => {
// Invalidate secrets queries
queryClient.invalidateQueries({ queryKey: ['secrets'] });
},
});
}

19
src/services/api/index.ts Normal file
View File

@@ -0,0 +1,19 @@
export { apiClient, ApiError } from './client';
export * from './types';
export { instancesApi } from './instances';
export { contextApi } from './context';
export { nodesApi } from './nodes';
export { clusterApi } from './cluster';
export { appsApi } from './apps';
export { servicesApi } from './services';
export { operationsApi } from './operations';
export { dnsmasqApi } from './dnsmasq';
export { utilitiesApi } from './utilities';
export { pxeApi } from './pxe';
// React Query hooks
export { useInstance, useInstanceOperations, useInstanceClusterHealth } from './hooks/useInstance';
export { useOperations, useOperation, useCancelOperation } from './hooks/useOperations';
export { useClusterHealth, useClusterStatus, useClusterNodes } from './hooks/useCluster';
export { useDashboardToken, useClusterVersions, useNodeIPs, useControlPlaneIP, useCopySecret } from './hooks/useUtilities';
export { usePxeAssets, useDownloadPxeAsset, useDeletePxeAsset } from './hooks/usePxeAssets';

View File

@@ -0,0 +1,49 @@
import { apiClient } from './client';
import type {
InstanceListResponse,
CreateInstanceRequest,
CreateInstanceResponse,
DeleteInstanceResponse,
GetInstanceResponse,
} from './types';
export const instancesApi = {
async list(): Promise<InstanceListResponse> {
return apiClient.get('/api/v1/instances');
},
async get(name: string): Promise<GetInstanceResponse> {
return apiClient.get(`/api/v1/instances/${name}`);
},
async create(data: CreateInstanceRequest): Promise<CreateInstanceResponse> {
return apiClient.post('/api/v1/instances', data);
},
async delete(name: string): Promise<DeleteInstanceResponse> {
return apiClient.delete(`/api/v1/instances/${name}`);
},
// Config management
async getConfig(instanceName: string): Promise<Record<string, unknown>> {
return apiClient.get(`/api/v1/instances/${instanceName}/config`);
},
async updateConfig(instanceName: string, config: Record<string, unknown>): Promise<{ message: string }> {
return apiClient.put(`/api/v1/instances/${instanceName}/config`, config);
},
async batchUpdateConfig(instanceName: string, updates: Array<{path: string; value: unknown}>): Promise<{ message: string; updated?: number }> {
return apiClient.patch(`/api/v1/instances/${instanceName}/config`, { updates });
},
// Secrets management
async getSecrets(instanceName: string, raw = false): Promise<Record<string, unknown>> {
const query = raw ? '?raw=true' : '';
return apiClient.get(`/api/v1/instances/${instanceName}/secrets${query}`);
},
async updateSecrets(instanceName: string, secrets: Record<string, unknown>): Promise<{ message: string }> {
return apiClient.put(`/api/v1/instances/${instanceName}/secrets`, secrets);
},
};

57
src/services/api/nodes.ts Normal file
View File

@@ -0,0 +1,57 @@
import { apiClient } from './client';
import type {
NodeListResponse,
NodeAddRequest,
NodeUpdateRequest,
Node,
HardwareInfo,
DiscoveryStatus,
OperationResponse,
} from './types';
export const nodesApi = {
async list(instanceName: string): Promise<NodeListResponse> {
return apiClient.get(`/api/v1/instances/${instanceName}/nodes`);
},
async get(instanceName: string, nodeName: string): Promise<Node> {
return apiClient.get(`/api/v1/instances/${instanceName}/nodes/${nodeName}`);
},
async add(instanceName: string, node: NodeAddRequest): Promise<OperationResponse> {
return apiClient.post(`/api/v1/instances/${instanceName}/nodes`, node);
},
async update(instanceName: string, nodeName: string, updates: NodeUpdateRequest): Promise<OperationResponse> {
return apiClient.put(`/api/v1/instances/${instanceName}/nodes/${nodeName}`, updates);
},
async delete(instanceName: string, nodeName: string): Promise<OperationResponse> {
return apiClient.delete(`/api/v1/instances/${instanceName}/nodes/${nodeName}`);
},
async apply(instanceName: string, nodeName: string): Promise<OperationResponse> {
return apiClient.post(`/api/v1/instances/${instanceName}/nodes/${nodeName}/apply`);
},
// Discovery
async discover(instanceName: string, subnet: string): Promise<OperationResponse> {
return apiClient.post(`/api/v1/instances/${instanceName}/nodes/discover`, { subnet });
},
async detect(instanceName: string): Promise<OperationResponse> {
return apiClient.post(`/api/v1/instances/${instanceName}/nodes/detect`);
},
async discoveryStatus(instanceName: string): Promise<DiscoveryStatus> {
return apiClient.get(`/api/v1/instances/${instanceName}/discovery`);
},
async getHardware(instanceName: string, ip: string): Promise<HardwareInfo> {
return apiClient.get(`/api/v1/instances/${instanceName}/nodes/hardware/${ip}`);
},
async fetchTemplates(instanceName: string): Promise<OperationResponse> {
return apiClient.post(`/api/v1/instances/${instanceName}/nodes/fetch-templates`);
},
};

View File

@@ -0,0 +1,23 @@
import { apiClient } from './client';
import type { Operation, OperationListResponse } from './types';
export const operationsApi = {
async list(instanceName: string): Promise<OperationListResponse> {
return apiClient.get(`/api/v1/instances/${instanceName}/operations`);
},
async get(operationId: string, instanceName?: string): Promise<Operation> {
const params = instanceName ? `?instance=${instanceName}` : '';
return apiClient.get(`/api/v1/operations/${operationId}${params}`);
},
async cancel(operationId: string, instanceName: string): Promise<{ message: string }> {
return apiClient.post(`/api/v1/operations/${operationId}/cancel?instance=${instanceName}`);
},
// SSE stream for operation updates
createStream(operationId: string): EventSource {
const baseUrl = import.meta.env.VITE_API_BASE_URL || 'http://localhost:5055';
return new EventSource(`${baseUrl}/api/v1/operations/${operationId}/stream`);
},
};

29
src/services/api/pxe.ts Normal file
View File

@@ -0,0 +1,29 @@
import { apiClient } from './client';
import type {
PxeAssetsResponse,
PxeAsset,
DownloadAssetRequest,
OperationResponse,
PxeAssetType,
} from './types';
export const pxeApi = {
async listAssets(instanceName: string): Promise<PxeAssetsResponse> {
return apiClient.get(`/api/v1/instances/${instanceName}/pxe/assets`);
},
async getAsset(instanceName: string, type: PxeAssetType): Promise<PxeAsset> {
return apiClient.get(`/api/v1/instances/${instanceName}/pxe/assets/${type}`);
},
async downloadAsset(
instanceName: string,
request: DownloadAssetRequest
): Promise<OperationResponse> {
return apiClient.post(`/api/v1/instances/${instanceName}/pxe/assets/download`, request);
},
async deleteAsset(instanceName: string, type: PxeAssetType): Promise<{ message: string }> {
return apiClient.delete(`/api/v1/instances/${instanceName}/pxe/assets/${type}`);
},
};

View File

@@ -0,0 +1,62 @@
import { apiClient } from './client';
import type {
ServiceListResponse,
Service,
ServiceStatus,
ServiceManifest,
ServiceInstallRequest,
OperationResponse,
} from './types';
export const servicesApi = {
// Instance services
async list(instanceName: string): Promise<ServiceListResponse> {
return apiClient.get(`/api/v1/instances/${instanceName}/services`);
},
async get(instanceName: string, serviceName: string): Promise<Service> {
return apiClient.get(`/api/v1/instances/${instanceName}/services/${serviceName}`);
},
async install(instanceName: string, service: ServiceInstallRequest): Promise<OperationResponse> {
return apiClient.post(`/api/v1/instances/${instanceName}/services`, service);
},
async installAll(instanceName: string): Promise<OperationResponse> {
return apiClient.post(`/api/v1/instances/${instanceName}/services/install-all`);
},
async delete(instanceName: string, serviceName: string): Promise<OperationResponse> {
return apiClient.delete(`/api/v1/instances/${instanceName}/services/${serviceName}`);
},
async getStatus(instanceName: string, serviceName: string): Promise<ServiceStatus> {
return apiClient.get(`/api/v1/instances/${instanceName}/services/${serviceName}/status`);
},
async getConfig(instanceName: string, serviceName: string): Promise<Record<string, unknown>> {
return apiClient.get(`/api/v1/instances/${instanceName}/services/${serviceName}/config`);
},
// Service lifecycle
async fetch(instanceName: string, serviceName: string): Promise<OperationResponse> {
return apiClient.post(`/api/v1/instances/${instanceName}/services/${serviceName}/fetch`);
},
async compile(instanceName: string, serviceName: string): Promise<OperationResponse> {
return apiClient.post(`/api/v1/instances/${instanceName}/services/${serviceName}/compile`);
},
async deploy(instanceName: string, serviceName: string): Promise<OperationResponse> {
return apiClient.post(`/api/v1/instances/${instanceName}/services/${serviceName}/deploy`);
},
// Global service info (not instance-specific)
async getManifest(serviceName: string): Promise<ServiceManifest> {
return apiClient.get(`/api/v1/services/${serviceName}/manifest`);
},
async getGlobalConfig(serviceName: string): Promise<Record<string, unknown>> {
return apiClient.get(`/api/v1/services/${serviceName}/config`);
},
};

View File

@@ -0,0 +1,53 @@
export interface App {
name: string;
description: string;
version: string;
category?: string;
icon?: string;
requires?: AppRequirement[];
defaultConfig?: Record<string, unknown>;
requiredSecrets?: string[];
dependencies?: string[];
config?: Record<string, string>;
status?: AppStatus;
}
export interface AppRequirement {
name: string;
}
export interface DeployedApp {
name: string;
status: 'added' | 'deployed';
version?: string;
namespace?: string;
url?: string;
}
export interface AppStatus {
status: 'available' | 'added' | 'deploying' | 'deployed' | 'running' | 'error' | 'stopped';
message?: string;
namespace?: string;
replicas?: number;
resources?: AppResources;
}
export interface AppResources {
cpu?: string;
memory?: string;
storage?: string;
}
export interface AppListResponse {
apps: App[];
}
export interface AppAddRequest {
name: string;
config?: Record<string, string>;
}
export interface AppAddResponse {
message: string;
app: string;
}

View File

@@ -0,0 +1,45 @@
export interface ClusterConfig {
clusterName: string;
vip: string;
version?: string;
}
export interface ClusterStatus {
ready: boolean;
nodes: number;
controlPlaneNodes: number;
workerNodes: number;
kubernetesVersion?: string;
talosVersion?: string;
}
export interface HealthCheck {
name: string;
status: 'passing' | 'warning' | 'failing';
message: string;
}
export interface ClusterHealthResponse {
status: 'healthy' | 'degraded' | 'unhealthy';
checks: HealthCheck[];
}
export interface KubeconfigResponse {
kubeconfig: string;
}
export interface TalosconfigResponse {
talosconfig: string;
}
export interface ClusterBootstrapRequest {
node: string;
}
export interface ClusterEndpointsRequest {
include_nodes?: boolean;
}
export interface ClusterResetRequest {
confirm: boolean;
}

View File

@@ -0,0 +1,17 @@
export interface ConfigUpdate {
path: string;
value: unknown;
}
export interface ConfigUpdateBatchRequest {
updates: ConfigUpdate[];
}
export interface ConfigUpdateResponse {
message: string;
updated?: number;
}
export interface SecretsResponse {
[key: string]: string;
}

View File

@@ -0,0 +1,12 @@
export interface ContextResponse {
context: string | null;
}
export interface SetContextRequest {
context: string;
}
export interface SetContextResponse {
context: string;
message: string;
}

View File

@@ -0,0 +1,9 @@
export * from './instance';
export * from './context';
export * from './operation';
export * from './config';
export * from './node';
export * from './cluster';
export * from './app';
export * from './service';
export * from './pxe';

View File

@@ -0,0 +1,27 @@
export interface Instance {
name: string;
config: Record<string, unknown>;
}
export interface InstanceListResponse {
instances: string[];
}
export interface CreateInstanceRequest {
name: string;
}
export interface CreateInstanceResponse {
name: string;
message: string;
warning?: string;
}
export interface DeleteInstanceResponse {
message: string;
}
export interface GetInstanceResponse {
name: string;
config: Record<string, unknown>;
}

View File

@@ -0,0 +1,58 @@
export interface Node {
hostname: string;
target_ip: string;
role: 'controlplane' | 'worker';
current_ip?: string;
interface?: string;
disk?: string;
version?: string;
schematic_id?: string;
// Backend state flags for deriving status
maintenance?: boolean;
configured?: boolean;
applied?: boolean;
// Optional fields (not yet returned by API)
hardware?: HardwareInfo;
talosVersion?: string;
kubernetesVersion?: string;
}
export interface HardwareInfo {
cpu?: string;
memory?: string;
disk?: string;
manufacturer?: string;
model?: string;
}
export interface DiscoveredNode {
ip: string;
hostname?: string;
maintenance_mode?: boolean;
version?: string;
interface?: string;
disks?: string[];
}
export interface DiscoveryStatus {
active: boolean;
started_at?: string;
nodes_found?: DiscoveredNode[];
error?: string;
}
export interface NodeListResponse {
nodes: Node[];
}
export interface NodeAddRequest {
hostname: string;
target_ip: string;
role: 'controlplane' | 'worker';
disk?: string;
}
export interface NodeUpdateRequest {
role?: 'controlplane' | 'worker';
config?: Record<string, unknown>;
}

View File

@@ -0,0 +1,21 @@
export interface Operation {
id: string;
instance_name: string;
type: string;
target: string;
status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled';
message: string;
progress: number;
started: string;
completed?: string;
error?: string;
}
export interface OperationListResponse {
operations: Operation[];
}
export interface OperationResponse {
operation_id: string;
message: string;
}

View File

@@ -0,0 +1,27 @@
export type PxeAssetType = 'kernel' | 'initramfs' | 'iso';
export type PxeAssetStatus = 'available' | 'missing' | 'downloading' | 'error';
export interface PxeAsset {
type: PxeAssetType;
status: PxeAssetStatus;
version?: string;
size?: string;
path?: string;
error?: string;
}
export interface PxeAssetsResponse {
assets: PxeAsset[];
}
export interface DownloadAssetRequest {
type: PxeAssetType;
version?: string;
url: string;
}
export interface OperationResponse {
operation_id: string;
message: string;
}

View File

@@ -0,0 +1,29 @@
export interface Service {
name: string;
description: string;
version?: string;
status?: ServiceStatus;
deployed?: boolean;
}
export interface ServiceStatus {
status: 'available' | 'deploying' | 'running' | 'error' | 'stopped';
message?: string;
namespace?: string;
ready?: boolean;
}
export interface ServiceListResponse {
services: Service[];
}
export interface ServiceManifest {
name: string;
version: string;
description: string;
config: Record<string, unknown>;
}
export interface ServiceInstallRequest {
name: string;
}

View File

@@ -0,0 +1,41 @@
import { apiClient } from './client';
export interface HealthResponse {
status: string;
[key: string]: unknown;
}
export interface VersionResponse {
version: string;
[key: string]: unknown;
}
export const utilitiesApi = {
async health(): Promise<HealthResponse> {
return apiClient.get('/api/v1/utilities/health');
},
async instanceHealth(instanceName: string): Promise<HealthResponse> {
return apiClient.get(`/api/v1/instances/${instanceName}/utilities/health`);
},
async getDashboardToken(): Promise<{ token: string }> {
return apiClient.get('/api/v1/utilities/dashboard/token');
},
async getNodeIPs(): Promise<{ ips: string[] }> {
return apiClient.get('/api/v1/utilities/nodes/ips');
},
async getControlPlaneIP(): Promise<{ ip: string }> {
return apiClient.get('/api/v1/utilities/controlplane/ip');
},
async copySecret(secret: string, targetInstance: string): Promise<{ message: string }> {
return apiClient.post(`/api/v1/utilities/secrets/${secret}/copy`, { target: targetInstance });
},
async getVersion(): Promise<VersionResponse> {
return apiClient.get('/api/v1/utilities/version');
},
};

View File

@@ -39,20 +39,20 @@ export const parseSimpleYaml = (yamlText: string): Config => {
const cleanValue = value.replace(/"/g, '');
if (currentSection === 'cloud') {
if (currentSubsection === 'dns') (config.cloud.dns as any)[key] = cleanValue;
else if (currentSubsection === 'router') (config.cloud.router as any)[key] = cleanValue;
else if (currentSubsection === 'dnsmasq') (config.cloud.dnsmasq as any)[key] = cleanValue;
else (config.cloud as any)[key] = cleanValue;
if (currentSubsection === 'dns') (config.cloud.dns as Record<string, string>)[key] = cleanValue;
else if (currentSubsection === 'router') (config.cloud.router as Record<string, string>)[key] = cleanValue;
else if (currentSubsection === 'dnsmasq') (config.cloud.dnsmasq as Record<string, string>)[key] = cleanValue;
else (config.cloud as Record<string, string>)[key] = cleanValue;
} else if (currentSection === 'cluster') {
if (currentSubsection === 'nodes') {
// Skip nodes level
} else if (currentSubsection === 'talos') {
(config.cluster.nodes.talos as any)[key] = cleanValue;
(config.cluster.nodes.talos as Record<string, string>)[key] = cleanValue;
} else {
(config.cluster as any)[key] = cleanValue;
(config.cluster as Record<string, string | number>)[key] = cleanValue;
}
} else if (currentSection === 'server') {
(config.server as any)[key] = key === 'port' ? parseInt(cleanValue) : cleanValue;
(config.server as Record<string, string | number>)[key] = key === 'port' ? parseInt(cleanValue) : cleanValue;
}
}
}