1543 lines
72 KiB
TypeScript
Executable File
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>
|
|
);
|
|
}
|