Files

151 lines
5.7 KiB
Swift
Executable File

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
}
}