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%
18.1 KB · 480 lines swift
Raw Blame History
1//2//  DashboardView.swift3//  Zyquo Router4//5//  Author: Simon-Pierre Boucher6//  Mail: contact@spboucher.ai7//8//  The hero screen: server card (status, Start/Stop, port, bind selector,9//  endpoint + copy-as snippets), live metric tiles, first-run onboarding.10//1112import SwiftUI1314struct DashboardView: View {15    @EnvironmentObject private var server: ServerController16    @EnvironmentObject private var vault: KeyVaultStore17    @EnvironmentObject private var localKeys: LocalKeysStore1819    @State private var totals: (requests: Int, tokens: Int, cost: Double, errors: Int) = (0, 0, 0, 0)20    @State private var uptimeText = "—"21    @State private var sparkline: [Int] = []22    @State private var activeStreams = 023    @State private var breakdown: [(provider: ProviderID, count: Int)] = []24    private let refresh = Timer.publish(every: 1, on: .main, in: .common).autoconnect()2526    private var hasAnyProviderKey: Bool {27        ProviderID.builtIn.contains { vault.hasKey(for: $0) }28    }2930    var body: some View {31        ScrollView {32            VStack(alignment: .leading, spacing: ZyquoSpacing.lg) {33                SectionHeader(34                    title: "Dashboard",35                    subtitle: "One local endpoint for every provider."36                )3738                if !hasAnyProviderKey {39                    OnboardingCard()40                }4142                ServerCard()4344                // Live tiles45                let columns = [GridItem(.adaptive(minimum: ZyquoMetrics.tileMinWidth), spacing: ZyquoSpacing.sm)]46                LazyVGrid(columns: columns, alignment: .leading, spacing: ZyquoSpacing.sm) {47                    StatTile(label: "Requests today", value: "\(totals.requests)")48                    StatTile(label: "Tokens today", value: compact(totals.tokens))49                    StatTile(50                        label: "Est. cost today",51                        value: totals.cost < 0.005 && totals.cost > 052                            ? "<$0.01"53                            : String(format: "$%.2f", totals.cost),54                        valueColor: ZyquoColor.accent55                    )56                    StatTile(57                        label: "Errors today",58                        value: "\(totals.errors)",59                        valueColor: totals.errors > 0 ? ZyquoColor.danger : ZyquoColor.textPrimary60                    )61                    StatTile(label: "Active streams", value: "\(activeStreams)",62                             valueColor: activeStreams > 0 ? ZyquoColor.accent : ZyquoColor.textPrimary)63                    StatTile(label: "Uptime", value: uptimeText)64                }6566                HStack(alignment: .top, spacing: ZyquoSpacing.sm) {67                    SparklineCard(title: "Requests / min (last 15 min)", values: sparkline)68                    ProviderBreakdownCard(breakdown: breakdown, total: totals.requests)69                }70            }71            .padding(ZyquoMetrics.contentInset)72            .frame(maxWidth: 860, alignment: .leading)73        }74        .frame(maxWidth: .infinity, alignment: .center)75        .onReceive(refresh) { _ in76            refreshTiles()77        }78    }7980    private func refreshTiles() {81        if let startedAt = server.startedAt {82            let seconds = Int(Date().timeIntervalSince(startedAt))83            uptimeText = seconds >= 360084                ? String(format: "%dh %02dm", seconds / 3600, (seconds % 3600) / 60)85                : String(format: "%dm %02ds", seconds / 60, seconds % 60)86        } else {87            uptimeText = "—"88        }89        let meter = server.usageMeter90        Task {91            let cutoff = Calendar.current.startOfDay(for: Date())92            let today = await meter.totals(since: cutoff)93            let perMinute = await meter.requestsPerMinute(minutes: 15)94            let streams = await meter.activeStreams95            let providers = await meter.providerBreakdown(since: cutoff)96            withAnimation(ZyquoMotion.live) {97                totals = (today.requests, today.usage.totalTokens, today.cost, today.errors)98                sparkline = perMinute99                activeStreams = streams100                breakdown = providers101            }102        }103    }104105    private func compact(_ value: Int) -> String {106        switch value {107        case 1_000_000...: return String(format: "%.1fM", Double(value) / 1_000_000)108        case 1_000...: return String(format: "%.1fK", Double(value) / 1_000)109        default: return "\(value)"110        }111    }112}113114// MARK: - Server card115116private struct ServerCard: View {117    @EnvironmentObject private var server: ServerController118    @EnvironmentObject private var localKeys: LocalKeysStore119    @State private var snippet: Snippet = .curl120121    var body: some View {122        VStack(alignment: .leading, spacing: ZyquoSpacing.md) {123            HStack(alignment: .center, spacing: ZyquoSpacing.md) {124                statusBlock125                Spacer()126                startStopButton127            }128129            ZyquoHairline()130131            HStack(spacing: ZyquoSpacing.lg) {132                // Port133                VStack(alignment: .leading, spacing: ZyquoSpacing.xxs) {134                    fieldLabel("PORT")135                    TextField("8787", value: $server.port, format: .number.grouping(.never))136                        .textFieldStyle(.plain)137                        .font(ZyquoFont.mono(size: 13, weight: .medium))138                        .frame(width: 64)139                        .padding(.horizontal, ZyquoSpacing.xs)140                        .padding(.vertical, 5)141                        .background(142                            RoundedRectangle(cornerRadius: ZyquoRadius.small, style: .continuous)143                                .fill(ZyquoColor.surfaceSecondary)144                        )145                        .disabled(server.isRunning)146                }147148                // Bind selector149                VStack(alignment: .leading, spacing: ZyquoSpacing.xxs) {150                    fieldLabel("BIND")151                    Picker("", selection: $server.bindLAN) {152                        Text("Localhost only").tag(false)153                        Text("LAN (requires local key)").tag(true)154                    }155                    .labelsHidden()156                    .pickerStyle(.menu)157                    .frame(width: 200)158                    .disabled(server.isRunning)159                }160161                Spacer()162            }163164            if server.bindLAN, !localKeys.hasEnabledKey {165                Label(166                    "LAN exposure requires at least one enabled local API key — create one in Keys.",167                    systemImage: "exclamationmark.triangle"168                )169                .font(ZyquoFont.caption)170                .foregroundStyle(ZyquoColor.warning)171            }172173            if case .failed(let message) = server.state {174                Label(message, systemImage: "xmark.octagon")175                    .font(ZyquoFont.body())176                    .foregroundStyle(ZyquoColor.danger)177            }178179            if server.isRunning {180                VStack(alignment: .leading, spacing: ZyquoSpacing.xs) {181                    fieldLabel("ENDPOINT")182                    HStack {183                        CopyField(value: server.endpointURL, size: 13)184                        Spacer()185                        Picker("", selection: $snippet) {186                            ForEach(Snippet.allCases) { choice in187                                Text(choice.rawValue).tag(choice)188                            }189                        }190                        .labelsHidden()191                        .pickerStyle(.segmented)192                        .frame(width: 220)193                    }194                    HStack(alignment: .top) {195                        Text(snippet.code(endpoint: server.endpointURL))196                            .font(ZyquoFont.mono(size: 11))197                            .foregroundStyle(ZyquoColor.textSecondary)198                            .textSelection(.enabled)199                            .frame(maxWidth: .infinity, alignment: .leading)200                        CopyButton(value: snippet.code(endpoint: server.endpointURL))201                    }202                    .padding(ZyquoSpacing.sm)203                    .background(204                        RoundedRectangle(cornerRadius: ZyquoRadius.small, style: .continuous)205                            .fill(ZyquoColor.surfaceSecondary)206                    )207                }208            }209        }210        .padding(ZyquoSpacing.lg)211        .zyquoCard()212        .animation(ZyquoMotion.state, value: server.isRunning)213    }214215    private var statusBlock: some View {216        HStack(spacing: ZyquoSpacing.sm) {217            Circle()218                .fill(statusColor)219                .frame(width: 12, height: 12)220                .overlay(221                    Circle()222                        .stroke(statusColor.opacity(0.25), lineWidth: 4)223                )224            VStack(alignment: .leading, spacing: 1) {225                Text(statusTitle)226                    .font(ZyquoFont.heading)227                    .foregroundStyle(ZyquoColor.textPrimary)228                Text(statusDetail)229                    .font(ZyquoFont.caption)230                    .foregroundStyle(ZyquoColor.textSecondary)231            }232        }233    }234235    private var startStopButton: some View {236        Button {237            server.toggle()238        } label: {239            HStack(spacing: 7) {240                Image(systemName: server.isRunning || server.state == .starting ? "stop.fill" : "play.fill")241                    .font(.system(size: 11, weight: .bold))242                Text(server.isRunning || server.state == .starting ? "Stop" : "Start")243                    .font(ZyquoFont.bodyEmphasis(size: 14))244            }245            .foregroundStyle(.white)246            .frame(width: 128, height: 38)247            .background(248                Capsule()249                    .fill(server.isRunning ? ZyquoColor.graphite : ZyquoColor.accent)250                    .shadow(251                        color: (server.isRunning ? ZyquoColor.graphite : ZyquoColor.accent).opacity(0.35),252                        radius: 10, y: 3253                    )254            )255        }256        .buttonStyle(.plain)257    }258259    private var statusColor: Color {260        switch server.state {261        case .running: return ZyquoColor.success262        case .starting: return ZyquoColor.warning263        case .failed: return ZyquoColor.danger264        case .stopped: return ZyquoColor.textTertiary265        }266    }267268    private var statusTitle: String {269        switch server.state {270        case .running(let port): return "Running on :\(port)"271        case .starting: return "Starting…"272        case .failed: return "Failed to start"273        case .stopped: return "Stopped"274        }275    }276277    private var statusDetail: String {278        switch server.state {279        case .running: return server.bindLAN ? "Serving on the local network" : "Serving on localhost only"280        case .starting: return "Binding the port"281        case .failed: return "See the error below"282        case .stopped: return "The gateway is offline"283        }284    }285286    private func fieldLabel(_ text: String) -> some View {287        Text(text)288            .font(.system(size: 9.5, weight: .semibold))289            .foregroundStyle(ZyquoColor.textTertiary)290            .kerning(0.4)291    }292}293294// MARK: - Copy-as snippets295296private enum Snippet: String, CaseIterable, Identifiable {297    case curl = "curl"298    case python = "Python"299    case javascript = "JS"300301    var id: String { rawValue }302303    func code(endpoint: String) -> String {304        switch self {305        case .curl:306            return """307            curl \(endpoint)/chat/completions \\308              -H "Content-Type: application/json" \\309              -d '{"model": "anthropic/claude-sonnet-4-5", "messages": [{"role": "user", "content": "Hello"}]}'310            """311        case .python:312            return """313            from openai import OpenAI314            client = OpenAI(base_url="\(endpoint)", api_key="zyquo")315            r = client.chat.completions.create(316                model="anthropic/claude-sonnet-4-5",317                messages=[{"role": "user", "content": "Hello"}],318            )319            """320        case .javascript:321            return """322            import OpenAI from "openai";323            const client = new OpenAI({ baseURL: "\(endpoint)", apiKey: "zyquo" });324            const r = await client.chat.completions.create({325              model: "anthropic/claude-sonnet-4-5",326              messages: [{ role: "user", content: "Hello" }],327            });328            """329        }330    }331}332333// MARK: - First-run onboarding334335private struct OnboardingCard: View {336    var body: some View {337        VStack(alignment: .leading, spacing: ZyquoSpacing.md) {338            Text("Three steps to one endpoint")339                .font(ZyquoFont.heading)340                .foregroundStyle(ZyquoColor.textPrimary)341            HStack(spacing: ZyquoSpacing.lg) {342                OnboardingStep(number: 1, title: "Add a provider key", detail: "Keys → Provider Keys")343                OnboardingStep(number: 2, title: "Pick a port", detail: "Default 8787")344                OnboardingStep(number: 3, title: "Press Start", detail: "Point any OpenAI SDK at it")345            }346        }347        .padding(ZyquoSpacing.lg)348        .frame(maxWidth: .infinity, alignment: .leading)349        .background(350            RoundedRectangle(cornerRadius: ZyquoRadius.medium, style: .continuous)351                .fill(ZyquoColor.accentSubtle)352                .overlay(353                    RoundedRectangle(cornerRadius: ZyquoRadius.medium, style: .continuous)354                        .strokeBorder(ZyquoColor.accent.opacity(0.25), lineWidth: ZyquoMetrics.hairline)355                )356        )357    }358}359360private struct OnboardingStep: View {361    let number: Int362    let title: String363    let detail: String364365    var body: some View {366        HStack(alignment: .top, spacing: ZyquoSpacing.xs) {367            Text("\(number)")368                .font(ZyquoFont.mono(size: 12, weight: .semibold))369                .foregroundStyle(.white)370                .frame(width: 20, height: 20)371                .background(Circle().fill(ZyquoColor.accent))372            VStack(alignment: .leading, spacing: 1) {373                Text(title)374                    .font(ZyquoFont.bodyEmphasis())375                    .foregroundStyle(ZyquoColor.textPrimary)376                Text(detail)377                    .font(ZyquoFont.caption)378                    .foregroundStyle(ZyquoColor.textSecondary)379            }380        }381    }382}383384// MARK: - Live charts385386/// Cyan requests-per-minute sparkline (chart token: requests = cyan).387private struct SparklineCard: View {388    let title: String389    let values: [Int]390391    var body: some View {392        VStack(alignment: .leading, spacing: ZyquoSpacing.xs) {393            Text(title.uppercased())394                .font(.system(size: 9.5, weight: .semibold))395                .foregroundStyle(ZyquoColor.textTertiary)396                .kerning(0.4)397            GeometryReader { proxy in398                let peak = max(values.max() ?? 1, 1)399                let barWidth = proxy.size.width / CGFloat(max(values.count, 1))400                HStack(alignment: .bottom, spacing: 2) {401                    ForEach(Array(values.enumerated()), id: \.offset) { _, value in402                        RoundedRectangle(cornerRadius: 1.5)403                            .fill(value == 0 ? AnyShapeStyle(ZyquoColor.border) : AnyShapeStyle(ZyquoColor.chartRequests))404                            .frame(405                                width: max(barWidth - 2, 2),406                                height: value == 0 ? 2 : max(proxy.size.height * CGFloat(value) / CGFloat(peak), 3)407                            )408                    }409                }410                .frame(maxHeight: .infinity, alignment: .bottom)411            }412            .frame(height: 56)413        }414        .padding(ZyquoSpacing.md)415        .frame(maxWidth: .infinity, alignment: .leading)416        .zyquoCard()417        .animation(ZyquoMotion.live, value: values)418    }419}420421/// Per-provider share of today's requests.422private struct ProviderBreakdownCard: View {423    let breakdown: [(provider: ProviderID, count: Int)]424    let total: Int425426    var body: some View {427        VStack(alignment: .leading, spacing: ZyquoSpacing.xs) {428            Text("BY PROVIDER (TODAY)")429                .font(.system(size: 9.5, weight: .semibold))430                .foregroundStyle(ZyquoColor.textTertiary)431                .kerning(0.4)432            if total == 0 {433                Text("No requests yet today.")434                    .font(ZyquoFont.caption)435                    .foregroundStyle(ZyquoColor.textTertiary)436                    .frame(maxWidth: .infinity, minHeight: 56, alignment: .leading)437            } else {438                GeometryReader { proxy in439                    HStack(spacing: 1) {440                        ForEach(breakdown, id: \.provider) { slice in441                            RoundedRectangle(cornerRadius: 2)442                                .fill(ZyquoColor.providerHue(slice.provider))443                                .frame(width: max(proxy.size.width * CGFloat(slice.count) / CGFloat(total), 3))444                        }445                    }446                }447                .frame(height: 10)448                FlowLegend(breakdown: breakdown)449            }450        }451        .padding(ZyquoSpacing.md)452        .frame(maxWidth: .infinity, alignment: .leading)453        .zyquoCard()454    }455}456457private struct FlowLegend: View {458    let breakdown: [(provider: ProviderID, count: Int)]459460    var body: some View {461        HStack(spacing: ZyquoSpacing.sm) {462            ForEach(breakdown.prefix(5), id: \.provider) { slice in463                HStack(spacing: 4) {464                    Circle()465                        .fill(ZyquoColor.providerHue(slice.provider))466                        .frame(width: 6, height: 6)467                    Text("\(slice.provider.displayName) \(slice.count)")468                        .font(ZyquoFont.caption)469                        .foregroundStyle(ZyquoColor.textSecondary)470                }471            }472            if breakdown.count > 5 {473                Text("+\(breakdown.count - 5) more")474                    .font(ZyquoFont.caption)475                    .foregroundStyle(ZyquoColor.textTertiary)476            }477        }478    }479}480