feat: multi-disk support — discover all disks in archive, select boot disk, additional disks included in job

- Storage pool discovery now uses pvesm status (reliable) with Custom... option
- discover_all_disks returns all found disk images sorted by size
- Analysis shows all disks with radio button for boot disk selection
- Confirm form passes boot disk + additional disks to job payload
This commit is contained in:
Claus Lohmar 2026-07-27 08:53:53 +00:00
parent 2e0c6cdba7
commit 75f90f16bb
4 changed files with 78 additions and 7 deletions

View file

@ -162,8 +162,14 @@ def _detect_archive_ext(name: str) -> Optional[str]:
def discover_disk(source: Path) -> DiskInfo:
"""Walk the source directory and find the primary disk image."""
return _pick_largest(discover_all_disks(source))
def discover_all_disks(source: Path) -> list:
"""Walk the source directory and return all disk images found,
sorted by size descending."""
if source.is_file():
return _probe(source)
return [_probe(source)]
candidates = []
for dirpath, _, filenames in os.walk(source):
@ -175,9 +181,14 @@ def discover_disk(source: Path) -> DiskInfo:
if not candidates:
raise FileNotFoundError(f"No disk image found in {source}")
# Pick largest as primary
candidates.sort(key=lambda p: p.stat().st_size, reverse=True)
return _probe(candidates[0])
return [_probe(p) for p in candidates]
def _pick_largest(disks: list) -> DiskInfo:
if not disks:
raise FileNotFoundError("No disks")
return disks[0]
def _probe(disk_path: Path) -> DiskInfo:
@ -392,8 +403,10 @@ def _process_analysis(analysis_id: str, vmid: int, source_filename: str):
source = _extract_nested(source)
_update_analysis(analysis_id, "processing", "Discovering disk image...")
disk = discover_disk(source)
disks = discover_all_disks(source)
disk = disks[0]
logger.info("Disk found: %s (format=%s, size=%.1f GiB)", disk.path, disk.format, disk.size_gb)
logger.info("Total disks discovered: %d", len(disks))
_update_analysis(analysis_id, "processing", "Detecting OS and boot type...")
extract_dir = source if source.is_dir() else source.parent
@ -402,6 +415,13 @@ def _process_analysis(analysis_id: str, vmid: int, source_filename: str):
logger.info("Analysis done: os=%s efi=%s", os_type, efi)
from models import AnalyzeResponse
all_disks_data = [{
"filename": d.path.name,
"format": d.format,
"size_gb": d.size_gb,
"size_bytes": d.size_bytes,
} for d in disks]
result = AnalyzeResponse(
vmid=vmid,
filename=disk.path.name,
@ -409,6 +429,7 @@ def _process_analysis(analysis_id: str, vmid: int, source_filename: str):
disk_size_gb=disk.size_gb,
os_type=os_type,
efi_detectable=efi,
all_disks=all_disks_data,
)
_update_analysis(analysis_id, "completed", "Analysis complete", result=result.model_dump())

View file

@ -106,6 +106,7 @@ class AnalyzeResponse(BaseModel):
disk_size_gb: float
os_type: Optional[str] = None
efi_detectable: Optional[bool] = None
all_disks: list = []
# ---------------------------------------------------------------------------

View file

@ -398,13 +398,36 @@ async def confirm_session(
auto_detect_boot: str = Form("true"),
boot_type: str = Form("uefi"),
session_id: str = Form(""),
has_multiple_disks: str = Form("0"),
boot_disk_index: str = Form("0"),
):
"""Build the job payload and submit to the backend."""
# Validate VM ID range
err = _validate_vmid(vmid)
if err:
return render("_analysis.html", request=request, error=err)
# Determine boot disk from multi-disk selection
boot_file = source_filename
boot_fmt = disk_format
if has_multiple_disks == "1":
idx = int(boot_disk_index) if boot_disk_index.isdigit() else 0
form_data = await request.form()
boot_file = form_data.get(f"disk_{idx}_file", source_filename)
boot_fmt = form_data.get(f"disk_{idx}_fmt", disk_format)
additional_disks = []
for i in range(10):
dfile = form_data.get(f"disk_{i}_file")
if dfile and i != idx:
additional_disks.append({
"disk_type": "image_file",
"source_filename": dfile,
"format": form_data.get(f"disk_{i}_fmt", "vmdk"),
})
else:
additional_disks = []
payload = {
"vmid": vmid,
"vm_name": vm_name,
@ -416,9 +439,10 @@ async def confirm_session(
"session_id": session_id,
"boot_disk": {
"disk_type": "image_file",
"source_filename": source_filename,
"format": disk_format,
"source_filename": boot_file,
"format": boot_fmt,
},
"additional_disks": additional_disks,
}
if target_disk_size_gb is not None:
payload["target_disk_size_gb"] = target_disk_size_gb

View file

@ -25,6 +25,24 @@
<tr><td>EFI Bootable</td><td>{{ 'Yes' if analysis.efi_detectable else 'Unknown' }}</td></tr>
</table>
{% if analysis.all_disks and analysis.all_disks|length > 1 %}
<div style="margin-top:1rem;">
<label style="font-size:0.75rem;color:var(--pve-muted);font-weight:600;margin-bottom:0.3rem;display:block;">Disks Found — select boot disk</label>
<table class="pve-table">
{% for d in analysis.all_disks %}
<tr>
<td style="width:auto;padding-right:0.5rem;">
<input type="radio" name="boot_disk_index" value="{{ loop.index0 }}" {% if loop.first %}checked{% endif %}>
</td>
<td>{{ d.filename }}</td>
<td>{{ d.format }}</td>
<td>{{ d.size_gb }} GB</td>
</tr>
{% endfor %}
</table>
</div>
{% endif %}
<p class="pve-dim" style="margin:0.75rem 0;">Is this correct? Configure the VM below and submit the conversion job.</p>
<form id="confirm-form" data-vmid="{{ vmid }}" data-source-filename="{{ source_filename }}">
@ -32,6 +50,13 @@
<input type="hidden" name="source_filename" value="{{ source_filename }}">
<input type="hidden" name="disk_format" value="{{ analysis.disk_format }}">
<input type="hidden" name="session_id" value="{{ session_id or '' }}">
{% if analysis.all_disks and analysis.all_disks|length > 1 %}
<input type="hidden" name="has_multiple_disks" value="1">
{% for d in analysis.all_disks %}
<input type="hidden" name="disk_{{ loop.index0 }}_file" value="{{ d.filename }}">
<input type="hidden" name="disk_{{ loop.index0 }}_fmt" value="{{ d.format }}">
{% endfor %}
{% endif %}
<div class="pve-row">
<div class="pve-form-group">