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.
329 lines
8.2 KiB
Swift
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()
|
|
}
|
|
}
|
|
}
|