initial commit
This commit is contained in:
commit
8de962eeb8
44 changed files with 4843 additions and 0 deletions
400
ONUDetailView.swift
Normal file
400
ONUDetailView.swift
Normal 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.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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue