.fab exclusion scoping: classes in the manual export (Task 4, v0.136.0)

SQ6 over-capture FIXED for classified apps: the userdata root tar is exclude-scoped (keeps only
ancestors/descendants of a SELECTED bind relpath — R1-C, the tier2Reconcile keep-rule); no selected
userdata bind → no root tar (radarr state-only). New appbackup.ComputeFabBuckets (shared
resolveGuardCollapse pipeline; class buckets; guards over ALL classes; no cross-bucket containment).
appexport/fabplan.go: computeFabPlan + tarDirectoryExcluding + fabEstimateSplit. ExportRequest gains
DeselectOptional/OptInExcluded (both start handlers — two-call-site); mandatory is a server-side
floor. Manifest v1 + import UNTOUCHED; legacy apps byte-identical to v0.130.0. Estimate additive
class split; export UI: locked-mandatory/optional-checkboxes/excluded-opt-in + two-number warning.
All 6 §10 red-proofs verified. 6D Accept #1 now runs against this shape.
This commit is contained in:
2026-07-15 11:28:40 +02:00
parent 5f216138e5
commit cf9ce01917
19 changed files with 1392 additions and 177 deletions
+27
View File
@@ -27,6 +27,27 @@ type ExportEstimate struct {
// failed). When true, DataSizeBytes is a partial/understated sum and FitsOnDest is FORCED false
// — a failed read must NEVER render as "fits". The UI shows "ismeretlen méret".
SizeUnknown bool `json:"size_unknown"`
// Task 4 class split (classified apps only; empty for legacy — existing fields above are
// unchanged, so old JSON consumers keep working). BaseBytes = config + DB + volumes + mandatory
// (always in the bundle). OptionalItems are pre-selected, ExcludedItems are opt-in — each carries
// its own size so the UI recomputes the total client-side per checkbox toggle (no extra du calls).
HasClassification bool `json:"has_classification"`
BaseBytes int64 `json:"base_bytes"`
BaseHuman string `json:"base_human"`
MandatoryItems []FabItem `json:"mandatory_items,omitempty"`
OptionalItems []FabItem `json:"optional_items,omitempty"`
ExcludedItems []FabItem `json:"excluded_items,omitempty"`
}
// FabItem is one class-scoped path in the `.fab` selection UI: Key is the DeselectOptional/OptInExcluded
// value ("root/rel"), RelPath is the display path, Bytes/Human its du size.
type FabItem struct {
Key string `json:"key"`
Root string `json:"root"`
RelPath string `json:"rel_path"`
Bytes int64 `json:"bytes"`
Human string `json:"human"`
}
// EstimateExport calculates size estimates for an app export.
@@ -57,6 +78,7 @@ func (e *Exporter) EstimateExport(stackName, destDrive string) (*ExportEstimate,
}
volumes := e.provider.GetDockerVolumes(stackName)
e.debugf("EstimateExport: Docker volumes: %v", volumes)
var volumeBytes int64
for _, vol := range volumes {
volSize, err := volumeSizer(vol)
if err != nil {
@@ -68,6 +90,7 @@ func (e *Exporter) EstimateExport(stackName, destDrive string) (*ExportEstimate,
}
e.debugf("EstimateExport: volume %s = %s", vol, humanizeBytes(volSize))
est.DataSizeBytes += volSize
volumeBytes += volSize
}
if est.SizeUnknown {
est.DataSizeHuman = "ismeretlen méret"
@@ -78,6 +101,10 @@ func (e *Exporter) EstimateExport(stackName, destDrive string) (*ExportEstimate,
est.TotalSizeBytes = est.ConfigSizeBytes + est.DataSizeBytes
est.TotalSizeHuman = humanizeBytes(est.TotalSizeBytes)
// Task 4: the class split (classified apps only). Independent du over each bucket path — additive,
// never touches the fields/fits gate above.
e.fabEstimateSplit(stackName, est, volumeBytes)
// Rough time estimate: ~500 MB/min for HDDs, minimum 1 minute
minutes := int(est.TotalSizeBytes / (500 * 1024 * 1024))
if minutes < 1 {