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