400 lines
16 KiB
Swift
400 lines
16 KiB
Swift
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.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)
|
||
}
|
||
}
|
||
}
|
||
}
|