Meshtastic-Apple/Meshtastic/Views/Settings/RetryQueueView.swift
Benjamin Faershtein a612cf6518 working
2026-02-08 18:43:26 -08:00

614 lines
16 KiB
Swift

//
// RetryQueueView.swift
// Meshtastic
//
// Retry queue management view for debugging and queue management
//
import SwiftUI
import MeshtasticProtobufs
struct RetryQueueView: View {
@State private var queueItems: [RetryQueueItem] = []
@State private var selectedGroup: RetryGroup?
@State private var searchText = ""
@State private var filterType: MessageType?
@State private var showingDeleteAlert = false
@State private var itemToDelete: RetryQueueItem?
@State private var isRefreshing = false
@State private var refreshTimer: Timer?
@State private var currentDate = Date()
private var idiom: UIUserInterfaceIdiom { UIDevice.current.userInterfaceIdiom }
private var groupedItems: [RetryGroup] {
let items = filteredItems
let grouped = Dictionary(grouping: items) { item in
RetryGroupKey(originalMessageId: item.originalMessageId, messageType: item.messageType)
}
return grouped.values.compactMap { items -> RetryGroup? in
guard let first = items.first else { return nil }
let sortedItems = items.sorted { $0.createdAt < $1.createdAt }
return RetryGroup(
originalMessageId: first.originalMessageId,
messageType: first.messageType,
items: sortedItems,
createdAt: first.createdAt
)
}.sorted { $0.createdAt < $1.createdAt }
}
private var filteredItems: [RetryQueueItem] {
var items = queueItems
if let filter = filterType {
items = items.filter { $0.messageType == filter }
}
if !searchText.isEmpty {
items = items.filter {
$0.originalMessageId.description.localizedCaseInsensitiveContains(searchText) ||
$0.messageType.rawValue.localizedCaseInsensitiveContains(searchText) ||
($0.currentPacketId?.description.localizedCaseInsensitiveContains(searchText) ?? false)
}
}
return items
}
var body: some View {
VStack(spacing: 0) {
if idiom == .phone {
phoneContent
} else {
iPadContent
}
}
.searchable(text: $searchText, placement: .navigationBarDrawer, prompt: "Search queue")
.navigationTitle("Retry Queue\(groupedItems.isEmpty ? "" : " (\(groupedItems.count))")")
.sheet(item: $selectedGroup) { group in
RetryQueueDetailSheet(group: group, allItems: groupedItems)
.presentationDetents([.medium, .large])
.presentationDragIndicator(.visible)
}
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Menu {
Button {
filterType = nil
} label: {
Label("All Types", systemImage: "tray.full")
}
Divider()
ForEach(MessageType.allCases, id: \.self) { type in
Button {
filterType = type
} label: {
Label(type.rawValue.capitalized, systemImage: type.icon)
}
}
} label: {
Image(systemName: filterType == nil ? "line.3.horizontal.decrease.circle" : "line.3.horizontal.decrease.circle.fill")
}
}
ToolbarItem(placement: .navigationBarTrailing) {
Button {
Task {
await refreshQueue()
}
} label: {
Image(systemName: "arrow.clockwise")
}
.disabled(isRefreshing)
}
if !groupedItems.isEmpty {
let hasAnyPending = groupedItems.contains { group in
group.items.contains { $0.state == .pending || $0.state == .sending || $0.state == .waitingForAck }
}
ToolbarItem(placement: .navigationBarTrailing) {
Menu {
Button(role: .destructive) {
itemToDelete = nil
showingDeleteAlert = true
} label: {
Label("Clear All", systemImage: "trash")
}
if hasAnyPending {
Button {
Task {
await MessageRetryQueueManager.shared.clearAll()
await refreshQueue()
}
} label: {
Label("Cancel All Retries", systemImage: "stop.circle")
}
}
} label: {
Image(systemName: "ellipsis.circle")
}
}
}
}
.alert("Delete Item?", isPresented: $showingDeleteAlert) {
Button("Cancel", role: .cancel) {
itemToDelete = nil
}
Button("Delete", role: .destructive) {
if let item = itemToDelete {
Task {
await MessageRetryQueueManager.shared.cancelRetry(forItemId: item.id)
await refreshQueue()
}
} else {
Task {
for group in groupedItems {
for item in group.items {
await MessageRetryQueueManager.shared.cancelRetry(forItemId: item.id)
}
}
await refreshQueue()
}
}
}
} message: {
if let item = itemToDelete {
Text("Delete retry for message \(item.originalMessageId.toHex())?")
} else {
Text("Delete \(groupedItems.count) group(s) from the queue?")
}
}
.task {
await refreshQueue()
startRefreshTimer()
}
.onDisappear {
stopRefreshTimer()
}
}
private func startRefreshTimer() {
stopRefreshTimer()
refreshTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
currentDate = Date()
}
}
private func stopRefreshTimer() {
refreshTimer?.invalidate()
refreshTimer = nil
}
private func refreshQueue() async {
isRefreshing = true
queueItems = await MessageRetryQueueManager.shared.getQueue()
isRefreshing = false
}
@ViewBuilder
private var phoneContent: some View {
if groupedItems.isEmpty {
ContentUnavailableView(
queueItems.isEmpty ? "Queue Empty" : "No Matching Items",
systemImage: queueItems.isEmpty ? "checkmark.circle" : "magnifyingglass"
)
} else {
List {
ForEach(groupedItems) { group in
RetryGroupRow(group: group, currentDate: currentDate)
.contentShape(Rectangle())
.onTapGesture {
selectedGroup = group
}
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button(role: .destructive) {
itemToDelete = group.items.first
showingDeleteAlert = true
} label: {
Label("Delete", systemImage: "trash")
}
}
.swipeActions(edge: .leading) {
if group.items.contains(where: { $0.state == .pending || $0.state == .sending || $0.state == .waitingForAck }) {
Button {
Task {
for item in group.items {
await MessageRetryQueueManager.shared.cancelRetry(forItemId: item.id)
}
await refreshQueue()
}
} label: {
Label("Stop", systemImage: "stop.circle")
}
.tint(.orange)
}
}
}
}
.listStyle(.plain)
}
}
@ViewBuilder
private var iPadContent: some View {
if groupedItems.isEmpty {
ContentUnavailableView(
queueItems.isEmpty ? "Queue Empty" : "No Matching Items",
systemImage: queueItems.isEmpty ? "checkmark.circle" : "magnifyingglass"
)
} else {
Table(groupedItems, selection: Binding(
get: { selectedGroup?.originalMessageId },
set: { newId in
selectedGroup = groupedItems.first { $0.originalMessageId == newId }
}
)) {
TableColumn("Type") { group in
HStack(spacing: 4) {
Image(systemName: group.messageType.icon)
.symbolRenderingMode(.hierarchical)
.foregroundColor(group.messageType.color)
Text(group.messageType.rawValue.capitalized)
}
}
.width(min: 80, max: 120)
TableColumn("Retries") { group in
let pendingItems = group.items.filter { $0.state == .pending }
if pendingItems.count > 1 {
Text("Attempts \(pendingItems.first?.displayAttemptNumber ?? 0)-\(pendingItems.last?.displayAttemptNumber ?? 0)")
} else if let first = group.items.first {
Text("Attempt \(first.displayAttemptNumber)")
}
}
.width(min: 80, max: 100)
TableColumn("Next") { group in
if let nextRetry = group.nextRetryDate {
let seconds = Int(nextRetry.timeIntervalSince(currentDate))
if seconds > 0 {
Text("\(seconds)s")
.foregroundColor(.orange)
} else {
Text("Now")
.foregroundColor(.red)
}
} else {
Text("-")
}
}
.width(min: 60, max: 80)
TableColumn("Status") { group in
let state = group.items.first?.state ?? .pending
Text(state.rawValue.capitalized)
.font(.caption)
.foregroundColor(state.color)
}
.width(min: 100, max: 120)
}
.onChange(of: selectedGroup) { _, newGroup in
if newGroup != nil {
selectedGroup = newGroup
}
}
}
}
}
struct RetryGroup: Identifiable, Hashable {
let id: Int64
let originalMessageId: Int64
let messageType: MessageType
let items: [RetryQueueItem]
let createdAt: Date
var nextRetryDate: Date? {
items.filter { $0.state == .pending }.map { $0.nextRetryDate }.min()
}
init(originalMessageId: Int64, messageType: MessageType, items: [RetryQueueItem], createdAt: Date) {
self.id = originalMessageId
self.originalMessageId = originalMessageId
self.messageType = messageType
self.items = items
self.createdAt = createdAt
}
func hash(into hasher: inout Hasher) {
hasher.combine(originalMessageId)
}
static func == (lhs: RetryGroup, rhs: RetryGroup) -> Bool {
lhs.originalMessageId == rhs.originalMessageId
}
}
struct RetryGroupKey: Hashable {
let originalMessageId: Int64
let messageType: MessageType
}
struct RetryGroupRow: View {
let group: RetryGroup
let currentDate: Date
var body: some View {
HStack(spacing: 12) {
Image(systemName: group.messageType.icon)
.symbolRenderingMode(.hierarchical)
.font(.title2)
.foregroundColor(group.messageType.color)
.frame(width: 32)
VStack(alignment: .leading, spacing: 4) {
HStack {
Text(group.messageType.rawValue.capitalized)
.font(.headline)
let state = group.items.first?.state ?? .pending
Text(state.rawValue.capitalized)
.font(.caption)
.padding(.horizontal, 8)
.padding(.vertical, 2)
.background(state.color.opacity(0.2))
.foregroundColor(state.color)
.clipShape(Capsule())
}
HStack(spacing: 8) {
Text(group.originalMessageId.toHex())
.font(.caption.monospaced())
Text(group.messageType.rawValue.capitalized)
.font(.caption)
.foregroundColor(.secondary)
}
HStack(spacing: 8) {
let pendingItems = group.items.filter { $0.state == .pending }
if pendingItems.count > 1 {
Label {
Text("Attempts \(pendingItems.first?.displayAttemptNumber ?? 0)-\(pendingItems.last?.displayAttemptNumber ?? 0)")
.font(.caption)
.foregroundColor(.secondary)
} icon: {
Image(systemName: "arrow.clockwise")
}
} else if let first = group.items.first {
Label {
Text("Attempt \(first.displayAttemptNumber)")
.font(.caption)
.foregroundColor(.secondary)
} icon: {
Image(systemName: "arrow.clockwise")
}
}
Spacer()
if let nextRetry = group.nextRetryDate {
let seconds = Int(nextRetry.timeIntervalSince(currentDate))
if seconds > 0 {
Text("In \(seconds)s")
.font(.caption)
.foregroundColor(.orange)
.monospacedDigit()
} else {
Text("Now")
.font(.caption)
.foregroundColor(.red)
}
}
}
}
Spacer()
}
.padding(.vertical, 4)
}
}
struct RetryQueueDetailSheet: View {
let group: RetryGroup
let allItems: [RetryGroup]
@Environment(\.dismiss) private var dismiss
@State private var isCancelling = false
private var currentState: RetryState {
group.items.first?.state ?? .pending
}
var body: some View {
NavigationStack {
ScrollView {
LazyVStack(alignment: .leading, spacing: 16) {
Section {
HStack {
Image(systemName: group.messageType.icon)
.symbolRenderingMode(.hierarchical)
.font(.largeTitle)
.foregroundColor(group.messageType.color)
VStack(alignment: .leading) {
Text(group.messageType.rawValue.capitalized)
.font(.headline)
Text("ID: \(group.originalMessageId.toHex())")
.font(.caption.monospaced())
.foregroundColor(.secondary)
}
Spacer()
Text(currentState.rawValue.capitalized)
.font(.caption)
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(currentState.color.opacity(0.2))
.foregroundColor(currentState.color)
.clipShape(Capsule())
}
}
Divider()
Section("Retry Attempts") {
ForEach(group.items.indices, id: \.self) { index in
let item = group.items[index]
HStack {
Image(systemName: item.state.icon)
.symbolRenderingMode(.hierarchical)
.foregroundColor(item.state.color)
VStack(alignment: .leading) {
Text("Attempt \(item.displayAttemptNumber)")
.font(.subheadline)
HStack {
if let currentId = item.currentPacketId {
Text("ID: \(currentId.toHex())")
.font(.caption.monospaced())
} else {
Text("Not sent yet")
.font(.caption)
}
if item.state == .pending {
Text("")
.foregroundColor(.secondary)
let seconds = Int(item.nextRetryDate.timeIntervalSince(Date()))
if seconds > 0 {
Text("In \(seconds)s")
.font(.caption)
.foregroundColor(.orange)
.monospacedDigit()
}
}
}
}
Spacer()
Text(item.state.rawValue.capitalized)
.font(.caption)
.foregroundColor(item.state.color)
}
.padding(.vertical, 4)
}
}
Divider()
Section("Created") {
HStack {
Text("Time:")
Spacer()
Text(group.createdAt.formatted(date: .abbreviated, time: .shortened))
}
}
Section {
let hasPendingRetries = group.items.contains(where: { $0.state == .pending || $0.state == .sending || $0.state == .waitingForAck })
Button(role: .destructive) {
isCancelling = true
} label: {
HStack {
Spacer()
if isCancelling {
ProgressView()
} else {
Label("Cancel All Retries", systemImage: "stop.circle.fill")
}
Spacer()
}
}
.disabled(isCancelling || !hasPendingRetries)
}
}
.padding()
}
.navigationTitle("Retry Details")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button("Done") {
dismiss()
}
}
}
.task {
if isCancelling {
for item in group.items {
await MessageRetryQueueManager.shared.cancelRetry(forItemId: item.id)
}
isCancelling = false
dismiss()
}
}
}
}
}
extension RetryState {
var icon: String {
switch self {
case .pending: return "clock"
case .sending: return "arrow.up.circle"
case .waitingForAck: return "hourglass"
case .completed: return "checkmark.circle"
case .failed: return "xmark.circle"
case .cancelled: return "minus.circle"
}
}
}
extension RetryQueueDetailSheet: Identifiable {
var id: Int64 { group.originalMessageId }
}
extension MessageType {
var icon: String {
switch self {
case .text: return "message"
case .position: return "location"
case .waypoint: return "mappin.circle"
case .admin: return "lock.shield"
case .traceroute: return "point.topleft.down.curvedto.point.bottomright.up"
case .nodeInfo: return "person.circle"
case .unknown: return "questionmark.circle"
}
}
var color: Color {
switch self {
case .text: return .blue
case .position: return .green
case .waypoint: return .orange
case .admin: return .purple
case .traceroute: return .cyan
case .nodeInfo: return .indigo
case .unknown: return .gray
}
}
}
extension RetryState {
var color: Color {
switch self {
case .pending: return .orange
case .sending: return .blue
case .waitingForAck: return .purple
case .completed: return .green
case .failed: return .red
case .cancelled: return .gray
}
}
}