mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-29 01:42:19 +00:00
198 lines
7.7 KiB
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))
|
|
}
|
|
}
|
|
}
|