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

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