Major update for stability
This commit is contained in:
@@ -251,6 +251,10 @@ struct JobDetailView: View {
|
||||
Binding(
|
||||
get: { job.scheduleEnabled },
|
||||
set: { enabled in
|
||||
if enabled && job.deleteExtraneous && !job.allowUnattendedDelete {
|
||||
errorMessage = "This job uses --delete. Edit the job and explicitly enable unattended delete before turning on scheduling."
|
||||
return
|
||||
}
|
||||
job.scheduleEnabled = enabled
|
||||
if enabled {
|
||||
if job.scheduleKind == .manual { job.scheduleKind = .daily }
|
||||
@@ -268,6 +272,10 @@ struct JobDetailView: View {
|
||||
Binding(
|
||||
get: { job.watchEnabled },
|
||||
set: { enabled in
|
||||
if enabled && job.deleteExtraneous && !job.allowUnattendedDelete {
|
||||
errorMessage = "This job uses --delete. Edit the job and explicitly enable unattended delete before turning on file watching."
|
||||
return
|
||||
}
|
||||
job.watchEnabled = enabled
|
||||
if enabled && job.watchPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
job.watchPath = job.sourcePath
|
||||
@@ -284,8 +292,12 @@ struct JobDetailView: View {
|
||||
|
||||
private func persistAutomationChange() {
|
||||
job.updatedAt = Date()
|
||||
try? modelContext.save()
|
||||
NotificationCenter.default.post(name: .syncJobsChanged, object: nil)
|
||||
do {
|
||||
try modelContext.save()
|
||||
NotificationCenter.default.post(name: .syncJobsChanged, object: nil)
|
||||
} catch {
|
||||
errorMessage = "Could not save automation settings: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
@@ -316,7 +328,12 @@ struct JobDetailView: View {
|
||||
let command = RsyncCommandBuilder.displayCommand(job: job, dryRun: dryRun, executable: executable)
|
||||
let run = SyncRun(job: job, command: command, wasDryRun: dryRun, trigger: .manual)
|
||||
modelContext.insert(run)
|
||||
try? modelContext.save()
|
||||
do {
|
||||
try modelContext.save()
|
||||
} catch {
|
||||
errorMessage = "Could not create run history: \(error.localizedDescription)"
|
||||
return
|
||||
}
|
||||
|
||||
if presentation.showsModal {
|
||||
presentedRun = run
|
||||
@@ -325,23 +342,25 @@ struct JobDetailView: View {
|
||||
|
||||
Task {
|
||||
do {
|
||||
let result = try await runner.run(job: job, dryRun: dryRun)
|
||||
run.output = result.stdout
|
||||
run.errorOutput = result.stderr
|
||||
let result = try await runner.run(job: job, dryRun: dryRun, trigger: .manual)
|
||||
run.output = SyncRun.sanitizedLog(result.stdout)
|
||||
run.errorOutput = SyncRun.sanitizedLog(result.stderr)
|
||||
run.exitCode = Int(result.exitCode)
|
||||
run.wasCancelled = result.wasCancelled
|
||||
run.finishedAt = Date()
|
||||
job.lastRunAt = run.finishedAt
|
||||
job.lastExitCode = Int(result.exitCode)
|
||||
job.updatedAt = Date()
|
||||
try? modelContext.save()
|
||||
do { try modelContext.save() }
|
||||
catch { errorMessage = "Could not save run history: \(error.localizedDescription)" }
|
||||
} catch {
|
||||
run.errorOutput = error.localizedDescription
|
||||
run.errorOutput = SyncRun.sanitizedLog(error.localizedDescription)
|
||||
run.exitCode = -1
|
||||
run.finishedAt = Date()
|
||||
job.lastRunAt = run.finishedAt
|
||||
job.lastExitCode = -1
|
||||
try? modelContext.save()
|
||||
do { try modelContext.save() }
|
||||
catch { errorMessage = "Could not save failed run history: \(error.localizedDescription)" }
|
||||
|
||||
if !presentation.showsModal {
|
||||
errorMessage = error.localizedDescription
|
||||
|
||||
@@ -38,6 +38,8 @@ struct JobEditorView: View {
|
||||
@State private var watchEnabled: Bool
|
||||
@State private var watchPath: String
|
||||
@State private var watchDebounceSeconds: Int
|
||||
@State private var allowUnattendedDelete: Bool
|
||||
@State private var validationMessage: String?
|
||||
|
||||
init(job: SyncJob? = nil, initialSection: JobEditorSection? = nil) {
|
||||
self.existingJob = job
|
||||
@@ -68,6 +70,8 @@ struct JobEditorView: View {
|
||||
_watchPath = State(initialValue: job?.watchPath ?? "")
|
||||
let defaultDebounce = UserDefaults.standard.integer(forKey: "defaultWatchDebounceSeconds")
|
||||
_watchDebounceSeconds = State(initialValue: job?.watchDebounceSeconds ?? (defaultDebounce == 0 ? 3 : defaultDebounce))
|
||||
_allowUnattendedDelete = State(initialValue: job?.allowUnattendedDelete ?? false)
|
||||
_validationMessage = State(initialValue: nil)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -174,6 +178,12 @@ struct JobEditorView: View {
|
||||
Toggle("Archive mode (-a)", isOn: $archive)
|
||||
Toggle("Compress during transfer (-z)", isOn: $compress)
|
||||
Toggle("Delete files missing from source (--delete)", isOn: $deleteExtraneous)
|
||||
if deleteExtraneous && (scheduleEnabled || watchEnabled) {
|
||||
Toggle("Allow unattended delete", isOn: $allowUnattendedDelete)
|
||||
Text("Required before scheduled or file-watch runs may use --delete. Enable only after validating the job with Dry Run.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
Toggle("Preserve permissions", isOn: $preservePermissions)
|
||||
Toggle("Preserve extended attributes", isOn: $preserveExtendedAttributes)
|
||||
Toggle("Show transfer progress", isOn: $showProgress)
|
||||
@@ -191,6 +201,9 @@ struct JobEditorView: View {
|
||||
Section("Advanced") {
|
||||
TextField("Additional rsync arguments", text: $additionalArguments)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
Text("Transport, password, remote-command and destructive options are blocked here. Use the dedicated controls above for SSH and --delete.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
@@ -213,6 +226,14 @@ struct JobEditorView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.alert("Invalid Sync Job", isPresented: Binding(
|
||||
get: { validationMessage != nil },
|
||||
set: { if !$0 { validationMessage = nil } }
|
||||
)) {
|
||||
Button("OK", role: .cancel) { validationMessage = nil }
|
||||
} message: {
|
||||
Text(validationMessage ?? "The job configuration is invalid.")
|
||||
}
|
||||
.frame(width: 690, height: 820)
|
||||
}
|
||||
|
||||
@@ -262,10 +283,22 @@ struct JobEditorView: View {
|
||||
job.watchEnabled = watchEnabled
|
||||
job.watchPath = watchPath
|
||||
job.watchDebounceSeconds = watchDebounceSeconds
|
||||
job.allowUnattendedDelete = deleteExtraneous ? allowUnattendedDelete : false
|
||||
job.updatedAt = Date()
|
||||
if existingJob == nil { modelContext.insert(job) }
|
||||
try? modelContext.save()
|
||||
NotificationCenter.default.post(name: .syncJobsChanged, object: nil)
|
||||
dismiss()
|
||||
|
||||
let validationTrigger: SyncRunTrigger = (job.scheduleEnabled || job.watchEnabled) ? .scheduled : .manual
|
||||
if let message = RsyncSecurityValidator.validationMessage(for: job, trigger: validationTrigger) {
|
||||
validationMessage = message
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
if existingJob == nil { modelContext.insert(job) }
|
||||
try modelContext.save()
|
||||
NotificationCenter.default.post(name: .syncJobsChanged, object: nil)
|
||||
dismiss()
|
||||
} catch {
|
||||
validationMessage = "Could not save the job: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ struct JobListView: View {
|
||||
@Binding var showingNewJob: Bool
|
||||
@State private var searchText = ""
|
||||
@State private var filter: JobFilter = .all
|
||||
@State private var persistenceError: String?
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
@@ -50,6 +51,14 @@ struct JobListView: View {
|
||||
.listStyle(.sidebar)
|
||||
}
|
||||
}
|
||||
.alert("Database Error", isPresented: Binding(
|
||||
get: { persistenceError != nil },
|
||||
set: { if !$0 { persistenceError = nil } }
|
||||
)) {
|
||||
Button("OK", role: .cancel) { persistenceError = nil }
|
||||
} message: {
|
||||
Text(persistenceError ?? "Unable to save job changes.")
|
||||
}
|
||||
}
|
||||
|
||||
private var dashboardHeader: some View {
|
||||
@@ -198,6 +207,7 @@ struct JobListView: View {
|
||||
copy.watchEnabled = job.watchEnabled
|
||||
copy.watchPath = job.watchPath
|
||||
copy.watchDebounceSeconds = job.watchDebounceSeconds
|
||||
copy.allowUnattendedDelete = job.allowUnattendedDelete
|
||||
modelContext.insert(copy)
|
||||
saveAndRefresh()
|
||||
selection = copy
|
||||
@@ -210,8 +220,12 @@ struct JobListView: View {
|
||||
}
|
||||
|
||||
private func saveAndRefresh() {
|
||||
try? modelContext.save()
|
||||
NotificationCenter.default.post(name: .syncJobsChanged, object: nil)
|
||||
do {
|
||||
try modelContext.save()
|
||||
NotificationCenter.default.post(name: .syncJobsChanged, object: nil)
|
||||
} catch {
|
||||
persistenceError = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ struct RunHistoryView: View {
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
@Query(sort: \SyncRun.startedAt, order: .reverse) private var runs: [SyncRun]
|
||||
@State private var selection: SyncRun?
|
||||
@State private var persistenceError: String?
|
||||
let onBack: () -> Void
|
||||
|
||||
var body: some View {
|
||||
@@ -24,7 +25,7 @@ struct RunHistoryView: View {
|
||||
Button("Clear", role: .destructive) {
|
||||
runs.forEach(modelContext.delete)
|
||||
selection = nil
|
||||
try? modelContext.save()
|
||||
saveHistoryChanges()
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
@@ -79,7 +80,7 @@ struct RunHistoryView: View {
|
||||
Button("Delete Log", role: .destructive) {
|
||||
if selection?.id == run.id { selection = nil }
|
||||
modelContext.delete(run)
|
||||
try? modelContext.save()
|
||||
saveHistoryChanges()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -103,6 +104,19 @@ struct RunHistoryView: View {
|
||||
.frame(minWidth: 620, maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.alert("Database Error", isPresented: Binding(
|
||||
get: { persistenceError != nil },
|
||||
set: { if !$0 { persistenceError = nil } }
|
||||
)) {
|
||||
Button("OK", role: .cancel) { persistenceError = nil }
|
||||
} message: {
|
||||
Text(persistenceError ?? "Unable to update run history.")
|
||||
}
|
||||
}
|
||||
|
||||
private func saveHistoryChanges() {
|
||||
do { try modelContext.save() }
|
||||
catch { persistenceError = error.localizedDescription }
|
||||
}
|
||||
|
||||
private func statusIcon(_ run: SyncRun) -> String {
|
||||
|
||||
Reference in New Issue
Block a user