Finish backup spec: button state, invariant assertion, NAS error surfacing
- BackupView: action button shows "Checking status…" with spinner and blocks taps while coordinator.phase == .checking — prevents starting a backup before reconciliation has determined what actually needs uploading - BackupView: NAS status row shows statusService.refreshError (actual SMB error reason: auth failure, timeout, host unreachable) instead of the generic "Unreachable" string — surfaces the real diagnosis to the user - BackupStatusService: invariant assertion after every full reconcile — logs .error if alreadySafe + needBackup ≠ phoneTotal so filter mismatches and off-by-ones surface immediately in Console.app - BackupStatusService.buildManifestIndex: full diagnostic logging — remotePath, manifest file path, download byte count, decoded entry count, bootstrap fallback reason — all visible in Console.app filtered by "BackupStatus" Co-Authored-By: Kutesir <kutesir@provoc.ug> Co-Authored-By: Sentry <sentry@provoc.ug>
This commit is contained in:
@@ -646,9 +646,11 @@ struct BackupView: View {
|
|||||||
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: nasStatusLabel)
|
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: nasStatusLabel)
|
||||||
}
|
}
|
||||||
if lanMonitor.nasReachable == false {
|
if lanMonitor.nasReachable == false {
|
||||||
Text("Unreachable")
|
Text(statusService.refreshError ?? "Unreachable")
|
||||||
.font(.system(size: 9, weight: .regular))
|
.font(.system(size: 9, weight: .regular))
|
||||||
.foregroundStyle(AppTheme.destructive.opacity(0.7))
|
.foregroundStyle(AppTheme.destructive.opacity(0.7))
|
||||||
|
.lineLimit(2)
|
||||||
|
.multilineTextAlignment(.trailing)
|
||||||
} else if lanMonitor.nasReachable == true && !engine.job.status.isActive {
|
} else if lanMonitor.nasReachable == true && !engine.job.status.isActive {
|
||||||
Text("Connected")
|
Text("Connected")
|
||||||
.font(.system(size: 9, weight: .regular))
|
.font(.system(size: 9, weight: .regular))
|
||||||
@@ -691,43 +693,56 @@ struct BackupView: View {
|
|||||||
|
|
||||||
private var actionButton: some View {
|
private var actionButton: some View {
|
||||||
Group {
|
Group {
|
||||||
switch resolvedStatus {
|
// Coordinator is running a status check — block the start button so
|
||||||
case .idle, .completed, .failed, .cancelled:
|
// a backup cannot be triggered before reconciliation completes.
|
||||||
Button(action: startBackup) {
|
if coordinator.phase == .checking {
|
||||||
Text(resolvedStatus == .idle ? "Start backup" : "Back up again")
|
|
||||||
}
|
|
||||||
.buttonStyle(PrimaryButtonStyle(
|
|
||||||
color: ctaFlashGreen ? AppTheme.positive : AppTheme.ink
|
|
||||||
))
|
|
||||||
.accessibilityHint("Starts backing up your photos to the NAS")
|
|
||||||
|
|
||||||
case .running:
|
|
||||||
Button { engine.pause() } label: { Text("Pause") }
|
|
||||||
.buttonStyle(GhostButtonStyle())
|
|
||||||
.accessibilityLabel("Pause backup")
|
|
||||||
|
|
||||||
case .paused:
|
|
||||||
HStack(spacing: 10) {
|
|
||||||
Button { engine.resume() } label: { Text("Resume") }
|
|
||||||
.buttonStyle(PrimaryButtonStyle())
|
|
||||||
.accessibilityLabel("Resume backup")
|
|
||||||
Button { engine.cancel() } label: { Text("Cancel") }
|
|
||||||
.buttonStyle(GhostButtonStyle())
|
|
||||||
.frame(width: 90)
|
|
||||||
.accessibilityLabel("Cancel backup")
|
|
||||||
}
|
|
||||||
|
|
||||||
case .preparing:
|
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
ProgressView().scaleEffect(0.8).tint(AppTheme.inkSecondary)
|
ProgressView().scaleEffect(0.8).tint(AppTheme.inkSecondary)
|
||||||
Text("Preparing…")
|
Text("Checking status…")
|
||||||
.font(AppTheme.body())
|
.font(AppTheme.body())
|
||||||
.foregroundStyle(AppTheme.inkSecondary)
|
.foregroundStyle(AppTheme.inkSecondary)
|
||||||
}
|
}
|
||||||
.frame(height: 50)
|
.frame(height: 50)
|
||||||
|
} else {
|
||||||
|
switch resolvedStatus {
|
||||||
|
case .idle, .completed, .failed, .cancelled:
|
||||||
|
Button(action: startBackup) {
|
||||||
|
Text(resolvedStatus == .idle ? "Start backup" : "Back up again")
|
||||||
|
}
|
||||||
|
.buttonStyle(PrimaryButtonStyle(
|
||||||
|
color: ctaFlashGreen ? AppTheme.positive : AppTheme.ink
|
||||||
|
))
|
||||||
|
.accessibilityHint("Starts backing up your photos to the NAS")
|
||||||
|
|
||||||
|
case .running:
|
||||||
|
Button { engine.pause() } label: { Text("Pause") }
|
||||||
|
.buttonStyle(GhostButtonStyle())
|
||||||
|
.accessibilityLabel("Pause backup")
|
||||||
|
|
||||||
|
case .paused:
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
Button { engine.resume() } label: { Text("Resume") }
|
||||||
|
.buttonStyle(PrimaryButtonStyle())
|
||||||
|
.accessibilityLabel("Resume backup")
|
||||||
|
Button { engine.cancel() } label: { Text("Cancel") }
|
||||||
|
.buttonStyle(GhostButtonStyle())
|
||||||
|
.frame(width: 90)
|
||||||
|
.accessibilityLabel("Cancel backup")
|
||||||
|
}
|
||||||
|
|
||||||
|
case .preparing:
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
ProgressView().scaleEffect(0.8).tint(AppTheme.inkSecondary)
|
||||||
|
Text("Preparing…")
|
||||||
|
.font(AppTheme.body())
|
||||||
|
.foregroundStyle(AppTheme.inkSecondary)
|
||||||
|
}
|
||||||
|
.frame(height: 50)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.animation(.spring(response: 0.3, dampingFraction: 0.82), value: engine.job.status == .running)
|
.animation(.spring(response: 0.3, dampingFraction: 0.82), value: engine.job.status == .running)
|
||||||
|
.animation(.spring(response: 0.3, dampingFraction: 0.82), value: coordinator.phase == .checking)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func startBackup() {
|
private func startBackup() {
|
||||||
|
|||||||
@@ -240,6 +240,12 @@ final class BackupStatusService: NSObject, ObservableObject {
|
|||||||
updated.lastCheckedAt = Date()
|
updated.lastCheckedAt = Date()
|
||||||
snapshot = updated
|
snapshot = updated
|
||||||
saveSnapshot()
|
saveSnapshot()
|
||||||
|
|
||||||
|
// Invariant check — alreadySafe + needBackup must always equal phoneTotal
|
||||||
|
let inv = updated.alreadySafe + updated.needBackup
|
||||||
|
if inv != updated.phoneTotal {
|
||||||
|
log.error("Invariant broken: alreadySafe(\(updated.alreadySafe)) + needBackup(\(updated.needBackup)) = \(inv) ≠ phoneTotal(\(updated.phoneTotal))")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Builds a ManifestIndex from NAS.
|
/// Builds a ManifestIndex from NAS.
|
||||||
@@ -250,8 +256,10 @@ final class BackupStatusService: NSObject, ObservableObject {
|
|||||||
path: String,
|
path: String,
|
||||||
conn: NASConnection
|
conn: NASConnection
|
||||||
) async -> ManifestIndex {
|
) async -> ManifestIndex {
|
||||||
|
log.info("buildManifestIndex: remotePath=\(conn.remotePath, privacy: .public) manifestFile=\(path, privacy: .public)")
|
||||||
do {
|
do {
|
||||||
let data = try await transfer.downloadData(at: path)
|
let data = try await transfer.downloadData(at: path)
|
||||||
|
log.info("buildManifestIndex: manifest downloaded — \(data.count) bytes")
|
||||||
// Decode and build index off main actor — JSONDecoder is not cheap for large manifests.
|
// Decode and build index off main actor — JSONDecoder is not cheap for large manifests.
|
||||||
let decoded: (ManifestIndex, BackupManifest)? = await Task.detached(priority: .utility) {
|
let decoded: (ManifestIndex, BackupManifest)? = await Task.detached(priority: .utility) {
|
||||||
guard let manifest = try? JSONDecoder.kisani.decode(BackupManifest.self, from: data)
|
guard let manifest = try? JSONDecoder.kisani.decode(BackupManifest.self, from: data)
|
||||||
@@ -259,6 +267,7 @@ final class BackupStatusService: NSObject, ObservableObject {
|
|||||||
return (ManifestIndex(manifest: manifest), manifest)
|
return (ManifestIndex(manifest: manifest), manifest)
|
||||||
}.value
|
}.value
|
||||||
if let (index, manifest) = decoded {
|
if let (index, manifest) = decoded {
|
||||||
|
log.info("buildManifestIndex: decoded \(index.totalCount) entries (isFilenameOnly=\(index.isFilenameOnly))")
|
||||||
lastManifest = manifest
|
lastManifest = manifest
|
||||||
Task { await NASManifestCache.shared.update(manifest) }
|
Task { await NASManifestCache.shared.update(manifest) }
|
||||||
|
|
||||||
@@ -270,20 +279,21 @@ final class BackupStatusService: NSObject, ObservableObject {
|
|||||||
let dirCount = items.filter {
|
let dirCount = items.filter {
|
||||||
!$0.isDirectory && !$0.name.hasPrefix(".") && $0.name != BackupManifest.remoteFilename
|
!$0.isDirectory && !$0.name.hasPrefix(".") && $0.name != BackupManifest.remoteFilename
|
||||||
}.count
|
}.count
|
||||||
log.info("buildManifestIndex: manifest=\(index.totalCount) dir=\(dirCount) — using max for nasArchiveTotal")
|
log.info("buildManifestIndex: validity — manifest=\(index.totalCount) dir=\(dirCount) — using max")
|
||||||
if dirCount > index.totalCount {
|
if dirCount > index.totalCount {
|
||||||
return ManifestIndex(manifest: manifest, overrideTotalCount: dirCount)
|
return ManifestIndex(manifest: manifest, overrideTotalCount: dirCount)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return index
|
return index
|
||||||
}
|
}
|
||||||
|
log.warning("buildManifestIndex: manifest downloaded but failed to decode (\(data.count) bytes)")
|
||||||
} catch {
|
} catch {
|
||||||
// File not found — fall through to directory listing bootstrap
|
log.info("buildManifestIndex: manifest not found at \(path, privacy: .public) — \(error.localizedDescription, privacy: .public) — falling back to directory listing")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bootstrap: list NAS directory and build filename-only index in background.
|
// Bootstrap: list NAS directory and build filename-only index in background.
|
||||||
let items = (try? await transfer.listDirectory(at: conn.remotePath)) ?? []
|
let items = (try? await transfer.listDirectory(at: conn.remotePath)) ?? []
|
||||||
log.info("Manifest not found — bootstrapping from \(items.count) NAS items")
|
log.info("buildManifestIndex: bootstrap from \(items.count) items in \(conn.remotePath, privacy: .public)")
|
||||||
return await Task.detached(priority: .utility) {
|
return await Task.detached(priority: .utility) {
|
||||||
ManifestIndex(nasListing: items)
|
ManifestIndex(nasListing: items)
|
||||||
}.value
|
}.value
|
||||||
|
|||||||
Reference in New Issue
Block a user