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()
|
|||
|
|
}
|