Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,14 @@ The `Wysylka:` line is the same verdict the app window shows, computed in one
place so the two can never disagree. When it is not nominal it prints a second
line saying why, and whether it clears on its own.

It has three kinds of answer, not two. Besides "fine" and "broken" there is
**"unknown"** — printed when rclone does not answer the question about its
queue. That third state exists because of a specific lie: the queue read used
to time out, the caller substituted zeros for the missing numbers, and both the
CLI and the app then announced *Wszystko wysłane na Google Drive* while 386
bands sat unsent. A verdict computed from numbers nobody measured is worse than
no verdict, so now it says so.

Five launchd agents keep it alive, all running code from inside the app:

| Agent | Job |
Expand Down
48 changes: 44 additions & 4 deletions mac-app/Sources/CloudMachineAgent/DriveCommands.swift
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,27 @@ struct AttachImage: AsyncParsableCommand {
}
let result = await BackupImageService.attach()
print(result.message)
if !result.succeeded { throw ExitCode(1) }

// Trzy przypadki, nie dwa. To polecenie chodzi pod launchd co 900 s, wiec
// jego kod wyjscia jest zapisem w `launchd-gdrive-attach.err.log` - tam,
// gdzie czlowiek patrzy, pytajac "czy backup dziala". "Obraz zajety przez
// odpinanie, ktore wlasnie trwa" NIE JEST awaria: nastepny tik za 15 minut
// zastanie juz wolny obraz i podepnie. Zapisywanie tego jako bledu to ten
// sam wzorzec, ktory ten kod tepi w druga strone - stan normalny czytany
// jako awaria, zamiast braku odpowiedzi czytanego jako odpowiedz.
//
// Rozstrzyga TYP wyniku (`CMActionResult.Disposition`), nie tresc
// komunikatu - dopasowanie do tekstu psuje sie przy pierwszej zmianie
// zdania i nikt tego nie zauwaza. `switch` jest wyczerpujacy, wiec nowy
// przypadek nie przejdzie tedy po cichu.
switch result.disposition {
case .ok:
break
case .skipped:
print("Nie jest to blad - nastepny przebieg agenta sprobuje ponownie.")
case .failed:
throw ExitCode(1)
}
}
}

