PONGo_ios/DashboardViewModel.swift
Jon Vanvik 203465913c Apply code-review fixes: identity/pagination, load races, TLS pin, leaks
Addresses a multi-agent code review (26 confirmed findings + a URLSession
leak) across correctness, concurrency, security, and cleanup:

- MongoDocument.id: deterministic fallback (was UUID() on every access) to stop
  SwiftUI List/ForEach identity churn; getAll pagination drives its cursor off
  the real _id via documentId, so a missing _id stops paging instead of looping
  on a fabricated cursor.
- View models: per-load generation guard (+ captured-tab guard on ONU detail)
  so a cancelled or overlapping load can't commit stale results or land data on
  the wrong tab.
- JSONValue.intValue: guard Int(d) against NaN/inf/out-of-range (crash).
- ServerTrustEvaluator: hash the real SubjectPublicKeyInfo (prepend the
  algorithm-specific ASN.1 SPKI header) so a standard openssl-captured
  public-key pin actually matches; unknown key types fail closed.
- AppEnvironment.configure: clear stale cookies + Keychain creds on host change.
- FirmwareFile.isCompatible: empty metadata no longer passes as compatible-for-all.
- APIClient: invalidate URLSession on deinit (per-configure/probe leak).
- Dashboard best-effort sections keep last-good on transient failure; unified
  optical thresholds; LoginView prefill-once; populatedBuckets unique ForEach id;
  Format rounding rollover; dead-code/docstring/dedup cleanups.

Verification pending on the Mac (no Swift toolchain on the Linux dev box).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 16:36:49 +02:00

89 lines
4.1 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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