185 lines
7.8 KiB
Swift
185 lines
7.8 KiB
Swift
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())
|
||
}
|
||
}
|