initial commit

This commit is contained in:
Jon Vanvik 2026-05-31 14:11:29 +02:00
commit 8de962eeb8
44 changed files with 4843 additions and 0 deletions

400
ONUDetailView.swift Normal file
View file

@ -0,0 +1,400 @@
import SwiftUI
struct ONUDetailView: View {
@State private var vm: ONUDetailViewModel
@State private var showResetConfirm = false
@State private var showFirmwareSheet = false
init(connection: MCMSConnection, onuId: String) {
_vm = State(initialValue: ONUDetailViewModel(connection: connection, onuId: onuId))
}
var body: some View {
@Bindable var vm = vm
VStack(spacing: 0) {
Picker("Tab", selection: $vm.tab) {
ForEach(ONUDetailTab.allCases) { Text($0.rawValue).tag($0) }
}
.pickerStyle(.segmented)
.padding()
tabContent
}
.navigationTitle(vm.onuId)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Menu {
Button(role: .destructive) { showResetConfirm = true } label: {
Label("Reset ONU", systemImage: "arrow.clockwise")
}
} label: {
Image(systemName: "ellipsis.circle")
}
.disabled(vm.isPerformingAction)
}
}
.sheet(isPresented: $showResetConfirm) {
ResetConfirmSheet(deviceKind: "ONU", deviceName: vm.onuId) {
Task { await vm.reset() }
}
}
.sheet(isPresented: $showFirmwareSheet) {
if let state = vm.liveState {
FirmwareUpdateSheet(vm: vm, state: state)
}
}
.alert("Operation", isPresented: Binding(
get: { vm.actionMessage != nil },
set: { if !$0 { vm.actionMessage = nil } }
)) {
Button("OK") { vm.actionMessage = nil }
} message: {
Text(vm.actionMessage ?? "")
}
.task(id: vm.tab) { await vm.loadCurrentTab() }
.refreshable { await vm.refresh() }
}
@ViewBuilder
private var tabContent: some View {
switch vm.phase {
case .idle, .loading:
LoadingView()
case .failed(let msg):
ErrorStateView(message: msg) { Task { await vm.loadCurrentTab() } }
case .loaded:
switch vm.tab {
case .stats: overviewView
case .cpe: cpeView
case .config: configView
case .alarms: alarmsView
case .logs: logsView
}
}
}
// MARK: Stats
private var overviewView: some View {
List {
if let s = vm.liveState {
statusSection(s)
firmwareSection(s)
opticalSection(s)
uniSection(s)
trafficSection(s)
if let updated = MongoTimestamp.displayString(s.lastUpdate) {
Section { LabeledContent("Last update", value: updated) }
}
}
historySection
}
}
@ViewBuilder private func statusSection(_ s: ONUStateDoc) -> some View {
Section("Status") {
if let reg = s.registrationState { LabeledContent("Registration", value: reg) }
if let eq = s.equipmentId { LabeledContent("Equipment", value: eq) }
if let mode = s.ponMode { LabeledContent("PON mode", value: mode) }
if let temp = s.temperatureC {
LabeledContent("Temperature", value: "\(temp.formatted(.number.precision(.fractionLength(1)))) °C")
}
if let online = s.onlineTime {
LabeledContent("Online since", value: MongoTimestamp.displayString(online) ?? online)
if let up = MongoTimestamp.duration(from: online, to: s.lastUpdate) {
LabeledContent("Uptime", value: up)
}
}
}
}
@ViewBuilder private func firmwareSection(_ s: ONUStateDoc) -> some View {
Section("Firmware") {
if let v = s.fwVersion { LabeledContent("Active version", value: v) }
ForEach(Array(s.fwBankVersions.enumerated()), id: \.offset) { idx, ver in
LabeledContent("Bank \(idx)" + (s.fwBankPtr == idx ? " · active" : ""),
value: ver.isEmpty ? "" : ver)
}
if let st = s.fwUpgradeStatus {
if let status = st["Status"]?.stringValue { LabeledContent("Upgrade", value: status) }
if let prog = st["Progress"]?.intValue { LabeledContent("Progress", value: "\(prog)%") }
}
Button { showFirmwareSheet = true } label: {
Label("Update firmware…", systemImage: "arrow.up.circle")
}
.disabled(vm.isPerformingAction)
}
}
@ViewBuilder private func opticalSection(_ s: ONUStateDoc) -> some View {
Section {
opticalRow("RX optical", s.rxOpticalDBm, green: -28, warn: -30)
opticalRow("TX optical", s.txOpticalDBm, green: 3, warn: 3)
} header: {
Text("Live optical")
} footer: {
Text("Green RX ≥ 30 dBm, TX ≥ 3 dBm (user guide §7).")
}
Section("FEC error counters") {
fecRow("ONU pre-FEC", s.onuPreFEC, isPost: false)
fecRow("ONU post-FEC", s.onuPostFEC, isPost: true)
fecRow("OLT pre-FEC", s.oltPreFEC, isPost: false)
fecRow("OLT post-FEC", s.oltPostFEC, isPost: true)
}
}
@ViewBuilder private func uniSection(_ s: ONUStateDoc) -> some View {
if !s.uniPorts.isEmpty {
Section("UNI ports") {
ForEach(s.uniPorts) { port in
HStack {
VStack(alignment: .leading, spacing: 2) {
Text(port.name).font(.subheadline)
if let detail = uniDetail(port) {
Text(detail).font(.caption).foregroundStyle(.secondary)
}
}
Spacer()
if let enabled = port.enabled {
StateBadge(text: enabled ? "enabled" : "disabled",
color: enabled ? .green : .gray)
}
}
}
}
}
}
private func uniDetail(_ port: UNIPort) -> String? {
if let state = port.state { return state }
let parts = [port.speed, port.duplex].compactMap { $0 }
return parts.isEmpty ? nil : parts.joined(separator: " · ")
}
@ViewBuilder private func trafficSection(_ s: ONUStateDoc) -> some View {
if !s.ponServices.isEmpty {
Section("Traffic") {
ForEach(s.ponServices) { svc in
VStack(alignment: .leading, spacing: 6) {
Text(svc.name).font(.subheadline)
HStack(alignment: .top) {
trafficStat("↓ Rx", rate: svc.rxRateBps, total: svc.rxTotalOctets)
trafficStat("↑ Tx", rate: svc.txRateBps, total: svc.txTotalOctets)
}
}
.padding(.vertical, 2)
}
}
}
}
private func trafficStat(_ label: String, rate: Double?, total: Double?) -> some View {
VStack(alignment: .leading, spacing: 2) {
Text(label).font(.caption2).foregroundStyle(.secondary)
Text(Format.bps(rate)).font(.callout.monospacedDigit())
Text("\(Format.bytes(total)) total").font(.caption2).foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
@ViewBuilder private var historySection: some View {
Section("History (24h)") {
LabeledContent("Samples", value: "\(vm.stats.count)")
if let latest = vm.stats.first, let ts = MongoTimestamp.displayString(latest.timestamp) {
LabeledContent("Latest sample", value: ts)
}
}
if let latest = vm.stats.first, !latest.numericMetrics.isEmpty {
Section("Latest counters") {
ForEach(latest.numericMetrics, id: \.key) { metric in
LabeledContent(metric.key) {
Text(metric.value.formatted()).monospacedDigit()
}
}
}
}
}
/// Optical level row, tinted by the user-guide thresholds (§7.3). `green` is
/// the full-pass floor, `warn` the margin floor; below `warn` is red.
private func opticalRow(_ title: String, _ value: Double?, green: Double, warn: Double) -> some View {
LabeledContent(title) {
if let value {
Text("\(value.formatted(.number.precision(.fractionLength(2)))) dBm")
.monospacedDigit()
.foregroundStyle(value >= green ? .green : (value >= warn ? .yellow : .red))
} else {
Text("").foregroundStyle(.secondary)
}
}
}
/// FEC counter row. Post-FEC > 0 means uncorrected errors reached the user
/// (red); pre-FEC > 0 is informational; all-zero is healthy (§7.17.2).
private func fecRow(_ title: String, _ value: Double?, isPost: Bool) -> some View {
LabeledContent(title) {
if let value {
Text(Int(value).formatted())
.monospacedDigit()
.foregroundStyle(isPost && value > 0 ? .red : (value > 0 ? .secondary : .green))
} else {
Text("").foregroundStyle(.secondary)
}
}
}
// MARK: CPE (DHCP leases)
private var cpeView: some View {
Group {
if vm.cpes.isEmpty {
ContentUnavailableView("No CPEs", systemImage: "laptopcomputer.slash")
} else {
List(vm.cpes) { cpe in
Section {
if let ip = cpe.ipv4 {
LabeledContent("IPv4") {
HStack(spacing: 6) {
Text(ip).monospacedDigit()
if let state = cpe.dhcpState { dhcpBadge(state) }
}
}
if let server = cpe.dhcpServer { LabeledContent("DHCP server", value: server) }
if let lease = cpe.dhcpLeaseSeconds { LabeledContent("Lease", value: leaseText(lease)) }
if let renew = MongoTimestamp.displayString(cpe.dhcpLastSuccess) {
LabeledContent("Last renew", value: renew)
}
}
if let ip6 = cpe.ipv6 {
LabeledContent("IPv6") {
HStack(spacing: 6) {
Text(ip6).font(.callout.monospaced())
if let state = cpe.dhcpv6State { dhcpBadge(state) }
}
}
if let prefix = cpe.ipv6Prefix { LabeledContent("Delegated prefix", value: prefix) }
if let valid = MongoTimestamp.displayString(cpe.ipv6ValidLifetime) {
LabeledContent("Valid until", value: valid)
}
}
if let circuit = cpe.circuitId { LabeledContent("Circuit ID", value: circuit) }
if !cpe.hasLease { Text("No active lease.").foregroundStyle(.secondary) }
} header: {
Text(cpe.mac ?? cpe.id)
}
}
}
}
}
private func dhcpBadge(_ state: String) -> some View {
let ok = state.lowercased().contains("bound") || state.lowercased() == "ack"
return StateBadge(text: state, color: ok ? .green : .orange)
}
private func leaseText(_ seconds: Int) -> String {
if seconds % 86400 == 0 { return "\(seconds / 86400)d" }
if seconds % 3600 == 0 { return "\(seconds / 3600)h" }
return "\(seconds / 60)m"
}
// MARK: Config
private var configView: some View {
List {
if let config = vm.config {
if let name = config.netconfName {
Section("Identity") { LabeledContent("NETCONF name", value: name) }
}
Section("Raw document") {
JSONTreeView(value: config.raw)
}
} else {
Text("No configuration loaded.").foregroundStyle(.secondary)
}
}
}
// MARK: Alarms
private var alarmsView: some View {
Group {
if vm.alarms.isEmpty {
ContentUnavailableView("No alarms", systemImage: "bell.slash")
} else {
List(vm.alarms) { alarm in
VStack(alignment: .leading, spacing: 4) {
HStack {
SeverityBadge(severity: alarm.severity)
Spacer()
if let at = MongoTimestamp.displayString(alarm.raisedAt) {
Text(at).font(.caption).foregroundStyle(.secondary)
}
}
Text(alarm.message ?? alarm.id).font(.subheadline)
}
.padding(.vertical, 2)
}
.listStyle(.plain)
}
}
}
// MARK: Logs
private var logsView: some View {
Group {
if vm.logs.isEmpty {
ContentUnavailableView("No logs", systemImage: "doc.text")
} else {
List(vm.logs) { log in
VStack(alignment: .leading, spacing: 4) {
HStack {
SeverityBadge(severity: log.severity)
Spacer()
if let t = MongoTimestamp.displayString(log.time) {
Text(t).font(.caption).foregroundStyle(.secondary)
}
}
Text(log.message ?? log.id).font(.caption).foregroundStyle(.primary)
}
.padding(.vertical, 2)
}
.listStyle(.plain)
}
}
}
}
/// Minimal recursive viewer for a `JSONValue` (read-only). Renders nested
/// objects/arrays as disclosure groups so the full Mongo document is browsable
/// before specific fields are typed.
struct JSONTreeView: View {
let value: JSONValue
var key: String? = nil
var body: some View {
switch value {
case .object(let dict):
DisclosureGroup(key ?? "{ }") {
ForEach(dict.keys.sorted(), id: \.self) { k in
JSONTreeView(value: dict[k]!, key: k)
}
}
case .array(let arr):
DisclosureGroup(key.map { "\($0) [\(arr.count)]" } ?? "[ \(arr.count) ]") {
ForEach(Array(arr.enumerated()), id: \.offset) { idx, item in
JSONTreeView(value: item, key: "\(idx)")
}
}
default:
LabeledContent(key ?? "value") {
Text(value.compactDescription)
.font(.callout.monospaced())
.foregroundStyle(.secondary)
.multilineTextAlignment(.trailing)
}
}
}
}