PONGo_ios/DashboardView.swift
2026-05-31 14:11:29 +02:00

185 lines
7.8 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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())
}
}