import Foundation import Observation @MainActor @Observable final class DashboardViewModel { var phase: LoadPhase = .idle var onuCounts: [(state: ONULifecycleState, count: Int)] = [] var onuTotal = 0 var oltCount = 0 var controllerCount = 0 var severityCounts: [(severity: AlarmSeverity, count: Int)] = [] // Aggregates derived from the state docs already fetched for the counts above // — no extra API calls. var totalDownstreamBps = 0.0 var totalUpstreamBps = 0.0 var abnormalRxCount = 0 // ONUs with Rx optical < −28 or > −10 dBm var lasersOffCount = 0 // OLTs with the laser shut down var opticalAvailable = false // whether the bulk ONU-STATE response carried Rx levels // The actual offenders, kept for drill-down (no re-fetch). var abnormalRxONUs: [ONUStateDoc] = [] var lasersOffOLTs: [OLTStateDoc] = [] private let connection: MCMSConnection /// Bumped per load; only the latest load commits, so the 30s auto-refresh loop /// and a manual pull-to-refresh can't interleave partial writes. private var loadGeneration = 0 init(connection: MCMSConnection) { self.connection = connection } /// Light by default: the ONU count, state breakdown, OLT traffic and /// laser-off count all come from the (small) OLT-STATE docs. The heavy /// per-ONU optical scan runs only when `extraStats` is on. func load(extraStats: Bool = false) async { loadGeneration += 1 let gen = loadGeneration phase = .loading do { // OLT-STATE drives the OLT count, ONU registration (dev guide // Procedure 2), total traffic, and laser state — all from a handful // of small docs. Fetch everything into locals first, then commit once, // so an overlapping load can't publish a half-updated dashboard. let olts = try await connection.olt.allStates() let registration = OLTRepository.registrationMap(from: olts) // Best-effort sections: nil means "fetch failed this cycle" → keep the // last good value rather than flicker to empty/zero on a transient blip. let controllers = try? await connection.controller.allConfigs().count let alarms = try? await connection.onu.alarmHistories() // Opt-in heavy scan: pull every ONU-STATE for the Rx optical check. var optAvailable = false var abnormal: [ONUStateDoc] = [] if extraStats { let onus = try await connection.onu.allStates() optAvailable = onus.contains { $0.rxOpticalDBm != nil } abnormal = onus.filter { $0.rxOpticalDBm.map(OpticalThreshold.rxAbnormal) ?? false } } guard gen == loadGeneration, !Task.isCancelled else { return } oltCount = olts.count onuTotal = registration.count onuCounts = Dictionary(grouping: registration.values, by: { $0 }) .map { (state: $0.key, count: $0.value.count) } .sorted { $0.count > $1.count } totalDownstreamBps = olts.compactMap(\.txRateBps).reduce(0, +) totalUpstreamBps = olts.compactMap(\.rxRateBps).reduce(0, +) lasersOffOLTs = olts.filter(\.isLaserOff) lasersOffCount = lasersOffOLTs.count if let controllers { controllerCount = controllers } // else keep last good if let alarms { severityCounts = Dictionary(grouping: alarms, by: { $0.severity }) .map { (severity: $0.key, count: $0.value.count) } .sorted { $0.severity.rank < $1.severity.rank } } // else keep last good opticalAvailable = optAvailable abnormalRxONUs = abnormal abnormalRxCount = abnormal.count phase = .loaded } catch { guard gen == loadGeneration, !Task.isCancelled else { return } phase = .failed((error as? APIError)?.localizedDescription ?? error.localizedDescription) } } }