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

329 lines
8.2 KiB
Swift

//
// AdminQueueView.swift
// FTStore Admin
//
// What the scraper has found and what it is doing with it. This is where
// someone looks when downloads stop moving, so the storage cap gets its own
// banner exactly like the web page.
//
import SwiftUI
struct AdminQueueView: View {
@EnvironmentObject private var session: AdminSession
@StateObject private var _queue = AdminLoader<AdminQueueResponse>()
@State private var _status: String?
@State private var _search = ""
@State private var _page = 1
@State private var _busyIds: Set<Int> = []
@State private var _banner: String?
private static let _statuses = [
"pending", "downloading", "processing", "completed", "failed", "skipped",
]
var body: some View {
List {
_storageBanner
_statsSection
if let banner = _banner {
Section {
Text(banner).font(.footnote).foregroundStyle(.secondary)
}
}
_itemsSection
_pagingSection
_bulkSection
}
.navigationTitle("Queue")
.navigationBarTitleDisplayMode(.inline)
.searchable(text: $_search, prompt: "File name")
.refreshable { await _reload() }
.task { await _reload() }
.onChange(of: _search) { _ in
_page = 1
Task { await _reload() }
}
.onChange(of: _status) { _ in
_page = 1
Task { await _reload() }
}
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Menu {
Picker("Status", selection: $_status) {
Text("All").tag(String?.none)
ForEach(Self._statuses, id: \.self) { status in
Text(status.capitalized).tag(String?.some(status))
}
}
} label: {
Image(systemName: _status == nil
? "line.3.horizontal.decrease.circle"
: "line.3.horizontal.decrease.circle.fill")
}
}
}
}
// MARK: Sections
@ViewBuilder
private var _storageBanner: some View {
if let storage = _queue.value?.storage, storage.blocked {
Section {
Label(
"Storage is full (\(AdminFormat.bytes(storage.usedBytes)) of \(AdminFormat.bytes(storage.limitBytes))). Downloads stay pending until space frees up.",
systemImage: "externaldrive.badge.exclamationmark"
)
.font(.footnote)
.foregroundStyle(.red)
}
}
}
@ViewBuilder
private var _statsSection: some View {
if let stats = _queue.value?.stats {
Section {
LazyVGrid(
columns: [GridItem(.flexible(), spacing: 8), GridItem(.flexible(), spacing: 8), GridItem(.flexible(), spacing: 8)],
spacing: 8
) {
_stat("Pending", stats.pending, .orange)
_stat("Active", stats.active, AdminTheme.brand)
_stat("Done", stats.completed, .green)
_stat("Failed", stats.failed, .red)
_stat("Skipped", stats.skipped, .secondary)
_stat("Total", stats.pending + stats.active + stats.completed + stats.failed + stats.skipped, .primary)
}
.listRowInsets(EdgeInsets(top: 8, leading: 12, bottom: 8, trailing: 12))
.listRowBackground(Color.clear)
}
}
}
private func _stat(_ title: String, _ value: Int, _ tint: Color) -> some View {
VStack(spacing: 2) {
Text(AdminFormat.count(value))
.font(.headline.monospacedDigit())
.foregroundStyle(tint)
Text(title)
.font(.caption2)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 8)
.background(
Color(uiColor: .secondarySystemBackground),
in: RoundedRectangle(cornerRadius: 12, style: .continuous)
)
}
@ViewBuilder
private var _itemsSection: some View {
Section {
if _queue.isLoading {
AdminLoadingRow()
} else if let error = _queue.errorMessage, !_queue.hasLoaded {
AdminErrorRow(message: error) { Task { await _reload() } }
} else if let items = _queue.value?.data, items.isEmpty {
AdminEmptyRow(icon: "tray", title: "Queue is empty")
} else if let items = _queue.value?.data {
ForEach(items) { item in
_row(item)
.swipeActions(edge: .trailing) {
if item.status == "failed" {
Button {
_retry(item)
} label: {
Label("Retry", systemImage: "arrow.clockwise")
}
.tint(AdminTheme.brand)
}
Button(role: .destructive) {
_batch("delete", ids: [item.id])
} label: {
Label("Delete", systemImage: "trash")
}
Button {
_batch("skip", ids: [item.id])
} label: {
Label("Skip", systemImage: "forward.end")
}
.tint(.orange)
}
}
}
} header: {
if let total = _queue.value?.total {
Text(
total == 1
? String.localized("1 item")
: String.localized("%@ items", AdminFormat.count(total))
)
}
}
}
private func _row(_ item: AdminQueueItem) -> some View {
VStack(alignment: .leading, spacing: 4) {
Text(item.fileName ?? "Message \(item.messageId)")
.font(.subheadline)
.lineLimit(2)
HStack(spacing: 8) {
AdminStatusPill(status: item.status)
if let size = item.fileSize {
Text(AdminFormat.bytes(size))
.font(.caption)
.foregroundStyle(.secondary)
}
Text(_channelName(item.channelId))
.font(.caption2)
.foregroundStyle(.tertiary)
.lineLimit(1)
Spacer()
if _busyIds.contains(item.id) {
ProgressView()
} else {
Text(AdminFormat.relative(item.updatedAt))
.font(.caption2)
.foregroundStyle(.tertiary)
}
}
if let error = item.error, !error.isEmpty {
Text(error)
.font(.caption2)
.foregroundStyle(.red)
.lineLimit(2)
}
}
.padding(.vertical, 2)
}
@ViewBuilder
private var _pagingSection: some View {
if let response = _queue.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)
}
}
}
@ViewBuilder
private var _bulkSection: some View {
if let stats = _queue.value?.stats {
Section("Bulk") {
AdminActionRow(
title: "Retry All Failed",
icon: "arrow.clockwise",
detail: stats.failed == 1
? .localized("1 failed item")
: .localized("%lld failed items", stats.failed)
) {
let ack = try await session.act(
.post,
"/api/queue/retry",
body: AdminRequest.RetryAll(all: true)
)
await _reload()
return ack.summary
}
.disabled(stats.failed == 0)
AdminActionRow(
title: "Clear Completed",
icon: "trash",
detail: stats.completed == 1
? .localized("1 completed item")
: .localized("%lld completed items", stats.completed),
destructive: true,
confirmation: "Delete every completed queue entry?"
) {
let ack = try await session.act(
.post,
"/api/queue/batch",
body: AdminRequest.QueueBatch(action: "delete", status: "completed")
)
await _reload()
return ack.summary
}
.disabled(stats.completed == 0)
}
}
}
// MARK: Helpers
private func _channelName(_ id: String) -> String {
_queue.value?.channelNames?[id] ?? id
}
private func _reload() async {
var query = ["page": String(_page), "pageSize": "50"]
if let _status { query["status"] = _status }
let needle = _search.trimmingCharacters(in: .whitespaces)
if !needle.isEmpty { query["search"] = needle }
await _queue.load {
try await session.get("/api/queue", query: query, as: AdminQueueResponse.self)
}
}
private func _retry(_ item: AdminQueueItem) {
_run(item.id) {
try await session.act(
.post,
"/api/queue/retry",
body: AdminRequest.Identified(id: item.id)
).summary
}
}
private func _batch(_ action: String, ids: [Int]) {
_run(ids.first ?? 0) {
try await session.act(
.post,
"/api/queue/batch",
body: AdminRequest.QueueBatch(action: action, ids: ids)
).summary
}
}
private func _run(_ id: Int, _ operation: @escaping @MainActor () async throws -> String) {
_busyIds.insert(id)
Task { @MainActor in
do {
_banner = try await operation()
} catch {
_banner = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription
}
_busyIds.remove(id)
await _reload()
}
}
}