Files
FTStore/admin/FTStoreAdmin/App/AdminDashboardView.swift
Felitendo 3e62b98577 refactor(admin): FTStoreAdmin/Console is FTStoreAdmin/App
The last place still calling the operator app a console. The Xcode project uses
a synchronized root group pointed at FTStoreAdmin itself, so it never named the
subfolder and the rename is a git mv and nothing else.

App/ rather than Screens/, because the folder holds the transport, the session
and the wire types as well as the views, and because the distinction that
actually matters here is against Support/: this app's own code, versus helpers
inlined from elsewhere with a note saying where they came from.

Both AGENTS.md files follow, including the note recording that the history runs
back through manager/FTStore/Admin/ — it now says the middle stretch was
Console/, since that is the part a git log --follow has to cross.
2026-08-10 21:16:57 +02:00

243 lines
6.2 KiB
Swift

//
// 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<AdminEnvelope<AdminMetricsOverview>>()
@StateObject private var _status = AdminLoader<AdminEnvelope<AdminDashboardStatus>>()
@StateObject private var _transfers = AdminLoader<AdminEnvelope<[AdminTransfer]>>()
@StateObject private var _activity = AdminLoader<AdminActivityResponse>()
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<AdminMetricsOverview>.self)
}
async let status: Void = _status.load {
try await session.get("/api/dashboard/status", as: AdminEnvelope<AdminDashboardStatus>.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)
}
}