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:
parent
2e0c6cdba7
commit
75f90f16bb
4 changed files with 78 additions and 7 deletions
|
|
@ -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())
|
||||
|
||||
|
|
|
|||
|
|
@ -106,6 +106,7 @@ class AnalyzeResponse(BaseModel):
|
|||
disk_size_gb: float
|
||||
os_type: Optional[str] = None
|
||||
efi_detectable: Optional[bool] = None
|
||||
all_disks: list = []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
Loading…
Reference in a new issue