mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-17 12:16:59 +00:00
feat(bar-app): add CCS icon variants and usage analytics UI
- Menu-bar icon from the CCS logo: color mark + monochrome template that macOS auto-tints; footer toggle, persisted in UserDefaults - Dropdown analytics section: today/7d/30d/all-time spend, 7-day sparkline, top-model bars (BarAnalytics model + client + view) - Polished rows: branded header, colored health dots, provider/tier chips - package_app.sh bundles the icon assets into the .app - assert harness covers analytics decode + compact money/count formatting
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 6.1 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.5 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.6 KiB |
@@ -36,6 +36,12 @@ mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources"
|
||||
cp "$BIN" "$APP/Contents/MacOS/$EXEC_NAME"
|
||||
sed "s/__VERSION__/$VERSION/g" "$ROOT/Resources/Info.plist" > "$APP/Contents/Info.plist"
|
||||
|
||||
# Bundle the CCS icon assets (menu-bar color/template + header logo) so
|
||||
# Bundle.main can resolve them at runtime.
|
||||
if [[ -d "$ROOT/Resources/Assets" ]]; then
|
||||
cp "$ROOT/Resources/Assets/"*.png "$APP/Contents/Resources/" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
echo "[i] Signing ($SIGNING)..."
|
||||
case "$SIGNING" in
|
||||
adhoc)
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import SwiftUI
|
||||
import CCSBarCore
|
||||
|
||||
/// Usage analytics block: spend rollups, a 7-day cost sparkline, and top models.
|
||||
struct BarAnalyticsView: View {
|
||||
let analytics: BarAnalytics
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
SectionLabel("Usage")
|
||||
|
||||
// Spend rollups, 2 x 2.
|
||||
VStack(spacing: 6) {
|
||||
HStack(spacing: 6) {
|
||||
StatCell(title: "Today", value: BarFormatting.money(analytics.today.cost))
|
||||
StatCell(title: "7 days", value: BarFormatting.money(analytics.last7d.cost))
|
||||
}
|
||||
HStack(spacing: 6) {
|
||||
StatCell(title: "30 days", value: BarFormatting.money(analytics.last30d.cost))
|
||||
StatCell(title: "All-time", value: BarFormatting.money(analytics.allTime.cost), accent: true)
|
||||
}
|
||||
}
|
||||
|
||||
// 7-day sparkline.
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
HStack {
|
||||
Text("Last 7 days").font(.caption2).foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
Text("\(BarFormatting.count(analytics.last7d.requests)) req")
|
||||
.font(.caption2).foregroundStyle(.secondary)
|
||||
}
|
||||
Sparkline(values: analytics.byDay.map(\.cost)).frame(height: 30)
|
||||
}
|
||||
|
||||
// Top models.
|
||||
if !analytics.topModels.isEmpty {
|
||||
let scope = analytics.topModelsWindow == "30d" ? "30d" : "all-time"
|
||||
SectionLabel("Top models · \(scope)")
|
||||
let peak = analytics.topModels.map(\.cost).max() ?? 1
|
||||
ForEach(analytics.topModels.prefix(4)) { model in
|
||||
ModelBar(model: model, peak: peak)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A small labelled stat tile.
|
||||
private struct StatCell: View {
|
||||
let title: String
|
||||
let value: String
|
||||
var accent: Bool = false
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(title.uppercased())
|
||||
.font(.system(size: 9, weight: .semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
Text(value)
|
||||
.font(.system(.callout, design: .rounded).weight(.semibold))
|
||||
.foregroundStyle(accent ? BarTheme.accent : .primary)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.7)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.vertical, 6)
|
||||
.padding(.horizontal, 9)
|
||||
.background(Color.primary.opacity(0.05), in: RoundedRectangle(cornerRadius: 7))
|
||||
}
|
||||
}
|
||||
|
||||
/// One top-model row: name + spend with a proportional accent bar behind.
|
||||
private struct ModelBar: View {
|
||||
let model: BarAnalytics.Model
|
||||
let peak: Double
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geo in
|
||||
let fraction = peak > 0 ? CGFloat(model.cost / peak) : 0
|
||||
ZStack(alignment: .leading) {
|
||||
RoundedRectangle(cornerRadius: 5)
|
||||
.fill(BarTheme.accent.opacity(0.16))
|
||||
.frame(width: max(8, geo.size.width * fraction))
|
||||
HStack {
|
||||
Text(model.model)
|
||||
.font(.caption)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
Spacer()
|
||||
Text(BarFormatting.money(model.cost))
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
}
|
||||
}
|
||||
.frame(height: 22)
|
||||
}
|
||||
}
|
||||
|
||||
/// Uppercase section divider label.
|
||||
struct SectionLabel: View {
|
||||
let text: String
|
||||
init(_ text: String) { self.text = text }
|
||||
var body: some View {
|
||||
Text(text.uppercased())
|
||||
.font(.system(size: 10, weight: .bold))
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.top, 2)
|
||||
}
|
||||
}
|
||||
@@ -2,95 +2,166 @@ import SwiftUI
|
||||
import AppKit
|
||||
import CCSBarCore
|
||||
|
||||
/// Dropdown content for the menu bar: per-account rows + actions, an offline
|
||||
/// state when CCS isn't running, and footer controls.
|
||||
/// Dropdown content for the menu bar: a CCS-branded header, usage analytics,
|
||||
/// per-account rows + actions, an offline state when CCS isn't running, and
|
||||
/// footer controls.
|
||||
struct BarMenuView: View {
|
||||
@ObservedObject var viewModel: BarViewModel
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
header
|
||||
Divider()
|
||||
|
||||
if viewModel.offline {
|
||||
offlineState
|
||||
} else if viewModel.rows.isEmpty {
|
||||
Text("No accounts found")
|
||||
.foregroundStyle(.secondary)
|
||||
offlineState.padding(14)
|
||||
} else {
|
||||
ForEach(viewModel.rows) { row in
|
||||
BarRowView(row: row, viewModel: viewModel)
|
||||
Divider()
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
if let analytics = viewModel.analytics {
|
||||
BarAnalyticsView(analytics: analytics)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
SectionLabel("Accounts")
|
||||
if viewModel.rows.isEmpty {
|
||||
Text("No accounts configured")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
ForEach(viewModel.rows) { row in
|
||||
BarRowView(row: row, viewModel: viewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(14)
|
||||
}
|
||||
.frame(maxHeight: 520)
|
||||
}
|
||||
|
||||
Divider()
|
||||
footer
|
||||
}
|
||||
.padding(12)
|
||||
.frame(width: 320)
|
||||
.frame(width: 340)
|
||||
.onAppear { viewModel.onOpen() }
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
HStack {
|
||||
Text("CCS").font(.headline)
|
||||
HStack(spacing: 8) {
|
||||
Image(nsImage: MenuBarIcon.headerImage())
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text("CCS").font(.headline)
|
||||
Text("usage & accounts").font(.caption2).foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
if viewModel.isRefreshing {
|
||||
Text("refreshing…").font(.caption).foregroundStyle(.secondary)
|
||||
ProgressView().controlSize(.small)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 10)
|
||||
}
|
||||
|
||||
private var offlineState: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("CCS is not running").font(.body)
|
||||
Label("CCS is not running", systemImage: "bolt.slash.fill")
|
||||
.font(.body)
|
||||
Text("Start CCS, then reopen this menu.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Button("Retry") { viewModel.reconnect(); viewModel.onOpen() }
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
|
||||
private var footer: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Button("Open dashboard") { openDashboard() }
|
||||
Button("Refresh") { viewModel.onOpen() }
|
||||
Button("Quit") { NSApplication.shared.terminate(nil) }
|
||||
HStack(spacing: 10) {
|
||||
Button {
|
||||
openDashboard()
|
||||
} label: {
|
||||
Label("Dashboard", systemImage: "chart.bar.xaxis")
|
||||
}
|
||||
Button {
|
||||
viewModel.toggleIconStyle()
|
||||
} label: {
|
||||
Label(
|
||||
viewModel.iconStyle == .color ? "Color" : "Mono",
|
||||
systemImage: viewModel.iconStyle == .color ? "paintpalette" : "circle.lefthalf.filled"
|
||||
)
|
||||
}
|
||||
.help("Toggle the menu-bar icon between color and monochrome")
|
||||
Spacer()
|
||||
Button {
|
||||
viewModel.onOpen()
|
||||
} label: {
|
||||
Image(systemName: "arrow.clockwise")
|
||||
}
|
||||
.help("Refresh")
|
||||
Button {
|
||||
NSApplication.shared.terminate(nil)
|
||||
} label: {
|
||||
Image(systemName: "power")
|
||||
}
|
||||
.help("Quit CCS Bar")
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.font(.caption)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 9)
|
||||
}
|
||||
|
||||
private func openDashboard() {
|
||||
// The dashboard runs on the same host/port the bar reads from discovery.
|
||||
if case .success(let discovery) = BarDiscovery.load(), let url = discovery.resolvedURL {
|
||||
NSWorkspace.shared.open(url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One account row: health dot, name, provider/tier/quota/paused subline, and
|
||||
/// One account row: colored health dot, name, a chip subline, today's cost, and
|
||||
/// a control menu.
|
||||
struct BarRowView: View {
|
||||
let row: BarSummaryRow
|
||||
@ObservedObject var viewModel: BarViewModel
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top) {
|
||||
Text(row.healthDot)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.frame(width: 22, alignment: .leading)
|
||||
HStack(alignment: .center, spacing: 9) {
|
||||
Circle()
|
||||
.fill(healthColor)
|
||||
.frame(width: 8, height: 8)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(row.displayName ?? row.accountId)
|
||||
.font(.body)
|
||||
.lineLimit(1)
|
||||
Text(subline)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
HStack(spacing: 6) {
|
||||
Text(row.displayName ?? row.accountId)
|
||||
.font(.system(.body, design: .default).weight(.medium))
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
if row.paused {
|
||||
Chip("paused", tint: .secondary)
|
||||
}
|
||||
if row.needsReauth {
|
||||
Chip("reauth", tint: .red)
|
||||
}
|
||||
}
|
||||
HStack(spacing: 6) {
|
||||
Chip(row.provider, tint: BarTheme.accent)
|
||||
if let tier = row.tier { Chip(tier, tint: .secondary) }
|
||||
Text(BarFormatting.quotaLabel(row.quotaPercentage))
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Menu("Actions") {
|
||||
let cost = BarFormatting.costLabel(row.todayCost)
|
||||
if !cost.isEmpty {
|
||||
Text(cost)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Menu {
|
||||
if row.paused {
|
||||
Button("Resume") { viewModel.resume(row) }
|
||||
} else {
|
||||
@@ -98,24 +169,46 @@ struct BarRowView: View {
|
||||
}
|
||||
Button("Set as default") { viewModel.setDefault(row) }
|
||||
Button("Solo (pause others)") { viewModel.solo(row) }
|
||||
Divider()
|
||||
if let tier = row.tier {
|
||||
Button("Lock to \(tier)") { viewModel.tierLock(row, tier: tier) }
|
||||
}
|
||||
Button("Clear tier lock") { viewModel.tierLock(row, tier: nil) }
|
||||
} label: {
|
||||
Image(systemName: "ellipsis.circle")
|
||||
}
|
||||
.menuStyle(.borderlessButton)
|
||||
.frame(width: 90)
|
||||
.menuIndicator(.hidden)
|
||||
.frame(width: 28)
|
||||
}
|
||||
.padding(.vertical, 5)
|
||||
.padding(.horizontal, 8)
|
||||
.background(Color.primary.opacity(0.035), in: RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
|
||||
private var subline: String {
|
||||
var parts: [String] = [row.provider]
|
||||
if let tier = row.tier { parts.append(tier) }
|
||||
parts.append(BarFormatting.quotaLabel(row.quotaPercentage))
|
||||
let cost = BarFormatting.costLabel(row.todayCost)
|
||||
if !cost.isEmpty { parts.append(cost) }
|
||||
if row.paused { parts.append("paused") }
|
||||
if row.needsReauth { parts.append("needs reauth") }
|
||||
return parts.joined(separator: " \u{00B7} ")
|
||||
private var healthColor: Color {
|
||||
switch row.health {
|
||||
case "error": return .red
|
||||
case "warning": return .orange
|
||||
default: return .green
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Small pill label used in account sublines.
|
||||
struct Chip: View {
|
||||
let text: String
|
||||
let tint: Color
|
||||
init(_ text: String, tint: Color) {
|
||||
self.text = text
|
||||
self.tint = tint
|
||||
}
|
||||
var body: some View {
|
||||
Text(text)
|
||||
.font(.system(size: 9, weight: .semibold))
|
||||
.padding(.horizontal, 5)
|
||||
.padding(.vertical, 1)
|
||||
.background(tint.opacity(0.16), in: Capsule())
|
||||
.foregroundStyle(tint == .secondary ? Color.secondary : tint)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,9 +8,13 @@ import CCSBarCore
|
||||
@MainActor
|
||||
final class BarViewModel: ObservableObject {
|
||||
@Published var rows: [BarSummaryRow] = []
|
||||
@Published var analytics: BarAnalytics?
|
||||
@Published var offline = false
|
||||
@Published var lastError: String?
|
||||
@Published var isRefreshing = false
|
||||
@Published var iconStyle: BarIconStyle {
|
||||
didSet { MenuBarIcon.saveStyle(iconStyle) }
|
||||
}
|
||||
|
||||
private let home: String
|
||||
private var client: CCSBarClient?
|
||||
@@ -18,9 +22,15 @@ final class BarViewModel: ObservableObject {
|
||||
|
||||
init(home: String = NSHomeDirectory()) {
|
||||
self.home = home
|
||||
self.iconStyle = MenuBarIcon.loadStyle()
|
||||
reconnect()
|
||||
}
|
||||
|
||||
/// Toggle the menu-bar icon between the color mark and the mono template.
|
||||
func toggleIconStyle() {
|
||||
iconStyle = (iconStyle == .color) ? .mono : .color
|
||||
}
|
||||
|
||||
/// Compact status-bar title.
|
||||
var statusTitle: String {
|
||||
offline ? "CCS offline" : BarFormatting.statusTitle(rows: rows)
|
||||
@@ -69,6 +79,12 @@ final class BarViewModel: ObservableObject {
|
||||
// offline state when there is nothing to show.
|
||||
if rows.isEmpty { offline = true }
|
||||
}
|
||||
|
||||
// Analytics is a best-effort side-load: a failure here must never blank the
|
||||
// glance or flip us offline. Keep the last-known analytics on error.
|
||||
if let fresh = try? await client.analytics() {
|
||||
analytics = fresh
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Account actions
|
||||
|
||||
@@ -11,6 +11,9 @@ struct CCSBarApp: App {
|
||||
MenuBarExtra {
|
||||
BarMenuView(viewModel: viewModel)
|
||||
} label: {
|
||||
// The CCS mark + compact glance. The image re-renders when the style
|
||||
// preference changes because `iconStyle` is observed.
|
||||
Image(nsImage: MenuBarIcon.statusImage(viewModel.iconStyle))
|
||||
Text(viewModel.statusTitle)
|
||||
}
|
||||
.menuBarExtraStyle(.window)
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import AppKit
|
||||
|
||||
/// Menu-bar icon style. `color` shows the full CCS mark; `mono` uses a template
|
||||
/// silhouette that macOS auto-tints black/white to match the menu bar.
|
||||
enum BarIconStyle: String, CaseIterable {
|
||||
case color
|
||||
case mono
|
||||
}
|
||||
|
||||
/// Loads the CCS icon assets bundled into the .app (Contents/Resources) and
|
||||
/// hands back correctly-sized NSImages. Falls back to an SF Symbol when running
|
||||
/// from `swift run` (no bundle), so the app is always usable in dev.
|
||||
enum MenuBarIcon {
|
||||
static let defaultsKey = "ccsbar.iconStyle"
|
||||
|
||||
static func loadStyle() -> BarIconStyle {
|
||||
let raw = UserDefaults.standard.string(forKey: defaultsKey) ?? BarIconStyle.color.rawValue
|
||||
return BarIconStyle(rawValue: raw) ?? .color
|
||||
}
|
||||
|
||||
static func saveStyle(_ style: BarIconStyle) {
|
||||
UserDefaults.standard.set(style.rawValue, forKey: defaultsKey)
|
||||
}
|
||||
|
||||
/// The status-bar label image at ~18pt for the given style.
|
||||
static func statusImage(_ style: BarIconStyle) -> NSImage {
|
||||
let asset = style == .mono ? "MenuBarTemplate" : "MenuBarColor"
|
||||
let image = bundleImage(asset) ?? sfSymbol("gauge.with.dots.needle.bottom.50percent")
|
||||
image.size = NSSize(width: 18, height: 18)
|
||||
image.isTemplate = (style == .mono)
|
||||
return image
|
||||
}
|
||||
|
||||
/// The color CCS mark for the dropdown header at ~24pt.
|
||||
static func headerImage() -> NSImage {
|
||||
let image = bundleImage("HeaderLogo") ?? sfSymbol("gauge.with.dots.needle.bottom.50percent")
|
||||
image.size = NSSize(width: 24, height: 24)
|
||||
image.isTemplate = false
|
||||
return image
|
||||
}
|
||||
|
||||
private static func bundleImage(_ name: String) -> NSImage? {
|
||||
guard
|
||||
let url = Bundle.main.url(forResource: name, withExtension: "png"),
|
||||
let image = NSImage(contentsOf: url)
|
||||
else { return nil }
|
||||
return image
|
||||
}
|
||||
|
||||
private static func sfSymbol(_ name: String) -> NSImage {
|
||||
NSImage(systemSymbolName: name, accessibilityDescription: "CCS")
|
||||
?? NSImage(systemSymbolName: "circle", accessibilityDescription: "CCS")
|
||||
?? NSImage()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import SwiftUI
|
||||
|
||||
/// A compact bar sparkline for daily values (e.g. cost per day over 7 days).
|
||||
/// Zero-value days render as faint placeholders so the cadence stays readable.
|
||||
struct Sparkline: View {
|
||||
let values: [Double]
|
||||
var accent: Color = BarTheme.accent
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geo in
|
||||
let peak = max(values.max() ?? 0, 0.0001)
|
||||
HStack(alignment: .bottom, spacing: 3) {
|
||||
ForEach(Array(values.enumerated()), id: \.offset) { _, value in
|
||||
let height = CGFloat(value / peak) * geo.size.height
|
||||
RoundedRectangle(cornerRadius: 2)
|
||||
.fill(value > 0 ? accent : Color.secondary.opacity(0.2))
|
||||
.frame(height: max(2, height))
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared visual tokens for the menu. The accent matches the CCS logo orange.
|
||||
enum BarTheme {
|
||||
static let accent = Color(red: 0.886, green: 0.451, blue: 0.137) // ~#E2732A
|
||||
}
|
||||
@@ -199,6 +199,27 @@ do {
|
||||
}
|
||||
recorder.status = 200
|
||||
|
||||
// Analytics formatting + decode.
|
||||
check(BarFormatting.money(0) == "$0.00", "money shows zero")
|
||||
check(BarFormatting.money(2609.1) == "$2.6k", "money compacts thousands")
|
||||
check(BarFormatting.count(5314) == "5.3k", "count compacts thousands")
|
||||
|
||||
let analyticsJSON = """
|
||||
{"today":{"cost":0,"requests":0},"last7d":{"cost":0,"requests":0},
|
||||
"last30d":{"cost":0,"requests":0},"allTime":{"cost":2609.1,"requests":5314},
|
||||
"byDay":[{"date":"2026-06-08","cost":0,"requests":0}],
|
||||
"topModels":[{"model":"gpt-5.4","cost":1253.65,"requests":1306}],
|
||||
"topModelsWindow":"all","generatedAt":"2026-06-08T13:00:00.000Z"}
|
||||
""".data(using: .utf8)!
|
||||
do {
|
||||
let a = try JSONDecoder().decode(BarAnalytics.self, from: analyticsJSON)
|
||||
check(a.allTime.requests == 5314, "analytics decodes all-time requests")
|
||||
check(a.topModels.first?.model == "gpt-5.4", "analytics decodes top model")
|
||||
check(a.topModelsWindow == "all", "analytics decodes window")
|
||||
} catch {
|
||||
check(false, "analytics decode threw: \(error)")
|
||||
}
|
||||
|
||||
// cleanup
|
||||
try? FileManager.default.removeItem(atPath: tmp)
|
||||
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import Foundation
|
||||
|
||||
/// Usage analytics for the menu bar, mirroring `GET /api/bar/analytics`.
|
||||
///
|
||||
/// Rolled up server-side from the persisted CLIProxy usage snapshot. All cost
|
||||
/// values are USD.
|
||||
public struct BarAnalytics: Codable, Sendable, Equatable {
|
||||
public struct Window: Codable, Sendable, Equatable {
|
||||
public let cost: Double
|
||||
public let requests: Int
|
||||
public init(cost: Double, requests: Int) {
|
||||
self.cost = cost
|
||||
self.requests = requests
|
||||
}
|
||||
}
|
||||
|
||||
public struct Day: Codable, Sendable, Equatable, Identifiable {
|
||||
public let date: String
|
||||
public let cost: Double
|
||||
public let requests: Int
|
||||
public var id: String { date }
|
||||
public init(date: String, cost: Double, requests: Int) {
|
||||
self.date = date
|
||||
self.cost = cost
|
||||
self.requests = requests
|
||||
}
|
||||
}
|
||||
|
||||
public struct Model: Codable, Sendable, Equatable, Identifiable {
|
||||
public let model: String
|
||||
public let cost: Double
|
||||
public let requests: Int
|
||||
public var id: String { model }
|
||||
public init(model: String, cost: Double, requests: Int) {
|
||||
self.model = model
|
||||
self.cost = cost
|
||||
self.requests = requests
|
||||
}
|
||||
}
|
||||
|
||||
public let today: Window
|
||||
public let last7d: Window
|
||||
public let last30d: Window
|
||||
public let allTime: Window
|
||||
public let byDay: [Day]
|
||||
public let topModels: [Model]
|
||||
/// "30d" when recent data exists, else "all".
|
||||
public let topModelsWindow: String
|
||||
public let generatedAt: String
|
||||
|
||||
public init(
|
||||
today: Window,
|
||||
last7d: Window,
|
||||
last30d: Window,
|
||||
allTime: Window,
|
||||
byDay: [Day],
|
||||
topModels: [Model],
|
||||
topModelsWindow: String,
|
||||
generatedAt: String
|
||||
) {
|
||||
self.today = today
|
||||
self.last7d = last7d
|
||||
self.last30d = last30d
|
||||
self.allTime = allTime
|
||||
self.byDay = byDay
|
||||
self.topModels = topModels
|
||||
self.topModelsWindow = topModelsWindow
|
||||
self.generatedAt = generatedAt
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,22 @@ public enum BarFormatting {
|
||||
return String(format: "$%.2f", cost)
|
||||
}
|
||||
|
||||
/// Always-visible compact currency, e.g. "$0.00", "$12.34", "$2.6k", "$1.3M".
|
||||
/// Used for the analytics rollups where zero is meaningful (no spend yet).
|
||||
public static func money(_ v: Double) -> String {
|
||||
let n = max(0, v)
|
||||
if n >= 1_000_000 { return String(format: "$%.1fM", n / 1_000_000) }
|
||||
if n >= 1_000 { return String(format: "$%.1fk", n / 1_000) }
|
||||
return String(format: "$%.2f", n)
|
||||
}
|
||||
|
||||
/// Compact integer count, e.g. "5", "1.2k", "3.4M".
|
||||
public static func count(_ n: Int) -> String {
|
||||
if n >= 1_000_000 { return String(format: "%.1fM", Double(n) / 1_000_000) }
|
||||
if n >= 1_000 { return String(format: "%.1fk", Double(n) / 1_000) }
|
||||
return "\(n)"
|
||||
}
|
||||
|
||||
/// Compact status-bar title. Shows the most-used (lowest remaining quota)
|
||||
/// active account, plus today's total cost when available.
|
||||
/// Example: "agy 82% · $3.20". Falls back to "CCS" when there are no rows.
|
||||
|
||||
@@ -58,6 +58,19 @@ public struct CCSBarClient {
|
||||
}
|
||||
}
|
||||
|
||||
/// GET /api/bar/analytics. Server-side rollup of the usage snapshot
|
||||
/// (today / 7d / 30d / all-time spend, sparkline, top models).
|
||||
public func analytics() async throws -> BarAnalytics {
|
||||
let url = baseURL.appendingPathComponent("api/bar/analytics")
|
||||
let (data, http) = try await transport.send(URLRequest(url: url))
|
||||
guard http.statusCode == 200 else { throw CCSBarClientError.httpStatus(http.statusCode) }
|
||||
do {
|
||||
return try JSONDecoder().decode(BarAnalytics.self, from: data)
|
||||
} catch {
|
||||
throw CCSBarClientError.decoding
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Account control (reuses existing CCS endpoints)
|
||||
|
||||
public func pause(provider: String, accountId: String) async throws {
|
||||
|
||||
Reference in New Issue
Block a user