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: OpticalThreshold.rxGreenFloor, warn: OpticalThreshold.rxRedFloor) opticalRow("TX optical", s.txOpticalDBm, green: OpticalThreshold.txGreenFloor, warn: OpticalThreshold.txGreenFloor) } header: { Text("Live optical") } footer: { Text("Green RX ≥ −28 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.1–7.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) } } } }