code-meter 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- code_meter/__init__.py +3 -0
- code_meter/analytics/__init__.py +7 -0
- code_meter/analytics/costs.py +43 -0
- code_meter/analytics/reports.py +357 -0
- code_meter/analytics/tokens.py +51 -0
- code_meter/cli.py +334 -0
- code_meter/config.py +116 -0
- code_meter/models/__init__.py +13 -0
- code_meter/models/project.py +85 -0
- code_meter/models/usage.py +61 -0
- code_meter/pricing/__init__.py +14 -0
- code_meter/pricing/anthropic.py +78 -0
- code_meter/pricing/engine.py +97 -0
- code_meter/pricing/google.py +60 -0
- code_meter/pricing/openai.py +69 -0
- code_meter/providers/__init__.py +15 -0
- code_meter/providers/antigravity.py +358 -0
- code_meter/providers/base.py +42 -0
- code_meter/providers/claude_code.py +378 -0
- code_meter/providers/codex.py +356 -0
- code_meter/storage/__init__.py +6 -0
- code_meter/storage/database.py +147 -0
- code_meter/storage/repository.py +482 -0
- code_meter/ui/__init__.py +19 -0
- code_meter/ui/dashboard.py +111 -0
- code_meter/ui/tables.py +195 -0
- code_meter/watcher.py +56 -0
- code_meter-0.1.0.dist-info/METADATA +207 -0
- code_meter-0.1.0.dist-info/RECORD +33 -0
- code_meter-0.1.0.dist-info/WHEEL +5 -0
- code_meter-0.1.0.dist-info/entry_points.txt +6 -0
- code_meter-0.1.0.dist-info/licenses/LICENSE +21 -0
- code_meter-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,482 @@
|
|
|
1
|
+
"""Repository for database persistence of usage records, prompts, code changes, scan state, and pricing."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Dict, List, Optional
|
|
8
|
+
|
|
9
|
+
from code_meter.models.usage import SessionCodeChange, SessionPrompt, UsageRecord
|
|
10
|
+
from code_meter.providers.base import FileScanState
|
|
11
|
+
from code_meter.storage.database import Database
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class UsageRepository:
|
|
17
|
+
"""DAO for usage events, prompts, code changes, scan states, and pricing lookup."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, db: Database):
|
|
20
|
+
self.db = db
|
|
21
|
+
self.db.initialize_schema()
|
|
22
|
+
|
|
23
|
+
def save_records(self, records: List[UsageRecord]) -> int:
|
|
24
|
+
"""Batch insert usage records into SQLite, ignoring duplicates based on UNIQUE(provider, request_id)."""
|
|
25
|
+
if not records:
|
|
26
|
+
return 0
|
|
27
|
+
|
|
28
|
+
inserted_count = 0
|
|
29
|
+
now_str = datetime.now(timezone.utc).isoformat()
|
|
30
|
+
|
|
31
|
+
conn = self.db.get_connection()
|
|
32
|
+
try:
|
|
33
|
+
cursor = conn.cursor()
|
|
34
|
+
query = """
|
|
35
|
+
INSERT OR IGNORE INTO usage_events (
|
|
36
|
+
provider, request_id, session_id, timestamp,
|
|
37
|
+
user_id, project_id, project_path, model,
|
|
38
|
+
input_tokens, output_tokens, cache_read_tokens, cache_write_tokens,
|
|
39
|
+
metadata_json, agent_id, task_id, tool_name, created_at
|
|
40
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
batch_data = []
|
|
44
|
+
for r in records:
|
|
45
|
+
meta_json = json.dumps(r.metadata) if r.metadata else None
|
|
46
|
+
ts_str = r.timestamp.isoformat() if isinstance(r.timestamp, datetime) else str(r.timestamp)
|
|
47
|
+
batch_data.append((
|
|
48
|
+
r.provider,
|
|
49
|
+
r.request_id,
|
|
50
|
+
r.session_id,
|
|
51
|
+
ts_str,
|
|
52
|
+
r.user_id,
|
|
53
|
+
r.project_id,
|
|
54
|
+
r.project_path,
|
|
55
|
+
r.model,
|
|
56
|
+
r.input_tokens,
|
|
57
|
+
r.output_tokens,
|
|
58
|
+
r.cache_read_tokens,
|
|
59
|
+
r.cache_write_tokens,
|
|
60
|
+
meta_json,
|
|
61
|
+
r.agent_id,
|
|
62
|
+
r.task_id,
|
|
63
|
+
r.tool_name,
|
|
64
|
+
now_str,
|
|
65
|
+
))
|
|
66
|
+
|
|
67
|
+
cursor.executemany(query, batch_data)
|
|
68
|
+
inserted_count = cursor.rowcount if cursor.rowcount > 0 else 0
|
|
69
|
+
conn.commit()
|
|
70
|
+
except Exception as e:
|
|
71
|
+
conn.rollback()
|
|
72
|
+
logger.error(f"Error saving records to database: {e}", exc_info=True)
|
|
73
|
+
raise
|
|
74
|
+
finally:
|
|
75
|
+
conn.close()
|
|
76
|
+
|
|
77
|
+
return inserted_count
|
|
78
|
+
|
|
79
|
+
def save_prompts(self, prompts: List[SessionPrompt]) -> int:
|
|
80
|
+
"""Batch insert user session prompts into SQLite."""
|
|
81
|
+
if not prompts:
|
|
82
|
+
return 0
|
|
83
|
+
|
|
84
|
+
now_str = datetime.now(timezone.utc).isoformat()
|
|
85
|
+
conn = self.db.get_connection()
|
|
86
|
+
inserted_count = 0
|
|
87
|
+
try:
|
|
88
|
+
cursor = conn.cursor()
|
|
89
|
+
query = """
|
|
90
|
+
INSERT OR IGNORE INTO session_prompts (
|
|
91
|
+
provider, session_id, prompt_id, timestamp, prompt_text, project_id, created_at
|
|
92
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?);
|
|
93
|
+
"""
|
|
94
|
+
batch_data = [
|
|
95
|
+
(
|
|
96
|
+
p.provider,
|
|
97
|
+
p.session_id,
|
|
98
|
+
p.prompt_id,
|
|
99
|
+
p.timestamp.isoformat() if isinstance(p.timestamp, datetime) else str(p.timestamp),
|
|
100
|
+
p.prompt_text,
|
|
101
|
+
p.project_id,
|
|
102
|
+
now_str,
|
|
103
|
+
)
|
|
104
|
+
for p in prompts
|
|
105
|
+
]
|
|
106
|
+
cursor.executemany(query, batch_data)
|
|
107
|
+
inserted_count = cursor.rowcount if cursor.rowcount > 0 else 0
|
|
108
|
+
conn.commit()
|
|
109
|
+
finally:
|
|
110
|
+
conn.close()
|
|
111
|
+
|
|
112
|
+
return inserted_count
|
|
113
|
+
|
|
114
|
+
def save_code_changes(self, changes: List[SessionCodeChange]) -> int:
|
|
115
|
+
"""Batch insert session code changes / diffs into SQLite."""
|
|
116
|
+
if not changes:
|
|
117
|
+
return 0
|
|
118
|
+
|
|
119
|
+
now_str = datetime.now(timezone.utc).isoformat()
|
|
120
|
+
conn = self.db.get_connection()
|
|
121
|
+
inserted_count = 0
|
|
122
|
+
try:
|
|
123
|
+
cursor = conn.cursor()
|
|
124
|
+
query = """
|
|
125
|
+
INSERT OR IGNORE INTO session_code_changes (
|
|
126
|
+
provider, session_id, change_id, timestamp, file_path, change_type, diff_summary, project_id, created_at
|
|
127
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?);
|
|
128
|
+
"""
|
|
129
|
+
batch_data = [
|
|
130
|
+
(
|
|
131
|
+
c.provider,
|
|
132
|
+
c.session_id,
|
|
133
|
+
c.change_id,
|
|
134
|
+
c.timestamp.isoformat() if isinstance(c.timestamp, datetime) else str(c.timestamp),
|
|
135
|
+
c.file_path,
|
|
136
|
+
c.change_type,
|
|
137
|
+
c.diff_summary,
|
|
138
|
+
c.project_id,
|
|
139
|
+
now_str,
|
|
140
|
+
)
|
|
141
|
+
for c in changes
|
|
142
|
+
]
|
|
143
|
+
cursor.executemany(query, batch_data)
|
|
144
|
+
inserted_count = cursor.rowcount if cursor.rowcount > 0 else 0
|
|
145
|
+
conn.commit()
|
|
146
|
+
finally:
|
|
147
|
+
conn.close()
|
|
148
|
+
|
|
149
|
+
return inserted_count
|
|
150
|
+
|
|
151
|
+
def get_scan_states(self) -> Dict[str, FileScanState]:
|
|
152
|
+
"""Load scan states for incremental scanning."""
|
|
153
|
+
states: Dict[str, FileScanState] = {}
|
|
154
|
+
conn = self.db.get_connection()
|
|
155
|
+
try:
|
|
156
|
+
cursor = conn.cursor()
|
|
157
|
+
cursor.execute("SELECT file_path, file_size, modified_time, read_offset FROM scan_state;")
|
|
158
|
+
for row in cursor.fetchall():
|
|
159
|
+
states[row["file_path"]] = FileScanState(
|
|
160
|
+
file_path=row["file_path"],
|
|
161
|
+
file_size=row["file_size"],
|
|
162
|
+
modified_time=row["modified_time"],
|
|
163
|
+
read_offset=row["read_offset"],
|
|
164
|
+
)
|
|
165
|
+
finally:
|
|
166
|
+
conn.close()
|
|
167
|
+
return states
|
|
168
|
+
|
|
169
|
+
def update_scan_states(self, states: List[FileScanState]) -> None:
|
|
170
|
+
"""Upsert file scan states."""
|
|
171
|
+
if not states:
|
|
172
|
+
return
|
|
173
|
+
now_str = datetime.now(timezone.utc).isoformat()
|
|
174
|
+
conn = self.db.get_connection()
|
|
175
|
+
try:
|
|
176
|
+
cursor = conn.cursor()
|
|
177
|
+
query = """
|
|
178
|
+
INSERT INTO scan_state (file_path, file_size, modified_time, read_offset, updated_at)
|
|
179
|
+
VALUES (?, ?, ?, ?, ?)
|
|
180
|
+
ON CONFLICT(file_path) DO UPDATE SET
|
|
181
|
+
file_size = excluded.file_size,
|
|
182
|
+
modified_time = excluded.modified_time,
|
|
183
|
+
read_offset = excluded.read_offset,
|
|
184
|
+
updated_at = excluded.updated_at;
|
|
185
|
+
"""
|
|
186
|
+
batch_data = [(s.file_path, s.file_size, s.modified_time, s.read_offset, now_str) for s in states]
|
|
187
|
+
cursor.executemany(query, batch_data)
|
|
188
|
+
conn.commit()
|
|
189
|
+
finally:
|
|
190
|
+
conn.close()
|
|
191
|
+
|
|
192
|
+
def save_pricing(self, pricing_records: List[Dict[str, Any]]) -> None:
|
|
193
|
+
"""Upsert model pricing definitions into SQLite."""
|
|
194
|
+
if not pricing_records:
|
|
195
|
+
return
|
|
196
|
+
conn = self.db.get_connection()
|
|
197
|
+
try:
|
|
198
|
+
cursor = conn.cursor()
|
|
199
|
+
query = """
|
|
200
|
+
INSERT INTO pricing (
|
|
201
|
+
provider, model, input_price_per_million, output_price_per_million,
|
|
202
|
+
cache_read_price_per_million, cache_write_price_per_million, effective_from
|
|
203
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
204
|
+
ON CONFLICT(provider, model, effective_from) DO UPDATE SET
|
|
205
|
+
input_price_per_million = excluded.input_price_per_million,
|
|
206
|
+
output_price_per_million = excluded.output_price_per_million,
|
|
207
|
+
cache_read_price_per_million = excluded.cache_read_price_per_million,
|
|
208
|
+
cache_write_price_per_million = excluded.cache_write_price_per_million;
|
|
209
|
+
"""
|
|
210
|
+
batch_data = [
|
|
211
|
+
(
|
|
212
|
+
p["provider"],
|
|
213
|
+
p["model"],
|
|
214
|
+
p["input_price_per_million"],
|
|
215
|
+
p["output_price_per_million"],
|
|
216
|
+
p.get("cache_read_price_per_million", 0.0),
|
|
217
|
+
p.get("cache_write_price_per_million", 0.0),
|
|
218
|
+
p.get("effective_from", "2020-01-01T00:00:00Z"),
|
|
219
|
+
)
|
|
220
|
+
for p in pricing_records
|
|
221
|
+
]
|
|
222
|
+
cursor.executemany(query, batch_data)
|
|
223
|
+
conn.commit()
|
|
224
|
+
finally:
|
|
225
|
+
conn.close()
|
|
226
|
+
|
|
227
|
+
def get_pricing_table(self) -> List[Dict[str, Any]]:
|
|
228
|
+
"""Retrieve all pricing entries."""
|
|
229
|
+
conn = self.db.get_connection()
|
|
230
|
+
try:
|
|
231
|
+
cursor = conn.cursor()
|
|
232
|
+
cursor.execute(
|
|
233
|
+
"""
|
|
234
|
+
SELECT provider, model, input_price_per_million, output_price_per_million,
|
|
235
|
+
cache_read_price_per_million, cache_write_price_per_million, effective_from
|
|
236
|
+
FROM pricing
|
|
237
|
+
ORDER BY provider, model, effective_from DESC;
|
|
238
|
+
"""
|
|
239
|
+
)
|
|
240
|
+
return [dict(row) for row in cursor.fetchall()]
|
|
241
|
+
finally:
|
|
242
|
+
conn.close()
|
|
243
|
+
|
|
244
|
+
def query_usage_events(
|
|
245
|
+
self,
|
|
246
|
+
provider: Optional[str] = None,
|
|
247
|
+
model: Optional[str] = None,
|
|
248
|
+
project_id: Optional[str] = None,
|
|
249
|
+
session_id: Optional[str] = None,
|
|
250
|
+
start_date: Optional[datetime] = None,
|
|
251
|
+
end_date: Optional[datetime] = None,
|
|
252
|
+
limit: Optional[int] = None,
|
|
253
|
+
) -> List[UsageRecord]:
|
|
254
|
+
"""Query usage events with flexible filtering options."""
|
|
255
|
+
conditions: List[str] = []
|
|
256
|
+
params: List[Any] = []
|
|
257
|
+
|
|
258
|
+
if provider:
|
|
259
|
+
conditions.append("provider = ?")
|
|
260
|
+
params.append(provider)
|
|
261
|
+
if model:
|
|
262
|
+
conditions.append("model LIKE ?")
|
|
263
|
+
params.append(f"%{model}%")
|
|
264
|
+
if project_id:
|
|
265
|
+
conditions.append("(project_id LIKE ? OR project_path LIKE ?)")
|
|
266
|
+
params.append(f"%{project_id}%")
|
|
267
|
+
params.append(f"%{project_id}%")
|
|
268
|
+
if session_id:
|
|
269
|
+
conditions.append("session_id = ?")
|
|
270
|
+
params.append(session_id)
|
|
271
|
+
if start_date:
|
|
272
|
+
conditions.append("timestamp >= ?")
|
|
273
|
+
params.append(start_date.isoformat())
|
|
274
|
+
if end_date:
|
|
275
|
+
conditions.append("timestamp <= ?")
|
|
276
|
+
params.append(end_date.isoformat())
|
|
277
|
+
|
|
278
|
+
where_clause = f"WHERE {' AND '.join(conditions)}" if conditions else ""
|
|
279
|
+
limit_clause = f"LIMIT {limit}" if limit else ""
|
|
280
|
+
|
|
281
|
+
query = f"""
|
|
282
|
+
SELECT provider, request_id, session_id, timestamp,
|
|
283
|
+
user_id, project_id, project_path, model,
|
|
284
|
+
input_tokens, output_tokens, cache_read_tokens, cache_write_tokens,
|
|
285
|
+
metadata_json, agent_id, task_id, tool_name
|
|
286
|
+
FROM usage_events
|
|
287
|
+
{where_clause}
|
|
288
|
+
ORDER BY timestamp DESC
|
|
289
|
+
{limit_clause};
|
|
290
|
+
"""
|
|
291
|
+
|
|
292
|
+
conn = self.db.get_connection()
|
|
293
|
+
records: List[UsageRecord] = []
|
|
294
|
+
try:
|
|
295
|
+
cursor = conn.cursor()
|
|
296
|
+
cursor.execute(query, params)
|
|
297
|
+
for row in cursor.fetchall():
|
|
298
|
+
meta = json.loads(row["metadata_json"]) if row["metadata_json"] else {}
|
|
299
|
+
ts_val = row["timestamp"]
|
|
300
|
+
try:
|
|
301
|
+
ts = datetime.fromisoformat(ts_val)
|
|
302
|
+
except ValueError:
|
|
303
|
+
ts = datetime.now(timezone.utc)
|
|
304
|
+
|
|
305
|
+
records.append(
|
|
306
|
+
UsageRecord(
|
|
307
|
+
provider=row["provider"],
|
|
308
|
+
request_id=row["request_id"],
|
|
309
|
+
session_id=row["session_id"],
|
|
310
|
+
timestamp=ts,
|
|
311
|
+
user_id=row["user_id"],
|
|
312
|
+
project_id=row["project_id"],
|
|
313
|
+
project_path=row["project_path"],
|
|
314
|
+
model=row["model"],
|
|
315
|
+
input_tokens=row["input_tokens"],
|
|
316
|
+
output_tokens=row["output_tokens"],
|
|
317
|
+
cache_read_tokens=row["cache_read_tokens"],
|
|
318
|
+
cache_write_tokens=row["cache_write_tokens"],
|
|
319
|
+
metadata=meta,
|
|
320
|
+
agent_id=row["agent_id"],
|
|
321
|
+
task_id=row["task_id"],
|
|
322
|
+
tool_name=row["tool_name"],
|
|
323
|
+
)
|
|
324
|
+
)
|
|
325
|
+
finally:
|
|
326
|
+
conn.close()
|
|
327
|
+
|
|
328
|
+
return records
|
|
329
|
+
|
|
330
|
+
def query_prompts(
|
|
331
|
+
self,
|
|
332
|
+
session_id: Optional[str] = None,
|
|
333
|
+
project_id: Optional[str] = None,
|
|
334
|
+
limit: Optional[int] = 50,
|
|
335
|
+
) -> List[SessionPrompt]:
|
|
336
|
+
"""Query user prompts asked per session or project."""
|
|
337
|
+
conditions: List[str] = []
|
|
338
|
+
params: List[Any] = []
|
|
339
|
+
|
|
340
|
+
if session_id:
|
|
341
|
+
conditions.append("session_id = ?")
|
|
342
|
+
params.append(session_id)
|
|
343
|
+
if project_id:
|
|
344
|
+
conditions.append("project_id LIKE ?")
|
|
345
|
+
params.append(f"%{project_id}%")
|
|
346
|
+
|
|
347
|
+
where_clause = f"WHERE {' AND '.join(conditions)}" if conditions else ""
|
|
348
|
+
limit_clause = f"LIMIT {limit}" if limit else ""
|
|
349
|
+
|
|
350
|
+
query = f"""
|
|
351
|
+
SELECT provider, session_id, prompt_id, timestamp, prompt_text, project_id
|
|
352
|
+
FROM session_prompts
|
|
353
|
+
{where_clause}
|
|
354
|
+
ORDER BY timestamp DESC
|
|
355
|
+
{limit_clause};
|
|
356
|
+
"""
|
|
357
|
+
|
|
358
|
+
conn = self.db.get_connection()
|
|
359
|
+
prompts: List[SessionPrompt] = []
|
|
360
|
+
try:
|
|
361
|
+
cursor = conn.cursor()
|
|
362
|
+
cursor.execute(query, params)
|
|
363
|
+
for row in cursor.fetchall():
|
|
364
|
+
ts_val = row["timestamp"]
|
|
365
|
+
try:
|
|
366
|
+
ts = datetime.fromisoformat(ts_val)
|
|
367
|
+
except ValueError:
|
|
368
|
+
ts = datetime.now(timezone.utc)
|
|
369
|
+
|
|
370
|
+
prompts.append(
|
|
371
|
+
SessionPrompt(
|
|
372
|
+
provider=row["provider"],
|
|
373
|
+
session_id=row["session_id"],
|
|
374
|
+
prompt_id=row["prompt_id"],
|
|
375
|
+
timestamp=ts,
|
|
376
|
+
prompt_text=row["prompt_text"],
|
|
377
|
+
project_id=row["project_id"],
|
|
378
|
+
)
|
|
379
|
+
)
|
|
380
|
+
finally:
|
|
381
|
+
conn.close()
|
|
382
|
+
|
|
383
|
+
return prompts
|
|
384
|
+
|
|
385
|
+
def query_code_changes(
|
|
386
|
+
self,
|
|
387
|
+
session_id: Optional[str] = None,
|
|
388
|
+
project_id: Optional[str] = None,
|
|
389
|
+
file_path: Optional[str] = None,
|
|
390
|
+
limit: Optional[int] = 50,
|
|
391
|
+
) -> List[SessionCodeChange]:
|
|
392
|
+
"""Query code changes / diff history per session or file."""
|
|
393
|
+
conditions: List[str] = []
|
|
394
|
+
params: List[Any] = []
|
|
395
|
+
|
|
396
|
+
if session_id:
|
|
397
|
+
conditions.append("session_id = ?")
|
|
398
|
+
params.append(session_id)
|
|
399
|
+
if project_id:
|
|
400
|
+
conditions.append("project_id LIKE ?")
|
|
401
|
+
params.append(f"%{project_id}%")
|
|
402
|
+
if file_path:
|
|
403
|
+
conditions.append("file_path LIKE ?")
|
|
404
|
+
params.append(f"%{file_path}%")
|
|
405
|
+
|
|
406
|
+
where_clause = f"WHERE {' AND '.join(conditions)}" if conditions else ""
|
|
407
|
+
limit_clause = f"LIMIT {limit}" if limit else ""
|
|
408
|
+
|
|
409
|
+
query = f"""
|
|
410
|
+
SELECT provider, session_id, change_id, timestamp, file_path, change_type, diff_summary, project_id
|
|
411
|
+
FROM session_code_changes
|
|
412
|
+
{where_clause}
|
|
413
|
+
ORDER BY timestamp DESC
|
|
414
|
+
{limit_clause};
|
|
415
|
+
"""
|
|
416
|
+
|
|
417
|
+
conn = self.db.get_connection()
|
|
418
|
+
changes: List[SessionCodeChange] = []
|
|
419
|
+
try:
|
|
420
|
+
cursor = conn.cursor()
|
|
421
|
+
cursor.execute(query, params)
|
|
422
|
+
for row in cursor.fetchall():
|
|
423
|
+
ts_val = row["timestamp"]
|
|
424
|
+
try:
|
|
425
|
+
ts = datetime.fromisoformat(ts_val)
|
|
426
|
+
except ValueError:
|
|
427
|
+
ts = datetime.now(timezone.utc)
|
|
428
|
+
|
|
429
|
+
changes.append(
|
|
430
|
+
SessionCodeChange(
|
|
431
|
+
provider=row["provider"],
|
|
432
|
+
session_id=row["session_id"],
|
|
433
|
+
change_id=row["change_id"],
|
|
434
|
+
timestamp=ts,
|
|
435
|
+
file_path=row["file_path"],
|
|
436
|
+
change_type=row["change_type"],
|
|
437
|
+
diff_summary=row["diff_summary"],
|
|
438
|
+
project_id=row["project_id"],
|
|
439
|
+
)
|
|
440
|
+
)
|
|
441
|
+
finally:
|
|
442
|
+
conn.close()
|
|
443
|
+
|
|
444
|
+
return changes
|
|
445
|
+
|
|
446
|
+
def get_status_summary(self) -> Dict[str, Any]:
|
|
447
|
+
"""Return system status summary stats including prompts and code changes."""
|
|
448
|
+
conn = self.db.get_connection()
|
|
449
|
+
try:
|
|
450
|
+
cursor = conn.cursor()
|
|
451
|
+
cursor.execute("SELECT COUNT(*) FROM usage_events;")
|
|
452
|
+
total_records = cursor.fetchone()[0]
|
|
453
|
+
|
|
454
|
+
cursor.execute("SELECT COUNT(*) FROM session_prompts;")
|
|
455
|
+
total_prompts = cursor.fetchone()[0]
|
|
456
|
+
|
|
457
|
+
cursor.execute("SELECT COUNT(*) FROM session_code_changes;")
|
|
458
|
+
total_changes = cursor.fetchone()[0]
|
|
459
|
+
|
|
460
|
+
cursor.execute("SELECT COUNT(DISTINCT session_id) FROM usage_events WHERE session_id IS NOT NULL;")
|
|
461
|
+
unique_sessions = cursor.fetchone()[0]
|
|
462
|
+
|
|
463
|
+
cursor.execute("SELECT COUNT(DISTINCT project_id) FROM usage_events WHERE project_id IS NOT NULL;")
|
|
464
|
+
unique_projects = cursor.fetchone()[0]
|
|
465
|
+
|
|
466
|
+
cursor.execute("SELECT COUNT(*) FROM scan_state;")
|
|
467
|
+
scanned_files = cursor.fetchone()[0]
|
|
468
|
+
|
|
469
|
+
db_bytes = self.db.db_path.stat().st_size if self.db.db_path.exists() else 0
|
|
470
|
+
|
|
471
|
+
return {
|
|
472
|
+
"db_path": str(self.db.db_path),
|
|
473
|
+
"db_size_bytes": db_bytes,
|
|
474
|
+
"total_records": total_records,
|
|
475
|
+
"total_prompts": total_prompts,
|
|
476
|
+
"total_code_changes": total_changes,
|
|
477
|
+
"unique_sessions": unique_sessions,
|
|
478
|
+
"unique_projects": unique_projects,
|
|
479
|
+
"scanned_files": scanned_files,
|
|
480
|
+
}
|
|
481
|
+
finally:
|
|
482
|
+
conn.close()
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""UI package for terminal rendering with Rich."""
|
|
2
|
+
|
|
3
|
+
from code_meter.ui.dashboard import render_dashboard
|
|
4
|
+
from code_meter.ui.tables import (
|
|
5
|
+
render_models_table,
|
|
6
|
+
render_projects_table,
|
|
7
|
+
render_sessions_table,
|
|
8
|
+
render_daily_table,
|
|
9
|
+
render_status_panel,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"render_dashboard",
|
|
14
|
+
"render_models_table",
|
|
15
|
+
"render_projects_table",
|
|
16
|
+
"render_sessions_table",
|
|
17
|
+
"render_daily_table",
|
|
18
|
+
"render_status_panel",
|
|
19
|
+
]
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Interactive Rich terminal dashboard layout."""
|
|
2
|
+
|
|
3
|
+
from typing import Optional
|
|
4
|
+
from rich.console import Console
|
|
5
|
+
from rich.panel import Panel
|
|
6
|
+
from rich.table import Table
|
|
7
|
+
from rich.text import Text
|
|
8
|
+
|
|
9
|
+
from code_meter.models.project import AggregateSummary
|
|
10
|
+
from code_meter.ui.tables import format_cost, format_tokens
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def render_dashboard(
|
|
14
|
+
summary: AggregateSummary,
|
|
15
|
+
title_suffix: str = "Overview",
|
|
16
|
+
console: Optional[Console] = None,
|
|
17
|
+
) -> None:
|
|
18
|
+
c = console or Console()
|
|
19
|
+
|
|
20
|
+
header_text = Text()
|
|
21
|
+
header_text.append("CODE METER\n", style="bold cyan splash")
|
|
22
|
+
header_text.append(f"{title_suffix}", style="dim white")
|
|
23
|
+
header_panel = Panel(header_text, style="bold blue", expand=True)
|
|
24
|
+
|
|
25
|
+
metrics_table = Table.grid(padding=(0, 4))
|
|
26
|
+
metrics_table.add_column(style="bold white")
|
|
27
|
+
metrics_table.add_column(style="bold yellow", justify="right")
|
|
28
|
+
|
|
29
|
+
metrics_table.add_row("Requests", f"{summary.total_requests:,}")
|
|
30
|
+
metrics_table.add_row("Input Tokens", format_tokens(summary.input_tokens))
|
|
31
|
+
metrics_table.add_row("Output Tokens", format_tokens(summary.output_tokens))
|
|
32
|
+
metrics_table.add_row("Cache Read", format_tokens(summary.cache_read_tokens))
|
|
33
|
+
metrics_table.add_row("Cache Write", format_tokens(summary.cache_write_tokens))
|
|
34
|
+
metrics_table.add_row("Total Tokens", format_tokens(summary.total_tokens))
|
|
35
|
+
metrics_table.add_row("", "")
|
|
36
|
+
metrics_table.add_row("Estimated Cost", format_cost(summary.estimated_cost_usd))
|
|
37
|
+
|
|
38
|
+
metrics_panel = Panel(
|
|
39
|
+
metrics_table,
|
|
40
|
+
title="[bold green]Key Metrics[/bold green]",
|
|
41
|
+
border_style="green",
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
model_table = Table(title="BY MODEL", header_style="bold magenta", expand=True)
|
|
45
|
+
model_table.add_column("Model", style="cyan")
|
|
46
|
+
model_table.add_column("Tokens", justify="right", style="bold")
|
|
47
|
+
model_table.add_column("Cost", justify="right", style="bold green")
|
|
48
|
+
|
|
49
|
+
for m in summary.by_model[:5]:
|
|
50
|
+
model_table.add_row(
|
|
51
|
+
m.model,
|
|
52
|
+
format_tokens(m.total_tokens),
|
|
53
|
+
format_cost(m.estimated_cost_usd),
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
project_table = Table(title="BY PROJECT", header_style="bold blue", expand=True)
|
|
57
|
+
project_table.add_column("Project", style="cyan")
|
|
58
|
+
project_table.add_column("Tokens", justify="right", style="bold")
|
|
59
|
+
project_table.add_column("Cost", justify="right", style="bold green")
|
|
60
|
+
|
|
61
|
+
for p in summary.by_project[:5]:
|
|
62
|
+
project_table.add_row(
|
|
63
|
+
p.project_name,
|
|
64
|
+
format_tokens(p.total_tokens),
|
|
65
|
+
format_cost(p.estimated_cost_usd),
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
today_grid = Table.grid(padding=(0, 2))
|
|
69
|
+
today_grid.add_column(style="bold white")
|
|
70
|
+
today_grid.add_column(style="bold yellow", justify="right")
|
|
71
|
+
|
|
72
|
+
if summary.today_summary:
|
|
73
|
+
ts = summary.today_summary
|
|
74
|
+
today_grid.add_row("Requests", f"{ts.request_count:,}")
|
|
75
|
+
today_grid.add_row("Tokens", format_tokens(ts.total_tokens))
|
|
76
|
+
today_grid.add_row("Estimated Cost", format_cost(ts.estimated_cost_usd))
|
|
77
|
+
else:
|
|
78
|
+
today_grid.add_row("No usage recorded today", "")
|
|
79
|
+
|
|
80
|
+
today_panel = Panel(
|
|
81
|
+
today_grid,
|
|
82
|
+
title="[bold yellow]TODAY[/bold yellow]",
|
|
83
|
+
border_style="yellow",
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
burn_grid = Table.grid(padding=(0, 2))
|
|
87
|
+
burn_grid.add_column(style="bold white")
|
|
88
|
+
burn_grid.add_column(style="bold magenta", justify="right")
|
|
89
|
+
|
|
90
|
+
if summary.tokens_per_hour is not None:
|
|
91
|
+
burn_grid.add_row("Tokens/hour", format_tokens(int(summary.tokens_per_hour)))
|
|
92
|
+
if summary.cost_per_hour is not None:
|
|
93
|
+
burn_grid.add_row("Cost/hour", f"${summary.cost_per_hour:.2f}")
|
|
94
|
+
burn_grid.add_row("Tokens/day", format_tokens(int(summary.tokens_per_day or 0)))
|
|
95
|
+
if summary.cost_per_day is not None:
|
|
96
|
+
burn_grid.add_row("Cost/day", f"${summary.cost_per_day:.2f}")
|
|
97
|
+
else:
|
|
98
|
+
burn_grid.add_row("Insufficient data for burn rate", "")
|
|
99
|
+
|
|
100
|
+
burn_panel = Panel(
|
|
101
|
+
burn_grid,
|
|
102
|
+
title="[bold magenta]BURN RATE[/bold magenta]",
|
|
103
|
+
border_style="magenta",
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
c.print(header_panel)
|
|
107
|
+
c.print(metrics_panel)
|
|
108
|
+
c.print(model_table)
|
|
109
|
+
c.print(project_table)
|
|
110
|
+
c.print(today_panel)
|
|
111
|
+
c.print(burn_panel)
|