Expand Down Expand Up @@ -275,10 +295,15 @@ struct DriveStatus: AsyncParsableCommand {
print(
"Narzedzia: \(readiness.ready ? "OK" : "brakuje: " + readiness.missing.joined(separator: ", "))"
)
print("Montowanie Drive: \(DriveBufferService.isMounted ? "OK" : "BRAK")")
let mounted = DriveBufferService.mountedState()
print("Montowanie Drive: \(StatusLines.mounted(mounted))")
print("Obraz podpiety: \(BackupImageService.describe(BackupImageService.attachment))")
print("Bufor: \(BufferGuardService.bufferGB()) GB z \(DriveBufferService.cacheSize)")
print("Wolne na dysku: \(BufferGuardService.freeGB()) GB")
// NIE `\(BufferGuardService.freeGB()) GB` - to zwraca `Int?`, odkad brak
// pomiaru przestal udawac zero, a interpolacja opcjonalnej wartosci
// wypisywala `Wolne na dysku: Optional(427) GB`. Kompilator mowil o tym
// tylko ostrzezeniem, wiec nie zatrzymalo to ani builda, ani testow.
print("Wolne na dysku: \(StatusLines.freeDisk(BufferGuardService.freeGB()))")

let queueStats = await DriveBufferService.queueStats()
if let stats = queueStats {
Expand All @@ -296,7 +321,13 @@ struct DriveStatus: AsyncParsableCommand {
// Ta sama odpowiedz, co na karcie w interfejsie - jedno zrodlo, zeby CLI
// i GUI nie mogly twierdzic czegos innego o tym samym stanie.
let upload = UploadState.from(
mounted: DriveBufferService.isMounted,
// `UploadState` nie ma stanu "nie wiadomo, czy zamontowane", a dolozenie
// go dotknelo by plikow poza moim zakresem. `?? false` daje wtedy
// `.mountDown` ("Wysylka nie dziala") - czyli ostrzega, zamiast
// uspokajac, a wiersz "Montowanie Drive" wyzej mowi juz wprost
// "NIE WIADOMO". Falszywy alarm jest tu wlasciwym kierunkiem pomylki.
mounted: mounted ?? false,
queueKnown: queueStats != nil,
queued: queueStats?.uploadsQueued ?? 0,
inProgress: queueStats?.uploadsInProgress ?? 0,
failedFiles: queueStats?.erroredFiles ?? 0,
Expand All @@ -321,6 +352,15 @@ struct DriveStatus: AsyncParsableCommand {
} else {
print("Backup: nie trwa")
}

// Na samym koncu i bez wyrownania do kolumny - to nie jest kolejny wiersz
// stanu, tylko cos, co ma zaklocic czytanie. `HealthAlert` od niedawna nie
// zamyka sprawy znacznikiem, dopoki powiadomienie nie doszlo, wiec
// niedoreczony alarm nie ginie juz na 12 h - ale bez tego bloku nikt by sie
// o nim nie dowiedzial, bo powiadomienia systemowego z definicji nie widac.
for line in StatusLines.undeliveredAlert(HealthAlert.lastDeliveryFailure()) {
print(line)
}
}
}

Expand Down
79 changes: 77 additions & 2 deletions mac-app/Sources/CloudMachineApp/Models/AppStatus.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,22 @@ struct BufferStatus: Equatable {
var mounted: Bool = false
var imageAttached: Bool = false
var sizeGB: Int = 0
var freeDiskGB: Int = 0
/// `nil` = pomiaru NIE BYLO (statfs zawiodl), a nie "zero gigabajtow" -
/// patrz `BufferGuardService.freeGB()`. To samo rozroznienie, co
/// `queueKnown` nizej.
var freeDiskGB: Int?
/// Ile plikow czeka na wyslanie. Ta liczba jest wazniejsza od rozmiaru
/// bufora: jesli rosnie i nie wraca do zera miedzy backupami, wysylka nie
/// nadaza za zapisem.
var uploadsQueued: Int = 0
var uploadsInProgress: Int = 0
/// Czy powyzsze liczniki w ogole pochodza z odczytu.
///
/// Domyslnie `false` i to jest wazniejsze niz wyglada: swiezo utworzony
/// `BufferStatus` ma same zera, ktore nie sa pomiarem. Domyslne `true`
/// znaczyloby "pusta kolejka" i pasek menu swiecilby na zielono, zanim
/// cokolwiek zostalo sprawdzone.
var queueKnown: Bool = false
var erroredFiles: Int = 0
/// Na Google Drive nie ma miejsca. NIE minie samo.
var driveFull: Bool = false
Expand All @@ -46,6 +56,7 @@ struct BufferStatus: Equatable {
var uploadState: UploadState {
UploadState.from(
mounted: mounted,
queueKnown: queueKnown,
queued: uploadsQueued,
inProgress: uploadsInProgress,
failedFiles: erroredFiles,
Expand All @@ -55,6 +66,52 @@ struct BufferStatus: Equatable {
}
}

/// Czy cykl backupu NADAL dziala - mierzone data ostatniej UDANEJ kopii.
///
/// Do 23.09.2026 interfejs nie zadawal tego pytania ani razu: `grep -rn
/// "BackupHealth" Sources/CloudMachineApp/` nie dawal ani jednego trafienia.
/// Panel liczyl zdrowie wylacznie ze stanu URZADZEN - montowanie, obraz, cel
/// Time Machine, kolejka - czyli ze stanu CHWILOWEGO. Awaria opisana
/// w naglowku `BackupHealth` jako najgrozniejsza wyglada dokladnie odwrotnie:
/// wszystko zamontowane, obraz podpiety, kolejka pusta, a Time Machine od
/// dwoch dni nie dokonczyl kopii. Panel swiecil wtedy "Sprawny / Gotowe".
struct BackupCycleStatus: Equatable {
/// Czy udalo sie w ogole odczytac preferencje Time Machine.
///
/// Domyslnie `false` i to jest wazniejsze, niz wyglada - tak samo jak przy
/// `queueKnown`: swiezo utworzony stan nie jest pomiarem, a brak Pelnego
/// dostepu do dysku (najczestsza przyczyna nieczytelnego pliku preferencji)
/// nie moze uchodzic za brak problemu.
var known: Bool = false
/// Data ostatniej ZAKONCZONEJ kopii. `nil` = nie ma ani jednej.
var lastSuccess: Date?
/// Gotowe zdania z `BackupHealth.Report` - do pokazania bez tlumaczenia.
var problems: [String] = []
/// Kiedy ostatnio pytalismy (czujka chodzi rzadziej niz odswiezanie panelu).
var checkedAt: Date?

func age(now: Date = Date()) -> TimeInterval? {
lastSuccess.map { now.timeIntervalSince($0) }
}

/// Czy ostatnia UDANA kopia jest dostatecznie swieza.
///
/// Brak odczytu i brak kopii daja `false` - jedno i drugie znaczy, ze nikt
/// nie potwierdzil, ze backup dziala, a zielony znaczek jest wlasnie takim
/// potwierdzeniem.
func isFresh(now: Date = Date(), maxAgeHours: Double = BackupHealth.maxAgeHours) -> Bool {
guard known, let age = age(now: now) else { return false }
return age <= maxAgeHours * 3600
}

/// Wiek slowami, do wiersza w panelu.
func ageText(now: Date = Date()) -> String {
guard known else { return "nie sprawdzono" }
guard let age = age(now: now) else { return "ani jednej" }
return "\(BackupHealth.formatAge(age)) temu"
}
}

struct LastRunResult: Equatable {
var succeeded: Bool
var message: String
Expand All @@ -80,6 +137,9 @@ final class AppStatus: ObservableObject {
@Published var dependencyState: DependencyState = .unknown
@Published var remoteConfigured: Bool = false
@Published var buffer = BufferStatus()
/// Odpowiedz na pytanie "kiedy ostatnio powstala KOPIA" - jedyna miara,
/// ktora rosnie wylacznie przy sukcesie.
@Published var backupCycle = BackupCycleStatus()
@Published var timeMachineState: TimeMachineState = .unknown
@Published var backupProgress: BackupProgressInfo?
@Published var lastAction: LastRunResult?
Expand Down Expand Up @@ -108,6 +168,14 @@ final class AppStatus: ObservableObject {
let upload = buffer.uploadState
if !upload.isNominal { return upload.headline }
if backupProgress != nil { return "Backup w toku" }
// Stan urzadzen moze byc nienaganny, a kopii moze nie byc od dwoch dni.
// To zdanie musi paść PRZED "Gotowe", bo inaczej naglowek zaprzecza
// znaczkowi obok (healthy = false, a napis "Gotowe").
if !backupCycle.isFresh() {
guard backupCycle.known else { return "Nie wiadomo, kiedy powstała ostatnia kopia" }
guard let age = backupCycle.age() else { return "Nie ma ani jednej ukończonej kopii" }
return "Brak ukończonej kopii od \(BackupHealth.formatAge(age))"
}
if upload.isMovingData { return upload.headline }
return "Gotowe"
}
Expand All @@ -118,9 +186,16 @@ final class AppStatus: ObservableObject {
/// pokazywal zielony znaczek i "Gotowe", podczas gdy czesc pasm obrazu nigdy
/// nie doleciala na Dysk - a taka kopia moze sie nie otworzyc. Zepsute
/// wygladalo dokladnie tak samo jak sprawne.
///
/// UWAGA DRUGA, z 23.09.2026: `backupCycle` MUSI tu byc z tego samego
/// powodu. Wszystkie pozostale warunki opisuja stan URZADZEN w tej chwili
/// i kazdy z nich moze byc spelniony, gdy od dwoch dni nie powstala zadna
/// kopia. Zielony znaczek ma znaczyc "dane sa bezpieczne", a to wynika
/// wylacznie z tego, ze kopia POWSTALA - nie z tego, ze dysk jest podpiety.
var healthy: Bool {
guard case .ready = dependencyState, remoteConfigured, buffer.mounted, buffer.imageAttached,
case .registered = timeMachineState, buffer.uploadState.isNominal
case .registered = timeMachineState, buffer.uploadState.isNominal,
backupCycle.isFresh()
else { return false }
return true
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,16 @@ final class CloudMachineController: ObservableObject {
private var lastBytesDone: Double?
private var lastBytesSampledAt: Date?

/// Co ile odswiezamy odpowiedz na pytanie "kiedy ostatnio powstala kopia".
///
/// Rzadziej niz reszta panelu (10 s) i celowo: `BackupHealth.currentReport()`
/// czyta plik preferencji Time Machine, pyta rclone o pojemnosc Dysku
/// i tmutil o cel backupu - to sekundy pracy, nie mikrosekundy. Cykl
/// backupu jest godzinowy, wiec odpowiedz sprzed pieciu minut jest tak samo
/// dobra jak sprzed pieciu sekund.
private static let backupCycleInterval: TimeInterval = 300
private var backupCycleCheckedAt: Date?

// MARK: - Cykl odswiezania

func startAutoRefresh(interval: TimeInterval = 10) {
Expand All @@ -40,9 +50,38 @@ final class CloudMachineController: ObservableObject {
await refreshBuffer()
await refreshTimeMachine()
await refreshProgress()
await refreshBackupCycle()
status.lastRefresh = Date()
}

/// Wiek ostatniej UDANEJ kopii.
///
/// Panel nie zadawal tego pytania ani razu (patrz `BackupCycleStatus`),
/// wiec awaria "wszystko podpiete, a kopii nie ma od dwoch dni" wygladala
/// w nim dokladnie tak samo jak sprawny system. Zrodlo jest to samo, z
/// ktorego korzysta czujka `backup-health` - jedno zrodlo prawdy, zeby
/// panel i czujka nie mogly twierdzic czegos innego o tym samym.
private func refreshBackupCycle(force: Bool = false) async {
if !force, let at = backupCycleCheckedAt,
Date().timeIntervalSince(at) < Self.backupCycleInterval
{
return
}
backupCycleCheckedAt = Date()

let report = await BackupHealth.currentReport()
var cycle = BackupCycleStatus()
// "Odczytano" znaczy tu: plik preferencji dalo sie przeczytac. Gdy sie
// nie da (najczesciej brak Pelnego dostepu do dysku), `currentReport`
// zglasza to jako problem i NIE podaje zadnej daty - wtedy `known`
// zostaje `false`, a nie udaje, ze kopii po prostu nie ma.
cycle.known = report.preferencesReadable
cycle.lastSuccess = report.lastSuccess
cycle.problems = report.problems.map(\.summary)
cycle.checkedAt = Date()
status.backupCycle = cycle
}

// MARK: - Poszczegolne odczyty

private func refreshDependencies() async {
Expand Down Expand Up @@ -94,6 +133,9 @@ final class CloudMachineController: ObservableObject {
// naprawic, limit dobowy mija sam.
buffer.driveFull = DriveBufferService.hitStorageQuota()
buffer.dailyQuotaExhausted = DriveBufferService.uploadStalled()
// Rozroznienie "odczytano" od "wyszlo zero" - bez tego brak odpowiedzi od
// rclone wygladal na pusta kolejke.
buffer.queueKnown = stats != nil
if let stats {
buffer.uploadsQueued = stats.uploadsQueued
buffer.uploadsInProgress = stats.uploadsInProgress
Expand Down
66 changes: 47 additions & 19 deletions mac-app/Sources/CloudMachineApp/Views/DashboardView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -200,21 +200,34 @@ struct DashboardView: View {
StatCard(
title: "Rozmiar Bufora",
value: "\(controller.status.buffer.sizeGB) GB",
subtitle: "Wolne na dysku: \(controller.status.buffer.freeDiskGB) GB",
// Brak pomiaru ma wygladac inaczej niz liczba - patrz
// `BufferGuardService.freeGB()`. Samo wstawienie opcjonalnej wartosci
// do tekstu dalo "Wolne na dysku: Optional(427) GB" i kompilator
// zglaszal to TYLKO jako ostrzezenie, wiec zaden test by tego nie zlapal.
subtitle:
"Wolne na dysku: \(controller.status.buffer.freeDiskGB.map { "\($0) GB" } ?? "nie zmierzono")",
systemImage: "internaldrive.fill",
iconColor: RenaCodeTheme.colorCyan
)

StatCard(
title: "Kolejka Wysyłki",
value: controller.status.buffer.draining
? "\(controller.status.buffer.uploadsQueued) w kolejce" : "Brak zaległości",
subtitle: controller.status.buffer.draining
? "\(controller.status.buffer.uploadsInProgress) transferów w toku"
: "Wszystko w chmurze",
// Bez odczytu kolejki ta karta nie ma prawa powiedziec "Brak
// zaleglosci" - zera sa wtedy brakiem pomiaru, nie wynikiem.
value: !controller.status.buffer.queueKnown
? "—"
: (controller.status.buffer.draining
? "\(controller.status.buffer.uploadsQueued) w kolejce" : "Brak zaległości"),
subtitle: !controller.status.buffer.queueKnown
? "rclone nie odpowiedział"
: (controller.status.buffer.draining
? "\(controller.status.buffer.uploadsInProgress) transferów w toku"
: "Wszystko w chmurze"),
systemImage: "icloud.and.arrow.up.fill",
iconColor: controller.status.buffer.draining
? RenaCodeTheme.colorWarning : RenaCodeTheme.colorSuccess
iconColor: !controller.status.buffer.queueKnown
? RenaCodeTheme.colorWarning
: (controller.status.buffer.draining
? RenaCodeTheme.colorWarning : RenaCodeTheme.colorSuccess)
)

StatCard(
Expand Down Expand Up @@ -437,20 +450,36 @@ struct DashboardView: View {

Divider().background(RenaCodeTheme.borderGlass)

// Brak pomiaru MUSI wygladac inaczej niz "0 GB" - patrz
// `BufferGuardService.freeGB()`. Nieudany statfs to awaria dozorcy
// bufora, a nie informacja o pustym dysku.
row(
"Wolne miejsce na lokalnym wolumenie",
"\(controller.status.buffer.freeDiskGB) GB",
ok: controller.status.buffer.freeDiskGB > 80
controller.status.buffer.freeDiskGB.map { "\($0) GB" } ?? "nie zmierzono",
ok: (controller.status.buffer.freeDiskGB ?? 0) > 80
)

Divider().background(RenaCodeTheme.borderGlass)

// Jedyny wiersz, ktory odpowiada na pytanie "czy kopia POWSTALA".
// Wszystkie pozostale opisuja stan urzadzen i moga byc zielone, gdy
// Time Machine od dwoch dni nie dokonczyl backupu.
row(
"Ostatnia ukończona kopia",
controller.status.backupCycle.ageText(),
ok: controller.status.backupCycle.isFresh()
)

Divider().background(RenaCodeTheme.borderGlass)

row(
"Kolejka synchronizacji z chmurą",
controller.status.buffer.draining
? "\(controller.status.buffer.uploadsInProgress) w toku, \(controller.status.buffer.uploadsQueued) w kolejce"
: "Wszystko wysłane",
ok: controller.status.buffer.erroredFiles == 0
!controller.status.buffer.queueKnown
? "nie odczytano"
: (controller.status.buffer.draining
? "\(controller.status.buffer.uploadsInProgress) w toku, \(controller.status.buffer.uploadsQueued) w kolejce"
: "Wszystko wysłane"),
ok: controller.status.buffer.queueKnown && controller.status.buffer.erroredFiles == 0
)

if controller.status.buffer.erroredFiles > 0 {
Expand Down Expand Up @@ -693,11 +722,9 @@ struct DashboardView: View {
return RenaCodeTheme.colorSuccess
}

private func uploadBadge(_ state: UploadState) -> String {
if state.needsAttention { return "WYMAGA REAKCJI" }
if !state.isNominal { return "MINIE SAMO — NIC NIE RÓB" }
return "W PORZĄDKU"
}
/// Etykieta idzie prosto z `UploadState`. Skladanie jej tutaj z dwoch bool-i
/// ograniczalo interfejs do trzech wariantow, a stanow jest wiecej.
private func uploadBadge(_ state: UploadState) -> String { state.badge }

private func uploadIcon(_ state: UploadState) -> String {
switch state {
Expand All @@ -708,6 +735,7 @@ struct DashboardView: View {
case .dailyQuotaExhausted: return "hourglass"
case .flowing: return "arrow.up.circle.fill"
case .upToDate: return "checkmark.icloud.fill"
case .queueUnknown: return "questionmark.circle.fill"
}
}

Expand Down
Loading
Loading