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 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 { do { switch tab { case .stats: // Live ONU-STATE carries the current optical/FEC levels (§3.12); // the /onus/stats/ time-series is a best-effort supplement. liveState = try await connection.onu.state(id: onuId) stats = (try? await connection.onu.stats(id: onuId, lastHours: 24)) ?? [] case .config: config = try await connection.onu.config(id: onuId) case .cpe: cpes = try await connection.onu.cpes(id: onuId) case .alarms: alarms = try await connection.onu.alarmHistories( query: queryForONU()).sorted { $0.severity.rank < $1.severity.rank } case .logs: logs = try await connection.onu.logs(id: onuId, lastHours: 24) } phase = .loaded } catch { phase = .failed((error as? APIError)?.localizedDescription ?? error.localizedDescription) } } /// Filter alarm-history list to this ONU. Reconcile the key with the real /// document shape; `_id` is a safe default for the per-ONU collection. 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 } } }