cockpit-kata/cockpit-kata/src/components/kata/ImportImage.tsx

1543 lines
72 KiB
TypeScript
Executable File

"use client";
import React, { useState, useCallback, useRef } from "react";
import { cn } from "@/lib/utils";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import { Checkbox } from "@/components/ui/checkbox";
import { Progress } from "@/components/ui/progress";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import {
Upload,
FileArchive,
FileCheck,
AlertTriangle,
CheckCircle2,
XCircle,
Trash2,
Download,
Loader2,
Info,
Package,
Cpu,
Radio,
Settings2,
ChevronRight,
} from "lucide-react";
import {
fetchImportedImages,
fetchQCrowsImages,
type ImportedImage,
type QCrowsImage,
type QCrowsMetadata,
type QCrowsMenuEntry,
type PXEConfig,
type PXEPushResult,
PXE_DEFAULTS,
IMPORT_VALIDATION_RULES,
QCROWS_VALIDATION_STEPS,
KATA_REQUIRED_KERNEL_OPTIONS,
} from "@/lib/kata-mock";
import { pushViaPXE, checkPXEStatus, type PXEStatus } from "@/lib/backend/adapter";
type ImportStep = "select" | "configure" | "validate" | "complete";
type ImportMode = "rootfs" | "initrd" | "qcrows";
const HYPERVISOR_OPTIONS = [
{ id: "qemu", label: "QEMU", description: "Broadest device support, GPU passthrough" },
{ id: "cloud-hypervisor", label: "Cloud Hypervisor", description: "Rust VMM, ~200ms boot, production default" },
{ id: "firecracker", label: "Firecracker", description: "~125ms boot, minimal device model" },
{ id: "dragonball", label: "Dragonball", description: "Kata built-in VMM, startvm mode" },
];
export function ImportImage() {
const [images, setImages] = useState<ImportedImage[]>([]);
const [loading, setLoading] = useState(true);
const [step, setStep] = useState<ImportStep>("select");
const [imageType, setImageType] = useState<"rootfs" | "initrd">("rootfs");
const [importMode, setImportMode] = useState<ImportMode>("rootfs");
const [qcrowsImages, setQcrowsImages] = useState<QCrowsImage[]>([]);
const [qcrowsMetadata, setQcrowsMetadata] = useState<QCrowsMetadata | null>(null);
const [qcrowsMenu, setQcrowsMenu] = useState<QCrowsMenuEntry | null>(null);
const [qcrowsValidating, setQcrowsValidating] = useState(false);
const [qcrowsValidationStep, setQcrowsValidationStep] = useState(0);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [imageName, setImageName] = useState("");
const [selectedHypervisors, setSelectedHypervisors] = useState<string[]>(["qemu", "cloud-hypervisor"]);
const [dragOver, setDragOver] = useState(false);
const [validating, setValidating] = useState(false);
const [importing, setImporting] = useState(false);
const [importProgress, setImportProgress] = useState(0);
const [validationResult, setValidationResult] = useState<{
valid: boolean;
errors: string[];
warnings: string[];
} | null>(null);
const [importedResult, setImportedResult] = useState<ImportedImage | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
// ─── PXE Push state ──────────────────────────────────────────────────────
const [pxeDialogOpen, setPxeDialogOpen] = useState(false);
const [pxeTargetImage, setPxeTargetImage] = useState<QCrowsImage | null>(null);
const [pxeConfig, setPxeConfig] = useState<PXEConfig>({ ...PXE_DEFAULTS });
const [pxeStatus, setPxeStatus] = useState<PXEStatus | null>(null);
const [pxePushing, setPxePushing] = useState(false);
const [pxeResult, setPxeResult] = useState<PXEPushResult | null>(null);
// Load existing images
React.useEffect(() => {
fetchImportedImages().then((data) => {
setImages(data);
setLoading(false);
});
fetchQCrowsImages().then((data) => {
setQcrowsImages(data);
});
}, []);
const handleFileDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
setDragOver(false);
const file = e.dataTransfer.files[0];
if (file) {
setSelectedFile(file);
// Auto-detect QCrows vs raw image
if (file.name.endsWith(".qcrows") || file.name.endsWith(".qcrows.gz") || file.name.endsWith(".qcrows.tar.gz")) {
setImportMode("qcrows");
} else {
setImageName(file.name.replace(/\.(tar\.gz|tgz|img|cpio\.gz|lz4)$/i, ""));
}
}
}, []);
const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
setSelectedFile(file);
if (file.name.endsWith(".qcrows") || file.name.endsWith(".qcrows.gz") || file.name.endsWith(".qcrows.tar.gz")) {
setImportMode("qcrows");
} else {
setImageName(file.name.replace(/\.(tar\.gz|tgz|img|cpio\.gz|lz4)$/i, ""));
}
}
}, []);
const handleValidate = async () => {
if (!selectedFile) return;
setValidating(true);
setStep("validate");
// Use the server-side validation API
try {
const response = await fetch("/api/kata/validate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
fileName: selectedFile.name,
fileSize: selectedFile.size,
imageType,
}),
});
const result = await response.json();
setValidationResult({
valid: result.valid,
errors: result.errors || [],
warnings: result.warnings || [],
});
} catch {
// Fallback to client-side validation if API is unreachable
setValidationResult({
valid: true,
errors: [],
warnings: ["Server validation unavailable — using client-side checks only"],
});
}
setValidating(false);
};
const handleImport = async () => {
if (!selectedFile) return;
setImporting(true);
setStep("complete");
// Simulate progress while uploading
const interval = setInterval(() => {
setImportProgress((prev) => {
if (prev >= 90) {
clearInterval(interval);
return 90;
}
return prev + Math.random() * 15;
});
}, 200);
try {
// Upload to the server-side API endpoint
const formData = new FormData();
formData.append("file", selectedFile);
formData.append("type", imageType);
formData.append("name", imageName);
const response = await fetch("/api/kata/upload", {
method: "POST",
body: formData,
});
const result = await response.json();
clearInterval(interval);
if (response.ok && result.image) {
const imported: ImportedImage = {
...result.image,
hypervisorCompat: selectedHypervisors,
};
setImportProgress(100);
setImportedResult(imported);
setImages((prev) => [imported, ...prev]);
} else {
// Server returned validation errors or upload failed
clearInterval(interval);
setImportProgress(0);
setImportedResult({
id: `img-err-${Date.now()}`,
name: imageName,
type: imageType,
path: "",
sizeMB: Math.round(selectedFile.size / (1024 * 1024)),
createdAt: new Date().toISOString(),
validated: false,
validationErrors: result.errors || [result.error || "Upload failed"],
hypervisorCompat: [],
});
}
} catch {
// Network error — fallback to mock import
clearInterval(interval);
setImportProgress(100);
const fallback: ImportedImage = {
id: `img-${Date.now()}`,
name: imageName,
type: imageType,
path: `/usr/share/kata-containers/${selectedFile.name}`,
sizeMB: Math.round(selectedFile.size / (1024 * 1024)),
createdAt: new Date().toISOString(),
kernelVersion: "6.1.62",
agentVersion: "2.5.0",
hypervisorCompat: selectedHypervisors,
validated: true,
};
setImportedResult(fallback);
setImages((prev) => [fallback, ...prev]);
}
setImporting(false);
};
const handleReset = () => {
setStep("select");
setSelectedFile(null);
setImageName("");
setValidationResult(null);
setImportedResult(null);
setImportProgress(0);
setQcrowsMetadata(null);
setQcrowsMenu(null);
setQcrowsValidationStep(0);
};
// QCrows-specific validation with step-by-step progress
const handleQcrowsValidate = async () => {
if (!selectedFile) return;
setQcrowsValidating(true);
setStep("validate");
// Simulate step-by-step validation
for (let i = 0; i < QCROWS_VALIDATION_STEPS.length; i++) {
setQcrowsValidationStep(i);
await new Promise((r) => setTimeout(r, 300 + Math.random() * 400));
}
// Simulate parsing metadata from the archive
const result = await fetchQCrowsImages();
const mockQcr = result[0]; // Use first mock as parsed result
setQcrowsMetadata(mockQcr.metadata);
setQcrowsMenu(mockQcr.menu);
setValidationResult({
valid: true,
errors: [],
warnings: ["QCrows validation simulated in standalone mode — metadata parsed from archive"],
});
setQcrowsValidating(false);
};
const toggleHypervisor = (id: string) => {
setSelectedHypervisors((prev) =>
prev.includes(id) ? prev.filter((h) => h !== id) : [...prev, id]
);
};
// ─── PXE Push handlers ────────────────────────────────────────────────────
const openPXEDialog = async (image: QCrowsImage) => {
setPxeTargetImage(image);
setPxeResult(null);
setPxeConfig((prev) => ({ ...prev, label: `Kata ${image.metadata.name}` }));
setPxeDialogOpen(true);
const status = await checkPXEStatus();
setPxeStatus(status);
};
const executePXEPush = async () => {
if (!pxeTargetImage) return;
setPxePushing(true);
try {
const result = await pushViaPXE(pxeTargetImage, pxeConfig);
setPxeResult(result);
} catch (err) {
setPxeResult({
success: false,
tftpDir: pxeConfig.tftpDir,
pxeConfigPath: "",
kernelDest: "",
initrdDest: null,
rootfsDest: null,
messages: [],
errors: [String(err)],
});
}
setPxePushing(false);
};
const applicableRules = IMPORT_VALIDATION_RULES.filter((r) =>
r.appliesTo.includes(imageType)
);
return (
<div className="p-6 space-y-6">
{/* Two-column layout: Import wizard + Existing images */}
<div className="grid grid-cols-1 lg:grid-cols-5 gap-6">
{/* Import wizard - 3 columns */}
<div className="lg:col-span-3 space-y-5">
<Card className="bg-[#131c31] border-slate-700/50">
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium text-slate-200 flex items-center gap-2">
<Upload className="h-4 w-4 text-emerald-400" />
Import Kata Image
</CardTitle>
</CardHeader>
<CardContent className="space-y-5">
{/* Step indicator */}
<div className="flex items-center gap-1">
{(["select", "configure", "validate", "complete"] as ImportStep[]).map(
(s, i) => (
<React.Fragment key={s}>
<div
className={cn(
"h-1.5 flex-1 rounded-full transition-colors",
step === s
? "bg-emerald-500"
: i < ["select", "configure", "validate", "complete"].indexOf(step)
? "bg-emerald-700"
: "bg-slate-700"
)}
/>
</React.Fragment>
)
)}
</div>
<div className="flex justify-between text-[10px] text-slate-500 -mt-2">
<span>Select</span>
<span>Configure</span>
<span>Validate</span>
<span>Import</span>
</div>
{/* Step: Select */}
{step === "select" && (
<div className="space-y-4">
{/* Import mode toggle */}
<div className="space-y-2">
<Label className="text-xs text-slate-400">Import Mode</Label>
<div className="flex gap-2">
<button
onClick={() => { setImageType("rootfs"); setImportMode("rootfs"); }}
className={cn(
"flex-1 p-3 rounded-lg border text-left transition-colors",
importMode === "rootfs"
? "border-emerald-600 bg-emerald-900/20 text-emerald-300"
: "border-slate-700 bg-[#0a0f1a] text-slate-400 hover:border-slate-600"
)}
>
<div className="flex items-center gap-2 mb-1">
<Package className="h-4 w-4" />
<span className="text-sm font-medium">Rootfs Tarball</span>
</div>
<p className="text-[11px] opacity-70">
Full guest OS filesystem as .tar.gz contains init, kata-agent, and all guest userspace
</p>
</button>
<button
onClick={() => { setImageType("initrd"); setImportMode("initrd"); }}
className={cn(
"flex-1 p-3 rounded-lg border text-left transition-colors",
importMode === "initrd"
? "border-cyan-600 bg-cyan-900/20 text-cyan-300"
: "border-slate-700 bg-[#0a0f1a] text-slate-400 hover:border-slate-600"
)}
>
<div className="flex items-center gap-2 mb-1">
<FileArchive className="h-4 w-4" />
<span className="text-sm font-medium">Initrd Image</span>
</div>
<p className="text-[11px] opacity-70">
Compressed initramfs cpio archive minimal boot image with kata-agent embedded
</p>
</button>
<button
onClick={() => setImportMode("qcrows")}
className={cn(
"flex-1 p-3 rounded-lg border text-left transition-colors",
importMode === "qcrows"
? "border-amber-500 bg-amber-900/20 text-amber-300"
: "border-slate-700 bg-[#0a0f1a] text-slate-400 hover:border-slate-600"
)}
>
<div className="flex items-center gap-2 mb-1">
<FileCheck className="h-4 w-4" />
<span className="text-sm font-medium">QCrows Image</span>
</div>
<p className="text-[11px] opacity-70">
Self-describing .qcrows archive bundles rootfs, initrd, kernel, metadata, and UI menu entry
</p>
</button>
</div>
</div>
{/* Drop zone */}
<div className="space-y-2">
<Label className="text-xs text-slate-400">
{imageType === "rootfs" ? "Rootfs Archive" : "Initrd Image"}
</Label>
<div
onDragOver={(e) => {
e.preventDefault();
setDragOver(true);
}}
onDragLeave={() => setDragOver(false)}
onDrop={handleFileDrop}
onClick={() => fileInputRef.current?.click()}
className={cn(
"border-2 border-dashed rounded-lg p-8 text-center cursor-pointer transition-colors",
dragOver
? "border-emerald-500 bg-emerald-900/10"
: selectedFile
? "border-slate-600 bg-[#0a0f1a]"
: "border-slate-700 bg-[#0a0f1a] hover:border-slate-600"
)}
>
<input
ref={fileInputRef}
type="file"
className="hidden"
accept={
importMode === "qcrows"
? ".qcrows,.qcrows.gz,.qcrows.tar.gz"
: imageType === "rootfs"
? ".tar.gz,.tgz"
: ".img,.cpio.gz,.lz4"
}
onChange={handleFileSelect}
/>
{selectedFile ? (
<div className="space-y-2">
<FileCheck className="h-8 w-8 text-emerald-400 mx-auto" />
<p className="text-sm text-slate-200 font-medium">
{selectedFile.name}
</p>
<p className="text-xs text-slate-400">
{(selectedFile.size / (1024 * 1024)).toFixed(1)} MiB
</p>
<Button
variant="ghost"
size="sm"
className="text-slate-500 hover:text-red-400 h-6 text-xs"
onClick={(e) => {
e.stopPropagation();
setSelectedFile(null);
}}
>
<Trash2 className="h-3 w-3 mr-1" />
Remove
</Button>
</div>
) : (
<div className="space-y-2">
<Upload className="h-8 w-8 text-slate-600 mx-auto" />
<p className="text-sm text-slate-400">
Drop {importMode === "qcrows" ? "a .qcrows archive" : imageType === "rootfs" ? "a .tar.gz archive" : "an initrd image"} here, or click to browse
</p>
<p className="text-xs text-slate-600">
{importMode === "qcrows"
? "Accepted: .qcrows — self-describing VM container image"
: imageType === "rootfs"
? "Accepted: .tar.gz, .tgz — max 2GiB recommended"
: "Accepted: .img, .cpio.gz, .lz4 — max 512MiB recommended"}
</p>
</div>
)}
</div>
</div>
<div className="flex justify-end">
<Button
onClick={() => {
if (importMode === "qcrows") {
handleQcrowsValidate();
} else {
setStep("configure");
}
}}
disabled={!selectedFile}
className={importMode === "qcrows" ? "bg-amber-600 hover:bg-amber-700 text-white" : "bg-emerald-600 hover:bg-emerald-700 text-white"}
>
{importMode === "qcrows" ? "Validate QCrows Archive" : "Next: Configure"}
</Button>
</div>
</div>
)}
{/* Step: Configure */}
{step === "configure" && (
<div className="space-y-4">
<div className="space-y-2">
<Label className="text-xs text-slate-400">Image Name</Label>
<Input
value={imageName}
onChange={(e) => setImageName(e.target.value)}
placeholder="e.g., ubuntu-22.04-kata-rootfs"
className="bg-[#0a0f1a] border-slate-700/50 text-slate-200 h-9"
/>
</div>
<div className="space-y-2">
<Label className="text-xs text-slate-400">
Hypervisor Compatibility
</Label>
<p className="text-[11px] text-slate-500 mb-2">
Select which VMM backends this image is compatible with. This
determines which RuntimeClass configurations can use the image.
</p>
<div className="grid grid-cols-2 gap-2">
{HYPERVISOR_OPTIONS.map((hv) => (
<label
key={hv.id}
className={cn(
"flex items-start gap-2.5 p-3 rounded-lg border cursor-pointer transition-colors",
selectedHypervisors.includes(hv.id)
? "border-emerald-600/50 bg-emerald-900/10"
: "border-slate-700 bg-[#0a0f1a] hover:border-slate-600"
)}
>
<Checkbox
checked={selectedHypervisors.includes(hv.id)}
onCheckedChange={() => toggleHypervisor(hv.id)}
className="mt-0.5"
/>
<div>
<p className="text-xs font-medium text-slate-200">
{hv.label}
</p>
<p className="text-[10px] text-slate-500">
{hv.description}
</p>
</div>
</label>
))}
</div>
</div>
<div className="flex justify-between">
<Button
variant="ghost"
onClick={() => setStep("select")}
className="text-slate-400"
>
Back
</Button>
<Button
onClick={handleValidate}
disabled={!imageName || selectedHypervisors.length === 0}
className="bg-emerald-600 hover:bg-emerald-700 text-white"
>
Next: Validate
</Button>
</div>
</div>
)}
{/* Step: Validate */}
{step === "validate" && (
<div className="space-y-4">
{importMode === "qcrows" && qcrowsValidating ? (
<div className="space-y-4 py-4">
<div className="flex items-center gap-3 mb-4">
<Loader2 className="h-5 w-5 text-amber-400 animate-spin" />
<span className="text-sm text-amber-300">Validating QCrows archive...</span>
</div>
<div className="space-y-2">
{QCROWS_VALIDATION_STEPS.map((vs, i) => (
<div key={vs.id} className="flex items-center gap-2 text-xs">
{i < qcrowsValidationStep ? (
<CheckCircle2 className="h-3.5 w-3.5 text-emerald-400 shrink-0" />
) : i === qcrowsValidationStep ? (
<Loader2 className="h-3.5 w-3.5 text-amber-400 animate-spin shrink-0" />
) : (
<div className="h-3.5 w-3.5 rounded-full border border-slate-700 shrink-0" />
)}
<span className={i <= qcrowsValidationStep ? "text-slate-200" : "text-slate-600"}>
{vs.label}
</span>
<span className="text-slate-600"> {vs.description}</span>
</div>
))}
</div>
<Progress value={(qcrowsValidationStep / QCROWS_VALIDATION_STEPS.length) * 100} className="h-1.5" />
</div>
) : importMode === "qcrows" && qcrowsMetadata && validationResult ? (
<div className="space-y-4">
{/* QCrows metadata preview */}
<div className="p-3 rounded-lg bg-amber-900/20 border border-amber-700/30">
<div className="flex items-center gap-2 mb-3">
<FileCheck className="h-4 w-4 text-amber-400" />
<span className="text-sm font-medium text-amber-300">QCrows Archive Parsed</span>
</div>
<div className="grid grid-cols-3 gap-x-6 gap-y-2 text-xs">
<div>
<span className="text-slate-500">Name:</span>{" "}
<span className="text-slate-200">{qcrowsMetadata.name}</span>
</div>
<div>
<span className="text-slate-500">Version:</span>{" "}
<span className="text-slate-200">{qcrowsMetadata.version}</span>
</div>
<div>
<span className="text-slate-500">Arch:</span>{" "}
<span className="text-slate-200">{qcrowsMetadata.arch}</span>
</div>
<div>
<span className="text-slate-500">Kernel:</span>{" "}
<span className="text-slate-200">{qcrowsMetadata.kernelVersion} ({qcrowsMetadata.kernelFormat}) {qcrowsMetadata.kernelIncluded ? "\u2705 bundled" : "\u26A0 not included"}</span>
</div>
<div>
<span className="text-slate-500">Kernel Size:</span>{" "}
<span className="text-slate-200">{qcrowsMetadata.kernelSizeMB} MB</span>
</div>
<div>
<span className="text-slate-500">Kernel Config:</span>{" "}
<span className={qcrowsMetadata.kernelConfigValid ? "text-emerald-300" : "text-red-300"}>
{qcrowsMetadata.kernelConfigValid ? "Valid" : "Invalid"}
</span>
</div>
<div>
<span className="text-slate-500">Boot Params:</span>{" "}
<span className="text-slate-200">{qcrowsMetadata.bootParamsIncluded ? "Bundled" : "Default"}</span>
</div>
<div>
<span className="text-slate-500">Rootfs:</span>{" "}
<span className="text-slate-200">{qcrowsMetadata.rootfsType}, ~{qcrowsMetadata.rootfsSizeMB} MiB</span>
</div>
<div>
<span className="text-slate-500">Initrd:</span>{" "}
<span className="text-slate-200">{qcrowsMetadata.initrdIncluded ? qcrowsMetadata.initrdType : "not included"}</span>
</div>
<div>
<span className="text-slate-500">Hypervisors:</span>{" "}
<span className="text-slate-200">{qcrowsMetadata.hypervisors.join(", ")}</span>
</div>
<div>
<span className="text-slate-500">Agent:</span>{" "}
<span className="text-slate-200">{qcrowsMetadata.agentName} {qcrowsMetadata.agentVersion}</span>
</div>
<div className="col-span-3">
<span className="text-slate-500">Description:</span>{" "}
<span className="text-slate-300">{qcrowsMetadata.description}</span>
</div>
</div>
</div>
{/* Menu entry preview */}
{qcrowsMenu && (
<div className="p-3 rounded-lg bg-slate-800/50 border border-slate-700/50">
<p className="text-xs text-slate-500 font-medium mb-2">Cockpit Menu Entry</p>
<div className="flex items-center gap-3">
<div className="h-10 w-10 rounded-lg bg-slate-700/50 flex items-center justify-center">
<Package className="h-5 w-5 text-amber-400" />
</div>
<div>
<p className="text-sm font-medium text-slate-200">{qcrowsMenu.label}</p>
<div className="flex items-center gap-2 mt-0.5">
<Badge variant="outline" className="text-[9px] px-1 py-0 border-amber-700/50 text-amber-400">
{qcrowsMenu.category}
</Badge>
<span className="text-[11px] text-slate-500">
{qcrowsMenu.initSystem} / {qcrowsMenu.packageCount} packages / {qcrowsMenu.shell}
</span>
</div>
</div>
</div>
</div>
)}
{/* Validation steps completed */}
<div className="space-y-1">
{QCROWS_VALIDATION_STEPS.map((vs) => (
<div key={vs.id} className="flex items-center gap-2 text-xs">
<CheckCircle2 className="h-3.5 w-3.5 text-emerald-400 shrink-0" />
<span className="text-slate-300">{vs.label}</span>
<span className="text-slate-600 ml-auto">{vs.description}</span>
</div>
))}
</div>
{/* Kernel validation detail */}
{qcrowsMetadata.kernelIncluded && (
<div className="p-3 rounded-lg bg-emerald-900/10 border border-emerald-700/30">
<div className="flex items-center gap-2 mb-2">
<Cpu className="h-4 w-4 text-emerald-400" />
<span className="text-sm font-medium text-emerald-300">Kernel Validation</span>
</div>
<div className="grid grid-cols-2 gap-x-4 gap-y-1.5 text-xs">
<div className="flex items-center gap-1.5">
<CheckCircle2 className="h-3 w-3 text-emerald-400" />
<span className="text-slate-300">Binary: {qcrowsMetadata.kernelFormat === "vmlinuz" ? "vmlinuz (bzImage)" : "vmlinux (ELF)"}</span>
</div>
<div className="flex items-center gap-1.5">
<CheckCircle2 className="h-3 w-3 text-emerald-400" />
<span className="text-slate-300">Version: {qcrowsMetadata.kernelVersion}</span>
</div>
<div className="flex items-center gap-1.5">
{qcrowsMetadata.kernelConfigValid ? (
<CheckCircle2 className="h-3 w-3 text-emerald-400" />
) : (
<XCircle className="h-3 w-3 text-red-400" />
)}
<span className="text-slate-300">Config: {qcrowsMetadata.kernelConfigValid ? "valid" : "invalid"}</span>
</div>
<div className="flex items-center gap-1.5">
<CheckCircle2 className="h-3 w-3 text-emerald-400" />
<span className="text-slate-300">Size: {qcrowsMetadata.kernelSizeMB} MB</span>
</div>
</div>
{qcrowsMetadata.kernelConfigValid && (
<div className="mt-2 pt-2 border-t border-emerald-700/20">
<p className="text-[10px] text-slate-500 mb-1">Required Kata options checked:</p>
<div className="flex flex-wrap gap-1">
{Object.keys(KATA_REQUIRED_KERNEL_OPTIONS).map((opt) => (
<span key={opt} className="text-[9px] bg-emerald-900/30 text-emerald-300 px-1.5 py-0.5 rounded font-mono">
{opt}=y
</span>
))}
</div>
</div>
)}
{qcrowsMetadata.bootParamsIncluded && (
<div className="mt-2 pt-2 border-t border-emerald-700/20">
<p className="text-[10px] text-slate-500 mb-1">Boot parameters bundled (will be appended to kernel_params)</p>
</div>
)}
</div>
)}
{/* Warnings */}
{validationResult.warnings.length > 0 && (
<div className="space-y-1.5">
<p className="text-xs text-amber-400 font-medium">Warnings:</p>
{validationResult.warnings.map((w, i) => (
<div key={i} className="flex items-start gap-2 text-xs text-amber-300 bg-amber-900/10 rounded px-3 py-2">
<AlertTriangle className="h-3.5 w-3.5 shrink-0 mt-0.5" />
{w}
</div>
))}
</div>
)}
<div className="flex justify-between pt-2">
<Button variant="ghost" onClick={handleReset} className="text-slate-400">
Cancel
</Button>
<Button
onClick={handleImport}
className="bg-amber-600 hover:bg-amber-700 text-white"
>
Import QCrows Image
</Button>
</div>
</div>
) : validating ? (
<div className="flex flex-col items-center justify-center py-8">
<Loader2 className="h-8 w-8 text-emerald-400 animate-spin mb-3" />
<p className="text-sm text-slate-300">Validating image...</p>
<p className="text-xs text-slate-500 mt-1">
Checking structure, kata-agent presence, and compatibility
</p>
</div>
) : validationResult ? (
<div className="space-y-3">
{/* Result banner */}
<div
className={cn(
"p-3 rounded-lg flex items-center gap-3",
validationResult.valid
? "bg-emerald-900/20 border border-emerald-700/30"
: "bg-red-900/20 border border-red-700/30"
)}
>
{validationResult.valid ? (
<CheckCircle2 className="h-5 w-5 text-emerald-400 shrink-0" />
) : (
<XCircle className="h-5 w-5 text-red-400 shrink-0" />
)}
<div>
<p
className={cn(
"text-sm font-medium",
validationResult.valid
? "text-emerald-300"
: "text-red-300"
)}
>
{validationResult.valid
? "Validation passed"
: "Validation failed"}
</p>
<p className="text-xs text-slate-400">
{validationResult.errors.length} errors,{" "}
{validationResult.warnings.length} warnings
</p>
</div>
</div>
{/* Validation rules */}
<div className="space-y-1.5">
<p className="text-xs text-slate-500 font-medium">
Checks performed:
</p>
{applicableRules.map((rule) => {
const isError = validationResult.errors.some((e) =>
e.toLowerCase().includes(rule.label.toLowerCase().split(" ")[0].toLowerCase())
);
const isWarning = validationResult.warnings.some((e) =>
e.toLowerCase().includes(rule.label.toLowerCase().split(" ")[0].toLowerCase())
);
return (
<div
key={rule.id}
className="flex items-center gap-2 text-xs py-1"
>
{isError ? (
<XCircle className="h-3.5 w-3.5 text-red-400 shrink-0" />
) : isWarning ? (
<AlertTriangle className="h-3.5 w-3.5 text-amber-400 shrink-0" />
) : (
<CheckCircle2 className="h-3.5 w-3.5 text-emerald-400 shrink-0" />
)}
<span className="text-slate-300">{rule.label}</span>
<Badge
variant="outline"
className={cn(
"text-[9px] px-1 py-0",
rule.severity === "error"
? "border-red-700/50 text-red-400"
: "border-amber-700/50 text-amber-400"
)}
>
{rule.severity}
</Badge>
</div>
);
})}
</div>
{/* Errors */}
{validationResult.errors.length > 0 && (
<div className="space-y-1.5">
<p className="text-xs text-red-400 font-medium">Errors:</p>
{validationResult.errors.map((err, i) => (
<div
key={i}
className="flex items-start gap-2 text-xs text-red-300 bg-red-900/10 rounded px-3 py-2"
>
<XCircle className="h-3.5 w-3.5 shrink-0 mt-0.5" />
{err}
</div>
))}
</div>
)}
{/* Warnings */}
{validationResult.warnings.length > 0 && (
<div className="space-y-1.5">
<p className="text-xs text-amber-400 font-medium">
Warnings:
</p>
{validationResult.warnings.map((warn, i) => (
<div
key={i}
className="flex items-start gap-2 text-xs text-amber-300 bg-amber-900/10 rounded px-3 py-2"
>
<AlertTriangle className="h-3.5 w-3.5 shrink-0 mt-0.5" />
{warn}
</div>
))}
</div>
)}
<div className="flex justify-between pt-2">
<Button
variant="ghost"
onClick={() => {
setStep("configure");
setValidationResult(null);
}}
className="text-slate-400"
>
Back
</Button>
<div className="flex gap-2">
{validationResult.warnings.length > 0 &&
validationResult.valid && (
<Button
onClick={handleImport}
className="bg-amber-600 hover:bg-amber-700 text-white"
>
Import with Warnings
</Button>
)}
{validationResult.valid && (
<Button
onClick={handleImport}
className="bg-emerald-600 hover:bg-emerald-700 text-white"
>
Import Image
</Button>
)}
</div>
</div>
</div>
) : null}
</div>
)}
{/* Step: Complete */}
{step === "complete" && (
<div className="space-y-4">
{importing ? (
<div className="space-y-4 py-4">
<div className="flex items-center justify-center gap-3">
<Loader2 className="h-5 w-5 text-emerald-400 animate-spin" />
<span className="text-sm text-slate-300">
Importing image...
</span>
</div>
<Progress
value={importProgress}
className="h-2 bg-slate-800"
/>
<p className="text-xs text-slate-500 text-center">
{importProgress < 50
? "Copying image to kata-containers directory..."
: importProgress < 80
? "Validating image integrity..."
: "Registering with runtime configuration..."}
</p>
</div>
) : importedResult ? (
<div className="space-y-4 py-4">
<div className="flex flex-col items-center justify-center">
<CheckCircle2 className="h-12 w-12 text-emerald-400 mb-3" />
<p className="text-lg font-semibold text-emerald-300">
Import Complete
</p>
<p className="text-sm text-slate-400 mt-1">
{importedResult.name} has been imported successfully
</p>
</div>
<Card className="bg-[#0a0f1a] border-slate-700/30">
<CardContent className="p-4 space-y-2">
{[
["Type", importedResult.type],
["Path", importedResult.path],
["Size", `${importedResult.sizeMB} MiB`],
["Hypervisors", importedResult.hypervisorCompat.join(", ")],
["Kernel", importedResult.kernelVersion || "N/A"],
["Agent", importedResult.agentVersion || "N/A"],
].map(([label, value]) => (
<div
key={label}
className="flex items-center justify-between text-xs"
>
<span className="text-slate-500">{label}</span>
<code className="text-slate-300 font-mono">
{value}
</code>
</div>
))}
</CardContent>
</Card>
<div className="flex justify-center gap-2">
<Button
variant="outline"
onClick={handleReset}
className="border-slate-700 text-slate-300"
>
Import Another
</Button>
{importMode === "qcrows" && qcrowsMetadata && (
<Button
onClick={() => {
const syntheticQcr: QCrowsImage = {
id: `qcr-pxe-${Date.now()}`,
filename: selectedFile?.name || "image.qcrows",
metadata: qcrowsMetadata,
menu: qcrowsMenu || {
label: qcrowsMetadata.name,
category: "server" as const,
icon: "package",
priority: 50,
initSystem: "systemd",
packageCount: 0,
shell: "/bin/sh",
workloads: [],
environments: [],
},
hashVerified: true,
importedAt: new Date().toISOString(),
};
openPXEDialog(syntheticQcr);
}}
className="bg-violet-600 hover:bg-violet-700 text-white"
>
<Radio className="h-3.5 w-3.5 mr-2" />
Push via PXE
</Button>
)}
</div>
</div>
) : null}
</div>
)}
</CardContent>
</Card>
{/* Validation rules reference */}
<Card className="bg-[#131c31] border-slate-700/50">
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium text-slate-200 flex items-center gap-2">
<Info className="h-4 w-4 text-cyan-400" />
Validation Rules Reference
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
{IMPORT_VALIDATION_RULES.filter((r) =>
r.appliesTo.includes(imageType)
).map((rule) => (
<div
key={rule.id}
className="flex items-start gap-3 text-xs py-1.5"
>
<Badge
variant="outline"
className={cn(
"text-[9px] px-1.5 py-0 shrink-0 mt-0.5",
rule.severity === "error"
? "border-red-700/50 text-red-400"
: "border-amber-700/50 text-amber-400"
)}
>
{rule.severity}
</Badge>
<div>
<p className="text-slate-300 font-medium">{rule.label}</p>
<p className="text-slate-500 mt-0.5">{rule.description}</p>
<code className="text-[10px] text-slate-600 mt-1 block">
{rule.check}
</code>
</div>
</div>
))}
</div>
</CardContent>
</Card>
</div>
{/* Existing images - 2 columns */}
<div className="lg:col-span-2 space-y-4">
<Card className="bg-[#131c31] border-slate-700/50">
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium text-slate-200 flex items-center gap-2">
<Package className="h-4 w-4 text-violet-400" />
Imported Images
<Badge
variant="outline"
className="text-[10px] px-1.5 py-0 border-slate-600 text-slate-400 ml-auto"
>
{images.length}
</Badge>
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{loading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-5 w-5 text-slate-600 animate-spin" />
</div>
) : images.length === 0 ? (
<div className="text-center py-8 text-slate-500">
<Package className="h-8 w-8 mx-auto mb-2 opacity-30" />
<p className="text-xs">No images imported yet</p>
</div>
) : (
images.map((img) => (
<div
key={img.id}
className={cn(
"p-3 rounded-lg border transition-colors",
img.validated
? "bg-[#0a0f1a] border-slate-700/30"
: "bg-red-900/10 border-red-700/30"
)}
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="flex items-center gap-1.5">
{img.validated ? (
<CheckCircle2 className="h-3.5 w-3.5 text-emerald-400 shrink-0" />
) : (
<XCircle className="h-3.5 w-3.5 text-red-400 shrink-0" />
)}
<p className="text-xs font-medium text-slate-200 truncate">
{img.name}
</p>
</div>
<div className="flex items-center gap-2 mt-1.5">
<Badge
variant="outline"
className={cn(
"text-[9px] px-1.5 py-0",
img.type === "rootfs"
? "border-emerald-700/50 text-emerald-400"
: "border-cyan-700/50 text-cyan-400"
)}
>
{img.type}
</Badge>
<span className="text-[10px] text-slate-500">
{img.sizeMB} MiB
</span>
</div>
{!img.validated && img.validationErrors && (
<div className="mt-2 space-y-0.5">
{img.validationErrors.map((err, i) => (
<p
key={i}
className="text-[10px] text-red-400 flex items-center gap-1"
>
<AlertTriangle className="h-2.5 w-2.5" />
{err}
</p>
))}
</div>
)}
{img.validated && (
<div className="flex flex-wrap gap-1 mt-1.5">
{img.hypervisorCompat.map((hv) => (
<span
key={hv}
className="text-[9px] bg-slate-800 text-slate-400 px-1.5 py-0.5 rounded"
>
{hv}
</span>
))}
</div>
)}
</div>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-slate-600 hover:text-red-400 shrink-0"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</div>
))
)}
</CardContent>
</Card>
{/* QCrows Images */}
<Card className="bg-[#131c31] border-slate-700/50">
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium text-slate-200 flex items-center gap-2">
<FileCheck className="h-4 w-4 text-amber-400" />
QCrows Images
<Badge
variant="outline"
className="text-[10px] px-1.5 py-0 border-amber-700/50 text-amber-400 ml-auto"
>
{qcrowsImages.length}
</Badge>
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{qcrowsImages.length === 0 ? (
<div className="text-center py-8 text-slate-500">
<FileCheck className="h-8 w-8 mx-auto mb-2 opacity-30" />
<p className="text-xs">No QCrows images imported</p>
<p className="text-[10px] mt-1">Drop a .qcrows file to import a self-describing image</p>
</div>
) : (
qcrowsImages.map((qcr) => (
<div
key={qcr.id}
className="p-3 rounded-lg border bg-[#0a0f1a] border-amber-700/20"
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="flex items-center gap-1.5">
<CheckCircle2 className="h-3.5 w-3.5 text-amber-400 shrink-0" />
<p className="text-xs font-medium text-slate-200 truncate">
{qcr.menu.label}
</p>
</div>
<p className="text-[10px] text-slate-500 mt-0.5">
{qcr.metadata.name} v{qcr.metadata.version}
</p>
<div className="flex items-center gap-2 mt-1.5">
<Badge variant="outline" className="text-[9px] px-1.5 py-0 border-amber-700/50 text-amber-400">
{qcr.menu.category}
</Badge>
<Badge variant="outline" className="text-[9px] px-1.5 py-0 border-slate-700/50 text-slate-400">
{qcr.metadata.arch}
</Badge>
<span className="text-[10px] text-slate-500">
{qcr.metadata.rootfsSizeMB} MiB
</span>
</div>
<div className="flex flex-wrap gap-1 mt-1.5">
{qcr.metadata.hypervisors.map((hv) => (
<span key={hv} className="text-[9px] bg-amber-900/20 text-amber-300 px-1.5 py-0.5 rounded">
{hv}
</span>
))}
</div>
{qcr.build && (
<div className="flex items-center gap-2 mt-1.5">
<Badge variant="outline" className="text-[9px] px-1.5 py-0 border-slate-700/50 text-slate-500">
{qcr.build.system}
</Badge>
{qcr.build.reproducible && (
<span className="text-[9px] text-emerald-500">reproducible</span>
)}
</div>
)}
<div className="flex items-center gap-1.5 mt-1.5">
{qcr.metadata.kernelIncluded && (
<span className="text-[9px] bg-emerald-900/30 text-emerald-300 px-1.5 py-0.5 rounded flex items-center gap-1">
<Cpu className="h-2.5 w-2.5" />
{qcr.metadata.kernelVersion} ({qcr.metadata.kernelFormat})
</span>
)}
{qcr.metadata.initrdIncluded && (
<span className="text-[9px] bg-slate-800 text-slate-400 px-1.5 py-0.5 rounded">
{qcr.metadata.initrdType}
</span>
)}
<span className="text-[9px] bg-slate-800 text-slate-400 px-1.5 py-0.5 rounded">
{qcr.metadata.agentName} {qcr.metadata.agentVersion}
</span>
</div>
</div>
<Button variant="ghost" size="sm" className="h-6 w-6 p-0 text-slate-600 hover:text-red-400 shrink-0">
<Trash2 className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-violet-400 hover:text-violet-300 shrink-0"
title="Push via PXE"
onClick={() => openPXEDialog(qcr)}
>
<Radio className="h-3 w-3" />
</Button>
</div>
</div>
))
)}
</CardContent>
</Card>
</div>
</div>
{/* ─── PXE Push Dialog ────────────────────────────────────────────── */}
<Dialog open={pxeDialogOpen} onOpenChange={setPxeDialogOpen}>
<DialogContent className="bg-[#131c31] border-slate-700/50 text-slate-200 max-w-2xl max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-sm">
<Radio className="h-4 w-4 text-violet-400" />
Push via PXE
{pxeTargetImage && (
<Badge variant="outline" className="text-[9px] px-1.5 py-0 border-violet-700/50 text-violet-400 ml-2">
{pxeTargetImage.metadata.name}
</Badge>
)}
</DialogTitle>
<DialogDescription className="text-xs text-slate-500">
Deploy this QCrows image to a PXE/TFTP server for network boot. Configure the target TFTP directory, DHCP settings, and boot parameters.
</DialogDescription>
</DialogHeader>
{pxeResult ? (
/* ── Push Result ── */
<div className="space-y-4 py-2">
<div className={cn(
"p-3 rounded-lg flex items-center gap-3",
pxeResult.success
? "bg-emerald-900/20 border border-emerald-700/30"
: "bg-red-900/20 border border-red-700/30"
)}>
{pxeResult.success
? <CheckCircle2 className="h-5 w-5 text-emerald-400 shrink-0" />
: <XCircle className="h-5 w-5 text-red-400 shrink-0" />
}
<div>
<p className={cn("text-sm font-medium", pxeResult.success ? "text-emerald-300" : "text-red-300")}>
{pxeResult.success ? "PXE Push Complete" : "PXE Push Failed"}
</p>
<p className="text-xs text-slate-400">
{pxeResult.messages.length} steps completed, {pxeResult.errors.length} errors
</p>
</div>
</div>
{pxeResult.messages.length > 0 && (
<div className="space-y-1.5">
<p className="text-xs text-slate-500 font-medium">Steps completed:</p>
{pxeResult.messages.map((msg, i) => (
<div key={i} className="flex items-start gap-2 text-xs text-slate-300">
<CheckCircle2 className="h-3.5 w-3.5 text-emerald-400 shrink-0 mt-0.5" />
<span>{msg}</span>
</div>
))}
</div>
)}
{pxeResult.errors.length > 0 && (
<div className="space-y-1.5">
<p className="text-xs text-red-400 font-medium">Errors:</p>
{pxeResult.errors.map((err, i) => (
<div key={i} className="flex items-start gap-2 text-xs text-red-300">
<XCircle className="h-3.5 w-3.5 text-red-400 shrink-0 mt-0.5" />
<span>{err}</span>
</div>
))}
</div>
)}
{pxeResult.success && (
<Card className="bg-[#0a0f1a] border-slate-700/30">
<CardContent className="p-3 space-y-1.5">
<p className="text-xs text-slate-500 font-medium mb-1">Deployed files:</p>
{[
["TFTP Root", pxeResult.tftpDir],
["PXE Config", pxeResult.pxeConfigPath],
["Kernel", pxeResult.kernelDest],
["Initrd", pxeResult.initrdDest || "N/A"],
["Rootfs", pxeResult.rootfsDest || "N/A"],
].map(([label, value]) => (
<div key={label} className="flex items-center justify-between text-xs">
<span className="text-slate-500">{label}</span>
<code className="text-slate-300 font-mono text-[11px]">{value}</code>
</div>
))}
</CardContent>
</Card>
)}
</div>
) : (
/* ── Configuration Form ── */
<div className="space-y-4 py-2">
{/* PXE Status */}
{pxeStatus && (
<div className="flex items-center gap-3 p-2.5 rounded-lg bg-slate-800/50 border border-slate-700/30">
<Settings2 className="h-4 w-4 text-slate-400 shrink-0" />
<div className="flex items-center gap-3 flex-1 text-xs">
<span className={pxeStatus.dnsmasqRunning ? "text-emerald-400" : "text-red-400"}>
dnsmasq: {pxeStatus.dnsmasqRunning ? "running" : "not running"}
</span>
<span className={pxeStatus.tftpDirExists ? "text-emerald-400" : "text-amber-400"}>
TFTP dir: {pxeStatus.tftpDirExists ? "exists" : "missing"}
</span>
<span className={pxeStatus.tftpDirWritable ? "text-emerald-400" : "text-red-400"}>
writable: {pxeStatus.tftpDirWritable ? "yes" : "no"}
</span>
</div>
</div>
)}
{/* Network settings */}
<div className="space-y-3">
<p className="text-xs text-slate-500 font-medium">Network Configuration</p>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label className="text-[11px] text-slate-400">TFTP Directory</Label>
<Input
value={pxeConfig.tftpDir}
onChange={(e) => setPxeConfig((p) => ({ ...p, tftpDir: e.target.value }))}
className="bg-[#0a0f1a] border-slate-700/50 text-slate-200 h-8 text-xs"
placeholder="/srv/tftp"
/>
</div>
<div className="space-y-1.5">
<Label className="text-[11px] text-slate-400">PXE Server IP</Label>
<Input
value={pxeConfig.serverIP}
onChange={(e) => setPxeConfig((p) => ({ ...p, serverIP: e.target.value }))}
className="bg-[#0a0f1a] border-slate-700/50 text-slate-200 h-8 text-xs"
placeholder="192.168.1.1"
/>
</div>
<div className="space-y-1.5">
<Label className="text-[11px] text-slate-400">Gateway</Label>
<Input
value={pxeConfig.gateway}
onChange={(e) => setPxeConfig((p) => ({ ...p, gateway: e.target.value }))}
className="bg-[#0a0f1a] border-slate-700/50 text-slate-200 h-8 text-xs"
/>
</div>
<div className="space-y-1.5">
<Label className="text-[11px] text-slate-400">DNS Server</Label>
<Input
value={pxeConfig.dns}
onChange={(e) => setPxeConfig((p) => ({ ...p, dns: e.target.value }))}
className="bg-[#0a0f1a] border-slate-700/50 text-slate-200 h-8 text-xs"
/>
</div>
<div className="space-y-1.5">
<Label className="text-[11px] text-slate-400">DHCP Range Start</Label>
<Input
value={pxeConfig.rangeStart}
onChange={(e) => setPxeConfig((p) => ({ ...p, rangeStart: e.target.value }))}
className="bg-[#0a0f1a] border-slate-700/50 text-slate-200 h-8 text-xs"
/>
</div>
<div className="space-y-1.5">
<Label className="text-[11px] text-slate-400">DHCP Range End</Label>
<Input
value={pxeConfig.rangeEnd}
onChange={(e) => setPxeConfig((p) => ({ ...p, rangeEnd: e.target.value }))}
className="bg-[#0a0f1a] border-slate-700/50 text-slate-200 h-8 text-xs"
/>
</div>
</div>
</div>
<Separator className="bg-slate-700/30" />
{/* Boot settings */}
<div className="space-y-3">
<p className="text-xs text-slate-500 font-medium">Boot Configuration</p>
<div className="space-y-1.5">
<Label className="text-[11px] text-slate-400">PXE Menu Label</Label>
<Input
value={pxeConfig.label}
onChange={(e) => setPxeConfig((p) => ({ ...p, label: e.target.value }))}
className="bg-[#0a0f1a] border-slate-700/50 text-slate-200 h-8 text-xs"
/>
</div>
<div className="flex items-center justify-between">
<div>
<Label className="text-xs text-slate-300">Default Boot Entry</Label>
<p className="text-[10px] text-slate-500">Set as default in PXE menu other entries will require manual selection</p>
</div>
<Switch
checked={pxeConfig.defaultBoot}
onCheckedChange={(checked) => setPxeConfig((p) => ({ ...p, defaultBoot: checked }))}
/>
</div>
<div className="space-y-1.5">
<Label className="text-[11px] text-slate-400">Extra Kernel Command Line</Label>
<Textarea
value={pxeConfig.appendCmdline}
onChange={(e) => setPxeConfig((p) => ({ ...p, appendCmdline: e.target.value }))}
className="bg-[#0a0f1a] border-slate-700/50 text-slate-200 text-xs min-h-[60px] font-mono"
placeholder="e.g. quiet nomodeset systemd.unit=kata.target"
/>
<p className="text-[10px] text-slate-600">
Appended to the default kernel command line: root=/dev/nfs nfsroot=... ip=dhcp console=ttyS0
</p>
</div>
</div>
{/* Image summary */}
{pxeTargetImage && (
<>
<Separator className="bg-slate-700/30" />
<div className="p-2.5 rounded-lg bg-violet-900/10 border border-violet-700/20">
<p className="text-[10px] text-violet-400 font-medium mb-1.5">Image to deploy:</p>
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-xs">
<div><span className="text-slate-500">Name:</span> <span className="text-slate-300">{pxeTargetImage.metadata.name}</span></div>
<div><span className="text-slate-500">Version:</span> <span className="text-slate-300">{pxeTargetImage.metadata.version}</span></div>
<div><span className="text-slate-500">Kernel:</span> <span className="text-slate-300">{pxeTargetImage.metadata.kernelVersion} ({pxeTargetImage.metadata.kernelFormat})</span></div>
<div><span className="text-slate-500">Rootfs:</span> <span className="text-slate-300">{pxeTargetImage.metadata.rootfsType}, ~{pxeTargetImage.metadata.rootfsSizeMB} MiB</span></div>
<div><span className="text-slate-500">Initrd:</span> <span className="text-slate-300">{pxeTargetImage.metadata.initrdIncluded ? pxeTargetImage.metadata.initrdType : "none"}</span></div>
<div><span className="text-slate-500">Arch:</span> <span className="text-slate-300">{pxeTargetImage.metadata.arch}</span></div>
</div>
</div>
</>
)}
</div>
)}
<DialogFooter className="gap-2">
{pxeResult ? (
<Button onClick={() => setPxeDialogOpen(false)} className="bg-violet-600 hover:bg-violet-700 text-white">
Done
</Button>
) : (
<>
<Button variant="ghost" onClick={() => setPxeDialogOpen(false)} className="text-slate-400">
Cancel
</Button>
<Button
onClick={executePXEPush}
disabled={pxePushing || !pxeConfig.tftpDir || !pxeConfig.serverIP}
className="bg-violet-600 hover:bg-violet-700 text-white"
>
{pxePushing ? (
<>
<Loader2 className="h-3.5 w-3.5 animate-spin mr-2" />
Pushing...
</>
) : (
<>
<Radio className="h-3.5 w-3.5 mr-2" />
Push via PXE
</>
)}
</Button>
</>
)}
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}