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.
161 lines
4.1 KiB
Swift
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)
|
|
}
|
|
}
|
|
}
|