Better support for Talos ISO downloads.

This commit is contained in:
2025-10-12 20:16:45 +00:00
parent e5bd3c36f5
commit 331777c5fd
12 changed files with 1245 additions and 221 deletions

167
BUILDING_WILD_APP.md Normal file
View File

@@ -0,0 +1,167 @@
# Building Wild App
This document describes the architecture and tooling used to build the Wild App, the web-based interface for managing Wild Cloud instances, hosted on Wild Central.
## Principles
- Stick with well known standards.
- Keep it simple.
- Use popular, well-maintained libraries.
- Use components wherever possible to avoid reinventing the wheel.
- Use TypeScript for type safety.
### Tooling
## Dev Environment Requirements
- Node.js 20+
- pnpm for package management
- vite for build tooling
- React + TypeScript
- Tailwind CSS for styling
- shadcn/ui for ready-made components
- radix-ui for accessible components
- eslint for linting
- tsc for type checking
- vitest for unit tests
#### Makefile commands
- Build application: `make app-build`
- Run locally: `make app-run`
- Format code: `make app-fmt`
- Lint and typecheck: `make app-check`
- Test installation: `make app-test`
### Scaffolding apps
It is important to start an app with a good base structure to avoid difficult to debug config issues.
This is a recommended setup.
#### Install pnpm if necessary:
```bash
curl -fsSL https://get.pnpm.io/install.sh | sh -
```
#### Install a React + Speedy Web Compiler (SWC) + TypeScript + TailwindCSS app with vite:
```
pnpm create vite@latest my-app -- --template react-swc-ts
```
#### Reconfigure to use shadcn/ui (radix + tailwind components) (see https://ui.shadcn.com/docs/installation/vite)
##### Add tailwind.
```bash
pnpm add tailwindcss @tailwindcss/vite
```
##### Replace everything in src/index.css with a tailwind import:
```bash
echo "@import \"tailwindcss\";" > src/index.css
```
##### Edit tsconfig files
The current version of Vite splits TypeScript configuration into three files, two of which need to be edited. Add the baseUrl and paths properties to the compilerOptions section of the tsconfig.json and tsconfig.app.json files:
tsconfig.json
```json
{
"files": [],
"references": [
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.node.json"
}
],
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}
}
```
tsconfig.app.json
```json
{
"compilerOptions": {
// ...
"baseUrl": ".",
"paths": {
"@/*": [
"./src/*"
]
}
// ...
}
}
```
##### Update vite.config.ts
```bash
pnpm add -D @types/node
```
Then edit vite.config.ts to include the node types:
```ts
import path from "path"
import tailwindcss from "@tailwindcss/vite"
import react from "@vitejs/plugin-react"
import { defineConfig } from "vite"
// https://vite.dev/config/
export default defineConfig({
plugins: [react(), tailwindcss()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
})
```
##### Run the cli
```bash
pnpm dlx shadcn@latest init
```
##### Add components
```bash
pnpm dlx shadcn@latest add button
pnpm dlx shadcn@latest add alert-dialog
```
You can then use components with `import { Button } from "@/components/ui/button"`
### UI Principles
- Use shadcn AppSideBar as the main navigation for the app: https://ui.shadcn.com/docs/components/sidebar
- Support light and dark mode with Tailwind's built-in dark mode support: https://tailwindcss.com/docs/dark-mode
### App Layout
- The sidebar let's you select which cloud instance you are curently managing from a dropdown.
- The sidebar is divided into Central, Cluster, and Apps.
- Central: Utilities for managing Wild Central itself.
- Cluster: Utilities for managing the current Wild Cloud instance.
- Managing nodes.
- Managing services.
- Apps: Managing the apps deployed on the current Wild Cloud instance.
- List of apps.
- App details.
- App logs.
- App metrics.

View File

