Files
boc/aamos-admin-upgrade/ios/AamosAdmin/Views/DashboardView.swift
T

379 lines
12 KiB
Swift
Raw Normal View History

import SwiftUI
// MARK: - MetricCard
private struct MetricCard: View {
let title: String
let value: String
let symbol: String
let color: Color
let subtitle: String?
init(title: String, value: String, symbol: String, color: Color, subtitle: String? = nil) {
self.title = title
self.value = value
self.symbol = symbol
self.color = color
self.subtitle = subtitle
}
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Image(systemName: symbol)
.font(.system(size: 18, weight: .semibold))
.foregroundStyle(color)
Text(value)
.font(.system(size: 30, weight: .bold, design: .rounded))
.foregroundStyle(Color(.label))
.minimumScaleFactor(0.6)
.lineLimit(1)
VStack(alignment: .leading, spacing: 2) {
Text(title)
.font(.system(size: 13, weight: .medium))
.foregroundStyle(Color(.secondaryLabel))
if let subtitle {
Text(subtitle)
.font(.system(size: 11))
.foregroundStyle(Color(.tertiaryLabel))
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(16)
.background(Color(.secondarySystemBackground))
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
}
}
// MARK: - CircularProgressView
private struct CircularProgressView: View {
let progress: Double // 0.0 1.0
let headline: String
let subline: String
@State private var animated: Double = 0
private var ringColor: Color {
switch progress {
case 0.9...: return Color(.systemGreen)
case 0.6..<0.9: return Color(.systemOrange)
default: return Color(.systemRed)
}
}
var body: some View {
HStack(spacing: 24) {
ZStack {
Circle()
.stroke(Color(.systemFill), lineWidth: 14)
Circle()
.trim(from: 0, to: animated)
.stroke(
ringColor,
style: StrokeStyle(lineWidth: 14, lineCap: .round)
)
.rotationEffect(.degrees(-90))
.animation(.easeInOut(duration: 0.9), value: animated)
VStack(spacing: 2) {
Text("\(Int(progress * 100))%")
.font(.system(size: 22, weight: .bold, design: .rounded))
.foregroundStyle(Color(.label))
Text("health")
.font(.system(size: 11, weight: .medium))
.foregroundStyle(Color(.secondaryLabel))
}
}
.frame(width: 96, height: 96)
VStack(alignment: .leading, spacing: 10) {
Text("Services Health")
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(Color(.label))
Text(headline)
.font(.system(size: 14))
.foregroundStyle(Color(.secondaryLabel))
Text(subline)
.font(.system(size: 12))
.foregroundStyle(Color(.tertiaryLabel))
VStack(spacing: 6) {
RingLegendRow(color: Color(.systemGreen), label: "Healthy", fraction: progress)
RingLegendRow(color: Color(.systemRed), label: "Degraded", fraction: max(0, 1 - progress))
}
}
Spacer(minLength: 0)
}
.padding(16)
.background(Color(.secondarySystemBackground))
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
.onAppear {
animated = progress
}
.onChange(of: progress) { _, newValue in
withAnimation(.easeInOut(duration: 0.8)) {
animated = newValue
}
}
}
}
private struct RingLegendRow: View {
let color: Color
let label: String
let fraction: Double
var body: some View {
HStack(spacing: 6) {
Circle()
.fill(color)
.frame(width: 8, height: 8)
Text(label)
.font(.system(size: 12))
.foregroundStyle(Color(.secondaryLabel))
Spacer()
Text("\(Int(fraction * 100))%")
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(Color(.label))
}
}
}
// MARK: - AuditEventRow
private struct AuditEventRow: View {
let entry: AuditEntry
private static let relativeFormatter: RelativeDateTimeFormatter = {
let f = RelativeDateTimeFormatter()
f.unitsStyle = .abbreviated
return f
}()
private var outcomeSymbol: String {
switch entry.outcome {
case .success: "checkmark.circle.fill"
case .failure: "xmark.circle.fill"
case .warning: "exclamationmark.triangle.fill"
}
}
private var outcomeColor: Color {
switch entry.outcome {
case .success: Color(.systemGreen)
case .failure: Color(.systemRed)
case .warning: Color(.systemOrange)
}
}
var body: some View {
HStack(spacing: 12) {
Image(systemName: outcomeSymbol)
.font(.system(size: 20, weight: .medium))
.foregroundStyle(outcomeColor)
.frame(width: 28, alignment: .center)
VStack(alignment: .leading, spacing: 3) {
HStack(alignment: .firstTextBaseline) {
Text(entry.action)
.font(.system(size: 15, weight: .medium))
.foregroundStyle(Color(.label))
.lineLimit(1)
Spacer()
Text(Self.relativeFormatter.localizedString(for: entry.timestamp, relativeTo: .now))
.font(.system(size: 12))
.foregroundStyle(Color(.tertiaryLabel))
}
HStack(spacing: 4) {
Text(entry.resource)
.font(.system(size: 13))
.foregroundStyle(Color(.secondaryLabel))
.lineLimit(1)
Text("·")
.font(.system(size: 13))
.foregroundStyle(Color(.quaternaryLabel))
Text(entry.actor)
.font(.system(size: 13))
.foregroundStyle(Color(.secondaryLabel))
.lineLimit(1)
.truncationMode(.middle)
}
if let detail = entry.detail {
Text(detail)
.font(.system(size: 12))
.foregroundStyle(Color(.tertiaryLabel))
.lineLimit(1)
}
}
}
.padding(.vertical, 10)
}
}
// MARK: - DashboardView
struct DashboardView: View {
@StateObject private var viewModel = DashboardViewModel()
@State private var showError = false
var body: some View {
NavigationStack {
ScrollView {
LazyVStack(spacing: 8) {
metricsGrid
healthRing
auditSection
}
.padding(.horizontal, 16)
.padding(.top, 8)
.padding(.bottom, 32)
}
.background(Color(.systemBackground))
.navigationTitle("Dashboard")
.navigationBarTitleDisplayMode(.large)
.toolbar { toolbarContent }
.refreshable {
await viewModel.loadDashboard()
}
.onAppear {
viewModel.startPolling()
}
.onDisappear {
viewModel.stopPolling()
}
.onChange(of: viewModel.error != nil) { _, hasError in
if hasError { showError = true }
}
.alert("Load Failed", isPresented: $showError) {
Button("Retry") { Task { await viewModel.loadDashboard() } }
Button("Dismiss", role: .cancel) {}
} message: {
Text(viewModel.error?.localizedDescription ?? "Unknown error")
}
}
}
// MARK: - Sections
@ViewBuilder private var metricsGrid: some View {
LazyVGrid(
columns: [GridItem(.flexible(), spacing: 8), GridItem(.flexible(), spacing: 8)],
spacing: 8
) {
MetricCard(
title: "Active Agents",
value: "\(viewModel.metrics.activeAgents)",
symbol: "person.2.fill",
color: Color(.systemBlue)
)
MetricCard(
title: "Services",
value: "\(Int(viewModel.metrics.uptimePercent))%",
symbol: "server.rack",
color: viewModel.metrics.uptimePercent >= 90
? Color(.systemGreen)
: Color(.systemOrange),
subtitle: "uptime"
)
MetricCard(
title: "Modules",
value: "\(viewModel.metrics.tasksCompleted)",
symbol: "puzzlepiece.extension.fill",
color: Color(.systemOrange),
subtitle: "\(viewModel.metrics.tasksFailed) failed"
)
MetricCard(
title: "CPU / Latency",
value: latencyLabel,
symbol: "gauge.with.needle.fill",
color: latencyColor,
subtitle: "avg response"
)
}
}
@ViewBuilder private var healthRing: some View {
CircularProgressView(
progress: viewModel.metrics.uptimePercent / 100,
headline: "\(Int(viewModel.metrics.uptimePercent))% services operational",
subline: "\(viewModel.metrics.alertCount) active alert\(viewModel.metrics.alertCount == 1 ? "" : "s")"
)
}
@ViewBuilder private var auditSection: some View {
VStack(spacing: 0) {
HStack {
Label("Recent Activity", systemImage: "clock.arrow.circlepath")
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(Color(.label))
Spacer()
if viewModel.isLoading {
ProgressView()
.scaleEffect(0.75)
}
}
.padding(.horizontal, 16)
.padding(.top, 16)
.padding(.bottom, 4)
let events = Array(viewModel.recentAudit.prefix(5))
if events.isEmpty && !viewModel.isLoading {
Text("No recent events")
.font(.system(size: 14))
.foregroundStyle(Color(.tertiaryLabel))
.frame(maxWidth: .infinity)
.padding(.vertical, 24)
} else {
ForEach(Array(events.enumerated()), id: \.element.id) { index, entry in
AuditEventRow(entry: entry)
.padding(.horizontal, 16)
if index < events.count - 1 {
Divider()
.padding(.leading, 56)
}
}
.padding(.bottom, 8)
}
}
.background(Color(.secondarySystemBackground))
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
}
@ToolbarContentBuilder private var toolbarContent: some ToolbarContent {
ToolbarItem(placement: .topBarTrailing) {
Button {
Task { await viewModel.loadDashboard() }
} label: {
Image(systemName: "arrow.clockwise")
.font(.system(size: 16, weight: .medium))
}
.disabled(viewModel.isLoading)
}
}
// MARK: - Computed helpers
private var latencyLabel: String {
let ms = viewModel.metrics.avgResponseTimeMs
return ms < 1_000 ? "\(Int(ms))ms" : String(format: "%.1fs", ms / 1_000)
}
private var latencyColor: Color {
let ms = viewModel.metrics.avgResponseTimeMs
if ms < 200 { return Color(.systemGreen) }
if ms < 500 { return Color(.systemOrange) }
return Color(.systemRed)
}
}
// MARK: - Preview
#Preview {
DashboardView()
}