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:
|
def discover_disk(source: Path) -> DiskInfo:
|
||||||
"""Walk the source directory and find the primary disk image."""
|
"""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():
|
if source.is_file():
|
||||||
return _probe(source)
|
return [_probe(source)]
|
||||||
|
|
||||||
candidates = []
|
candidates = []
|
||||||
for dirpath, _, filenames in os.walk(source):
|
for dirpath, _, filenames in os.walk(source):
|
||||||
|
|
@ -175,9 +181,14 @@ def discover_disk(source: Path) -> DiskInfo:
|
||||||
if not candidates:
|
if not candidates:
|
||||||
raise FileNotFoundError(f"No disk image found in {source}")
|
raise FileNotFoundError(f"No disk image found in {source}")
|
||||||
|
|
||||||
# Pick largest as primary
|
|
||||||
candidates.sort(key=lambda p: p.stat().st_size, reverse=True)
|
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:
|
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)
|
source = _extract_nested(source)
|
||||||
|
|
||||||
_update_analysis(analysis_id, "processing", "Discovering disk image...")
|
_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("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...")
|
_update_analysis(analysis_id, "processing", "Detecting OS and boot type...")
|
||||||
extract_dir = source if source.is_dir() else source.parent
|
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)
|
logger.info("Analysis done: os=%s efi=%s", os_type, efi)
|
||||||
|
|
||||||
from models import AnalyzeResponse
|
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(
|
result = AnalyzeResponse(
|
||||||
vmid=vmid,
|
vmid=vmid,
|
||||||
filename=disk.path.name,
|
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,
|
disk_size_gb=disk.size_gb,
|
||||||
os_type=os_type,
|
os_type=os_type,
|
||||||
efi_detectable=efi,
|
efi_detectable=efi,
|
||||||
|
all_disks=all_disks_data,
|
||||||
)
|
)
|
||||||
_update_analysis(analysis_id, "completed", "Analysis complete", result=result.model_dump())
|
_update_analysis(analysis_id, "completed", "Analysis complete", result=result.model_dump())
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -106,6 +106,7 @@ class AnalyzeResponse(BaseModel):
|
||||||
disk_size_gb: float
|
disk_size_gb: float
|
||||||
os_type: Optional[str] = None
|
os_type: Optional[str] = None
|
||||||
efi_detectable: Optional[bool] = None
|
efi_detectable: Optional[bool] = None
|
||||||
|
all_disks: list = []
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -398,13 +398,36 @@ async def confirm_session(
|
||||||
auto_detect_boot: str = Form("true"),
|
auto_detect_boot: str = Form("true"),
|
||||||
boot_type: str = Form("uefi"),
|
boot_type: str = Form("uefi"),
|
||||||
session_id: str = Form(""),
|
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."""
|
"""Build the job payload and submit to the backend."""
|
||||||
# Validate VM ID range
|
|
||||||
err = _validate_vmid(vmid)
|
err = _validate_vmid(vmid)
|
||||||
if err:
|
if err:
|
||||||
return render("_analysis.html", request=request, error=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 = {
|
payload = {
|
||||||
"vmid": vmid,
|
"vmid": vmid,
|
||||||
"vm_name": vm_name,
|
"vm_name": vm_name,
|
||||||
|
|
@ -416,9 +439,10 @@ async def confirm_session(
|
||||||
"session_id": session_id,
|
"session_id": session_id,
|
||||||
"boot_disk": {
|
"boot_disk": {
|
||||||
"disk_type": "image_file",
|
"disk_type": "image_file",
|
||||||
"source_filename": source_filename,
|
"source_filename": boot_file,
|
||||||
"format": disk_format,
|
"format": boot_fmt,
|
||||||
},
|
},
|
||||||
|
"additional_disks": additional_disks,
|
||||||
}
|
}
|
||||||
if target_disk_size_gb is not None:
|
if target_disk_size_gb is not None:
|
||||||
payload["target_disk_size_gb"] = target_disk_size_gb
|
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>
|
<tr><td>EFI Bootable</td><td>{{ 'Yes' if analysis.efi_detectable else 'Unknown' }}</td></tr>
|
||||||
</table>
|
</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>
|
<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 }}">
|
<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="source_filename" value="{{ source_filename }}">
|
||||||
<input type="hidden" name="disk_format" value="{{ analysis.disk_format }}">
|
<input type="hidden" name="disk_format" value="{{ analysis.disk_format }}">
|
||||||
<input type="hidden" name="session_id" value="{{ session_id or '' }}">
|
<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-row">
|
||||||
<div class="pve-form-group">
|
<div class="pve-form-group">
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue