// // 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() @State private var _status: String? @State private var _search = "" @State private var _page = 1 @State private var _busyIds: Set = [] @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() } } }