151 lines
5.7 KiB
Swift
151 lines
5.7 KiB
Swift
import Foundation
|
|
import SwiftData
|
|
|
|
@Model
|
|
final class SyncRun {
|
|
var id: UUID
|
|
var jobID: UUID
|
|
var jobName: String
|
|
var startedAt: Date
|
|
var finishedAt: Date?
|
|
var command: String
|
|
var output: String
|
|
var errorOutput: String
|
|
var exitCode: Int?
|
|
var wasDryRun: Bool
|
|
var wasCancelled: Bool
|
|
var triggerRaw: String = SyncRunTrigger.manual.rawValue
|
|
|
|
init(job: SyncJob, command: String, wasDryRun: Bool, trigger: SyncRunTrigger = .manual) {
|
|
self.id = UUID()
|
|
self.jobID = job.id
|
|
self.jobName = job.name
|
|
self.startedAt = Date()
|
|
self.command = command
|
|
self.output = ""
|
|
self.errorOutput = ""
|
|
self.exitCode = nil
|
|
self.wasDryRun = wasDryRun
|
|
self.wasCancelled = false
|
|
self.triggerRaw = trigger.rawValue
|
|
}
|
|
|
|
var trigger: SyncRunTrigger { SyncRunTrigger(rawValue: triggerRaw) ?? .manual }
|
|
var succeeded: Bool { exitCode == 0 && !wasCancelled }
|
|
var isRunning: Bool { finishedAt == nil }
|
|
|
|
var duration: TimeInterval? {
|
|
guard let finishedAt else { return nil }
|
|
return finishedAt.timeIntervalSince(startedAt)
|
|
}
|
|
|
|
var summary: RsyncSummary {
|
|
RsyncSummary.parse(output + "\n" + errorOutput)
|
|
}
|
|
|
|
|
|
static func sanitizedLog(_ value: String, maxBytes: Int = 2_000_000) -> String {
|
|
let stripped = stripANSI(value)
|
|
let redacted = stripped
|
|
.replacingOccurrences(of: #"(?i)--password-file(?:=|\s+)[^\s]+"#, with: "--password-file=[REDACTED]", options: .regularExpression)
|
|
.replacingOccurrences(of: #"(?i)(password|passwd|token|secret)=([^\s]+)"#, with: "$1=[REDACTED]", options: .regularExpression)
|
|
|
|
let data = Data(redacted.utf8)
|
|
guard data.count > maxBytes else { return redacted }
|
|
let prefix = data.prefix(maxBytes)
|
|
return String(decoding: prefix, as: UTF8.self) + "\n\n[Log truncated by Rsync Studio for safety and storage limits.]\n"
|
|
}
|
|
|
|
private static func stripANSI(_ value: String) -> String {
|
|
value.replacingOccurrences(
|
|
of: #"\u001B\[[0-9;?]*[ -/]*[@-~]"#,
|
|
with: "",
|
|
options: .regularExpression
|
|
)
|
|
}
|
|
|
|
var exportText: String {
|
|
var text = "Rsync Studio Run Log\n"
|
|
text += "Job: \(jobName)\n"
|
|
text += "Started: \(startedAt.formatted(date: .complete, time: .standard))\n"
|
|
if let finishedAt { text += "Finished: \(finishedAt.formatted(date: .complete, time: .standard))\n" }
|
|
text += "Trigger: \(trigger.title)\n"
|
|
text += "Dry run: \(wasDryRun ? "Yes" : "No")\n"
|
|
text += "\nCommand\n-------\n$ \(command)\n\nOutput\n------\n"
|
|
if !output.isEmpty { text += output }
|
|
if !errorOutput.isEmpty {
|
|
if !text.hasSuffix("\n") { text += "\n" }
|
|
text += "\n--- stderr ---\n\(errorOutput)"
|
|
}
|
|
if let exitCode {
|
|
if !text.hasSuffix("\n") { text += "\n" }
|
|
text += "\nExit code: \(exitCode)\n"
|
|
}
|
|
return text
|
|
}
|
|
}
|
|
|
|
struct RsyncSummary: Equatable {
|
|
var numberOfFiles: String?
|
|
var numberOfFilesTransferred: String?
|
|
var totalFileSize: String?
|
|
var totalTransferredFileSize: String?
|
|
var unmatchedData: String?
|
|
var matchedData: String?
|
|
var fileListSize: String?
|
|
var fileListGenerationTime: String?
|
|
var fileListTransferTime: String?
|
|
var totalSent: String?
|
|
var totalReceived: String?
|
|
var transferRate: String?
|
|
var totalSizeAndSpeedup: String?
|
|
|
|
var hasStatistics: Bool {
|
|
[
|
|
numberOfFiles,
|
|
numberOfFilesTransferred,
|
|
totalFileSize,
|
|
totalTransferredFileSize,
|
|
totalSent,
|
|
totalReceived
|
|
].contains { $0 != nil }
|
|
}
|
|
|
|
static func parse(_ text: String) -> RsyncSummary {
|
|
var result = RsyncSummary()
|
|
|
|
for rawLine in text.components(separatedBy: .newlines) {
|
|
let line = rawLine.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !line.isEmpty else { continue }
|
|
|
|
func value(after prefix: String) -> String? {
|
|
guard line.hasPrefix(prefix) else { return nil }
|
|
return String(line.dropFirst(prefix.count)).trimmingCharacters(in: .whitespaces)
|
|
}
|
|
|
|
if let value = value(after: "Number of files:") { result.numberOfFiles = value; continue }
|
|
if let value = value(after: "Number of files transferred:") { result.numberOfFilesTransferred = value; continue }
|
|
if let value = value(after: "Total file size:") { result.totalFileSize = value; continue }
|
|
if let value = value(after: "Total transferred file size:") { result.totalTransferredFileSize = value; continue }
|
|
if let value = value(after: "Unmatched data:") { result.unmatchedData = value; continue }
|
|
if let value = value(after: "Matched data:") { result.matchedData = value; continue }
|
|
if let value = value(after: "File list size:") { result.fileListSize = value; continue }
|
|
if let value = value(after: "File list generation time:") { result.fileListGenerationTime = value; continue }
|
|
if let value = value(after: "File list transfer time:") { result.fileListTransferTime = value; continue }
|
|
if let value = value(after: "Total sent:") { result.totalSent = value; continue }
|
|
if let value = value(after: "Total received:") { result.totalReceived = value; continue }
|
|
|
|
if line.hasPrefix("sent "), line.contains(" bytes/sec") {
|
|
result.transferRate = line
|
|
continue
|
|
}
|
|
|
|
if line.hasPrefix("total size is "), line.contains("speedup is") {
|
|
result.totalSizeAndSpeedup = line
|
|
}
|
|
}
|
|
|
|
return result
|
|
}
|
|
}
|