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.
243 lines
6.2 KiB
Swift
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)
|
|
}
|
|
}
|