mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-04 18:13:17 +00:00
Codex quota in the menu bar came only from frozen local session logs, so it
showed stale data ("older session") that no refresh could update. Fetch it live
from the same source the dashboard uses, under the existing Claude-style safety
controls (10-min TTL, in-flight coalescing, 429 backoff, circuit breaker,
serve-stale), falling back to local logs when offline or rate-limited. A success
with no usable 5h/weekly window keeps the local reading rather than caching a
contentless row. The footer refresh and a new inline button on the Codex card
force a live pull past the open debounce, and a forced /summary re-pulls native
rows while serving the last-known cached rows if the live pull overruns its
budget, so the Claude/Codex cards never blank mid-refresh.
The spend strip gains a Today / 7d / 30d selector (default 7d), a taller chart,
and bottom time-axis labels (local hours for today, weekday for 7d, dates for
30d). The analytics endpoint now exposes a 24-bucket hourly series for today,
converted from the pipeline's UTC hour keys to the user's local clock so the
intra-day chart matches the dashboard.
161 lines
6.2 KiB
Swift
161 lines
6.2 KiB
Swift
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.
|
|
|
|
/// Spend and request count for one usage surface (tool/origin), e.g. "Claude Code"
|
|
/// or "Codex". The server sends these ordered descending by cost for the same window
|
|
/// as topModels, so the array can be rendered as-is.
|
|
public struct BarAnalyticsSurface: Codable, Sendable, Equatable, Identifiable {
|
|
public let source: String
|
|
public let surface: String
|
|
public let cost: Double
|
|
public let requests: Int
|
|
/// Stable identity for SwiftUI lists: surface name is unique within a window.
|
|
public var id: String { surface }
|
|
public init(source: String, surface: String, cost: Double, requests: Int) {
|
|
self.source = source
|
|
self.surface = surface
|
|
self.cost = cost
|
|
self.requests = requests
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
|
|
/// Spend and request count for one hour of today. 24 entries, zero-filled,
|
|
/// oldest (00:00) to newest (23:00). The `hour` key is "YYYY-MM-DD HH:00".
|
|
public struct Hour: Codable, Sendable, Equatable, Identifiable {
|
|
public let hour: String
|
|
public let cost: Double
|
|
public let requests: Int
|
|
public var id: String { hour }
|
|
public init(hour: String, cost: Double, requests: Int) {
|
|
self.hour = hour
|
|
self.cost = cost
|
|
self.requests = requests
|
|
}
|
|
}
|
|
|
|
public let today: Window
|
|
public let last7d: Window
|
|
public let last30d: Window
|
|
/// Honest calendar month-to-date (1st of the current local month → now), NOT a
|
|
/// rolling 30 days. A fresh month resets this toward ~0 even when `last30d`
|
|
/// stays populated, so a monthly-cap alert measures the real billing month.
|
|
public let monthToDate: Window
|
|
public let allTime: Window
|
|
/// Oldest → newest, exactly 30 zero-filled entries, for the sparkline.
|
|
public let byDay: [Day]
|
|
/// Today's hourly breakdown: 24 entries, oldest (00:00) → newest (23:00),
|
|
/// zero-filled. Absent from older payloads; defaults to [].
|
|
public let byHour: [Hour]
|
|
public let topModels: [Model]
|
|
/// "30d" when recent data exists, else "all".
|
|
public let topModelsWindow: String
|
|
/// ISO timestamp of the most recent non-failed usage record, null if none.
|
|
public let lastActivityAt: String?
|
|
/// Whole local-days since `lastActivityAt`, null if no usable records.
|
|
public let daysSinceLastActivity: Int?
|
|
/// True when the trailing 30 days carry any spend or requests. The UI pivots
|
|
/// its empty/stale presentation on this without re-deriving it.
|
|
public let hasRecentData: Bool
|
|
public let generatedAt: String
|
|
/// Spend/requests per usage surface (e.g. "Claude Code", "Codex"), ordered
|
|
/// descending by cost for the same window as topModels. Empty when the backend
|
|
/// has no surface breakdown; the UI omits the section in that case.
|
|
public let bySurface: [BarAnalyticsSurface]
|
|
|
|
public init(
|
|
today: Window,
|
|
last7d: Window,
|
|
last30d: Window,
|
|
monthToDate: Window = Window(cost: 0, requests: 0),
|
|
allTime: Window,
|
|
byDay: [Day],
|
|
byHour: [Hour] = [],
|
|
topModels: [Model],
|
|
topModelsWindow: String,
|
|
lastActivityAt: String? = nil,
|
|
daysSinceLastActivity: Int? = nil,
|
|
hasRecentData: Bool = false,
|
|
generatedAt: String,
|
|
bySurface: [BarAnalyticsSurface] = []
|
|
) {
|
|
self.today = today
|
|
self.last7d = last7d
|
|
self.last30d = last30d
|
|
self.monthToDate = monthToDate
|
|
self.allTime = allTime
|
|
self.byDay = byDay
|
|
self.byHour = byHour
|
|
self.topModels = topModels
|
|
self.topModelsWindow = topModelsWindow
|
|
self.lastActivityAt = lastActivityAt
|
|
self.daysSinceLastActivity = daysSinceLastActivity
|
|
self.hasRecentData = hasRecentData
|
|
self.generatedAt = generatedAt
|
|
self.bySurface = bySurface
|
|
}
|
|
|
|
// Custom decoder: `bySurface` is a new field absent from older snapshots.
|
|
// Defaulting to [] when the key is missing keeps the app backward-compatible
|
|
// with any cached or older-backend analytics payload.
|
|
public init(from decoder: Decoder) throws {
|
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
|
today = try c.decode(Window.self, forKey: .today)
|
|
last7d = try c.decode(Window.self, forKey: .last7d)
|
|
last30d = try c.decode(Window.self, forKey: .last30d)
|
|
// `monthToDate` is a newer field; older cached payloads omit it. Default to a
|
|
// zero window so a stale cache decodes cleanly instead of throwing.
|
|
monthToDate =
|
|
(try c.decodeIfPresent(Window.self, forKey: .monthToDate)) ?? Window(cost: 0, requests: 0)
|
|
allTime = try c.decode(Window.self, forKey: .allTime)
|
|
byDay = try c.decode([Day].self, forKey: .byDay)
|
|
// `byHour` is a new field; older payloads omit it. Default to [] so cached
|
|
// or older-backend payloads decode cleanly without throwing.
|
|
byHour = (try c.decodeIfPresent([Hour].self, forKey: .byHour)) ?? []
|
|
topModels = try c.decode([Model].self, forKey: .topModels)
|
|
topModelsWindow = try c.decode(String.self, forKey: .topModelsWindow)
|
|
lastActivityAt = try c.decodeIfPresent(String.self, forKey: .lastActivityAt)
|
|
daysSinceLastActivity = try c.decodeIfPresent(Int.self, forKey: .daysSinceLastActivity)
|
|
hasRecentData = try c.decode(Bool.self, forKey: .hasRecentData)
|
|
generatedAt = try c.decode(String.self, forKey: .generatedAt)
|
|
bySurface = (try c.decodeIfPresent([BarAnalyticsSurface].self, forKey: .bySurface)) ?? []
|
|
}
|
|
}
|