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