@@ -153,7 +153,7 @@ export function AppSidebar() {
</SidebarMenuSubButton>
</SidebarMenuSubItem>
<SidebarMenuSubItem>
{/* <SidebarMenuSubItem>
<SidebarMenuSubButton asChild>
<NavLink to={`/instances/${instanceId}/dhcp`}>
<div className="p-1 rounded-md">
@@ -173,7 +173,7 @@ export function AppSidebar() {
<span className="truncate">PXE</span>
</NavLink>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
</SidebarMenuSubItem> */}
<SidebarMenuSubItem>
<SidebarMenuSubButton asChild>

View File

@@ -0,0 +1,301 @@
import { useState } from 'react';
import { Link } from 'react-router';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../../components/ui/card';
import { Button } from '../../components/ui/button';
import { Badge } from '../../components/ui/badge';
import {
Download,
AlertCircle,
Loader2,
Disc,
BookOpen,
ExternalLink,
CheckCircle,
XCircle,
Usb,
ArrowLeft,
CloudLightning,
} from 'lucide-react';
import { useAssetList, useDownloadAsset, useAssetStatus } from '../../services/api/hooks/useAssets';
import { assetsApi } from '../../services/api/assets';
import type { AssetType } from '../../services/api/types/asset';
export function AssetsIsoPage() {
const { data, isLoading, error } = useAssetList();
const downloadAsset = useDownloadAsset();
const [selectedSchematicId, setSelectedSchematicId] = useState<string | null>(null);
const [selectedVersion, setSelectedVersion] = useState('v1.8.0');
const { data: statusData } = useAssetStatus(selectedSchematicId);
// Select the first schematic by default if available
const schematic = data?.schematics?.[0] || null;
const schematicId = schematic?.schematic_id || null;
// Get the ISO asset
const isoAsset = schematic?.assets.find((asset) => asset.type === 'iso');
const handleDownload = async () => {
if (!schematicId) return;
try {
await downloadAsset.mutateAsync({
schematicId,
request: { version: selectedVersion, assets: ['iso'] },
});
} catch (err) {
console.error('Download failed:', err);
}
};
const getStatusBadge = (downloaded: boolean, downloading?: boolean) => {
if (downloading) {
return (
<Badge variant="warning" className="flex items-center gap-1">
<Loader2 className="h-3 w-3 animate-spin" />
Downloading
</Badge>
);
}
if (downloaded) {
return (
<Badge variant="success" className="flex items-center gap-1">
<CheckCircle className="h-3 w-3" />
Available
</Badge>
);
}
return (
<Badge variant="secondary" className="flex items-center gap-1">
<AlertCircle className="h-3 w-3" />
Missing
</Badge>
);
};
const getDownloadProgress = () => {
if (!statusData?.progress?.iso) return null;
const progress = statusData.progress.iso;
if (progress.status === 'downloading' && progress.bytes_downloaded && progress.total_bytes) {
const percentage = (progress.bytes_downloaded / progress.total_bytes) * 100;
return (
<div className="mt-2">
<div className="flex justify-between text-sm text-muted-foreground mb-1">
<span>Downloading...</span>
<span>{percentage.toFixed(1)}%</span>
</div>
<div className="w-full bg-muted rounded-full h-2">
<div
className="bg-primary h-2 rounded-full transition-all"
style={{ width: `${percentage}%` }}
/>
</div>
</div>
);
}
return null;
};
return (
<div className="min-h-screen bg-background">
{/* Header */}
<header className="border-b">
<div className="container mx-auto px-4 py-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Link to="/" className="flex items-center gap-2">
<CloudLightning className="h-6 w-6 text-primary" />
<span className="text-lg font-bold">Wild Cloud</span>
</Link>
<span className="text-muted-foreground">/</span>
<span className="text-sm font-medium">ISO Management</span>
</div>
<Link to="/">
<Button variant="ghost" size="sm">
<ArrowLeft className="h-4 w-4 mr-2" />
Back to Home
</Button>
</Link>
</div>
</div>
</header>
{/* Main Content */}
<main className="container mx-auto px-4 py-8">
<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"
onClick={() => window.open('https://www.talos.dev/latest/talos-guides/install/bare-metal-platforms/digital-rebar/', '_blank')}
>
<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>
{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>
{/* 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>
) : !isoAsset ? (
<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} 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>
) : (
<Card className="p-4">
<div className="flex items-center gap-4">
<div className="p-2 bg-muted rounded-lg">
<Disc className="h-5 w-5 text-primary" />
</div>
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<h5 className="font-medium capitalize">Talos ISO</h5>
{getStatusBadge(isoAsset.downloaded, statusData?.downloading)}
</div>
<div className="text-sm text-muted-foreground space-y-1">
{schematic?.version && <div>Version: {schematic.version}</div>}
{isoAsset.size && <div>Size: {(isoAsset.size / 1024 / 1024).toFixed(2)} MB</div>}
{isoAsset.path && (
<div className="font-mono text-xs truncate">{isoAsset.path}</div>
)}
</div>
{getDownloadProgress()}
</div>
<div className="flex gap-2">
{!isoAsset.downloaded && !statusData?.downloading && (
<Button
size="sm"
onClick={handleDownload}
disabled={downloadAsset.isPending}
>
{downloadAsset.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<>
<Download className="h-4 w-4 mr-1" />
Download
</>
)}
</Button>
)}
{isoAsset.downloaded && schematicId && (
<Button
size="sm"
variant="outline"
onClick={() => {
window.location.href = assetsApi.getAssetUrl(schematicId, 'iso');
}}
>
<Download className="h-4 w-4 mr-1" />
Download to Computer
</Button>
)}
</div>
</div>
</Card>
)}
</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>
</main>
</div>
);
}

View File

@@ -0,0 +1,299 @@
import { useState } from 'react';
import { Link } from 'react-router';
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,
Loader2,
CheckCircle,
AlertCircle,
FileArchive,
ArrowLeft,
CloudLightning,
} from 'lucide-react';
import { useAssetList, useDownloadAsset, useAssetStatus } from '../../services/api/hooks/useAssets';
import type { AssetType } from '../../services/api/types/asset';
export function AssetsPxePage() {
const { data, isLoading, error } = useAssetList();
const downloadAsset = useDownloadAsset();
const [selectedVersion, setSelectedVersion] = useState('v1.8.0');
// Select the first schematic by default if available
const schematic = data?.schematics?.[0] || null;
const schematicId = schematic?.schematic_id || null;
const { data: statusData } = useAssetStatus(schematicId);
// Get PXE assets (kernel and initramfs)
const pxeAssets = schematic?.assets.filter((asset) => asset.type !== 'iso') || [];
const handleDownload = async (assetType: AssetType) => {
if (!schematicId) return;
try {
await downloadAsset.mutateAsync({
schematicId,
request: { version: selectedVersion, assets: [assetType] },
});
} catch (err) {
console.error('Download failed:', err);
}
};
const handleDownloadAll = async () => {
if (!schematicId) return;
try {
await downloadAsset.mutateAsync({
schematicId,
request: { version: selectedVersion, assets: ['kernel', 'initramfs'] },
});
} catch (err) {
console.error('Download failed:', err);
}
};
const getStatusBadge = (downloaded: boolean, downloading?: boolean) => {
if (downloading) {
return (
<Badge variant="default" className="flex items-center gap-1">
<Loader2 className="h-3 w-3 animate-spin" />
Downloading
</Badge>
);
}
if (downloaded) {
return (
<Badge variant="success" className="flex items-center gap-1">
<CheckCircle className="h-3 w-3" />
Available
</Badge>
);
}
return (
<Badge variant="secondary" className="flex items-center gap-1">
<AlertCircle className="h-3 w-3" />
Missing
</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" />;
default:
return <FileArchive className="h-5 w-5 text-muted-foreground" />;
}
};
const getDownloadProgress = (assetType: AssetType) => {
if (!statusData?.progress?.[assetType]) return null;
const progress = statusData.progress[assetType];
if (progress?.status === 'downloading' && progress.bytes_downloaded && progress.total_bytes) {
const percentage = (progress.bytes_downloaded / progress.total_bytes) * 100;
return (
<div className="mt-2">
<div className="flex justify-between text-sm text-muted-foreground mb-1">
<span>Downloading...</span>
<span>{percentage.toFixed(1)}%</span>
</div>
<div className="w-full bg-muted rounded-full h-2">
<div
className="bg-primary h-2 rounded-full transition-all"
style={{ width: `${percentage}%` }}
/>
</div>
</div>
);
}
return null;
};
const isAssetDownloading = (assetType: AssetType) => {
return statusData?.progress?.[assetType]?.status === 'downloading';
};
return (
<div className="min-h-screen bg-background">
{/* Header */}
<header className="border-b">
<div className="container mx-auto px-4 py-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Link to="/" className="flex items-center gap-2">
<CloudLightning className="h-6 w-6 text-primary" />
<span className="text-lg font-bold">Wild Cloud</span>
</Link>
<span className="text-muted-foreground">/</span>
<span className="text-sm font-medium">PXE Management</span>
</div>
<Link to="/">
<Button variant="ghost" size="sm">
<ArrowLeft className="h-4 w-4 mr-2" />
Back to Home
</Button>
</Link>
</div>
</div>
</header>
{/* Main Content */}
<main className="container mx-auto px-4 py-8">
<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"
onClick={() => window.open('https://www.talos.dev/latest/talos-guides/install/bare-metal-platforms/pxe/', '_blank')}
>
<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>
{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">
{pxeAssets.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.downloaded, isAssetDownloading(asset.type as AssetType))}
</div>
<div className="text-sm text-muted-foreground space-y-1">
{schematic?.version && <div>Version: {schematic.version}</div>}
{asset.size && <div>Size: {(asset.size / 1024 / 1024).toFixed(2)} MB</div>}
{asset.path && (
<div className="font-mono text-xs truncate">{asset.path}</div>
)}
</div>
{getDownloadProgress(asset.type as AssetType)}
</div>
<div className="flex gap-2">
{!asset.downloaded && !isAssetDownloading(asset.type as AssetType) && (
<Button
size="sm"
onClick={() => handleDownload(asset.type as AssetType)}
disabled={downloadAsset.isPending}
>
{downloadAsset.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<>
<Download className="h-4 w-4 mr-1" />
Download
</>
)}
</Button>
)}
</div>
</div>
</Card>
))}
</div>
)}
</div>
{/* Download All Button */}
{pxeAssets.length > 0 && pxeAssets.some((a) => !a.downloaded) && (
<div className="flex justify-end">
<Button
onClick={handleDownloadAll}
disabled={downloadAsset.isPending || statusData?.downloading}
>
<Download className="h-4 w-4 mr-2" />
Download All Missing Assets
</Button>
</div>
)}
</div>
)}
</CardContent>
</Card>
</div>
</main>
</div>
);
}

