PONGo_ios/ONUDetailViewModel.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

153 lines
5.6 KiB
Swift
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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