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%
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