View File

@@ -4,87 +4,97 @@ import { Button } from '../../components/ui/button';
import { Badge } from '../../components/ui/badge';
import {
Download,
Trash2,
AlertCircle,
Loader2,
Disc,
BookOpen,
ExternalLink,
CheckCircle,
XCircle,
Usb,
Trash2,
} from 'lucide-react';
import { usePxeAssets, useDownloadPxeAsset, useDeletePxeAsset } from '../../services/api/hooks/usePxeAssets';
import { useInstanceContext } from '../../hooks';
import type { PxeAssetType } from '../../services/api/types/pxe';
import { useAssetList, useDownloadAsset, useDeleteAsset } from '../../services/api/hooks/useAssets';
import { assetsApi } from '../../services/api/assets';
import type { Platform } from '../../services/api/types/asset';
// Helper function to extract version from ISO filename
// Filename format: talos-v1.11.2-metal-amd64.iso
function extractVersionFromPath(path: string): string {
const filename = path.split('/').pop() || '';
const match = filename.match(/talos-(v\d+\.\d+\.\d+)-metal/);
return match ? match[1] : 'unknown';
}
// Helper function to extract platform from ISO filename
// Filename format: talos-v1.11.2-metal-amd64.iso
function extractPlatformFromPath(path: string): string {
const filename = path.split('/').pop() || '';
const match = filename.match(/-(amd64|arm64)\.iso$/);
return match ? match[1] : 'unknown';
}
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');
const { data, isLoading, error, refetch } = useAssetList();
const downloadAsset = useDownloadAsset();
const deleteAsset = useDeleteAsset();
// Filter to show only ISO assets
const isoAssets = data?.assets.filter((asset) => asset.type === 'iso') || [];
const [schematicId, setSchematicId] = useState('');
const [selectedVersion, setSelectedVersion] = useState('v1.11.2');
const [selectedPlatform, setSelectedPlatform] = useState<Platform>('amd64');
const [isDownloading, setIsDownloading] = useState(false);
const handleDownload = async (type: PxeAssetType) => {
if (!currentInstance) return;
const handleDownload = async () => {
if (!schematicId) {
alert('Please enter a schematic ID');
return;
}
setDownloadingType(type);
setIsDownloading(true);
try {
const url = `https://github.com/siderolabs/talos/releases/download/${selectedVersion}/metal-amd64.iso`;
await downloadAsset.mutateAsync({
instanceName: currentInstance,
request: { type, version: selectedVersion, url },
schematicId,
request: {
version: selectedVersion,
platform: selectedPlatform,
assets: ['iso']
},
});
// Refresh the list after download
await refetch();
} catch (err) {
console.error('Download failed:', err);
alert(`Download failed: ${err instanceof Error ? err.message : 'Unknown error'}`);
} finally {
setDownloadingType(null);
setIsDownloading(false);
}
};
const handleDelete = async (type: PxeAssetType) => {
if (!currentInstance) return;
const handleDelete = async (schematicIdToDelete: string) => {
if (!confirm('Are you sure you want to delete this schematic and all its assets? This action cannot be undone.')) {
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" />;
try {
await deleteAsset.mutateAsync(schematicIdToDelete);
await refetch();
} catch (err) {
console.error('Delete failed:', err);
alert(`Delete failed: ${err instanceof Error ? err.message : 'Unknown error'}`);
}
};
// Find all ISO assets from all schematics (including multiple ISOs per schematic)
const isoAssets = data?.schematics
.flatMap(schematic => {
// Get ALL ISO assets for this schematic (not just the first one)
const isoAssetsForSchematic = schematic.assets.filter(asset => asset.type === 'iso');
return isoAssetsForSchematic.map(isoAsset => ({
...isoAsset,
schematic_id: schematic.schematic_id,
version: schematic.version
}));
}) || [];
return (
<div className="space-y-6">
{/* Educational Intro Section */}
@@ -111,46 +121,57 @@ export function IsoPage() {
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"
onClick={() => window.open('https://www.balena.io/etcher/', '_blank')}
>
<ExternalLink className="h-4 w-4 mr-2" />
Learn about creating bootable USB drives
Download Balena Etcher
</Button>
</div>
</div>
</Card>
{/* Download New ISO Section */}
<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" />
<Disc className="h-6 w-6 text-primary" />
</div>
<div className="flex-1">
<CardTitle>ISO Management</CardTitle>
<CardTitle>Download Talos ISO</CardTitle>
<CardDescription>
Download Talos ISO images for creating bootable USB drives
Specify the schematic ID, version, and platform to download a Talos ISO image
</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.
<CardContent className="space-y-4">
{/* Schematic ID Input */}
<div>
<label className="text-sm font-medium mb-2 block">
Schematic ID
<span className="text-muted-foreground ml-2">(64-character hex string)</span>
</label>
<input
type="text"
value={schematicId}
onChange={(e) => setSchematicId(e.target.value)}
placeholder="e.g., 434a0300db532066f1098e05ac068159371d00f0aba0a3103a0e826e83825c82"
className="w-full px-3 py-2 border rounded-lg bg-background font-mono text-sm"
/>
<p className="text-xs text-muted-foreground mt-1">
Get your schematic ID from the{' '}
<a
href="https://factory.talos.dev"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
Talos Image Factory
</a>
</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>
@@ -159,87 +180,132 @@ export function IsoPage() {
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>
<option value="v1.11.2">v1.11.2</option>
<option value="v1.11.1">v1.11.1</option>
<option value="v1.11.0">v1.11.0</option>
</select>
</div>
{/* ISO Asset */}
{/* Platform Selection */}
<div>
<h4 className="font-medium mb-4">ISO Image</h4>
<label className="text-sm font-medium mb-2 block">Platform</label>
<div className="flex gap-4">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="platform"
value="amd64"
checked={selectedPlatform === 'amd64'}
onChange={(e) => setSelectedPlatform(e.target.value as Platform)}
className="w-4 h-4"
/>
<span>amd64 (Intel/AMD 64-bit)</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="platform"
value="arm64"
checked={selectedPlatform === 'arm64'}
onChange={(e) => setSelectedPlatform(e.target.value as Platform)}
className="w-4 h-4"
/>
<span>arm64 (ARM 64-bit)</span>
</label>
</div>
</div>
{/* Download Button */}
<Button
onClick={handleDownload}
disabled={isDownloading || !schematicId}
className="w-full md:w-auto"
>
{isDownloading ? (
<>
<Loader2 className="h-4 w-4 animate-spin mr-2" />
Downloading...
</>
) : (
<>
<Download className="h-4 w-4 mr-2" />
Download ISO
</>
)}
</Button>
</CardContent>
</Card>
{/* Downloaded ISOs Section */}
<Card>
<CardHeader>
<CardTitle>Downloaded ISO Images</CardTitle>
<CardDescription>Available ISO images on Wild Central</CardDescription>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</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 ISOs</h3>
<p className="text-muted-foreground mb-4">{(error as Error).message}</p>
<Button onClick={() => refetch()}>Retry</Button>
</div>
) : isoAssets.length === 0 ? (
<Card className="p-8 text-center">
<div className="text-center py-8">
<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.
<h3 className="text-lg font-medium mb-2">No ISOs Downloaded</h3>
<p className="text-muted-foreground">
Download a Talos ISO using the form above to get started.
</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>
) : (
<div className="space-y-3">
{isoAssets.map((asset) => (
<Card key={asset.type} className="p-4">
{isoAssets.map((asset: any) => {
const version = extractVersionFromPath(asset.path || '');
const platform = extractPlatformFromPath(asset.path || '');
return (
<Card key={asset.schematic_id} className="p-4">
<div className="flex items-center gap-4">
<div className="p-2 bg-muted rounded-lg">{getAssetIcon(asset.type)}</div>
<div className="p-2 bg-muted rounded-lg">
<Disc className="h-5 w-5 text-primary" />
</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)}
<h5 className="font-medium">Talos ISO</h5>
<Badge variant="outline">{version}</Badge>
<Badge variant="outline" className="uppercase">{platform}</Badge>
{asset.downloaded ? (
<Badge variant="success" className="flex items-center gap-1">
<CheckCircle className="h-3 w-3" />
Downloaded
</Badge>
) : (
<Badge variant="secondary" className="flex items-center gap-1">
<AlertCircle className="h-3 w-3" />
Missing
</Badge>
)}
</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 className="font-mono text-xs truncate">
Schematic: {asset.schematic_id}
</div>
{asset.size && (
<div>Size: {(asset.size / 1024 / 1024).toFixed(2)} MB</div>
)}
</div>
</div>
{asset.downloaded && (
<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`;
}
window.location.href = assetsApi.getAssetUrl(asset.schematic_id, 'iso');
}}
>
<Download className="h-4 w-4 mr-1" />
@@ -247,25 +313,22 @@ export function IsoPage() {
</Button>
<Button
size="sm"
variant="destructive"
onClick={() => handleDelete(asset.type as PxeAssetType)}
disabled={deleteAsset.isPending}
variant="outline"
className="text-red-600 hover:text-red-700 hover:bg-red-50"
onClick={() => handleDelete(asset.schematic_id)}
>
{deleteAsset.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Trash2 className="h-4 w-4" />
)}
</Button>
</>
)}
</div>
)}
</div>
</Card>
))}
);
})}
</div>
)}
</div>
</CardContent>
</Card>
{/* Instructions Card */}
<Card className="p-6 bg-muted/50">
@@ -274,7 +337,8 @@ export function IsoPage() {
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>Get your schematic ID from Talos Image Factory</li>
<li>Download the ISO image using the form 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>
@@ -282,9 +346,5 @@ export function IsoPage() {
</ol>
</Card>
</div>
)}
</CardContent>
</Card>
</div>
);
}

View File

@@ -1,8 +1,8 @@
import { useNavigate } from 'react-router';
import { useNavigate, Link } 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';
import { Server, Usb, HardDrive, CloudLightning } from 'lucide-react';
export function LandingPage() {
const navigate = useNavigate();
@@ -16,12 +16,24 @@ export function LandingPage() {
};
return (
<div className="flex items-center justify-center min-h-screen">
<Card className="w-full max-w-md">
<div className="flex items-center justify-center min-h-screen bg-background">
<div className="container max-w-4xl px-4">
<div className="text-center mb-8">
<div className="flex items-center justify-center gap-3 mb-4">
<CloudLightning className="h-12 w-12 text-primary" />
<h1 className="text-4xl font-bold">Wild Cloud</h1>
</div>
<p className="text-lg text-muted-foreground">
Manage your cloud infrastructure with ease
</p>
</div>
<div className="grid gap-4 md:grid-cols-2">
<Card>
<CardHeader className="text-center">
<CardTitle className="text-2xl">Wild Cloud</CardTitle>
<CardTitle className="text-xl">Cloud Instance</CardTitle>
<CardDescription>
Select an instance to manage your cloud infrastructure
Manage your Wild Cloud instance
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
@@ -35,6 +47,39 @@ export function LandingPage() {
</Button>
</CardContent>
</Card>
<Card>
<CardHeader className="text-center">
<CardTitle className="text-xl">Boot Assets</CardTitle>
<CardDescription>
Download Talos installation media
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<Link to="/iso" className="block">
<Button
variant="outline"
className="w-full"
size="lg"
>
<Usb className="mr-2 h-5 w-5" />
ISO / USB Boot
</Button>
</Link>
<Link to="/pxe" className="block">
<Button
variant="outline"
className="w-full"
size="lg"
>
<HardDrive className="mr-2 h-5 w-5" />
PXE Network Boot
</Button>
</Link>
</CardContent>
</Card>
</div>
</div>
</div>
);
}

View File

@@ -20,12 +20,23 @@ import { InfrastructurePage } from './pages/InfrastructurePage';
import { ClusterPage } from './pages/ClusterPage';
import { AppsPage } from './pages/AppsPage';
import { AdvancedPage } from './pages/AdvancedPage';
import { AssetsIsoPage } from './pages/AssetsIsoPage';
import { AssetsPxePage } from './pages/AssetsPxePage';
export const routes: RouteObject[] = [
{
path: '/',
element: <LandingPage />,
},
// Centralized asset routes (not under instance context)
{
path: '/iso',
element: <AssetsIsoPage />,
},
{
path: '/pxe',
element: <AssetsPxePage />,
},
{
path: '/instances/:instanceId',
element: <InstanceLayout />,

View File

@@ -0,0 +1,42 @@
import { apiClient } from './client';
import type { AssetListResponse, Schematic, DownloadAssetRequest, AssetStatusResponse } from './types/asset';
// Get API base URL
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:5055';
export const assetsApi = {
// List all schematics
list: async (): Promise<AssetListResponse> => {
const response = await apiClient.get('/api/v1/assets');
return response as AssetListResponse;
},
// Get schematic details
get: async (schematicId: string): Promise<Schematic> => {
const response = await apiClient.get(`/api/v1/assets/${schematicId}`);
return response as Schematic;
},
// Download assets for a schematic
download: async (schematicId: string, request: DownloadAssetRequest): Promise<{ message: string }> => {
const response = await apiClient.post(`/api/v1/assets/${schematicId}/download`, request);
return response as { message: string };
},
// Get download status
status: async (schematicId: string): Promise<AssetStatusResponse> => {
const response = await apiClient.get(`/api/v1/assets/${schematicId}/status`);
return response as AssetStatusResponse;
},
// Get download URL for an asset (includes base URL for direct download)
getAssetUrl: (schematicId: string, assetType: 'kernel' | 'initramfs' | 'iso'): string => {
return `${API_BASE_URL}/api/v1/assets/${schematicId}/pxe/${assetType}`;
},
// Delete a schematic and all its assets
delete: async (schematicId: string): Promise<{ message: string }> => {
const response = await apiClient.delete(`/api/v1/assets/${schematicId}`);
return response as { message: string };
},
};

View File

@@ -0,0 +1,58 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { assetsApi } from '../assets';
import type { DownloadAssetRequest } from '../types/asset';
export function useAssetList() {
return useQuery({
queryKey: ['assets'],
queryFn: assetsApi.list,
});
}
export function useAsset(schematicId: string | null | undefined) {
return useQuery({
queryKey: ['assets', schematicId],
queryFn: () => assetsApi.get(schematicId!),
enabled: !!schematicId,
});
}
export function useAssetStatus(schematicId: string | null | undefined) {
return useQuery({
queryKey: ['assets', schematicId, 'status'],
queryFn: () => assetsApi.status(schematicId!),
enabled: !!schematicId,
refetchInterval: (query) => {
const data = query.state.data;
// Poll every 2 seconds if downloading
return data?.downloading ? 2000 : false;
},
});
}
export function useDownloadAsset() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ schematicId, request }: { schematicId: string; request: DownloadAssetRequest }) =>
assetsApi.download(schematicId, request),
onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: ['assets'] });
queryClient.invalidateQueries({ queryKey: ['assets', variables.schematicId] });
queryClient.invalidateQueries({ queryKey: ['assets', variables.schematicId, 'status'] });
},
});
}
export function useDeleteAsset() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (schematicId: string) => assetsApi.delete(schematicId),
onSuccess: (_, schematicId) => {
queryClient.invalidateQueries({ queryKey: ['assets'] });
queryClient.invalidateQueries({ queryKey: ['assets', schematicId] });
queryClient.invalidateQueries({ queryKey: ['assets', schematicId, 'status'] });
},
});
}

View File

@@ -10,6 +10,7 @@ export { operationsApi } from './operations';
export { dnsmasqApi } from './dnsmasq';
export { utilitiesApi } from './utilities';
export { pxeApi } from './pxe';
export { assetsApi } from './assets';
// React Query hooks
export { useInstance, useInstanceOperations, useInstanceClusterHealth } from './hooks/useInstance';
@@ -17,3 +18,4 @@ export { useOperations, useOperation, useCancelOperation } from './hooks/useOper
export { useClusterHealth, useClusterStatus, useClusterNodes } from './hooks/useCluster';
export { useDashboardToken, useClusterVersions, useNodeIPs, useControlPlaneIP, useCopySecret } from './hooks/useUtilities';
export { usePxeAssets, useDownloadPxeAsset, useDeletePxeAsset } from './hooks/usePxeAssets';
export { useAssetList, useAsset, useAssetStatus, useDownloadAsset } from './hooks/useAssets';

View File

@@ -0,0 +1,38 @@
export type AssetType = 'kernel' | 'initramfs' | 'iso';
export type Platform = 'amd64' | 'arm64';
// Simplified Asset interface matching backend
export interface Asset {
type: string;
path: string;
size: number;
sha256: string;
downloaded: boolean;
}
// Schematic representation matching backend
export interface Schematic {
schematic_id: string;
version: string;
path: string;
assets: Asset[];
}
export interface AssetListResponse {
schematics: Schematic[];
}
export interface DownloadAssetRequest {
version: string;
platform?: Platform;
assets?: AssetType[];
force?: boolean;
}
// Simplified status response matching backend
export interface AssetStatusResponse {
schematic_id: string;
version: string;
assets: Record<string, Asset>;
complete: boolean;
}

View File

@@ -7,3 +7,4 @@ export * from './cluster';
export * from './app';
export * from './service';
export * from './pxe';
export * from './asset';