smlflg's picture
Initial public upload from Projekte/CodexDashBoard
981aa5d verified
Raw History Blame Contribute Delete
8.1 kB
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
import sqlite3
from typing import Iterable
from .paths import LOGS_DB, STATE_DB, TUI_LOG
@dataclass(frozen=True)
class ThreadSummary:
id: str
title: str
cwd: str
model: str
reasoning_effort: str
tokens_used: int
archived: bool
source: str
git_branch: str
created_ms: int
updated_ms: int
@dataclass(frozen=True)
class LogEntry:
id: int
ts_ms: int
level: str
target: str
thread_id: str
message: str
@dataclass(frozen=True)
class DataAccessError:
source: str
path: Path
message: str
@dataclass(frozen=True)
class DataResult:
data: object
error: DataAccessError | None = None
@dataclass(frozen=True)
class DashboardStats:
thread_count: int
archived_count: int
total_tokens: int
recent_hour_count: int
latest_thread: ThreadSummary | None
error: DataAccessError | None = None
def _data_error(source: str, path: Path, exc: BaseException) -> DataAccessError:
return DataAccessError(source=source, path=path, message=str(exc))
def _connect_readonly(path: Path) -> sqlite3.Connection:
if not path.exists():
raise FileNotFoundError(path)
uri = f"file:{path}?mode=ro"
conn = sqlite3.connect(uri, uri=True, timeout=0.05)
conn.row_factory = sqlite3.Row
return conn
def _shorten(text: str | None, limit: int = 120) -> str:
value = (text or "").strip().replace("\n", " ")
if len(value) <= limit:
return value
return value[: limit - 3].rstrip() + "..."
def _thread_from_row(row: sqlite3.Row) -> ThreadSummary:
title = row["title"] or row["first_user_message"] or "(untitled)"
return ThreadSummary(
id=row["id"],
title=_shorten(title),
cwd=row["cwd"] or "",
model=row["model"] or row["model_provider"] or "",
reasoning_effort=row["reasoning_effort"] or "",
tokens_used=int(row["tokens_used"] or 0),
archived=bool(row["archived"]),
source=row["source"] or "",
git_branch=row["git_branch"] or "",
created_ms=int(row["created_ms"] or 0),
updated_ms=int(row["updated_ms"] or 0),
)
def list_threads(
*,
db_path: Path = STATE_DB,
query: str = "",
include_archived: bool = False,
limit: int = 80,
) -> list[ThreadSummary]:
return list_threads_result(
db_path=db_path,
query=query,
include_archived=include_archived,
limit=limit,
).data
def list_threads_result(
*,
db_path: Path = STATE_DB,
query: str = "",
include_archived: bool = False,
limit: int = 80,
) -> DataResult:
clauses = []
params: list[object] = []
if not include_archived:
clauses.append("archived = 0")
if query.strip():
needle = f"%{query.strip()}%"
clauses.append(
"(title LIKE ? OR first_user_message LIKE ? OR cwd LIKE ? OR git_branch LIKE ?)"
)
params.extend([needle, needle, needle, needle])
where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
params.append(limit)
sql = f"""
SELECT
id,
title,
first_user_message,
cwd,
model,
model_provider,
reasoning_effort,
tokens_used,
archived,
source,
git_branch,
COALESCE(created_at_ms, created_at * 1000) AS created_ms,
COALESCE(updated_at_ms, updated_at * 1000) AS updated_ms
FROM threads
{where}
ORDER BY updated_ms DESC, id DESC
LIMIT ?
"""
try:
with _connect_readonly(db_path) as conn:
return DataResult([_thread_from_row(row) for row in conn.execute(sql, params)])
except FileNotFoundError:
return DataResult([])
except (sqlite3.Error, TypeError, ValueError) as exc:
return DataResult([], _data_error("state.sqlite", db_path, exc))
def get_stats(*, db_path: Path = STATE_DB) -> DashboardStats:
threads_result = list_threads_result(db_path=db_path, include_archived=False, limit=1)
threads = threads_result.data
try:
with _connect_readonly(db_path) as conn:
row = conn.execute(
"""
SELECT
COUNT(*) AS thread_count,
SUM(CASE WHEN archived = 1 THEN 1 ELSE 0 END) AS archived_count,
COALESCE(SUM(tokens_used), 0) AS total_tokens,
SUM(
CASE
WHEN COALESCE(updated_at_ms, updated_at * 1000)
>= (strftime('%s','now') * 1000 - 3600000)
THEN 1 ELSE 0
END
) AS recent_hour_count
FROM threads
"""
).fetchone()
except FileNotFoundError:
return DashboardStats(0, 0, 0, 0, None)
except sqlite3.Error as exc:
return DashboardStats(0, 0, 0, 0, None, _data_error("state.sqlite", db_path, exc))
return DashboardStats(
thread_count=int(row["thread_count"] or 0),
archived_count=int(row["archived_count"] or 0),
total_tokens=int(row["total_tokens"] or 0),
recent_hour_count=int(row["recent_hour_count"] or 0),
latest_thread=threads[0] if threads else None,
error=threads_result.error,
)
def list_logs(
*,
db_path: Path = LOGS_DB,
query: str = "",
limit: int = 150,
) -> list[LogEntry]:
return list_logs_result(db_path=db_path, query=query, limit=limit).data
def list_logs_result(
*,
db_path: Path = LOGS_DB,
query: str = "",
limit: int = 150,
) -> DataResult:
clauses = []
params: list[object] = []
if query.strip():
needle = f"%{query.strip()}%"
clauses.append(
"(target LIKE ? OR module_path LIKE ? OR feedback_log_body LIKE ? OR thread_id LIKE ?)"
)
params.extend([needle, needle, needle, needle])
where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
params.append(limit)
sql = f"""
SELECT
id,
ts,
ts_nanos,
level,
target,
thread_id,
feedback_log_body
FROM logs
{where}
ORDER BY ts DESC, ts_nanos DESC, id DESC
LIMIT ?
"""
try:
with _connect_readonly(db_path) as conn:
rows = conn.execute(sql, params).fetchall()
except FileNotFoundError:
return DataResult([])
except sqlite3.Error as exc:
return DataResult([], _data_error("logs_2.sqlite", db_path, exc))
try:
return DataResult([
LogEntry(
id=int(row["id"]),
ts_ms=int(row["ts"] or 0) * 1000 + int((row["ts_nanos"] or 0) / 1_000_000),
level=row["level"] or "",
target=row["target"] or "",
thread_id=row["thread_id"] or "",
message=_shorten(row["feedback_log_body"], 240),
)
for row in rows
])
except (TypeError, ValueError, sqlite3.Error) as exc:
return DataResult([], _data_error("logs_2.sqlite", db_path, exc))
def tail_tui_log(path: Path = TUI_LOG, lines: int = 80) -> list[str]:
if not path.exists():
return []
try:
data = path.read_text(encoding="utf-8", errors="replace").splitlines()
except OSError:
return []
return data[-lines:]
def format_time(ms: int) -> str:
if not ms:
return "-"
return datetime.fromtimestamp(ms / 1000).strftime("%Y-%m-%d %H:%M")
def format_tokens(value: int) -> str:
if value >= 1_000_000:
return f"{value / 1_000_000:.1f}M"
if value >= 1_000:
return f"{value / 1_000:.1f}k"
return str(value)
def first_existing(paths: Iterable[Path]) -> Path | None:
for path in paths:
if path.exists():
return path
return None