Usage: add cost summaries to /usage + mac menu
This commit is contained in:
@@ -27,6 +27,10 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
||||
private var cachedUsageErrorText: String?
|
||||
private var usageCacheUpdatedAt: Date?
|
||||
private let usageRefreshIntervalSeconds: TimeInterval = 30
|
||||
private var cachedCostSummary: GatewayCostUsageSummary?
|
||||
private var cachedCostErrorText: String?
|
||||
private var costCacheUpdatedAt: Date?
|
||||
private let costRefreshIntervalSeconds: TimeInterval = 45
|
||||
private let nodesStore = NodesStore.shared
|
||||
#if DEBUG
|
||||
private var testControlChannelConnected: Bool?
|
||||
@@ -64,6 +68,7 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
||||
guard let self else { return }
|
||||
await self.refreshCache(force: forceRefresh)
|
||||
await self.refreshUsageCache(force: forceRefresh)
|
||||
await self.refreshCostUsageCache(force: forceRefresh)
|
||||
await MainActor.run {
|
||||
guard self.isMenuOpen else { return }
|
||||
self.inject(into: menu)
|
||||
@@ -200,6 +205,7 @@ extension MenuSessionsInjector {
|
||||
}
|
||||
|
||||
cursor = self.insertUsageSection(into: menu, at: cursor, width: width)
|
||||
cursor = self.insertCostUsageSection(into: menu, at: cursor, width: width)
|
||||
|
||||
DispatchQueue.main.async { [weak self, weak headerView] in
|
||||
guard let self, let headerView else { return }
|
||||
@@ -344,6 +350,28 @@ extension MenuSessionsInjector {
|
||||
return cursor
|
||||
}
|
||||
|
||||
private func insertCostUsageSection(into menu: NSMenu, at cursor: Int, width: CGFloat) -> Int {
|
||||
guard self.isControlChannelConnected else { return cursor }
|
||||
guard let submenu = self.buildCostUsageSubmenu(width: width) else { return cursor }
|
||||
var cursor = cursor
|
||||
|
||||
if cursor > 0, !menu.items[cursor - 1].isSeparatorItem {
|
||||
let separator = NSMenuItem.separator()
|
||||
separator.tag = self.tag
|
||||
menu.insertItem(separator, at: cursor)
|
||||
cursor += 1
|
||||
}
|
||||
|
||||
let item = NSMenuItem(title: "Usage cost (30 days)", action: nil, keyEquivalent: "")
|
||||
item.tag = self.tag
|
||||
item.isEnabled = true
|
||||
item.image = NSImage(systemSymbolName: "chart.bar.xaxis", accessibilityDescription: nil)
|
||||
item.submenu = submenu
|
||||
menu.insertItem(item, at: cursor)
|
||||
cursor += 1
|
||||
return cursor
|
||||
}
|
||||
|
||||
private var selectedUsageProviderId: String? {
|
||||
guard let model = self.cachedSnapshot?.defaults.model.nonEmpty else { return nil }
|
||||
let trimmed = model.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
@@ -393,6 +421,36 @@ extension MenuSessionsInjector {
|
||||
}
|
||||
}
|
||||
|
||||
private func buildCostUsageSubmenu(width: CGFloat) -> NSMenu? {
|
||||
if let error = self.cachedCostErrorText, !error.isEmpty, self.cachedCostSummary == nil {
|
||||
let menu = NSMenu()
|
||||
let item = NSMenuItem(title: error, action: nil, keyEquivalent: "")
|
||||
item.isEnabled = false
|
||||
menu.addItem(item)
|
||||
return menu
|
||||
}
|
||||
|
||||
guard let summary = self.cachedCostSummary else { return nil }
|
||||
guard !summary.daily.isEmpty else { return nil }
|
||||
|
||||
let menu = NSMenu()
|
||||
menu.delegate = self
|
||||
|
||||
let chartView = CostUsageHistoryMenuView(summary: summary, width: width)
|
||||
let hosting = NSHostingView(rootView: AnyView(chartView))
|
||||
let controller = NSHostingController(rootView: AnyView(chartView))
|
||||
let size = controller.sizeThatFits(in: CGSize(width: width, height: .greatestFiniteMagnitude))
|
||||
hosting.frame = NSRect(origin: .zero, size: NSSize(width: width, height: size.height))
|
||||
|
||||
let chartItem = NSMenuItem()
|
||||
chartItem.view = hosting
|
||||
chartItem.isEnabled = false
|
||||
chartItem.representedObject = "costUsageChart"
|
||||
menu.addItem(chartItem)
|
||||
|
||||
return menu
|
||||
}
|
||||
|
||||
private func gatewayEntry() -> NodeInfo? {
|
||||
let mode = AppStateStore.shared.connectionMode
|
||||
let isConnected = self.isControlChannelConnected
|
||||
@@ -581,6 +639,31 @@ extension MenuSessionsInjector {
|
||||
self.usageCacheUpdatedAt = Date()
|
||||
}
|
||||
|
||||
private func refreshCostUsageCache(force: Bool) async {
|
||||
if !force,
|
||||
let updated = self.costCacheUpdatedAt,
|
||||
Date().timeIntervalSince(updated) < self.costRefreshIntervalSeconds
|
||||
{
|
||||
return
|
||||
}
|
||||
|
||||
guard self.isControlChannelConnected else {
|
||||
self.cachedCostSummary = nil
|
||||
self.cachedCostErrorText = nil
|
||||
self.costCacheUpdatedAt = Date()
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
self.cachedCostSummary = try await CostUsageLoader.loadSummary()
|
||||
self.cachedCostErrorText = nil
|
||||
} catch {
|
||||
self.cachedCostSummary = nil
|
||||
self.cachedCostErrorText = self.compactUsageError(error)
|
||||
}
|
||||
self.costCacheUpdatedAt = Date()
|
||||
}
|
||||
|
||||
private func compactUsageError(_ error: Error) -> String {
|
||||
let message = error.localizedDescription.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if message.isEmpty { return "Usage unavailable" }
|
||||
|
||||
Reference in New Issue
Block a user