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

185
DashboardView.swift Normal file
View file

@ -0,0 +1,185 @@
import SwiftUI
struct DashboardView: View {
private let connection: MCMSConnection
@State private var vm: DashboardViewModel
@AppStorage("dashboard.extraStats") private var extraStats = false
@AppStorage("dashboard.autoRefresh") private var autoRefresh = false
init(connection: MCMSConnection) {
self.connection = connection
_vm = State(initialValue: DashboardViewModel(connection: connection))
}
private let columns = [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())]
var body: some View {
Group {
switch vm.phase {
case .idle:
LoadingView(label: "Loading network…")
case .loading where vm.onuTotal == 0:
LoadingView(label: "Loading network…")
case .failed(let msg):
ErrorStateView(message: msg) { Task { await vm.load(extraStats: extraStats) } }
default:
content
}
}
.navigationTitle("Dashboard")
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Menu {
Toggle("Detailed stats (Rx scan)", isOn: $extraStats)
Toggle("Auto-refresh · 30s", isOn: $autoRefresh)
} label: {
Image(systemName: autoRefresh ? "arrow.clockwise.circle.fill" : "ellipsis.circle")
}
}
}
// Reloads on appear, and whenever a toggle flips; keeps refreshing every
// 30s while auto-refresh is on. Cancelled when the view goes away.
.task(id: "\(extraStats)-\(autoRefresh)") {
while !Task.isCancelled {
await vm.load(extraStats: extraStats)
guard autoRefresh else { break }
try? await Task.sleep(for: .seconds(30))
}
}
.refreshable { await vm.load(extraStats: extraStats) }
}
private var content: some View {
ScrollView {
VStack(alignment: .leading, spacing: 20) {
LazyVGrid(columns: columns, spacing: 12) {
CountTile(title: "ONUs", value: vm.onuTotal,
systemImage: "point.3.connected.trianglepath.dotted", tint: .blue)
CountTile(title: "OLTs", value: vm.oltCount,
systemImage: "antenna.radiowaves.left.and.right", tint: .teal)
CountTile(title: "Controllers", value: vm.controllerCount,
systemImage: "cpu", tint: .indigo)
}
if vm.totalDownstreamBps > 0 || vm.totalUpstreamBps > 0 {
section(title: "Traffic · all OLTs") {
HStack {
trafficStat("Downstream", systemImage: "arrow.down", bps: vm.totalDownstreamBps, tint: .blue)
Spacer()
trafficStat("Upstream", systemImage: "arrow.up", bps: vm.totalUpstreamBps, tint: .teal)
}
}
}
if vm.opticalAvailable || vm.lasersOffCount > 0 {
section(title: "Health") {
if vm.opticalAvailable {
healthLink(title: "ONUs with abnormal Rx", detail: "< 28 or > 10 dBm", count: vm.abnormalRxCount) {
AbnormalRxListView(connection: connection, onus: vm.abnormalRxONUs)
}
}
healthLink(title: "OLTs with laser off", detail: nil, count: vm.lasersOffCount) {
LaserOffListView(connection: connection, olts: vm.lasersOffOLTs)
}
}
}
if !vm.onuCounts.isEmpty {
section(title: "ONU states") {
ForEach(vm.onuCounts, id: \.state) { row in
NavigationLink {
ONUListView(connection: connection,
stateFilter: row.state,
title: row.state.label)
} label: {
HStack {
StateBadge(text: row.state.label, color: row.state.color)
Spacer()
Text("\(row.count)").font(.body.monospacedDigit())
Image(systemName: "chevron.right")
.font(.caption2.weight(.semibold))
.foregroundStyle(.tertiary)
}
.padding(.vertical, 4)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
}
}
if !vm.severityCounts.isEmpty {
section(title: "ONU alarms by severity") {
ForEach(vm.severityCounts, id: \.severity) { row in
HStack {
SeverityBadge(severity: row.severity)
Spacer()
Text("\(row.count)").font(.body.monospacedDigit())
}
.padding(.vertical, 4)
}
}
}
}
.padding()
}
}
@ViewBuilder
private func section<Content: View>(title: String, @ViewBuilder content: () -> Content) -> some View {
VStack(alignment: .leading, spacing: 8) {
Text(title).font(.headline)
VStack(spacing: 0) { content() }
.padding()
.background(Color(.secondarySystemBackground))
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
}
}
private func trafficStat(_ title: String, systemImage: String, bps: Double, tint: Color) -> some View {
VStack(alignment: .leading, spacing: 4) {
Label(title, systemImage: systemImage).font(.caption).foregroundStyle(.secondary)
Text(Format.bps(bps))
.font(.system(.title3, design: .rounded).weight(.semibold))
.foregroundStyle(tint)
.monospacedDigit()
}
}
/// A health row that becomes a navigation link into the affected devices
/// when the count is non-zero.
@ViewBuilder
private func healthLink<Destination: View>(
title: String, detail: String?, count: Int,
@ViewBuilder destination: () -> Destination
) -> some View {
if count > 0 {
NavigationLink(destination: destination()) {
healthRow(title, detail: detail, count: count, tappable: true)
}
.buttonStyle(.plain)
} else {
healthRow(title, detail: detail, count: count, tappable: false)
}
}
private func healthRow(_ title: String, detail: String?, count: Int, tappable: Bool) -> some View {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text(title)
if let detail { Text(detail).font(.caption).foregroundStyle(.secondary) }
}
Spacer()
Text("\(count)")
.font(.body.monospacedDigit().weight(.semibold))
.foregroundStyle(count > 0 ? .orange : .green)
if tappable {
Image(systemName: "chevron.right")
.font(.caption2.weight(.semibold))
.foregroundStyle(.tertiary)
}
}
.padding(.vertical, 4)
.contentShape(Rectangle())
}
}