mirror of
https://github.com/meshtastic/Meshtastic-Apple.git
synced 2026-04-20 22:13:56 +00:00
614 lines
16 KiB
Swift
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
|
|
}
|
|
}
|
|
}
|