// // DashboardView.swift // Zyquo Router // // Author: Simon-Pierre Boucher // Mail: contact@spboucher.ai // // The hero screen: server card (status, Start/Stop, port, bind selector, // endpoint + copy-as snippets), live metric tiles, first-run onboarding. // import SwiftUI struct DashboardView: View { @EnvironmentObject private var server: ServerController @EnvironmentObject private var vault: KeyVaultStore @EnvironmentObject private var localKeys: LocalKeysStore @State private var totals: (requests: Int, tokens: Int, cost: Double, errors: Int) = (0, 0, 0, 0) @State private var uptimeText = "—" @State private var sparkline: [Int] = [] @State private var activeStreams = 0 @State private var breakdown: [(provider: ProviderID, count: Int)] = [] private let refresh = Timer.publish(every: 1, on: .main, in: .common).autoconnect() private var hasAnyProviderKey: Bool { ProviderID.builtIn.contains { vault.hasKey(for: $0) } } var body: some View { ScrollView { VStack(alignment: .leading, spacing: ZyquoSpacing.lg) { SectionHeader( title: "Dashboard", subtitle: "One local endpoint for every provider." ) if !hasAnyProviderKey { OnboardingCard() } ServerCard() // Live tiles let columns = [GridItem(.adaptive(minimum: ZyquoMetrics.tileMinWidth), spacing: ZyquoSpacing.sm)] LazyVGrid(columns: columns, alignment: .leading, spacing: ZyquoSpacing.sm) { StatTile(label: "Requests today", value: "\(totals.requests)") StatTile(label: "Tokens today", value: compact(totals.tokens)) StatTile( label: "Est. cost today", value: totals.cost < 0.005 && totals.cost > 0 ? "<$0.01" : String(format: "$%.2f", totals.cost), valueColor: ZyquoColor.accent ) StatTile( label: "Errors today", value: "\(totals.errors)", valueColor: totals.errors > 0 ? ZyquoColor.danger : ZyquoColor.textPrimary ) StatTile(label: "Active streams", value: "\(activeStreams)", valueColor: activeStreams > 0 ? ZyquoColor.accent : ZyquoColor.textPrimary) StatTile(label: "Uptime", value: uptimeText) } HStack(alignment: .top, spacing: ZyquoSpacing.sm) { SparklineCard(title: "Requests / min (last 15 min)", values: sparkline) ProviderBreakdownCard(breakdown: breakdown, total: totals.requests) } } .padding(ZyquoMetrics.contentInset) .frame(maxWidth: 860, alignment: .leading) } .frame(maxWidth: .infinity, alignment: .center) .onReceive(refresh) { _ in refreshTiles() } } private func refreshTiles() { if let startedAt = server.startedAt { let seconds = Int(Date().timeIntervalSince(startedAt)) uptimeText = seconds >= 3600 ? String(format: "%dh %02dm", seconds / 3600, (seconds % 3600) / 60) : String(format: "%dm %02ds", seconds / 60, seconds % 60) } else { uptimeText = "—" } let meter = server.usageMeter Task { let cutoff = Calendar.current.startOfDay(for: Date()) let today = await meter.totals(since: cutoff) let perMinute = await meter.requestsPerMinute(minutes: 15) let streams = await meter.activeStreams let providers = await meter.providerBreakdown(since: cutoff) withAnimation(ZyquoMotion.live) { totals = (today.requests, today.usage.totalTokens, today.cost, today.errors) sparkline = perMinute activeStreams = streams breakdown = providers } } } private func compact(_ value: Int) -> String { switch value { case 1_000_000...: return String(format: "%.1fM", Double(value) / 1_000_000) case 1_000...: return String(format: "%.1fK", Double(value) / 1_000) default: return "\(value)" } } } // MARK: - Server card private struct ServerCard: View { @EnvironmentObject private var server: ServerController @EnvironmentObject private var localKeys: LocalKeysStore @State private var snippet: Snippet = .curl var body: some View { VStack(alignment: .leading, spacing: ZyquoSpacing.md) { HStack(alignment: .center, spacing: ZyquoSpacing.md) { statusBlock Spacer() startStopButton } ZyquoHairline() HStack(spacing: ZyquoSpacing.lg) { // Port VStack(alignment: .leading, spacing: ZyquoSpacing.xxs) { fieldLabel("PORT") TextField("8787", value: $server.port, format: .number.grouping(.never)) .textFieldStyle(.plain) .font(ZyquoFont.mono(size: 13, weight: .medium)) .frame(width: 64) .padding(.horizontal, ZyquoSpacing.xs) .padding(.vertical, 5) .background( RoundedRectangle(cornerRadius: ZyquoRadius.small, style: .continuous) .fill(ZyquoColor.surfaceSecondary) ) .disabled(server.isRunning) } // Bind selector VStack(alignment: .leading, spacing: ZyquoSpacing.xxs) { fieldLabel("BIND") Picker("", selection: $server.bindLAN) { Text("Localhost only").tag(false) Text("LAN (requires local key)").tag(true) } .labelsHidden() .pickerStyle(.menu) .frame(width: 200) .disabled(server.isRunning) } Spacer() } if server.bindLAN, !localKeys.hasEnabledKey { Label( "LAN exposure requires at least one enabled local API key — create one in Keys.", systemImage: "exclamationmark.triangle" ) .font(ZyquoFont.caption) .foregroundStyle(ZyquoColor.warning) } if case .failed(let message) = server.state { Label(message, systemImage: "xmark.octagon") .font(ZyquoFont.body()) .foregroundStyle(ZyquoColor.danger) } if server.isRunning { VStack(alignment: .leading, spacing: ZyquoSpacing.xs) { fieldLabel("ENDPOINT") HStack { CopyField(value: server.endpointURL, size: 13) Spacer() Picker("", selection: $snippet) { ForEach(Snippet.allCases) { choice in Text(choice.rawValue).tag(choice) } } .labelsHidden() .pickerStyle(.segmented) .frame(width: 220) } HStack(alignment: .top) { Text(snippet.code(endpoint: server.endpointURL)) .font(ZyquoFont.mono(size: 11)) .foregroundStyle(ZyquoColor.textSecondary) .textSelection(.enabled) .frame(maxWidth: .infinity, alignment: .leading) CopyButton(value: snippet.code(endpoint: server.endpointURL)) } .padding(ZyquoSpacing.sm) .background( RoundedRectangle(cornerRadius: ZyquoRadius.small, style: .continuous) .fill(ZyquoColor.surfaceSecondary) ) } } } .padding(ZyquoSpacing.lg) .zyquoCard() .animation(ZyquoMotion.state, value: server.isRunning) } private var statusBlock: some View { HStack(spacing: ZyquoSpacing.sm) { Circle() .fill(statusColor) .frame(width: 12, height: 12) .overlay( Circle() .stroke(statusColor.opacity(0.25), lineWidth: 4) ) VStack(alignment: .leading, spacing: 1) { Text(statusTitle) .font(ZyquoFont.heading) .foregroundStyle(ZyquoColor.textPrimary) Text(statusDetail) .font(ZyquoFont.caption) .foregroundStyle(ZyquoColor.textSecondary) } } } private var startStopButton: some View { Button { server.toggle() } label: { HStack(spacing: 7) { Image(systemName: server.isRunning || server.state == .starting ? "stop.fill" : "play.fill") .font(.system(size: 11, weight: .bold)) Text(server.isRunning || server.state == .starting ? "Stop" : "Start") .font(ZyquoFont.bodyEmphasis(size: 14)) } .foregroundStyle(.white) .frame(width: 128, height: 38) .background( Capsule() .fill(server.isRunning ? ZyquoColor.graphite : ZyquoColor.accent) .shadow( color: (server.isRunning ? ZyquoColor.graphite : ZyquoColor.accent).opacity(0.35), radius: 10, y: 3 ) ) } .buttonStyle(.plain) } private var statusColor: Color { switch server.state { case .running: return ZyquoColor.success case .starting: return ZyquoColor.warning case .failed: return ZyquoColor.danger case .stopped: return ZyquoColor.textTertiary } } private var statusTitle: String { switch server.state { case .running(let port): return "Running on :\(port)" case .starting: return "Starting…" case .failed: return "Failed to start" case .stopped: return "Stopped" } } private var statusDetail: String { switch server.state { case .running: return server.bindLAN ? "Serving on the local network" : "Serving on localhost only" case .starting: return "Binding the port" case .failed: return "See the error below" case .stopped: return "The gateway is offline" } } private func fieldLabel(_ text: String) -> some View { Text(text) .font(.system(size: 9.5, weight: .semibold)) .foregroundStyle(ZyquoColor.textTertiary) .kerning(0.4) } } // MARK: - Copy-as snippets private enum Snippet: String, CaseIterable, Identifiable { case curl = "curl" case python = "Python" case javascript = "JS" var id: String { rawValue } func code(endpoint: String) -> String { switch self { case .curl: return """ curl \(endpoint)/chat/completions \\ -H "Content-Type: application/json" \\ -d '{"model": "anthropic/claude-sonnet-4-5", "messages": [{"role": "user", "content": "Hello"}]}' """ case .python: return """ from openai import OpenAI client = OpenAI(base_url="\(endpoint)", api_key="zyquo") r = client.chat.completions.create( model="anthropic/claude-sonnet-4-5", messages=[{"role": "user", "content": "Hello"}], ) """ case .javascript: return """ import OpenAI from "openai"; const client = new OpenAI({ baseURL: "\(endpoint)", apiKey: "zyquo" }); const r = await client.chat.completions.create({ model: "anthropic/claude-sonnet-4-5", messages: [{ role: "user", content: "Hello" }], }); """ } } } // MARK: - First-run onboarding private struct OnboardingCard: View { var body: some View { VStack(alignment: .leading, spacing: ZyquoSpacing.md) { Text("Three steps to one endpoint") .font(ZyquoFont.heading) .foregroundStyle(ZyquoColor.textPrimary) HStack(spacing: ZyquoSpacing.lg) { OnboardingStep(number: 1, title: "Add a provider key", detail: "Keys → Provider Keys") OnboardingStep(number: 2, title: "Pick a port", detail: "Default 8787") OnboardingStep(number: 3, title: "Press Start", detail: "Point any OpenAI SDK at it") } } .padding(ZyquoSpacing.lg) .frame(maxWidth: .infinity, alignment: .leading) .background( RoundedRectangle(cornerRadius: ZyquoRadius.medium, style: .continuous) .fill(ZyquoColor.accentSubtle) .overlay( RoundedRectangle(cornerRadius: ZyquoRadius.medium, style: .continuous) .strokeBorder(ZyquoColor.accent.opacity(0.25), lineWidth: ZyquoMetrics.hairline) ) ) } } private struct OnboardingStep: View { let number: Int let title: String let detail: String var body: some View { HStack(alignment: .top, spacing: ZyquoSpacing.xs) { Text("\(number)") .font(ZyquoFont.mono(size: 12, weight: .semibold)) .foregroundStyle(.white) .frame(width: 20, height: 20) .background(Circle().fill(ZyquoColor.accent)) VStack(alignment: .leading, spacing: 1) { Text(title) .font(ZyquoFont.bodyEmphasis()) .foregroundStyle(ZyquoColor.textPrimary) Text(detail) .font(ZyquoFont.caption) .foregroundStyle(ZyquoColor.textSecondary) } } } } // MARK: - Live charts /// Cyan requests-per-minute sparkline (chart token: requests = cyan). private struct SparklineCard: View { let title: String let values: [Int] var body: some View { VStack(alignment: .leading, spacing: ZyquoSpacing.xs) { Text(title.uppercased()) .font(.system(size: 9.5, weight: .semibold)) .foregroundStyle(ZyquoColor.textTertiary) .kerning(0.4) GeometryReader { proxy in let peak = max(values.max() ?? 1, 1) let barWidth = proxy.size.width / CGFloat(max(values.count, 1)) HStack(alignment: .bottom, spacing: 2) { ForEach(Array(values.enumerated()), id: \.offset) { _, value in RoundedRectangle(cornerRadius: 1.5) .fill(value == 0 ? AnyShapeStyle(ZyquoColor.border) : AnyShapeStyle(ZyquoColor.chartRequests)) .frame( width: max(barWidth - 2, 2), height: value == 0 ? 2 : max(proxy.size.height * CGFloat(value) / CGFloat(peak), 3) ) } } .frame(maxHeight: .infinity, alignment: .bottom) } .frame(height: 56) } .padding(ZyquoSpacing.md) .frame(maxWidth: .infinity, alignment: .leading) .zyquoCard() .animation(ZyquoMotion.live, value: values) } } /// Per-provider share of today's requests. private struct ProviderBreakdownCard: View { let breakdown: [(provider: ProviderID, count: Int)] let total: Int var body: some View { VStack(alignment: .leading, spacing: ZyquoSpacing.xs) { Text("BY PROVIDER (TODAY)") .font(.system(size: 9.5, weight: .semibold)) .foregroundStyle(ZyquoColor.textTertiary) .kerning(0.4) if total == 0 { Text("No requests yet today.") .font(ZyquoFont.caption) .foregroundStyle(ZyquoColor.textTertiary) .frame(maxWidth: .infinity, minHeight: 56, alignment: .leading) } else { GeometryReader { proxy in HStack(spacing: 1) { ForEach(breakdown, id: \.provider) { slice in RoundedRectangle(cornerRadius: 2) .fill(ZyquoColor.providerHue(slice.provider)) .frame(width: max(proxy.size.width * CGFloat(slice.count) / CGFloat(total), 3)) } } } .frame(height: 10) FlowLegend(breakdown: breakdown) } } .padding(ZyquoSpacing.md) .frame(maxWidth: .infinity, alignment: .leading) .zyquoCard() } } private struct FlowLegend: View { let breakdown: [(provider: ProviderID, count: Int)] var body: some View { HStack(spacing: ZyquoSpacing.sm) { ForEach(breakdown.prefix(5), id: \.provider) { slice in HStack(spacing: 4) { Circle() .fill(ZyquoColor.providerHue(slice.provider)) .frame(width: 6, height: 6) Text("\(slice.provider.displayName) \(slice.count)") .font(ZyquoFont.caption) .foregroundStyle(ZyquoColor.textSecondary) } } if breakdown.count > 5 { Text("+\(breakdown.count - 5) more") .font(ZyquoFont.caption) .foregroundStyle(ZyquoColor.textTertiary) } } } }