Files
boc/aamos-admin-upgrade/ios/AamosAdmin/Views/DashboardView.swift
T
Bernt 6de2455917 v1.2.0: Add Global Markets footer, translated to 9 languages
- Added GLOBAL_MARKETS_TITLE to all translation files
- Updated footer with 12 markets (4 active + 8 upcoming)
- Translated market section to: zh-cn, zh-tw, ja, ko, th, vi, id, ms, hi
- Built and deployed to production
- CloudFront invalidation: I3RTMXVFDJXWLG3SYX208OP1CC
2026-07-08 19:56:03 +00:00

379 lines
12 KiB
Swift
Raw 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
// 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()
}