mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 10:36:35 +00:00
Add Insights Dashboard with usage analytics
New sidebar section showing rich analytics from the sessions database: - Overview grid: sessions, messages, tokens (input/output/cache), active time, avg session duration, avg messages per session - Model breakdown: sessions and total tokens per model - Platform breakdown: CLI vs Telegram etc with session/message counts - Top tools bar chart: ranked by call count with percentages - Activity patterns: day-of-week bars and hourly heatmap - Notable sessions: longest, most messages, most tokens, most tool calls with clickable links to open in Sessions browser - Time period selector: 7/30/90 days or all time Also adds ROADMAP.md documenting the full feature expansion plan. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,58 @@
|
|||||||
|
# Scarf — Feature Roadmap
|
||||||
|
|
||||||
|
## Tier 1 — High Value, Data Already Available
|
||||||
|
|
||||||
|
### 1. Insights Dashboard
|
||||||
|
Rich usage analytics pulled from the sessions and messages SQLite tables:
|
||||||
|
- Overview stats: sessions, messages, tool calls, tokens, active time, avg session duration
|
||||||
|
- Model breakdown: sessions and tokens per model
|
||||||
|
- Platform breakdown: CLI vs Telegram vs Discord usage
|
||||||
|
- Top tools chart: ranked tool usage with call counts and percentages
|
||||||
|
- Activity patterns: sessions by day-of-week, peak hours heatmap
|
||||||
|
- Notable sessions: longest, most messages, most tokens, most tool calls
|
||||||
|
- Time period selector: last 7/30/90 days
|
||||||
|
|
||||||
|
### 2. Tool Management Panel
|
||||||
|
- List all toolsets with enabled/disabled status and descriptions
|
||||||
|
- Toggle switches to enable/disable tools (via `hermes tools enable/disable`)
|
||||||
|
- Per-platform tool configuration
|
||||||
|
- MCP tool status
|
||||||
|
|
||||||
|
### 3. Session Management Enhancements
|
||||||
|
- Rename sessions from the Sessions browser (via `hermes sessions rename`)
|
||||||
|
- Delete sessions (via `hermes sessions delete`)
|
||||||
|
- Export sessions to JSONL (via `hermes sessions export`)
|
||||||
|
- Session stats card (total count, DB size, per-platform breakdown)
|
||||||
|
|
||||||
|
## Tier 2 — Medium Value, New Service Code Required
|
||||||
|
|
||||||
|
### 4. Skills Hub
|
||||||
|
- Search remote registries for new skills (6 sources)
|
||||||
|
- Install/uninstall skills from GUI
|
||||||
|
- Skill update indicator
|
||||||
|
- Trust level badges (builtin, local, hub)
|
||||||
|
|
||||||
|
### 5. Gateway Control Center
|
||||||
|
- Start/stop/restart gateway from GUI
|
||||||
|
- Real-time status: PID, uptime, connected platforms
|
||||||
|
- Pairing management: view approved users, approve/revoke
|
||||||
|
- Platform status per messaging service
|
||||||
|
|
||||||
|
### 6. System Health View
|
||||||
|
- Mirror `hermes status` and `hermes doctor` output
|
||||||
|
- API key validation, auth provider status, external tools
|
||||||
|
- Update available indicator
|
||||||
|
|
||||||
|
## Tier 3 — Nice to Have
|
||||||
|
|
||||||
|
### 7. Profile Management
|
||||||
|
- List/create/switch profiles (isolated Hermes instances)
|
||||||
|
|
||||||
|
### 8. Plugin Management
|
||||||
|
- Install from Git, enable/disable, update
|
||||||
|
|
||||||
|
### 9. MCP Server Management
|
||||||
|
- Add/remove/test MCP servers, toggle tools per server
|
||||||
|
|
||||||
|
### 10. Config Editor
|
||||||
|
- Structured form editor for config.yaml with validation
|
||||||
@@ -16,6 +16,8 @@ struct ContentView: View {
|
|||||||
switch coordinator.selectedSection {
|
switch coordinator.selectedSection {
|
||||||
case .dashboard:
|
case .dashboard:
|
||||||
DashboardView()
|
DashboardView()
|
||||||
|
case .insights:
|
||||||
|
InsightsView()
|
||||||
case .sessions:
|
case .sessions:
|
||||||
SessionsView()
|
SessionsView()
|
||||||
case .activity:
|
case .activity:
|
||||||
|
|||||||
@@ -183,6 +183,112 @@ actor HermesDataService {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Insights Queries
|
||||||
|
|
||||||
|
func fetchSessionsInPeriod(since: Date) -> [HermesSession] {
|
||||||
|
guard let db else { return [] }
|
||||||
|
let sql = """
|
||||||
|
SELECT id, source, user_id, model, title, parent_session_id,
|
||||||
|
started_at, ended_at, end_reason, message_count, tool_call_count,
|
||||||
|
input_tokens, output_tokens, cache_read_tokens, cache_write_tokens,
|
||||||
|
estimated_cost_usd
|
||||||
|
FROM sessions
|
||||||
|
WHERE started_at >= ?
|
||||||
|
ORDER BY started_at DESC
|
||||||
|
"""
|
||||||
|
var stmt: OpaquePointer?
|
||||||
|
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return [] }
|
||||||
|
defer { sqlite3_finalize(stmt) }
|
||||||
|
sqlite3_bind_double(stmt, 1, since.timeIntervalSince1970)
|
||||||
|
|
||||||
|
var sessions: [HermesSession] = []
|
||||||
|
while sqlite3_step(stmt) == SQLITE_ROW {
|
||||||
|
sessions.append(sessionFromRow(stmt!))
|
||||||
|
}
|
||||||
|
return sessions
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchUserMessageCount(since: Date) -> Int {
|
||||||
|
guard let db else { return 0 }
|
||||||
|
let sql = """
|
||||||
|
SELECT COUNT(*) FROM messages m
|
||||||
|
JOIN sessions s ON m.session_id = s.id
|
||||||
|
WHERE m.role = 'user' AND s.started_at >= ?
|
||||||
|
"""
|
||||||
|
var stmt: OpaquePointer?
|
||||||
|
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return 0 }
|
||||||
|
defer { sqlite3_finalize(stmt) }
|
||||||
|
sqlite3_bind_double(stmt, 1, since.timeIntervalSince1970)
|
||||||
|
guard sqlite3_step(stmt) == SQLITE_ROW else { return 0 }
|
||||||
|
return Int(sqlite3_column_int(stmt, 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchToolUsage(since: Date) -> [(name: String, count: Int)] {
|
||||||
|
guard let db else { return [] }
|
||||||
|
let sql = """
|
||||||
|
SELECT m.tool_name, COUNT(*) as cnt
|
||||||
|
FROM messages m
|
||||||
|
JOIN sessions s ON m.session_id = s.id
|
||||||
|
WHERE m.tool_name IS NOT NULL AND m.tool_name <> '' AND s.started_at >= ?
|
||||||
|
GROUP BY m.tool_name
|
||||||
|
ORDER BY cnt DESC
|
||||||
|
"""
|
||||||
|
var stmt: OpaquePointer?
|
||||||
|
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return [] }
|
||||||
|
defer { sqlite3_finalize(stmt) }
|
||||||
|
sqlite3_bind_double(stmt, 1, since.timeIntervalSince1970)
|
||||||
|
|
||||||
|
var results: [(name: String, count: Int)] = []
|
||||||
|
while sqlite3_step(stmt) == SQLITE_ROW {
|
||||||
|
let name = columnText(stmt!, 0)
|
||||||
|
let count = Int(sqlite3_column_int(stmt!, 1))
|
||||||
|
results.append((name: name, count: count))
|
||||||
|
}
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchSessionStartHours(since: Date) -> [Int: Int] {
|
||||||
|
guard let db else { return [:] }
|
||||||
|
let sql = """
|
||||||
|
SELECT started_at FROM sessions WHERE started_at >= ?
|
||||||
|
"""
|
||||||
|
var stmt: OpaquePointer?
|
||||||
|
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return [:] }
|
||||||
|
defer { sqlite3_finalize(stmt) }
|
||||||
|
sqlite3_bind_double(stmt, 1, since.timeIntervalSince1970)
|
||||||
|
|
||||||
|
var hours: [Int: Int] = [:]
|
||||||
|
let calendar = Calendar.current
|
||||||
|
while sqlite3_step(stmt) == SQLITE_ROW {
|
||||||
|
let ts = sqlite3_column_double(stmt!, 0)
|
||||||
|
let date = Date(timeIntervalSince1970: ts)
|
||||||
|
let hour = calendar.component(.hour, from: date)
|
||||||
|
hours[hour, default: 0] += 1
|
||||||
|
}
|
||||||
|
return hours
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchSessionDaysOfWeek(since: Date) -> [Int: Int] {
|
||||||
|
guard let db else { return [:] }
|
||||||
|
let sql = """
|
||||||
|
SELECT started_at FROM sessions WHERE started_at >= ?
|
||||||
|
"""
|
||||||
|
var stmt: OpaquePointer?
|
||||||
|
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return [:] }
|
||||||
|
defer { sqlite3_finalize(stmt) }
|
||||||
|
sqlite3_bind_double(stmt, 1, since.timeIntervalSince1970)
|
||||||
|
|
||||||
|
var days: [Int: Int] = [:]
|
||||||
|
let calendar = Calendar.current
|
||||||
|
while sqlite3_step(stmt) == SQLITE_ROW {
|
||||||
|
let ts = sqlite3_column_double(stmt!, 0)
|
||||||
|
let date = Date(timeIntervalSince1970: ts)
|
||||||
|
let weekday = (calendar.component(.weekday, from: date) + 5) % 7 // Mon=0
|
||||||
|
days[weekday, default: 0] += 1
|
||||||
|
}
|
||||||
|
return days
|
||||||
|
}
|
||||||
|
|
||||||
func stateDBModificationDate() -> Date? {
|
func stateDBModificationDate() -> Date? {
|
||||||
let walPath = HermesPaths.stateDB + "-wal"
|
let walPath = HermesPaths.stateDB + "-wal"
|
||||||
let dbPath = HermesPaths.stateDB
|
let dbPath = HermesPaths.stateDB
|
||||||
|
|||||||
@@ -0,0 +1,234 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum InsightsPeriod: String, CaseIterable, Identifiable {
|
||||||
|
case week = "7 Days"
|
||||||
|
case month = "30 Days"
|
||||||
|
case quarter = "90 Days"
|
||||||
|
case all = "All Time"
|
||||||
|
|
||||||
|
var id: String { rawValue }
|
||||||
|
|
||||||
|
var sinceDate: Date {
|
||||||
|
let calendar = Calendar.current
|
||||||
|
switch self {
|
||||||
|
case .week: return calendar.date(byAdding: .day, value: -7, to: Date()) ?? Date()
|
||||||
|
case .month: return calendar.date(byAdding: .day, value: -30, to: Date()) ?? Date()
|
||||||
|
case .quarter: return calendar.date(byAdding: .day, value: -90, to: Date()) ?? Date()
|
||||||
|
case .all: return Date(timeIntervalSince1970: 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ModelUsage: Identifiable {
|
||||||
|
var id: String { model }
|
||||||
|
let model: String
|
||||||
|
let sessions: Int
|
||||||
|
let inputTokens: Int
|
||||||
|
let outputTokens: Int
|
||||||
|
let cacheReadTokens: Int
|
||||||
|
let cacheWriteTokens: Int
|
||||||
|
var totalTokens: Int { inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens }
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PlatformUsage: Identifiable {
|
||||||
|
var id: String { platform }
|
||||||
|
let platform: String
|
||||||
|
let sessions: Int
|
||||||
|
let messages: Int
|
||||||
|
let tokens: Int
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ToolUsage: Identifiable {
|
||||||
|
var id: String { name }
|
||||||
|
let name: String
|
||||||
|
let count: Int
|
||||||
|
let percentage: Double
|
||||||
|
}
|
||||||
|
|
||||||
|
struct NotableSession: Identifiable {
|
||||||
|
var id: String { session.id }
|
||||||
|
let label: String
|
||||||
|
let value: String
|
||||||
|
let session: HermesSession
|
||||||
|
let preview: String
|
||||||
|
}
|
||||||
|
|
||||||
|
@Observable
|
||||||
|
final class InsightsViewModel {
|
||||||
|
private let dataService = HermesDataService()
|
||||||
|
|
||||||
|
var period: InsightsPeriod = .month
|
||||||
|
var isLoading = true
|
||||||
|
|
||||||
|
var sessions: [HermesSession] = []
|
||||||
|
var sessionPreviews: [String: String] = [:]
|
||||||
|
var userMessageCount = 0
|
||||||
|
var totalMessages = 0
|
||||||
|
var totalToolCalls = 0
|
||||||
|
var totalInputTokens = 0
|
||||||
|
var totalOutputTokens = 0
|
||||||
|
var totalCacheReadTokens = 0
|
||||||
|
var totalCacheWriteTokens = 0
|
||||||
|
var totalTokens = 0
|
||||||
|
var activeTime: TimeInterval = 0
|
||||||
|
var avgSessionDuration: TimeInterval = 0
|
||||||
|
|
||||||
|
var modelUsage: [ModelUsage] = []
|
||||||
|
var platformUsage: [PlatformUsage] = []
|
||||||
|
var toolUsage: [ToolUsage] = []
|
||||||
|
var hourlyActivity: [Int: Int] = [:]
|
||||||
|
var dailyActivity: [Int: Int] = [:]
|
||||||
|
var notableSessions: [NotableSession] = []
|
||||||
|
|
||||||
|
func load() async {
|
||||||
|
isLoading = true
|
||||||
|
let opened = await dataService.open()
|
||||||
|
guard opened else {
|
||||||
|
isLoading = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let since = period.sinceDate
|
||||||
|
sessions = await dataService.fetchSessionsInPeriod(since: since)
|
||||||
|
sessionPreviews = await dataService.fetchSessionPreviews(limit: 500)
|
||||||
|
userMessageCount = await dataService.fetchUserMessageCount(since: since)
|
||||||
|
let tools = await dataService.fetchToolUsage(since: since)
|
||||||
|
hourlyActivity = await dataService.fetchSessionStartHours(since: since)
|
||||||
|
dailyActivity = await dataService.fetchSessionDaysOfWeek(since: since)
|
||||||
|
|
||||||
|
await dataService.close()
|
||||||
|
|
||||||
|
computeAggregates()
|
||||||
|
computeModelBreakdown()
|
||||||
|
computePlatformBreakdown()
|
||||||
|
computeToolBreakdown(tools)
|
||||||
|
computeNotableSessions()
|
||||||
|
isLoading = false
|
||||||
|
}
|
||||||
|
|
||||||
|
func previewFor(_ session: HermesSession) -> String {
|
||||||
|
if let title = session.title, !title.isEmpty { return title }
|
||||||
|
if let preview = sessionPreviews[session.id], !preview.isEmpty { return preview }
|
||||||
|
return session.id
|
||||||
|
}
|
||||||
|
|
||||||
|
private func computeAggregates() {
|
||||||
|
totalMessages = sessions.reduce(0) { $0 + $1.messageCount }
|
||||||
|
totalToolCalls = sessions.reduce(0) { $0 + $1.toolCallCount }
|
||||||
|
totalInputTokens = sessions.reduce(0) { $0 + $1.inputTokens }
|
||||||
|
totalOutputTokens = sessions.reduce(0) { $0 + $1.outputTokens }
|
||||||
|
totalCacheReadTokens = sessions.reduce(0) { $0 + $1.cacheReadTokens }
|
||||||
|
totalCacheWriteTokens = sessions.reduce(0) { $0 + $1.cacheWriteTokens }
|
||||||
|
totalTokens = totalInputTokens + totalOutputTokens + totalCacheReadTokens + totalCacheWriteTokens
|
||||||
|
|
||||||
|
var total: TimeInterval = 0
|
||||||
|
var count = 0
|
||||||
|
for session in sessions {
|
||||||
|
if let dur = session.duration, dur > 0 {
|
||||||
|
total += dur
|
||||||
|
count += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
activeTime = total
|
||||||
|
avgSessionDuration = count > 0 ? total / Double(count) : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private func computeModelBreakdown() {
|
||||||
|
var grouped: [String: (sessions: Int, input: Int, output: Int, cacheRead: Int, cacheWrite: Int)] = [:]
|
||||||
|
for s in sessions {
|
||||||
|
let model = s.model ?? "unknown"
|
||||||
|
var entry = grouped[model, default: (0, 0, 0, 0, 0)]
|
||||||
|
entry.sessions += 1
|
||||||
|
entry.input += s.inputTokens
|
||||||
|
entry.output += s.outputTokens
|
||||||
|
entry.cacheRead += s.cacheReadTokens
|
||||||
|
entry.cacheWrite += s.cacheWriteTokens
|
||||||
|
grouped[model] = entry
|
||||||
|
}
|
||||||
|
modelUsage = grouped.map { key, val in
|
||||||
|
ModelUsage(model: key, sessions: val.sessions, inputTokens: val.input,
|
||||||
|
outputTokens: val.output, cacheReadTokens: val.cacheRead,
|
||||||
|
cacheWriteTokens: val.cacheWrite)
|
||||||
|
}.sorted { $0.totalTokens > $1.totalTokens }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func computePlatformBreakdown() {
|
||||||
|
var grouped: [String: (sessions: Int, messages: Int, tokens: Int)] = [:]
|
||||||
|
for s in sessions {
|
||||||
|
var entry = grouped[s.source, default: (0, 0, 0)]
|
||||||
|
entry.sessions += 1
|
||||||
|
entry.messages += s.messageCount
|
||||||
|
entry.tokens += s.inputTokens + s.outputTokens + s.cacheReadTokens + s.cacheWriteTokens
|
||||||
|
grouped[s.source] = entry
|
||||||
|
}
|
||||||
|
platformUsage = grouped.map { key, val in
|
||||||
|
PlatformUsage(platform: key, sessions: val.sessions, messages: val.messages, tokens: val.tokens)
|
||||||
|
}.sorted { $0.sessions > $1.sessions }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func computeToolBreakdown(_ tools: [(name: String, count: Int)]) {
|
||||||
|
let total = tools.reduce(0) { $0 + $1.count }
|
||||||
|
toolUsage = tools.map { tool in
|
||||||
|
ToolUsage(name: tool.name, count: tool.count,
|
||||||
|
percentage: total > 0 ? Double(tool.count) / Double(total) * 100 : 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func computeNotableSessions() {
|
||||||
|
notableSessions = []
|
||||||
|
|
||||||
|
if let longest = sessions.filter({ $0.duration != nil }).max(by: { ($0.duration ?? 0) < ($1.duration ?? 0) }) {
|
||||||
|
notableSessions.append(NotableSession(
|
||||||
|
label: "Longest Session",
|
||||||
|
value: formatDuration(longest.duration ?? 0),
|
||||||
|
session: longest,
|
||||||
|
preview: previewFor(longest)
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
if let mostMsgs = sessions.max(by: { $0.messageCount < $1.messageCount }), mostMsgs.messageCount > 0 {
|
||||||
|
notableSessions.append(NotableSession(
|
||||||
|
label: "Most Messages",
|
||||||
|
value: "\(mostMsgs.messageCount) msgs",
|
||||||
|
session: mostMsgs,
|
||||||
|
preview: previewFor(mostMsgs)
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
if let mostTokens = sessions.max(by: { $0.totalTokens < $1.totalTokens }), mostTokens.totalTokens > 0 {
|
||||||
|
notableSessions.append(NotableSession(
|
||||||
|
label: "Most Tokens",
|
||||||
|
value: formatTokens(mostTokens.totalTokens),
|
||||||
|
session: mostTokens,
|
||||||
|
preview: previewFor(mostTokens)
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
if let mostTools = sessions.max(by: { $0.toolCallCount < $1.toolCallCount }), mostTools.toolCallCount > 0 {
|
||||||
|
notableSessions.append(NotableSession(
|
||||||
|
label: "Most Tool Calls",
|
||||||
|
value: "\(mostTools.toolCallCount) calls",
|
||||||
|
session: mostTools,
|
||||||
|
preview: previewFor(mostTools)
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatDuration(_ interval: TimeInterval) -> String {
|
||||||
|
let hours = Int(interval) / 3600
|
||||||
|
let minutes = (Int(interval) % 3600) / 60
|
||||||
|
if hours > 0 {
|
||||||
|
return "\(hours)h \(minutes)m"
|
||||||
|
}
|
||||||
|
return "\(minutes)m"
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatTokens(_ count: Int) -> String {
|
||||||
|
if count >= 1_000_000 {
|
||||||
|
return String(format: "%.1fM", Double(count) / 1_000_000)
|
||||||
|
} else if count >= 1_000 {
|
||||||
|
return String(format: "%.1fK", Double(count) / 1_000)
|
||||||
|
}
|
||||||
|
return "\(count)"
|
||||||
|
}
|
||||||
@@ -0,0 +1,317 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct InsightsView: View {
|
||||||
|
@State private var viewModel = InsightsViewModel()
|
||||||
|
@Environment(AppCoordinator.self) private var coordinator
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 24) {
|
||||||
|
periodPicker
|
||||||
|
overviewSection
|
||||||
|
modelSection
|
||||||
|
platformSection
|
||||||
|
toolsSection
|
||||||
|
activitySection
|
||||||
|
notableSection
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||||
|
}
|
||||||
|
.navigationTitle("Insights")
|
||||||
|
.task { await viewModel.load() }
|
||||||
|
.onChange(of: viewModel.period) {
|
||||||
|
Task { await viewModel.load() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var periodPicker: some View {
|
||||||
|
Picker("Period", selection: $viewModel.period) {
|
||||||
|
ForEach(InsightsPeriod.allCases) { period in
|
||||||
|
Text(period.rawValue).tag(period)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
.frame(maxWidth: 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Overview
|
||||||
|
|
||||||
|
private var overviewSection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text("Overview")
|
||||||
|
.font(.headline)
|
||||||
|
LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 4), spacing: 12) {
|
||||||
|
InsightCard(label: "Sessions", value: "\(viewModel.sessions.count)")
|
||||||
|
InsightCard(label: "Messages", value: "\(viewModel.totalMessages)")
|
||||||
|
InsightCard(label: "User Messages", value: "\(viewModel.userMessageCount)")
|
||||||
|
InsightCard(label: "Tool Calls", value: "\(viewModel.totalToolCalls)")
|
||||||
|
InsightCard(label: "Input Tokens", value: formatTokens(viewModel.totalInputTokens))
|
||||||
|
InsightCard(label: "Output Tokens", value: formatTokens(viewModel.totalOutputTokens))
|
||||||
|
InsightCard(label: "Cache Read", value: formatTokens(viewModel.totalCacheReadTokens))
|
||||||
|
InsightCard(label: "Cache Write", value: formatTokens(viewModel.totalCacheWriteTokens))
|
||||||
|
InsightCard(label: "Total Tokens", value: formatTokens(viewModel.totalTokens))
|
||||||
|
InsightCard(label: "Active Time", value: formatDuration(viewModel.activeTime))
|
||||||
|
InsightCard(label: "Avg Session", value: formatDuration(viewModel.avgSessionDuration))
|
||||||
|
InsightCard(label: "Avg Msgs/Session", value: viewModel.sessions.isEmpty ? "0" : String(format: "%.1f", Double(viewModel.totalMessages) / Double(viewModel.sessions.count)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Models
|
||||||
|
|
||||||
|
private var modelSection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text("Models")
|
||||||
|
.font(.headline)
|
||||||
|
if viewModel.modelUsage.isEmpty {
|
||||||
|
Text("No data")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
} else {
|
||||||
|
ForEach(viewModel.modelUsage) { model in
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "cpu")
|
||||||
|
.foregroundStyle(.blue)
|
||||||
|
.frame(width: 20)
|
||||||
|
Text(model.model)
|
||||||
|
.font(.system(.body, design: .monospaced))
|
||||||
|
Spacer()
|
||||||
|
VStack(alignment: .trailing, spacing: 2) {
|
||||||
|
Text("\(model.sessions) sessions")
|
||||||
|
.font(.caption)
|
||||||
|
Text(formatTokens(model.totalTokens) + " tokens")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(10)
|
||||||
|
.background(.quaternary.opacity(0.5))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Platforms
|
||||||
|
|
||||||
|
private var platformSection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text("Platforms")
|
||||||
|
.font(.headline)
|
||||||
|
if viewModel.platformUsage.isEmpty {
|
||||||
|
Text("No data")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
} else {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
ForEach(viewModel.platformUsage) { platform in
|
||||||
|
VStack(spacing: 6) {
|
||||||
|
Image(systemName: platformIcon(platform.platform))
|
||||||
|
.font(.title2)
|
||||||
|
.foregroundStyle(Color.accentColor)
|
||||||
|
Text(platform.platform)
|
||||||
|
.font(.caption.bold())
|
||||||
|
Text("\(platform.sessions) sessions")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text("\(platform.messages) msgs")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(12)
|
||||||
|
.background(.quaternary.opacity(0.5))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Tools
|
||||||
|
|
||||||
|
private var toolsSection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text("Top Tools")
|
||||||
|
.font(.headline)
|
||||||
|
if viewModel.toolUsage.isEmpty {
|
||||||
|
Text("No data")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
} else {
|
||||||
|
let maxCount = viewModel.toolUsage.first?.count ?? 1
|
||||||
|
ForEach(viewModel.toolUsage.prefix(15)) { tool in
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
Text(tool.name)
|
||||||
|
.font(.system(.caption, design: .monospaced))
|
||||||
|
.frame(width: 140, alignment: .trailing)
|
||||||
|
GeometryReader { geo in
|
||||||
|
RoundedRectangle(cornerRadius: 3)
|
||||||
|
.fill(barColor(for: tool.name))
|
||||||
|
.frame(width: max(4, geo.size.width * Double(tool.count) / Double(maxCount)))
|
||||||
|
}
|
||||||
|
.frame(height: 16)
|
||||||
|
Text("\(tool.count)")
|
||||||
|
.font(.caption.monospaced())
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.frame(width: 40, alignment: .trailing)
|
||||||
|
Text(String(format: "%.1f%%", tool.percentage))
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
.frame(width: 50, alignment: .trailing)
|
||||||
|
}
|
||||||
|
.frame(height: 20)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Activity Patterns
|
||||||
|
|
||||||
|
private var activitySection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
Text("Activity Patterns")
|
||||||
|
.font(.headline)
|
||||||
|
HStack(alignment: .top, spacing: 24) {
|
||||||
|
dayOfWeekChart
|
||||||
|
hourlyChart
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var dayOfWeekChart: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text("By Day")
|
||||||
|
.font(.caption.bold())
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
let dayNames = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
|
||||||
|
let maxVal = max(1, viewModel.dailyActivity.values.max() ?? 1)
|
||||||
|
ForEach(0..<7, id: \.self) { day in
|
||||||
|
let count = viewModel.dailyActivity[day] ?? 0
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Text(dayNames[day])
|
||||||
|
.font(.caption.monospaced())
|
||||||
|
.frame(width: 30, alignment: .trailing)
|
||||||
|
RoundedRectangle(cornerRadius: 2)
|
||||||
|
.fill(Color.accentColor.opacity(0.7))
|
||||||
|
.frame(width: max(0, CGFloat(count) / CGFloat(maxVal) * 120), height: 14)
|
||||||
|
if count > 0 {
|
||||||
|
Text("\(count)")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var hourlyChart: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text("By Hour")
|
||||||
|
.font(.caption.bold())
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
let maxVal = max(1, viewModel.hourlyActivity.values.max() ?? 1)
|
||||||
|
HStack(alignment: .bottom, spacing: 2) {
|
||||||
|
ForEach(0..<24, id: \.self) { hour in
|
||||||
|
let count = viewModel.hourlyActivity[hour] ?? 0
|
||||||
|
VStack(spacing: 2) {
|
||||||
|
RoundedRectangle(cornerRadius: 2)
|
||||||
|
.fill(count > 0 ? Color.accentColor.opacity(0.7) : Color.secondary.opacity(0.15))
|
||||||
|
.frame(width: 12, height: max(4, CGFloat(count) / CGFloat(maxVal) * 80))
|
||||||
|
if hour % 6 == 0 {
|
||||||
|
Text("\(hour)")
|
||||||
|
.font(.system(size: 8))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
} else {
|
||||||
|
Text("")
|
||||||
|
.font(.system(size: 8))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Notable Sessions
|
||||||
|
|
||||||
|
private var notableSection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text("Notable Sessions")
|
||||||
|
.font(.headline)
|
||||||
|
if viewModel.notableSessions.isEmpty {
|
||||||
|
Text("No data")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
} else {
|
||||||
|
ForEach(viewModel.notableSessions) { notable in
|
||||||
|
HStack {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(notable.label)
|
||||||
|
.font(.caption.bold())
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text(notable.preview)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Text(notable.value)
|
||||||
|
.font(.system(.body, design: .monospaced, weight: .semibold))
|
||||||
|
Button {
|
||||||
|
coordinator.selectedSessionId = notable.session.id
|
||||||
|
coordinator.selectedSection = .sessions
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "arrow.right.circle")
|
||||||
|
.foregroundStyle(Color.accentColor)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.help("Open session")
|
||||||
|
}
|
||||||
|
.padding(10)
|
||||||
|
.background(.quaternary.opacity(0.5))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
private func platformIcon(_ platform: String) -> String {
|
||||||
|
switch platform {
|
||||||
|
case "cli": return "terminal"
|
||||||
|
case "telegram": return "paperplane"
|
||||||
|
case "discord": return "bubble.left.and.bubble.right"
|
||||||
|
case "slack": return "number"
|
||||||
|
case "email": return "envelope"
|
||||||
|
default: return "bubble.left"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func barColor(for toolName: String) -> Color {
|
||||||
|
switch toolName {
|
||||||
|
case "terminal": return .orange
|
||||||
|
case "read_file", "search_files": return .green
|
||||||
|
case "write_file", "patch": return .blue
|
||||||
|
case "web_search", "web_extract": return .purple
|
||||||
|
case _ where toolName.hasPrefix("browser"): return .indigo
|
||||||
|
case "memory": return .pink
|
||||||
|
case "vision", "image_gen": return .mint
|
||||||
|
default: return Color.accentColor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct InsightCard: View {
|
||||||
|
let label: String
|
||||||
|
let value: String
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 4) {
|
||||||
|
Text(value)
|
||||||
|
.font(.system(.title3, design: .monospaced, weight: .semibold))
|
||||||
|
Text(label)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(10)
|
||||||
|
.background(.quaternary.opacity(0.5))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import Foundation
|
|||||||
|
|
||||||
enum SidebarSection: String, CaseIterable, Identifiable {
|
enum SidebarSection: String, CaseIterable, Identifiable {
|
||||||
case dashboard = "Dashboard"
|
case dashboard = "Dashboard"
|
||||||
|
case insights = "Insights"
|
||||||
case sessions = "Sessions"
|
case sessions = "Sessions"
|
||||||
case activity = "Activity"
|
case activity = "Activity"
|
||||||
case chat = "Chat"
|
case chat = "Chat"
|
||||||
@@ -16,6 +17,7 @@ enum SidebarSection: String, CaseIterable, Identifiable {
|
|||||||
var icon: String {
|
var icon: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .dashboard: return "gauge.with.dots.needle.33percent"
|
case .dashboard: return "gauge.with.dots.needle.33percent"
|
||||||
|
case .insights: return "chart.bar"
|
||||||
case .sessions: return "bubble.left.and.bubble.right"
|
case .sessions: return "bubble.left.and.bubble.right"
|
||||||
case .activity: return "bolt.horizontal"
|
case .activity: return "bolt.horizontal"
|
||||||
case .chat: return "text.bubble"
|
case .chat: return "text.bubble"
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ struct SidebarView: View {
|
|||||||
@Bindable var coordinator = coordinator
|
@Bindable var coordinator = coordinator
|
||||||
List(selection: $coordinator.selectedSection) {
|
List(selection: $coordinator.selectedSection) {
|
||||||
Section("Monitor") {
|
Section("Monitor") {
|
||||||
ForEach([SidebarSection.dashboard, .sessions, .activity]) { section in
|
ForEach([SidebarSection.dashboard, .insights, .sessions, .activity]) { section in
|
||||||
Label(section.rawValue, systemImage: section.icon)
|
Label(section.rawValue, systemImage: section.icon)
|
||||||
.tag(section)
|
.tag(section)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user