litellm/apps/macos-usage/Sources/LiteLLMUsageMenuBar/Views.swift

198 lines
7.7 KiB
Swift

import SwiftUI
struct UsagePopover: View {
@EnvironmentObject private var store: UsageStore
@Environment(\.openWindow) private var openWindow
private let teamSelection = "__team__"
var body: some View {
VStack(alignment: .leading, spacing: 12) {
header
scopePicker
content
Divider()
actions
}
.padding(16)
.frame(width: 320)
.task {
await store.loadKeys()
await store.refresh()
}
}
private var header: some View {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text("LiteLLM 사용량").font(.headline)
}
Spacer()
statusIcon
}
}
private var scopePicker: some View {
VStack(alignment: .leading, spacing: 4) {
Picker("조회 대상", selection: Binding(
get: { store.selectedKeyName ?? teamSelection },
set: { store.selectKey(named: $0 == teamSelection ? nil : $0) }
)) {
Text("팀 전체").tag(teamSelection)
ForEach(store.availableKeys) { key in
Text(key.displayName).tag(key.keyName)
}
}
.pickerStyle(.menu)
.disabled(!store.hasCredentials || store.isLoadingKeys)
if let keyListMessage = store.keyListMessage, !store.isLoadingKeys {
Text(keyListMessage).font(.caption).foregroundStyle(.secondary)
}
}
}
@ViewBuilder
private var content: some View {
switch store.state {
case .needsKey:
MessageView(title: "로그인 정보를 설정하세요", detail: "설정에서 게이트웨이 ID와 비밀번호를 입력하면 사용량을 조회합니다", symbol: "person.crop.circle.badge.key")
case .loading:
HStack(spacing: 8) { ProgressView(); Text("사용량을 확인하는 중...").foregroundStyle(.secondary) }
case let .ready(snapshot):
VStack(alignment: .leading, spacing: 10) {
Text(snapshot.percentage.formatted(.number.precision(.fractionLength(0))) + "%")
.font(.system(size: 38, weight: .bold, design: .rounded))
.foregroundStyle(color(for: snapshot.percentage))
ProgressView(value: min(snapshot.percentage, 100), total: 100)
.tint(color(for: snapshot.percentage))
HStack {
Fact(label: "사용액", value: currency(snapshot.spend))
Spacer()
Fact(label: "한도", value: currency(snapshot.limit))
}
if let resetAt = snapshot.resetAt {
Text("다음 초기화 " + resetAt.formatted(date: .abbreviated, time: .shortened))
.font(.caption).foregroundStyle(.secondary)
}
}
case let .unavailable(message), let .failure(message):
MessageView(title: "사용량을 표시할 수 없습니다", detail: message, symbol: "exclamationmark.triangle")
}
}
private var actions: some View {
HStack {
Button("새로고침") { Task { await store.refresh() } }
.buttonStyle(.borderless)
.disabled(store.state == .loading || !store.hasCredentials)
Spacer()
Button("설정") {
openWindow(id: "login-settings")
NSApplication.shared.activate(ignoringOtherApps: true)
}
.buttonStyle(.borderless)
Button("종료") { NSApplication.shared.terminate(nil) }
.buttonStyle(.borderless)
}
.font(.callout)
}
@ViewBuilder
private var statusIcon: some View {
switch store.state {
case .ready(let snapshot): Image(systemName: "circle.fill").foregroundStyle(color(for: snapshot.percentage))
case .loading: ProgressView().controlSize(.small)
case .needsKey: Image(systemName: "key.fill").foregroundStyle(.secondary)
case .unavailable, .failure: Image(systemName: "exclamationmark.circle.fill").foregroundStyle(.red)
}
}
private func color(for percentage: Double) -> Color {
percentage >= 90 ? .red : percentage >= 75 ? .orange : .green
}
private func currency(_ value: Double) -> String { value.formatted(.currency(code: "USD")) }
}
struct SettingsView: View {
@EnvironmentObject private var store: UsageStore
@Environment(\.dismiss) private var dismiss
@State private var gatewayURL = ""
@State private var username = ""
@State private var password = ""
@State private var message: String?
var body: some View {
Form {
Section("사용량") {
Picker("자동 새로고침", selection: Binding(
get: { store.autoRefreshInterval },
set: { store.setAutoRefreshInterval($0) }
)) {
ForEach(AutoRefreshInterval.allCases) { interval in
Text(interval.title).tag(interval)
}
}
Text("팝오버가 닫혀 있어도 메뉴바 사용량을 갱신합니다")
.font(.caption).foregroundStyle(.secondary)
}
Section("게이트웨이") {
TextField("서버 주소", text: $gatewayURL)
TextField("아이디", text: $username)
SecureField("비밀번호", text: $password)
Text("LiteLLM 프록시 서버 주소를 입력하세요")
.font(.caption).foregroundStyle(.secondary)
Text("로그인 정보는 macOS Keychain에 저장되며 세션은 앱 실행 중 메모리에만 유지됩니다")
.font(.caption).foregroundStyle(.secondary)
Button("저장") {
guard !username.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, !password.isEmpty else {
message = "아이디와 비밀번호를 모두 입력하세요"
return
}
do {
try store.saveSettings(gatewayURL: gatewayURL, username: username, password: password)
username = ""
password = ""
dismiss()
} catch { message = error.localizedDescription }
}
.disabled(gatewayURL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || username.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || password.isEmpty)
if let message { Text(message).font(.caption).foregroundStyle(.secondary) }
}
}
.formStyle(.grouped)
.frame(width: 380, height: 360)
.padding()
.onAppear {
gatewayURL = store.gatewayURL.absoluteString
username = store.savedUsername
password = store.savedPassword
}
}
}
private struct MessageView: View {
let title: String
let detail: String
let symbol: String
var body: some View {
Label {
VStack(alignment: .leading, spacing: 3) {
Text(title).font(.subheadline.weight(.semibold))
Text(detail).font(.caption).foregroundStyle(.secondary)
}
} icon: { Image(systemName: symbol).foregroundStyle(.secondary) }
}
}
private struct Fact: View {
let label: String
let value: String
var body: some View {
VStack(alignment: .leading, spacing: 2) {
Text(label).font(.caption).foregroundStyle(.secondary)
Text(value).font(.system(.callout, design: .monospaced))
}
}
}