SPB Git

spb/zyquo-router Public MIT

One local endpoint, every AI provider — a private OpenAI-compatible LLM gateway for your Mac (170 models, 12 providers).

Swift 95.7% Python 2.3% Shell 1.2% Makefile 0.9%
12.0 KB · 329 lines swift
Raw Blame History
1//2//  RequestsView.swift3//  Zyquo Router4//5//  Author: Simon-Pierre Boucher6//  Mail: contact@spboucher.ai7//8//  Live traffic: streaming log table (filters, pause/clear, export) and a9//  detail inspector with redacted-by-default bodies (explicit per-session10//  reveal) and the timing waterfall.11//1213import SwiftUI14import UniformTypeIdentifiers1516struct RequestsView: View {17    @EnvironmentObject private var server: ServerController1819    @State private var entries: [RequestLogEntry] = []20    @State private var revision = -121    @State private var paused = false22    @State private var filterText = ""23    @State private var providerFilter: ProviderID?24    @State private var errorsOnly = false25    @State private var selectedID: UUID?26    @AppStorage("logBodiesRevealed") private var bodiesRevealed = false27    @FocusState private var filterFocused: Bool2829    private let refresh = Timer.publish(every: 0.5, on: .main, in: .common).autoconnect()3031    private var filtered: [RequestLogEntry] {32        entries.reversed().filter { entry in33            if let providerFilter, entry.provider != providerFilter { return false }34            if errorsOnly, entry.status < 400 { return false }35            if !filterText.isEmpty,36               !entry.namespacedModelID.localizedCaseInsensitiveContains(filterText) { return false }37            return true38        }39    }4041    var body: some View {42        VStack(alignment: .leading, spacing: 0) {43            toolbar44            ZyquoHairline()45            if filtered.isEmpty {46                EmptyState(47                    systemImage: "antenna.radiowaves.left.and.right",48                    title: entries.isEmpty ? "No traffic yet" : "Nothing matches the filters",49                    message: entries.isEmpty50                        ? (server.isRunning51                            ? "Requests routed through \(server.endpointURL) will stream in here live."52                            : "Start the server, then point any OpenAI client at the endpoint.")53                        : "Adjust or clear the filters above."54                )55            } else {56                HSplitView {57                    table58                        .frame(minWidth: 460)59                    if let selected = filtered.first(where: { $0.id == selectedID }) {60                        RequestDetailPane(entry: selected, bodiesRevealed: $bodiesRevealed)61                            .frame(minWidth: 320)62                    }63                }64            }65        }66        .onReceive(refresh) { _ in67            guard !paused else { return }68            let log = server.requestLog69            Task {70                let newRevision = await log.revision71                if newRevision != revision {72                    revision = newRevision73                    entries = await log.entries74                }75            }76        }77    }7879    private var toolbar: some View {80        HStack(spacing: ZyquoSpacing.sm) {81            SectionHeader(title: "Requests")82            Spacer()83            Toggle("Errors only", isOn: $errorsOnly)84                .toggleStyle(.checkbox)85                .font(ZyquoFont.caption)86            Picker("", selection: $providerFilter) {87                Text("All providers").tag(ProviderID?.none)88                ForEach(ProviderID.builtIn) { provider in89                    Text(provider.displayName).tag(ProviderID?.some(provider))90                }91            }92            .labelsHidden()93            .frame(width: 150)94            TextField("Filter by model…  (⌘F)", text: $filterText)95                .textFieldStyle(.roundedBorder)96                .frame(width: 190)97                .focused($filterFocused)98            Button {99                paused.toggle()100            } label: {101                Image(systemName: paused ? "play.fill" : "pause.fill")102            }103            .help(paused ? "Resume live updates" : "Pause live updates")104            Button {105                let log = server.requestLog106                Task {107                    await log.clear()108                    entries = []109                    selectedID = nil110                }111            } label: {112                Image(systemName: "trash")113            }114            .help("Clear the log")115            Button {116                exportLog()117            } label: {118                Image(systemName: "square.and.arrow.up")119            }120            .help("Export log metadata as JSON")121        }122        .padding(.horizontal, ZyquoMetrics.contentInset)123        .padding(.vertical, ZyquoSpacing.sm)124        .background(125            // ⌘F routes here from the app menu.126            Button("") { filterFocused = true }127                .keyboardShortcut("f", modifiers: .command)128                .hidden()129        )130    }131132    private var table: some View {133        ScrollView {134            LazyVStack(spacing: 0) {135                ForEach(filtered) { entry in136                    RequestRow(entry: entry, selected: entry.id == selectedID)137                        .contentShape(Rectangle())138                        .onTapGesture {139                            selectedID = selectedID == entry.id ? nil : entry.id140                        }141                    ZyquoHairline()142                        .padding(.leading, ZyquoMetrics.contentInset)143                }144            }145        }146    }147148    private func exportLog() {149        let log = server.requestLog150        Task {151            let data = await log.exportJSON()152            let panel = NSSavePanel()153            panel.allowedContentTypes = [.json]154            panel.nameFieldStringValue = "zyquo-router-log.json"155            if panel.runModal() == .OK, let url = panel.url {156                try? data.write(to: url)157            }158        }159    }160}161162// MARK: - Row163164private struct RequestRow: View {165    let entry: RequestLogEntry166    let selected: Bool167    @State private var hovering = false168169    var body: some View {170        HStack(spacing: ZyquoSpacing.sm) {171            Text(entry.date, format: .dateTime.hour().minute().second())172                .font(ZyquoFont.mono(size: 11))173                .foregroundStyle(ZyquoColor.textSecondary)174                .frame(width: 64, alignment: .leading)175176            HStack(spacing: ZyquoSpacing.xs) {177                Circle()178                    .fill(ZyquoColor.providerHue(entry.provider))179                    .frame(width: 6, height: 6)180                Text(entry.namespacedModelID)181                    .font(ZyquoFont.mono(size: 11.5))182                    .foregroundStyle(ZyquoColor.textPrimary)183                    .lineLimit(1)184                    .truncationMode(.middle)185                if entry.streamed {186                    CapabilityBadge(label: "SSE", tint: ZyquoColor.accent)187                }188                if entry.usageEstimated {189                    CapabilityBadge(label: "EST", tint: ZyquoColor.warning)190                }191            }192            .frame(maxWidth: .infinity, alignment: .leading)193194            Text("\(entry.status)")195                .font(ZyquoFont.mono(size: 11, weight: .semibold))196                .foregroundStyle(entry.status < 400 ? ZyquoColor.success : ZyquoColor.danger)197                .frame(width: 36, alignment: .trailing)198199            Text(String(format: "%.2fs", entry.latency))200                .font(ZyquoFont.mono(size: 11))201                .foregroundStyle(ZyquoColor.textSecondary)202                .frame(width: 56, alignment: .trailing)203204            Text("\(entry.usage.inputTokens)\(entry.usage.outputTokens)")205                .font(ZyquoFont.mono(size: 11))206                .foregroundStyle(ZyquoColor.textSecondary)207                .frame(width: 84, alignment: .trailing)208209            Text(costText)210                .font(ZyquoFont.mono(size: 11))211                .foregroundStyle(ZyquoColor.textSecondary)212                .frame(width: 58, alignment: .trailing)213        }214        .padding(.horizontal, ZyquoMetrics.contentInset)215        .padding(.vertical, 6)216        .background(selected ? ZyquoColor.accentSubtle : (hovering ? ZyquoColor.surfaceSecondary : .clear))217        .onHover { hover in218            withAnimation(ZyquoMotion.hover) { hovering = hover }219        }220    }221222    private var costText: String {223        guard let cost = entry.estimatedCost, cost > 0 else { return "—" }224        return cost < 0.01 ? "<$0.01" : String(format: "$%.2f", cost)225    }226}227228// MARK: - Detail inspector229230private struct RequestDetailPane: View {231    let entry: RequestLogEntry232    @Binding var bodiesRevealed: Bool233234    var body: some View {235        ScrollView {236            VStack(alignment: .leading, spacing: ZyquoSpacing.md) {237                Text(entry.namespacedModelID)238                    .font(ZyquoFont.mono(size: 13, weight: .medium))239                    .foregroundStyle(ZyquoColor.textPrimary)240                    .textSelection(.enabled)241242                waterfall243244                if let error = entry.errorMessage {245                    Label(error, systemImage: "xmark.octagon")246                        .font(ZyquoFont.body())247                        .foregroundStyle(ZyquoColor.danger)248                        .textSelection(.enabled)249                }250251                Toggle("Reveal request/response bodies (this session)", isOn: $bodiesRevealed)252                    .toggleStyle(.switch)253                    .controlSize(.small)254                    .font(ZyquoFont.caption)255256                bodySection(title: "REQUEST", body: entry.requestBody)257                bodySection(title: "RESPONSE", body: entry.responseBody)258            }259            .padding(ZyquoSpacing.md)260        }261        .background(ZyquoColor.surface)262    }263264    private var waterfall: some View {265        VStack(alignment: .leading, spacing: ZyquoSpacing.xxs) {266            caption("TIMING")267            GeometryReader { proxy in268                let total = max(entry.latency, 0.001)269                let ttfb = min(entry.upstreamTTFB ?? entry.latency, total)270                HStack(spacing: 1) {271                    RoundedRectangle(cornerRadius: 2)272                        .fill(ZyquoColor.graphite.opacity(0.55))273                        .frame(width: max(proxy.size.width * (ttfb / total), 2))274                    RoundedRectangle(cornerRadius: 2)275                        .fill(ZyquoColor.accent)276                        .frame(maxWidth: .infinity)277                }278            }279            .frame(height: 8)280            HStack {281                Text(entry.upstreamTTFB.map { String(format: "upstream TTFB %.0f ms", $0 * 1000) } ?? "buffered call")282                Spacer()283                Text(String(format: "total %.2f s", entry.latency))284            }285            .font(ZyquoFont.mono(size: 10.5))286            .foregroundStyle(ZyquoColor.textSecondary)287        }288    }289290    @ViewBuilder291    private func bodySection(title: String, body text: String) -> some View {292        VStack(alignment: .leading, spacing: ZyquoSpacing.xxs) {293            HStack {294                caption(title)295                Spacer()296                if bodiesRevealed, !text.isEmpty {297                    CopyButton(value: text)298                }299            }300            Group {301                if !bodiesRevealed {302                    Text("Redacted — enable reveal above to inspect.")303                        .font(ZyquoFont.caption)304                        .foregroundStyle(ZyquoColor.textTertiary)305                        .frame(maxWidth: .infinity, alignment: .leading)306                } else {307                    Text(text.isEmpty ? "(empty)" : text)308                        .font(ZyquoFont.mono(size: 10.5))309                        .foregroundStyle(ZyquoColor.textPrimary)310                        .textSelection(.enabled)311                        .frame(maxWidth: .infinity, alignment: .leading)312                }313            }314            .padding(ZyquoSpacing.xs)315            .background(316                RoundedRectangle(cornerRadius: ZyquoRadius.small, style: .continuous)317                    .fill(ZyquoColor.surfaceSecondary)318            )319        }320    }321322    private func caption(_ text: String) -> some View {323        Text(text)324            .font(.system(size: 9.5, weight: .semibold))325            .foregroundStyle(ZyquoColor.textTertiary)326            .kerning(0.4)327    }328}329