// // AdminDashboardView.swift // FTStore Admin // // The web dashboard's overview, folded onto a phone: KPI tiles, the two // 30-day trends, ingestion per source, live transfers and the activity feed. // import SwiftUI import Charts struct AdminDashboardView: View { @EnvironmentObject private var session: AdminSession @StateObject private var _metrics = AdminLoader>() @StateObject private var _status = AdminLoader>() @StateObject private var _transfers = AdminLoader>() @StateObject private var _activity = AdminLoader() private var _overview: AdminMetricsOverview? { _metrics.value?.data } var body: some View { List { _kpiSection _trendSection _sourceSection _transferSection _activitySection } .navigationTitle("Dashboard") .navigationBarTitleDisplayMode(.inline) .refreshable { await _reload() } .task { await _reload() } } // MARK: KPIs @ViewBuilder private var _kpiSection: some View { Section { if let overview = _overview { LazyVGrid( columns: [GridItem(.flexible(), spacing: 10), GridItem(.flexible(), spacing: 10)], spacing: 10 ) { AdminKPITile( title: "Builds", value: AdminFormat.count(overview.totalIpas), detail: "in the catalog", icon: "shippingbox.fill" ) AdminKPITile( title: "Downloads", value: AdminFormat.count(overview.totalDownloads), detail: "all time", icon: "arrow.down.circle.fill" ) AdminKPITile( title: "Success Rate", value: String(format: "%.1f%%", overview.successRate), detail: "pipeline", icon: "checkmark.seal.fill", tint: overview.successRate >= 90 ? .green : .orange ) AdminKPITile( title: "Sources", value: AdminFormat.count(overview.activeChannels), detail: "active", icon: "antenna.radiowaves.left.and.right", tint: AdminTheme.brand ) } .listRowInsets(EdgeInsets(top: 8, leading: 12, bottom: 8, trailing: 12)) .listRowBackground(Color.clear) if let status = _status.value?.data { VStack(alignment: .leading, spacing: 4) { Text("Last scan \(AdminFormat.relative(status.lastScanAt))") Text("Last feed publish \(AdminFormat.relative(status.lastJsonGenAt))") } .font(.caption) .foregroundStyle(.secondary) } } else if let error = _metrics.errorMessage { AdminErrorRow(message: error) { Task { await _reload() } } } else { AdminLoadingRow() } } } // MARK: Trends @ViewBuilder private var _trendSection: some View { if let overview = _overview { Section("IPAs Added · 30 days") { _barChart(overview.ipasAddedByDay, tint: AdminTheme.brand) } Section("Downloads · 30 days") { _barChart(overview.releaseDownloadsByDay, tint: .green) } } } @ViewBuilder private func _barChart(_ points: [AdminMetricsOverview.DayPoint], tint: Color) -> some View { if points.isEmpty || points.allSatisfy({ $0.count == 0 }) { AdminEmptyRow(icon: "chart.bar", title: "No data yet") } else { Chart(points) { point in BarMark( x: .value("Day", point.date), y: .value("Count", point.count) ) .foregroundStyle(tint) } // Dates are the raw `YYYY-MM-DD` keys the API sends; 30 of them // never fit on a phone, so the axis is dropped and the tallest bar // carries the scale. .chartXAxis(.hidden) .frame(height: 140) .padding(.vertical, 6) } } // MARK: Sources @ViewBuilder private var _sourceSection: some View { if let channels = _overview?.ipasByChannel, !channels.isEmpty { Section("Builds by Source") { ForEach(channels.sorted { $0.count > $1.count }) { entry in HStack { Text(entry.channel) .font(.subheadline) .lineLimit(1) Spacer() Text(AdminFormat.count(entry.count)) .font(.subheadline.monospacedDigit()) .foregroundStyle(.secondary) } } } } } // MARK: Transfers @ViewBuilder private var _transferSection: some View { if let transfers = _transfers.value?.data, !transfers.isEmpty { Section("In Flight") { ForEach(transfers) { transfer in VStack(alignment: .leading, spacing: 4) { Text(transfer.fileName ?? "Message \(transfer.messageId)") .font(.subheadline) .lineLimit(1) HStack(spacing: 8) { AdminStatusPill(status: transfer.status) Text(AdminFormat.bytes(transfer.fileSize)) .font(.caption) .foregroundStyle(.secondary) } } } } } } // MARK: Activity @ViewBuilder private var _activitySection: some View { Section { if let entries = _activity.value?.data, !entries.isEmpty { ForEach(entries.prefix(15)) { entry in AdminActivityRow(entry: entry) } } else if _activity.isLoading { AdminLoadingRow() } else { AdminEmptyRow(icon: "clock", title: "No activity yet") } } header: { HStack { Text("Recent Activity") Spacer() NavigationLink("All Logs") { AdminLogsView() } .font(.caption) } } } // MARK: Loading private func _reload() async { async let metrics: Void = _metrics.load { try await session.get("/api/metrics/overview", as: AdminEnvelope.self) } async let status: Void = _status.load { try await session.get("/api/dashboard/status", as: AdminEnvelope.self) } async let transfers: Void = _transfers.load { try await session.get("/api/dashboard/transfers", as: AdminEnvelope<[AdminTransfer]>.self) } async let activity: Void = _activity.load { try await session.get( "/api/dashboard/activity", query: ["pageSize": "15"], as: AdminActivityResponse.self ) } _ = await (metrics, status, transfers, activity) } } // MARK: - Shared row struct AdminActivityRow: View { let entry: AdminActivityEntry var body: some View { VStack(alignment: .leading, spacing: 4) { Text(entry.message) .font(.subheadline) .lineLimit(3) HStack(spacing: 8) { AdminStatusPill(status: entry.status) Text(entry.type) .font(.caption2) .foregroundStyle(.tertiary) Spacer() Text(AdminFormat.relative(entry.createdAt)) .font(.caption2) .foregroundStyle(.tertiary) } } .padding(.vertical, 2) } }