First swing.
This commit is contained in:
10
src/router/pages/AdvancedPage.tsx
Normal file
10
src/router/pages/AdvancedPage.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { ErrorBoundary } from '../../components';
|
||||
import { Advanced } from '../../components';
|
||||
|
||||
export function AdvancedPage() {
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<Advanced />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
11
src/router/pages/AppsPage.tsx
Normal file
11
src/router/pages/AppsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
116
src/router/pages/BaseServicesPage.tsx
Normal file
116
src/router/pages/BaseServicesPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
10
src/router/pages/CentralPage.tsx
Normal file
10
src/router/pages/CentralPage.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { ErrorBoundary } from '../../components';
|
||||
import { CentralComponent } from '../../components/CentralComponent';
|
||||
|
||||
export function CentralPage() {
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<CentralComponent />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
10
src/router/pages/CloudPage.tsx
Normal file
10
src/router/pages/CloudPage.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { ErrorBoundary } from '../../components';
|
||||
import { CloudComponent } from '../../components/CloudComponent';
|
||||
|
||||
export function CloudPage() {
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<CloudComponent />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
210
src/router/pages/ClusterAccessPage.tsx
Normal file
210
src/router/pages/ClusterAccessPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
211
src/router/pages/ClusterHealthPage.tsx
Normal file
211
src/router/pages/ClusterHealthPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
11
src/router/pages/ClusterPage.tsx
Normal file
11
src/router/pages/ClusterPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
243
src/router/pages/DashboardPage.tsx
Normal file
243
src/router/pages/DashboardPage.tsx
Normal 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>
|
||||
|
||||
);
|
||||
}
|
||||
10
src/router/pages/DhcpPage.tsx
Normal file
10
src/router/pages/DhcpPage.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { ErrorBoundary } from '../../components';
|
||||
import { DhcpComponent } from '../../components/DhcpComponent';
|
||||
|
||||
export function DhcpPage() {
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<DhcpComponent />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
10
src/router/pages/DnsPage.tsx
Normal file
10
src/router/pages/DnsPage.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { ErrorBoundary } from '../../components';
|
||||
import { DnsComponent } from '../../components/DnsComponent';
|
||||
|
||||
export function DnsPage() {
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<DnsComponent />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
11
src/router/pages/InfrastructurePage.tsx
Normal file
11
src/router/pages/InfrastructurePage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
290
src/router/pages/IsoPage.tsx
Normal file
290
src/router/pages/IsoPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
40
src/router/pages/LandingPage.tsx
Normal file
40
src/router/pages/LandingPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
30
src/router/pages/NotFoundPage.tsx
Normal file
30
src/router/pages/NotFoundPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
209
src/router/pages/OperationsPage.tsx
Normal file
209
src/router/pages/OperationsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
281
src/router/pages/PxePage.tsx
Normal file
281
src/router/pages/PxePage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
211
src/router/pages/SecretsPage.tsx
Normal file
211
src/router/pages/SecretsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
182
src/router/pages/UtilitiesPage.tsx
Normal file
182
src/router/pages/UtilitiesPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user