import Foundation import Observation enum ONUDetailTab: String, CaseIterable, Identifiable { case stats = "Overview" case cpe = "CPE" case config = "Config" case alarms = "Alarms" case logs = "Logs" var id: String { rawValue } } @MainActor @Observable final class ONUDetailViewModel { let onuId: String private let connection: MCMSConnection /// Bumped per (re)load so a late/cancelled request — or one for a tab the user /// has since left — can't commit its results over the current tab's data. private var loadGeneration = 0 var tab: ONUDetailTab = .stats var phase: LoadPhase = .idle var config: ONUConfigDoc? var liveState: ONUStateDoc? var stats: [StatSample] = [] var alarms: [AlarmHistoryDoc] = [] var logs: [LogEntry] = [] var cpes: [CPEState] = [] var firmwareFiles: [FirmwareFile] = [] var firmwareLoadError: String? var isPerformingAction = false var actionMessage: String? init(connection: MCMSConnection, onuId: String) { self.connection = connection self.onuId = onuId } /// Tab switch / first load: blanks to a spinner while the new tab loads. func loadCurrentTab() async { phase = .loading await reloadCurrentTab() } /// Pull-to-refresh: reloads in place WITHOUT flipping to `.loading`, so the /// scrollable content (and the refresh task driving it) isn't torn down /// mid-request — which otherwise surfaces as a spurious "cancelled" error. func refresh() async { await reloadCurrentTab() } private func reloadCurrentTab() async { loadGeneration += 1 let gen = loadGeneration let requested = tab // Commit only if this is still the latest load AND the user is still on the // tab we fetched for — otherwise drop the result. func current() -> Bool { gen == loadGeneration && requested == tab && !Task.isCancelled } do { switch requested { case .stats: // Live ONU-STATE carries the current optical/FEC levels (§3.12); // the /onus/stats/ time-series is a best-effort supplement. let state = try await connection.onu.state(id: onuId) let samples = (try? await connection.onu.stats(id: onuId, lastHours: 24)) ?? [] guard current() else { return } liveState = state stats = samples case .config: let doc = try await connection.onu.config(id: onuId) guard current() else { return } config = doc case .cpe: let list = try await connection.onu.cpes(id: onuId) guard current() else { return } cpes = list case .alarms: let list = try await connection.onu.alarmHistories(query: queryForONU()) .sorted { $0.severity.rank < $1.severity.rank } guard current() else { return } alarms = list case .logs: let list = try await connection.onu.logs(id: onuId, lastHours: 24) guard current() else { return } logs = list } phase = .loaded } catch { guard current() else { return } phase = .failed((error as? APIError)?.localizedDescription ?? error.localizedDescription) } } /// Filter the fleet-wide alarm-history list to this ONU. ⚠️ The `_id` key is /// UNVERIFIED — if it's wrong the server ignores the filter and returns EVERY /// ONU's alarms into this detail screen (no error). Confirm the real key (or /// switch to the per-id `.onuAlarmHistory(id:)` endpoint) against openapi.json / /// a live response before trusting this tab. private func queryForONU() -> APIQuery { var q = APIQuery() q.query = ["_id": onuId] return q } func reset() async { isPerformingAction = true actionMessage = nil do { try await connection.onu.reset(id: onuId) actionMessage = "Reset requested." } catch { actionMessage = (error as? APIError)?.localizedDescription ?? error.localizedDescription } isPerformingAction = false } func loadFirmware() async { firmwareLoadError = nil do { firmwareFiles = try await connection.onu.firmwareFiles() } catch { firmwareLoadError = (error as? APIError)?.localizedDescription ?? error.localizedDescription } } /// Stage a firmware image to the ONU's inactive bank (Procedure 7). Service-affecting. func upgrade(to file: FirmwareFile) async { isPerformingAction = true actionMessage = nil do { let slot = try await connection.onu.upgradeFirmware(id: onuId, file: file) actionMessage = "Firmware \(file.version ?? "image") staged to bank \(slot). The ONU starts it after its next reboot." } catch { actionMessage = (error as? APIError)?.localizedDescription ?? error.localizedDescription } isPerformingAction = false } } extension StatSample { /// All numeric counters in the sample, sorted by key — for display/charting. var numericMetrics: [(key: String, value: Double)] { (raw.objectValue ?? [:]).compactMap { key, value in switch value { case .double(let d): return (key, d) case .int(let i): return (key, Double(i)) default: return nil } } .sorted { $0.key < $1.key } } }