Files
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

161 lines
4.1 KiB
Swift

//
// AdminLogsView.swift
// FTStore Admin
//
// The server's activity log — the same paginated, filterable feed the web
// dashboard shows, which is usually the fastest way to find out why a build
// never made it into the feed.
//
import SwiftUI
struct AdminLogsView: View {
@EnvironmentObject private var session: AdminSession
@StateObject private var _logs = AdminLoader<AdminActivityResponse>()
@State private var _search = ""
@State private var _type: String?
@State private var _status: String?
@State private var _page = 1
private static let _types = ["scan", "download", "process", "generate", "cleanup", "system"]
private static let _statuses = ["success", "info", "warning", "error"]
var body: some View {
List {
_statsSection
Section {
if _logs.isLoading {
AdminLoadingRow()
} else if let error = _logs.errorMessage, !_logs.hasLoaded {
AdminErrorRow(message: error) { Task { await _reload() } }
} else if let entries = _logs.value?.data, entries.isEmpty {
AdminEmptyRow(
icon: "scroll",
title: "Nothing logged",
detail: "No entry matches the current filter."
)
} else if let entries = _logs.value?.data {
ForEach(entries) { entry in
AdminActivityRow(entry: entry)
}
}
} header: {
if let total = _logs.value?.total {
Text(
total == 1
? String.localized("1 entry")
: String.localized("%@ entries", AdminFormat.count(total))
)
}
}
_pagingSection
}
.navigationTitle("Logs")
.navigationBarTitleDisplayMode(.inline)
.searchable(text: $_search, prompt: "Message contains")
.refreshable { await _reload() }
.task { await _reload() }
.onChange(of: _search) { _ in _resetAndReload() }
.onChange(of: _type) { _ in _resetAndReload() }
.onChange(of: _status) { _ in _resetAndReload() }
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Menu {
Picker("Type", selection: $_type) {
Text("All types").tag(String?.none)
ForEach(Self._types, id: \.self) { type in
Text(type.capitalized).tag(String?.some(type))
}
}
Divider()
Picker("Status", selection: $_status) {
Text("All statuses").tag(String?.none)
ForEach(Self._statuses, id: \.self) { status in
Text(status.capitalized).tag(String?.some(status))
}
}
} label: {
Image(systemName: _type == nil && _status == nil
? "line.3.horizontal.decrease.circle"
: "line.3.horizontal.decrease.circle.fill")
}
}
}
}
// MARK: Sections
@ViewBuilder
private var _statsSection: some View {
if let stats = _logs.value?.stats, !stats.isEmpty {
Section {
HStack(spacing: 8) {
ForEach(stats.keys.sorted(), id: \.self) { key in
VStack(spacing: 2) {
Text(AdminFormat.count(stats[key] ?? 0))
.font(.subheadline.monospacedDigit())
.foregroundStyle(AdminStatusPill.tint(for: key))
Text(key.capitalized)
.font(.caption2)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity)
}
}
.padding(.vertical, 4)
}
}
}
@ViewBuilder
private var _pagingSection: some View {
if let response = _logs.value, response.totalPages > 1 {
Section {
HStack {
Button("Previous") {
_page = max(1, _page - 1)
Task { await _reload() }
}
.disabled(_page <= 1)
Spacer()
Text("Page \(response.page) of \(response.totalPages)")
.font(.caption)
.foregroundStyle(.secondary)
Spacer()
Button("Next") {
_page = min(response.totalPages, _page + 1)
Task { await _reload() }
}
.disabled(_page >= response.totalPages)
}
.font(.subheadline)
}
}
}
// MARK: Loading
private func _resetAndReload() {
_page = 1
Task { await _reload() }
}
private func _reload() async {
var query = ["page": String(_page), "pageSize": "50"]
if let _type { query["type"] = _type }
if let _status { query["status"] = _status }
let needle = _search.trimmingCharacters(in: .whitespaces)
if !needle.isEmpty { query["search"] = needle }
await _logs.load {
try await session.get("/api/dashboard/activity", query: query, as: AdminActivityResponse.self)
}
}
}